fix: model editor time as wasm MediaTime ticks end-to-end
This commit is contained in:
parent
f1b45b4328
commit
eea6d43c82
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: ({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export * from "./ticks";
|
||||
export * from "./media-time";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
|
||||
|
||||
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
|
||||
Loading…
Reference in New Issue