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 { useActionHandler } from "@/actions/use-action-handler";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
mediaTime,
|
||||||
|
mediaTimeFromSeconds,
|
||||||
|
minMediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
|
||||||
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
import { getElementsAtTime, hasMediaId } from "@/timeline";
|
||||||
import { cancelInteraction } from "@/editor/cancel-interaction";
|
import { cancelInteraction } from "@/editor/cancel-interaction";
|
||||||
|
|
@ -83,7 +92,7 @@ export function useEditorActions() {
|
||||||
if (editor.playback.getIsPlaying()) {
|
if (editor.playback.getIsPlaying()) {
|
||||||
editor.playback.toggle();
|
editor.playback.toggle();
|
||||||
}
|
}
|
||||||
editor.playback.seek({ time: 0 });
|
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -92,11 +101,15 @@ export function useEditorActions() {
|
||||||
"seek-forward",
|
"seek-forward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 1;
|
const seconds = args?.seconds ?? 1;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: minMediaTime({
|
||||||
editor.timeline.getTotalDuration(),
|
a: editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + seconds,
|
b: addMediaTime({
|
||||||
),
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -106,8 +119,15 @@ export function useEditorActions() {
|
||||||
"seek-backward",
|
"seek-backward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 1;
|
const seconds = args?.seconds ?? 1;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -117,15 +137,20 @@ export function useEditorActions() {
|
||||||
"frame-step-forward",
|
"frame-step-forward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
const ticksPerFrame = Math.round(
|
const ticksPerFrame = mediaTime({
|
||||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
ticks: Math.round(
|
||||||
);
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
editor.playback.seek({
|
|
||||||
time: Math.min(
|
|
||||||
editor.timeline.getTotalDuration(),
|
|
||||||
editor.playback.getCurrentTime() + ticksPerFrame,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
editor.playback.seek({
|
||||||
|
time: minMediaTime({
|
||||||
|
a: editor.timeline.getTotalDuration(),
|
||||||
|
b: addMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: ticksPerFrame,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -134,11 +159,19 @@ export function useEditorActions() {
|
||||||
"frame-step-backward",
|
"frame-step-backward",
|
||||||
() => {
|
() => {
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
const ticksPerFrame = Math.round(
|
const ticksPerFrame = mediaTime({
|
||||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
ticks: Math.round(
|
||||||
);
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
|
),
|
||||||
|
});
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame),
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: ticksPerFrame,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -148,11 +181,15 @@ export function useEditorActions() {
|
||||||
"jump-forward",
|
"jump-forward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 5;
|
const seconds = args?.seconds ?? 5;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.min(
|
time: minMediaTime({
|
||||||
editor.timeline.getTotalDuration(),
|
a: editor.timeline.getTotalDuration(),
|
||||||
editor.playback.getCurrentTime() + seconds,
|
b: addMediaTime({
|
||||||
),
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -162,8 +199,15 @@ export function useEditorActions() {
|
||||||
"jump-backward",
|
"jump-backward",
|
||||||
(args) => {
|
(args) => {
|
||||||
const seconds = args?.seconds ?? 5;
|
const seconds = args?.seconds ?? 5;
|
||||||
|
const delta = mediaTimeFromSeconds({ seconds });
|
||||||
editor.playback.seek({
|
editor.playback.seek({
|
||||||
time: Math.max(0, editor.playback.getCurrentTime() - seconds),
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({
|
||||||
|
a: editor.playback.getCurrentTime(),
|
||||||
|
b: delta,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -172,7 +216,7 @@ export function useEditorActions() {
|
||||||
useActionHandler(
|
useActionHandler(
|
||||||
"goto-start",
|
"goto-start",
|
||||||
() => {
|
() => {
|
||||||
editor.playback.seek({ time: 0 });
|
editor.playback.seek({ time: ZERO_MEDIA_TIME });
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
NormalizedCubicBezier,
|
NormalizedCubicBezier,
|
||||||
ScalarAnimationKey,
|
ScalarAnimationKey,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import { roundMediaTime } from "@/wasm";
|
||||||
|
|
||||||
const VALUE_EPSILON = 1e-6;
|
const VALUE_EPSILON = 1e-6;
|
||||||
|
|
||||||
|
|
@ -84,11 +85,11 @@ export function getCurveHandlesForNormalizedCubicBezier({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rightHandle: {
|
rightHandle: {
|
||||||
dt: spanTime * x1,
|
dt: roundMediaTime({ time: spanTime * x1 }),
|
||||||
dv: effectiveSpanValue * y1,
|
dv: effectiveSpanValue * y1,
|
||||||
},
|
},
|
||||||
leftHandle: {
|
leftHandle: {
|
||||||
dt: spanTime * (x2 - 1),
|
dt: roundMediaTime({ time: spanTime * (x2 - 1) }),
|
||||||
dv: effectiveSpanValue * (y2 - 1),
|
dv: effectiveSpanValue * (y2 - 1),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
ScalarSegmentType,
|
ScalarSegmentType,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import { clamp } from "@/utils/math";
|
import { clamp } from "@/utils/math";
|
||||||
|
import { mediaTime } from "@/wasm";
|
||||||
import {
|
import {
|
||||||
getBezierPoint,
|
getBezierPoint,
|
||||||
getDefaultLeftHandle,
|
getDefaultLeftHandle,
|
||||||
|
|
@ -63,9 +64,13 @@ function normalizeRightHandle({
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
const span = mediaTime({
|
||||||
|
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
dt: Math.min(span, Math.max(0, handle.dt)),
|
dt: mediaTime({
|
||||||
|
ticks: Math.min(span, Math.max(0, handle.dt)),
|
||||||
|
}),
|
||||||
dv: handle.dv,
|
dv: handle.dv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -83,9 +88,13 @@ function normalizeLeftHandle({
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const span = Math.max(1, rightKey.time - leftKey.time);
|
const span = mediaTime({
|
||||||
|
ticks: Math.max(1, rightKey.time - leftKey.time),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
dt: Math.max(-span, Math.min(0, handle.dt)),
|
dt: mediaTime({
|
||||||
|
ticks: Math.max(-span, Math.min(0, handle.dt)),
|
||||||
|
}),
|
||||||
dv: handle.dv,
|
dv: handle.dv,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ import {
|
||||||
coerceAnimationValueForProperty,
|
coerceAnimationValueForProperty,
|
||||||
getAnimationPropertyDefinition,
|
getAnimationPropertyDefinition,
|
||||||
} from "./property-registry";
|
} from "./property-registry";
|
||||||
|
import {
|
||||||
|
type MediaTime,
|
||||||
|
roundMediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
function isNearlySameTime({
|
function isNearlySameTime({
|
||||||
leftTime,
|
leftTime,
|
||||||
|
|
@ -171,7 +177,7 @@ function createScalarKey({
|
||||||
previousKey,
|
previousKey,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
previousKey?: ScalarAnimationKey;
|
previousKey?: ScalarAnimationKey;
|
||||||
|
|
@ -195,7 +201,7 @@ function createDiscreteKey({
|
||||||
value,
|
value,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: string | boolean;
|
value: string | boolean;
|
||||||
}): DiscreteAnimationKey {
|
}): DiscreteAnimationKey {
|
||||||
return {
|
return {
|
||||||
|
|
@ -241,7 +247,7 @@ function getTargetKeyMetadata({
|
||||||
keyframeId,
|
keyframeId,
|
||||||
}: {
|
}: {
|
||||||
channel: AnimationChannel | undefined;
|
channel: AnimationChannel | undefined;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
}) {
|
}) {
|
||||||
const normalizedChannel =
|
const normalizedChannel =
|
||||||
|
|
@ -280,7 +286,7 @@ function upsertDiscreteChannelKey({
|
||||||
keyframeId,
|
keyframeId,
|
||||||
}: {
|
}: {
|
||||||
channel: DiscreteAnimationChannel | undefined;
|
channel: DiscreteAnimationChannel | undefined;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: string | boolean;
|
value: string | boolean;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
}): DiscreteAnimationChannel {
|
}): DiscreteAnimationChannel {
|
||||||
|
|
@ -337,7 +343,7 @@ function upsertScalarChannelKey({
|
||||||
keyframeId,
|
keyframeId,
|
||||||
}: {
|
}: {
|
||||||
channel: ScalarAnimationChannel | undefined;
|
channel: ScalarAnimationChannel | undefined;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
defaultInterpolation?: AnimationInterpolation;
|
defaultInterpolation?: AnimationInterpolation;
|
||||||
|
|
@ -442,7 +448,7 @@ export function upsertPathKeyframe({
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
@ -537,7 +543,7 @@ export function upsertElementKeyframe({
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
@ -576,7 +582,7 @@ export function upsertKeyframe({
|
||||||
keyframeId,
|
keyframeId,
|
||||||
}: {
|
}: {
|
||||||
channel: AnimationChannel | undefined;
|
channel: AnimationChannel | undefined;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
@ -642,7 +648,7 @@ export function retimeKeyframe({
|
||||||
}: {
|
}: {
|
||||||
channel: AnimationChannel | undefined;
|
channel: AnimationChannel | undefined;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}): AnimationChannel | undefined {
|
}): AnimationChannel | undefined {
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -887,7 +893,7 @@ export function clampAnimationsToDuration({
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
if (!animations || duration <= 0) {
|
if (!animations || duration <= 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -923,7 +929,7 @@ function splitDiscreteChannelAtTime({
|
||||||
shouldIncludeSplitBoundary,
|
shouldIncludeSplitBoundary,
|
||||||
}: {
|
}: {
|
||||||
channel: DiscreteAnimationChannel | undefined;
|
channel: DiscreteAnimationChannel | undefined;
|
||||||
splitTime: number;
|
splitTime: MediaTime;
|
||||||
leftBoundaryId: string;
|
leftBoundaryId: string;
|
||||||
rightBoundaryId: string;
|
rightBoundaryId: string;
|
||||||
shouldIncludeSplitBoundary: boolean;
|
shouldIncludeSplitBoundary: boolean;
|
||||||
|
|
@ -939,7 +945,10 @@ function splitDiscreteChannelAtTime({
|
||||||
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
||||||
let rightKeys = normalizedChannel.keys
|
let rightKeys = normalizedChannel.keys
|
||||||
.filter((key) => key.time >= splitTime)
|
.filter((key) => key.time >= splitTime)
|
||||||
.map((key) => ({ ...key, time: key.time - splitTime }));
|
.map((key) => ({
|
||||||
|
...key,
|
||||||
|
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||||
|
}));
|
||||||
|
|
||||||
if (shouldIncludeSplitBoundary) {
|
if (shouldIncludeSplitBoundary) {
|
||||||
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
||||||
|
|
@ -967,7 +976,7 @@ function splitDiscreteChannelAtTime({
|
||||||
rightKeys = [
|
rightKeys = [
|
||||||
createDiscreteKey({
|
createDiscreteKey({
|
||||||
id: rightBoundaryId,
|
id: rightBoundaryId,
|
||||||
time: 0,
|
time: ZERO_MEDIA_TIME,
|
||||||
value: boundaryValue as string | boolean,
|
value: boundaryValue as string | boolean,
|
||||||
}),
|
}),
|
||||||
...rightKeys,
|
...rightKeys,
|
||||||
|
|
@ -993,7 +1002,7 @@ function splitScalarChannelAtTime({
|
||||||
shouldIncludeSplitBoundary,
|
shouldIncludeSplitBoundary,
|
||||||
}: {
|
}: {
|
||||||
channel: ScalarAnimationChannel | undefined;
|
channel: ScalarAnimationChannel | undefined;
|
||||||
splitTime: number;
|
splitTime: MediaTime;
|
||||||
leftBoundaryId: string;
|
leftBoundaryId: string;
|
||||||
rightBoundaryId: string;
|
rightBoundaryId: string;
|
||||||
shouldIncludeSplitBoundary: boolean;
|
shouldIncludeSplitBoundary: boolean;
|
||||||
|
|
@ -1009,7 +1018,10 @@ function splitScalarChannelAtTime({
|
||||||
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
|
||||||
let rightKeys = normalizedChannel.keys
|
let rightKeys = normalizedChannel.keys
|
||||||
.filter((key) => key.time >= splitTime)
|
.filter((key) => key.time >= splitTime)
|
||||||
.map((key) => ({ ...key, time: key.time - splitTime }));
|
.map((key) => ({
|
||||||
|
...key,
|
||||||
|
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||||
|
}));
|
||||||
|
|
||||||
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
const hasBoundaryOnLeft = leftKeys.some((key) =>
|
||||||
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
|
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
|
||||||
|
|
@ -1089,7 +1101,7 @@ function splitScalarChannelAtTime({
|
||||||
{
|
{
|
||||||
...leftKey,
|
...leftKey,
|
||||||
rightHandle: {
|
rightHandle: {
|
||||||
dt: q0.x - p0.x,
|
dt: roundMediaTime({ time: q0.x - p0.x }),
|
||||||
dv: q0.y - p0.y,
|
dv: q0.y - p0.y,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1098,7 +1110,7 @@ function splitScalarChannelAtTime({
|
||||||
time: splitTime,
|
time: splitTime,
|
||||||
value: boundaryValue,
|
value: boundaryValue,
|
||||||
leftHandle: {
|
leftHandle: {
|
||||||
dt: r0.x - splitPoint.x,
|
dt: roundMediaTime({ time: r0.x - splitPoint.x }),
|
||||||
dv: r0.y - splitPoint.y,
|
dv: r0.y - splitPoint.y,
|
||||||
},
|
},
|
||||||
segmentToNext: leftKey.segmentToNext,
|
segmentToNext: leftKey.segmentToNext,
|
||||||
|
|
@ -1108,10 +1120,10 @@ function splitScalarChannelAtTime({
|
||||||
rightKeys = [
|
rightKeys = [
|
||||||
{
|
{
|
||||||
id: rightBoundaryId,
|
id: rightBoundaryId,
|
||||||
time: 0,
|
time: ZERO_MEDIA_TIME,
|
||||||
value: boundaryValue,
|
value: boundaryValue,
|
||||||
rightHandle: {
|
rightHandle: {
|
||||||
dt: r1.x - splitPoint.x,
|
dt: roundMediaTime({ time: r1.x - splitPoint.x }),
|
||||||
dv: r1.y - splitPoint.y,
|
dv: r1.y - splitPoint.y,
|
||||||
},
|
},
|
||||||
segmentToNext: "bezier",
|
segmentToNext: "bezier",
|
||||||
|
|
@ -1119,9 +1131,9 @@ function splitScalarChannelAtTime({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...rightKey,
|
...rightKey,
|
||||||
time: rightKey.time - splitTime,
|
time: subMediaTime({ a: rightKey.time, b: splitTime }),
|
||||||
leftHandle: {
|
leftHandle: {
|
||||||
dt: q2.x - p3.x,
|
dt: roundMediaTime({ time: q2.x - p3.x }),
|
||||||
dv: q2.y - p3.y,
|
dv: q2.y - p3.y,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1129,7 +1141,7 @@ function splitScalarChannelAtTime({
|
||||||
.filter((key) => key.time > rightKey.time)
|
.filter((key) => key.time > rightKey.time)
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
...key,
|
...key,
|
||||||
time: key.time - splitTime,
|
time: subMediaTime({ a: key.time, b: splitTime }),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1145,7 +1157,7 @@ function splitScalarChannelAtTime({
|
||||||
rightKeys = [
|
rightKeys = [
|
||||||
createScalarKey({
|
createScalarKey({
|
||||||
id: rightBoundaryId,
|
id: rightBoundaryId,
|
||||||
time: 0,
|
time: ZERO_MEDIA_TIME,
|
||||||
value: boundaryValue,
|
value: boundaryValue,
|
||||||
interpolation: getScalarSegmentInterpolation({
|
interpolation: getScalarSegmentInterpolation({
|
||||||
segment: leftKey.segmentToNext,
|
segment: leftKey.segmentToNext,
|
||||||
|
|
@ -1201,7 +1213,7 @@ export function splitAnimationsAtTime({
|
||||||
shouldIncludeSplitBoundary = true,
|
shouldIncludeSplitBoundary = true,
|
||||||
}: {
|
}: {
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
splitTime: number;
|
splitTime: MediaTime;
|
||||||
shouldIncludeSplitBoundary?: boolean;
|
shouldIncludeSplitBoundary?: boolean;
|
||||||
}): {
|
}): {
|
||||||
leftAnimations: ElementAnimations | undefined;
|
leftAnimations: ElementAnimations | undefined;
|
||||||
|
|
@ -1317,7 +1329,7 @@ export function retimeElementKeyframe({
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}): ElementAnimations | undefined {
|
}): ElementAnimations | undefined {
|
||||||
const binding = getBinding({ animations, propertyPath });
|
const binding = getBinding({ animations, propertyPath });
|
||||||
if (!binding) {
|
if (!binding) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export const ANIMATION_PROPERTY_PATHS = [
|
export const ANIMATION_PROPERTY_PATHS = [
|
||||||
"transform.positionX",
|
"transform.positionX",
|
||||||
|
|
@ -77,13 +78,13 @@ export type TangentMode = "auto" | "aligned" | "broken" | "flat";
|
||||||
export type ChannelExtrapolationMode = "hold" | "linear";
|
export type ChannelExtrapolationMode = "hold" | "linear";
|
||||||
|
|
||||||
export interface CurveHandle {
|
export interface CurveHandle {
|
||||||
dt: number;
|
dt: MediaTime;
|
||||||
dv: number;
|
dv: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
|
||||||
id: string;
|
id: string;
|
||||||
time: number; // relative to element start time
|
time: MediaTime; // relative to element start time
|
||||||
value: TValue;
|
value: TValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +210,7 @@ export interface ScalarCurveKeyframePatch {
|
||||||
export interface ElementKeyframe {
|
export interface ElementKeyframe {
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
id: string;
|
id: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation: AnimationInterpolation;
|
interpolation: AnimationInterpolation;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
KeyframeClipboardCurvePatch,
|
KeyframeClipboardCurvePatch,
|
||||||
KeyframeClipboardItem,
|
KeyframeClipboardItem,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { roundMediaTime, subMediaTime, type MediaTime } from "@/wasm";
|
||||||
|
|
||||||
function resolveSingleSourceElement({
|
function resolveSingleSourceElement({
|
||||||
selectedKeyframes,
|
selectedKeyframes,
|
||||||
|
|
@ -80,7 +81,7 @@ function buildClipboardItem({
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
propertyPath: KeyframeClipboardItem["propertyPath"];
|
propertyPath: KeyframeClipboardItem["propertyPath"];
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: number }) | null {
|
}): (Omit<KeyframeClipboardItem, "timeOffset"> & { time: MediaTime }) | null {
|
||||||
const keyframe = getKeyframeById({
|
const keyframe = getKeyframeById({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
propertyPath,
|
propertyPath,
|
||||||
|
|
@ -136,10 +137,11 @@ export const KeyframesClipboardHandler = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const minTime = Math.min(...rawItems.map((item) => item.time));
|
const minTime = Math.min(...rawItems.map((item) => item.time));
|
||||||
|
const minTimeMedia = roundMediaTime({ time: minTime });
|
||||||
const items = rawItems
|
const items = rawItems
|
||||||
.map(({ time, ...item }) => ({
|
.map(({ time, ...item }) => ({
|
||||||
...item,
|
...item,
|
||||||
timeOffset: time - minTime,
|
timeOffset: subMediaTime({ a: time, b: minTimeMedia }),
|
||||||
}))
|
}))
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
ElementRef,
|
ElementRef,
|
||||||
TrackType,
|
TrackType,
|
||||||
} from "@/timeline";
|
} from "@/timeline";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export interface ElementClipboardItem {
|
export interface ElementClipboardItem {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
|
|
@ -26,7 +27,7 @@ export interface KeyframeClipboardCurvePatch {
|
||||||
|
|
||||||
export interface KeyframeClipboardItem {
|
export interface KeyframeClipboardItem {
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
timeOffset: number;
|
timeOffset: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation: AnimationInterpolation;
|
interpolation: AnimationInterpolation;
|
||||||
curvePatches: KeyframeClipboardCurvePatch[];
|
curvePatches: KeyframeClipboardCurvePatch[];
|
||||||
|
|
@ -61,7 +62,7 @@ export interface PasteContext {
|
||||||
editor: EditorCore;
|
editor: EditorCore;
|
||||||
selectedElements: ElementRef[];
|
selectedElements: ElementRef[];
|
||||||
selectedKeyframes: SelectedKeyframeRef[];
|
selectedKeyframes: SelectedKeyframeRef[];
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
export interface ClipboardHandler<TType extends ClipboardEntryType> {
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ import { EditorCore } from "@/core";
|
||||||
import type { TScene } from "@/timeline";
|
import type { TScene } from "@/timeline";
|
||||||
import { updateSceneInArray } from "@/timeline/scenes";
|
import { updateSceneInArray } from "@/timeline/scenes";
|
||||||
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
|
import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export class MoveBookmarkCommand extends Command {
|
export class MoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fromTime: number,
|
private fromTime: MediaTime,
|
||||||
private toTime: number,
|
private toTime: MediaTime,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
removeBookmarkFromArray,
|
removeBookmarkFromArray,
|
||||||
} from "@/timeline/bookmarks/index";
|
} from "@/timeline/bookmarks/index";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export class RemoveBookmarkCommand extends Command {
|
export class RemoveBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: number = 0;
|
private frameTime: MediaTime = 0 as MediaTime;
|
||||||
|
|
||||||
constructor(private time: number) {
|
constructor(private time: MediaTime) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
toggleBookmarkInArray,
|
toggleBookmarkInArray,
|
||||||
} from "@/timeline/bookmarks/index";
|
} from "@/timeline/bookmarks/index";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export class ToggleBookmarkCommand extends Command {
|
export class ToggleBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
private frameTime: number = 0;
|
private frameTime: MediaTime = 0 as MediaTime;
|
||||||
|
|
||||||
constructor(private time: number) {
|
constructor(private time: MediaTime) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ import {
|
||||||
getFrameTime,
|
getFrameTime,
|
||||||
updateBookmarkInArray,
|
updateBookmarkInArray,
|
||||||
} from "@/timeline/bookmarks/index";
|
} from "@/timeline/bookmarks/index";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export class UpdateBookmarkCommand extends Command {
|
export class UpdateBookmarkCommand extends Command {
|
||||||
private savedScenes: TScene[] | null = null;
|
private savedScenes: TScene[] | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private time: number,
|
private time: MediaTime,
|
||||||
private updates: Partial<Omit<Bookmark, "time">>,
|
private updates: Partial<Omit<Bookmark, "time">>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ import type { KeyframeClipboardItem } from "@/clipboard";
|
||||||
import type { SceneTracks, TimelineElement } from "@/timeline";
|
import type { SceneTracks, TimelineElement } from "@/timeline";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
function pasteKeyframesIntoElement({
|
function pasteKeyframesIntoElement({
|
||||||
element,
|
element,
|
||||||
|
|
@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({
|
||||||
clipboardItems,
|
clipboardItems,
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
clipboardItems: KeyframeClipboardItem[];
|
clipboardItems: KeyframeClipboardItem[];
|
||||||
}): TimelineElement {
|
}): TimelineElement {
|
||||||
let nextElement = element;
|
let nextElement = element;
|
||||||
|
|
@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyframeTime = Math.max(
|
const keyframeTime = maxMediaTime({
|
||||||
0,
|
a: ZERO_MEDIA_TIME,
|
||||||
Math.min(time + item.timeOffset, nextElement.duration),
|
b: minMediaTime({
|
||||||
);
|
a: addMediaTime({ a: time, b: item.timeOffset }),
|
||||||
|
b: nextElement.duration,
|
||||||
|
}),
|
||||||
|
});
|
||||||
const nextAnimations = upsertPathKeyframe({
|
const nextAnimations = upsertPathKeyframe({
|
||||||
animations: nextElement.animations,
|
animations: nextElement.animations,
|
||||||
propertyPath: item.propertyPath,
|
propertyPath: item.propertyPath,
|
||||||
|
|
@ -79,7 +89,7 @@ export class PasteKeyframesCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly time: number;
|
private readonly time: MediaTime;
|
||||||
private readonly clipboardItems: KeyframeClipboardItem[];
|
private readonly clipboardItems: KeyframeClipboardItem[];
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
|
|
@ -90,7 +100,7 @@ export class PasteKeyframesCommand extends Command {
|
||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
clipboardItems: KeyframeClipboardItem[];
|
clipboardItems: KeyframeClipboardItem[];
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,25 @@ import {
|
||||||
enforceMainTrackStart,
|
enforceMainTrackStart,
|
||||||
} from "@/timeline/placement";
|
} from "@/timeline/placement";
|
||||||
import { cloneAnimations } from "@/animation";
|
import { cloneAnimations } from "@/animation";
|
||||||
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
export class PasteCommand extends Command {
|
export class PasteCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private pastedElements: { trackId: string; elementId: string }[] = [];
|
private pastedElements: { trackId: string; elementId: string }[] = [];
|
||||||
private readonly time: number;
|
private readonly time: MediaTime;
|
||||||
private readonly clipboardItems: ElementClipboardItem[];
|
private readonly clipboardItems: ElementClipboardItem[];
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
time,
|
time,
|
||||||
clipboardItems,
|
clipboardItems,
|
||||||
}: {
|
}: {
|
||||||
time: number;
|
time: MediaTime;
|
||||||
clipboardItems: ElementClipboardItem[];
|
clipboardItems: ElementClipboardItem[];
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -39,8 +46,12 @@ export class PasteCommand extends Command {
|
||||||
this.savedState = editor.scenes.getActiveScene().tracks;
|
this.savedState = editor.scenes.getActiveScene().tracks;
|
||||||
this.pastedElements = [];
|
this.pastedElements = [];
|
||||||
|
|
||||||
const minStart = Math.min(
|
const minStart = this.clipboardItems.reduce(
|
||||||
...this.clipboardItems.map((item) => item.element.startTime),
|
(earliestStartTime, item) =>
|
||||||
|
item.element.startTime < earliestStartTime
|
||||||
|
? item.element.startTime
|
||||||
|
: earliestStartTime,
|
||||||
|
this.clipboardItems[0].element.startTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
let updatedTracks = this.savedState;
|
let updatedTracks = this.savedState;
|
||||||
|
|
@ -97,12 +108,18 @@ export class PasteCommand extends Command {
|
||||||
targetTrackId: targetTrack.id,
|
targetTrackId: targetTrack.id,
|
||||||
requestedStartTime: earliestElement.startTime,
|
requestedStartTime: earliestElement.startTime,
|
||||||
});
|
});
|
||||||
const delta = adjustedEarliestStartTime - earliestElement.startTime;
|
const delta = subMediaTime({
|
||||||
|
a: adjustedEarliestStartTime,
|
||||||
|
b: earliestElement.startTime,
|
||||||
|
});
|
||||||
|
|
||||||
if (delta !== 0) {
|
if (delta !== ZERO_MEDIA_TIME) {
|
||||||
elementsForPlacement = elementsToAdd.map((element) => ({
|
elementsForPlacement = elementsToAdd.map((element) => ({
|
||||||
...element,
|
...element,
|
||||||
startTime: Math.max(0, element.startTime + delta),
|
startTime: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: addMediaTime({ a: element.startTime, b: delta }),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,14 +185,20 @@ function buildPastedElements({
|
||||||
time,
|
time,
|
||||||
}: {
|
}: {
|
||||||
items: ElementClipboardItem[];
|
items: ElementClipboardItem[];
|
||||||
minStart: number;
|
minStart: MediaTime;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}): TimelineElement[] {
|
}): TimelineElement[] {
|
||||||
const elementsToAdd: TimelineElement[] = [];
|
const elementsToAdd: TimelineElement[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const relativeOffset = item.element.startTime - minStart;
|
const relativeOffset = subMediaTime({
|
||||||
const startTime = Math.max(0, time + relativeOffset);
|
a: item.element.startTime,
|
||||||
|
b: minStart,
|
||||||
|
});
|
||||||
|
const startTime = maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: addMediaTime({ a: time, b: relativeOffset }),
|
||||||
|
});
|
||||||
const newElementId = generateUUID();
|
const newElementId = generateUUID();
|
||||||
|
|
||||||
elementsToAdd.push({
|
elementsToAdd.push({
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id";
|
||||||
import { EditorCore } from "@/core";
|
import { EditorCore } from "@/core";
|
||||||
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
|
import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement";
|
||||||
import { cloneAnimations } from "@/animation";
|
import { cloneAnimations } from "@/animation";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
interface DuplicateElementsParams {
|
interface DuplicateElementsParams {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
|
|
@ -119,7 +120,7 @@ function buildDuplicateElement({
|
||||||
}: {
|
}: {
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
id: string;
|
id: string;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
}): TimelineElement {
|
}): TimelineElement {
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
resolveTrackPlacement,
|
resolveTrackPlacement,
|
||||||
validateElementTrackCompatibility,
|
validateElementTrackCompatibility,
|
||||||
} from "@/timeline/placement";
|
} from "@/timeline/placement";
|
||||||
|
import { roundMediaTime } from "@/wasm";
|
||||||
|
|
||||||
type InsertElementPlacement =
|
type InsertElementPlacement =
|
||||||
| { mode: "explicit"; trackId: string }
|
| { mode: "explicit"; trackId: string }
|
||||||
|
|
@ -266,7 +267,12 @@ export class InsertElementCommand extends Command {
|
||||||
placementResult.kind === "existingTrack"
|
placementResult.kind === "existingTrack"
|
||||||
? {
|
? {
|
||||||
...element,
|
...element,
|
||||||
startTime: placementResult.adjustedStartTime ?? element.startTime,
|
startTime:
|
||||||
|
placementResult.adjustedStartTime !== undefined
|
||||||
|
? roundMediaTime({
|
||||||
|
time: placementResult.adjustedStartTime,
|
||||||
|
})
|
||||||
|
: element.startTime,
|
||||||
}
|
}
|
||||||
: element;
|
: element;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command";
|
||||||
import { updateElementInSceneTracks } from "@/timeline";
|
import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import type { AnimationPath } from "@/animation/types";
|
import type { AnimationPath } from "@/animation/types";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
import {
|
||||||
|
type MediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
export class RetimeKeyframeCommand extends Command {
|
export class RetimeKeyframeCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
@ -11,7 +17,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
private readonly keyframeId: string;
|
private readonly keyframeId: string;
|
||||||
private readonly nextTime: number;
|
private readonly nextTime: MediaTime;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
trackId,
|
trackId,
|
||||||
|
|
@ -24,7 +30,7 @@ export class RetimeKeyframeCommand extends Command {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
nextTime: number;
|
nextTime: MediaTime;
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
this.trackId = trackId;
|
this.trackId = trackId;
|
||||||
|
|
@ -47,7 +53,10 @@ export class RetimeKeyframeCommand extends Command {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
|
const boundedTime = maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: minMediaTime({ a: this.nextTime, b: element.duration }),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
animations: retimeElementKeyframe({
|
animations: retimeElementKeyframe({
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ import { updateElementInSceneTracks } from "@/timeline";
|
||||||
import { isVisualElement } from "@/timeline/element-utils";
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
import type { AnimationInterpolation } from "@/animation/types";
|
import type { AnimationInterpolation } from "@/animation/types";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
|
import {
|
||||||
|
type MediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
export class UpsertEffectParamKeyframeCommand extends Command {
|
export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly effectId: string;
|
private readonly effectId: string;
|
||||||
private readonly paramKey: string;
|
private readonly paramKey: string;
|
||||||
private readonly time: number;
|
private readonly time: MediaTime;
|
||||||
private readonly value: number | string | boolean;
|
private readonly value: number | string | boolean;
|
||||||
private readonly interpolation: AnimationInterpolation | undefined;
|
private readonly interpolation: AnimationInterpolation | undefined;
|
||||||
private readonly keyframeId: string | undefined;
|
private readonly keyframeId: string | undefined;
|
||||||
|
|
@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
effectId: string;
|
effectId: string;
|
||||||
paramKey: string;
|
paramKey: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: number | string | boolean;
|
value: number | string | boolean;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command {
|
||||||
elementId: this.elementId,
|
elementId: this.elementId,
|
||||||
elementPredicate: isVisualElement,
|
elementPredicate: isVisualElement,
|
||||||
update: (element) => {
|
update: (element) => {
|
||||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
const boundedTime = maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||||
|
});
|
||||||
const propertyPath = buildEffectParamPath({
|
const propertyPath = buildEffectParamPath({
|
||||||
effectId: this.effectId,
|
effectId: this.effectId,
|
||||||
paramKey: this.paramKey,
|
paramKey: this.paramKey,
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,19 @@ import type {
|
||||||
AnimationInterpolation,
|
AnimationInterpolation,
|
||||||
AnimationValue,
|
AnimationValue,
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
|
import {
|
||||||
|
type MediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
export class UpsertKeyframeCommand extends Command {
|
export class UpsertKeyframeCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private readonly trackId: string;
|
private readonly trackId: string;
|
||||||
private readonly elementId: string;
|
private readonly elementId: string;
|
||||||
private readonly propertyPath: AnimationPath;
|
private readonly propertyPath: AnimationPath;
|
||||||
private readonly time: number;
|
private readonly time: MediaTime;
|
||||||
private readonly value: AnimationValue;
|
private readonly value: AnimationValue;
|
||||||
private readonly interpolation: AnimationInterpolation | undefined;
|
private readonly interpolation: AnimationInterpolation | undefined;
|
||||||
private readonly keyframeId: string | undefined;
|
private readonly keyframeId: string | undefined;
|
||||||
|
|
@ -31,7 +37,7 @@ export class UpsertKeyframeCommand extends Command {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
@ -63,7 +69,10 @@ export class UpsertKeyframeCommand extends Command {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
|
const boundedTime = maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: minMediaTime({ a: this.time, b: element.duration }),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...element,
|
...element,
|
||||||
animations: upsertPathKeyframe({
|
animations: upsertPathKeyframe({
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,18 @@ import { EditorCore } from "@/core";
|
||||||
import { isRetimableElement } from "@/timeline";
|
import { isRetimableElement } from "@/timeline";
|
||||||
import { splitAnimationsAtTime } from "@/animation";
|
import { splitAnimationsAtTime } from "@/animation";
|
||||||
import { getSourceSpanAtClipTime } from "@/retime";
|
import { getSourceSpanAtClipTime } from "@/retime";
|
||||||
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
roundMediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
export class SplitElementsCommand extends Command {
|
export class SplitElementsCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
private rightSideElements: { trackId: string; elementId: string }[] = [];
|
||||||
private readonly elements: { trackId: string; elementId: string }[];
|
private readonly elements: { trackId: string; elementId: string }[];
|
||||||
private readonly splitTime: number;
|
private readonly splitTime: MediaTime;
|
||||||
private readonly retainSide: "both" | "left" | "right";
|
private readonly retainSide: "both" | "left" | "right";
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
|
|
@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command {
|
||||||
retainSide = "both",
|
retainSide = "both",
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
splitTime: number;
|
splitTime: MediaTime;
|
||||||
retainSide?: "both" | "left" | "right";
|
retainSide?: "both" | "left" | "right";
|
||||||
}) {
|
}) {
|
||||||
super();
|
super();
|
||||||
|
|
@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command {
|
||||||
return [element];
|
return [element];
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativeTime = this.splitTime - element.startTime;
|
const relativeTime = subMediaTime({
|
||||||
|
a: this.splitTime,
|
||||||
|
b: element.startTime,
|
||||||
|
});
|
||||||
const leftVisibleDuration = relativeTime;
|
const leftVisibleDuration = relativeTime;
|
||||||
const rightVisibleDuration = element.duration - relativeTime;
|
const rightVisibleDuration = subMediaTime({
|
||||||
|
a: element.duration,
|
||||||
|
b: relativeTime,
|
||||||
|
});
|
||||||
const retimeRef = isRetimableElement(element)
|
const retimeRef = isRetimableElement(element)
|
||||||
? element.retime
|
? element.retime
|
||||||
: undefined;
|
: undefined;
|
||||||
const leftSourceSpan = getSourceSpanAtClipTime({
|
// Snap the source-side split point exactly once and derive the right
|
||||||
clipTime: leftVisibleDuration,
|
// half from it. Independently rounding both spans (left and total)
|
||||||
retime: retimeRef,
|
// would let a 1-tick rounding error desynchronise them, breaking the
|
||||||
|
// invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`.
|
||||||
|
// See the same discipline in `compute-resize.ts` (snap-once comment).
|
||||||
|
const leftSourceSpan = roundMediaTime({
|
||||||
|
time: getSourceSpanAtClipTime({
|
||||||
|
clipTime: leftVisibleDuration,
|
||||||
|
retime: retimeRef,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const totalSourceSpan = getSourceSpanAtClipTime({
|
const totalSourceSpan = roundMediaTime({
|
||||||
clipTime: element.duration,
|
time: getSourceSpanAtClipTime({
|
||||||
retime: retimeRef,
|
clipTime: element.duration,
|
||||||
|
retime: retimeRef,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const rightSourceSpan = subMediaTime({
|
||||||
|
a: totalSourceSpan,
|
||||||
|
b: leftSourceSpan,
|
||||||
});
|
});
|
||||||
const rightSourceSpan = totalSourceSpan - leftSourceSpan;
|
|
||||||
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
splitTime: relativeTime,
|
splitTime: relativeTime,
|
||||||
|
|
@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command {
|
||||||
});
|
});
|
||||||
let splitResult: TimelineElement[];
|
let splitResult: TimelineElement[];
|
||||||
|
|
||||||
|
const leftTrimEnd = addMediaTime({
|
||||||
|
a: element.trimEnd,
|
||||||
|
b: rightSourceSpan,
|
||||||
|
});
|
||||||
|
const rightTrimStart = addMediaTime({
|
||||||
|
a: element.trimStart,
|
||||||
|
b: leftSourceSpan,
|
||||||
|
});
|
||||||
|
|
||||||
if (this.retainSide === "left") {
|
if (this.retainSide === "left") {
|
||||||
splitResult = [
|
splitResult = [
|
||||||
{
|
{
|
||||||
...element,
|
...element,
|
||||||
duration: leftVisibleDuration,
|
duration: leftVisibleDuration,
|
||||||
trimEnd: element.trimEnd + rightSourceSpan,
|
trimEnd: leftTrimEnd,
|
||||||
name: `${element.name} (left)`,
|
name: `${element.name} (left)`,
|
||||||
animations: leftAnimations,
|
animations: leftAnimations,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
|
|
@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command {
|
||||||
id: newId,
|
id: newId,
|
||||||
startTime: this.splitTime,
|
startTime: this.splitTime,
|
||||||
duration: rightVisibleDuration,
|
duration: rightVisibleDuration,
|
||||||
trimStart: element.trimStart + leftSourceSpan,
|
trimStart: rightTrimStart,
|
||||||
name: `${element.name} (right)`,
|
name: `${element.name} (right)`,
|
||||||
animations: rightAnimations,
|
animations: rightAnimations,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// "both" - split into two pieces
|
|
||||||
const secondElementId = generateUUID();
|
const secondElementId = generateUUID();
|
||||||
this.rightSideElements.push({
|
this.rightSideElements.push({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
|
|
@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command {
|
||||||
{
|
{
|
||||||
...element,
|
...element,
|
||||||
duration: leftVisibleDuration,
|
duration: leftVisibleDuration,
|
||||||
trimEnd: element.trimEnd + rightSourceSpan,
|
trimEnd: leftTrimEnd,
|
||||||
name: `${element.name} (left)`,
|
name: `${element.name} (left)`,
|
||||||
animations: leftAnimations,
|
animations: leftAnimations,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
|
|
@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command {
|
||||||
id: secondElementId,
|
id: secondElementId,
|
||||||
startTime: this.splitTime,
|
startTime: this.splitTime,
|
||||||
duration: rightVisibleDuration,
|
duration: rightVisibleDuration,
|
||||||
trimStart: element.trimStart + leftSourceSpan,
|
trimStart: rightTrimStart,
|
||||||
name: `${element.name} (right)`,
|
name: `${element.name} (right)`,
|
||||||
animations: rightAnimations,
|
animations: rightAnimations,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
interface EditableTimecodeProps {
|
interface EditableTimecodeProps {
|
||||||
time: number;
|
time: MediaTime;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
format?: TimeCodeFormat;
|
format?: TimeCodeFormat;
|
||||||
fps: FrameRate;
|
fps: FrameRate;
|
||||||
onTimeChange?: ({ time }: { time: number }) => void;
|
onTimeChange?: ({ time }: { time: MediaTime }) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -53,9 +54,12 @@ export function EditableTimecode({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = duration
|
const clampedTime = (
|
||||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime)
|
duration
|
||||||
: parsedTime;
|
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ??
|
||||||
|
parsedTime)
|
||||||
|
: parsedTime
|
||||||
|
) as MediaTime;
|
||||||
|
|
||||||
onTimeChange?.({ time: clampedTime });
|
onTimeChange?.({ time: clampedTime });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,14 @@ import { useEditor } from "@/editor/use-editor";
|
||||||
import { clearDragData, setDragData } from "@/timeline/drag-data";
|
import { clearDragData, setDragData } from "@/timeline/drag-data";
|
||||||
import type { TimelineDragData } from "@/timeline/drag";
|
import type { TimelineDragData } from "@/timeline/drag";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export interface DraggableItemProps {
|
export interface DraggableItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
preview: ReactNode;
|
preview: ReactNode;
|
||||||
dragData: TimelineDragData;
|
dragData: TimelineDragData;
|
||||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
onAddToTimeline?: ({ currentTime }: { currentTime: MediaTime }) => void;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
containerClassName?: string;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useFileUpload } from "@/media/use-file-upload";
|
import { useFileUpload } from "@/media/use-file-upload";
|
||||||
import { invokeAction } from "@/actions";
|
import { invokeAction } from "@/actions";
|
||||||
|
|
@ -255,11 +255,11 @@ function MediaAssetDraggable({
|
||||||
startTime,
|
startTime,
|
||||||
}: {
|
}: {
|
||||||
asset: MediaAsset;
|
asset: MediaAsset;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
}) => {
|
}) => {
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration != null
|
asset.duration != null
|
||||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
? mediaTimeFromSeconds({ seconds: asset.duration })
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: asset.id,
|
mediaId: asset.id,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { getElementLocalTime } from "@/animation";
|
import { getElementLocalTime } from "@/animation";
|
||||||
|
import { addMediaTime, mediaTime, type MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function useElementPlayhead({
|
export function useElementPlayhead({
|
||||||
startTime,
|
startTime,
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
}) {
|
}) {
|
||||||
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
const playheadTime = useEditor((editor) => editor.playback.getCurrentTime());
|
||||||
const localTime = getElementLocalTime({
|
const localTime = mediaTime({
|
||||||
timelineTime: playheadTime,
|
ticks: getElementLocalTime({
|
||||||
elementStartTime: startTime,
|
timelineTime: playheadTime,
|
||||||
elementDuration: duration,
|
elementStartTime: startTime,
|
||||||
|
elementDuration: duration,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const isPlayheadWithinElementRange =
|
const isPlayheadWithinElementRange =
|
||||||
playheadTime >= startTime &&
|
playheadTime >= startTime &&
|
||||||
playheadTime <= startTime + duration;
|
playheadTime <= addMediaTime({ a: startTime, b: duration });
|
||||||
|
|
||||||
return { localTime, isPlayheadWithinElementRange };
|
return { localTime, isPlayheadWithinElementRange };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function useKeyframedColorProperty({
|
export function useKeyframedColorProperty({
|
||||||
trackId,
|
trackId,
|
||||||
|
|
@ -21,7 +22,7 @@ export function useKeyframedColorProperty({
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: number;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
resolvedColor: string;
|
resolvedColor: string;
|
||||||
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { AnimationPropertyPath, ElementAnimations } from "@/animation/types
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
import { snapToStep } from "@/utils/math";
|
import { snapToStep } from "@/utils/math";
|
||||||
import { usePropertyDraft } from "./use-property-draft";
|
import { usePropertyDraft } from "./use-property-draft";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function useKeyframedNumberProperty({
|
export function useKeyframedNumberProperty({
|
||||||
trackId,
|
trackId,
|
||||||
|
|
@ -27,7 +28,7 @@ export function useKeyframedNumberProperty({
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
propertyPath: AnimationPropertyPath;
|
propertyPath: AnimationPropertyPath;
|
||||||
localTime: number;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
displayValue: string;
|
displayValue: string;
|
||||||
parse: (input: string) => number | null;
|
parse: (input: string) => number | null;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
} from "@/animation/types";
|
} from "@/animation/types";
|
||||||
import type { ParamDefinition } from "@/params";
|
import type { ParamDefinition } from "@/params";
|
||||||
import type { TimelineElement } from "@/timeline";
|
import type { TimelineElement } from "@/timeline";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export interface KeyframedParamPropertyResult {
|
export interface KeyframedParamPropertyResult {
|
||||||
hasAnimatedKeyframes: boolean;
|
hasAnimatedKeyframes: boolean;
|
||||||
|
|
@ -39,7 +40,7 @@ export function useKeyframedParamProperty({
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
animations: ElementAnimations | undefined;
|
animations: ElementAnimations | undefined;
|
||||||
localTime: number;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
resolvedValue: number | string | boolean;
|
resolvedValue: number | string | boolean;
|
||||||
buildBaseUpdates: ({
|
buildBaseUpdates: ({
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
type CopyContext,
|
type CopyContext,
|
||||||
type PasteContext,
|
type PasteContext,
|
||||||
} from "@/clipboard";
|
} from "@/clipboard";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export class ClipboardManager {
|
export class ClipboardManager {
|
||||||
private entry: ClipboardEntry | null = null;
|
private entry: ClipboardEntry | null = null;
|
||||||
|
|
@ -34,7 +35,7 @@ export class ClipboardManager {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
paste({ time }: { time?: number } = {}): boolean {
|
paste({ time }: { time?: MediaTime } = {}): boolean {
|
||||||
if (!this.entry) {
|
if (!this.entry) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +65,7 @@ export class ClipboardManager {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPasteContext({ time }: { time?: number }): PasteContext {
|
private getPasteContext({ time }: { time?: MediaTime }): PasteContext {
|
||||||
return {
|
return {
|
||||||
editor: this.editor,
|
editor: this.editor,
|
||||||
selectedElements: this.editor.selection.getSelectedElements(),
|
selectedElements: this.editor.selection.getSelectedElements(),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
clampMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
mediaTimeFromSeconds,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
|
|
||||||
export class PlaybackManager {
|
export class PlaybackManager {
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
private currentTime = 0;
|
private currentTime: MediaTime = ZERO_MEDIA_TIME;
|
||||||
private volume = 1;
|
private volume = 1;
|
||||||
private muted = false;
|
private muted = false;
|
||||||
private previousVolume = 1;
|
private previousVolume = 1;
|
||||||
|
|
@ -12,7 +18,7 @@ export class PlaybackManager {
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private playbackTimer: number | null = null;
|
private playbackTimer: number | null = null;
|
||||||
private playbackStartWallTime = 0;
|
private playbackStartWallTime = 0;
|
||||||
private playbackStartTime = 0;
|
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
||||||
private timelineScopeBound = false;
|
private timelineScopeBound = false;
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
@ -38,7 +44,7 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.currentTime >= maxTime) {
|
if (this.currentTime >= maxTime) {
|
||||||
this.seek({ time: 0 });
|
this.seek({ time: ZERO_MEDIA_TIME });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
|
|
@ -60,7 +66,7 @@ export class PlaybackManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seek({ time }: { time: number }): void {
|
seek({ time }: { time: MediaTime }): void {
|
||||||
this.currentTime = this.clampTimeToTimeline(time);
|
this.currentTime = this.clampTimeToTimeline(time);
|
||||||
if (this.isPlaying) {
|
if (this.isPlaying) {
|
||||||
this.playbackStartWallTime = performance.now();
|
this.playbackStartWallTime = performance.now();
|
||||||
|
|
@ -107,7 +113,7 @@ export class PlaybackManager {
|
||||||
return this.isPlaying;
|
return this.isPlaying;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentTime(): number {
|
getCurrentTime(): MediaTime {
|
||||||
return this.currentTime;
|
return this.currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,11 +191,13 @@ export class PlaybackManager {
|
||||||
const fps = this.editor.project.getActive()?.settings.fps;
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
const elapsedSeconds =
|
const elapsedSeconds =
|
||||||
(performance.now() - this.playbackStartWallTime) / 1000;
|
(performance.now() - this.playbackStartWallTime) / 1000;
|
||||||
const rawTime =
|
const rawTime = addMediaTime({
|
||||||
this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND);
|
a: this.playbackStartTime,
|
||||||
const newTime = fps
|
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
||||||
|
});
|
||||||
|
const newTime = (fps
|
||||||
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
||||||
: rawTime;
|
: rawTime) as MediaTime;
|
||||||
const maxTime = this.editor.timeline.getTotalDuration();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
if (newTime >= maxTime) {
|
if (newTime >= maxTime) {
|
||||||
|
|
@ -205,12 +213,12 @@ export class PlaybackManager {
|
||||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
private clampTimeToTimeline(time: number): number {
|
private clampTimeToTimeline(time: MediaTime): MediaTime {
|
||||||
const maxTime = this.editor.timeline.getTotalDuration();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
return Math.max(0, Math.min(maxTime, time));
|
return clampMediaTime({ time, min: ZERO_MEDIA_TIME, max: maxTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchSeekEvent(time: number): void {
|
private dispatchSeekEvent(time: MediaTime): void {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -222,7 +230,7 @@ export class PlaybackManager {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchUpdateEvent(time: number): void {
|
private dispatchUpdateEvent(time: MediaTime): void {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import type { SceneTracks, TScene } from "@/timeline";
|
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||||
import { storageService } from "@/services/storage/service";
|
import { storageService } from "@/services/storage/service";
|
||||||
import {
|
import {
|
||||||
getMainScene,
|
getMainScene,
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ToggleBookmarkCommand,
|
ToggleBookmarkCommand,
|
||||||
UpdateBookmarkCommand,
|
UpdateBookmarkCommand,
|
||||||
} from "@/commands/scene";
|
} from "@/commands/scene";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export class ScenesManager {
|
export class ScenesManager {
|
||||||
private active: TScene | null = null;
|
private active: TScene | null = null;
|
||||||
|
|
@ -106,12 +107,12 @@ export class ScenesManager {
|
||||||
this.notify();
|
this.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleBookmark({ time }: { time: number }): Promise<void> {
|
async toggleBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||||
const command = new ToggleBookmarkCommand(time);
|
const command = new ToggleBookmarkCommand(time);
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
isBookmarked({ time }: { time: number }): boolean {
|
isBookmarked({ time }: { time: MediaTime }): boolean {
|
||||||
const activeScene = this.getActiveScene();
|
const activeScene = this.getActiveScene();
|
||||||
const activeProject = this.editor.project.getActive();
|
const activeProject = this.editor.project.getActive();
|
||||||
|
|
||||||
|
|
@ -125,7 +126,7 @@ export class ScenesManager {
|
||||||
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeBookmark({ time }: { time: number }): Promise<void> {
|
async removeBookmark({ time }: { time: MediaTime }): Promise<void> {
|
||||||
const command = new RemoveBookmarkCommand(time);
|
const command = new RemoveBookmarkCommand(time);
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
@ -134,8 +135,8 @@ export class ScenesManager {
|
||||||
time,
|
time,
|
||||||
updates,
|
updates,
|
||||||
}: {
|
}: {
|
||||||
time: number;
|
time: MediaTime;
|
||||||
updates: Partial<{ note: string; color: string; duration: number }>;
|
updates: Partial<Omit<Bookmark, "time">>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const command = new UpdateBookmarkCommand(time, updates);
|
const command = new UpdateBookmarkCommand(time, updates);
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
|
|
@ -145,14 +146,14 @@ export class ScenesManager {
|
||||||
fromTime,
|
fromTime,
|
||||||
toTime,
|
toTime,
|
||||||
}: {
|
}: {
|
||||||
fromTime: number;
|
fromTime: MediaTime;
|
||||||
toTime: number;
|
toTime: MediaTime;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const command = new MoveBookmarkCommand(fromTime, toTime);
|
const command = new MoveBookmarkCommand(fromTime, toTime);
|
||||||
this.editor.command.execute({ command });
|
this.editor.command.execute({ command });
|
||||||
}
|
}
|
||||||
|
|
||||||
getBookmarkAtTime({ time }: { time: number }) {
|
getBookmarkAtTime({ time }: { time: MediaTime }) {
|
||||||
const activeScene = this.active;
|
const activeScene = this.active;
|
||||||
const activeProject = this.editor.project.getActive();
|
const activeProject = this.editor.project.getActive();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
} from "@/timeline";
|
} from "@/timeline";
|
||||||
import { calculateTotalDuration } from "@/timeline";
|
import { calculateTotalDuration } from "@/timeline";
|
||||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||||
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
import {
|
import {
|
||||||
canElementBeHidden,
|
canElementBeHidden,
|
||||||
canElementHaveAudio,
|
canElementHaveAudio,
|
||||||
|
|
@ -95,10 +96,10 @@ export class TimelineManager {
|
||||||
pushHistory = true,
|
pushHistory = true,
|
||||||
}: {
|
}: {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
trimStart: number;
|
trimStart: MediaTime;
|
||||||
trimEnd: number;
|
trimEnd: MediaTime;
|
||||||
startTime?: number;
|
startTime?: MediaTime;
|
||||||
duration?: number;
|
duration?: MediaTime;
|
||||||
pushHistory?: boolean;
|
pushHistory?: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
const trackId = this.findTrackIdForElement({ elementId });
|
const trackId = this.findTrackIdForElement({ elementId });
|
||||||
|
|
@ -188,7 +189,7 @@ export class TimelineManager {
|
||||||
retainSide = "both",
|
retainSide = "both",
|
||||||
}: {
|
}: {
|
||||||
elements: { trackId: string; elementId: string }[];
|
elements: { trackId: string; elementId: string }[];
|
||||||
splitTime: number;
|
splitTime: MediaTime;
|
||||||
retainSide?: "both" | "left" | "right";
|
retainSide?: "both" | "left" | "right";
|
||||||
}): { trackId: string; elementId: string }[] {
|
}): { trackId: string; elementId: string }[] {
|
||||||
const command = new SplitElementsCommand({
|
const command = new SplitElementsCommand({
|
||||||
|
|
@ -200,20 +201,20 @@ export class TimelineManager {
|
||||||
return command.getRightSideElements();
|
return command.getRightSideElements();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTotalDuration(): number {
|
getTotalDuration(): MediaTime {
|
||||||
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
const activeScene = this.editor.scenes.getActiveSceneOrNull();
|
||||||
if (!activeScene) {
|
if (!activeScene) {
|
||||||
return 0;
|
return ZERO_MEDIA_TIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
return calculateTotalDuration({ tracks: activeScene.tracks });
|
return calculateTotalDuration({ tracks: activeScene.tracks });
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastFrameTime(): number {
|
getLastFrameTime(): MediaTime {
|
||||||
const duration = this.getTotalDuration();
|
const duration = this.getTotalDuration();
|
||||||
const fps = this.editor.project.getActive()?.settings.fps;
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
if (!fps || duration <= 0) return duration;
|
if (!fps || duration <= 0) return duration;
|
||||||
return lastFrameTime({ duration, rate: fps }) ?? duration;
|
return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||||
|
|
@ -483,7 +484,7 @@ export class TimelineManager {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: AnimationValue;
|
value: AnimationValue;
|
||||||
interpolation?: AnimationInterpolation;
|
interpolation?: AnimationInterpolation;
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
@ -600,7 +601,7 @@ export class TimelineManager {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
propertyPath: AnimationPath;
|
propertyPath: AnimationPath;
|
||||||
keyframeId: string;
|
keyframeId: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}): void {
|
}): void {
|
||||||
const command = new RetimeKeyframeCommand({
|
const command = new RetimeKeyframeCommand({
|
||||||
trackId,
|
trackId,
|
||||||
|
|
@ -658,7 +659,7 @@ export class TimelineManager {
|
||||||
elementId: string;
|
elementId: string;
|
||||||
effectId: string;
|
effectId: string;
|
||||||
paramKey: string;
|
paramKey: string;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
value: number;
|
value: number;
|
||||||
interpolation?: "linear" | "hold";
|
interpolation?: "linear" | "hold";
|
||||||
keyframeId?: string;
|
keyframeId?: string;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
registerDefaultGraphics();
|
registerDefaultGraphics();
|
||||||
|
|
||||||
|
|
@ -197,10 +198,11 @@ function AnimatedGraphicParamField({
|
||||||
isPlayheadWithinElementRange,
|
isPlayheadWithinElementRange,
|
||||||
resolvedParams,
|
resolvedParams,
|
||||||
}: {
|
}: {
|
||||||
|
key?: string;
|
||||||
param: ParamDefinition;
|
param: ParamDefinition;
|
||||||
trackId: string;
|
trackId: string;
|
||||||
element: GraphicElement;
|
element: GraphicElement;
|
||||||
localTime: number;
|
localTime: MediaTime;
|
||||||
isPlayheadWithinElementRange: boolean;
|
isPlayheadWithinElementRange: boolean;
|
||||||
resolvedParams: ParamValues;
|
resolvedParams: ParamValues;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { AddMediaAssetCommand } from "@/commands/media";
|
||||||
import { InsertElementCommand } from "@/commands/timeline";
|
import { InsertElementCommand } from "@/commands/timeline";
|
||||||
import { BatchCommand } from "@/commands";
|
import { BatchCommand } from "@/commands";
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTimeFromSeconds } from "@/wasm";
|
||||||
import { isTypableDOMElement } from "@/utils/browser";
|
import { isTypableDOMElement } from "@/utils/browser";
|
||||||
import type { MediaType } from "@/media/types";
|
import type { MediaType } from "@/media/types";
|
||||||
|
|
||||||
|
|
@ -75,7 +75,7 @@ export function usePasteMedia() {
|
||||||
const assetId = addMediaCmd.getAssetId();
|
const assetId = addMediaCmd.getAssetId();
|
||||||
const duration =
|
const duration =
|
||||||
asset.duration != null
|
asset.duration != null
|
||||||
? Math.round(asset.duration * TICKS_PER_SECOND)
|
? mediaTimeFromSeconds({ seconds: asset.duration })
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const trackType = asset.type === "audio" ? "audio" : "video";
|
const trackType = asset.type === "audio" ? "audio" : "video";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
|
||||||
import { usePreviewViewport } from "./preview-viewport";
|
import { usePreviewViewport } from "./preview-viewport";
|
||||||
import { GridPopover } from "./guide-popover";
|
import { GridPopover } from "./guide-popover";
|
||||||
import { usePreviewStore } from "@/preview/preview-store";
|
import { usePreviewStore } from "@/preview/preview-store";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function PreviewToolbar({
|
export function PreviewToolbar({
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
|
|
@ -67,13 +68,13 @@ function TimecodeDisplay() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
|
const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
|
||||||
const fps = useEditor((e) => e.project.getActive().settings.fps);
|
const fps = useEditor((e) => e.project.getActive().settings.fps);
|
||||||
const [currentTime, setCurrentTime] = useState(() =>
|
const [currentTime, setCurrentTime] = useState<MediaTime>(() =>
|
||||||
editor.playback.getCurrentTime(),
|
editor.playback.getCurrentTime(),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) =>
|
const handler = (e: Event) =>
|
||||||
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
|
setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
|
||||||
window.addEventListener("playback-update", handler);
|
window.addEventListener("playback-update", handler);
|
||||||
window.addEventListener("playback-seek", handler);
|
window.addEventListener("playback-seek", handler);
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { FrameRate } from "opencut-wasm";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import type { TScene } from "@/timeline/types";
|
import type { TScene } from "@/timeline/types";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export type TBackground =
|
export type TBackground =
|
||||||
| {
|
| {
|
||||||
|
|
@ -20,7 +21,7 @@ export interface TProjectMetadata {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +38,7 @@ export interface TProjectSettings {
|
||||||
export interface TTimelineViewState {
|
export interface TTimelineViewState {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
scrollLeft: number;
|
scrollLeft: number;
|
||||||
playheadTime: number;
|
playheadTime: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TProject {
|
export interface TProject {
|
||||||
|
|
|
||||||
|
|
@ -30,14 +30,15 @@ import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/
|
||||||
import { resolveOpacityAtTime } from "@/animation";
|
import { resolveOpacityAtTime } from "@/animation";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { isPropertyAtDefault } from "./transform-tab";
|
import { isPropertyAtDefault } from "./transform-tab";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
type BlendingElement = {
|
type BlendingElement = {
|
||||||
id: string;
|
id: string;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
type: ElementType;
|
type: ElementType;
|
||||||
blendMode?: BlendMode;
|
blendMode?: BlendMode;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
animations?: ElementAnimations;
|
animations?: ElementAnimations;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
import { mediaTimeToSeconds, roundMediaTime } from "@/wasm";
|
||||||
import {
|
import {
|
||||||
getElementLocalTime,
|
getElementLocalTime,
|
||||||
resolveColorAtTime,
|
resolveColorAtTime,
|
||||||
|
|
@ -204,7 +204,7 @@ async function resolveVideoNode({
|
||||||
const frame = await videoCache.getFrameAt({
|
const frame = await videoCache.getFrameAt({
|
||||||
mediaId: node.params.mediaId,
|
mediaId: node.params.mediaId,
|
||||||
file: node.params.file,
|
file: node.params.file,
|
||||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
|
||||||
});
|
});
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -421,7 +421,7 @@ async function resolveBackdropSource({
|
||||||
const frame = await videoCache.getFrameAt({
|
const frame = await videoCache.getFrameAt({
|
||||||
mediaId: node.params.mediaId,
|
mediaId: node.params.mediaId,
|
||||||
file: node.params.file,
|
file: node.params.file,
|
||||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
time: mediaTimeToSeconds({ time: roundMediaTime({ time: sourceTimeTicks }) }),
|
||||||
});
|
});
|
||||||
if (!frame) {
|
if (!frame) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -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 { V24toV25Migration } from "./v24-to-v25";
|
||||||
import { V25toV26Migration } from "./v25-to-v26";
|
import { V25toV26Migration } from "./v25-to-v26";
|
||||||
import { V26toV27Migration } from "./v26-to-v27";
|
import { V26toV27Migration } from "./v26-to-v27";
|
||||||
|
import { V27toV28Migration } from "./v27-to-v28";
|
||||||
export { runStorageMigrations } from "./runner";
|
export { runStorageMigrations } from "./runner";
|
||||||
export type { MigrationProgress } from "./runner";
|
export type { MigrationProgress } from "./runner";
|
||||||
|
|
||||||
export const CURRENT_PROJECT_VERSION = 27;
|
export const CURRENT_PROJECT_VERSION = 28;
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
new V0toV1Migration(),
|
new V0toV1Migration(),
|
||||||
|
|
@ -59,4 +60,5 @@ export const migrations = [
|
||||||
new V24toV25Migration(),
|
new V24toV25Migration(),
|
||||||
new V25toV26Migration(),
|
new V25toV26Migration(),
|
||||||
new V26toV27Migration(),
|
new V26toV27Migration(),
|
||||||
|
new V27toV28Migration(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
runStorageMigrations,
|
||||||
} from "@/services/storage/migrations";
|
} from "@/services/storage/migrations";
|
||||||
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
import type { Bookmark, SceneTracks, TScene } from "@/timeline";
|
||||||
|
import { roundMediaTime } from "@/wasm";
|
||||||
|
|
||||||
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
return raw
|
return raw
|
||||||
.map((item): Bookmark | null => {
|
.map((item): Bookmark | null => {
|
||||||
if (typeof item === "number") return { time: item };
|
if (typeof item === "number") {
|
||||||
|
return { time: roundMediaTime({ time: item }) };
|
||||||
|
}
|
||||||
const obj = item as Record<string, unknown>;
|
const obj = item as Record<string, unknown>;
|
||||||
if (
|
if (
|
||||||
typeof obj !== "object" ||
|
typeof obj !== "object" ||
|
||||||
|
|
@ -37,10 +40,12 @@ function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
time: obj.time,
|
time: roundMediaTime({ time: obj.time }),
|
||||||
...(typeof obj.note === "string" && { note: obj.note }),
|
...(typeof obj.note === "string" && { note: obj.note }),
|
||||||
...(typeof obj.color === "string" && { color: obj.color }),
|
...(typeof obj.color === "string" && { color: obj.color }),
|
||||||
...(typeof obj.duration === "number" && { duration: obj.duration }),
|
...(typeof obj.duration === "number" && {
|
||||||
|
duration: roundMediaTime({ time: obj.duration }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((b): b is Bookmark => b !== null);
|
.filter((b): b is Bookmark => b !== null);
|
||||||
|
|
@ -198,9 +203,11 @@ class StorageService {
|
||||||
id: serializedProject.metadata.id,
|
id: serializedProject.metadata.id,
|
||||||
name: serializedProject.metadata.name,
|
name: serializedProject.metadata.name,
|
||||||
thumbnail: serializedProject.metadata.thumbnail,
|
thumbnail: serializedProject.metadata.thumbnail,
|
||||||
duration:
|
duration: roundMediaTime({
|
||||||
serializedProject.metadata.duration ??
|
time:
|
||||||
getProjectDurationFromScenes({ scenes }),
|
serializedProject.metadata.duration ??
|
||||||
|
getProjectDurationFromScenes({ scenes }),
|
||||||
|
}),
|
||||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||||
},
|
},
|
||||||
|
|
@ -253,11 +260,13 @@ class StorageService {
|
||||||
id: serializedProject.metadata.id,
|
id: serializedProject.metadata.id,
|
||||||
name: serializedProject.metadata.name,
|
name: serializedProject.metadata.name,
|
||||||
thumbnail: serializedProject.metadata.thumbnail,
|
thumbnail: serializedProject.metadata.thumbnail,
|
||||||
duration:
|
duration: roundMediaTime({
|
||||||
serializedProject.metadata.duration ??
|
time:
|
||||||
getProjectDurationFromScenes({
|
serializedProject.metadata.duration ??
|
||||||
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
getProjectDurationFromScenes({
|
||||||
}),
|
scenes: (serializedProject.scenes ?? []) as unknown as TScene[],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
setCanvasLetterSpacing,
|
setCanvasLetterSpacing,
|
||||||
} from "@/text/layout";
|
} from "@/text/layout";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTimeFromSeconds } from "@/wasm";
|
||||||
import type { CreateTextElement } from "@/timeline";
|
import type { CreateTextElement } from "@/timeline";
|
||||||
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
|
import type { SubtitleCue, SubtitleStyleOverrides } from "./types";
|
||||||
|
|
||||||
|
|
@ -310,8 +310,8 @@ export function buildSubtitleTextElement({
|
||||||
...DEFAULTS.text.element,
|
...DEFAULTS.text.element,
|
||||||
name: `Caption ${index + 1}`,
|
name: `Caption ${index + 1}`,
|
||||||
content,
|
content,
|
||||||
duration: Math.round(caption.duration * TICKS_PER_SECOND),
|
duration: mediaTimeFromSeconds({ seconds: caption.duration }),
|
||||||
startTime: Math.round(caption.startTime * TICKS_PER_SECOND),
|
startTime: mediaTimeFromSeconds({ seconds: caption.startTime }),
|
||||||
fontSize: style.fontSize,
|
fontSize: style.fontSize,
|
||||||
fontFamily: style.fontFamily,
|
fontFamily: style.fontFamily,
|
||||||
color: style.color,
|
color: style.color,
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { DEFAULTS } from "@/timeline/defaults";
|
import { DEFAULTS } from "@/timeline/defaults";
|
||||||
import { buildTextElement } from "@/timeline/element-utils";
|
import { buildTextElement } from "@/timeline/element-utils";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function TextView() {
|
export function TextView() {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
||||||
const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
|
const handleAddToTimeline = ({ currentTime }: { currentTime: MediaTime }) => {
|
||||||
const activeScene = editor.scenes.getActiveScene();
|
const activeScene = editor.scenes.getActiveScene();
|
||||||
if (!activeScene) return;
|
if (!activeScene) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { uppercase } from "@/utils/string";
|
||||||
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
import { clamp, formatNumberForDisplay } from "@/utils/math";
|
||||||
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
|
import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
|
||||||
|
import {
|
||||||
|
type MediaTime,
|
||||||
|
mediaTimeFromSeconds,
|
||||||
|
mediaTimeToSeconds,
|
||||||
|
subMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
const MIN_BOOKMARK_WIDTH_PX = 2;
|
const MIN_BOOKMARK_WIDTH_PX = 2;
|
||||||
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
||||||
|
|
@ -39,12 +46,12 @@ function seekToBookmarkTime({
|
||||||
time,
|
time,
|
||||||
}: {
|
}: {
|
||||||
editor: EditorCore;
|
editor: EditorCore;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}) {
|
}) {
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
const duration = editor.timeline.getTotalDuration();
|
const duration = editor.timeline.getTotalDuration();
|
||||||
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||||
const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
|
const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime;
|
||||||
editor.playback.seek({ time: snappedTime });
|
editor.playback.seek({ time: snappedTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +144,7 @@ function TimelineBookmark({
|
||||||
|
|
||||||
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
||||||
const time = bookmark.time;
|
const time = bookmark.time;
|
||||||
const bookmarkDuration = bookmark.duration ?? 0;
|
const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME;
|
||||||
const durationWidth =
|
const durationWidth =
|
||||||
bookmarkDuration > 0
|
bookmarkDuration > 0
|
||||||
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
|
? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
|
||||||
|
|
@ -182,7 +189,7 @@ function TimelineBookmark({
|
||||||
left: `${bookmarkLeft}px`,
|
left: `${bookmarkLeft}px`,
|
||||||
width: `${bookmarkWidth}px`,
|
width: `${bookmarkWidth}px`,
|
||||||
}}
|
}}
|
||||||
aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
|
aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -286,8 +293,8 @@ function BookmarkPopoverContent({
|
||||||
onPopoverClose,
|
onPopoverClose,
|
||||||
}: {
|
}: {
|
||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
time: number;
|
time: MediaTime;
|
||||||
timelineDuration: number;
|
timelineDuration: MediaTime;
|
||||||
onPopoverClose: () => void;
|
onPopoverClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
|
@ -314,9 +321,8 @@ function BookmarkPopoverContent({
|
||||||
note,
|
note,
|
||||||
color,
|
color,
|
||||||
duration,
|
duration,
|
||||||
}: Partial<{ note: string; color: string; duration: number }>) => {
|
}: Partial<Omit<Bookmark, "time">>) => {
|
||||||
const updates: Partial<{ note: string; color: string; duration: number }> =
|
const updates: Partial<Omit<Bookmark, "time">> = {};
|
||||||
{};
|
|
||||||
if (note !== undefined && note !== bookmark.note) updates.note = note;
|
if (note !== undefined && note !== bookmark.note) updates.note = note;
|
||||||
if (
|
if (
|
||||||
color !== undefined &&
|
color !== undefined &&
|
||||||
|
|
@ -330,6 +336,15 @@ function BookmarkPopoverContent({
|
||||||
if (Object.keys(updates).length === 0) return;
|
if (Object.keys(updates).length === 0) return;
|
||||||
editor.scenes.updateBookmark({ time, updates });
|
editor.scenes.updateBookmark({ time, updates });
|
||||||
};
|
};
|
||||||
|
const maxDuration = mediaTimeToSeconds({
|
||||||
|
time:
|
||||||
|
timelineDuration > time
|
||||||
|
? subMediaTime({ a: timelineDuration, b: time })
|
||||||
|
: ZERO_MEDIA_TIME,
|
||||||
|
});
|
||||||
|
const durationSeconds = mediaTimeToSeconds({
|
||||||
|
time: bookmark.duration ?? ZERO_MEDIA_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -387,7 +402,7 @@ function BookmarkPopoverContent({
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
value={bookmark.duration ?? 0}
|
value={durationSeconds}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const parsed = parseFloat(event.target.value);
|
const parsed = parseFloat(event.target.value);
|
||||||
const value = Number.isNaN(parsed)
|
const value = Number.isNaN(parsed)
|
||||||
|
|
@ -395,9 +410,11 @@ function BookmarkPopoverContent({
|
||||||
: clamp({
|
: clamp({
|
||||||
value: parsed,
|
value: parsed,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: Math.max(0, timelineDuration - time),
|
max: maxDuration,
|
||||||
});
|
});
|
||||||
handleUpdate({ duration: value });
|
handleUpdate({
|
||||||
|
duration: mediaTimeFromSeconds({ seconds: value }),
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="h-8 text-sm"
|
className="h-8 text-sm"
|
||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,16 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||||
import type { Bookmark } from "@/timeline";
|
import type { Bookmark } from "@/timeline";
|
||||||
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export interface BookmarkDragState {
|
export interface BookmarkDragState {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
bookmarkTime: number | null;
|
bookmarkTime: MediaTime | null;
|
||||||
currentTime: number;
|
currentTime: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingBookmarkDrag {
|
interface PendingBookmarkDrag {
|
||||||
bookmarkTime: number;
|
bookmarkTime: MediaTime;
|
||||||
startMouseX: number;
|
startMouseX: number;
|
||||||
startMouseY: number;
|
startMouseY: number;
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,7 @@ export function useBookmarkDrag({
|
||||||
const [dragState, setDragState] = useState<BookmarkDragState>({
|
const [dragState, setDragState] = useState<BookmarkDragState>({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
bookmarkTime: null,
|
bookmarkTime: null,
|
||||||
currentTime: 0,
|
currentTime: ZERO_MEDIA_TIME,
|
||||||
});
|
});
|
||||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||||
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
|
const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
|
||||||
|
|
@ -69,8 +70,8 @@ export function useBookmarkDrag({
|
||||||
bookmarkTime,
|
bookmarkTime,
|
||||||
initialCurrentTime,
|
initialCurrentTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarkTime: number;
|
bookmarkTime: MediaTime;
|
||||||
initialCurrentTime: number;
|
initialCurrentTime: MediaTime;
|
||||||
}) => {
|
}) => {
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
|
|
@ -85,7 +86,7 @@ export function useBookmarkDrag({
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
bookmarkTime: null,
|
bookmarkTime: null,
|
||||||
currentTime: 0,
|
currentTime: ZERO_MEDIA_TIME,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -94,9 +95,9 @@ export function useBookmarkDrag({
|
||||||
rawTime,
|
rawTime,
|
||||||
excludeBookmarkTime,
|
excludeBookmarkTime,
|
||||||
}: {
|
}: {
|
||||||
rawTime: number;
|
rawTime: MediaTime;
|
||||||
excludeBookmarkTime: number;
|
excludeBookmarkTime: MediaTime;
|
||||||
}): { snappedTime: number; snapPoint: SnapPoint | null } => {
|
}): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => {
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
if (!shouldSnap) {
|
if (!shouldSnap) {
|
||||||
return { snappedTime: rawTime, snapPoint: null };
|
return { snappedTime: rawTime, snapPoint: null };
|
||||||
|
|
@ -116,7 +117,7 @@ export function useBookmarkDrag({
|
||||||
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
snappedTime: result.snappedTime,
|
snappedTime: result.snappedTime as MediaTime,
|
||||||
snapPoint: result.snapPoint,
|
snapPoint: result.snapPoint,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -162,11 +163,14 @@ export function useBookmarkDrag({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const frameSnappedTime =
|
const clampedTime =
|
||||||
|
mouseTime > duration ? duration : mouseTime;
|
||||||
|
const frameSnappedTime = (
|
||||||
roundToFrame({
|
roundToFrame({
|
||||||
time: Math.max(0, Math.min(mouseTime, duration)),
|
time: clampedTime,
|
||||||
rate: activeProject.settings.fps,
|
rate: activeProject.settings.fps,
|
||||||
}) ?? Math.max(0, Math.min(mouseTime, duration));
|
}) ?? clampedTime
|
||||||
|
) as MediaTime;
|
||||||
const { snappedTime: initialTime } = getSnapResult({
|
const { snappedTime: initialTime } = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: bookmarkTime,
|
excludeBookmarkTime: bookmarkTime,
|
||||||
|
|
@ -193,10 +197,12 @@ export function useBookmarkDrag({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const clampedTime = Math.max(0, Math.min(mouseTime, duration));
|
const clampedTime =
|
||||||
const frameSnappedTime =
|
mouseTime > duration ? duration : mouseTime;
|
||||||
|
const frameSnappedTime = (
|
||||||
roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
|
roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
|
||||||
clampedTime;
|
clampedTime
|
||||||
|
) as MediaTime;
|
||||||
const snapResult = getSnapResult({
|
const snapResult = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: dragState.bookmarkTime,
|
excludeBookmarkTime: dragState.bookmarkTime,
|
||||||
|
|
@ -234,10 +240,8 @@ export function useBookmarkDrag({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = Math.max(
|
const clampedTime =
|
||||||
0,
|
dragState.currentTime > duration ? duration : dragState.currentTime;
|
||||||
Math.min(dragState.currentTime, duration),
|
|
||||||
);
|
|
||||||
|
|
||||||
editor.scenes.moveBookmark({
|
editor.scenes.moveBookmark({
|
||||||
fromTime: dragState.bookmarkTime,
|
fromTime: dragState.bookmarkTime,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
type PreviewOverlaySourceResult,
|
type PreviewOverlaySourceResult,
|
||||||
} from "@/preview/overlays";
|
} from "@/preview/overlays";
|
||||||
import { getBookmarksActiveAtTime } from "./utils";
|
import { getBookmarksActiveAtTime } from "./utils";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
|
export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
|
||||||
id: "bookmark-notes",
|
id: "bookmark-notes",
|
||||||
|
|
@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
|
||||||
function BookmarkNotesOverlay({
|
function BookmarkNotesOverlay({
|
||||||
bookmarks,
|
bookmarks,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Array<{ time: number; note: string; color?: string }>;
|
bookmarks: Array<{ time: MediaTime; note: string; color?: string }>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5" aria-live="polite">
|
<div className="flex flex-col gap-1.5" aria-live="polite">
|
||||||
|
|
@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({
|
||||||
isVisible,
|
isVisible,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
time: number;
|
time: MediaTime;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
}): PreviewOverlaySourceResult {
|
}): PreviewOverlaySourceResult {
|
||||||
const bookmarksWithNotes = getBookmarksActiveAtTime({
|
const bookmarksWithNotes = getBookmarksActiveAtTime({
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import type { Bookmark } from "@/timeline";
|
import type { Bookmark } from "@/timeline";
|
||||||
import type { FrameRate } from "opencut-wasm";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
|
import { addMediaTime, type MediaTime } from "@/wasm";
|
||||||
|
|
||||||
function bookmarkTimeEqual({
|
function bookmarkTimeEqual({
|
||||||
bookmarkTime,
|
bookmarkTime,
|
||||||
frameTime,
|
frameTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarkTime: number;
|
bookmarkTime: MediaTime;
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return bookmarkTime === frameTime;
|
return bookmarkTime === frameTime;
|
||||||
}
|
}
|
||||||
|
|
@ -17,7 +18,7 @@ export function findBookmarkIndex({
|
||||||
frameTime,
|
frameTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
}): number {
|
}): number {
|
||||||
return bookmarks.findIndex((bookmark) =>
|
return bookmarks.findIndex((bookmark) =>
|
||||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||||
|
|
@ -29,7 +30,7 @@ export function isBookmarkAtTime({
|
||||||
frameTime,
|
frameTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return bookmarks.some((bookmark) =>
|
return bookmarks.some((bookmark) =>
|
||||||
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
|
||||||
|
|
@ -41,7 +42,7 @@ export function toggleBookmarkInArray({
|
||||||
frameTime,
|
frameTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
}): Bookmark[] {
|
}): Bookmark[] {
|
||||||
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
|
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ export function removeBookmarkFromArray({
|
||||||
frameTime,
|
frameTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
}): Bookmark[] {
|
}): Bookmark[] {
|
||||||
return bookmarks.filter(
|
return bookmarks.filter(
|
||||||
(bookmark) =>
|
(bookmark) =>
|
||||||
|
|
@ -72,7 +73,7 @@ export function updateBookmarkInArray({
|
||||||
updates,
|
updates,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
updates: Partial<Omit<Bookmark, "time">>;
|
updates: Partial<Omit<Bookmark, "time">>;
|
||||||
}): Bookmark[] {
|
}): Bookmark[] {
|
||||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||||
|
|
@ -92,8 +93,8 @@ export function moveBookmarkInArray({
|
||||||
toTime,
|
toTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
fromTime: number;
|
fromTime: MediaTime;
|
||||||
toTime: number;
|
toTime: MediaTime;
|
||||||
}): Bookmark[] {
|
}): Bookmark[] {
|
||||||
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
|
const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
|
|
@ -110,10 +111,10 @@ export function getFrameTime({
|
||||||
time,
|
time,
|
||||||
fps,
|
fps,
|
||||||
}: {
|
}: {
|
||||||
time: number;
|
time: MediaTime;
|
||||||
fps: FrameRate;
|
fps: FrameRate;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
return roundToFrame({ time, rate: fps }) ?? time;
|
return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBookmarkAtTime({
|
export function getBookmarkAtTime({
|
||||||
|
|
@ -121,7 +122,7 @@ export function getBookmarkAtTime({
|
||||||
frameTime,
|
frameTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
frameTime: number;
|
frameTime: MediaTime;
|
||||||
}): Bookmark | null {
|
}): Bookmark | null {
|
||||||
const index = findBookmarkIndex({ bookmarks, frameTime });
|
const index = findBookmarkIndex({ bookmarks, frameTime });
|
||||||
return index === -1 ? null : bookmarks[index];
|
return index === -1 ? null : bookmarks[index];
|
||||||
|
|
@ -132,13 +133,13 @@ export function getBookmarksActiveAtTime({
|
||||||
time,
|
time,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
time: number;
|
time: MediaTime;
|
||||||
}): Bookmark[] {
|
}): Bookmark[] {
|
||||||
return bookmarks.filter((bookmark) => {
|
return bookmarks.filter((bookmark) => {
|
||||||
const start = bookmark.time;
|
const start = bookmark.time;
|
||||||
const end =
|
const end =
|
||||||
bookmark.duration != null && bookmark.duration > 0
|
bookmark.duration != null && bookmark.duration > 0
|
||||||
? start + bookmark.duration
|
? addMediaTime({ a: start, b: bookmark.duration })
|
||||||
: start;
|
: start;
|
||||||
return time >= start && time <= end;
|
return time >= start && time <= end;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
|
||||||
import { resolveTrackPlacement } from "@/timeline/placement";
|
import { resolveTrackPlacement } from "@/timeline/placement";
|
||||||
import { TIMELINE_TRACK_GAP_PX } from "./layout";
|
import { TIMELINE_TRACK_GAP_PX } from "./layout";
|
||||||
import { getTrackHeight } from "./track-layout";
|
import { getTrackHeight } from "./track-layout";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import {
|
||||||
|
mediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
roundMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
function findElementAtPosition({
|
function findElementAtPosition({
|
||||||
mouseX,
|
mouseX,
|
||||||
|
|
@ -20,9 +25,11 @@ function findElementAtPosition({
|
||||||
pixelsPerSecond: number;
|
pixelsPerSecond: number;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}): { elementId: string; trackId: string } | null {
|
}): { elementId: string; trackId: string } | null {
|
||||||
const time = Math.round(
|
const time = mediaTime({
|
||||||
(mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
|
ticks: Math.round(
|
||||||
);
|
(mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
|
||||||
|
),
|
||||||
|
});
|
||||||
const track = tracks[trackIndex];
|
const track = tracks[trackIndex];
|
||||||
if (!track || !("elements" in track)) return null;
|
if (!track || !("elements" in track)) return null;
|
||||||
|
|
||||||
|
|
@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null;
|
||||||
function fallbackNewTrackDropTarget({
|
function fallbackNewTrackDropTarget({
|
||||||
xPosition,
|
xPosition,
|
||||||
}: {
|
}: {
|
||||||
xPosition: number;
|
xPosition: MediaTime;
|
||||||
}): DropTarget {
|
}): DropTarget {
|
||||||
return {
|
return {
|
||||||
trackIndex: 0,
|
trackIndex: 0,
|
||||||
|
|
@ -111,13 +118,16 @@ export function computeDropTarget({
|
||||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
const mainTrackIndex = tracks.overlay.length;
|
const mainTrackIndex = tracks.overlay.length;
|
||||||
const xPosition =
|
const xPosition =
|
||||||
typeof startTimeOverride === "number"
|
startTimeOverride !== undefined
|
||||||
? startTimeOverride
|
? startTimeOverride
|
||||||
: isExternalDrop
|
: isExternalDrop
|
||||||
? playheadTime
|
? playheadTime
|
||||||
: Math.round(
|
: mediaTime({
|
||||||
Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
|
ticks: Math.round(
|
||||||
);
|
Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) *
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
if (orderedTracks.length === 0) {
|
if (orderedTracks.length === 0) {
|
||||||
const placementResult = resolveTrackPlacement({
|
const placementResult = resolveTrackPlacement({
|
||||||
|
|
@ -221,7 +231,10 @@ export function computeDropTarget({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (placementResult.kind === "existingTrack") {
|
if (placementResult.kind === "existingTrack") {
|
||||||
const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
|
const adjustedXPosition =
|
||||||
|
placementResult.adjustedStartTime !== undefined
|
||||||
|
? roundMediaTime({ time: placementResult.adjustedStartTime })
|
||||||
|
: xPosition;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trackIndex: placementResult.trackIndex,
|
trackIndex: placementResult.trackIndex,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
import type { ElementDragState, DropTarget } from "@/timeline";
|
import type { ElementDragState, DropTarget } from "@/timeline";
|
||||||
import { TimelineTrackContent } from "./timeline-track";
|
import { TimelineTrackContent } from "./timeline-track";
|
||||||
import { TimelinePlayhead } from "./timeline-playhead";
|
import { TimelinePlayhead } from "./timeline-playhead";
|
||||||
|
|
@ -132,7 +133,7 @@ export function Timeline() {
|
||||||
[scene],
|
[scene],
|
||||||
);
|
);
|
||||||
const mainTrackId = scene?.tracks.main.id ?? null;
|
const mainTrackId = scene?.tracks.main.id ?? null;
|
||||||
const seek = (time: number) => editor.playback.seek({ time });
|
const seek = (time: MediaTime) => editor.playback.seek({ time });
|
||||||
|
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineHeaderRef = useRef<HTMLDivElement>(null);
|
const timelineHeaderRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ import {
|
||||||
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
||||||
import { getTimelinePixelsPerSecond } from "@/timeline";
|
import { getTimelinePixelsPerSecond } from "@/timeline";
|
||||||
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm/ticks";
|
import { addMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
import {
|
import {
|
||||||
getActionDefinition,
|
getActionDefinition,
|
||||||
type TAction,
|
type TAction,
|
||||||
|
|
@ -257,10 +257,11 @@ export function TimelineElement({
|
||||||
isBeingDragged && dragState.isDragging
|
isBeingDragged && dragState.isDragging
|
||||||
? dragState.currentMouseY - dragState.startMouseY
|
? dragState.currentMouseY - dragState.startMouseY
|
||||||
: 0;
|
: 0;
|
||||||
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
|
const dragTimeOffset =
|
||||||
|
dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME;
|
||||||
const elementStartTime =
|
const elementStartTime =
|
||||||
isBeingDragged && dragState.isDragging
|
isBeingDragged && dragState.isDragging
|
||||||
? dragState.currentTime + dragTimeOffset
|
? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset })
|
||||||
: renderElement.startTime;
|
: renderElement.startTime;
|
||||||
const displayedStartTime = elementStartTime;
|
const displayedStartTime = elementStartTime;
|
||||||
const displayedDuration = renderElement.duration;
|
const displayedDuration = renderElement.duration;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,14 @@ import {
|
||||||
timelineTimeToSnappedPixels,
|
timelineTimeToSnappedPixels,
|
||||||
} from "@/timeline";
|
} from "@/timeline";
|
||||||
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
|
import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
mediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
|
import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
|
||||||
import { TIMELINE_LAYERS } from "./layers";
|
import { TIMELINE_LAYERS } from "./layers";
|
||||||
|
|
@ -72,17 +79,24 @@ export function TimelinePlayhead({
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const fps = editor.project.getActive().settings.fps;
|
const fps = editor.project.getActive().settings.fps;
|
||||||
const ticksPerFrame = Math.round(
|
const ticksPerFrame = mediaTime({
|
||||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
ticks: Math.round(
|
||||||
);
|
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||||
|
),
|
||||||
|
});
|
||||||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||||||
const now = editor.playback.getCurrentTime();
|
const now = editor.playback.getCurrentTime();
|
||||||
const nextTime = Math.max(
|
const nextTime =
|
||||||
0,
|
direction > 0
|
||||||
Math.min(duration, now + direction * ticksPerFrame),
|
? addMediaTime({ a: now, b: ticksPerFrame })
|
||||||
);
|
: subMediaTime({ a: now, b: ticksPerFrame });
|
||||||
|
|
||||||
editor.playback.seek({ time: nextTime });
|
editor.playback.seek({
|
||||||
|
time: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: duration < nextTime ? duration : nextTime,
|
||||||
|
}),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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 { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||||
import type { TTimelineViewState } from "@/project/types";
|
import type { TTimelineViewState } from "@/project/types";
|
||||||
import type { BlendMode, Transform } from "@/rendering";
|
import type { BlendMode, Transform } from "@/rendering";
|
||||||
|
import { ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
import type { TextElement } from "./types";
|
import type { TextElement } from "./types";
|
||||||
|
|
||||||
const defaultTransform: Transform = {
|
const defaultTransform: Transform = {
|
||||||
|
|
@ -42,9 +43,9 @@ const defaultTextElement: Omit<TextElement, "id"> = {
|
||||||
letterSpacing: defaultTextLetterSpacing,
|
letterSpacing: defaultTextLetterSpacing,
|
||||||
lineHeight: defaultTextLineHeight,
|
lineHeight: defaultTextLineHeight,
|
||||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime: 0,
|
startTime: ZERO_MEDIA_TIME,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
transform: {
|
transform: {
|
||||||
...defaultTransform,
|
...defaultTransform,
|
||||||
position: { ...defaultTransform.position },
|
position: { ...defaultTransform.position },
|
||||||
|
|
@ -55,7 +56,7 @@ const defaultTextElement: Omit<TextElement, "id"> = {
|
||||||
const defaultTimelineViewState: TTimelineViewState = {
|
const defaultTimelineViewState: TTimelineViewState = {
|
||||||
zoomLevel: 1,
|
zoomLevel: 1,
|
||||||
scrollLeft: 0,
|
scrollLeft: 0,
|
||||||
playheadTime: 0,
|
playheadTime: ZERO_MEDIA_TIME,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULTS = {
|
export const DEFAULTS = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||||
|
|
||||||
export function getMouseTimeFromClientX({
|
export function getMouseTimeFromClientX({
|
||||||
clientX,
|
clientX,
|
||||||
|
|
@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({
|
||||||
containerRect: DOMRect;
|
containerRect: DOMRect;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
scrollLeft: number;
|
scrollLeft: number;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
const mouseX = clientX - containerRect.left + scrollLeft;
|
const mouseX = clientX - containerRect.left + scrollLeft;
|
||||||
const seconds = Math.max(
|
const seconds = Math.max(
|
||||||
0,
|
0,
|
||||||
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
);
|
);
|
||||||
return Math.round(seconds * TICKS_PER_SECOND);
|
return mediaTime({
|
||||||
|
ticks: Math.round(seconds * TICKS_PER_SECOND),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects";
|
||||||
import { buildDefaultGraphicInstance } from "@/graphics";
|
import { buildDefaultGraphicInstance } from "@/graphics";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
import { capitalizeFirstLetter } from "@/utils/string";
|
import { capitalizeFirstLetter } from "@/utils/string";
|
||||||
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export function canElementHaveAudio(
|
export function canElementHaveAudio(
|
||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
|
|
@ -107,7 +108,7 @@ export function buildTextElement({
|
||||||
startTime,
|
startTime,
|
||||||
}: {
|
}: {
|
||||||
raw: Partial<Omit<TextElement, "type" | "id">>;
|
raw: Partial<Omit<TextElement, "type" | "id">>;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
}): CreateTimelineElement {
|
}): CreateTimelineElement {
|
||||||
const t = raw as Partial<TextElement>;
|
const t = raw as Partial<TextElement>;
|
||||||
|
|
||||||
|
|
@ -117,8 +118,8 @@ export function buildTextElement({
|
||||||
content: t.content ?? DEFAULTS.text.element.content,
|
content: t.content ?? DEFAULTS.text.element.content,
|
||||||
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
|
fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
|
||||||
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
|
||||||
color: t.color ?? DEFAULTS.text.element.color,
|
color: t.color ?? DEFAULTS.text.element.color,
|
||||||
|
|
@ -141,8 +142,8 @@ export function buildEffectElement({
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
effectType: string;
|
effectType: string;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
duration?: number;
|
duration?: MediaTime;
|
||||||
}): CreateEffectElement {
|
}): CreateEffectElement {
|
||||||
const instance = buildDefaultEffectInstance({ effectType });
|
const instance = buildDefaultEffectInstance({ effectType });
|
||||||
return {
|
return {
|
||||||
|
|
@ -152,8 +153,8 @@ export function buildEffectElement({
|
||||||
params: instance.params,
|
params: instance.params,
|
||||||
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,7 +167,7 @@ export function buildStickerElement({
|
||||||
}: {
|
}: {
|
||||||
stickerId: string;
|
stickerId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
intrinsicWidth?: number;
|
intrinsicWidth?: number;
|
||||||
intrinsicHeight?: number;
|
intrinsicHeight?: number;
|
||||||
}): CreateStickerElement {
|
}): CreateStickerElement {
|
||||||
|
|
@ -180,8 +181,8 @@ export function buildStickerElement({
|
||||||
intrinsicHeight,
|
intrinsicHeight,
|
||||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
position: { ...DEFAULTS.element.transform.position },
|
position: { ...DEFAULTS.element.transform.position },
|
||||||
|
|
@ -199,7 +200,7 @@ export function buildGraphicElement({
|
||||||
}: {
|
}: {
|
||||||
definitionId: string;
|
definitionId: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
params?: Partial<ParamValues>;
|
params?: Partial<ParamValues>;
|
||||||
}): CreateGraphicElement {
|
}): CreateGraphicElement {
|
||||||
const instance = buildDefaultGraphicInstance({ definitionId });
|
const instance = buildDefaultGraphicInstance({ definitionId });
|
||||||
|
|
@ -210,8 +211,8 @@ export function buildGraphicElement({
|
||||||
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
params: { ...instance.params, ...(params ?? {}) } as ParamValues,
|
||||||
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
duration: DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
position: { ...DEFAULTS.element.transform.position },
|
position: { ...DEFAULTS.element.transform.position },
|
||||||
|
|
@ -229,8 +230,8 @@ function buildVideoElement({
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
}): CreateVideoElement {
|
}): CreateVideoElement {
|
||||||
return {
|
return {
|
||||||
type: "video",
|
type: "video",
|
||||||
|
|
@ -238,8 +239,8 @@ function buildVideoElement({
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
sourceDuration: duration,
|
sourceDuration: duration,
|
||||||
muted: false,
|
muted: false,
|
||||||
isSourceAudioEnabled: true,
|
isSourceAudioEnabled: true,
|
||||||
|
|
@ -262,8 +263,8 @@ function buildImageElement({
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
}): CreateImageElement {
|
}): CreateImageElement {
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: "image",
|
||||||
|
|
@ -271,8 +272,8 @@ function buildImageElement({
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
transform: {
|
transform: {
|
||||||
...DEFAULTS.element.transform,
|
...DEFAULTS.element.transform,
|
||||||
|
|
@ -292,8 +293,8 @@ function buildUploadAudioElement({
|
||||||
}: {
|
}: {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateUploadAudioElement {
|
}): CreateUploadAudioElement {
|
||||||
const element: CreateUploadAudioElement = {
|
const element: CreateUploadAudioElement = {
|
||||||
|
|
@ -303,8 +304,8 @@ function buildUploadAudioElement({
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
sourceDuration: duration,
|
sourceDuration: duration,
|
||||||
volume: DEFAULTS.element.volume,
|
volume: DEFAULTS.element.volume,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
|
@ -326,8 +327,8 @@ export function buildElementFromMedia({
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateTimelineElement {
|
}): CreateTimelineElement {
|
||||||
switch (mediaType) {
|
switch (mediaType) {
|
||||||
|
|
@ -355,8 +356,8 @@ export function buildLibraryAudioElement({
|
||||||
}: {
|
}: {
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
buffer?: AudioBuffer;
|
buffer?: AudioBuffer;
|
||||||
}): CreateLibraryAudioElement {
|
}): CreateLibraryAudioElement {
|
||||||
const element: CreateLibraryAudioElement = {
|
const element: CreateLibraryAudioElement = {
|
||||||
|
|
@ -366,8 +367,8 @@ export function buildLibraryAudioElement({
|
||||||
name,
|
name,
|
||||||
duration,
|
duration,
|
||||||
startTime,
|
startTime,
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
sourceDuration: duration,
|
sourceDuration: duration,
|
||||||
volume: DEFAULTS.element.volume,
|
volume: DEFAULTS.element.volume,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline";
|
||||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||||
import type { GroupMember, MoveGroup } from "./types";
|
import type { GroupMember, MoveGroup } from "./types";
|
||||||
import { getTrackPlacementById } from "./track-placement";
|
import { getTrackPlacementById } from "./track-placement";
|
||||||
|
import { subMediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function buildMoveGroup({
|
export function buildMoveGroup({
|
||||||
anchorRef,
|
anchorRef,
|
||||||
|
|
@ -59,7 +60,10 @@ export function buildMoveGroup({
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
elementType: element.type,
|
elementType: element.type,
|
||||||
duration: element.duration,
|
duration: element.duration,
|
||||||
timeOffset: element.startTime - anchorElement.startTime,
|
timeOffset: subMediaTime({
|
||||||
|
a: element.startTime,
|
||||||
|
b: anchorElement.startTime,
|
||||||
|
}),
|
||||||
trackSection: placement.section,
|
trackSection: placement.section,
|
||||||
sectionIndex: placement.sectionIndex,
|
sectionIndex: placement.sectionIndex,
|
||||||
displayIndex: placement.displayIndex,
|
displayIndex: placement.displayIndex,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ import {
|
||||||
getTrackPlacementByDisplayIndex,
|
getTrackPlacementByDisplayIndex,
|
||||||
getTrackPlacementById,
|
getTrackPlacementById,
|
||||||
} from "./track-placement";
|
} from "./track-placement";
|
||||||
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
type GroupMoveTarget =
|
type GroupMoveTarget =
|
||||||
| {
|
| {
|
||||||
|
|
@ -32,7 +39,7 @@ export function resolveGroupMove({
|
||||||
}: {
|
}: {
|
||||||
group: MoveGroup;
|
group: MoveGroup;
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
anchorStartTime: number;
|
anchorStartTime: MediaTime;
|
||||||
target: GroupMoveTarget;
|
target: GroupMoveTarget;
|
||||||
}): GroupMoveResult | null {
|
}): GroupMoveResult | null {
|
||||||
if (target.kind === "newTracks") {
|
if (target.kind === "newTracks") {
|
||||||
|
|
@ -61,7 +68,7 @@ function resolveExistingTrackMove({
|
||||||
}: {
|
}: {
|
||||||
group: MoveGroup;
|
group: MoveGroup;
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
anchorStartTime: number;
|
anchorStartTime: MediaTime;
|
||||||
anchorTargetTrackId: string;
|
anchorTargetTrackId: string;
|
||||||
}): GroupMoveResult | null {
|
}): GroupMoveResult | null {
|
||||||
const anchorTargetPlacement = getTrackPlacementById({
|
const anchorTargetPlacement = getTrackPlacementById({
|
||||||
|
|
@ -93,7 +100,10 @@ function resolveExistingTrackMove({
|
||||||
targetTrackId:
|
targetTrackId:
|
||||||
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
|
targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
|
||||||
elementId: member.elementId,
|
elementId: member.elementId,
|
||||||
newStartTime: clampedAnchorStartTime + member.timeOffset,
|
newStartTime: addMediaTime({
|
||||||
|
a: clampedAnchorStartTime,
|
||||||
|
b: member.timeOffset,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!canApplyMovesToExistingTracks({ tracks, moves })) {
|
if (!canApplyMovesToExistingTracks({ tracks, moves })) {
|
||||||
|
|
@ -119,7 +129,7 @@ function resolveNewTrackMove({
|
||||||
}: {
|
}: {
|
||||||
group: MoveGroup;
|
group: MoveGroup;
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
anchorStartTime: number;
|
anchorStartTime: MediaTime;
|
||||||
anchorInsertIndex: number;
|
anchorInsertIndex: number;
|
||||||
newTrackIds: string[];
|
newTrackIds: string[];
|
||||||
}): GroupMoveResult | null {
|
}): GroupMoveResult | null {
|
||||||
|
|
@ -173,7 +183,10 @@ function resolveNewTrackMove({
|
||||||
sourceTrackId: member.trackId,
|
sourceTrackId: member.trackId,
|
||||||
targetTrackId: newTrackIds[memberIndex],
|
targetTrackId: newTrackIds[memberIndex],
|
||||||
elementId: member.elementId,
|
elementId: member.elementId,
|
||||||
newStartTime: clampedAnchorStartTime + member.timeOffset,
|
newStartTime: addMediaTime({
|
||||||
|
a: clampedAnchorStartTime,
|
||||||
|
b: member.timeOffset,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -335,17 +348,26 @@ function clampAnchorStartTime({
|
||||||
}: {
|
}: {
|
||||||
group: MoveGroup;
|
group: MoveGroup;
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
anchorStartTime: number;
|
anchorStartTime: MediaTime;
|
||||||
targetTrackIdsByElementId: Map<string, string>;
|
targetTrackIdsByElementId: Map<string, string>;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
const minimumAnchorStartTime = Math.max(
|
const minimumAnchorStartTime = group.members.reduce(
|
||||||
0,
|
(minimumStartTime, member) =>
|
||||||
...group.members.map((member) => -member.timeOffset),
|
member.timeOffset < ZERO_MEDIA_TIME
|
||||||
);
|
? maxMediaTime({
|
||||||
let clampedAnchorStartTime = Math.max(
|
a: minimumStartTime,
|
||||||
minimumAnchorStartTime,
|
b: subMediaTime({
|
||||||
anchorStartTime,
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: member.timeOffset,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: minimumStartTime,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
);
|
);
|
||||||
|
let clampedAnchorStartTime =
|
||||||
|
anchorStartTime < minimumAnchorStartTime
|
||||||
|
? minimumAnchorStartTime
|
||||||
|
: anchorStartTime;
|
||||||
|
|
||||||
const memberOnMainTrack = group.members.find(
|
const memberOnMainTrack = group.members.find(
|
||||||
(member) =>
|
(member) =>
|
||||||
|
|
@ -358,11 +380,13 @@ function clampAnchorStartTime({
|
||||||
const movingElementIds = new Set(
|
const movingElementIds = new Set(
|
||||||
group.members.map((member) => member.elementId),
|
group.members.map((member) => member.elementId),
|
||||||
);
|
);
|
||||||
const requestedMainStartTime =
|
const requestedMainStartTime = addMediaTime({
|
||||||
clampedAnchorStartTime + memberOnMainTrack.timeOffset;
|
a: clampedAnchorStartTime,
|
||||||
|
b: memberOnMainTrack.timeOffset,
|
||||||
|
});
|
||||||
const earliestStationaryMainStartTime = tracks.main.elements
|
const earliestStationaryMainStartTime = tracks.main.elements
|
||||||
.filter((element) => !movingElementIds.has(element.id))
|
.filter((element) => !movingElementIds.has(element.id))
|
||||||
.reduce<number | null>((earliestStartTime, element) => {
|
.reduce<MediaTime | null>((earliestStartTime, element) => {
|
||||||
if (earliestStartTime == null || element.startTime < earliestStartTime) {
|
if (earliestStartTime == null || element.startTime < earliestStartTime) {
|
||||||
return element.startTime;
|
return element.startTime;
|
||||||
}
|
}
|
||||||
|
|
@ -373,10 +397,13 @@ function clampAnchorStartTime({
|
||||||
earliestStationaryMainStartTime == null ||
|
earliestStationaryMainStartTime == null ||
|
||||||
requestedMainStartTime <= earliestStationaryMainStartTime
|
requestedMainStartTime <= earliestStationaryMainStartTime
|
||||||
) {
|
) {
|
||||||
clampedAnchorStartTime = Math.max(
|
clampedAnchorStartTime = maxMediaTime({
|
||||||
minimumAnchorStartTime,
|
a: minimumAnchorStartTime,
|
||||||
-memberOnMainTrack.timeOffset,
|
b: subMediaTime({
|
||||||
);
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: memberOnMainTrack.timeOffset,
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return clampedAnchorStartTime;
|
return clampedAnchorStartTime;
|
||||||
|
|
@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({
|
||||||
const sourceElement = sourceElements.get(move.elementId);
|
const sourceElement = sourceElements.get(move.elementId);
|
||||||
return {
|
return {
|
||||||
startTime: move.newStartTime,
|
startTime: move.newStartTime,
|
||||||
duration: sourceElement?.duration ?? 0,
|
duration: sourceElement?.duration ?? ZERO_MEDIA_TIME,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (hasOverlappingTimeSpans({ timeSpans })) {
|
if (hasOverlappingTimeSpans({ timeSpans })) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||||
import type { MoveGroup } from "./types";
|
import type { MoveGroup } from "./types";
|
||||||
|
import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function snapGroupEdges({
|
export function snapGroupEdges({
|
||||||
group,
|
group,
|
||||||
|
|
@ -18,12 +19,12 @@ export function snapGroupEdges({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
}: {
|
}: {
|
||||||
group: MoveGroup;
|
group: MoveGroup;
|
||||||
anchorStartTime: number;
|
anchorStartTime: MediaTime;
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
playheadTime: number;
|
playheadTime: MediaTime;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}): {
|
}): {
|
||||||
snappedAnchorStartTime: number;
|
snappedAnchorStartTime: MediaTime;
|
||||||
snapPoint: SnapPoint | null;
|
snapPoint: SnapPoint | null;
|
||||||
} {
|
} {
|
||||||
const excludeElementIds = new Set(
|
const excludeElementIds = new Set(
|
||||||
|
|
@ -47,7 +48,10 @@ export function snapGroupEdges({
|
||||||
let snapPoint: SnapPoint | null = null;
|
let snapPoint: SnapPoint | null = null;
|
||||||
|
|
||||||
for (const member of group.members) {
|
for (const member of group.members) {
|
||||||
const memberStartTime = anchorStartTime + member.timeOffset;
|
const memberStartTime = addMediaTime({
|
||||||
|
a: anchorStartTime,
|
||||||
|
b: member.timeOffset,
|
||||||
|
});
|
||||||
const memberStartSnap = resolveTimelineSnap({
|
const memberStartSnap = resolveTimelineSnap({
|
||||||
targetTime: memberStartTime,
|
targetTime: memberStartTime,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
|
|
@ -58,12 +62,18 @@ export function snapGroupEdges({
|
||||||
memberStartSnap.snapDistance < closestSnapDistance
|
memberStartSnap.snapDistance < closestSnapDistance
|
||||||
) {
|
) {
|
||||||
closestSnapDistance = memberStartSnap.snapDistance;
|
closestSnapDistance = memberStartSnap.snapDistance;
|
||||||
snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
|
snappedAnchorStartTime = subMediaTime({
|
||||||
|
a: memberStartSnap.snappedTime as MediaTime,
|
||||||
|
b: member.timeOffset,
|
||||||
|
});
|
||||||
snapPoint = memberStartSnap.snapPoint;
|
snapPoint = memberStartSnap.snapPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberEndSnap = resolveTimelineSnap({
|
const memberEndSnap = resolveTimelineSnap({
|
||||||
targetTime: memberStartTime + member.duration,
|
targetTime: addMediaTime({
|
||||||
|
a: memberStartTime,
|
||||||
|
b: member.duration,
|
||||||
|
}),
|
||||||
snapPoints,
|
snapPoints,
|
||||||
maxSnapDistance,
|
maxSnapDistance,
|
||||||
});
|
});
|
||||||
|
|
@ -72,8 +82,13 @@ export function snapGroupEdges({
|
||||||
memberEndSnap.snapDistance < closestSnapDistance
|
memberEndSnap.snapDistance < closestSnapDistance
|
||||||
) {
|
) {
|
||||||
closestSnapDistance = memberEndSnap.snapDistance;
|
closestSnapDistance = memberEndSnap.snapDistance;
|
||||||
snappedAnchorStartTime =
|
snappedAnchorStartTime = subMediaTime({
|
||||||
memberEndSnap.snappedTime - member.duration - member.timeOffset;
|
a: subMediaTime({
|
||||||
|
a: memberEndSnap.snappedTime as MediaTime,
|
||||||
|
b: member.duration,
|
||||||
|
}),
|
||||||
|
b: member.timeOffset,
|
||||||
|
});
|
||||||
snapPoint = memberEndSnap.snapPoint;
|
snapPoint = memberEndSnap.snapPoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import type { ElementRef, ElementType, TrackType } from "@/timeline";
|
import type { ElementRef, ElementType, TrackType } from "@/timeline";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export type GroupTrackSection = "overlay" | "main" | "audio";
|
export type GroupTrackSection = "overlay" | "main" | "audio";
|
||||||
|
|
||||||
export interface GroupMember extends ElementRef {
|
export interface GroupMember extends ElementRef {
|
||||||
elementType: ElementType;
|
elementType: ElementType;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
timeOffset: number;
|
timeOffset: MediaTime;
|
||||||
trackSection: GroupTrackSection;
|
trackSection: GroupTrackSection;
|
||||||
sectionIndex: number;
|
sectionIndex: number;
|
||||||
displayIndex: number;
|
displayIndex: number;
|
||||||
|
|
@ -26,7 +27,7 @@ export interface PlannedElementMove {
|
||||||
sourceTrackId: string;
|
sourceTrackId: string;
|
||||||
targetTrackId: string;
|
targetTrackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
newStartTime: number;
|
newStartTime: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupMoveResult {
|
export interface GroupMoveResult {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
getSourceSpanAtClipTime,
|
getSourceSpanAtClipTime,
|
||||||
getTimelineDurationForSourceSpan,
|
getTimelineDurationForSourceSpan,
|
||||||
} from "@/retime";
|
} from "@/retime";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm";
|
||||||
import type {
|
import type {
|
||||||
ComputeGroupResizeArgs,
|
ComputeGroupResizeArgs,
|
||||||
GroupResizeMember,
|
GroupResizeMember,
|
||||||
|
|
@ -188,15 +188,17 @@ function getSourceDeltaForClipDelta({
|
||||||
return clipDelta;
|
return clipDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return clipDelta >= 0
|
const sourceDelta =
|
||||||
? getSourceSpanAtClipTime({
|
clipDelta >= 0
|
||||||
clipTime: clipDelta,
|
? getSourceSpanAtClipTime({
|
||||||
retime: member.retime,
|
clipTime: clipDelta,
|
||||||
})
|
retime: member.retime,
|
||||||
: -getSourceSpanAtClipTime({
|
})
|
||||||
clipTime: Math.abs(clipDelta),
|
: -getSourceSpanAtClipTime({
|
||||||
retime: member.retime,
|
clipTime: Math.abs(clipDelta),
|
||||||
});
|
retime: member.retime,
|
||||||
|
});
|
||||||
|
return roundMediaTime({ time: sourceDelta });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVisibleSourceSpanForDuration({
|
function getVisibleSourceSpanForDuration({
|
||||||
|
|
@ -227,9 +229,11 @@ function getDurationForVisibleSourceSpan({
|
||||||
return sourceSpan;
|
return sourceSpan;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getTimelineDurationForSourceSpan({
|
return roundMediaTime({
|
||||||
sourceSpan,
|
time: getTimelineDurationForSourceSpan({
|
||||||
retime: member.retime,
|
sourceSpan,
|
||||||
|
retime: member.retime,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,14 @@ import {
|
||||||
type MoveGroup,
|
type MoveGroup,
|
||||||
} from "@/timeline/group-move";
|
} from "@/timeline/group-move";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
mediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||||
|
|
@ -54,9 +61,9 @@ const initialDragState: ElementDragState = {
|
||||||
trackId: null,
|
trackId: null,
|
||||||
startMouseX: 0,
|
startMouseX: 0,
|
||||||
startMouseY: 0,
|
startMouseY: 0,
|
||||||
startElementTime: 0,
|
startElementTime: ZERO_MEDIA_TIME,
|
||||||
clickOffsetTime: 0,
|
clickOffsetTime: ZERO_MEDIA_TIME,
|
||||||
currentTime: 0,
|
currentTime: ZERO_MEDIA_TIME,
|
||||||
currentMouseY: 0,
|
currentMouseY: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -66,8 +73,8 @@ interface PendingDragState {
|
||||||
selectedElements: ElementRef[];
|
selectedElements: ElementRef[];
|
||||||
startMouseX: number;
|
startMouseX: number;
|
||||||
startMouseY: number;
|
startMouseY: number;
|
||||||
startElementTime: number;
|
startElementTime: MediaTime;
|
||||||
clickOffsetTime: number;
|
clickOffsetTime: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClickOffsetTime({
|
function getClickOffsetTime({
|
||||||
|
|
@ -78,10 +85,12 @@ function getClickOffsetTime({
|
||||||
clientX: number;
|
clientX: number;
|
||||||
elementRect: DOMRect;
|
elementRect: DOMRect;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
const clickOffsetX = clientX - elementRect.left;
|
const clickOffsetX = clientX - elementRect.left;
|
||||||
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||||
return Math.round(seconds * TICKS_PER_SECOND);
|
return mediaTime({
|
||||||
|
ticks: Math.round(seconds * TICKS_PER_SECOND),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVerticalDragDirection({
|
function getVerticalDragDirection({
|
||||||
|
|
@ -118,7 +127,7 @@ function getDragDropTarget({
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
headerRef?: RefObject<HTMLElement | null>;
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
snappedTime: number;
|
snappedTime: MediaTime;
|
||||||
verticalDragDirection?: "up" | "down" | null;
|
verticalDragDirection?: "up" | "down" | null;
|
||||||
}): DropTarget | null {
|
}): DropTarget | null {
|
||||||
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
||||||
|
|
@ -162,7 +171,7 @@ interface StartDragParams
|
||||||
ElementDragState,
|
ElementDragState,
|
||||||
"isDragging" | "currentTime" | "currentMouseY"
|
"isDragging" | "currentTime" | "currentMouseY"
|
||||||
> {
|
> {
|
||||||
initialCurrentTime: number;
|
initialCurrentTime: MediaTime;
|
||||||
initialCurrentMouseY: number;
|
initialCurrentMouseY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,7 +258,7 @@ export function useElementInteraction({
|
||||||
dropTarget,
|
dropTarget,
|
||||||
}: {
|
}: {
|
||||||
group: MoveGroup;
|
group: MoveGroup;
|
||||||
snappedTime: number;
|
snappedTime: MediaTime;
|
||||||
dropTarget: DropTarget | null;
|
dropTarget: DropTarget | null;
|
||||||
}): GroupMoveResult | null => {
|
}): GroupMoveResult | null => {
|
||||||
if (!dropTarget) {
|
if (!dropTarget) {
|
||||||
|
|
@ -317,7 +326,7 @@ export function useElementInteraction({
|
||||||
frameSnappedTime,
|
frameSnappedTime,
|
||||||
group,
|
group,
|
||||||
}: {
|
}: {
|
||||||
frameSnappedTime: number;
|
frameSnappedTime: MediaTime;
|
||||||
group: MoveGroup | null;
|
group: MoveGroup | null;
|
||||||
}) => {
|
}) => {
|
||||||
if (!group || !snappingEnabled || isShiftHeldRef.current) {
|
if (!group || !snappingEnabled || isShiftHeldRef.current) {
|
||||||
|
|
@ -366,15 +375,19 @@ export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const adjustedTime = Math.max(
|
const adjustedTime =
|
||||||
0,
|
mouseTime > pendingDragRef.current.clickOffsetTime
|
||||||
mouseTime - pendingDragRef.current.clickOffsetTime,
|
? subMediaTime({
|
||||||
);
|
a: mouseTime,
|
||||||
const snappedTime =
|
b: pendingDragRef.current.clickOffsetTime,
|
||||||
|
})
|
||||||
|
: ZERO_MEDIA_TIME;
|
||||||
|
const snappedTime = (
|
||||||
roundToFrame({
|
roundToFrame({
|
||||||
time: adjustedTime,
|
time: adjustedTime,
|
||||||
rate: activeProject.settings.fps,
|
rate: activeProject.settings.fps,
|
||||||
}) ?? adjustedTime;
|
}) ?? adjustedTime
|
||||||
|
) as MediaTime;
|
||||||
const moveGroup = buildMoveGroup({
|
const moveGroup = buildMoveGroup({
|
||||||
anchorRef: {
|
anchorRef: {
|
||||||
trackId: pendingDragRef.current.trackId,
|
trackId: pendingDragRef.current.trackId,
|
||||||
|
|
@ -389,7 +402,7 @@ export function useElementInteraction({
|
||||||
|
|
||||||
moveGroupRef.current = moveGroup;
|
moveGroupRef.current = moveGroup;
|
||||||
newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
|
newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
|
||||||
const dragTimeOffsets: Record<string, number> = {};
|
const dragTimeOffsets: Record<string, MediaTime> = {};
|
||||||
for (const member of moveGroup.members) {
|
for (const member of moveGroup.members) {
|
||||||
dragTimeOffsets[member.elementId] = member.timeOffset;
|
dragTimeOffsets[member.elementId] = member.timeOffset;
|
||||||
}
|
}
|
||||||
|
|
@ -478,10 +491,17 @@ export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft,
|
scrollLeft,
|
||||||
});
|
});
|
||||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
const adjustedTime =
|
||||||
|
mouseTime > dragState.clickOffsetTime
|
||||||
|
? subMediaTime({
|
||||||
|
a: mouseTime,
|
||||||
|
b: dragState.clickOffsetTime,
|
||||||
|
})
|
||||||
|
: ZERO_MEDIA_TIME;
|
||||||
const fps = activeProject.settings.fps;
|
const fps = activeProject.settings.fps;
|
||||||
const frameSnappedTime =
|
const frameSnappedTime = (
|
||||||
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
|
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime
|
||||||
|
) as MediaTime;
|
||||||
|
|
||||||
const moveGroup = moveGroupRef.current;
|
const moveGroup = moveGroupRef.current;
|
||||||
const { snappedTime, snapPoint } = getDragSnapResult({
|
const { snappedTime, snapPoint } = getDragSnapResult({
|
||||||
|
|
@ -596,8 +616,10 @@ export function useElementInteraction({
|
||||||
const currentMember = moveGroup.members.find(
|
const currentMember = moveGroup.members.find(
|
||||||
(member) => member.elementId === move.elementId,
|
(member) => member.elementId === move.elementId,
|
||||||
);
|
);
|
||||||
const originalStartTime =
|
const originalStartTime = addMediaTime({
|
||||||
dragState.startElementTime + (currentMember?.timeOffset ?? 0);
|
a: dragState.startElementTime,
|
||||||
|
b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME,
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
currentMember?.trackId !== move.targetTrackId ||
|
currentMember?.trackId !== move.targetTrackId ||
|
||||||
originalStartTime !== move.newStartTime
|
originalStartTime !== move.newStartTime
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,15 @@ import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||||
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
|
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
|
||||||
import { timelineTimeToSnappedPixels } from "@/timeline";
|
import { timelineTimeToSnappedPixels } from "@/timeline";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
mediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||||
import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
|
import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
|
||||||
import { BatchCommand } from "@/commands";
|
import { BatchCommand } from "@/commands";
|
||||||
|
|
@ -22,13 +30,13 @@ import { registerCanceller } from "@/editor/cancel-interaction";
|
||||||
export interface KeyframeDragState {
|
export interface KeyframeDragState {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
draggingKeyframeIds: Set<string>;
|
draggingKeyframeIds: Set<string>;
|
||||||
deltaTime: number;
|
deltaTime: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialDragState: KeyframeDragState = {
|
const initialDragState: KeyframeDragState = {
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
draggingKeyframeIds: new Set(),
|
draggingKeyframeIds: new Set(),
|
||||||
deltaTime: 0,
|
deltaTime: ZERO_MEDIA_TIME,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PendingKeyframeDrag {
|
interface PendingKeyframeDrag {
|
||||||
|
|
@ -43,7 +51,7 @@ export function useKeyframeDrag({
|
||||||
}: {
|
}: {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
element: TimelineElement;
|
element: TimelineElement;
|
||||||
displayedStartTime: number;
|
displayedStartTime: MediaTime;
|
||||||
}) {
|
}) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const {
|
const {
|
||||||
|
|
@ -83,7 +91,7 @@ export function useKeyframeDrag({
|
||||||
deltaTime,
|
deltaTime,
|
||||||
}: {
|
}: {
|
||||||
keyframeRefs: SelectedKeyframeRef[];
|
keyframeRefs: SelectedKeyframeRef[];
|
||||||
deltaTime: number;
|
deltaTime: MediaTime;
|
||||||
}) => {
|
}) => {
|
||||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||||
const keyframe = getKeyframeById({
|
const keyframe = getKeyframeById({
|
||||||
|
|
@ -92,10 +100,13 @@ export function useKeyframeDrag({
|
||||||
keyframeId: keyframeRef.keyframeId,
|
keyframeId: keyframeRef.keyframeId,
|
||||||
});
|
});
|
||||||
if (!keyframe) return [];
|
if (!keyframe) return [];
|
||||||
const nextTime = Math.max(
|
const nextTime = maxMediaTime({
|
||||||
0,
|
a: ZERO_MEDIA_TIME,
|
||||||
Math.min(element.duration, keyframe.time + deltaTime),
|
b: minMediaTime({
|
||||||
);
|
a: element.duration,
|
||||||
|
b: addMediaTime({ a: keyframe.time, b: deltaTime }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
return [
|
return [
|
||||||
new RetimeKeyframeCommand({
|
new RetimeKeyframeCommand({
|
||||||
trackId: keyframeRef.trackId,
|
trackId: keyframeRef.trackId,
|
||||||
|
|
@ -138,7 +149,7 @@ export function useKeyframeDrag({
|
||||||
draggingKeyframeIds: new Set(
|
draggingKeyframeIds: new Set(
|
||||||
pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
|
pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
|
||||||
),
|
),
|
||||||
deltaTime: 0,
|
deltaTime: ZERO_MEDIA_TIME,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -146,11 +157,14 @@ export function useKeyframeDrag({
|
||||||
if (!dragState.isDragging) return;
|
if (!dragState.isDragging) return;
|
||||||
|
|
||||||
const startX = mouseDownXRef.current ?? clientX;
|
const startX = mouseDownXRef.current ?? clientX;
|
||||||
const rawDelta = Math.round(
|
const rawDelta = mediaTime({
|
||||||
((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
|
ticks: Math.round(
|
||||||
);
|
((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
|
||||||
const snappedDelta =
|
),
|
||||||
roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
|
});
|
||||||
|
const snappedDelta = (
|
||||||
|
roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta
|
||||||
|
) as MediaTime;
|
||||||
|
|
||||||
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
||||||
};
|
};
|
||||||
|
|
@ -246,7 +260,7 @@ export function useKeyframeDrag({
|
||||||
event: ReactMouseEvent;
|
event: ReactMouseEvent;
|
||||||
keyframes: SelectedKeyframeRef[];
|
keyframes: SelectedKeyframeRef[];
|
||||||
orderedKeyframes: SelectedKeyframeRef[];
|
orderedKeyframes: SelectedKeyframeRef[];
|
||||||
indicatorTime: number;
|
indicatorTime: MediaTime;
|
||||||
}) => {
|
}) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
|
@ -259,12 +273,17 @@ export function useKeyframeDrag({
|
||||||
if (wasDrag) return;
|
if (wasDrag) return;
|
||||||
|
|
||||||
const duration = editor.timeline.getTotalDuration();
|
const duration = editor.timeline.getTotalDuration();
|
||||||
const seekTime =
|
const absoluteIndicatorTime = addMediaTime({
|
||||||
|
a: displayedStartTime,
|
||||||
|
b: indicatorTime,
|
||||||
|
});
|
||||||
|
const seekTime = (
|
||||||
snappedSeekTime({
|
snappedSeekTime({
|
||||||
time: displayedStartTime + indicatorTime,
|
time: absoluteIndicatorTime,
|
||||||
duration,
|
duration,
|
||||||
rate: fps,
|
rate: fps,
|
||||||
}) ?? displayedStartTime + indicatorTime;
|
}) ?? absoluteIndicatorTime
|
||||||
|
) as MediaTime;
|
||||||
editor.playback.seek({ time: seekTime });
|
editor.playback.seek({ time: seekTime });
|
||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { processMediaAssets } from "@/media/processing";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { showMediaUploadToast } from "@/media/upload-toast";
|
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
import { roundToFrame } from "opencut-wasm";
|
||||||
import {
|
import {
|
||||||
|
|
@ -45,9 +45,9 @@ export function useTimelineDragDrop({
|
||||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||||
|
|
||||||
const getSnappedTime = useCallback(
|
const getSnappedTime = useCallback(
|
||||||
({ time }: { time: number }) => {
|
({ time }: { time: MediaTime }) => {
|
||||||
const projectFps = editor.project.getActive().settings.fps;
|
const projectFps = editor.project.getActive().settings.fps;
|
||||||
return roundToFrame({ time, rate: projectFps }) ?? time;
|
return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime;
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
@ -76,7 +76,7 @@ export function useTimelineDragDrop({
|
||||||
}: {
|
}: {
|
||||||
elementType: ElementType;
|
elementType: ElementType;
|
||||||
mediaId?: string;
|
mediaId?: string;
|
||||||
}): number => {
|
}): MediaTime => {
|
||||||
if (
|
if (
|
||||||
elementType === "text" ||
|
elementType === "text" ||
|
||||||
elementType === "graphic" ||
|
elementType === "graphic" ||
|
||||||
|
|
@ -89,7 +89,7 @@ export function useTimelineDragDrop({
|
||||||
const mediaAssets = editor.media.getAssets();
|
const mediaAssets = editor.media.getAssets();
|
||||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||||
return media?.duration != null
|
return media?.duration != null
|
||||||
? Math.round(media.duration * TICKS_PER_SECOND)
|
? mediaTimeFromSeconds({ seconds: media.duration })
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
}
|
}
|
||||||
return DEFAULT_NEW_ELEMENT_DURATION;
|
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
|
|
@ -346,7 +346,7 @@ export function useTimelineDragDrop({
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
mediaAsset.duration != null
|
mediaAsset.duration != null
|
||||||
? Math.round(mediaAsset.duration * TICKS_PER_SECOND)
|
? mediaTimeFromSeconds({ seconds: mediaAsset.duration })
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const element = buildElementFromMedia({
|
const element = buildElementFromMedia({
|
||||||
mediaId: mediaAsset.id,
|
mediaId: mediaAsset.id,
|
||||||
|
|
@ -470,7 +470,7 @@ export function useTimelineDragDrop({
|
||||||
|
|
||||||
const duration =
|
const duration =
|
||||||
createdAsset.duration != null
|
createdAsset.duration != null
|
||||||
? Math.round(createdAsset.duration * TICKS_PER_SECOND)
|
? mediaTimeFromSeconds({ seconds: createdAsset.duration })
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
const currentTime = editor.playback.getCurrentTime();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { snappedSeekTime } from "opencut-wasm";
|
import { snappedSeekTime } from "opencut-wasm";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||||
import { useEffect, useCallback, useRef } from "react";
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
|
@ -53,11 +53,11 @@ export function useTimelinePlayhead({
|
||||||
}, [zoomLevel, duration, isScrubbing, editor.playback]);
|
}, [zoomLevel, duration, isScrubbing, editor.playback]);
|
||||||
|
|
||||||
const seek = useCallback(
|
const seek = useCallback(
|
||||||
({ time }: { time: number }) => editor.playback.seek({ time }),
|
({ time }: { time: MediaTime }) => editor.playback.seek({ time }),
|
||||||
[editor.playback],
|
[editor.playback],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrubTimeRef = useRef<number | null>(null);
|
const scrubTimeRef = useRef<MediaTime | null>(null);
|
||||||
const isDraggingRulerRef = useRef(false);
|
const isDraggingRulerRef = useRef(false);
|
||||||
const hasDraggedRulerRef = useRef(false);
|
const hasDraggedRulerRef = useRef(false);
|
||||||
const lastMouseXRef = useRef<number>(0);
|
const lastMouseXRef = useRef<number>(0);
|
||||||
|
|
@ -92,11 +92,14 @@ export function useTimelinePlayhead({
|
||||||
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
const rawTime = mediaTime({
|
||||||
|
ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND),
|
||||||
|
});
|
||||||
|
|
||||||
const rate = activeProject.settings.fps;
|
const rate = activeProject.settings.fps;
|
||||||
const frameTime =
|
const frameTime = (
|
||||||
snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
|
snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime
|
||||||
|
) as MediaTime;
|
||||||
|
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||||
const time = (() => {
|
const time = (() => {
|
||||||
|
|
@ -224,7 +227,7 @@ export function useTimelinePlayhead({
|
||||||
}, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
|
}, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
|
||||||
|
|
||||||
const updatePlayheadLeft = useCallback(
|
const updatePlayheadLeft = useCallback(
|
||||||
(time: number) => {
|
(time: MediaTime) => {
|
||||||
const playheadEl = playheadRef?.current;
|
const playheadEl = playheadRef?.current;
|
||||||
if (!playheadEl) return;
|
if (!playheadEl) return;
|
||||||
const centerPosition = timelineTimeToSnappedPixels({
|
const centerPosition = timelineTimeToSnappedPixels({
|
||||||
|
|
@ -251,8 +254,7 @@ export function useTimelinePlayhead({
|
||||||
}, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
|
}, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePlaybackUpdate = (e: Event) => {
|
const handlePlaybackTime = (time: MediaTime) => {
|
||||||
const time = (e as CustomEvent<{ time: number }>).detail.time;
|
|
||||||
updatePlayheadLeft(time);
|
updatePlayheadLeft(time);
|
||||||
|
|
||||||
if (!isPlayingRef.current || isScrubbingRef.current) return;
|
if (!isPlayingRef.current || isScrubbingRef.current) return;
|
||||||
|
|
@ -280,11 +282,12 @@ export function useTimelinePlayhead({
|
||||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handlePlaybackUpdate = (e: Event) => {
|
||||||
|
handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
|
||||||
|
};
|
||||||
|
|
||||||
const initialTime = editor.playback.getCurrentTime();
|
const initialTime = editor.playback.getCurrentTime();
|
||||||
handlePlaybackUpdate({
|
handlePlaybackTime(initialTime);
|
||||||
detail: { time: initialTime },
|
|
||||||
} as CustomEvent<{ time: number }>);
|
|
||||||
|
|
||||||
window.addEventListener("playback-update", handlePlaybackUpdate);
|
window.addEventListener("playback-update", handlePlaybackUpdate);
|
||||||
window.addEventListener("playback-seek", handlePlaybackUpdate);
|
window.addEventListener("playback-seek", handlePlaybackUpdate);
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ export function useTimelineResize({
|
||||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: patch,
|
updates: patch as Partial<TimelineElement>,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -155,7 +155,7 @@ export function useTimelineResize({
|
||||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
patch,
|
patch: patch as Partial<TimelineElement>,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
|
||||||
import type { MutableRefObject, RefObject } from "react";
|
import type { MutableRefObject, RefObject } from "react";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
import { snappedSeekTime } from "opencut-wasm";
|
import { snappedSeekTime } from "opencut-wasm";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
|
||||||
interface UseTimelineSeekProps {
|
interface UseTimelineSeekProps {
|
||||||
|
|
@ -11,10 +11,10 @@ interface UseTimelineSeekProps {
|
||||||
rulerScrollRef: RefObject<HTMLDivElement | null>;
|
rulerScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
isSelecting: boolean;
|
isSelecting: boolean;
|
||||||
clearSelectedElements: () => void;
|
clearSelectedElements: () => void;
|
||||||
seek: (time: number) => void;
|
seek: (time: MediaTime) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetMouseTracking({
|
function resetMouseTracking({
|
||||||
|
|
@ -135,11 +135,13 @@ export function useTimelineSeek({
|
||||||
(mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
(mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
const rawTime = mediaTime({
|
||||||
|
ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND),
|
||||||
|
});
|
||||||
|
|
||||||
const rate = activeProject?.settings.fps;
|
const rate = activeProject?.settings.fps;
|
||||||
const time = rate
|
const time = rate
|
||||||
? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime)
|
? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime)
|
||||||
: rawTime;
|
: rawTime;
|
||||||
seek(time);
|
seek(time);
|
||||||
editor.project.setTimelineViewState({
|
editor.project.setTimelineViewState({
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale";
|
||||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { zoomToSlider } from "@/timeline/zoom-utils";
|
import { zoomToSlider } from "@/timeline/zoom-utils";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
interface UseTimelineZoomProps {
|
interface UseTimelineZoomProps {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
minZoom?: number;
|
minZoom?: number;
|
||||||
initialZoom?: number;
|
initialZoom?: number;
|
||||||
initialScrollLeft?: number;
|
initialScrollLeft?: number;
|
||||||
initialPlayheadTime?: number;
|
initialPlayheadTime?: MediaTime;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
rulerScrollRef: RefObject<HTMLDivElement | null>;
|
rulerScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +107,7 @@ export function useTimelineZoom({
|
||||||
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
|
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setZoomLevel((prev) => {
|
setZoomLevel((prev: number) => {
|
||||||
if (prev < minZoom) {
|
if (prev < minZoom) {
|
||||||
return minZoom;
|
return minZoom;
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +117,7 @@ export function useTimelineZoom({
|
||||||
|
|
||||||
const wrappedSetZoomLevel = useCallback(
|
const wrappedSetZoomLevel = useCallback(
|
||||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
||||||
setZoomLevel((prev) => {
|
setZoomLevel((prev: number) => {
|
||||||
const nextZoom =
|
const nextZoom =
|
||||||
typeof zoomLevelOrUpdater === "function"
|
typeof zoomLevelOrUpdater === "function"
|
||||||
? zoomLevelOrUpdater(prev)
|
? zoomLevelOrUpdater(prev)
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,32 @@
|
||||||
import type { SceneTracks } from "./types";
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
import type { SceneTracks } from "./types";
|
||||||
export * from "./types";
|
|
||||||
export * from "./drag";
|
export * from "./types";
|
||||||
export * from "./track-capabilities";
|
export * from "./drag";
|
||||||
export * from "./track-element-update";
|
export * from "./track-capabilities";
|
||||||
export * from "./element-utils";
|
export * from "./track-element-update";
|
||||||
export * from "./audio-separation";
|
export * from "./element-utils";
|
||||||
export * from "./zoom-utils";
|
export * from "./audio-separation";
|
||||||
export * from "./ruler-utils";
|
export * from "./zoom-utils";
|
||||||
export * from "./pixel-utils";
|
export * from "./ruler-utils";
|
||||||
|
export * from "./pixel-utils";
|
||||||
export function calculateTotalDuration({
|
|
||||||
tracks,
|
export function calculateTotalDuration({
|
||||||
}: {
|
tracks,
|
||||||
tracks: SceneTracks;
|
}: {
|
||||||
}): number {
|
tracks: SceneTracks;
|
||||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
}): MediaTime {
|
||||||
if (orderedTracks.length === 0) return 0;
|
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||||
|
if (orderedTracks.length === 0) return ZERO_MEDIA_TIME;
|
||||||
const trackEndTimes = orderedTracks.map((track) =>
|
|
||||||
track.elements.reduce((maxEnd, element) => {
|
let maxEnd: MediaTime = ZERO_MEDIA_TIME;
|
||||||
const elementEnd = element.startTime + element.duration;
|
for (const track of orderedTracks) {
|
||||||
return Math.max(maxEnd, elementEnd);
|
for (const element of track.elements) {
|
||||||
}, 0),
|
// `startTime + duration` is integer-by-construction (both `MediaTime`),
|
||||||
);
|
// so the cast re-establishes the brand without runtime work.
|
||||||
|
const elementEnd = (element.startTime + element.duration) as MediaTime;
|
||||||
return Math.max(...trackEndTimes, 0);
|
if (elementEnd > maxEnd) maxEnd = elementEnd;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return maxEnd;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
} from "@/timeline";
|
} from "@/timeline";
|
||||||
import type { Transform } from "@/rendering";
|
import type { Transform } from "@/rendering";
|
||||||
import { resolveTrackPlacement } from "@/timeline/placement";
|
import { resolveTrackPlacement } from "@/timeline/placement";
|
||||||
|
import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
function buildTransform(): Transform {
|
function buildTransform(): Transform {
|
||||||
return {
|
return {
|
||||||
|
|
@ -67,10 +68,10 @@ function buildElement({
|
||||||
id,
|
id,
|
||||||
type: "audio",
|
type: "audio",
|
||||||
name: id,
|
name: id,
|
||||||
startTime,
|
startTime: mediaTime({ ticks: startTime }),
|
||||||
duration,
|
duration: mediaTime({ ticks: duration }),
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
sourceType: "upload",
|
sourceType: "upload",
|
||||||
mediaId: `media-${id}`,
|
mediaId: `media-${id}`,
|
||||||
|
|
@ -80,10 +81,10 @@ function buildElement({
|
||||||
id,
|
id,
|
||||||
type: "graphic",
|
type: "graphic",
|
||||||
name: id,
|
name: id,
|
||||||
startTime,
|
startTime: mediaTime({ ticks: startTime }),
|
||||||
duration,
|
duration: mediaTime({ ticks: duration }),
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
definitionId: `graphic-${id}`,
|
definitionId: `graphic-${id}`,
|
||||||
params: {},
|
params: {},
|
||||||
transform: buildTransform(),
|
transform: buildTransform(),
|
||||||
|
|
@ -94,10 +95,10 @@ function buildElement({
|
||||||
id,
|
id,
|
||||||
type: "text",
|
type: "text",
|
||||||
name: id,
|
name: id,
|
||||||
startTime,
|
startTime: mediaTime({ ticks: startTime }),
|
||||||
duration,
|
duration: mediaTime({ ticks: duration }),
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
content: id,
|
content: id,
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontFamily: "sans-serif",
|
fontFamily: "sans-serif",
|
||||||
|
|
@ -118,10 +119,10 @@ function buildElement({
|
||||||
id,
|
id,
|
||||||
type: "video",
|
type: "video",
|
||||||
name: id,
|
name: id,
|
||||||
startTime,
|
startTime: mediaTime({ ticks: startTime }),
|
||||||
duration,
|
duration: mediaTime({ ticks: duration }),
|
||||||
trimStart: 0,
|
trimStart: ZERO_MEDIA_TIME,
|
||||||
trimEnd: 0,
|
trimEnd: ZERO_MEDIA_TIME,
|
||||||
mediaId: `media-${id}`,
|
mediaId: `media-${id}`,
|
||||||
transform: buildTransform(),
|
transform: buildTransform(),
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|
@ -224,7 +225,11 @@ function buildTimeSpan({
|
||||||
duration: number;
|
duration: number;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
}) {
|
}) {
|
||||||
return { startTime, duration, excludeElementId };
|
return {
|
||||||
|
startTime: mediaTime({ ticks: startTime }),
|
||||||
|
duration: mediaTime({ ticks: duration }),
|
||||||
|
excludeElementId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSceneTracks({
|
function buildSceneTracks({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline";
|
import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline";
|
||||||
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export const MAIN_TRACK_NAME = "Main Track";
|
export const MAIN_TRACK_NAME = "Main Track";
|
||||||
|
|
||||||
|
|
@ -31,9 +32,9 @@ export function enforceMainTrackStart({
|
||||||
}: {
|
}: {
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
targetTrackId: string;
|
targetTrackId: string;
|
||||||
requestedStartTime: number;
|
requestedStartTime: MediaTime;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
if (tracks.main.id !== targetTrackId) {
|
if (tracks.main.id !== targetTrackId) {
|
||||||
return requestedStartTime;
|
return requestedStartTime;
|
||||||
}
|
}
|
||||||
|
|
@ -43,11 +44,11 @@ export function enforceMainTrackStart({
|
||||||
excludeElementId,
|
excludeElementId,
|
||||||
});
|
});
|
||||||
if (!earliestElement) {
|
if (!earliestElement) {
|
||||||
return 0;
|
return ZERO_MEDIA_TIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedStartTime <= earliestElement.startTime) {
|
if (requestedStartTime <= earliestElement.startTime) {
|
||||||
return 0;
|
return ZERO_MEDIA_TIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestedStartTime;
|
return requestedStartTime;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
PlacementSubject,
|
PlacementSubject,
|
||||||
PlacementTimeSpan,
|
PlacementTimeSpan,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import { ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
type ResolveTrackPlacementParams = PlacementSubject & {
|
type ResolveTrackPlacementParams = PlacementSubject & {
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
|
|
@ -32,7 +33,7 @@ function buildExistingTrackResult({
|
||||||
timeSpans: PlacementTimeSpan[];
|
timeSpans: PlacementTimeSpan[];
|
||||||
}): PlacementResult {
|
}): PlacementResult {
|
||||||
const firstSpan = timeSpans[0];
|
const firstSpan = timeSpans[0];
|
||||||
const requestedStartTime = firstSpan?.startTime ?? 0;
|
const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME;
|
||||||
const adjustedStartTime = enforceMainTrackStart({
|
const adjustedStartTime = enforceMainTrackStart({
|
||||||
tracks,
|
tracks,
|
||||||
targetTrackId: track.id,
|
targetTrackId: track.id,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { ElementType, TrackType } from "@/timeline";
|
import type { ElementType, TrackType } from "@/timeline";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export interface PlacementTimeSpan {
|
export interface PlacementTimeSpan {
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ export type PlacementResult =
|
||||||
trackId: string;
|
trackId: string;
|
||||||
trackIndex: number;
|
trackIndex: number;
|
||||||
trackType: TrackType;
|
trackType: TrackType;
|
||||||
adjustedStartTime?: number;
|
adjustedStartTime?: MediaTime;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "newTrack";
|
kind: "newTrack";
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { TScene } from "@/timeline";
|
||||||
import { generateUUID } from "@/utils/id";
|
import { generateUUID } from "@/utils/id";
|
||||||
import { calculateTotalDuration } from "@/timeline";
|
import { calculateTotalDuration } from "@/timeline";
|
||||||
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
|
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
|
||||||
|
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
|
||||||
return scenes.find((scene) => scene.isMain) || null;
|
return scenes.find((scene) => scene.isMain) || null;
|
||||||
|
|
@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({
|
||||||
scenes,
|
scenes,
|
||||||
}: {
|
}: {
|
||||||
scenes: TScene[];
|
scenes: TScene[];
|
||||||
}): number {
|
}): MediaTime {
|
||||||
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
|
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
|
||||||
if (!mainScene?.tracks) {
|
if (!mainScene?.tracks) {
|
||||||
return 0;
|
return ZERO_MEDIA_TIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
return calculateTotalDuration({ tracks: mainScene.tracks });
|
return calculateTotalDuration({ tracks: mainScene.tracks });
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Effect } from "@/effects/types";
|
||||||
import type { Mask } from "@/masks/types";
|
import type { Mask } from "@/masks/types";
|
||||||
import type { ParamValues } from "@/params";
|
import type { ParamValues } from "@/params";
|
||||||
import type { BlendMode, Transform } from "@/rendering";
|
import type { BlendMode, Transform } from "@/rendering";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export type ElementRef = {
|
export type ElementRef = {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
|
|
@ -10,10 +11,10 @@ export type ElementRef = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Bookmark {
|
export interface Bookmark {
|
||||||
time: number;
|
time: MediaTime;
|
||||||
note?: string;
|
note?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
duration?: number;
|
duration?: MediaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TScene {
|
export interface TScene {
|
||||||
|
|
@ -107,11 +108,11 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement;
|
||||||
interface BaseTimelineElement {
|
interface BaseTimelineElement {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
trimStart: number;
|
trimStart: MediaTime;
|
||||||
trimEnd: number;
|
trimEnd: MediaTime;
|
||||||
sourceDuration?: number;
|
sourceDuration?: MediaTime;
|
||||||
animations?: ElementAnimations;
|
animations?: ElementAnimations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,13 +274,13 @@ export interface ElementDragState {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
elementId: string | null;
|
elementId: string | null;
|
||||||
dragElementIds: string[];
|
dragElementIds: string[];
|
||||||
dragTimeOffsets: Record<string, number>;
|
dragTimeOffsets: Record<string, MediaTime>;
|
||||||
trackId: string | null;
|
trackId: string | null;
|
||||||
startMouseX: number;
|
startMouseX: number;
|
||||||
startMouseY: number;
|
startMouseY: number;
|
||||||
startElementTime: number;
|
startElementTime: MediaTime;
|
||||||
clickOffsetTime: number;
|
clickOffsetTime: MediaTime;
|
||||||
currentTime: number;
|
currentTime: MediaTime;
|
||||||
currentMouseY: number;
|
currentMouseY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,7 +288,7 @@ export interface DropTarget {
|
||||||
trackIndex: number;
|
trackIndex: number;
|
||||||
isNewTrack: boolean;
|
isNewTrack: boolean;
|
||||||
insertPosition: "above" | "below" | null;
|
insertPosition: "above" | "below" | null;
|
||||||
xPosition: number;
|
xPosition: MediaTime;
|
||||||
targetElement: { elementId: string; trackId: string } | null;
|
targetElement: { elementId: string; trackId: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,13 +297,13 @@ export interface ComputeDropTargetParams {
|
||||||
mouseX: number;
|
mouseX: number;
|
||||||
mouseY: number;
|
mouseY: number;
|
||||||
tracks: SceneTracks;
|
tracks: SceneTracks;
|
||||||
playheadTime: number;
|
playheadTime: MediaTime;
|
||||||
isExternalDrop: boolean;
|
isExternalDrop: boolean;
|
||||||
elementDuration: number;
|
elementDuration: MediaTime;
|
||||||
pixelsPerSecond: number;
|
pixelsPerSecond: number;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
verticalDragDirection?: "up" | "down" | null;
|
verticalDragDirection?: "up" | "down" | null;
|
||||||
startTimeOverride?: number;
|
startTimeOverride?: MediaTime;
|
||||||
excludeElementId?: string;
|
excludeElementId?: string;
|
||||||
targetElementTypes?: string[];
|
targetElementTypes?: string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from "@/retime";
|
} from "@/retime";
|
||||||
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
|
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
|
||||||
import { isRetimableElement } from "@/timeline";
|
import { isRetimableElement } from "@/timeline";
|
||||||
|
import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm";
|
||||||
|
|
||||||
type ElementUpdateField = keyof TimelineElement | string;
|
type ElementUpdateField = keyof TimelineElement | string;
|
||||||
|
|
||||||
|
|
@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [
|
||||||
0,
|
0,
|
||||||
sourceDuration - element.trimStart - element.trimEnd,
|
sourceDuration - element.trimStart - element.trimEnd,
|
||||||
);
|
);
|
||||||
const nextDuration = getTimelineDurationForSourceSpan({
|
const nextDuration = roundMediaTime({
|
||||||
sourceSpan: visibleSourceSpan,
|
time: getTimelineDurationForSourceSpan({
|
||||||
retime: nextRetime,
|
sourceSpan: visibleSourceSpan,
|
||||||
|
retime: nextRetime,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [
|
||||||
{
|
{
|
||||||
triggers: ["startTime"],
|
triggers: ["startTime"],
|
||||||
apply: ({ element, context }) => {
|
apply: ({ element, context }) => {
|
||||||
const requestedStartTime = Math.max(0, element.startTime);
|
const requestedStartTime =
|
||||||
|
element.startTime < ZERO_MEDIA_TIME
|
||||||
|
? ZERO_MEDIA_TIME
|
||||||
|
: element.startTime;
|
||||||
if (context.trackId !== context.tracks.main.id) {
|
if (context.trackId !== context.tracks.main.id) {
|
||||||
return {
|
return {
|
||||||
element: {
|
element: {
|
||||||
|
|
@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [
|
||||||
...element,
|
...element,
|
||||||
startTime:
|
startTime:
|
||||||
!earliestElement || requestedStartTime <= earliestElement.startTime
|
!earliestElement || requestedStartTime <= earliestElement.startTime
|
||||||
? 0
|
? ZERO_MEDIA_TIME
|
||||||
: requestedStartTime,
|
: requestedStartTime,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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