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,
|
PlannedElementMove,
|
||||||
PlannedTrackCreation,
|
PlannedTrackCreation,
|
||||||
} from "@/timeline/group-move";
|
} from "@/timeline/group-move";
|
||||||
|
import { normalizeTimelineElement } from "@/timeline/normalize";
|
||||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||||
|
|
||||||
export class MoveElementCommand extends Command {
|
export class MoveElementCommand extends Command {
|
||||||
|
|
@ -81,8 +82,12 @@ export class MoveElementCommand extends Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
movedElementsById.set(move.elementId, {
|
movedElementsById.set(move.elementId, {
|
||||||
...sourceElement,
|
...normalizeTimelineElement({
|
||||||
startTime: move.newStartTime,
|
element: {
|
||||||
|
...sourceElement,
|
||||||
|
startTime: move.newStartTime,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ 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 {
|
||||||
|
normalizeTimelineElement,
|
||||||
|
normalizeTimelineValue,
|
||||||
|
} from "@/timeline/normalize";
|
||||||
|
|
||||||
export class SplitElementsCommand extends Command {
|
export class SplitElementsCommand extends Command {
|
||||||
private savedState: SceneTracks | null = null;
|
private savedState: SceneTracks | null = null;
|
||||||
|
|
@ -73,9 +77,13 @@ export class SplitElementsCommand extends Command {
|
||||||
return [element];
|
return [element];
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativeTime = this.splitTime - element.startTime;
|
const relativeTime = normalizeTimelineValue({
|
||||||
|
value: this.splitTime - element.startTime,
|
||||||
|
});
|
||||||
const leftVisibleDuration = relativeTime;
|
const leftVisibleDuration = relativeTime;
|
||||||
const rightVisibleDuration = element.duration - relativeTime;
|
const rightVisibleDuration = normalizeTimelineValue({
|
||||||
|
value: element.duration - relativeTime,
|
||||||
|
});
|
||||||
const retimeRef = isRetimableElement(element)
|
const retimeRef = isRetimableElement(element)
|
||||||
? element.retime
|
? element.retime
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -97,14 +105,16 @@ export class SplitElementsCommand extends Command {
|
||||||
|
|
||||||
if (this.retainSide === "left") {
|
if (this.retainSide === "left") {
|
||||||
splitResult = [
|
splitResult = [
|
||||||
{
|
normalizeTimelineElement({
|
||||||
...element,
|
element: {
|
||||||
duration: leftVisibleDuration,
|
...element,
|
||||||
trimEnd: element.trimEnd + rightSourceSpan,
|
duration: leftVisibleDuration,
|
||||||
name: `${element.name} (left)`,
|
trimEnd: element.trimEnd + rightSourceSpan,
|
||||||
animations: leftAnimations,
|
name: `${element.name} (left)`,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
animations: leftAnimations,
|
||||||
},
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
} else if (this.retainSide === "right") {
|
} else if (this.retainSide === "right") {
|
||||||
const newId = generateUUID();
|
const newId = generateUUID();
|
||||||
|
|
@ -113,16 +123,18 @@ export class SplitElementsCommand extends Command {
|
||||||
elementId: newId,
|
elementId: newId,
|
||||||
});
|
});
|
||||||
splitResult = [
|
splitResult = [
|
||||||
{
|
normalizeTimelineElement({
|
||||||
...element,
|
element: {
|
||||||
id: newId,
|
...element,
|
||||||
startTime: this.splitTime,
|
id: newId,
|
||||||
duration: rightVisibleDuration,
|
startTime: this.splitTime,
|
||||||
trimStart: element.trimStart + leftSourceSpan,
|
duration: rightVisibleDuration,
|
||||||
name: `${element.name} (right)`,
|
trimStart: element.trimStart + leftSourceSpan,
|
||||||
animations: rightAnimations,
|
name: `${element.name} (right)`,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
animations: rightAnimations,
|
||||||
},
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// "both" - split into two pieces
|
// "both" - split into two pieces
|
||||||
|
|
@ -132,24 +144,28 @@ export class SplitElementsCommand extends Command {
|
||||||
elementId: secondElementId,
|
elementId: secondElementId,
|
||||||
});
|
});
|
||||||
splitResult = [
|
splitResult = [
|
||||||
{
|
normalizeTimelineElement({
|
||||||
...element,
|
element: {
|
||||||
duration: leftVisibleDuration,
|
...element,
|
||||||
trimEnd: element.trimEnd + rightSourceSpan,
|
duration: leftVisibleDuration,
|
||||||
name: `${element.name} (left)`,
|
trimEnd: element.trimEnd + rightSourceSpan,
|
||||||
animations: leftAnimations,
|
name: `${element.name} (left)`,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
animations: leftAnimations,
|
||||||
},
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
{
|
},
|
||||||
...element,
|
}),
|
||||||
id: secondElementId,
|
normalizeTimelineElement({
|
||||||
startTime: this.splitTime,
|
element: {
|
||||||
duration: rightVisibleDuration,
|
...element,
|
||||||
trimStart: element.trimStart + leftSourceSpan,
|
id: secondElementId,
|
||||||
name: `${element.name} (right)`,
|
startTime: this.splitTime,
|
||||||
animations: rightAnimations,
|
duration: rightVisibleDuration,
|
||||||
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
trimStart: element.trimStart + leftSourceSpan,
|
||||||
},
|
name: `${element.name} (right)`,
|
||||||
|
animations: rightAnimations,
|
||||||
|
...(retimeRef !== undefined ? { retime: retimeRef } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import type {
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
} from "@/timeline";
|
} from "@/timeline";
|
||||||
import { calculateTotalDuration } from "@/timeline";
|
import { calculateTotalDuration } from "@/timeline";
|
||||||
|
import { normalizeSceneTracks } from "@/timeline/normalize";
|
||||||
|
import { quantizeMediaTime } from "@/wasm/ticks";
|
||||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||||
import {
|
import {
|
||||||
canElementBeHidden,
|
canElementBeHidden,
|
||||||
|
|
@ -210,7 +212,7 @@ export class TimelineManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastFrameTime(): number {
|
getLastFrameTime(): number {
|
||||||
const duration = this.getTotalDuration();
|
const duration = quantizeMediaTime({ time: 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;
|
||||||
|
|
@ -926,7 +928,9 @@ export class TimelineManager {
|
||||||
updateTracks(newTracks: SceneTracks): void {
|
updateTracks(newTracks: SceneTracks): void {
|
||||||
this.previewOverlay.clear();
|
this.previewOverlay.clear();
|
||||||
this.previewTracks = null;
|
this.previewTracks = null;
|
||||||
this.editor.scenes.updateSceneTracks({ tracks: newTracks });
|
this.editor.scenes.updateSceneTracks({
|
||||||
|
tracks: normalizeSceneTracks({ tracks: newTracks }),
|
||||||
|
});
|
||||||
this.notify();
|
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 { 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,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 type { TProject, TProjectMetadata } from "@/project/types";
|
||||||
|
import {
|
||||||
|
normalizeBookmark,
|
||||||
|
normalizeScene,
|
||||||
|
normalizeSceneTracks,
|
||||||
|
normalizeTimelineValue,
|
||||||
|
} from "@/timeline/normalize";
|
||||||
import { getProjectDurationFromScenes } from "@/timeline/scenes";
|
import { getProjectDurationFromScenes } from "@/timeline/scenes";
|
||||||
import type { MediaAsset } from "@/media/types";
|
import type { MediaAsset } from "@/media/types";
|
||||||
import { IndexedDBAdapter } from "./indexeddb-adapter";
|
import { IndexedDBAdapter } from "./indexeddb-adapter";
|
||||||
|
|
@ -127,25 +133,30 @@ class StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveProject({ project }: { project: TProject }): Promise<void> {
|
async saveProject({ project }: { project: TProject }): Promise<void> {
|
||||||
|
const normalizedScenes = project.scenes.map((scene) =>
|
||||||
|
normalizeScene({ scene }),
|
||||||
|
);
|
||||||
const duration =
|
const duration =
|
||||||
project.metadata.duration ??
|
project.metadata.duration ??
|
||||||
getProjectDurationFromScenes({ scenes: project.scenes });
|
getProjectDurationFromScenes({ scenes: normalizedScenes });
|
||||||
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
|
const serializedScenes: SerializedScene[] = normalizedScenes.map(
|
||||||
id: scene.id,
|
(scene) => ({
|
||||||
name: scene.name,
|
id: scene.id,
|
||||||
isMain: scene.isMain,
|
name: scene.name,
|
||||||
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
|
isMain: scene.isMain,
|
||||||
bookmarks: scene.bookmarks,
|
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
|
||||||
createdAt: scene.createdAt.toISOString(),
|
bookmarks: scene.bookmarks,
|
||||||
updatedAt: scene.updatedAt.toISOString(),
|
createdAt: scene.createdAt.toISOString(),
|
||||||
}));
|
updatedAt: scene.updatedAt.toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const serializedProject: SerializedProject = {
|
const serializedProject: SerializedProject = {
|
||||||
metadata: {
|
metadata: {
|
||||||
id: project.metadata.id,
|
id: project.metadata.id,
|
||||||
name: project.metadata.name,
|
name: project.metadata.name,
|
||||||
thumbnail: project.metadata.thumbnail,
|
thumbnail: project.metadata.thumbnail,
|
||||||
duration,
|
duration: normalizeTimelineValue({ value: duration }),
|
||||||
createdAt: project.metadata.createdAt.toISOString(),
|
createdAt: project.metadata.createdAt.toISOString(),
|
||||||
updatedAt: project.metadata.updatedAt.toISOString(),
|
updatedAt: project.metadata.updatedAt.toISOString(),
|
||||||
},
|
},
|
||||||
|
|
@ -153,7 +164,14 @@ class StorageService {
|
||||||
currentSceneId: project.currentSceneId,
|
currentSceneId: project.currentSceneId,
|
||||||
settings: project.settings,
|
settings: project.settings,
|
||||||
version: project.version,
|
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);
|
await this.projectsAdapter.set(project.metadata.id, serializedProject);
|
||||||
|
|
@ -187,8 +205,10 @@ class StorageService {
|
||||||
id: scene.id,
|
id: scene.id,
|
||||||
name: scene.name,
|
name: scene.name,
|
||||||
isMain: scene.isMain,
|
isMain: scene.isMain,
|
||||||
tracks: scene.tracks,
|
tracks: normalizeSceneTracks({ tracks: scene.tracks }),
|
||||||
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
|
bookmarks: normalizeBookmarks({ raw: scene.bookmarks }).map(
|
||||||
|
(bookmark) => normalizeBookmark({ bookmark }),
|
||||||
|
),
|
||||||
createdAt: new Date(scene.createdAt),
|
createdAt: new Date(scene.createdAt),
|
||||||
updatedAt: new Date(scene.updatedAt),
|
updatedAt: new Date(scene.updatedAt),
|
||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
@ -198,9 +218,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: normalizeTimelineValue({
|
||||||
serializedProject.metadata.duration ??
|
value:
|
||||||
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),
|
||||||
},
|
},
|
||||||
|
|
@ -208,7 +230,14 @@ class StorageService {
|
||||||
currentSceneId: serializedProject.currentSceneId || "",
|
currentSceneId: serializedProject.currentSceneId || "",
|
||||||
settings: serializedProject.settings,
|
settings: serializedProject.settings,
|
||||||
version: serializedProject.version,
|
version: serializedProject.version,
|
||||||
timelineViewState: serializedProject.timelineViewState,
|
timelineViewState: serializedProject.timelineViewState
|
||||||
|
? {
|
||||||
|
...serializedProject.timelineViewState,
|
||||||
|
playheadTime: normalizeTimelineValue({
|
||||||
|
value: serializedProject.timelineViewState.playheadTime,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { project };
|
return { project };
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,30 @@
|
||||||
import type { SceneTracks } from "./types";
|
import type { SceneTracks } from "./types";
|
||||||
|
import { normalizeTimelineValue } from "./normalize";
|
||||||
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];
|
}): number {
|
||||||
if (orderedTracks.length === 0) return 0;
|
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 trackEndTimes = orderedTracks.map((track) =>
|
||||||
const elementEnd = element.startTime + element.duration;
|
track.elements.reduce((maxEnd, element) => {
|
||||||
return Math.max(maxEnd, elementEnd);
|
const elementEnd = element.startTime + element.duration;
|
||||||
}, 0),
|
return Math.max(maxEnd, elementEnd);
|
||||||
);
|
}, 0),
|
||||||
|
);
|
||||||
return Math.max(...trackEndTimes, 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";
|
} 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 {
|
||||||
|
normalizeTimelineElement,
|
||||||
|
normalizeTimelineValue,
|
||||||
|
} from "@/timeline/normalize";
|
||||||
|
|
||||||
type ElementUpdateField = keyof TimelineElement | string;
|
type ElementUpdateField = keyof TimelineElement | string;
|
||||||
|
|
||||||
|
|
@ -70,7 +74,7 @@ const deriveRules: ElementUpdateRule[] = [
|
||||||
element: {
|
element: {
|
||||||
...element,
|
...element,
|
||||||
retime: nextRetime,
|
retime: nextRetime,
|
||||||
duration: nextDuration,
|
duration: normalizeTimelineValue({ value: nextDuration }),
|
||||||
},
|
},
|
||||||
changedFields: ["retime", "duration"],
|
changedFields: ["retime", "duration"],
|
||||||
};
|
};
|
||||||
|
|
@ -82,25 +86,31 @@ const enforceRules: ElementUpdateRule[] = [
|
||||||
{
|
{
|
||||||
triggers: ["duration"],
|
triggers: ["duration"],
|
||||||
apply: ({ element }) => ({
|
apply: ({ element }) => ({
|
||||||
element: {
|
element: normalizeTimelineElement({
|
||||||
...element,
|
element: {
|
||||||
animations: clampAnimationsToDuration({
|
...element,
|
||||||
animations: element.animations,
|
animations: clampAnimationsToDuration({
|
||||||
duration: element.duration,
|
animations: element.animations,
|
||||||
}),
|
duration: element.duration,
|
||||||
},
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
triggers: ["startTime"],
|
triggers: ["startTime"],
|
||||||
apply: ({ element, context }) => {
|
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) {
|
if (context.trackId !== context.tracks.main.id) {
|
||||||
return {
|
return {
|
||||||
element: {
|
element: normalizeTimelineElement({
|
||||||
...element,
|
element: {
|
||||||
startTime: requestedStartTime,
|
...element,
|
||||||
},
|
startTime: requestedStartTime,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,13 +124,16 @@ const enforceRules: ElementUpdateRule[] = [
|
||||||
}, null);
|
}, null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
element: {
|
element: normalizeTimelineElement({
|
||||||
...element,
|
element: {
|
||||||
startTime:
|
...element,
|
||||||
!earliestElement || requestedStartTime <= earliestElement.startTime
|
startTime:
|
||||||
? 0
|
!earliestElement ||
|
||||||
: requestedStartTime,
|
requestedStartTime <= earliestElement.startTime
|
||||||
},
|
? 0
|
||||||
|
: requestedStartTime,
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -136,9 +149,7 @@ export function applyElementUpdate({
|
||||||
context: ElementUpdateContext;
|
context: ElementUpdateContext;
|
||||||
}): TimelineElement {
|
}): TimelineElement {
|
||||||
let nextElement = { ...element, ...patch } as TimelineElement;
|
let nextElement = { ...element, ...patch } as TimelineElement;
|
||||||
const changedFields = new Set(
|
const changedFields = new Set(Object.keys(patch) as ElementUpdateField[]);
|
||||||
Object.keys(patch) as ElementUpdateField[],
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const rule of deriveRules) {
|
for (const rule of deriveRules) {
|
||||||
if (!shouldApplyRule({ rule, changedFields })) {
|
if (!shouldApplyRule({ rule, changedFields })) {
|
||||||
|
|
@ -170,7 +181,7 @@ export function applyElementUpdate({
|
||||||
}).element;
|
}).element;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextElement;
|
return normalizeTimelineElement({ element: nextElement });
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldApplyRule({
|
function shouldApplyRule({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
|
import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
|
||||||
|
|
||||||
export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
|
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