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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,12 @@ import { updateElementInSceneTracks } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
import type { AnimationInterpolation } from "@/animation/types";
import type { SceneTracks } from "@/timeline";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
private readonly elementId: string;
private readonly effectId: string;
private readonly paramKey: string;
private readonly time: number;
private readonly time: MediaTime;
private readonly value: number | string | boolean;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: string;
effectId: string;
paramKey: string;
time: number;
time: MediaTime;
value: number | string | boolean;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
elementId: this.elementId,
elementPredicate: isVisualElement,
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({
effectId: this.effectId,
paramKey: this.paramKey,

View File

@ -8,13 +8,19 @@ import type {
AnimationInterpolation,
AnimationValue,
} from "@/animation/types";
import {
type MediaTime,
maxMediaTime,
minMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
export class UpsertKeyframeCommand extends Command {
private savedState: SceneTracks | null = null;
private readonly trackId: string;
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly time: number;
private readonly time: MediaTime;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@ -31,7 +37,7 @@ export class UpsertKeyframeCommand extends Command {
trackId: string;
elementId: string;
propertyPath: AnimationPath;
time: number;
time: MediaTime;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
@ -63,7 +69,10 @@ export class UpsertKeyframeCommand extends Command {
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 {
...element,
animations: upsertPathKeyframe({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media";
import { InsertElementCommand } from "@/commands/timeline";
import { BatchCommand } from "@/commands";
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
import { TICKS_PER_SECOND } from "@/wasm";
import { mediaTimeFromSeconds } from "@/wasm";
import { isTypableDOMElement } from "@/utils/browser";
import type { MediaType } from "@/media/types";
@ -75,7 +75,7 @@ export function usePasteMedia() {
const assetId = addMediaCmd.getAssetId();
const duration =
asset.duration != null
? Math.round(asset.duration * TICKS_PER_SECOND)
? mediaTimeFromSeconds({ seconds: asset.duration })
: DEFAULT_NEW_ELEMENT_DURATION;
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 { GridPopover } from "./guide-popover";
import { usePreviewStore } from "@/preview/preview-store";
import type { MediaTime } from "@/wasm";
export function PreviewToolbar({
onToggleFullscreen,
@ -67,13 +68,13 @@ function TimecodeDisplay() {
const editor = useEditor();
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
const fps = useEditor((e) => e.project.getActive().settings.fps);
const [currentTime, setCurrentTime] = useState(() =>
const [currentTime, setCurrentTime] = useState<MediaTime>(() =>
editor.playback.getCurrentTime(),
);
useEffect(() => {
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-seek", handler);
return () => {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { mediaTimeToSeconds } from "opencut-wasm";
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
import {
getElementLocalTime,
resolveColorAtTime,
@ -204,7 +204,7 @@ async function resolveVideoNode({
const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId,
file: node.params.file,
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
});
if (!frame) {
return null;
@ -421,7 +421,7 @@ async function resolveBackdropSource({
const frame = await videoCache.getFrameAt({
mediaId: node.params.mediaId,
file: node.params.file,
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
});
if (!frame) {
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 { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 27;
export const CURRENT_PROJECT_VERSION = 28;
export const migrations = [
new V0toV1Migration(),
@ -59,4 +60,5 @@ export const migrations = [
new V24toV25Migration(),
new V25toV26Migration(),
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,
} from "@/services/storage/migrations";
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
import { roundMediaTime } from "@/wasm";
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
if (!Array.isArray(raw)) return [];
return raw
.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>;
if (
typeof obj !== "object" ||
@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
return null;
}
return {
time: obj.time,
time: roundMediaTime({ time: obj.time }),
...(typeof obj.note === "string" && { note: obj.note }),
...(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);
@ -198,9 +203,11 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
duration: roundMediaTime({
time:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({ scenes }),
}),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
},
@ -253,11 +260,13 @@ class StorageService {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
duration:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
}),
duration: roundMediaTime({
time:
serializedProject.metadata.duration ??
getProjectDurationFromScenes({
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
}),
}),
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
});

View File

@ -5,7 +5,7 @@ import {
setCanvasLetterSpacing,
} from "@/text/layout";
import { DEFAULTS } from "@/timeline/defaults";
import { TICKS_PER_SECOND } from "@/wasm";
import { mediaTimeFromSeconds } from "@/wasm";
import type { CreateTextElement } from "@/timeline";
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
@ -310,8 +310,8 @@ export function buildSubtitleTextElement({
...DEFAULTS.text.element,
name: `Caption ${index + 1}`,
content,
duration: Math.round(caption.duration * TICKS_PER_SECOND),
startTime: Math.round(caption.startTime * TICKS_PER_SECOND),
duration: mediaTimeFromSeconds({ seconds: caption.duration }),
startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
fontSize: style.fontSize,
fontFamily: style.fontFamily,
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 { DEFAULTS } from "@/timeline/defaults";
import { buildTextElement } from "@/timeline/element-utils";
import type { MediaTime } from "@/wasm";
export function TextView() {
const editor = useEditor();
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
const activeScene = editor.scenes.getActiveScene();
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 { clamp, formatNumberForDisplay } from "@/utils/math";
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
import {
type MediaTime,
mediaTimeFromSeconds,
mediaTimeToSeconds,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
const MIN_BOOKMARK_WIDTH_PX = 2;
const BOOKMARK_MARKER_WIDTH_PX = 12;
@ -39,12 +46,12 @@ function seekToBookmarkTime({
time,
}: {
editor: EditorCore;
time: number;
time: MediaTime;
}) {
const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
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 });
}
@ -137,7 +144,7 @@ function TimelineBookmark({
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
const time = bookmark.time;
const bookmarkDuration = bookmark.duration ?? 0;
const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME;
const durationWidth =
bookmarkDuration > 0
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
@ -182,7 +189,7 @@ function TimelineBookmark({
left: `${bookmarkLeft}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"
onMouseDown={handleMouseDown}
onClick={handleClick}
@ -286,8 +293,8 @@ function BookmarkPopoverContent({
onPopoverClose,
}: {
bookmark: Bookmark;
time: number;
timelineDuration: number;
time: MediaTime;
timelineDuration: MediaTime;
onPopoverClose: () => void;
}) {
const editor = useEditor();
@ -314,9 +321,8 @@ function BookmarkPopoverContent({
note,
color,
duration,
}: Partial<{ note: string; color: string; duration: number }>) => {
const updates: Partial<{ note: string; color: string; duration: number }> =
{};
}: Partial<Omit<Bookmark, "time">>) => {
const updates: Partial<Omit<Bookmark, "time">> = {};
if (note !== undefined && note !== bookmark.note) updates.note = note;
if (
color !== undefined &&
@ -330,6 +336,15 @@ function BookmarkPopoverContent({
if (Object.keys(updates).length === 0) return;
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 (
<>
@ -387,7 +402,7 @@ function BookmarkPopoverContent({
type="number"
min={0}
step={0.1}
value={bookmark.duration ?? 0}
value={durationSeconds}
onChange={(event) => {
const parsed = parseFloat(event.target.value);
const value = Number.isNaN(parsed)
@ -395,9 +410,11 @@ function BookmarkPopoverContent({
: clamp({
value: parsed,
min: 0,
max: Math.max(0, timelineDuration - time),
max: maxDuration,
});
handleUpdate({ duration: value });
handleUpdate({
duration: mediaTimeFromSeconds({ seconds: value }),
});
}}
className="h-8 text-sm"
containerClassName="w-full"

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ import {
useState,
type ReactNode,
} from "react";
import type { MediaTime } from "@/wasm";
import type { ElementDragState, DropTarget } from "@/timeline";
import { TimelineTrackContent } from "./timeline-track";
import { TimelinePlayhead } from "./timeline-playhead";
@ -132,7 +133,7 @@ export function Timeline() {
[scene],
);
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 timelineHeaderRef = useRef<HTMLDivElement>(null);

View File

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

View File

@ -7,7 +7,14 @@ import {
timelineTimeToSnappedPixels,
} from "@/timeline";
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 { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
import { TIMELINE_LAYERS } from "./layers";
@ -72,17 +79,24 @@ export function TimelinePlayhead({
event.preventDefault();
const fps = editor.project.getActive().settings.fps;
const ticksPerFrame = Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
);
const ticksPerFrame = mediaTime({
ticks: Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
),
});
const direction = event.key === "ArrowRight" ? 1 : -1;
const now = editor.playback.getCurrentTime();
const nextTime = Math.max(
0,
Math.min(duration, now + direction * ticksPerFrame),
);
const nextTime =
direction > 0
? 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 (

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -139,7 +139,7 @@ export function useTimelineResize({
updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
updates: patch,
updates: patch as Partial<TimelineElement>,
})),
});
};
@ -155,7 +155,7 @@ export function useTimelineResize({
updates: result.updates.map(({ trackId, elementId, patch }) => ({
trackId,
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 { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
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";
interface UseTimelineSeekProps {
@ -11,10 +11,10 @@ interface UseTimelineSeekProps {
rulerScrollRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
zoomLevel: number;
duration: number;
duration: MediaTime;
isSelecting: boolean;
clearSelectedElements: () => void;
seek: (time: number) => void;
seek: (time: MediaTime) => void;
}
function resetMouseTracking({
@ -135,11 +135,13 @@ export function useTimelineSeek({
(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 time = rate
? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime)
? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime)
: rawTime;
seek(time);
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 { useEditor } from "@/editor/use-editor";
import { zoomToSlider } from "@/timeline/zoom-utils";
import type { MediaTime } from "@/wasm";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>;
minZoom?: number;
initialZoom?: number;
initialScrollLeft?: number;
initialPlayheadTime?: number;
initialPlayheadTime?: MediaTime;
tracksScrollRef: RefObject<HTMLDivElement | null>;
rulerScrollRef: RefObject<HTMLDivElement | null>;
}
@ -106,7 +107,7 @@ export function useTimelineZoom({
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
return;
}
setZoomLevel((prev) => {
setZoomLevel((prev: number) => {
if (prev < minZoom) {
return minZoom;
}
@ -116,7 +117,7 @@ export function useTimelineZoom({
const wrappedSetZoomLevel = useCallback(
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
setZoomLevel((prev) => {
setZoomLevel((prev: number) => {
const nextZoom =
typeof zoomLevelOrUpdater === "function"
? zoomLevelOrUpdater(prev)

View File

@ -1,29 +1,32 @@
import type { SceneTracks } from "./types";
export * from "./types";
export * from "./drag";
export * from "./track-capabilities";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
tracks,
}: {
tracks: SceneTracks;
}): number {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (orderedTracks.length === 0) return 0;
const trackEndTimes = orderedTracks.map((track) =>
track.elements.reduce((maxEnd, element) => {
const elementEnd = element.startTime + element.duration;
return Math.max(maxEnd, elementEnd);
}, 0),
);
return Math.max(...trackEndTimes, 0);
}
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
import type { SceneTracks } from "./types";
export * from "./types";
export * from "./drag";
export * from "./track-capabilities";
export * from "./track-element-update";
export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
tracks,
}: {
tracks: SceneTracks;
}): MediaTime {
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
if (orderedTracks.length === 0) return ZERO_MEDIA_TIME;
let maxEnd: MediaTime = ZERO_MEDIA_TIME;
for (const track of orderedTracks) {
for (const element of track.elements) {
// `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;
if (elementEnd > maxEnd) maxEnd = elementEnd;
}
}
return maxEnd;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import type { TScene } from "@/timeline";
import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline";
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 {
return scenes.find((scene) => scene.isMain) || null;
@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
}): number {
}): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) {
return 0;
return ZERO_MEDIA_TIME;
}
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 { ParamValues } from "@/params";
import type { BlendMode, Transform } from "@/rendering";
import type { MediaTime } from "@/wasm";
export type ElementRef = {
trackId: string;
@ -10,10 +11,10 @@ export type ElementRef = {
};
export interface Bookmark {
time: number;
time: MediaTime;
note?: string;
color?: string;
duration?: number;
duration?: MediaTime;
}
export interface TScene {
@ -107,11 +108,11 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement;
interface BaseTimelineElement {
id: string;
name: string;
duration: number;
startTime: number;
trimStart: number;
trimEnd: number;
sourceDuration?: number;
duration: MediaTime;
startTime: MediaTime;
trimStart: MediaTime;
trimEnd: MediaTime;
sourceDuration?: MediaTime;
animations?: ElementAnimations;
}
@ -273,13 +274,13 @@ export interface ElementDragState {
isDragging: boolean;
elementId: string | null;
dragElementIds: string[];
dragTimeOffsets: Record<string, number>;
dragTimeOffsets: Record<string, MediaTime>;
trackId: string | null;
startMouseX: number;
startMouseY: number;
startElementTime: number;
clickOffsetTime: number;
currentTime: number;
startElementTime: MediaTime;
clickOffsetTime: MediaTime;
currentTime: MediaTime;
currentMouseY: number;
}
@ -287,7 +288,7 @@ export interface DropTarget {
trackIndex: number;
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
xPosition: number;
xPosition: MediaTime;
targetElement: { elementId: string; trackId: string } | null;
}
@ -296,13 +297,13 @@ export interface ComputeDropTargetParams {
mouseX: number;
mouseY: number;
tracks: SceneTracks;
playheadTime: number;
playheadTime: MediaTime;
isExternalDrop: boolean;
elementDuration: number;
elementDuration: MediaTime;
pixelsPerSecond: number;
zoomLevel: number;
verticalDragDirection?: "up" | "down" | null;
startTimeOverride?: number;
startTimeOverride?: MediaTime;
excludeElementId?: string;
targetElementTypes?: string[];
}

View File

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