Fix fractional timeline ticks from clip retime
Co-authored-by: Maze <mazeincoding@users.noreply.github.com>
This commit is contained in:
parent
f1b45b4328
commit
9cf858c0c2
|
|
@ -13,6 +13,7 @@ import type {
|
|||
PlannedElementMove,
|
||||
PlannedTrackCreation,
|
||||
} from "@/timeline/group-move";
|
||||
import { normalizeTimelineElement } from "@/timeline/normalize";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
|
||||
export class MoveElementCommand extends Command {
|
||||
|
|
@ -81,8 +82,12 @@ export class MoveElementCommand extends Command {
|
|||
}
|
||||
|
||||
movedElementsById.set(move.elementId, {
|
||||
...sourceElement,
|
||||
startTime: move.newStartTime,
|
||||
...normalizeTimelineElement({
|
||||
element: {
|
||||
...sourceElement,
|
||||
startTime: move.newStartTime,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import { EditorCore } from "@/core";
|
|||
import { isRetimableElement } from "@/timeline";
|
||||
import { splitAnimationsAtTime } from "@/animation";
|
||||
import { getSourceSpanAtClipTime } from "@/retime";
|
||||
import {
|
||||
normalizeTimelineElement,
|
||||
normalizeTimelineValue,
|
||||
} from "@/timeline/normalize";
|
||||
|
||||
export class SplitElementsCommand extends Command {
|
||||
private savedState: SceneTracks | null = null;
|
||||
|
|
@ -73,9 +77,13 @@ export class SplitElementsCommand extends Command {
|
|||
return [element];
|
||||
}
|
||||
|
||||
const relativeTime = this.splitTime - element.startTime;
|
||||
const relativeTime = normalizeTimelineValue({
|
||||
value: this.splitTime - element.startTime,
|
||||
});
|
||||
const leftVisibleDuration = relativeTime;
|
||||
const rightVisibleDuration = element.duration - relativeTime;
|
||||
const rightVisibleDuration = normalizeTimelineValue({
|
||||
value: element.duration - relativeTime,
|
||||
});
|
||||
const retimeRef = isRetimableElement(element)
|
||||
? element.retime
|
||||
: undefined;
|
||||
|
|
@ -97,14 +105,16 @@ export class SplitElementsCommand extends Command {
|
|||
|
||||
if (this.retainSide === "left") {
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
}),
|
||||
];
|
||||
} else if (this.retainSide === "right") {
|
||||
const newId = generateUUID();
|
||||
|
|
@ -113,16 +123,18 @@ export class SplitElementsCommand extends Command {
|
|||
elementId: newId,
|
||||
});
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
id: newId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
}),
|
||||
];
|
||||
} else {
|
||||
// "both" - split into two pieces
|
||||
|
|
@ -132,24 +144,28 @@ export class SplitElementsCommand extends Command {
|
|||
elementId: secondElementId,
|
||||
});
|
||||
splitResult = [
|
||||
{
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
{
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
duration: leftVisibleDuration,
|
||||
trimEnd: element.trimEnd + rightSourceSpan,
|
||||
name: `${element.name} (left)`,
|
||||
animations: leftAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
}),
|
||||
normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
id: secondElementId,
|
||||
startTime: this.splitTime,
|
||||
duration: rightVisibleDuration,
|
||||
trimStart: element.trimStart + leftSourceSpan,
|
||||
name: `${element.name} (right)`,
|
||||
animations: rightAnimations,
|
||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import type {
|
|||
RetimeConfig,
|
||||
} from "@/timeline";
|
||||
import { calculateTotalDuration } from "@/timeline";
|
||||
import { normalizeSceneTracks } from "@/timeline/normalize";
|
||||
import { quantizeMediaTime } from "@/wasm/ticks";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
import {
|
||||
canElementBeHidden,
|
||||
|
|
@ -210,7 +212,7 @@ export class TimelineManager {
|
|||
}
|
||||
|
||||
getLastFrameTime(): number {
|
||||
const duration = this.getTotalDuration();
|
||||
const duration = quantizeMediaTime({ time: this.getTotalDuration() });
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
if (!fps || duration <= 0) return duration;
|
||||
return lastFrameTime({ duration, rate: fps }) ?? duration;
|
||||
|
|
@ -926,7 +928,9 @@ export class TimelineManager {
|
|||
updateTracks(newTracks: SceneTracks): void {
|
||||
this.previewOverlay.clear();
|
||||
this.previewTracks = null;
|
||||
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
||||
this.editor.scenes.updateSceneTracks({
|
||||
tracks: normalizeSceneTracks({ tracks: newTracks }),
|
||||
});
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV27ToV28 } from "../transformers/v27-to-v28";
|
||||
|
||||
describe("V27 to V28 Migration", () => {
|
||||
test("rounds persisted fractional media times to integer ticks", () => {
|
||||
const result = transformProjectV27ToV28({
|
||||
project: {
|
||||
id: "project-v27-retime",
|
||||
version: 27,
|
||||
metadata: {
|
||||
id: "project-v27-retime",
|
||||
name: "Project",
|
||||
duration: 4052374.193548387,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
timelineViewState: {
|
||||
zoomLevel: 1,
|
||||
scrollLeft: 120,
|
||||
playheadTime: 4052374.193548387,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
name: "Scene",
|
||||
isMain: true,
|
||||
bookmarks: [{ time: 4052374.193548387, duration: 17.9 }],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
tracks: {
|
||||
main: {
|
||||
id: "track-1",
|
||||
name: "Main",
|
||||
type: "video",
|
||||
muted: false,
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "video",
|
||||
name: "Clip",
|
||||
startTime: 10.4,
|
||||
duration: 290322.5806451613,
|
||||
trimStart: 30.5,
|
||||
trimEnd: 11.2,
|
||||
sourceDuration: 290364.2806451613,
|
||||
retime: {
|
||||
rate: 1.24,
|
||||
},
|
||||
transform: {
|
||||
position: { x: 0, y: 0 },
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
animations: {
|
||||
bindings: {
|
||||
opacity: {
|
||||
path: "opacity",
|
||||
kind: "number",
|
||||
components: [
|
||||
{
|
||||
key: "value",
|
||||
channelId: "opacity:value",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
"opacity:value": {
|
||||
kind: "scalar",
|
||||
keys: [
|
||||
{
|
||||
id: "key-1",
|
||||
time: 120.75,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
rightHandle: {
|
||||
dt: 15.25,
|
||||
dv: 0.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
overlay: [],
|
||||
audio: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
fps: { numerator: 30, denominator: 1 },
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(28);
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
expect(metadata.duration).toBe(4_052_374);
|
||||
|
||||
const timelineViewState = result.project.timelineViewState as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(timelineViewState.playheadTime).toBe(4_052_374);
|
||||
|
||||
const scene = (result.project.scenes as Array<Record<string, unknown>>)[0];
|
||||
expect(scene.bookmarks).toEqual([{ time: 4_052_374, duration: 18 }]);
|
||||
|
||||
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);
|
||||
expect(element.duration).toBe(290_323);
|
||||
expect(element.trimStart).toBe(31);
|
||||
expect(element.trimEnd).toBe(11);
|
||||
expect(element.sourceDuration).toBe(290_364);
|
||||
|
||||
const animations = element.animations as Record<string, unknown>;
|
||||
const channels = animations.channels as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(channels["opacity:value"]).toEqual({
|
||||
kind: "scalar",
|
||||
keys: [
|
||||
{
|
||||
id: "key-1",
|
||||
time: 121,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
rightHandle: {
|
||||
dt: 15,
|
||||
dv: 0.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -26,10 +26,11 @@ import { V23toV24Migration } from "./v23-to-v24";
|
|||
import { V24toV25Migration } from "./v24-to-v25";
|
||||
import { V25toV26Migration } from "./v25-to-v26";
|
||||
import { V26toV27Migration } from "./v26-to-v27";
|
||||
import { V27toV28Migration } from "./v27-to-v28";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 27;
|
||||
export const CURRENT_PROJECT_VERSION = 28;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
|
|
@ -59,4 +60,5 @@ export const migrations = [
|
|||
new V24toV25Migration(),
|
||||
new V25toV26Migration(),
|
||||
new V26toV27Migration(),
|
||||
new V27toV28Migration(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
const TIMELINE_KEYS = new Set([
|
||||
"duration",
|
||||
"startTime",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"sourceDuration",
|
||||
"time",
|
||||
"playheadTime",
|
||||
"dt",
|
||||
]);
|
||||
|
||||
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: {
|
||||
...(normalizeTimelineRecord({ value: project }) as ProjectRecord),
|
||||
version: 28,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTimelineRecord({ value }: { value: unknown }): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeTimelineRecord({ value: item }));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entryValue]) => [
|
||||
key,
|
||||
TIMELINE_KEYS.has(key)
|
||||
? normalizeTimelineScalar({ value: entryValue })
|
||||
: normalizeTimelineRecord({ value: entryValue }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTimelineScalar({ value }: { value: unknown }): unknown {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Math.round(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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import type { TProject, TProjectMetadata } from "@/project/types";
|
||||
import {
|
||||
normalizeBookmark,
|
||||
normalizeScene,
|
||||
normalizeSceneTracks,
|
||||
normalizeTimelineValue,
|
||||
} from "@/timeline/normalize";
|
||||
import { getProjectDurationFromScenes } from "@/timeline/scenes";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import { IndexedDBAdapter } from "./indexeddb-adapter";
|
||||
|
|
@ -127,25 +133,30 @@ class StorageService {
|
|||
}
|
||||
|
||||
async saveProject({ project }: { project: TProject }): Promise<void> {
|
||||
const normalizedScenes = project.scenes.map((scene) =>
|
||||
normalizeScene({ scene }),
|
||||
);
|
||||
const duration =
|
||||
project.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes: project.scenes });
|
||||
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
isMain: scene.isMain,
|
||||
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
|
||||
bookmarks: scene.bookmarks,
|
||||
createdAt: scene.createdAt.toISOString(),
|
||||
updatedAt: scene.updatedAt.toISOString(),
|
||||
}));
|
||||
getProjectDurationFromScenes({ scenes: normalizedScenes });
|
||||
const serializedScenes: SerializedScene[] = normalizedScenes.map(
|
||||
(scene) => ({
|
||||
id: scene.id,
|
||||
name: scene.name,
|
||||
isMain: scene.isMain,
|
||||
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
|
||||
bookmarks: scene.bookmarks,
|
||||
createdAt: scene.createdAt.toISOString(),
|
||||
updatedAt: scene.updatedAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const serializedProject: SerializedProject = {
|
||||
metadata: {
|
||||
id: project.metadata.id,
|
||||
name: project.metadata.name,
|
||||
thumbnail: project.metadata.thumbnail,
|
||||
duration,
|
||||
duration: normalizeTimelineValue({ value: duration }),
|
||||
createdAt: project.metadata.createdAt.toISOString(),
|
||||
updatedAt: project.metadata.updatedAt.toISOString(),
|
||||
},
|
||||
|
|
@ -153,7 +164,14 @@ class StorageService {
|
|||
currentSceneId: project.currentSceneId,
|
||||
settings: project.settings,
|
||||
version: project.version,
|
||||
timelineViewState: project.timelineViewState,
|
||||
timelineViewState: project.timelineViewState
|
||||
? {
|
||||
...project.timelineViewState,
|
||||
playheadTime: normalizeTimelineValue({
|
||||
value: project.timelineViewState.playheadTime,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
await this.projectsAdapter.set(project.metadata.id, serializedProject);
|
||||
|
|
@ -187,8 +205,10 @@ class StorageService {
|
|||
id: scene.id,
|
||||
name: scene.name,
|
||||
isMain: scene.isMain,
|
||||
tracks: scene.tracks,
|
||||
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
|
||||
tracks: normalizeSceneTracks({ tracks: scene.tracks }),
|
||||
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }).map(
|
||||
(bookmark) => normalizeBookmark({ bookmark }),
|
||||
),
|
||||
createdAt: new Date(scene.createdAt),
|
||||
updatedAt: new Date(scene.updatedAt),
|
||||
})) ?? [];
|
||||
|
|
@ -198,9 +218,11 @@ class StorageService {
|
|||
id: serializedProject.metadata.id,
|
||||
name: serializedProject.metadata.name,
|
||||
thumbnail: serializedProject.metadata.thumbnail,
|
||||
duration:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
duration: normalizeTimelineValue({
|
||||
value:
|
||||
serializedProject.metadata.duration ??
|
||||
getProjectDurationFromScenes({ scenes }),
|
||||
}),
|
||||
createdAt: new Date(serializedProject.metadata.createdAt),
|
||||
updatedAt: new Date(serializedProject.metadata.updatedAt),
|
||||
},
|
||||
|
|
@ -208,7 +230,14 @@ class StorageService {
|
|||
currentSceneId: serializedProject.currentSceneId || "",
|
||||
settings: serializedProject.settings,
|
||||
version: serializedProject.version,
|
||||
timelineViewState: serializedProject.timelineViewState,
|
||||
timelineViewState: serializedProject.timelineViewState
|
||||
? {
|
||||
...serializedProject.timelineViewState,
|
||||
playheadTime: normalizeTimelineValue({
|
||||
value: serializedProject.timelineViewState.playheadTime,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return { project };
|
||||
|
|
|
|||
|
|
@ -1,29 +1,30 @@
|
|||
import type { SceneTracks } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./drag";
|
||||
export * from "./track-capabilities";
|
||||
export * from "./track-element-update";
|
||||
export * from "./element-utils";
|
||||
export * from "./audio-separation";
|
||||
export * from "./zoom-utils";
|
||||
export * from "./ruler-utils";
|
||||
export * from "./pixel-utils";
|
||||
|
||||
export function calculateTotalDuration({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
}): number {
|
||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
if (orderedTracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = orderedTracks.map((track) =>
|
||||
track.elements.reduce((maxEnd, element) => {
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
return Math.max(maxEnd, elementEnd);
|
||||
}, 0),
|
||||
);
|
||||
|
||||
return Math.max(...trackEndTimes, 0);
|
||||
}
|
||||
import type { SceneTracks } from "./types";
|
||||
import { normalizeTimelineValue } from "./normalize";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./drag";
|
||||
export * from "./track-capabilities";
|
||||
export * from "./track-element-update";
|
||||
export * from "./element-utils";
|
||||
export * from "./audio-separation";
|
||||
export * from "./zoom-utils";
|
||||
export * from "./ruler-utils";
|
||||
export * from "./pixel-utils";
|
||||
|
||||
export function calculateTotalDuration({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
}): number {
|
||||
const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
|
||||
if (orderedTracks.length === 0) return 0;
|
||||
|
||||
const trackEndTimes = orderedTracks.map((track) =>
|
||||
track.elements.reduce((maxEnd, element) => {
|
||||
const elementEnd = element.startTime + element.duration;
|
||||
return Math.max(maxEnd, elementEnd);
|
||||
}, 0),
|
||||
);
|
||||
|
||||
return normalizeTimelineValue({ value: Math.max(...trackEndTimes, 0) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
import type { Bookmark, SceneTracks, TScene, TimelineElement } from "./types";
|
||||
|
||||
export function normalizeTimelineValue({ value }: { value: number }): number {
|
||||
return Number.isFinite(value) ? Math.round(value) : value;
|
||||
}
|
||||
|
||||
export function normalizeBookmark({
|
||||
bookmark,
|
||||
}: {
|
||||
bookmark: Bookmark;
|
||||
}): Bookmark {
|
||||
return {
|
||||
...bookmark,
|
||||
time: normalizeTimelineValue({ value: bookmark.time }),
|
||||
...(bookmark.duration !== undefined && {
|
||||
duration: normalizeTimelineValue({ value: bookmark.duration }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTimelineElement<TElement extends TimelineElement>({
|
||||
element,
|
||||
}: {
|
||||
element: TElement;
|
||||
}): TElement {
|
||||
const nextElement = {
|
||||
...element,
|
||||
startTime: normalizeTimelineValue({ value: element.startTime }),
|
||||
duration: normalizeTimelineValue({ value: element.duration }),
|
||||
trimStart: normalizeTimelineValue({ value: element.trimStart }),
|
||||
trimEnd: normalizeTimelineValue({ value: element.trimEnd }),
|
||||
...(typeof element.sourceDuration === "number" && {
|
||||
sourceDuration: normalizeTimelineValue({ value: element.sourceDuration }),
|
||||
}),
|
||||
} as TElement;
|
||||
|
||||
if ("animations" in nextElement && nextElement.animations) {
|
||||
nextElement.animations = normalizeAnimations({
|
||||
animations: nextElement.animations,
|
||||
});
|
||||
}
|
||||
|
||||
return nextElement;
|
||||
}
|
||||
|
||||
export function normalizeSceneTracks({
|
||||
tracks,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
}): SceneTracks {
|
||||
return {
|
||||
overlay: tracks.overlay.map((track) => normalizeTrack({ track })),
|
||||
main: normalizeTrack({ track: tracks.main }),
|
||||
audio: tracks.audio.map((track) => normalizeTrack({ track })),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeScene({ scene }: { scene: TScene }): TScene {
|
||||
return {
|
||||
...scene,
|
||||
tracks: normalizeSceneTracks({ tracks: scene.tracks }),
|
||||
bookmarks: scene.bookmarks.map((bookmark) =>
|
||||
normalizeBookmark({ bookmark }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnimations({
|
||||
animations,
|
||||
}: {
|
||||
animations: NonNullable<TimelineElement["animations"]>;
|
||||
}): NonNullable<TimelineElement["animations"]> {
|
||||
return {
|
||||
...animations,
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(animations.channels).map(([channelId, channel]) => {
|
||||
if (!channel) {
|
||||
return [channelId, channel];
|
||||
}
|
||||
|
||||
return [
|
||||
channelId,
|
||||
{
|
||||
...channel,
|
||||
keys: channel.keys.map((keyframe) =>
|
||||
normalizeAnimationKeyframe({ keyframe }),
|
||||
),
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as typeof animations.channels,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTrack<TTrack extends { elements: TimelineElement[] }>({
|
||||
track,
|
||||
}: {
|
||||
track: TTrack;
|
||||
}): TTrack {
|
||||
return {
|
||||
...track,
|
||||
elements: track.elements.map((element) =>
|
||||
normalizeTimelineElement({ element }),
|
||||
) as TTrack["elements"],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnimationKeyframe<TKeyframe extends { time: number }>({
|
||||
keyframe,
|
||||
}: {
|
||||
keyframe: TKeyframe;
|
||||
}): TKeyframe {
|
||||
const nextKeyframe = {
|
||||
...keyframe,
|
||||
time: normalizeTimelineValue({ value: keyframe.time }),
|
||||
} as TKeyframe & {
|
||||
leftHandle?: { dt: number };
|
||||
rightHandle?: { dt: number };
|
||||
};
|
||||
|
||||
if ("leftHandle" in keyframe && nextKeyframe.leftHandle) {
|
||||
nextKeyframe.leftHandle = {
|
||||
...nextKeyframe.leftHandle,
|
||||
dt: normalizeTimelineValue({ value: nextKeyframe.leftHandle.dt }),
|
||||
};
|
||||
}
|
||||
|
||||
if ("rightHandle" in keyframe && nextKeyframe.rightHandle) {
|
||||
nextKeyframe.rightHandle = {
|
||||
...nextKeyframe.rightHandle,
|
||||
dt: normalizeTimelineValue({ value: nextKeyframe.rightHandle.dt }),
|
||||
};
|
||||
}
|
||||
|
||||
return nextKeyframe;
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ import {
|
|||
} from "@/retime";
|
||||
import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
|
||||
import { isRetimableElement } from "@/timeline";
|
||||
import {
|
||||
normalizeTimelineElement,
|
||||
normalizeTimelineValue,
|
||||
} from "@/timeline/normalize";
|
||||
|
||||
type ElementUpdateField = keyof TimelineElement | string;
|
||||
|
||||
|
|
@ -70,7 +74,7 @@ const deriveRules: ElementUpdateRule[] = [
|
|||
element: {
|
||||
...element,
|
||||
retime: nextRetime,
|
||||
duration: nextDuration,
|
||||
duration: normalizeTimelineValue({ value: nextDuration }),
|
||||
},
|
||||
changedFields: ["retime", "duration"],
|
||||
};
|
||||
|
|
@ -82,25 +86,31 @@ const enforceRules: ElementUpdateRule[] = [
|
|||
{
|
||||
triggers: ["duration"],
|
||||
apply: ({ element }) => ({
|
||||
element: {
|
||||
...element,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: element.duration,
|
||||
}),
|
||||
},
|
||||
element: normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
animations: clampAnimationsToDuration({
|
||||
animations: element.animations,
|
||||
duration: element.duration,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
triggers: ["startTime"],
|
||||
apply: ({ element, context }) => {
|
||||
const requestedStartTime = Math.max(0, element.startTime);
|
||||
const requestedStartTime = normalizeTimelineValue({
|
||||
value: Math.max(0, element.startTime),
|
||||
});
|
||||
if (context.trackId !== context.tracks.main.id) {
|
||||
return {
|
||||
element: {
|
||||
...element,
|
||||
startTime: requestedStartTime,
|
||||
},
|
||||
element: normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
startTime: requestedStartTime,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -114,13 +124,16 @@ const enforceRules: ElementUpdateRule[] = [
|
|||
}, null);
|
||||
|
||||
return {
|
||||
element: {
|
||||
...element,
|
||||
startTime:
|
||||
!earliestElement || requestedStartTime <= earliestElement.startTime
|
||||
? 0
|
||||
: requestedStartTime,
|
||||
},
|
||||
element: normalizeTimelineElement({
|
||||
element: {
|
||||
...element,
|
||||
startTime:
|
||||
!earliestElement ||
|
||||
requestedStartTime <= earliestElement.startTime
|
||||
? 0
|
||||
: requestedStartTime,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
@ -136,9 +149,7 @@ export function applyElementUpdate({
|
|||
context: ElementUpdateContext;
|
||||
}): TimelineElement {
|
||||
let nextElement = { ...element, ...patch } as TimelineElement;
|
||||
const changedFields = new Set(
|
||||
Object.keys(patch) as ElementUpdateField[],
|
||||
);
|
||||
const changedFields = new Set(Object.keys(patch) as ElementUpdateField[]);
|
||||
|
||||
for (const rule of deriveRules) {
|
||||
if (!shouldApplyRule({ rule, changedFields })) {
|
||||
|
|
@ -170,7 +181,7 @@ export function applyElementUpdate({
|
|||
}).element;
|
||||
}
|
||||
|
||||
return nextElement;
|
||||
return normalizeTimelineElement({ element: nextElement });
|
||||
}
|
||||
|
||||
function shouldApplyRule({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
|
||||
|
||||
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
|
||||
|
||||
export function quantizeMediaTime({ time }: { time: number }): number {
|
||||
return Number.isFinite(time) ? Math.round(time) : time;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue