fix: model editor time as wasm MediaTime ticks end-to-end

This commit is contained in:
Maze Winther 2026-04-26 02:50:04 +02:00
parent f1b45b4328
commit eea6d43c82
79 changed files with 1589 additions and 511 deletions

View File

@ -5,7 +5,16 @@ import { useTimelineStore } from "@/timeline/timeline-store";
import { useActionHandler } from "@/actions/use-action-handler"; import { useActionHandler } from "@/actions/use-action-handler";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { TICKS_PER_SECOND } from "@/wasm"; import {
addMediaTime,
maxMediaTime,
mediaTime,
mediaTimeFromSeconds,
minMediaTime,
subMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
import { getElementsAtTime, hasMediaId } from "@/timeline"; import { getElementsAtTime, hasMediaId } from "@/timeline";
import { cancelInteraction } from "@/editor/cancel-interaction"; import { cancelInteraction } from "@/editor/cancel-interaction";
@ -83,7 +92,7 @@ export function useEditorActions() {
if (editor.playback.getIsPlaying()) { if (editor.playback.getIsPlaying()) {
editor.playback.toggle(); editor.playback.toggle();
} }
editor.playback.seek({ time: 0 }); editor.playback.seek({ time: ZERO_MEDIA_TIME });
}, },
undefined, undefined,
); );
@ -92,11 +101,15 @@ export function useEditorActions() {
"seek-forward", "seek-forward",
(args) => { (args) => {
const seconds = args?.seconds ?? 1; const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ editor.playback.seek({
time: Math.min( time: minMediaTime({
editor.timeline.getTotalDuration(), a: editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds, b: addMediaTime({
), a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
}); });
}, },
undefined, undefined,
@ -106,8 +119,15 @@ export function useEditorActions() {
"seek-backward", "seek-backward",
(args) => { (args) => {
const seconds = args?.seconds ?? 1; const seconds = args?.seconds ?? 1;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - seconds), time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
}); });
}, },
undefined, undefined,
@ -117,15 +137,20 @@ export function useEditorActions() {
"frame-step-forward", "frame-step-forward",
() => { () => {
const fps = editor.project.getActive().settings.fps; const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round( const ticksPerFrame = mediaTime({
(TICKS_PER_SECOND * fps.denominator) / fps.numerator, ticks: Math.round(
); (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
editor.playback.seek({
time: Math.min(
editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + ticksPerFrame,
), ),
}); });
editor.playback.seek({
time: minMediaTime({
a: editor.timeline.getTotalDuration(),
b: addMediaTime({
a: editor.playback.getCurrentTime(),
b: ticksPerFrame,
}),
}),
});
}, },
undefined, undefined,
); );
@ -134,11 +159,19 @@ export function useEditorActions() {
"frame-step-backward", "frame-step-backward",
() => { () => {
const fps = editor.project.getActive().settings.fps; const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round( const ticksPerFrame = mediaTime({
(TICKS_PER_SECOND * fps.denominator) / fps.numerator, ticks: Math.round(
); (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
editor.playback.seek({ editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: ticksPerFrame,
}),
}),
}); });
}, },
undefined, undefined,
@ -148,11 +181,15 @@ export function useEditorActions() {
"jump-forward", "jump-forward",
(args) => { (args) => {
const seconds = args?.seconds ?? 5; const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ editor.playback.seek({
time: Math.min( time: minMediaTime({
editor.timeline.getTotalDuration(), a: editor.timeline.getTotalDuration(),
editor.playback.getCurrentTime() + seconds, b: addMediaTime({
), a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
}); });
}, },
undefined, undefined,
@ -162,8 +199,15 @@ export function useEditorActions() {
"jump-backward", "jump-backward",
(args) => { (args) => {
const seconds = args?.seconds ?? 5; const seconds = args?.seconds ?? 5;
const delta = mediaTimeFromSeconds({ seconds });
editor.playback.seek({ editor.playback.seek({
time: Math.max(0, editor.playback.getCurrentTime() - seconds), time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: subMediaTime({
a: editor.playback.getCurrentTime(),
b: delta,
}),
}),
}); });
}, },
undefined, undefined,
@ -172,7 +216,7 @@ export function useEditorActions() {
useActionHandler( useActionHandler(
"goto-start", "goto-start",
() => { () => {
editor.playback.seek({ time: 0 }); editor.playback.seek({ time: ZERO_MEDIA_TIME });
}, },
undefined, undefined,
); );

View File

@ -7,6 +7,7 @@ import type {
NormalizedCubicBezier, NormalizedCubicBezier,
ScalarAnimationKey, ScalarAnimationKey,
} from "@/animation/types"; } from "@/animation/types";
import { roundMediaTime } from "@/wasm";
const VALUE_EPSILON = 1e-6; const VALUE_EPSILON = 1e-6;
@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
return { return {
rightHandle: { rightHandle: {
dt: spanTime * x1, dt: roundMediaTime({ time: spanTime * x1 }),
dv: effectiveSpanValue * y1, dv: effectiveSpanValue * y1,
}, },
leftHandle: { leftHandle: {
dt: spanTime * (x2 - 1), dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
dv: effectiveSpanValue * (y2 - 1), dv: effectiveSpanValue * (y2 - 1),
}, },
}; };

View File

@ -9,6 +9,7 @@ import type {
ScalarSegmentType, ScalarSegmentType,
} from "@/animation/types"; } from "@/animation/types";
import { clamp } from "@/utils/math"; import { clamp } from "@/utils/math";
import { mediaTime } from "@/wasm";
import { import {
getBezierPoint, getBezierPoint,
getDefaultLeftHandle, getDefaultLeftHandle,
@ -63,9 +64,13 @@ function normalizeRightHandle({
return undefined; return undefined;
} }
const span = Math.max(1, rightKey.time - leftKey.time); const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
return { return {
dt: Math.min(span, Math.max(0, handle.dt)), dt: mediaTime({
ticks: Math.min(span, Math.max(0, handle.dt)),
}),
dv: handle.dv, dv: handle.dv,
}; };
} }
@ -83,9 +88,13 @@ function normalizeLeftHandle({
return undefined; return undefined;
} }
const span = Math.max(1, rightKey.time - leftKey.time); const span = mediaTime({
ticks: Math.max(1, rightKey.time - leftKey.time),
});
return { return {
dt: Math.max(-span, Math.min(0, handle.dt)), dt: mediaTime({
ticks: Math.max(-span, Math.min(0, handle.dt)),
}),
dv: handle.dv, dv: handle.dv,
}; };
} }

View File

@ -35,6 +35,12 @@ import {
coerceAnimationValueForProperty, coerceAnimationValueForProperty,
getAnimationPropertyDefinition, getAnimationPropertyDefinition,
} from "./property-registry"; } from "./property-registry";
import {
type MediaTime,
roundMediaTime,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
function isNearlySameTime({ function isNearlySameTime({
leftTime, leftTime,
@ -171,7 +177,7 @@ function createScalarKey({
previousKey, previousKey,
}: { }: {
id: string; id: string;
time: number; time: MediaTime;
value: number; value: number;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey; previousKey?: ScalarAnimationKey;
@ -195,7 +201,7 @@ function createDiscreteKey({
value, value,
}: { }: {
id: string; id: string;
time: number; time: MediaTime;
value: string | boolean; value: string | boolean;
}): DiscreteAnimationKey { }): DiscreteAnimationKey {
return { return {
@ -241,7 +247,7 @@ function getTargetKeyMetadata({
keyframeId, keyframeId,
}: { }: {
channel: AnimationChannel | undefined; channel: AnimationChannel | undefined;
time: number; time: MediaTime;
keyframeId?: string; keyframeId?: string;
}) { }) {
const normalizedChannel = const normalizedChannel =
@ -280,7 +286,7 @@ function upsertDiscreteChannelKey({
keyframeId, keyframeId,
}: { }: {
channel: DiscreteAnimationChannel | undefined; channel: DiscreteAnimationChannel | undefined;
time: number; time: MediaTime;
value: string | boolean; value: string | boolean;
keyframeId?: string; keyframeId?: string;
}): DiscreteAnimationChannel { }): DiscreteAnimationChannel {
@ -337,7 +343,7 @@ function upsertScalarChannelKey({
keyframeId, keyframeId,
}: { }: {
channel: ScalarAnimationChannel | undefined; channel: ScalarAnimationChannel | undefined;
time: number; time: MediaTime;
value: number; value: number;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation; defaultInterpolation?: AnimationInterpolation;
@ -442,7 +448,7 @@ export function upsertPathKeyframe({
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: number; time: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@ -537,7 +543,7 @@ export function upsertElementKeyframe({
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
time: number; time: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@ -576,7 +582,7 @@ export function upsertKeyframe({
keyframeId, keyframeId,
}: { }: {
channel: AnimationChannel | undefined; channel: AnimationChannel | undefined;
time: number; time: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@ -642,7 +648,7 @@ export function retimeKeyframe({
}: { }: {
channel: AnimationChannel | undefined; channel: AnimationChannel | undefined;
keyframeId: string; keyframeId: string;
time: number; time: MediaTime;
}): AnimationChannel | undefined { }): AnimationChannel | undefined {
if (!channel) { if (!channel) {
return undefined; return undefined;
@ -887,7 +893,7 @@ export function clampAnimationsToDuration({
duration, duration,
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
duration: number; duration: MediaTime;
}): ElementAnimations | undefined { }): ElementAnimations | undefined {
if (!animations || duration <= 0) { if (!animations || duration <= 0) {
return undefined; return undefined;
@ -923,7 +929,7 @@ function splitDiscreteChannelAtTime({
shouldIncludeSplitBoundary, shouldIncludeSplitBoundary,
}: { }: {
channel: DiscreteAnimationChannel | undefined; channel: DiscreteAnimationChannel | undefined;
splitTime: number; splitTime: MediaTime;
leftBoundaryId: string; leftBoundaryId: string;
rightBoundaryId: string; rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean; shouldIncludeSplitBoundary: boolean;
@ -939,7 +945,10 @@ function splitDiscreteChannelAtTime({
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime) .filter((key) => key.time >= splitTime)
.map((key) => ({ ...key, time: key.time - splitTime })); .map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
}));
if (shouldIncludeSplitBoundary) { if (shouldIncludeSplitBoundary) {
const hasBoundaryOnLeft = leftKeys.some((key) => const hasBoundaryOnLeft = leftKeys.some((key) =>
@ -967,7 +976,7 @@ function splitDiscreteChannelAtTime({
rightKeys = [ rightKeys = [
createDiscreteKey({ createDiscreteKey({
id: rightBoundaryId, id: rightBoundaryId,
time: 0, time: ZERO_MEDIA_TIME,
value: boundaryValue as string | boolean, value: boundaryValue as string | boolean,
}), }),
...rightKeys, ...rightKeys,
@ -993,7 +1002,7 @@ function splitScalarChannelAtTime({
shouldIncludeSplitBoundary, shouldIncludeSplitBoundary,
}: { }: {
channel: ScalarAnimationChannel | undefined; channel: ScalarAnimationChannel | undefined;
splitTime: number; splitTime: MediaTime;
leftBoundaryId: string; leftBoundaryId: string;
rightBoundaryId: string; rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean; shouldIncludeSplitBoundary: boolean;
@ -1009,7 +1018,10 @@ function splitScalarChannelAtTime({
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime) .filter((key) => key.time >= splitTime)
.map((key) => ({ ...key, time: key.time - splitTime })); .map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
}));
const hasBoundaryOnLeft = leftKeys.some((key) => const hasBoundaryOnLeft = leftKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
@ -1089,7 +1101,7 @@ function splitScalarChannelAtTime({
{ {
...leftKey, ...leftKey,
rightHandle: { rightHandle: {
dt: q0.x - p0.x, dt: roundMediaTime({ time: q0.x - p0.x }),
dv: q0.y - p0.y, dv: q0.y - p0.y,
}, },
}, },
@ -1098,7 +1110,7 @@ function splitScalarChannelAtTime({
time: splitTime, time: splitTime,
value: boundaryValue, value: boundaryValue,
leftHandle: { leftHandle: {
dt: r0.x - splitPoint.x, dt: roundMediaTime({ time: r0.x - splitPoint.x }),
dv: r0.y - splitPoint.y, dv: r0.y - splitPoint.y,
}, },
segmentToNext: leftKey.segmentToNext, segmentToNext: leftKey.segmentToNext,
@ -1108,10 +1120,10 @@ function splitScalarChannelAtTime({
rightKeys = [ rightKeys = [
{ {
id: rightBoundaryId, id: rightBoundaryId,
time: 0, time: ZERO_MEDIA_TIME,
value: boundaryValue, value: boundaryValue,
rightHandle: { rightHandle: {
dt: r1.x - splitPoint.x, dt: roundMediaTime({ time: r1.x - splitPoint.x }),
dv: r1.y - splitPoint.y, dv: r1.y - splitPoint.y,
}, },
segmentToNext: "bezier", segmentToNext: "bezier",
@ -1119,9 +1131,9 @@ function splitScalarChannelAtTime({
}, },
{ {
...rightKey, ...rightKey,
time: rightKey.time - splitTime, time: subMediaTime({ a: rightKey.time, b: splitTime }),
leftHandle: { leftHandle: {
dt: q2.x - p3.x, dt: roundMediaTime({ time: q2.x - p3.x }),
dv: q2.y - p3.y, dv: q2.y - p3.y,
}, },
}, },
@ -1129,7 +1141,7 @@ function splitScalarChannelAtTime({
.filter((key) => key.time > rightKey.time) .filter((key) => key.time > rightKey.time)
.map((key) => ({ .map((key) => ({
...key, ...key,
time: key.time - splitTime, time: subMediaTime({ a: key.time, b: splitTime }),
})), })),
]; ];
} else { } else {
@ -1145,7 +1157,7 @@ function splitScalarChannelAtTime({
rightKeys = [ rightKeys = [
createScalarKey({ createScalarKey({
id: rightBoundaryId, id: rightBoundaryId,
time: 0, time: ZERO_MEDIA_TIME,
value: boundaryValue, value: boundaryValue,
interpolation: getScalarSegmentInterpolation({ interpolation: getScalarSegmentInterpolation({
segment: leftKey.segmentToNext, segment: leftKey.segmentToNext,
@ -1201,7 +1213,7 @@ export function splitAnimationsAtTime({
shouldIncludeSplitBoundary = true, shouldIncludeSplitBoundary = true,
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
splitTime: number; splitTime: MediaTime;
shouldIncludeSplitBoundary?: boolean; shouldIncludeSplitBoundary?: boolean;
}): { }): {
leftAnimations: ElementAnimations | undefined; leftAnimations: ElementAnimations | undefined;
@ -1317,7 +1329,7 @@ export function retimeElementKeyframe({
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
time: number; time: MediaTime;
}): ElementAnimations | undefined { }): ElementAnimations | undefined {
const binding = getBinding({ animations, propertyPath }); const binding = getBinding({ animations, propertyPath });
if (!binding) { if (!binding) {

View File

@ -1,4 +1,5 @@
import type { ParamValues } from "@/params"; import type { ParamValues } from "@/params";
import type { MediaTime } from "@/wasm";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX", "transform.positionX",
@ -77,13 +78,13 @@ export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear"; export type ChannelExtrapolationMode = "hold" | "linear";
export interface CurveHandle { export interface CurveHandle {
dt: number; dt: MediaTime;
dv: number; dv: number;
} }
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> { interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
id: string; id: string;
time: number; // relative to element start time time: MediaTime; // relative to element start time
value: TValue; value: TValue;
} }
@ -209,7 +210,7 @@ export interface ScalarCurveKeyframePatch {
export interface ElementKeyframe { export interface ElementKeyframe {
propertyPath: AnimationPath; propertyPath: AnimationPath;
id: string; id: string;
time: number; time: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation: AnimationInterpolation; interpolation: AnimationInterpolation;
} }

View File

@ -7,6 +7,7 @@ import type {
KeyframeClipboardCurvePatch, KeyframeClipboardCurvePatch,
KeyframeClipboardItem, KeyframeClipboardItem,
} from "../types"; } from "../types";
import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
function resolveSingleSourceElement({ function resolveSingleSourceElement({
selectedKeyframes, selectedKeyframes,
@ -80,7 +81,7 @@ function buildClipboardItem({
element: TimelineElement; element: TimelineElement;
propertyPath: KeyframeClipboardItem["propertyPath"]; propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string; keyframeId: string;
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null { }): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
const keyframe = getKeyframeById({ const keyframe = getKeyframeById({
animations: element.animations, animations: element.animations,
propertyPath, propertyPath,
@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = {
} }
const minTime = Math.min(...rawItems.map((item) => item.time)); const minTime = Math.min(...rawItems.map((item) => item.time));
const minTimeMedia = roundMediaTime({ time: minTime });
const items = rawItems const items = rawItems
.map(({ time, ...item }) => ({ .map(({ time, ...item }) => ({
...item, ...item,
timeOffset: time - minTime, timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
})) }))
.sort( .sort(
(left, right) => (left, right) =>

View File

@ -12,6 +12,7 @@ import type {
ElementRef, ElementRef,
TrackType, TrackType,
} from "@/timeline"; } from "@/timeline";
import type { MediaTime } from "@/wasm";
export interface ElementClipboardItem { export interface ElementClipboardItem {
trackId: string; trackId: string;
@ -26,7 +27,7 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem { export interface KeyframeClipboardItem {
propertyPath: AnimationPath; propertyPath: AnimationPath;
timeOffset: number; timeOffset: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation: AnimationInterpolation; interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[]; curvePatches: KeyframeClipboardCurvePatch[];
@ -61,7 +62,7 @@ export interface PasteContext {
editor: EditorCore; editor: EditorCore;
selectedElements: ElementRef[]; selectedElements: ElementRef[];
selectedKeyframes: SelectedKeyframeRef[]; selectedKeyframes: SelectedKeyframeRef[];
time: number; time: MediaTime;
} }
export interface ClipboardHandler<TType extends ClipboardEntryType> { export interface ClipboardHandler<TType extends ClipboardEntryType> {

View File

@ -3,13 +3,14 @@ import { EditorCore } from "@/core";
import type { TScene } from "@/timeline"; import type { TScene } from "@/timeline";
import { updateSceneInArray } from "@/timeline/scenes"; import { updateSceneInArray } from "@/timeline/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm";
export class MoveBookmarkCommand extends Command { export class MoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor(
private fromTime: number, private fromTime: MediaTime,
private toTime: number, private toTime: MediaTime,
) { ) {
super(); super();
} }

View File

@ -6,12 +6,13 @@ import {
getFrameTime, getFrameTime,
removeBookmarkFromArray, removeBookmarkFromArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm";
export class RemoveBookmarkCommand extends Command { export class RemoveBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: number = 0; private frameTime: MediaTime = 0 as MediaTime;
constructor(private time: number) { constructor(private time: MediaTime) {
super(); super();
} }

View File

@ -6,12 +6,13 @@ import {
getFrameTime, getFrameTime,
toggleBookmarkInArray, toggleBookmarkInArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm";
export class ToggleBookmarkCommand extends Command { export class ToggleBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
private frameTime: number = 0; private frameTime: MediaTime = 0 as MediaTime;
constructor(private time: number) { constructor(private time: MediaTime) {
super(); super();
} }

View File

@ -6,12 +6,13 @@ import {
getFrameTime, getFrameTime,
updateBookmarkInArray, updateBookmarkInArray,
} from "@/timeline/bookmarks/index"; } from "@/timeline/bookmarks/index";
import type { MediaTime } from "@/wasm";
export class UpdateBookmarkCommand extends Command { export class UpdateBookmarkCommand extends Command {
private savedScenes: TScene[] | null = null; private savedScenes: TScene[] | null = null;
constructor( constructor(
private time: number, private time: MediaTime,
private updates: Partial<Omit<Bookmark, "time">>, private updates: Partial<Omit<Bookmark, "time">>,
) { ) {
super(); super();

View File

@ -10,6 +10,13 @@ import type { KeyframeClipboardItem } from "@/clipboard";
import type { SceneTracks, TimelineElement } from "@/timeline"; import type { SceneTracks, TimelineElement } from "@/timeline";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import {
addMediaTime,
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
function pasteKeyframesIntoElement({ function pasteKeyframesIntoElement({
element, element,
@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
clipboardItems, clipboardItems,
}: { }: {
element: TimelineElement; element: TimelineElement;
time: number; time: MediaTime;
clipboardItems: KeyframeClipboardItem[]; clipboardItems: KeyframeClipboardItem[];
}): TimelineElement { }): TimelineElement {
let nextElement = element; let nextElement = element;
@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
continue; continue;
} }
const keyframeTime = Math.max( const keyframeTime = maxMediaTime({
0, a: ZERO_MEDIA_TIME,
Math.min(time + item.timeOffset, nextElement.duration), b: minMediaTime({
); a: addMediaTime({ a: time, b: item.timeOffset }),
b: nextElement.duration,
}),
});
const nextAnimations = upsertPathKeyframe({ const nextAnimations = upsertPathKeyframe({
animations: nextElement.animations, animations: nextElement.animations,
propertyPath: item.propertyPath, propertyPath: item.propertyPath,
@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly time: number; private readonly time: MediaTime;
private readonly clipboardItems: KeyframeClipboardItem[]; private readonly clipboardItems: KeyframeClipboardItem[];
constructor({ constructor({
@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command {
}: { }: {
trackId: string; trackId: string;
elementId: string; elementId: string;
time: number; time: MediaTime;
clipboardItems: KeyframeClipboardItem[]; clipboardItems: KeyframeClipboardItem[];
}) { }) {
super(); super();

View File

@ -13,18 +13,25 @@ import {
enforceMainTrackStart, enforceMainTrackStart,
} from "@/timeline/placement"; } from "@/timeline/placement";
import { cloneAnimations } from "@/animation"; import { cloneAnimations } from "@/animation";
import {
addMediaTime,
type MediaTime,
maxMediaTime,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class PasteCommand extends Command { export class PasteCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private pastedElements: { trackId: string; elementId: string }[] = []; private pastedElements: { trackId: string; elementId: string }[] = [];
private readonly time: number; private readonly time: MediaTime;
private readonly clipboardItems: ElementClipboardItem[]; private readonly clipboardItems: ElementClipboardItem[];
constructor({ constructor({
time, time,
clipboardItems, clipboardItems,
}: { }: {
time: number; time: MediaTime;
clipboardItems: ElementClipboardItem[]; clipboardItems: ElementClipboardItem[];
}) { }) {
super(); super();
@ -39,8 +46,12 @@ export class PasteCommand extends Command {
this.savedState = editor.scenes.getActiveScene().tracks; this.savedState = editor.scenes.getActiveScene().tracks;
this.pastedElements = []; this.pastedElements = [];
const minStart = Math.min( const minStart = this.clipboardItems.reduce(
...this.clipboardItems.map((item) => item.element.startTime), (earliestStartTime, item) =>
item.element.startTime < earliestStartTime
? item.element.startTime
: earliestStartTime,
this.clipboardItems[0].element.startTime,
); );
let updatedTracks = this.savedState; let updatedTracks = this.savedState;
@ -97,12 +108,18 @@ export class PasteCommand extends Command {
targetTrackId: targetTrack.id, targetTrackId: targetTrack.id,
requestedStartTime: earliestElement.startTime, requestedStartTime: earliestElement.startTime,
}); });
const delta = adjustedEarliestStartTime - earliestElement.startTime; const delta = subMediaTime({
a: adjustedEarliestStartTime,
b: earliestElement.startTime,
});
if (delta !== 0) { if (delta !== ZERO_MEDIA_TIME) {
elementsForPlacement = elementsToAdd.map((element) => ({ elementsForPlacement = elementsToAdd.map((element) => ({
...element, ...element,
startTime: Math.max(0, element.startTime + delta), startTime: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: addMediaTime({ a: element.startTime, b: delta }),
}),
})); }));
} }
} }
@ -168,14 +185,20 @@ function buildPastedElements({
time, time,
}: { }: {
items: ElementClipboardItem[]; items: ElementClipboardItem[];
minStart: number; minStart: MediaTime;
time: number; time: MediaTime;
}): TimelineElement[] { }): TimelineElement[] {
const elementsToAdd: TimelineElement[] = []; const elementsToAdd: TimelineElement[] = [];
for (const item of items) { for (const item of items) {
const relativeOffset = item.element.startTime - minStart; const relativeOffset = subMediaTime({
const startTime = Math.max(0, time + relativeOffset); a: item.element.startTime,
b: minStart,
});
const startTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: addMediaTime({ a: time, b: relativeOffset }),
});
const newElementId = generateUUID(); const newElementId = generateUUID();
elementsToAdd.push({ elementsToAdd.push({

View File

@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core"; import { EditorCore } from "@/core";
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
import { cloneAnimations } from "@/animation"; import { cloneAnimations } from "@/animation";
import type { MediaTime } from "@/wasm";
interface DuplicateElementsParams { interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
@ -119,7 +120,7 @@ function buildDuplicateElement({
}: { }: {
element: TimelineElement; element: TimelineElement;
id: string; id: string;
startTime: number; startTime: MediaTime;
}): TimelineElement { }): TimelineElement {
return { return {
...element, ...element,

View File

@ -18,6 +18,7 @@ import {
resolveTrackPlacement, resolveTrackPlacement,
validateElementTrackCompatibility, validateElementTrackCompatibility,
} from "@/timeline/placement"; } from "@/timeline/placement";
import { roundMediaTime } from "@/wasm";
type InsertElementPlacement = type InsertElementPlacement =
| { mode: "explicit"; trackId: string } | { mode: "explicit"; trackId: string }
@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
placementResult.kind === "existingTrack" placementResult.kind === "existingTrack"
? { ? {
...element, ...element,
startTime: placementResult.adjustedStartTime ?? element.startTime, startTime:
placementResult.adjustedStartTime !== undefined
? roundMediaTime({
time: placementResult.adjustedStartTime,
})
: element.startTime,
} }
: element; : element;

View File

@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline";
import type { AnimationPath } from "@/animation/types"; import type { AnimationPath } from "@/animation/types";
import type { SceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class RetimeKeyframeCommand extends Command { export class RetimeKeyframeCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
@ -11,7 +17,7 @@ export class RetimeKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly keyframeId: string; private readonly keyframeId: string;
private readonly nextTime: number; private readonly nextTime: MediaTime;
constructor({ constructor({
trackId, trackId,
@ -24,7 +30,7 @@ export class RetimeKeyframeCommand extends Command {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
nextTime: number; nextTime: MediaTime;
}) { }) {
super(); super();
this.trackId = trackId; this.trackId = trackId;
@ -47,7 +53,10 @@ export class RetimeKeyframeCommand extends Command {
return element; return element;
} }
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.nextTime, b: element.duration }),
});
return { return {
...element, ...element,
animations: retimeElementKeyframe({ animations: retimeElementKeyframe({

View File

@ -9,6 +9,12 @@ import { updateElementInSceneTracks } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils"; import { isVisualElement } from "@/timeline/element-utils";
import type { AnimationInterpolation } from "@/animation/types"; import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline"; import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class UpsertEffectParamKeyframeCommand extends Command { export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly effectId: string; private readonly effectId: string;
private readonly paramKey: string; private readonly paramKey: string;
private readonly time: number; private readonly time: MediaTime;
private readonly value: number | string | boolean; private readonly value: number | string | boolean;
private readonly interpolation: AnimationInterpolation | undefined; private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined; private readonly keyframeId: string | undefined;
@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: string; elementId: string;
effectId: string; effectId: string;
paramKey: string; paramKey: string;
time: number; time: MediaTime;
value: number | string | boolean; value: number | string | boolean;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: this.elementId, elementId: this.elementId,
elementPredicate: isVisualElement, elementPredicate: isVisualElement,
update: (element) => { update: (element) => {
const boundedTime = Math.max(0, Math.min(this.time, element.duration)); const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.time, b: element.duration }),
});
const propertyPath = buildEffectParamPath({ const propertyPath = buildEffectParamPath({
effectId: this.effectId, effectId: this.effectId,
paramKey: this.paramKey, paramKey: this.paramKey,

View File

@ -8,13 +8,19 @@ import type {
AnimationInterpolation, AnimationInterpolation,
AnimationValue, AnimationValue,
} from "@/animation/types"; } from "@/animation/types";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class UpsertKeyframeCommand extends Command { export class UpsertKeyframeCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private readonly trackId: string; private readonly trackId: string;
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly time: number; private readonly time: MediaTime;
private readonly value: AnimationValue; private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined; private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined; private readonly keyframeId: string | undefined;
@ -31,7 +37,7 @@ export class UpsertKeyframeCommand extends Command {
trackId: string; trackId: string;
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: number; time: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@ -63,7 +69,10 @@ export class UpsertKeyframeCommand extends Command {
return element; return element;
} }
const boundedTime = Math.max(0, Math.min(this.time, element.duration)); const boundedTime = maxMediaTime({
a: ZERO_MEDIA_TIME,
b: minMediaTime({ a: this.time, b: element.duration }),
});
return { return {
...element, ...element,
animations: upsertPathKeyframe({ animations: upsertPathKeyframe({

View File

@ -9,12 +9,18 @@ import { EditorCore } from "@/core";
import { isRetimableElement } from "@/timeline"; import { isRetimableElement } from "@/timeline";
import { splitAnimationsAtTime } from "@/animation"; import { splitAnimationsAtTime } from "@/animation";
import { getSourceSpanAtClipTime } from "@/retime"; import { getSourceSpanAtClipTime } from "@/retime";
import {
addMediaTime,
type MediaTime,
roundMediaTime,
subMediaTime,
} from "@/wasm";
export class SplitElementsCommand extends Command { export class SplitElementsCommand extends Command {
private savedState: SceneTracks | null = null; private savedState: SceneTracks | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = []; private rightSideElements: { trackId: string; elementId: string }[] = [];
private readonly elements: { trackId: string; elementId: string }[]; private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number; private readonly splitTime: MediaTime;
private readonly retainSide: "both" | "left" | "right"; private readonly retainSide: "both" | "left" | "right";
constructor({ constructor({
@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
retainSide = "both", retainSide = "both",
}: { }: {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
splitTime: number; splitTime: MediaTime;
retainSide?: "both" | "left" | "right"; retainSide?: "both" | "left" | "right";
}) { }) {
super(); super();
@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
return [element]; return [element];
} }
const relativeTime = this.splitTime - element.startTime; const relativeTime = subMediaTime({
a: this.splitTime,
b: element.startTime,
});
const leftVisibleDuration = relativeTime; const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime; const rightVisibleDuration = subMediaTime({
a: element.duration,
b: relativeTime,
});
const retimeRef = isRetimableElement(element) const retimeRef = isRetimableElement(element)
? element.retime ? element.retime
: undefined; : undefined;
const leftSourceSpan = getSourceSpanAtClipTime({ // Snap the source-side split point exactly once and derive the right
clipTime: leftVisibleDuration, // half from it. Independently rounding both spans (left and total)
retime: retimeRef, // would let a 1-tick rounding error desynchronise them, breaking the
// invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
// See the same discipline in `compute-resize.ts` (snap-once comment).
const leftSourceSpan = roundMediaTime({
time: getSourceSpanAtClipTime({
clipTime: leftVisibleDuration,
retime: retimeRef,
}),
}); });
const totalSourceSpan = getSourceSpanAtClipTime({ const totalSourceSpan = roundMediaTime({
clipTime: element.duration, time: getSourceSpanAtClipTime({
retime: retimeRef, clipTime: element.duration,
retime: retimeRef,
}),
});
const rightSourceSpan = subMediaTime({
a: totalSourceSpan,
b: leftSourceSpan,
}); });
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations, animations: element.animations,
splitTime: relativeTime, splitTime: relativeTime,
@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
}); });
let splitResult: TimelineElement[]; let splitResult: TimelineElement[];
const leftTrimEnd = addMediaTime({
a: element.trimEnd,
b: rightSourceSpan,
});
const rightTrimStart = addMediaTime({
a: element.trimStart,
b: leftSourceSpan,
});
if (this.retainSide === "left") { if (this.retainSide === "left") {
splitResult = [ splitResult = [
{ {
...element, ...element,
duration: leftVisibleDuration, duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan, trimEnd: leftTrimEnd,
name: `${element.name} (left)`, name: `${element.name} (left)`,
animations: leftAnimations, animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
id: newId, id: newId,
startTime: this.splitTime, startTime: this.splitTime,
duration: rightVisibleDuration, duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan, trimStart: rightTrimStart,
name: `${element.name} (right)`, name: `${element.name} (right)`,
animations: rightAnimations, animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
}, },
]; ];
} else { } else {
// "both" - split into two pieces
const secondElementId = generateUUID(); const secondElementId = generateUUID();
this.rightSideElements.push({ this.rightSideElements.push({
trackId: track.id, trackId: track.id,
@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
{ {
...element, ...element,
duration: leftVisibleDuration, duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightSourceSpan, trimEnd: leftTrimEnd,
name: `${element.name} (left)`, name: `${element.name} (left)`,
animations: leftAnimations, animations: leftAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
id: secondElementId, id: secondElementId,
startTime: this.splitTime, startTime: this.splitTime,
duration: rightVisibleDuration, duration: rightVisibleDuration,
trimStart: element.trimStart + leftSourceSpan, trimStart: rightTrimStart,
name: `${element.name} (right)`, name: `${element.name} (right)`,
animations: rightAnimations, animations: rightAnimations,
...(retimeRef !== undefined ? { retime: retimeRef } : {}), ...(retimeRef !== undefined ? { retime: retimeRef } : {}),

View File

@ -3,13 +3,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import type { MediaTime } from "@/wasm";
interface EditableTimecodeProps { interface EditableTimecodeProps {
time: number; time: MediaTime;
duration: number; duration: MediaTime;
format?: TimeCodeFormat; format?: TimeCodeFormat;
fps: FrameRate; fps: FrameRate;
onTimeChange?: ({ time }: { time: number }) => void; onTimeChange?: ({ time }: { time: MediaTime }) => void;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
} }
@ -53,9 +54,12 @@ export function EditableTimecode({
return; return;
} }
const clampedTime = duration const clampedTime = (
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) duration
: parsedTime; ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ??
parsedTime)
: parsedTime
) as MediaTime;
onTimeChange?.({ time: clampedTime }); onTimeChange?.({ time: clampedTime });
setIsEditing(false); setIsEditing(false);

View File

@ -14,13 +14,14 @@ import { useEditor } from "@/editor/use-editor";
import { clearDragData, setDragData } from "@/timeline/drag-data"; import { clearDragData, setDragData } from "@/timeline/drag-data";
import type { TimelineDragData } from "@/timeline/drag"; import type { TimelineDragData } from "@/timeline/drag";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import type { MediaTime } from "@/wasm";
export interface DraggableItemProps { export interface DraggableItemProps {
name: string; name: string;
preview: ReactNode; preview: ReactNode;
dragData: TimelineDragData; dragData: TimelineDragData;
onDragStart?: ({ e }: { e: React.DragEvent }) => void; onDragStart?: ({ e }: { e: React.DragEvent }) => void;
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
aspectRatio?: number; aspectRatio?: number;
className?: string; className?: string;
containerClassName?: string; containerClassName?: string;

View File

@ -26,7 +26,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { useFileUpload } from "@/media/use-file-upload"; import { useFileUpload } from "@/media/use-file-upload";
import { invokeAction } from "@/actions"; import { invokeAction } from "@/actions";
@ -255,11 +255,11 @@ function MediaAssetDraggable({
startTime, startTime,
}: { }: {
asset: MediaAsset; asset: MediaAsset;
startTime: number; startTime: MediaTime;
}) => { }) => {
const duration = const duration =
asset.duration != null asset.duration != null
? Math.round(asset.duration * TICKS_PER_SECOND) ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION; : DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({ const element = buildElementFromMedia({
mediaId: asset.id, mediaId: asset.id,

View File

@ -1,22 +1,25 @@
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { getElementLocalTime } from "@/animation"; import { getElementLocalTime } from "@/animation";
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
export function useElementPlayhead({ export function useElementPlayhead({
startTime, startTime,
duration, duration,
}: { }: {
startTime: number; startTime: MediaTime;
duration: number; duration: MediaTime;
}) { }) {
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
const localTime = getElementLocalTime({ const localTime = mediaTime({
timelineTime: playheadTime, ticks: getElementLocalTime({
elementStartTime: startTime, timelineTime: playheadTime,
elementDuration: duration, elementStartTime: startTime,
elementDuration: duration,
}),
}); });
const isPlayheadWithinElementRange = const isPlayheadWithinElementRange =
playheadTime >= startTime && playheadTime >= startTime &&
playheadTime <= startTime + duration; playheadTime <= addMediaTime({ a: startTime, b: duration });
return { localTime, isPlayheadWithinElementRange }; return { localTime, isPlayheadWithinElementRange };
} }

View File

@ -6,6 +6,7 @@ import {
} from "@/animation"; } from "@/animation";
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types"; import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export function useKeyframedColorProperty({ export function useKeyframedColorProperty({
trackId, trackId,
@ -21,7 +22,7 @@ export function useKeyframedColorProperty({
elementId: string; elementId: string;
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
localTime: number; localTime: MediaTime;
isPlayheadWithinElementRange: boolean; isPlayheadWithinElementRange: boolean;
resolvedColor: string; resolvedColor: string;
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;

View File

@ -8,6 +8,7 @@ import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import { snapToStep } from "@/utils/math"; import { snapToStep } from "@/utils/math";
import { usePropertyDraft } from "./use-property-draft"; import { usePropertyDraft } from "./use-property-draft";
import type { MediaTime } from "@/wasm";
export function useKeyframedNumberProperty({ export function useKeyframedNumberProperty({
trackId, trackId,
@ -27,7 +28,7 @@ export function useKeyframedNumberProperty({
elementId: string; elementId: string;
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath; propertyPath: AnimationPropertyPath;
localTime: number; localTime: MediaTime;
isPlayheadWithinElementRange: boolean; isPlayheadWithinElementRange: boolean;
displayValue: string; displayValue: string;
parse: (input: string) => number | null; parse: (input: string) => number | null;

View File

@ -15,6 +15,7 @@ import type {
} from "@/animation/types"; } from "@/animation/types";
import type { ParamDefinition } from "@/params"; import type { ParamDefinition } from "@/params";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import type { MediaTime } from "@/wasm";
export interface KeyframedParamPropertyResult { export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean; hasAnimatedKeyframes: boolean;
@ -39,7 +40,7 @@ export function useKeyframedParamProperty({
trackId: string; trackId: string;
elementId: string; elementId: string;
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
localTime: number; localTime: MediaTime;
isPlayheadWithinElementRange: boolean; isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean; resolvedValue: number | string | boolean;
buildBaseUpdates: ({ buildBaseUpdates: ({

View File

@ -6,6 +6,7 @@ import {
type CopyContext, type CopyContext,
type PasteContext, type PasteContext,
} from "@/clipboard"; } from "@/clipboard";
import type { MediaTime } from "@/wasm";
export class ClipboardManager { export class ClipboardManager {
private entry: ClipboardEntry | null = null; private entry: ClipboardEntry | null = null;
@ -34,7 +35,7 @@ export class ClipboardManager {
return true; return true;
} }
paste({ time }: { time?: number } = {}): boolean { paste({ time }: { time?: MediaTime } = {}): boolean {
if (!this.entry) { if (!this.entry) {
return false; return false;
} }
@ -64,7 +65,7 @@ export class ClipboardManager {
}; };
} }
private getPasteContext({ time }: { time?: number }): PasteContext { private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
return { return {
editor: this.editor, editor: this.editor,
selectedElements: this.editor.selection.getSelectedElements(), selectedElements: this.editor.selection.getSelectedElements(),

View File

@ -1,10 +1,16 @@
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import { TICKS_PER_SECOND } from "@/wasm"; import {
addMediaTime,
clampMediaTime,
type MediaTime,
mediaTimeFromSeconds,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { roundToFrame } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm";
export class PlaybackManager { export class PlaybackManager {
private isPlaying = false; private isPlaying = false;
private currentTime = 0; private currentTime: MediaTime = ZERO_MEDIA_TIME;
private volume = 1; private volume = 1;
private muted = false; private muted = false;
private previousVolume = 1; private previousVolume = 1;
@ -12,7 +18,7 @@ export class PlaybackManager {
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private playbackTimer: number | null = null; private playbackTimer: number | null = null;
private playbackStartWallTime = 0; private playbackStartWallTime = 0;
private playbackStartTime = 0; private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
private timelineScopeBound = false; private timelineScopeBound = false;
constructor(private editor: EditorCore) {} constructor(private editor: EditorCore) {}
@ -38,7 +44,7 @@ export class PlaybackManager {
} }
if (this.currentTime >= maxTime) { if (this.currentTime >= maxTime) {
this.seek({ time: 0 }); this.seek({ time: ZERO_MEDIA_TIME });
} }
this.isPlaying = true; this.isPlaying = true;
@ -60,7 +66,7 @@ export class PlaybackManager {
} }
} }
seek({ time }: { time: number }): void { seek({ time }: { time: MediaTime }): void {
this.currentTime = this.clampTimeToTimeline(time); this.currentTime = this.clampTimeToTimeline(time);
if (this.isPlaying) { if (this.isPlaying) {
this.playbackStartWallTime = performance.now(); this.playbackStartWallTime = performance.now();
@ -107,7 +113,7 @@ export class PlaybackManager {
return this.isPlaying; return this.isPlaying;
} }
getCurrentTime(): number { getCurrentTime(): MediaTime {
return this.currentTime; return this.currentTime;
} }
@ -185,11 +191,13 @@ export class PlaybackManager {
const fps = this.editor.project.getActive()?.settings.fps; const fps = this.editor.project.getActive()?.settings.fps;
const elapsedSeconds = const elapsedSeconds =
(performance.now() - this.playbackStartWallTime) / 1000; (performance.now() - this.playbackStartWallTime) / 1000;
const rawTime = const rawTime = addMediaTime({
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); a: this.playbackStartTime,
const newTime = fps b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
});
const newTime = (fps
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
: rawTime; : rawTime) as MediaTime;
const maxTime = this.editor.timeline.getTotalDuration(); const maxTime = this.editor.timeline.getTotalDuration();
if (newTime >= maxTime) { if (newTime >= maxTime) {
@ -205,12 +213,12 @@ export class PlaybackManager {
this.playbackTimer = requestAnimationFrame(this.updateTime); this.playbackTimer = requestAnimationFrame(this.updateTime);
}; };
private clampTimeToTimeline(time: number): number { private clampTimeToTimeline(time: MediaTime): MediaTime {
const maxTime = this.editor.timeline.getTotalDuration(); const maxTime = this.editor.timeline.getTotalDuration();
return Math.max(0, Math.min(maxTime, time)); return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
} }
private dispatchSeekEvent(time: number): void { private dispatchSeekEvent(time: MediaTime): void {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
} }
@ -222,7 +230,7 @@ export class PlaybackManager {
); );
} }
private dispatchUpdateEvent(time: number): void { private dispatchUpdateEvent(time: MediaTime): void {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return; return;
} }

View File

@ -1,5 +1,5 @@
import type { EditorCore } from "@/core"; import type { EditorCore } from "@/core";
import type { SceneTracks, TScene } from "@/timeline"; import type { Bookmark, SceneTracks, TScene } from "@/timeline";
import { storageService } from "@/services/storage/service"; import { storageService } from "@/services/storage/service";
import { import {
getMainScene, getMainScene,
@ -21,6 +21,7 @@ import {
ToggleBookmarkCommand, ToggleBookmarkCommand,
UpdateBookmarkCommand, UpdateBookmarkCommand,
} from "@/commands/scene"; } from "@/commands/scene";
import type { MediaTime } from "@/wasm";
export class ScenesManager { export class ScenesManager {
private active: TScene | null = null; private active: TScene | null = null;
@ -106,12 +107,12 @@ export class ScenesManager {
this.notify(); this.notify();
} }
async toggleBookmark({ time }: { time: number }): Promise<void> { async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
const command = new ToggleBookmarkCommand(time); const command = new ToggleBookmarkCommand(time);
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
isBookmarked({ time }: { time: number }): boolean { isBookmarked({ time }: { time: MediaTime }): boolean {
const activeScene = this.getActiveScene(); const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive(); const activeProject = this.editor.project.getActive();
@ -125,7 +126,7 @@ export class ScenesManager {
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime }); return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
} }
async removeBookmark({ time }: { time: number }): Promise<void> { async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
const command = new RemoveBookmarkCommand(time); const command = new RemoveBookmarkCommand(time);
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
@ -134,8 +135,8 @@ export class ScenesManager {
time, time,
updates, updates,
}: { }: {
time: number; time: MediaTime;
updates: Partial<{ note: string; color: string; duration: number }>; updates: Partial<Omit<Bookmark, "time">>;
}): Promise<void> { }): Promise<void> {
const command = new UpdateBookmarkCommand(time, updates); const command = new UpdateBookmarkCommand(time, updates);
this.editor.command.execute({ command }); this.editor.command.execute({ command });
@ -145,14 +146,14 @@ export class ScenesManager {
fromTime, fromTime,
toTime, toTime,
}: { }: {
fromTime: number; fromTime: MediaTime;
toTime: number; toTime: MediaTime;
}): Promise<void> { }): Promise<void> {
const command = new MoveBookmarkCommand(fromTime, toTime); const command = new MoveBookmarkCommand(fromTime, toTime);
this.editor.command.execute({ command }); this.editor.command.execute({ command });
} }
getBookmarkAtTime({ time }: { time: number }) { getBookmarkAtTime({ time }: { time: MediaTime }) {
const activeScene = this.active; const activeScene = this.active;
const activeProject = this.editor.project.getActive(); const activeProject = this.editor.project.getActive();

View File

@ -10,6 +10,7 @@ import type {
} from "@/timeline"; } from "@/timeline";
import { calculateTotalDuration } from "@/timeline"; import { calculateTotalDuration } from "@/timeline";
import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import { import {
canElementBeHidden, canElementBeHidden,
canElementHaveAudio, canElementHaveAudio,
@ -95,10 +96,10 @@ export class TimelineManager {
pushHistory = true, pushHistory = true,
}: { }: {
elementId: string; elementId: string;
trimStart: number; trimStart: MediaTime;
trimEnd: number; trimEnd: MediaTime;
startTime?: number; startTime?: MediaTime;
duration?: number; duration?: MediaTime;
pushHistory?: boolean; pushHistory?: boolean;
}): void { }): void {
const trackId = this.findTrackIdForElement({ elementId }); const trackId = this.findTrackIdForElement({ elementId });
@ -188,7 +189,7 @@ export class TimelineManager {
retainSide = "both", retainSide = "both",
}: { }: {
elements: { trackId: string; elementId: string }[]; elements: { trackId: string; elementId: string }[];
splitTime: number; splitTime: MediaTime;
retainSide?: "both" | "left" | "right"; retainSide?: "both" | "left" | "right";
}): { trackId: string; elementId: string }[] { }): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({ const command = new SplitElementsCommand({
@ -200,20 +201,20 @@ export class TimelineManager {
return command.getRightSideElements(); return command.getRightSideElements();
} }
getTotalDuration(): number { getTotalDuration(): MediaTime {
const activeScene = this.editor.scenes.getActiveSceneOrNull(); const activeScene = this.editor.scenes.getActiveSceneOrNull();
if (!activeScene) { if (!activeScene) {
return 0; return ZERO_MEDIA_TIME;
} }
return calculateTotalDuration({ tracks: activeScene.tracks }); return calculateTotalDuration({ tracks: activeScene.tracks });
} }
getLastFrameTime(): number { getLastFrameTime(): MediaTime {
const duration = this.getTotalDuration(); const duration = this.getTotalDuration();
const fps = this.editor.project.getActive()?.settings.fps; const fps = this.editor.project.getActive()?.settings.fps;
if (!fps || duration <= 0) return duration; if (!fps || duration <= 0) return duration;
return lastFrameTime({ duration, rate: fps }) ?? duration; return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
} }
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
@ -483,7 +484,7 @@ export class TimelineManager {
trackId: string; trackId: string;
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: number; time: MediaTime;
value: AnimationValue; value: AnimationValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
@ -600,7 +601,7 @@ export class TimelineManager {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
time: number; time: MediaTime;
}): void { }): void {
const command = new RetimeKeyframeCommand({ const command = new RetimeKeyframeCommand({
trackId, trackId,
@ -658,7 +659,7 @@ export class TimelineManager {
elementId: string; elementId: string;
effectId: string; effectId: string;
paramKey: string; paramKey: string;
time: number; time: MediaTime;
value: number; value: number;
interpolation?: "linear" | "hold"; interpolation?: "linear" | "hold";
keyframeId?: string; keyframeId?: string;

View File

@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui"; import { cn } from "@/utils/ui";
import type { MediaTime } from "@/wasm";
registerDefaultGraphics(); registerDefaultGraphics();
@ -197,10 +198,11 @@ function AnimatedGraphicParamField({
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedParams, resolvedParams,
}: { }: {
key?: string;
param: ParamDefinition; param: ParamDefinition;
trackId: string; trackId: string;
element: GraphicElement; element: GraphicElement;
localTime: number; localTime: MediaTime;
isPlayheadWithinElementRange: boolean; isPlayheadWithinElementRange: boolean;
resolvedParams: ParamValues; resolvedParams: ParamValues;
}) { }) {

View File

@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media";
import { InsertElementCommand } from "@/commands/timeline"; import { InsertElementCommand } from "@/commands/timeline";
import { BatchCommand } from "@/commands"; import { BatchCommand } from "@/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTimeFromSeconds } from "@/wasm";
import { isTypableDOMElement } from "@/utils/browser"; import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/media/types"; import type { MediaType } from "@/media/types";
@ -75,7 +75,7 @@ export function usePasteMedia() {
const assetId = addMediaCmd.getAssetId(); const assetId = addMediaCmd.getAssetId();
const duration = const duration =
asset.duration != null asset.duration != null
? Math.round(asset.duration * TICKS_PER_SECOND) ? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION; : DEFAULT_NEW_ELEMENT_DURATION;
const trackType = asset.type === "audio" ? "audio" : "video"; const trackType = asset.type === "audio" ? "audio" : "video";

View File

@ -26,6 +26,7 @@ import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
import { usePreviewViewport } from "./preview-viewport"; import { usePreviewViewport } from "./preview-viewport";
import { GridPopover } from "./guide-popover"; import { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/preview/preview-store"; import { usePreviewStore } from "@/preview/preview-store";
import type { MediaTime } from "@/wasm";
export function PreviewToolbar({ export function PreviewToolbar({
onToggleFullscreen, onToggleFullscreen,
@ -67,13 +68,13 @@ function TimecodeDisplay() {
const editor = useEditor(); const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps); const fps = useEditor((e) => e.project.getActive().settings.fps);
const [currentTime, setCurrentTime] = useState(() => const [currentTime, setCurrentTime] = useState<MediaTime>(() =>
editor.playback.getCurrentTime(), editor.playback.getCurrentTime(),
); );
useEffect(() => { useEffect(() => {
const handler = (e: Event) => const handler = (e: Event) =>
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
window.addEventListener("playback-update", handler); window.addEventListener("playback-update", handler);
window.addEventListener("playback-seek", handler); window.addEventListener("playback-seek", handler);
return () => { return () => {

View File

@ -1,5 +1,6 @@
import type { FrameRate } from "opencut-wasm"; import type { FrameRate } from "opencut-wasm";
import type { TScene } from "@/timeline/types"; import type { TScene } from "@/timeline/types";
import type { MediaTime } from "@/wasm";
export type TBackground = export type TBackground =
| { | {
@ -20,7 +21,7 @@ export interface TProjectMetadata {
id: string; id: string;
name: string; name: string;
thumbnail?: string; thumbnail?: string;
duration: number; duration: MediaTime;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -37,7 +38,7 @@ export interface TProjectSettings {
export interface TTimelineViewState { export interface TTimelineViewState {
zoomLevel: number; zoomLevel: number;
scrollLeft: number; scrollLeft: number;
playheadTime: number; playheadTime: MediaTime;
} }
export interface TProject { export interface TProject {

View File

@ -30,14 +30,15 @@ import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/
import { resolveOpacityAtTime } from "@/animation"; import { resolveOpacityAtTime } from "@/animation";
import { DEFAULTS } from "@/timeline/defaults"; import { DEFAULTS } from "@/timeline/defaults";
import { isPropertyAtDefault } from "./transform-tab"; import { isPropertyAtDefault } from "./transform-tab";
import type { MediaTime } from "@/wasm";
type BlendingElement = { type BlendingElement = {
id: string; id: string;
opacity: number; opacity: number;
type: ElementType; type: ElementType;
blendMode?: BlendMode; blendMode?: BlendMode;
startTime: number; startTime: MediaTime;
duration: number; duration: MediaTime;
animations?: ElementAnimations; animations?: ElementAnimations;
}; };

View File

@ -1,4 +1,4 @@
import { mediaTimeToSeconds } from "opencut-wasm"; import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
import { import {
getElementLocalTime, getElementLocalTime,
resolveColorAtTime, resolveColorAtTime,
@ -204,7 +204,7 @@ async function resolveVideoNode({
const frame = await videoCache.getFrameAt({ const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId, mediaId: node.params.mediaId,
file: node.params.file, file: node.params.file,
time: mediaTimeToSeconds({ time: sourceTimeTicks }), time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
}); });
if (!frame) { if (!frame) {
return null; return null;
@ -421,7 +421,7 @@ async function resolveBackdropSource({
const frame = await videoCache.getFrameAt({ const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId, mediaId: node.params.mediaId,
file: node.params.file, file: node.params.file,
time: mediaTimeToSeconds({ time: sourceTimeTicks }), time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
}); });
if (!frame) { if (!frame) {
return null; return null;

View File

@ -0,0 +1,257 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
describe("V27 to V28 Migration", () => {
test("rounds persisted media-time floats back to integer ticks", () => {
const result = transformProjectV27ToV28({
project: {
id: "project-v27-float-time",
version: 27,
metadata: {
id: "project-v27-float-time",
name: "Project",
duration: 2_152_466.3677130044,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
settings: {
fps: { numerator: 30, denominator: 1 },
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
timelineViewState: {
zoomLevel: 1.25,
scrollLeft: 120,
playheadTime: 301_234.8,
},
scenes: [
{
id: "scene-1",
name: "Scene 1",
isMain: true,
bookmarks: [
{
time: 120_000.4,
duration: 60_000.6,
note: "Marker",
color: "#ff0000",
},
],
tracks: {
main: {
id: "main-track",
type: "video",
name: "Main",
muted: false,
hidden: false,
elements: [
{
id: "element-1",
type: "video",
name: "Clip",
startTime: 300_000.49,
duration: 2_152_466.3677130044,
trimStart: 30_000.2,
trimEnd: 15_000.7,
sourceDuration: 2_197_467.6,
mediaId: "media-1",
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
animations: {
channels: {
opacity: {
kind: "scalar",
keys: [
{
id: "key-1",
time: 1_000.6,
value: 1,
segmentToNext: "bezier",
tangentMode: "flat",
leftHandle: { dt: -120.6, dv: 0 },
rightHandle: { dt: 60.4, dv: 0 },
},
],
},
},
},
},
],
},
overlay: [],
audio: [],
},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(28);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(2_152_466);
const timelineViewState = result.project.timelineViewState as Record<
string,
unknown
>;
expect(timelineViewState.playheadTime).toBe(301_235);
expect(timelineViewState.zoomLevel).toBe(1.25);
expect(timelineViewState.scrollLeft).toBe(120);
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const scene = scenes[0];
expect(scene.bookmarks).toEqual([
{
time: 120_000,
duration: 60_001,
note: "Marker",
color: "#ff0000",
},
]);
const tracks = scene.tracks as Record<string, unknown>;
const mainTrack = tracks.main as Record<string, unknown>;
const elements = mainTrack.elements as Array<Record<string, unknown>>;
const element = elements[0];
expect(element.startTime).toBe(300_000);
expect(element.duration).toBe(2_152_466);
expect(element.trimStart).toBe(30_000);
expect(element.trimEnd).toBe(15_001);
expect(element.sourceDuration).toBe(2_197_468);
const animations = element.animations as Record<string, unknown>;
const channels = animations.channels as Record<string, Record<string, unknown>>;
const opacityChannel = channels.opacity;
expect(opacityChannel.keys).toEqual([
{
id: "key-1",
time: 1_001,
value: 1,
segmentToNext: "bezier",
tangentMode: "flat",
leftHandle: { dt: -121, dv: 0 },
rightHandle: { dt: 60, dv: 0 },
},
]);
});
test("keeps already-integer media-time values unchanged", () => {
const result = transformProjectV27ToV28({
project: {
id: "project-v27-integer-time",
version: 27,
metadata: {
id: "project-v27-integer-time",
name: "Project",
duration: 120_000,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
timelineViewState: {
zoomLevel: 2,
scrollLeft: 300,
playheadTime: 30_000,
},
scenes: [
{
id: "scene-1",
bookmarks: [{ time: 60_000, duration: 15_000 }],
tracks: {
main: {
id: "main-track",
type: "video",
name: "Main",
muted: false,
hidden: false,
elements: [
{
id: "element-1",
type: "video",
name: "Clip",
startTime: 10_000,
duration: 20_000,
trimStart: 1_000,
trimEnd: 2_000,
sourceDuration: 23_000,
mediaId: "media-1",
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
},
],
},
overlay: [],
audio: [],
},
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(28);
const metadata = result.project.metadata as Record<string, unknown>;
expect(metadata.duration).toBe(120_000);
const timelineViewState = result.project.timelineViewState as Record<
string,
unknown
>;
expect(timelineViewState).toEqual({
zoomLevel: 2,
scrollLeft: 300,
playheadTime: 30_000,
});
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const scene = scenes[0];
expect(scene.bookmarks).toEqual([{ time: 60_000, duration: 15_000 }]);
const tracks = scene.tracks as Record<string, unknown>;
const mainTrack = tracks.main as Record<string, unknown>;
const element = (mainTrack.elements as Array<Record<string, unknown>>)[0];
expect(element.startTime).toBe(10_000);
expect(element.duration).toBe(20_000);
expect(element.trimStart).toBe(1_000);
expect(element.trimEnd).toBe(2_000);
expect(element.sourceDuration).toBe(23_000);
});
test("skips projects already on v28", () => {
const result = transformProjectV27ToV28({
project: {
id: "project-v28",
version: 28,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v28");
});
test("skips projects not on v27", () => {
const result = transformProjectV27ToV28({
project: {
id: "project-v26",
version: 26,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("not v27");
});
});

View File

@ -26,10 +26,11 @@ import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25"; import { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26"; import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27"; import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 27; export const CURRENT_PROJECT_VERSION = 28;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -59,4 +60,5 @@ export const migrations = [
new V24toV25Migration(), new V24toV25Migration(),
new V25toV26Migration(), new V25toV26Migration(),
new V26toV27Migration(), new V26toV27Migration(),
new V27toV28Migration(),
]; ];

View File

@ -0,0 +1,228 @@
import { roundMediaTime } from "@/wasm";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV27ToV28({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
const version = project.version;
if (typeof version !== "number") {
return { project, skipped: true, reason: "invalid version" };
}
if (version >= 28) {
return { project, skipped: true, reason: "already v28" };
}
if (version !== 27) {
return { project, skipped: true, reason: "not v27" };
}
return {
project: {
...migrateProject({ project }),
version: 28,
},
skipped: false,
};
}
function migrateProject({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const nextProject = { ...project };
if (isRecord(project.metadata)) {
nextProject.metadata = migrateTimeFields({
record: project.metadata,
keys: ["duration"],
});
}
if (isRecord(project.timelineViewState)) {
nextProject.timelineViewState = migrateTimeFields({
record: project.timelineViewState,
keys: ["playheadTime"],
});
}
if (Array.isArray(project.scenes)) {
nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
}
return nextProject;
}
function migrateScene({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) {
return scene;
}
const nextScene = { ...scene };
if (Array.isArray(scene.bookmarks)) {
nextScene.bookmarks = scene.bookmarks.map((bookmark) =>
migrateBookmark({ bookmark }),
);
}
if (isRecord(scene.tracks)) {
nextScene.tracks = migrateTracks({ tracks: scene.tracks });
}
return nextScene;
}
function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
const nextTracks = { ...tracks };
if (isRecord(tracks.main)) {
nextTracks.main = migrateTrack({ track: tracks.main });
}
if (Array.isArray(tracks.overlay)) {
nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
}
if (Array.isArray(tracks.audio)) {
nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
}
return nextTracks;
}
function migrateTrack({ track }: { track: unknown }): unknown {
if (!isRecord(track) || !Array.isArray(track.elements)) {
return track;
}
return {
...track,
elements: track.elements.map((element) => migrateElement({ element })),
};
}
function migrateElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) {
return element;
}
const nextElement = migrateTimeFields({
record: element,
keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"],
});
if (isRecord(element.animations)) {
nextElement.animations = migrateAnimations({
animations: element.animations,
});
}
return nextElement;
}
function migrateAnimations({
animations,
}: {
animations: ProjectRecord;
}): ProjectRecord {
if (!isRecord(animations.channels)) {
return animations;
}
return {
...animations,
channels: Object.fromEntries(
Object.entries(animations.channels).map(([channelId, channel]) => [
channelId,
migrateAnimationChannel({ channel }),
]),
),
};
}
function migrateAnimationChannel({ channel }: { channel: unknown }): unknown {
if (!isRecord(channel) || !Array.isArray(channel.keys)) {
return channel;
}
return {
...channel,
keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })),
};
}
function migrateAnimationKeyframe({
keyframe,
}: {
keyframe: unknown;
}): unknown {
if (!isRecord(keyframe)) {
return keyframe;
}
const nextKeyframe = migrateTimeFields({
record: keyframe,
keys: ["time"],
});
if (isRecord(keyframe.leftHandle)) {
nextKeyframe.leftHandle = migrateTimeFields({
record: keyframe.leftHandle,
keys: ["dt"],
});
}
if (isRecord(keyframe.rightHandle)) {
nextKeyframe.rightHandle = migrateTimeFields({
record: keyframe.rightHandle,
keys: ["dt"],
});
}
return nextKeyframe;
}
function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown {
if (!isRecord(bookmark)) {
return bookmark;
}
return migrateTimeFields({
record: bookmark,
keys: ["time", "duration"],
});
}
function migrateTimeFields({
record,
keys,
}: {
record: ProjectRecord;
keys: string[];
}): ProjectRecord {
const nextRecord = { ...record };
for (const key of keys) {
if (!(key in record)) {
continue;
}
nextRecord[key] = normalizeMediaTimeValue({ value: record[key] });
}
return nextRecord;
}
function normalizeMediaTimeValue({ value }: { value: unknown }): unknown {
if (typeof value !== "number" || !Number.isFinite(value)) {
return value;
}
return roundMediaTime({ time: value });
}

View File

@ -0,0 +1,14 @@
import { StorageMigration, type StorageMigrationRunArgs } from "./base";
import type { MigrationResult, ProjectRecord } from "./transformers/types";
import { transformProjectV27ToV28 } from "./transformers/v27-to-v28";
export class V27toV28Migration extends StorageMigration {
from = 27;
to = 28;
async run({
project,
}: StorageMigrationRunArgs): Promise<MigrationResult<ProjectRecord>> {
return transformProjectV27ToV28({ project });
}
}

View File

@ -22,12 +22,15 @@ import {
runStorageMigrations, runStorageMigrations,
} from "@/services/storage/migrations"; } from "@/services/storage/migrations";
import type { Bookmark, SceneTracks, TScene } from "@/timeline"; import type { Bookmark, SceneTracks, TScene } from "@/timeline";
import { roundMediaTime } from "@/wasm";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw return raw
.map((item): Bookmark | null => { .map((item): Bookmark | null => {
if (typeof item === "number") return { time: item }; if (typeof item === "number") {
return { time: roundMediaTime({ time: item }) };
}
const obj = item as Record<string, unknown>; const obj = item as Record<string, unknown>;
if ( if (
typeof obj !== "object" || typeof obj !== "object" ||
@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
return null; return null;
} }
return { return {
time: obj.time, time: roundMediaTime({ time: obj.time }),
...(typeof obj.note === "string" && { note: obj.note }), ...(typeof obj.note === "string" && { note: obj.note }),
...(typeof obj.color === "string" && { color: obj.color }), ...(typeof obj.color === "string" && { color: obj.color }),
...(typeof obj.duration === "number" && { duration: obj.duration }), ...(typeof obj.duration === "number" && {
duration: roundMediaTime({ time: obj.duration }),
}),
}; };
}) })
.filter((b): b is Bookmark => b !== null); .filter((b): b is Bookmark => b !== null);
@ -198,9 +203,11 @@ class StorageService {
id: serializedProject.metadata.id, id: serializedProject.metadata.id,
name: serializedProject.metadata.name, name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail, thumbnail: serializedProject.metadata.thumbnail,
duration: duration: roundMediaTime({
serializedProject.metadata.duration ?? time:
getProjectDurationFromScenes({ scenes }), serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
}),
createdAt: new Date(serializedProject.metadata.createdAt), createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt), updatedAt: new Date(serializedProject.metadata.updatedAt),
}, },
@ -253,11 +260,13 @@ class StorageService {
id: serializedProject.metadata.id, id: serializedProject.metadata.id,
name: serializedProject.metadata.name, name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail, thumbnail: serializedProject.metadata.thumbnail,
duration: duration: roundMediaTime({
serializedProject.metadata.duration ?? time:
getProjectDurationFromScenes({ serializedProject.metadata.duration ??
scenes: (serializedProject.scenes ?? []) as unknown as TScene[], getProjectDurationFromScenes({
}), scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
}),
}),
createdAt: new Date(serializedProject.metadata.createdAt), createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt), updatedAt: new Date(serializedProject.metadata.updatedAt),
}); });

View File

@ -5,7 +5,7 @@ import {
setCanvasLetterSpacing, setCanvasLetterSpacing,
} from "@/text/layout"; } from "@/text/layout";
import { DEFAULTS } from "@/timeline/defaults"; import { DEFAULTS } from "@/timeline/defaults";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTimeFromSeconds } from "@/wasm";
import type { CreateTextElement } from "@/timeline"; import type { CreateTextElement } from "@/timeline";
import type { SubtitleCue, SubtitleStyleOverrides } from "./types"; import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
@ -310,8 +310,8 @@ export function buildSubtitleTextElement({
...DEFAULTS.text.element, ...DEFAULTS.text.element,
name: `Caption ${index + 1}`, name: `Caption ${index + 1}`,
content, content,
duration: Math.round(caption.duration * TICKS_PER_SECOND), duration: mediaTimeFromSeconds({ seconds: caption.duration }),
startTime: Math.round(caption.startTime * TICKS_PER_SECOND), startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
fontSize: style.fontSize, fontSize: style.fontSize,
fontFamily: style.fontFamily, fontFamily: style.fontFamily,
color: style.color, color: style.color,

View File

@ -3,11 +3,12 @@ import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { DEFAULTS } from "@/timeline/defaults"; import { DEFAULTS } from "@/timeline/defaults";
import { buildTextElement } from "@/timeline/element-utils"; import { buildTextElement } from "@/timeline/element-utils";
import type { MediaTime } from "@/wasm";
export function TextView() { export function TextView() {
const editor = useEditor(); const editor = useEditor();
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
const activeScene = editor.scenes.getActiveScene(); const activeScene = editor.scenes.getActiveScene();
if (!activeScene) return; if (!activeScene) return;

View File

@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import type { Transform } from "@/rendering";
import type { SceneTracks, VideoElement } from "@/timeline";
import { applyElementUpdate } from "@/timeline/update-pipeline";
import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
function buildTransform(): Transform {
return {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
};
}
function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
return {
id: "video-1",
type: "video",
name: "Video 1",
startTime: ZERO_MEDIA_TIME,
duration: mediaTime({ ticks: 10 }),
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
mediaId: "media-1",
transform: buildTransform(),
opacity: 1,
...overrides,
};
}
function buildTracks(element: VideoElement): SceneTracks {
return {
overlay: [],
main: {
id: "main-track",
type: "video",
name: "Main",
muted: false,
hidden: false,
elements: [element],
},
audio: [],
};
}
describe("applyElementUpdate", () => {
test("rounds retimed durations back to integer media time", () => {
const element = buildVideoElement();
const tracks = buildTracks(element);
const updatedElement = applyElementUpdate({
element,
patch: {
retime: { rate: 1.5 },
},
context: {
tracks,
trackId: tracks.main.id,
},
});
expect(updatedElement.duration).toBe(7);
expect(Number.isInteger(updatedElement.duration)).toBe(true);
});
});

View File

@ -26,6 +26,13 @@ import { Label } from "@/components/ui/label";
import { uppercase } from "@/utils/string"; import { uppercase } from "@/utils/string";
import { clamp, formatNumberForDisplay } from "@/utils/math"; import { clamp, formatNumberForDisplay } from "@/utils/math";
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
import {
type MediaTime,
mediaTimeFromSeconds,
mediaTimeToSeconds,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
const MIN_BOOKMARK_WIDTH_PX = 2; const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12; const BOOKMARK_MARKER_WIDTH_PX = 12;
@ -39,12 +46,12 @@ function seekToBookmarkTime({
time, time,
}: { }: {
editor: EditorCore; editor: EditorCore;
time: number; time: MediaTime;
}) { }) {
const activeProject = editor.project.getActive(); const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration(); const duration = editor.timeline.getTotalDuration();
const rate = activeProject?.settings.fps ?? DEFAULT_FPS; const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime;
editor.playback.seek({ time: snappedTime }); editor.playback.seek({ time: snappedTime });
} }
@ -137,7 +144,7 @@ function TimelineBookmark({
const displayTime = isDragging ? dragState.currentTime : bookmark.time; const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time; const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0; const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME;
const durationWidth = const durationWidth =
bookmarkDuration > 0 bookmarkDuration > 0
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
@ -182,7 +189,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}px`, left: `${bookmarkLeft}px`,
width: `${bookmarkWidth}px`, width: `${bookmarkWidth}px`,
}} }}
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`}
type="button" type="button"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onClick={handleClick} onClick={handleClick}
@ -286,8 +293,8 @@ function BookmarkPopoverContent({
onPopoverClose, onPopoverClose,
}: { }: {
bookmark: Bookmark; bookmark: Bookmark;
time: number; time: MediaTime;
timelineDuration: number; timelineDuration: MediaTime;
onPopoverClose: () => void; onPopoverClose: () => void;
}) { }) {
const editor = useEditor(); const editor = useEditor();
@ -314,9 +321,8 @@ function BookmarkPopoverContent({
note, note,
color, color,
duration, duration,
}: Partial<{ note: string; color: string; duration: number }>) => { }: Partial<Omit<Bookmark, "time">>) => {
const updates: Partial<{ note: string; color: string; duration: number }> = const updates: Partial<Omit<Bookmark, "time">> = {};
{};
if (note !== undefined && note !== bookmark.note) updates.note = note; if (note !== undefined && note !== bookmark.note) updates.note = note;
if ( if (
color !== undefined && color !== undefined &&
@ -330,6 +336,15 @@ function BookmarkPopoverContent({
if (Object.keys(updates).length === 0) return; if (Object.keys(updates).length === 0) return;
editor.scenes.updateBookmark({ time, updates }); editor.scenes.updateBookmark({ time, updates });
}; };
const maxDuration = mediaTimeToSeconds({
time:
timelineDuration > time
? subMediaTime({ a: timelineDuration, b: time })
: ZERO_MEDIA_TIME,
});
const durationSeconds = mediaTimeToSeconds({
time: bookmark.duration ?? ZERO_MEDIA_TIME,
});
return ( return (
<> <>
@ -387,7 +402,7 @@ function BookmarkPopoverContent({
type="number" type="number"
min={0} min={0}
step={0.1} step={0.1}
value={bookmark.duration ?? 0} value={durationSeconds}
onChange={(event) => { onChange={(event) => {
const parsed = parseFloat(event.target.value); const parsed = parseFloat(event.target.value);
const value = Number.isNaN(parsed) const value = Number.isNaN(parsed)
@ -395,9 +410,11 @@ function BookmarkPopoverContent({
: clamp({ : clamp({
value: parsed, value: parsed,
min: 0, min: 0,
max: Math.max(0, timelineDuration - time), max: maxDuration,
}); });
handleUpdate({ duration: value }); handleUpdate({
duration: mediaTimeFromSeconds({ seconds: value }),
});
}} }}
className="h-8 text-sm" className="h-8 text-sm"
containerClassName="w-full" containerClassName="w-full"

View File

@ -21,15 +21,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
import type { Bookmark } from "@/timeline"; import type { Bookmark } from "@/timeline";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export interface BookmarkDragState { export interface BookmarkDragState {
isDragging: boolean; isDragging: boolean;
bookmarkTime: number | null; bookmarkTime: MediaTime | null;
currentTime: number; currentTime: MediaTime;
} }
interface PendingBookmarkDrag { interface PendingBookmarkDrag {
bookmarkTime: number; bookmarkTime: MediaTime;
startMouseX: number; startMouseX: number;
startMouseY: number; startMouseY: number;
} }
@ -58,7 +59,7 @@ export function useBookmarkDrag({
const [dragState, setDragState] = useState<BookmarkDragState>({ const [dragState, setDragState] = useState<BookmarkDragState>({
isDragging: false, isDragging: false,
bookmarkTime: null, bookmarkTime: null,
currentTime: 0, currentTime: ZERO_MEDIA_TIME,
}); });
const [isPendingDrag, setIsPendingDrag] = useState(false); const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
@ -69,8 +70,8 @@ export function useBookmarkDrag({
bookmarkTime, bookmarkTime,
initialCurrentTime, initialCurrentTime,
}: { }: {
bookmarkTime: number; bookmarkTime: MediaTime;
initialCurrentTime: number; initialCurrentTime: MediaTime;
}) => { }) => {
setDragState({ setDragState({
isDragging: true, isDragging: true,
@ -85,7 +86,7 @@ export function useBookmarkDrag({
setDragState({ setDragState({
isDragging: false, isDragging: false,
bookmarkTime: null, bookmarkTime: null,
currentTime: 0, currentTime: ZERO_MEDIA_TIME,
}); });
}, []); }, []);
@ -94,9 +95,9 @@ export function useBookmarkDrag({
rawTime, rawTime,
excludeBookmarkTime, excludeBookmarkTime,
}: { }: {
rawTime: number; rawTime: MediaTime;
excludeBookmarkTime: number; excludeBookmarkTime: MediaTime;
}): { snappedTime: number; snapPoint: SnapPoint | null } => { }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => {
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
if (!shouldSnap) { if (!shouldSnap) {
return { snappedTime: rawTime, snapPoint: null }; return { snappedTime: rawTime, snapPoint: null };
@ -116,7 +117,7 @@ export function useBookmarkDrag({
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
}); });
return { return {
snappedTime: result.snappedTime, snappedTime: result.snappedTime as MediaTime,
snapPoint: result.snapPoint, snapPoint: result.snapPoint,
}; };
}, },
@ -162,11 +163,14 @@ export function useBookmarkDrag({
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const frameSnappedTime = const clampedTime =
mouseTime > duration ? duration : mouseTime;
const frameSnappedTime = (
roundToFrame({ roundToFrame({
time: Math.max(0, Math.min(mouseTime, duration)), time: clampedTime,
rate: activeProject.settings.fps, rate: activeProject.settings.fps,
}) ?? Math.max(0, Math.min(mouseTime, duration)); }) ?? clampedTime
) as MediaTime;
const { snappedTime: initialTime } = getSnapResult({ const { snappedTime: initialTime } = getSnapResult({
rawTime: frameSnappedTime, rawTime: frameSnappedTime,
excludeBookmarkTime: bookmarkTime, excludeBookmarkTime: bookmarkTime,
@ -193,10 +197,12 @@ export function useBookmarkDrag({
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const clampedTime = Math.max(0, Math.min(mouseTime, duration)); const clampedTime =
const frameSnappedTime = mouseTime > duration ? duration : mouseTime;
const frameSnappedTime = (
roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
clampedTime; clampedTime
) as MediaTime;
const snapResult = getSnapResult({ const snapResult = getSnapResult({
rawTime: frameSnappedTime, rawTime: frameSnappedTime,
excludeBookmarkTime: dragState.bookmarkTime, excludeBookmarkTime: dragState.bookmarkTime,
@ -234,10 +240,8 @@ export function useBookmarkDrag({
return; return;
} }
const clampedTime = Math.max( const clampedTime =
0, dragState.currentTime > duration ? duration : dragState.currentTime;
Math.min(dragState.currentTime, duration),
);
editor.scenes.moveBookmark({ editor.scenes.moveBookmark({
fromTime: dragState.bookmarkTime, fromTime: dragState.bookmarkTime,

View File

@ -5,6 +5,7 @@ import {
type PreviewOverlaySourceResult, type PreviewOverlaySourceResult,
} from "@/preview/overlays"; } from "@/preview/overlays";
import { getBookmarksActiveAtTime } from "./utils"; import { getBookmarksActiveAtTime } from "./utils";
import type { MediaTime } from "@/wasm";
export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
id: "bookmark-notes", id: "bookmark-notes",
@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
function BookmarkNotesOverlay({ function BookmarkNotesOverlay({
bookmarks, bookmarks,
}: { }: {
bookmarks: Array<{ time: number; note: string; color?: string }>; bookmarks: Array<{ time: MediaTime; note: string; color?: string }>;
}) { }) {
return ( return (
<div className="flex flex-col gap-1.5" aria-live="polite"> <div className="flex flex-col gap-1.5" aria-live="polite">
@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({
isVisible, isVisible,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
time: number; time: MediaTime;
isVisible: boolean; isVisible: boolean;
}): PreviewOverlaySourceResult { }): PreviewOverlaySourceResult {
const bookmarksWithNotes = getBookmarksActiveAtTime({ const bookmarksWithNotes = getBookmarksActiveAtTime({

View File

@ -1,13 +1,14 @@
import type { Bookmark } from "@/timeline"; import type { Bookmark } from "@/timeline";
import type { FrameRate } from "opencut-wasm"; import type { FrameRate } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm";
import { addMediaTime, type MediaTime } from "@/wasm";
function bookmarkTimeEqual({ function bookmarkTimeEqual({
bookmarkTime, bookmarkTime,
frameTime, frameTime,
}: { }: {
bookmarkTime: number; bookmarkTime: MediaTime;
frameTime: number; frameTime: MediaTime;
}): boolean { }): boolean {
return bookmarkTime === frameTime; return bookmarkTime === frameTime;
} }
@ -17,7 +18,7 @@ export function findBookmarkIndex({
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: MediaTime;
}): number { }): number {
return bookmarks.findIndex((bookmark) => return bookmarks.findIndex((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
@ -29,7 +30,7 @@ export function isBookmarkAtTime({
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: MediaTime;
}): boolean { }): boolean {
return bookmarks.some((bookmark) => return bookmarks.some((bookmark) =>
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
@ -41,7 +42,7 @@ export function toggleBookmarkInArray({
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: MediaTime;
}): Bookmark[] { }): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
@ -58,7 +59,7 @@ export function removeBookmarkFromArray({
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: MediaTime;
}): Bookmark[] { }): Bookmark[] {
return bookmarks.filter( return bookmarks.filter(
(bookmark) => (bookmark) =>
@ -72,7 +73,7 @@ export function updateBookmarkInArray({
updates, updates,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: MediaTime;
updates: Partial<Omit<Bookmark, "time">>; updates: Partial<Omit<Bookmark, "time">>;
}): Bookmark[] { }): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime }); const index = findBookmarkIndex({ bookmarks, frameTime });
@ -92,8 +93,8 @@ export function moveBookmarkInArray({
toTime, toTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
fromTime: number; fromTime: MediaTime;
toTime: number; toTime: MediaTime;
}): Bookmark[] { }): Bookmark[] {
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
if (index === -1) { if (index === -1) {
@ -110,10 +111,10 @@ export function getFrameTime({
time, time,
fps, fps,
}: { }: {
time: number; time: MediaTime;
fps: FrameRate; fps: FrameRate;
}): number { }): MediaTime {
return roundToFrame({ time, rate: fps }) ?? time; return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
} }
export function getBookmarkAtTime({ export function getBookmarkAtTime({
@ -121,7 +122,7 @@ export function getBookmarkAtTime({
frameTime, frameTime,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
frameTime: number; frameTime: MediaTime;
}): Bookmark | null { }): Bookmark | null {
const index = findBookmarkIndex({ bookmarks, frameTime }); const index = findBookmarkIndex({ bookmarks, frameTime });
return index === -1 ? null : bookmarks[index]; return index === -1 ? null : bookmarks[index];
@ -132,13 +133,13 @@ export function getBookmarksActiveAtTime({
time, time,
}: { }: {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
time: number; time: MediaTime;
}): Bookmark[] { }): Bookmark[] {
return bookmarks.filter((bookmark) => { return bookmarks.filter((bookmark) => {
const start = bookmark.time; const start = bookmark.time;
const end = const end =
bookmark.duration != null && bookmark.duration > 0 bookmark.duration != null && bookmark.duration > 0
? start + bookmark.duration ? addMediaTime({ a: start, b: bookmark.duration })
: start; : start;
return time >= start && time <= end; return time >= start && time <= end;
}); });

View File

@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
import { resolveTrackPlacement } from "@/timeline/placement"; import { resolveTrackPlacement } from "@/timeline/placement";
import { TIMELINE_TRACK_GAP_PX } from "./layout"; import { TIMELINE_TRACK_GAP_PX } from "./layout";
import { getTrackHeight } from "./track-layout"; import { getTrackHeight } from "./track-layout";
import { TICKS_PER_SECOND } from "@/wasm"; import {
mediaTime,
type MediaTime,
roundMediaTime,
TICKS_PER_SECOND,
} from "@/wasm";
function findElementAtPosition({ function findElementAtPosition({
mouseX, mouseX,
@ -20,9 +25,11 @@ function findElementAtPosition({
pixelsPerSecond: number; pixelsPerSecond: number;
zoomLevel: number; zoomLevel: number;
}): { elementId: string; trackId: string } | null { }): { elementId: string; trackId: string } | null {
const time = Math.round( const time = mediaTime({
(mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, ticks: Math.round(
); (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
),
});
const track = tracks[trackIndex]; const track = tracks[trackIndex];
if (!track || !("elements" in track)) return null; if (!track || !("elements" in track)) return null;
@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null;
function fallbackNewTrackDropTarget({ function fallbackNewTrackDropTarget({
xPosition, xPosition,
}: { }: {
xPosition: number; xPosition: MediaTime;
}): DropTarget { }): DropTarget {
return { return {
trackIndex: 0, trackIndex: 0,
@ -111,13 +118,16 @@ export function computeDropTarget({
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
const mainTrackIndex = tracks.overlay.length; const mainTrackIndex = tracks.overlay.length;
const xPosition = const xPosition =
typeof startTimeOverride === "number" startTimeOverride !== undefined
? startTimeOverride ? startTimeOverride
: isExternalDrop : isExternalDrop
? playheadTime ? playheadTime
: Math.round( : mediaTime({
Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, ticks: Math.round(
); Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) *
TICKS_PER_SECOND,
),
});
if (orderedTracks.length === 0) { if (orderedTracks.length === 0) {
const placementResult = resolveTrackPlacement({ const placementResult = resolveTrackPlacement({
@ -221,7 +231,10 @@ export function computeDropTarget({
} }
if (placementResult.kind === "existingTrack") { if (placementResult.kind === "existingTrack") {
const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; const adjustedXPosition =
placementResult.adjustedStartTime !== undefined
? roundMediaTime({ time: placementResult.adjustedStartTime })
: xPosition;
return { return {
trackIndex: placementResult.trackIndex, trackIndex: placementResult.trackIndex,

View File

@ -29,6 +29,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import type { MediaTime } from "@/wasm";
import type { ElementDragState, DropTarget } from "@/timeline"; import type { ElementDragState, DropTarget } from "@/timeline";
import { TimelineTrackContent } from "./timeline-track"; import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead"; import { TimelinePlayhead } from "./timeline-playhead";
@ -132,7 +133,7 @@ export function Timeline() {
[scene], [scene],
); );
const mainTrackId = scene?.tracks.main.id ?? null; const mainTrackId = scene?.tracks.main.id ?? null;
const seek = (time: number) => editor.playback.seek({ time }); const seek = (time: MediaTime) => editor.playback.seek({ time });
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const timelineHeaderRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null);

View File

@ -49,7 +49,7 @@ import {
import { buildWaveformGainSamples } from "@/timeline/audio-state"; import { buildWaveformGainSamples } from "@/timeline/audio-state";
import { getTimelinePixelsPerSecond } from "@/timeline"; import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { TICKS_PER_SECOND } from "@/wasm/ticks"; import { addMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm";
import { import {
getActionDefinition, getActionDefinition,
type TAction, type TAction,
@ -257,10 +257,11 @@ export function TimelineElement({
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? dragState.currentMouseY - dragState.startMouseY ? dragState.currentMouseY - dragState.startMouseY
: 0; : 0;
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; const dragTimeOffset =
dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME;
const elementStartTime = const elementStartTime =
isBeingDragged && dragState.isDragging isBeingDragged && dragState.isDragging
? dragState.currentTime + dragTimeOffset ? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset })
: renderElement.startTime; : renderElement.startTime;
const displayedStartTime = elementStartTime; const displayedStartTime = elementStartTime;
const displayedDuration = renderElement.duration; const displayedDuration = renderElement.duration;

View File

@ -7,7 +7,14 @@ import {
timelineTimeToSnappedPixels, timelineTimeToSnappedPixels,
} from "@/timeline"; } from "@/timeline";
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
import { TICKS_PER_SECOND } from "@/wasm"; import {
addMediaTime,
maxMediaTime,
mediaTime,
subMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
import { TIMELINE_LAYERS } from "./layers"; import { TIMELINE_LAYERS } from "./layers";
@ -72,17 +79,24 @@ export function TimelinePlayhead({
event.preventDefault(); event.preventDefault();
const fps = editor.project.getActive().settings.fps; const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round( const ticksPerFrame = mediaTime({
(TICKS_PER_SECOND * fps.denominator) / fps.numerator, ticks: Math.round(
); (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
const direction = event.key === "ArrowRight" ? 1 : -1; const direction = event.key === "ArrowRight" ? 1 : -1;
const now = editor.playback.getCurrentTime(); const now = editor.playback.getCurrentTime();
const nextTime = Math.max( const nextTime =
0, direction > 0
Math.min(duration, now + direction * ticksPerFrame), ? addMediaTime({ a: now, b: ticksPerFrame })
); : subMediaTime({ a: now, b: ticksPerFrame });
editor.playback.seek({ time: nextTime }); editor.playback.seek({
time: maxMediaTime({
a: ZERO_MEDIA_TIME,
b: duration < nextTime ? duration : nextTime,
}),
});
}; };
return ( return (

View File

@ -1,3 +1,5 @@
import { TICKS_PER_SECOND } from "@/wasm/ticks"; import { mediaTime, TICKS_PER_SECOND } from "@/wasm";
export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({
ticks: 5 * TICKS_PER_SECOND,
});

View File

@ -1,6 +1,7 @@
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import type { TTimelineViewState } from "@/project/types"; import type { TTimelineViewState } from "@/project/types";
import type { BlendMode, Transform } from "@/rendering"; import type { BlendMode, Transform } from "@/rendering";
import { ZERO_MEDIA_TIME } from "@/wasm";
import type { TextElement } from "./types"; import type { TextElement } from "./types";
const defaultTransform: Transform = { const defaultTransform: Transform = {
@ -42,9 +43,9 @@ const defaultTextElement: Omit<TextElement, "id"> = {
letterSpacing: defaultTextLetterSpacing, letterSpacing: defaultTextLetterSpacing,
lineHeight: defaultTextLineHeight, lineHeight: defaultTextLineHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION, duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime: 0, startTime: ZERO_MEDIA_TIME,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
transform: { transform: {
...defaultTransform, ...defaultTransform,
position: { ...defaultTransform.position }, position: { ...defaultTransform.position },
@ -55,7 +56,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
const defaultTimelineViewState: TTimelineViewState = { const defaultTimelineViewState: TTimelineViewState = {
zoomLevel: 1, zoomLevel: 1,
scrollLeft: 0, scrollLeft: 0,
playheadTime: 0, playheadTime: ZERO_MEDIA_TIME,
}; };
export const DEFAULTS = { export const DEFAULTS = {

View File

@ -1,5 +1,5 @@
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
export function getMouseTimeFromClientX({ export function getMouseTimeFromClientX({
clientX, clientX,
@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({
containerRect: DOMRect; containerRect: DOMRect;
zoomLevel: number; zoomLevel: number;
scrollLeft: number; scrollLeft: number;
}): number { }): MediaTime {
const mouseX = clientX - containerRect.left + scrollLeft; const mouseX = clientX - containerRect.left + scrollLeft;
const seconds = Math.max( const seconds = Math.max(
0, 0,
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
); );
return Math.round(seconds * TICKS_PER_SECOND); return mediaTime({
ticks: Math.round(seconds * TICKS_PER_SECOND),
});
} }

View File

@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects";
import { buildDefaultGraphicInstance } from "@/graphics"; import { buildDefaultGraphicInstance } from "@/graphics";
import type { ParamValues } from "@/params"; import type { ParamValues } from "@/params";
import { capitalizeFirstLetter } from "@/utils/string"; import { capitalizeFirstLetter } from "@/utils/string";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function canElementHaveAudio( export function canElementHaveAudio(
element: TimelineElement, element: TimelineElement,
@ -107,7 +108,7 @@ export function buildTextElement({
startTime, startTime,
}: { }: {
raw: Partial<Omit<TextElement, "type" | "id">>; raw: Partial<Omit<TextElement, "type" | "id">>;
startTime: number; startTime: MediaTime;
}): CreateTimelineElement { }): CreateTimelineElement {
const t = raw as Partial<TextElement>; const t = raw as Partial<TextElement>;
@ -117,8 +118,8 @@ export function buildTextElement({
content: t.content ?? DEFAULTS.text.element.content, content: t.content ?? DEFAULTS.text.element.content,
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
color: t.color ?? DEFAULTS.text.element.color, color: t.color ?? DEFAULTS.text.element.color,
@ -141,8 +142,8 @@ export function buildEffectElement({
duration, duration,
}: { }: {
effectType: string; effectType: string;
startTime: number; startTime: MediaTime;
duration?: number; duration?: MediaTime;
}): CreateEffectElement { }): CreateEffectElement {
const instance = buildDefaultEffectInstance({ effectType }); const instance = buildDefaultEffectInstance({ effectType });
return { return {
@ -152,8 +153,8 @@ export function buildEffectElement({
params: instance.params, params: instance.params,
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
}; };
} }
@ -166,7 +167,7 @@ export function buildStickerElement({
}: { }: {
stickerId: string; stickerId: string;
name?: string; name?: string;
startTime: number; startTime: MediaTime;
intrinsicWidth?: number; intrinsicWidth?: number;
intrinsicHeight?: number; intrinsicHeight?: number;
}): CreateStickerElement { }): CreateStickerElement {
@ -180,8 +181,8 @@ export function buildStickerElement({
intrinsicHeight, intrinsicHeight,
duration: DEFAULT_NEW_ELEMENT_DURATION, duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
transform: { transform: {
...DEFAULTS.element.transform, ...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position }, position: { ...DEFAULTS.element.transform.position },
@ -199,7 +200,7 @@ export function buildGraphicElement({
}: { }: {
definitionId: string; definitionId: string;
name?: string; name?: string;
startTime: number; startTime: MediaTime;
params?: Partial<ParamValues>; params?: Partial<ParamValues>;
}): CreateGraphicElement { }): CreateGraphicElement {
const instance = buildDefaultGraphicInstance({ definitionId }); const instance = buildDefaultGraphicInstance({ definitionId });
@ -210,8 +211,8 @@ export function buildGraphicElement({
params: { ...instance.params, ...(params ?? {}) } as ParamValues, params: { ...instance.params, ...(params ?? {}) } as ParamValues,
duration: DEFAULT_NEW_ELEMENT_DURATION, duration: DEFAULT_NEW_ELEMENT_DURATION,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
transform: { transform: {
...DEFAULTS.element.transform, ...DEFAULTS.element.transform,
position: { ...DEFAULTS.element.transform.position }, position: { ...DEFAULTS.element.transform.position },
@ -229,8 +230,8 @@ function buildVideoElement({
}: { }: {
mediaId: string; mediaId: string;
name: string; name: string;
duration: number; duration: MediaTime;
startTime: number; startTime: MediaTime;
}): CreateVideoElement { }): CreateVideoElement {
return { return {
type: "video", type: "video",
@ -238,8 +239,8 @@ function buildVideoElement({
name, name,
duration, duration,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration, sourceDuration: duration,
muted: false, muted: false,
isSourceAudioEnabled: true, isSourceAudioEnabled: true,
@ -262,8 +263,8 @@ function buildImageElement({
}: { }: {
mediaId: string; mediaId: string;
name: string; name: string;
duration: number; duration: MediaTime;
startTime: number; startTime: MediaTime;
}): CreateImageElement { }): CreateImageElement {
return { return {
type: "image", type: "image",
@ -271,8 +272,8 @@ function buildImageElement({
name, name,
duration, duration,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
hidden: false, hidden: false,
transform: { transform: {
...DEFAULTS.element.transform, ...DEFAULTS.element.transform,
@ -292,8 +293,8 @@ function buildUploadAudioElement({
}: { }: {
mediaId: string; mediaId: string;
name: string; name: string;
duration: number; duration: MediaTime;
startTime: number; startTime: MediaTime;
buffer?: AudioBuffer; buffer?: AudioBuffer;
}): CreateUploadAudioElement { }): CreateUploadAudioElement {
const element: CreateUploadAudioElement = { const element: CreateUploadAudioElement = {
@ -303,8 +304,8 @@ function buildUploadAudioElement({
name, name,
duration, duration,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration, sourceDuration: duration,
volume: DEFAULTS.element.volume, volume: DEFAULTS.element.volume,
muted: false, muted: false,
@ -326,8 +327,8 @@ export function buildElementFromMedia({
mediaId: string; mediaId: string;
mediaType: MediaType; mediaType: MediaType;
name: string; name: string;
duration: number; duration: MediaTime;
startTime: number; startTime: MediaTime;
buffer?: AudioBuffer; buffer?: AudioBuffer;
}): CreateTimelineElement { }): CreateTimelineElement {
switch (mediaType) { switch (mediaType) {
@ -355,8 +356,8 @@ export function buildLibraryAudioElement({
}: { }: {
sourceUrl: string; sourceUrl: string;
name: string; name: string;
duration: number; duration: MediaTime;
startTime: number; startTime: MediaTime;
buffer?: AudioBuffer; buffer?: AudioBuffer;
}): CreateLibraryAudioElement { }): CreateLibraryAudioElement {
const element: CreateLibraryAudioElement = { const element: CreateLibraryAudioElement = {
@ -366,8 +367,8 @@ export function buildLibraryAudioElement({
name, name,
duration, duration,
startTime, startTime,
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
sourceDuration: duration, sourceDuration: duration,
volume: DEFAULTS.element.volume, volume: DEFAULTS.element.volume,
muted: false, muted: false,

View File

@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline";
import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import { findTrackInSceneTracks } from "@/timeline/track-element-update";
import type { GroupMember, MoveGroup } from "./types"; import type { GroupMember, MoveGroup } from "./types";
import { getTrackPlacementById } from "./track-placement"; import { getTrackPlacementById } from "./track-placement";
import { subMediaTime } from "@/wasm";
export function buildMoveGroup({ export function buildMoveGroup({
anchorRef, anchorRef,
@ -59,7 +60,10 @@ export function buildMoveGroup({
elementId: element.id, elementId: element.id,
elementType: element.type, elementType: element.type,
duration: element.duration, duration: element.duration,
timeOffset: element.startTime - anchorElement.startTime, timeOffset: subMediaTime({
a: element.startTime,
b: anchorElement.startTime,
}),
trackSection: placement.section, trackSection: placement.section,
sectionIndex: placement.sectionIndex, sectionIndex: placement.sectionIndex,
displayIndex: placement.displayIndex, displayIndex: placement.displayIndex,

View File

@ -12,6 +12,13 @@ import {
getTrackPlacementByDisplayIndex, getTrackPlacementByDisplayIndex,
getTrackPlacementById, getTrackPlacementById,
} from "./track-placement"; } from "./track-placement";
import {
addMediaTime,
maxMediaTime,
type MediaTime,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
type GroupMoveTarget = type GroupMoveTarget =
| { | {
@ -32,7 +39,7 @@ export function resolveGroupMove({
}: { }: {
group: MoveGroup; group: MoveGroup;
tracks: SceneTracks; tracks: SceneTracks;
anchorStartTime: number; anchorStartTime: MediaTime;
target: GroupMoveTarget; target: GroupMoveTarget;
}): GroupMoveResult | null { }): GroupMoveResult | null {
if (target.kind === "newTracks") { if (target.kind === "newTracks") {
@ -61,7 +68,7 @@ function resolveExistingTrackMove({
}: { }: {
group: MoveGroup; group: MoveGroup;
tracks: SceneTracks; tracks: SceneTracks;
anchorStartTime: number; anchorStartTime: MediaTime;
anchorTargetTrackId: string; anchorTargetTrackId: string;
}): GroupMoveResult | null { }): GroupMoveResult | null {
const anchorTargetPlacement = getTrackPlacementById({ const anchorTargetPlacement = getTrackPlacementById({
@ -93,7 +100,10 @@ function resolveExistingTrackMove({
targetTrackId: targetTrackId:
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
elementId: member.elementId, elementId: member.elementId,
newStartTime: clampedAnchorStartTime + member.timeOffset, newStartTime: addMediaTime({
a: clampedAnchorStartTime,
b: member.timeOffset,
}),
})); }));
if (!canApplyMovesToExistingTracks({ tracks, moves })) { if (!canApplyMovesToExistingTracks({ tracks, moves })) {
@ -119,7 +129,7 @@ function resolveNewTrackMove({
}: { }: {
group: MoveGroup; group: MoveGroup;
tracks: SceneTracks; tracks: SceneTracks;
anchorStartTime: number; anchorStartTime: MediaTime;
anchorInsertIndex: number; anchorInsertIndex: number;
newTrackIds: string[]; newTrackIds: string[];
}): GroupMoveResult | null { }): GroupMoveResult | null {
@ -173,7 +183,10 @@ function resolveNewTrackMove({
sourceTrackId: member.trackId, sourceTrackId: member.trackId,
targetTrackId: newTrackIds[memberIndex], targetTrackId: newTrackIds[memberIndex],
elementId: member.elementId, elementId: member.elementId,
newStartTime: clampedAnchorStartTime + member.timeOffset, newStartTime: addMediaTime({
a: clampedAnchorStartTime,
b: member.timeOffset,
}),
})); }));
return { return {
@ -335,17 +348,26 @@ function clampAnchorStartTime({
}: { }: {
group: MoveGroup; group: MoveGroup;
tracks: SceneTracks; tracks: SceneTracks;
anchorStartTime: number; anchorStartTime: MediaTime;
targetTrackIdsByElementId: Map<string, string>; targetTrackIdsByElementId: Map<string, string>;
}): number { }): MediaTime {
const minimumAnchorStartTime = Math.max( const minimumAnchorStartTime = group.members.reduce(
0, (minimumStartTime, member) =>
...group.members.map((member) => -member.timeOffset), member.timeOffset < ZERO_MEDIA_TIME
); ? maxMediaTime({
let clampedAnchorStartTime = Math.max( a: minimumStartTime,
minimumAnchorStartTime, b: subMediaTime({
anchorStartTime, a: ZERO_MEDIA_TIME,
b: member.timeOffset,
}),
})
: minimumStartTime,
ZERO_MEDIA_TIME,
); );
let clampedAnchorStartTime =
anchorStartTime < minimumAnchorStartTime
? minimumAnchorStartTime
: anchorStartTime;
const memberOnMainTrack = group.members.find( const memberOnMainTrack = group.members.find(
(member) => (member) =>
@ -358,11 +380,13 @@ function clampAnchorStartTime({
const movingElementIds = new Set( const movingElementIds = new Set(
group.members.map((member) => member.elementId), group.members.map((member) => member.elementId),
); );
const requestedMainStartTime = const requestedMainStartTime = addMediaTime({
clampedAnchorStartTime + memberOnMainTrack.timeOffset; a: clampedAnchorStartTime,
b: memberOnMainTrack.timeOffset,
});
const earliestStationaryMainStartTime = tracks.main.elements const earliestStationaryMainStartTime = tracks.main.elements
.filter((element) => !movingElementIds.has(element.id)) .filter((element) => !movingElementIds.has(element.id))
.reduce<number | null>((earliestStartTime, element) => { .reduce<MediaTime | null>((earliestStartTime, element) => {
if (earliestStartTime == null || element.startTime < earliestStartTime) { if (earliestStartTime == null || element.startTime < earliestStartTime) {
return element.startTime; return element.startTime;
} }
@ -373,10 +397,13 @@ function clampAnchorStartTime({
earliestStationaryMainStartTime == null || earliestStationaryMainStartTime == null ||
requestedMainStartTime <= earliestStationaryMainStartTime requestedMainStartTime <= earliestStationaryMainStartTime
) { ) {
clampedAnchorStartTime = Math.max( clampedAnchorStartTime = maxMediaTime({
minimumAnchorStartTime, a: minimumAnchorStartTime,
-memberOnMainTrack.timeOffset, b: subMediaTime({
); a: ZERO_MEDIA_TIME,
b: memberOnMainTrack.timeOffset,
}),
});
} }
return clampedAnchorStartTime; return clampedAnchorStartTime;
@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({
const sourceElement = sourceElements.get(move.elementId); const sourceElement = sourceElements.get(move.elementId);
return { return {
startTime: move.newStartTime, startTime: move.newStartTime,
duration: sourceElement?.duration ?? 0, duration: sourceElement?.duration ?? ZERO_MEDIA_TIME,
}; };
}); });
if (hasOverlappingTimeSpans({ timeSpans })) { if (hasOverlappingTimeSpans({ timeSpans })) {

View File

@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
import type { MoveGroup } from "./types"; import type { MoveGroup } from "./types";
import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
export function snapGroupEdges({ export function snapGroupEdges({
group, group,
@ -18,12 +19,12 @@ export function snapGroupEdges({
zoomLevel, zoomLevel,
}: { }: {
group: MoveGroup; group: MoveGroup;
anchorStartTime: number; anchorStartTime: MediaTime;
tracks: SceneTracks; tracks: SceneTracks;
playheadTime: number; playheadTime: MediaTime;
zoomLevel: number; zoomLevel: number;
}): { }): {
snappedAnchorStartTime: number; snappedAnchorStartTime: MediaTime;
snapPoint: SnapPoint | null; snapPoint: SnapPoint | null;
} { } {
const excludeElementIds = new Set( const excludeElementIds = new Set(
@ -47,7 +48,10 @@ export function snapGroupEdges({
let snapPoint: SnapPoint | null = null; let snapPoint: SnapPoint | null = null;
for (const member of group.members) { for (const member of group.members) {
const memberStartTime = anchorStartTime + member.timeOffset; const memberStartTime = addMediaTime({
a: anchorStartTime,
b: member.timeOffset,
});
const memberStartSnap = resolveTimelineSnap({ const memberStartSnap = resolveTimelineSnap({
targetTime: memberStartTime, targetTime: memberStartTime,
snapPoints, snapPoints,
@ -58,12 +62,18 @@ export function snapGroupEdges({
memberStartSnap.snapDistance < closestSnapDistance memberStartSnap.snapDistance < closestSnapDistance
) { ) {
closestSnapDistance = memberStartSnap.snapDistance; closestSnapDistance = memberStartSnap.snapDistance;
snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; snappedAnchorStartTime = subMediaTime({
a: memberStartSnap.snappedTime as MediaTime,
b: member.timeOffset,
});
snapPoint = memberStartSnap.snapPoint; snapPoint = memberStartSnap.snapPoint;
} }
const memberEndSnap = resolveTimelineSnap({ const memberEndSnap = resolveTimelineSnap({
targetTime: memberStartTime + member.duration, targetTime: addMediaTime({
a: memberStartTime,
b: member.duration,
}),
snapPoints, snapPoints,
maxSnapDistance, maxSnapDistance,
}); });
@ -72,8 +82,13 @@ export function snapGroupEdges({
memberEndSnap.snapDistance < closestSnapDistance memberEndSnap.snapDistance < closestSnapDistance
) { ) {
closestSnapDistance = memberEndSnap.snapDistance; closestSnapDistance = memberEndSnap.snapDistance;
snappedAnchorStartTime = snappedAnchorStartTime = subMediaTime({
memberEndSnap.snappedTime - member.duration - member.timeOffset; a: subMediaTime({
a: memberEndSnap.snappedTime as MediaTime,
b: member.duration,
}),
b: member.timeOffset,
});
snapPoint = memberEndSnap.snapPoint; snapPoint = memberEndSnap.snapPoint;
} }
} }

View File

@ -1,11 +1,12 @@
import type { ElementRef, ElementType, TrackType } from "@/timeline"; import type { ElementRef, ElementType, TrackType } from "@/timeline";
import type { MediaTime } from "@/wasm";
export type GroupTrackSection = "overlay" | "main" | "audio"; export type GroupTrackSection = "overlay" | "main" | "audio";
export interface GroupMember extends ElementRef { export interface GroupMember extends ElementRef {
elementType: ElementType; elementType: ElementType;
duration: number; duration: MediaTime;
timeOffset: number; timeOffset: MediaTime;
trackSection: GroupTrackSection; trackSection: GroupTrackSection;
sectionIndex: number; sectionIndex: number;
displayIndex: number; displayIndex: number;
@ -26,7 +27,7 @@ export interface PlannedElementMove {
sourceTrackId: string; sourceTrackId: string;
targetTrackId: string; targetTrackId: string;
elementId: string; elementId: string;
newStartTime: number; newStartTime: MediaTime;
} }
export interface GroupMoveResult { export interface GroupMoveResult {

View File

@ -3,7 +3,7 @@ import {
getSourceSpanAtClipTime, getSourceSpanAtClipTime,
getTimelineDurationForSourceSpan, getTimelineDurationForSourceSpan,
} from "@/retime"; } from "@/retime";
import { TICKS_PER_SECOND } from "@/wasm"; import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm";
import type { import type {
ComputeGroupResizeArgs, ComputeGroupResizeArgs,
GroupResizeMember, GroupResizeMember,
@ -188,15 +188,17 @@ function getSourceDeltaForClipDelta({
return clipDelta; return clipDelta;
} }
return clipDelta >= 0 const sourceDelta =
? getSourceSpanAtClipTime({ clipDelta >= 0
clipTime: clipDelta, ? getSourceSpanAtClipTime({
retime: member.retime, clipTime: clipDelta,
}) retime: member.retime,
: -getSourceSpanAtClipTime({ })
clipTime: Math.abs(clipDelta), : -getSourceSpanAtClipTime({
retime: member.retime, clipTime: Math.abs(clipDelta),
}); retime: member.retime,
});
return roundMediaTime({ time: sourceDelta });
} }
function getVisibleSourceSpanForDuration({ function getVisibleSourceSpanForDuration({
@ -227,9 +229,11 @@ function getDurationForVisibleSourceSpan({
return sourceSpan; return sourceSpan;
} }
return getTimelineDurationForSourceSpan({ return roundMediaTime({
sourceSpan, time: getTimelineDurationForSourceSpan({
retime: member.retime, sourceSpan,
retime: member.retime,
}),
}); });
} }

View File

@ -17,7 +17,14 @@ import {
type MoveGroup, type MoveGroup,
} from "@/timeline/group-move"; } from "@/timeline/group-move";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { TICKS_PER_SECOND } from "@/wasm"; import {
addMediaTime,
type MediaTime,
mediaTime,
subMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
import { roundToFrame } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm";
import { computeDropTarget } from "@/timeline/components/drop-target"; import { computeDropTarget } from "@/timeline/components/drop-target";
@ -54,9 +61,9 @@ const initialDragState: ElementDragState = {
trackId: null, trackId: null,
startMouseX: 0, startMouseX: 0,
startMouseY: 0, startMouseY: 0,
startElementTime: 0, startElementTime: ZERO_MEDIA_TIME,
clickOffsetTime: 0, clickOffsetTime: ZERO_MEDIA_TIME,
currentTime: 0, currentTime: ZERO_MEDIA_TIME,
currentMouseY: 0, currentMouseY: 0,
}; };
@ -66,8 +73,8 @@ interface PendingDragState {
selectedElements: ElementRef[]; selectedElements: ElementRef[];
startMouseX: number; startMouseX: number;
startMouseY: number; startMouseY: number;
startElementTime: number; startElementTime: MediaTime;
clickOffsetTime: number; clickOffsetTime: MediaTime;
} }
function getClickOffsetTime({ function getClickOffsetTime({
@ -78,10 +85,12 @@ function getClickOffsetTime({
clientX: number; clientX: number;
elementRect: DOMRect; elementRect: DOMRect;
zoomLevel: number; zoomLevel: number;
}): number { }): MediaTime {
const clickOffsetX = clientX - elementRect.left; const clickOffsetX = clientX - elementRect.left;
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
return Math.round(seconds * TICKS_PER_SECOND); return mediaTime({
ticks: Math.round(seconds * TICKS_PER_SECOND),
});
} }
function getVerticalDragDirection({ function getVerticalDragDirection({
@ -118,7 +127,7 @@ function getDragDropTarget({
tracksScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>; headerRef?: RefObject<HTMLElement | null>;
zoomLevel: number; zoomLevel: number;
snappedTime: number; snappedTime: MediaTime;
verticalDragDirection?: "up" | "down" | null; verticalDragDirection?: "up" | "down" | null;
}): DropTarget | null { }): DropTarget | null {
const containerRect = tracksContainerRef.current?.getBoundingClientRect(); const containerRect = tracksContainerRef.current?.getBoundingClientRect();
@ -162,7 +171,7 @@ interface StartDragParams
ElementDragState, ElementDragState,
"isDragging" | "currentTime" | "currentMouseY" "isDragging" | "currentTime" | "currentMouseY"
> { > {
initialCurrentTime: number; initialCurrentTime: MediaTime;
initialCurrentMouseY: number; initialCurrentMouseY: number;
} }
@ -249,7 +258,7 @@ export function useElementInteraction({
dropTarget, dropTarget,
}: { }: {
group: MoveGroup; group: MoveGroup;
snappedTime: number; snappedTime: MediaTime;
dropTarget: DropTarget | null; dropTarget: DropTarget | null;
}): GroupMoveResult | null => { }): GroupMoveResult | null => {
if (!dropTarget) { if (!dropTarget) {
@ -317,7 +326,7 @@ export function useElementInteraction({
frameSnappedTime, frameSnappedTime,
group, group,
}: { }: {
frameSnappedTime: number; frameSnappedTime: MediaTime;
group: MoveGroup | null; group: MoveGroup | null;
}) => { }) => {
if (!group || !snappingEnabled || isShiftHeldRef.current) { if (!group || !snappingEnabled || isShiftHeldRef.current) {
@ -366,15 +375,19 @@ export function useElementInteraction({
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const adjustedTime = Math.max( const adjustedTime =
0, mouseTime > pendingDragRef.current.clickOffsetTime
mouseTime - pendingDragRef.current.clickOffsetTime, ? subMediaTime({
); a: mouseTime,
const snappedTime = b: pendingDragRef.current.clickOffsetTime,
})
: ZERO_MEDIA_TIME;
const snappedTime = (
roundToFrame({ roundToFrame({
time: adjustedTime, time: adjustedTime,
rate: activeProject.settings.fps, rate: activeProject.settings.fps,
}) ?? adjustedTime; }) ?? adjustedTime
) as MediaTime;
const moveGroup = buildMoveGroup({ const moveGroup = buildMoveGroup({
anchorRef: { anchorRef: {
trackId: pendingDragRef.current.trackId, trackId: pendingDragRef.current.trackId,
@ -389,7 +402,7 @@ export function useElementInteraction({
moveGroupRef.current = moveGroup; moveGroupRef.current = moveGroup;
newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
const dragTimeOffsets: Record<string, number> = {}; const dragTimeOffsets: Record<string, MediaTime> = {};
for (const member of moveGroup.members) { for (const member of moveGroup.members) {
dragTimeOffsets[member.elementId] = member.timeOffset; dragTimeOffsets[member.elementId] = member.timeOffset;
} }
@ -478,10 +491,17 @@ export function useElementInteraction({
zoomLevel, zoomLevel,
scrollLeft, scrollLeft,
}); });
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const adjustedTime =
mouseTime > dragState.clickOffsetTime
? subMediaTime({
a: mouseTime,
b: dragState.clickOffsetTime,
})
: ZERO_MEDIA_TIME;
const fps = activeProject.settings.fps; const fps = activeProject.settings.fps;
const frameSnappedTime = const frameSnappedTime = (
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime
) as MediaTime;
const moveGroup = moveGroupRef.current; const moveGroup = moveGroupRef.current;
const { snappedTime, snapPoint } = getDragSnapResult({ const { snappedTime, snapPoint } = getDragSnapResult({
@ -596,8 +616,10 @@ export function useElementInteraction({
const currentMember = moveGroup.members.find( const currentMember = moveGroup.members.find(
(member) => member.elementId === move.elementId, (member) => member.elementId === move.elementId,
); );
const originalStartTime = const originalStartTime = addMediaTime({
dragState.startElementTime + (currentMember?.timeOffset ?? 0); a: dragState.startElementTime,
b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME,
});
return ( return (
currentMember?.trackId !== move.targetTrackId || currentMember?.trackId !== move.targetTrackId ||
originalStartTime !== move.newStartTime originalStartTime !== move.newStartTime

View File

@ -11,7 +11,15 @@ import { useKeyframeSelection } from "./use-keyframe-selection";
import { roundToFrame, snappedSeekTime } from "opencut-wasm"; import { roundToFrame, snappedSeekTime } from "opencut-wasm";
import { timelineTimeToSnappedPixels } from "@/timeline"; import { timelineTimeToSnappedPixels } from "@/timeline";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { TICKS_PER_SECOND } from "@/wasm"; import {
addMediaTime,
type MediaTime,
maxMediaTime,
mediaTime,
minMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/commands"; import { BatchCommand } from "@/commands";
@ -22,13 +30,13 @@ import { registerCanceller } from "@/editor/cancel-interaction";
export interface KeyframeDragState { export interface KeyframeDragState {
isDragging: boolean; isDragging: boolean;
draggingKeyframeIds: Set<string>; draggingKeyframeIds: Set<string>;
deltaTime: number; deltaTime: MediaTime;
} }
const initialDragState: KeyframeDragState = { const initialDragState: KeyframeDragState = {
isDragging: false, isDragging: false,
draggingKeyframeIds: new Set(), draggingKeyframeIds: new Set(),
deltaTime: 0, deltaTime: ZERO_MEDIA_TIME,
}; };
interface PendingKeyframeDrag { interface PendingKeyframeDrag {
@ -43,7 +51,7 @@ export function useKeyframeDrag({
}: { }: {
zoomLevel: number; zoomLevel: number;
element: TimelineElement; element: TimelineElement;
displayedStartTime: number; displayedStartTime: MediaTime;
}) { }) {
const editor = useEditor(); const editor = useEditor();
const { const {
@ -83,7 +91,7 @@ export function useKeyframeDrag({
deltaTime, deltaTime,
}: { }: {
keyframeRefs: SelectedKeyframeRef[]; keyframeRefs: SelectedKeyframeRef[];
deltaTime: number; deltaTime: MediaTime;
}) => { }) => {
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
const keyframe = getKeyframeById({ const keyframe = getKeyframeById({
@ -92,10 +100,13 @@ export function useKeyframeDrag({
keyframeId: keyframeRef.keyframeId, keyframeId: keyframeRef.keyframeId,
}); });
if (!keyframe) return []; if (!keyframe) return [];
const nextTime = Math.max( const nextTime = maxMediaTime({
0, a: ZERO_MEDIA_TIME,
Math.min(element.duration, keyframe.time + deltaTime), b: minMediaTime({
); a: element.duration,
b: addMediaTime({ a: keyframe.time, b: deltaTime }),
}),
});
return [ return [
new RetimeKeyframeCommand({ new RetimeKeyframeCommand({
trackId: keyframeRef.trackId, trackId: keyframeRef.trackId,
@ -138,7 +149,7 @@ export function useKeyframeDrag({
draggingKeyframeIds: new Set( draggingKeyframeIds: new Set(
pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
), ),
deltaTime: 0, deltaTime: ZERO_MEDIA_TIME,
}); });
return; return;
} }
@ -146,11 +157,14 @@ export function useKeyframeDrag({
if (!dragState.isDragging) return; if (!dragState.isDragging) return;
const startX = mouseDownXRef.current ?? clientX; const startX = mouseDownXRef.current ?? clientX;
const rawDelta = Math.round( const rawDelta = mediaTime({
((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, ticks: Math.round(
); ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
const snappedDelta = ),
roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; });
const snappedDelta = (
roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta
) as MediaTime;
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
}; };
@ -246,7 +260,7 @@ export function useKeyframeDrag({
event: ReactMouseEvent; event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[]; keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[];
indicatorTime: number; indicatorTime: MediaTime;
}) => { }) => {
event.stopPropagation(); event.stopPropagation();
@ -259,12 +273,17 @@ export function useKeyframeDrag({
if (wasDrag) return; if (wasDrag) return;
const duration = editor.timeline.getTotalDuration(); const duration = editor.timeline.getTotalDuration();
const seekTime = const absoluteIndicatorTime = addMediaTime({
a: displayedStartTime,
b: indicatorTime,
});
const seekTime = (
snappedSeekTime({ snappedSeekTime({
time: displayedStartTime + indicatorTime, time: absoluteIndicatorTime,
duration, duration,
rate: fps, rate: fps,
}) ?? displayedStartTime + indicatorTime; }) ?? absoluteIndicatorTime
) as MediaTime;
editor.playback.seek({ time: seekTime }); editor.playback.seek({ time: seekTime });
if (event.shiftKey) { if (event.shiftKey) {

View File

@ -4,7 +4,7 @@ import { processMediaAssets } from "@/media/processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { showMediaUploadToast } from "@/media/upload-toast"; import { showMediaUploadToast } from "@/media/upload-toast";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { roundToFrame } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm";
import { import {
@ -45,9 +45,9 @@ export function useTimelineDragDrop({
const [dragElementType, setElementType] = useState<ElementType | null>(null); const [dragElementType, setElementType] = useState<ElementType | null>(null);
const getSnappedTime = useCallback( const getSnappedTime = useCallback(
({ time }: { time: number }) => { ({ time }: { time: MediaTime }) => {
const projectFps = editor.project.getActive().settings.fps; const projectFps = editor.project.getActive().settings.fps;
return roundToFrame({ time, rate: projectFps }) ?? time; return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime;
}, },
[editor], [editor],
); );
@ -76,7 +76,7 @@ export function useTimelineDragDrop({
}: { }: {
elementType: ElementType; elementType: ElementType;
mediaId?: string; mediaId?: string;
}): number => { }): MediaTime => {
if ( if (
elementType === "text" || elementType === "text" ||
elementType === "graphic" || elementType === "graphic" ||
@ -89,7 +89,7 @@ export function useTimelineDragDrop({
const mediaAssets = editor.media.getAssets(); const mediaAssets = editor.media.getAssets();
const media = mediaAssets.find((m) => m.id === mediaId); const media = mediaAssets.find((m) => m.id === mediaId);
return media?.duration != null return media?.duration != null
? Math.round(media.duration * TICKS_PER_SECOND) ? mediaTimeFromSeconds({ seconds: media.duration })
: DEFAULT_NEW_ELEMENT_DURATION; : DEFAULT_NEW_ELEMENT_DURATION;
} }
return DEFAULT_NEW_ELEMENT_DURATION; return DEFAULT_NEW_ELEMENT_DURATION;
@ -346,7 +346,7 @@ export function useTimelineDragDrop({
const duration = const duration =
mediaAsset.duration != null mediaAsset.duration != null
? Math.round(mediaAsset.duration * TICKS_PER_SECOND) ? mediaTimeFromSeconds({ seconds: mediaAsset.duration })
: DEFAULT_NEW_ELEMENT_DURATION; : DEFAULT_NEW_ELEMENT_DURATION;
const element = buildElementFromMedia({ const element = buildElementFromMedia({
mediaId: mediaAsset.id, mediaId: mediaAsset.id,
@ -470,7 +470,7 @@ export function useTimelineDragDrop({
const duration = const duration =
createdAsset.duration != null createdAsset.duration != null
? Math.round(createdAsset.duration * TICKS_PER_SECOND) ? mediaTimeFromSeconds({ seconds: createdAsset.duration })
: DEFAULT_NEW_ELEMENT_DURATION; : DEFAULT_NEW_ELEMENT_DURATION;
const sceneTracks = editor.scenes.getActiveScene().tracks; const sceneTracks = editor.scenes.getActiveScene().tracks;
const currentTime = editor.playback.getCurrentTime(); const currentTime = editor.playback.getCurrentTime();

View File

@ -1,5 +1,5 @@
import { snappedSeekTime } from "opencut-wasm"; import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import { useEffect, useCallback, useRef } from "react"; import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
@ -53,11 +53,11 @@ export function useTimelinePlayhead({
}, [zoomLevel, duration, isScrubbing, editor.playback]); }, [zoomLevel, duration, isScrubbing, editor.playback]);
const seek = useCallback( const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }), ({ time }: { time: MediaTime }) => editor.playback.seek({ time }),
[editor.playback], [editor.playback],
); );
const scrubTimeRef = useRef<number | null>(null); const scrubTimeRef = useRef<MediaTime | null>(null);
const isDraggingRulerRef = useRef(false); const isDraggingRulerRef = useRef(false);
const hasDraggedRulerRef = useRef(false); const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef<number>(0); const lastMouseXRef = useRef<number>(0);
@ -92,11 +92,14 @@ export function useTimelinePlayhead({
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
), ),
); );
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); const rawTime = mediaTime({
ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND),
});
const rate = activeProject.settings.fps; const rate = activeProject.settings.fps;
const frameTime = const frameTime = (
snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime
) as MediaTime;
const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
const time = (() => { const time = (() => {
@ -224,7 +227,7 @@ export function useTimelinePlayhead({
}, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
const updatePlayheadLeft = useCallback( const updatePlayheadLeft = useCallback(
(time: number) => { (time: MediaTime) => {
const playheadEl = playheadRef?.current; const playheadEl = playheadRef?.current;
if (!playheadEl) return; if (!playheadEl) return;
const centerPosition = timelineTimeToSnappedPixels({ const centerPosition = timelineTimeToSnappedPixels({
@ -251,8 +254,7 @@ export function useTimelinePlayhead({
}, [editor.playback, rulerScrollRef, updatePlayheadLeft]); }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
useEffect(() => { useEffect(() => {
const handlePlaybackUpdate = (e: Event) => { const handlePlaybackTime = (time: MediaTime) => {
const time = (e as CustomEvent<{ time: number }>).detail.time;
updatePlayheadLeft(time); updatePlayheadLeft(time);
if (!isPlayingRef.current || isScrubbingRef.current) return; if (!isPlayingRef.current || isScrubbingRef.current) return;
@ -280,11 +282,12 @@ export function useTimelinePlayhead({
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
} }
}; };
const handlePlaybackUpdate = (e: Event) => {
handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
};
const initialTime = editor.playback.getCurrentTime(); const initialTime = editor.playback.getCurrentTime();
handlePlaybackUpdate({ handlePlaybackTime(initialTime);
detail: { time: initialTime },
} as CustomEvent<{ time: number }>);
window.addEventListener("playback-update", handlePlaybackUpdate); window.addEventListener("playback-update", handlePlaybackUpdate);
window.addEventListener("playback-seek", handlePlaybackUpdate); window.addEventListener("playback-seek", handlePlaybackUpdate);

View File

@ -139,7 +139,7 @@ export function useTimelineResize({
updates: result.updates.map(({ trackId, elementId, patch }) => ({ updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId, trackId,
elementId, elementId,
updates: patch, updates: patch as Partial<TimelineElement>,
})), })),
}); });
}; };
@ -155,7 +155,7 @@ export function useTimelineResize({
updates: result.updates.map(({ trackId, elementId, patch }) => ({ updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId, trackId,
elementId, elementId,
patch, patch: patch as Partial<TimelineElement>,
})), })),
}); });
} }

View File

@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
import type { MutableRefObject, RefObject } from "react"; import type { MutableRefObject, RefObject } from "react";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import { snappedSeekTime } from "opencut-wasm"; import { snappedSeekTime } from "opencut-wasm";
import { TICKS_PER_SECOND } from "@/wasm"; import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
interface UseTimelineSeekProps { interface UseTimelineSeekProps {
@ -11,10 +11,10 @@ interface UseTimelineSeekProps {
rulerScrollRef: RefObject<HTMLDivElement | null>; rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number; zoomLevel: number;
duration: number; duration: MediaTime;
isSelecting: boolean; isSelecting: boolean;
clearSelectedElements: () => void; clearSelectedElements: () => void;
seek: (time: number) => void; seek: (time: MediaTime) => void;
} }
function resetMouseTracking({ function resetMouseTracking({
@ -135,11 +135,13 @@ export function useTimelineSeek({
(mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
), ),
); );
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); const rawTime = mediaTime({
ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND),
});
const rate = activeProject?.settings.fps; const rate = activeProject?.settings.fps;
const time = rate const time = rate
? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) ? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime)
: rawTime; : rawTime;
seek(time); seek(time);
editor.project.setTimelineViewState({ editor.project.setTimelineViewState({

View File

@ -12,13 +12,14 @@ import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale";
import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import { useEditor } from "@/editor/use-editor"; import { useEditor } from "@/editor/use-editor";
import { zoomToSlider } from "@/timeline/zoom-utils"; import { zoomToSlider } from "@/timeline/zoom-utils";
import type { MediaTime } from "@/wasm";
interface UseTimelineZoomProps { interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>;
minZoom?: number; minZoom?: number;
initialZoom?: number; initialZoom?: number;
initialScrollLeft?: number; initialScrollLeft?: number;
initialPlayheadTime?: number; initialPlayheadTime?: MediaTime;
tracksScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>; rulerScrollRef: RefObject<HTMLDivElement | null>;
} }
@ -106,7 +107,7 @@ export function useTimelineZoom({
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
return; return;
} }
setZoomLevel((prev) => { setZoomLevel((prev: number) => {
if (prev < minZoom) { if (prev < minZoom) {
return minZoom; return minZoom;
} }
@ -116,7 +117,7 @@ export function useTimelineZoom({
const wrappedSetZoomLevel = useCallback( const wrappedSetZoomLevel = useCallback(
(zoomLevelOrUpdater: number | ((prev: number) => number)) => { (zoomLevelOrUpdater: number | ((prev: number) => number)) => {
setZoomLevel((prev) => { setZoomLevel((prev: number) => {
const nextZoom = const nextZoom =
typeof zoomLevelOrUpdater === "function" typeof zoomLevelOrUpdater === "function"
? zoomLevelOrUpdater(prev) ? zoomLevelOrUpdater(prev)

View File

@ -1,29 +1,32 @@
import type { SceneTracks } from "./types"; import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import type { SceneTracks } from "./types";
export * from "./types";
export * from "./drag"; export * from "./types";
export * from "./track-capabilities"; export * from "./drag";
export * from "./track-element-update"; export * from "./track-capabilities";
export * from "./element-utils"; export * from "./track-element-update";
export * from "./audio-separation"; export * from "./element-utils";
export * from "./zoom-utils"; export * from "./audio-separation";
export * from "./ruler-utils"; export * from "./zoom-utils";
export * from "./pixel-utils"; export * from "./ruler-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
tracks, export function calculateTotalDuration({
}: { tracks,
tracks: SceneTracks; }: {
}): number { tracks: SceneTracks;
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; }): MediaTime {
if (orderedTracks.length === 0) return 0; const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (orderedTracks.length === 0) return ZERO_MEDIA_TIME;
const trackEndTimes = orderedTracks.map((track) =>
track.elements.reduce((maxEnd, element) => { let maxEnd: MediaTime = ZERO_MEDIA_TIME;
const elementEnd = element.startTime + element.duration; for (const track of orderedTracks) {
return Math.max(maxEnd, elementEnd); for (const element of track.elements) {
}, 0), // `startTime + duration` is integer-by-construction (both `MediaTime`),
); // so the cast re-establishes the brand without runtime work.
const elementEnd = (element.startTime + element.duration) as MediaTime;
return Math.max(...trackEndTimes, 0); if (elementEnd > maxEnd) maxEnd = elementEnd;
} }
}
return maxEnd;
}

View File

@ -14,6 +14,7 @@ import type {
} from "@/timeline"; } from "@/timeline";
import type { Transform } from "@/rendering"; import type { Transform } from "@/rendering";
import { resolveTrackPlacement } from "@/timeline/placement"; import { resolveTrackPlacement } from "@/timeline/placement";
import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
function buildTransform(): Transform { function buildTransform(): Transform {
return { return {
@ -67,10 +68,10 @@ function buildElement({
id, id,
type: "audio", type: "audio",
name: id, name: id,
startTime, startTime: mediaTime({ ticks: startTime }),
duration, duration: mediaTime({ ticks: duration }),
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
volume: 1, volume: 1,
sourceType: "upload", sourceType: "upload",
mediaId: `media-${id}`, mediaId: `media-${id}`,
@ -80,10 +81,10 @@ function buildElement({
id, id,
type: "graphic", type: "graphic",
name: id, name: id,
startTime, startTime: mediaTime({ ticks: startTime }),
duration, duration: mediaTime({ ticks: duration }),
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
definitionId: `graphic-${id}`, definitionId: `graphic-${id}`,
params: {}, params: {},
transform: buildTransform(), transform: buildTransform(),
@ -94,10 +95,10 @@ function buildElement({
id, id,
type: "text", type: "text",
name: id, name: id,
startTime, startTime: mediaTime({ ticks: startTime }),
duration, duration: mediaTime({ ticks: duration }),
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
content: id, content: id,
fontSize: 32, fontSize: 32,
fontFamily: "sans-serif", fontFamily: "sans-serif",
@ -118,10 +119,10 @@ function buildElement({
id, id,
type: "video", type: "video",
name: id, name: id,
startTime, startTime: mediaTime({ ticks: startTime }),
duration, duration: mediaTime({ ticks: duration }),
trimStart: 0, trimStart: ZERO_MEDIA_TIME,
trimEnd: 0, trimEnd: ZERO_MEDIA_TIME,
mediaId: `media-${id}`, mediaId: `media-${id}`,
transform: buildTransform(), transform: buildTransform(),
opacity: 1, opacity: 1,
@ -224,7 +225,11 @@ function buildTimeSpan({
duration: number; duration: number;
excludeElementId?: string; excludeElementId?: string;
}) { }) {
return { startTime, duration, excludeElementId }; return {
startTime: mediaTime({ ticks: startTime }),
duration: mediaTime({ ticks: duration }),
excludeElementId,
};
} }
function buildSceneTracks({ function buildSceneTracks({

View File

@ -1,4 +1,5 @@
import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export const MAIN_TRACK_NAME = "Main Track"; export const MAIN_TRACK_NAME = "Main Track";
@ -31,9 +32,9 @@ export function enforceMainTrackStart({
}: { }: {
tracks: SceneTracks; tracks: SceneTracks;
targetTrackId: string; targetTrackId: string;
requestedStartTime: number; requestedStartTime: MediaTime;
excludeElementId?: string; excludeElementId?: string;
}): number { }): MediaTime {
if (tracks.main.id !== targetTrackId) { if (tracks.main.id !== targetTrackId) {
return requestedStartTime; return requestedStartTime;
} }
@ -43,11 +44,11 @@ export function enforceMainTrackStart({
excludeElementId, excludeElementId,
}); });
if (!earliestElement) { if (!earliestElement) {
return 0; return ZERO_MEDIA_TIME;
} }
if (requestedStartTime <= earliestElement.startTime) { if (requestedStartTime <= earliestElement.startTime) {
return 0; return ZERO_MEDIA_TIME;
} }
return requestedStartTime; return requestedStartTime;

View File

@ -13,6 +13,7 @@ import type {
PlacementSubject, PlacementSubject,
PlacementTimeSpan, PlacementTimeSpan,
} from "./types"; } from "./types";
import { ZERO_MEDIA_TIME } from "@/wasm";
type ResolveTrackPlacementParams = PlacementSubject & { type ResolveTrackPlacementParams = PlacementSubject & {
tracks: SceneTracks; tracks: SceneTracks;
@ -32,7 +33,7 @@ function buildExistingTrackResult({
timeSpans: PlacementTimeSpan[]; timeSpans: PlacementTimeSpan[];
}): PlacementResult { }): PlacementResult {
const firstSpan = timeSpans[0]; const firstSpan = timeSpans[0];
const requestedStartTime = firstSpan?.startTime ?? 0; const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME;
const adjustedStartTime = enforceMainTrackStart({ const adjustedStartTime = enforceMainTrackStart({
tracks, tracks,
targetTrackId: track.id, targetTrackId: track.id,

View File

@ -1,8 +1,9 @@
import type { ElementType, TrackType } from "@/timeline"; import type { ElementType, TrackType } from "@/timeline";
import type { MediaTime } from "@/wasm";
export interface PlacementTimeSpan { export interface PlacementTimeSpan {
startTime: number; startTime: MediaTime;
duration: number; duration: MediaTime;
excludeElementId?: string; excludeElementId?: string;
} }
@ -29,7 +30,7 @@ export type PlacementResult =
trackId: string; trackId: string;
trackIndex: number; trackIndex: number;
trackType: TrackType; trackType: TrackType;
adjustedStartTime?: number; adjustedStartTime?: MediaTime;
} }
| { | {
kind: "newTrack"; kind: "newTrack";

View File

@ -2,6 +2,7 @@ import type { TScene } from "@/timeline";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline"; import { calculateTotalDuration } from "@/timeline";
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track"; import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null; return scenes.find((scene) => scene.isMain) || null;
@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({
scenes, scenes,
}: { }: {
scenes: TScene[]; scenes: TScene[];
}): number { }): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) { if (!mainScene?.tracks) {
return 0; return ZERO_MEDIA_TIME;
} }
return calculateTotalDuration({ tracks: mainScene.tracks }); return calculateTotalDuration({ tracks: mainScene.tracks });

View File

@ -3,6 +3,7 @@ import type { Effect } from "@/effects/types";
import type { Mask } from "@/masks/types"; import type { Mask } from "@/masks/types";
import type { ParamValues } from "@/params"; import type { ParamValues } from "@/params";
import type { BlendMode, Transform } from "@/rendering"; import type { BlendMode, Transform } from "@/rendering";
import type { MediaTime } from "@/wasm";
export type ElementRef = { export type ElementRef = {
trackId: string; trackId: string;
@ -10,10 +11,10 @@ export type ElementRef = {
}; };
export interface Bookmark { export interface Bookmark {
time: number; time: MediaTime;
note?: string; note?: string;
color?: string; color?: string;
duration?: number; duration?: MediaTime;
} }
export interface TScene { export interface TScene {
@ -107,11 +108,11 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement;
interface BaseTimelineElement { interface BaseTimelineElement {
id: string; id: string;
name: string; name: string;
duration: number; duration: MediaTime;
startTime: number; startTime: MediaTime;
trimStart: number; trimStart: MediaTime;
trimEnd: number; trimEnd: MediaTime;
sourceDuration?: number; sourceDuration?: MediaTime;
animations?: ElementAnimations; animations?: ElementAnimations;
} }
@ -273,13 +274,13 @@ export interface ElementDragState {
isDragging: boolean; isDragging: boolean;
elementId: string | null; elementId: string | null;
dragElementIds: string[]; dragElementIds: string[];
dragTimeOffsets: Record<string, number>; dragTimeOffsets: Record<string, MediaTime>;
trackId: string | null; trackId: string | null;
startMouseX: number; startMouseX: number;
startMouseY: number; startMouseY: number;
startElementTime: number; startElementTime: MediaTime;
clickOffsetTime: number; clickOffsetTime: MediaTime;
currentTime: number; currentTime: MediaTime;
currentMouseY: number; currentMouseY: number;
} }
@ -287,7 +288,7 @@ export interface DropTarget {
trackIndex: number; trackIndex: number;
isNewTrack: boolean; isNewTrack: boolean;
insertPosition: "above" | "below" | null; insertPosition: "above" | "below" | null;
xPosition: number; xPosition: MediaTime;
targetElement: { elementId: string; trackId: string } | null; targetElement: { elementId: string; trackId: string } | null;
} }
@ -296,13 +297,13 @@ export interface ComputeDropTargetParams {
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
tracks: SceneTracks; tracks: SceneTracks;
playheadTime: number; playheadTime: MediaTime;
isExternalDrop: boolean; isExternalDrop: boolean;
elementDuration: number; elementDuration: MediaTime;
pixelsPerSecond: number; pixelsPerSecond: number;
zoomLevel: number; zoomLevel: number;
verticalDragDirection?: "up" | "down" | null; verticalDragDirection?: "up" | "down" | null;
startTimeOverride?: number; startTimeOverride?: MediaTime;
excludeElementId?: string; excludeElementId?: string;
targetElementTypes?: string[]; targetElementTypes?: string[];
} }

View File

@ -6,6 +6,7 @@ import {
} from "@/retime"; } from "@/retime";
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
import { isRetimableElement } from "@/timeline"; import { isRetimableElement } from "@/timeline";
import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm";
type ElementUpdateField = keyof TimelineElement | string; type ElementUpdateField = keyof TimelineElement | string;
@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [
0, 0,
sourceDuration - element.trimStart - element.trimEnd, sourceDuration - element.trimStart - element.trimEnd,
); );
const nextDuration = getTimelineDurationForSourceSpan({ const nextDuration = roundMediaTime({
sourceSpan: visibleSourceSpan, time: getTimelineDurationForSourceSpan({
retime: nextRetime, sourceSpan: visibleSourceSpan,
retime: nextRetime,
}),
}); });
return { return {
@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [
{ {
triggers: ["startTime"], triggers: ["startTime"],
apply: ({ element, context }) => { apply: ({ element, context }) => {
const requestedStartTime = Math.max(0, element.startTime); const requestedStartTime =
element.startTime < ZERO_MEDIA_TIME
? ZERO_MEDIA_TIME
: element.startTime;
if (context.trackId !== context.tracks.main.id) { if (context.trackId !== context.tracks.main.id) {
return { return {
element: { element: {
@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [
...element, ...element,
startTime: startTime:
!earliestElement || requestedStartTime <= earliestElement.startTime !earliestElement || requestedStartTime <= earliestElement.startTime
? 0 ? ZERO_MEDIA_TIME
: requestedStartTime, : requestedStartTime,
}, },
}; };

View File

@ -1 +1 @@
export * from "./ticks"; export * from "./media-time";

View File

@ -0,0 +1,129 @@
import {
TICKS_PER_SECOND as _TICKS_PER_SECOND,
mediaTimeFromSeconds as _mediaTimeFromSeconds,
mediaTimeToSeconds as _mediaTimeToSeconds,
} from "opencut-wasm";
/**
* Integer-tick time. Mirrors `MediaTime(i64)` in `rust/crates/time/src/media_time.rs`.
*
* `opencut-wasm` exposes `MediaTime` as a bare `number` alias because tsify
* collapses tuple structs. The brand here is the TS-side discipline that
* recovers the invariant: a `MediaTime` is an integer count of ticks, and the
* only legal way to construct one from a fractional `number` is `roundMediaTime`
* (or `mediaTimeFromSeconds`, which rounds inside the wasm boundary).
*
* Reading is free `MediaTime` is assignable to `number`. Writing is gated
* a bare `number` is not assignable to `MediaTime`.
*/
export type MediaTime = number & { readonly __mediaTime: unique symbol };
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
export const ZERO_MEDIA_TIME = 0 as MediaTime;
/**
* Construct a `MediaTime` from a known-integer tick count. Asserts in dev that
* the value is actually an integer; cast in release. Use `roundMediaTime` when
* the input may be fractional.
*/
export function mediaTime({ ticks }: { ticks: number }): MediaTime {
if (process.env.NODE_ENV !== "production" && !Number.isInteger(ticks)) {
throw new Error(
`mediaTime() requires an integer tick count, got ${ticks}. Use roundMediaTime() for fractional values.`,
);
}
return ticks as MediaTime;
}
/**
* Project a fractional value onto the integer-tick lattice.
*
* Rounds half away from zero (`-1.5 → -2`, `1.5 → 2`) and normalises `-0` to
* `0`. The away-from-zero rule matches Rust's `.round()` and avoids the
* `Math.round(-0.5) === -0` quirk that propagates `-0` into stored data.
*/
export function roundMediaTime({ time }: { time: number }): MediaTime {
const roundedMagnitude = Math.round(Math.abs(time));
if (roundedMagnitude === 0) {
return 0 as MediaTime;
}
return (time < 0 ? -roundedMagnitude : roundedMagnitude) as MediaTime;
}
export function mediaTimeFromSeconds({
seconds,
}: {
seconds: number;
}): MediaTime {
const result = _mediaTimeFromSeconds({ seconds });
if (result === undefined) {
throw new Error(
`mediaTimeFromSeconds: rust returned undefined for seconds=${seconds}`,
);
}
return result as MediaTime;
}
export function mediaTimeToSeconds({ time }: { time: MediaTime }): number {
return _mediaTimeToSeconds({ time });
}
/**
* Sum `MediaTime` values. Inputs are integer ticks, so the sum is integer too;
* the cast is a no-op at runtime, only re-establishes the brand for the type
* system.
*/
export function addMediaTime({
a,
b,
}: {
a: MediaTime;
b: MediaTime;
}): MediaTime {
return (a + b) as MediaTime;
}
export function subMediaTime({
a,
b,
}: {
a: MediaTime;
b: MediaTime;
}): MediaTime {
return (a - b) as MediaTime;
}
export function maxMediaTime({
a,
b,
}: {
a: MediaTime;
b: MediaTime;
}): MediaTime {
return (a > b ? a : b) as MediaTime;
}
export function minMediaTime({
a,
b,
}: {
a: MediaTime;
b: MediaTime;
}): MediaTime {
return (a < b ? a : b) as MediaTime;
}
export function clampMediaTime({
time,
min,
max,
}: {
time: MediaTime;
min: MediaTime;
max: MediaTime;
}): MediaTime {
if (time < min) return min;
if (time > max) return max;
return time;
}

View File

@ -1,3 +0,0 @@
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();