mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { frameRateToFloat } from "@/lib/fps/utils";
|
||||
import type { BaseNode } from "./nodes/base-node";
|
||||
|
||||
export type CanvasRendererParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
fps: FrameRate;
|
||||
};
|
||||
|
||||
export class CanvasRenderer {
|
||||
@@ -11,7 +13,7 @@ export class CanvasRenderer {
|
||||
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
fps: FrameRate;
|
||||
|
||||
constructor({ width, height, fps }: CanvasRendererParams) {
|
||||
this.width = width;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur";
|
||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
import type { RetimeConfig } from "@/lib/timeline";
|
||||
@@ -39,9 +39,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
|
||||
|
||||
private isInRange({ time }: { time: number }): boolean {
|
||||
const localTime = time - this.params.timeOffset;
|
||||
return (
|
||||
localTime >= -TIME_EPSILON_SECONDS && localTime < this.params.duration
|
||||
);
|
||||
return localTime >= 0 && localTime < this.params.duration;
|
||||
}
|
||||
|
||||
private getSourceLocalTime({ time }: { time: number }): number {
|
||||
@@ -61,10 +59,11 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
|
||||
time: number;
|
||||
}): Promise<BackdropSource | null> {
|
||||
if (this.params.mediaType === "video") {
|
||||
const sourceTimeTicks = this.getSourceLocalTime({ time });
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: this.params.mediaId,
|
||||
file: this.params.file,
|
||||
time: this.getSourceLocalTime({ time }),
|
||||
time: mediaTimeToSeconds({ time: sourceTimeTicks }),
|
||||
});
|
||||
|
||||
if (!frame) {
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
|
||||
export interface VideoNodeParams extends VisualNodeParams {
|
||||
url: string;
|
||||
file: File;
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export class VideoNode extends VisualNode<VideoNodeParams> {
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
if (!this.isInRange({ time })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoTime = this.getSourceLocalTime({ time });
|
||||
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: this.params.mediaId,
|
||||
file: this.params.file,
|
||||
time: videoTime,
|
||||
});
|
||||
|
||||
if (frame) {
|
||||
this.renderVisual({
|
||||
renderer,
|
||||
source: frame.canvas,
|
||||
sourceWidth: frame.canvas.width,
|
||||
sourceHeight: frame.canvas.height,
|
||||
timelineTime: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { VisualNode, type VisualNodeParams } from "./visual-node";
|
||||
import { videoCache } from "@/services/video-cache/service";
|
||||
|
||||
export interface VideoNodeParams extends VisualNodeParams {
|
||||
url: string;
|
||||
file: File;
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export class VideoNode extends VisualNode<VideoNodeParams> {
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
if (!this.isInRange({ time })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoTimeTicks = this.getSourceLocalTime({ time });
|
||||
const videoTimeSeconds = mediaTimeToSeconds({ time: videoTimeTicks });
|
||||
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: this.params.mediaId,
|
||||
file: this.params.file,
|
||||
time: videoTimeSeconds,
|
||||
});
|
||||
|
||||
if (frame) {
|
||||
this.renderVisual({
|
||||
renderer,
|
||||
source: frame.canvas,
|
||||
sourceWidth: frame.canvas.width,
|
||||
sourceHeight: frame.canvas.height,
|
||||
timelineTime: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { effectsRegistry, resolveEffectPasses } from "@/lib/effects";
|
||||
import { masksRegistry } from "@/lib/masks";
|
||||
import { getSourceTimeAtClipTime } from "@/lib/retime";
|
||||
@@ -58,7 +57,7 @@ export abstract class VisualNode<
|
||||
protected isInRange({ time }: { time: number }): boolean {
|
||||
const localTime = time - this.params.timeOffset;
|
||||
return (
|
||||
localTime >= -TIME_EPSILON_SECONDS &&
|
||||
localTime >= 0 &&
|
||||
localTime < this.params.duration
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,163 +1,170 @@
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
import {
|
||||
Output,
|
||||
Mp4OutputFormat,
|
||||
WebMOutputFormat,
|
||||
BufferTarget,
|
||||
CanvasSource,
|
||||
AudioBufferSource,
|
||||
QUALITY_LOW,
|
||||
QUALITY_MEDIUM,
|
||||
QUALITY_HIGH,
|
||||
QUALITY_VERY_HIGH,
|
||||
} from "mediabunny";
|
||||
import type { RootNode } from "./nodes/root-node";
|
||||
import type { ExportFormat, ExportQuality } from "@/lib/export";
|
||||
import { CanvasRenderer } from "./canvas-renderer";
|
||||
|
||||
type ExportParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
shouldIncludeAudio?: boolean;
|
||||
audioBuffer?: AudioBuffer;
|
||||
};
|
||||
|
||||
const qualityMap = {
|
||||
low: QUALITY_LOW,
|
||||
medium: QUALITY_MEDIUM,
|
||||
high: QUALITY_HIGH,
|
||||
very_high: QUALITY_VERY_HIGH,
|
||||
};
|
||||
|
||||
export type SceneExporterEvents = {
|
||||
progress: [progress: number];
|
||||
complete: [buffer: ArrayBuffer];
|
||||
error: [error: Error];
|
||||
cancelled: [];
|
||||
};
|
||||
|
||||
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
|
||||
private renderer: CanvasRenderer;
|
||||
private format: ExportFormat;
|
||||
private quality: ExportQuality;
|
||||
private shouldIncludeAudio: boolean;
|
||||
private audioBuffer?: AudioBuffer;
|
||||
|
||||
private isCancelled = false;
|
||||
|
||||
constructor({
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
format,
|
||||
quality,
|
||||
shouldIncludeAudio,
|
||||
audioBuffer,
|
||||
}: ExportParams) {
|
||||
super();
|
||||
this.renderer = new CanvasRenderer({
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
});
|
||||
|
||||
this.format = format;
|
||||
this.quality = quality;
|
||||
this.shouldIncludeAudio = shouldIncludeAudio ?? false;
|
||||
this.audioBuffer = audioBuffer;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.isCancelled = true;
|
||||
}
|
||||
|
||||
async export({
|
||||
rootNode,
|
||||
}: {
|
||||
rootNode: RootNode;
|
||||
}): Promise<ArrayBuffer | null> {
|
||||
const { fps } = this.renderer;
|
||||
const frameCount = Math.ceil(rootNode.duration * fps);
|
||||
|
||||
const outputFormat =
|
||||
this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
||||
|
||||
const output = new Output({
|
||||
format: outputFormat,
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const videoSource = new CanvasSource(this.renderer.canvas, {
|
||||
codec: this.format === "webm" ? "vp9" : "avc",
|
||||
bitrate: qualityMap[this.quality],
|
||||
});
|
||||
|
||||
output.addVideoTrack(videoSource, { frameRate: fps });
|
||||
|
||||
let audioSource: AudioBufferSource | null = null;
|
||||
if (this.shouldIncludeAudio && this.audioBuffer) {
|
||||
let audioCodec: "aac" | "opus" =
|
||||
this.format === "webm" ? "opus" : "aac";
|
||||
|
||||
if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
|
||||
const { supported } = await AudioEncoder.isConfigSupported({
|
||||
codec: "mp4a.40.2",
|
||||
sampleRate: this.audioBuffer.sampleRate,
|
||||
numberOfChannels: this.audioBuffer.numberOfChannels,
|
||||
bitrate: 192000,
|
||||
});
|
||||
if (!supported) audioCodec = "opus";
|
||||
}
|
||||
|
||||
audioSource = new AudioBufferSource({
|
||||
codec: audioCodec,
|
||||
bitrate: qualityMap[this.quality],
|
||||
});
|
||||
output.addAudioTrack(audioSource);
|
||||
}
|
||||
|
||||
await output.start();
|
||||
|
||||
if (audioSource && this.audioBuffer) {
|
||||
await audioSource.add(this.audioBuffer);
|
||||
audioSource.close();
|
||||
}
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
if (this.isCancelled) {
|
||||
await output.cancel();
|
||||
this.emit("cancelled");
|
||||
return null;
|
||||
}
|
||||
|
||||
const time = i / fps;
|
||||
await this.renderer.render({ node: rootNode, time });
|
||||
await videoSource.add(time, 1 / fps);
|
||||
|
||||
this.emit("progress", i / frameCount);
|
||||
}
|
||||
|
||||
if (this.isCancelled) {
|
||||
await output.cancel();
|
||||
this.emit("cancelled");
|
||||
return null;
|
||||
}
|
||||
|
||||
videoSource.close();
|
||||
await output.finalize();
|
||||
this.emit("progress", 1);
|
||||
|
||||
const buffer = output.target.buffer;
|
||||
if (!buffer) {
|
||||
this.emit("error", new Error("Failed to export video"));
|
||||
return null;
|
||||
}
|
||||
|
||||
this.emit("complete", buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
import EventEmitter from "eventemitter3";
|
||||
|
||||
import {
|
||||
Output,
|
||||
Mp4OutputFormat,
|
||||
WebMOutputFormat,
|
||||
BufferTarget,
|
||||
CanvasSource,
|
||||
AudioBufferSource,
|
||||
QUALITY_LOW,
|
||||
QUALITY_MEDIUM,
|
||||
QUALITY_HIGH,
|
||||
QUALITY_VERY_HIGH,
|
||||
} from "mediabunny";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { mediaTimeToSeconds } from "opencut-wasm";
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
import { frameRateToFloat } from "@/lib/fps/utils";
|
||||
import type { RootNode } from "./nodes/root-node";
|
||||
import type { ExportFormat, ExportQuality } from "@/lib/export";
|
||||
import { CanvasRenderer } from "./canvas-renderer";
|
||||
|
||||
type ExportParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: FrameRate;
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
shouldIncludeAudio?: boolean;
|
||||
audioBuffer?: AudioBuffer;
|
||||
};
|
||||
|
||||
const qualityMap = {
|
||||
low: QUALITY_LOW,
|
||||
medium: QUALITY_MEDIUM,
|
||||
high: QUALITY_HIGH,
|
||||
very_high: QUALITY_VERY_HIGH,
|
||||
};
|
||||
|
||||
export type SceneExporterEvents = {
|
||||
progress: [progress: number];
|
||||
complete: [buffer: ArrayBuffer];
|
||||
error: [error: Error];
|
||||
cancelled: [];
|
||||
};
|
||||
|
||||
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
|
||||
private renderer: CanvasRenderer;
|
||||
private format: ExportFormat;
|
||||
private quality: ExportQuality;
|
||||
private shouldIncludeAudio: boolean;
|
||||
private audioBuffer?: AudioBuffer;
|
||||
|
||||
private isCancelled = false;
|
||||
|
||||
constructor({
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
format,
|
||||
quality,
|
||||
shouldIncludeAudio,
|
||||
audioBuffer,
|
||||
}: ExportParams) {
|
||||
super();
|
||||
this.renderer = new CanvasRenderer({
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
});
|
||||
|
||||
this.format = format;
|
||||
this.quality = quality;
|
||||
this.shouldIncludeAudio = shouldIncludeAudio ?? false;
|
||||
this.audioBuffer = audioBuffer;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.isCancelled = true;
|
||||
}
|
||||
|
||||
async export({
|
||||
rootNode,
|
||||
}: {
|
||||
rootNode: RootNode;
|
||||
}): Promise<ArrayBuffer | null> {
|
||||
const fps = this.renderer.fps;
|
||||
const fpsFloat = frameRateToFloat(fps);
|
||||
const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator);
|
||||
const frameCount = Math.floor(rootNode.duration / ticksPerFrame);
|
||||
|
||||
const outputFormat =
|
||||
this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat();
|
||||
|
||||
const output = new Output({
|
||||
format: outputFormat,
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const videoSource = new CanvasSource(this.renderer.canvas, {
|
||||
codec: this.format === "webm" ? "vp9" : "avc",
|
||||
bitrate: qualityMap[this.quality],
|
||||
});
|
||||
|
||||
output.addVideoTrack(videoSource, { frameRate: fpsFloat });
|
||||
|
||||
let audioSource: AudioBufferSource | null = null;
|
||||
if (this.shouldIncludeAudio && this.audioBuffer) {
|
||||
let audioCodec: "aac" | "opus" =
|
||||
this.format === "webm" ? "opus" : "aac";
|
||||
|
||||
if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") {
|
||||
const { supported } = await AudioEncoder.isConfigSupported({
|
||||
codec: "mp4a.40.2",
|
||||
sampleRate: this.audioBuffer.sampleRate,
|
||||
numberOfChannels: this.audioBuffer.numberOfChannels,
|
||||
bitrate: 192000,
|
||||
});
|
||||
if (!supported) audioCodec = "opus";
|
||||
}
|
||||
|
||||
audioSource = new AudioBufferSource({
|
||||
codec: audioCodec,
|
||||
bitrate: qualityMap[this.quality],
|
||||
});
|
||||
output.addAudioTrack(audioSource);
|
||||
}
|
||||
|
||||
await output.start();
|
||||
|
||||
if (audioSource && this.audioBuffer) {
|
||||
await audioSource.add(this.audioBuffer);
|
||||
audioSource.close();
|
||||
}
|
||||
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
if (this.isCancelled) {
|
||||
await output.cancel();
|
||||
this.emit("cancelled");
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeTicks = i * ticksPerFrame;
|
||||
const timeSeconds = mediaTimeToSeconds({ time: timeTicks });
|
||||
await this.renderer.render({ node: rootNode, time: timeTicks });
|
||||
await videoSource.add(timeSeconds, 1 / fpsFloat);
|
||||
|
||||
this.emit("progress", i / frameCount);
|
||||
}
|
||||
|
||||
if (this.isCancelled) {
|
||||
await output.cancel();
|
||||
this.emit("cancelled");
|
||||
return null;
|
||||
}
|
||||
|
||||
videoSource.close();
|
||||
await output.finalize();
|
||||
this.emit("progress", 1);
|
||||
|
||||
const buffer = output.target.buffer;
|
||||
if (!buffer) {
|
||||
this.emit("error", new Error("Failed to export video"));
|
||||
return null;
|
||||
}
|
||||
|
||||
this.emit("complete", buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
} from "@/lib/background/constants";
|
||||
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
const DEFAULT_FPS = 30;
|
||||
import type { MediaAssetData } from "@/services/storage/types";
|
||||
import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV22ToV23 } from "../transformers/v22-to-v23";
|
||||
|
||||
describe("V22 to V23 Migration", () => {
|
||||
test("converts project time values from seconds to ticks and fps to a frame-rate object", () => {
|
||||
const result = transformProjectV22ToV23({
|
||||
project: {
|
||||
id: "project-v22-time",
|
||||
version: 22,
|
||||
metadata: {
|
||||
id: "project-v22-time",
|
||||
name: "Project",
|
||||
duration: 15.5,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 29.97,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
timelineViewState: {
|
||||
zoomLevel: 1,
|
||||
scrollLeft: 120,
|
||||
playheadTime: 1.25,
|
||||
},
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-1",
|
||||
bookmarks: [
|
||||
{ time: 2.5, duration: 0.75, note: "Marker", color: "#ff0000" },
|
||||
{ time: 4.5 },
|
||||
],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-1",
|
||||
type: "video",
|
||||
elements: [
|
||||
{
|
||||
id: "element-1",
|
||||
type: "video",
|
||||
startTime: 1.25,
|
||||
duration: 5.5,
|
||||
trimStart: 0.25,
|
||||
trimEnd: 0.5,
|
||||
sourceDuration: 6.25,
|
||||
animations: {
|
||||
bindings: {
|
||||
opacity: {
|
||||
path: "opacity",
|
||||
kind: "number",
|
||||
components: [
|
||||
{
|
||||
key: "value",
|
||||
channelId: "opacity:value",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
"opacity:value": {
|
||||
kind: "scalar",
|
||||
keys: [
|
||||
{
|
||||
id: "key-1",
|
||||
time: 0.5,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
rightHandle: {
|
||||
dt: 0.25,
|
||||
dv: 0.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
time: 1.0,
|
||||
value: 0.4,
|
||||
segmentToNext: "linear",
|
||||
tangentMode: "flat",
|
||||
leftHandle: {
|
||||
dt: -0.125,
|
||||
dv: -0.1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(23);
|
||||
|
||||
const metadata = result.project.metadata as Record<string, unknown>;
|
||||
expect(metadata.duration).toBe(1_860_000);
|
||||
|
||||
const settings = result.project.settings as Record<string, unknown>;
|
||||
expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 });
|
||||
|
||||
const timelineViewState = result.project.timelineViewState as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(timelineViewState.playheadTime).toBe(150_000);
|
||||
expect(timelineViewState.scrollLeft).toBe(120);
|
||||
|
||||
const scenes = result.project.scenes as Array<Record<string, unknown>>;
|
||||
const scene = scenes[0];
|
||||
expect(scene.bookmarks).toEqual([
|
||||
{
|
||||
time: 300_000,
|
||||
duration: 90_000,
|
||||
note: "Marker",
|
||||
color: "#ff0000",
|
||||
},
|
||||
{ time: 540_000 },
|
||||
]);
|
||||
|
||||
const tracks = scene.tracks as Array<Record<string, unknown>>;
|
||||
const elements = tracks[0].elements as Array<Record<string, unknown>>;
|
||||
const element = elements[0];
|
||||
expect(element.startTime).toBe(150_000);
|
||||
expect(element.duration).toBe(660_000);
|
||||
expect(element.trimStart).toBe(30_000);
|
||||
expect(element.trimEnd).toBe(60_000);
|
||||
expect(element.sourceDuration).toBe(750_000);
|
||||
|
||||
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: 60_000,
|
||||
value: 1,
|
||||
segmentToNext: "bezier",
|
||||
tangentMode: "flat",
|
||||
rightHandle: {
|
||||
dt: 30_000,
|
||||
dv: 0.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
time: 120_000,
|
||||
value: 0.4,
|
||||
segmentToNext: "linear",
|
||||
tangentMode: "flat",
|
||||
leftHandle: {
|
||||
dt: -15_000,
|
||||
dv: -0.1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("skips projects already on v23", () => {
|
||||
const result = transformProjectV22ToV23({
|
||||
project: {
|
||||
id: "project-v23",
|
||||
version: 23,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v23");
|
||||
});
|
||||
|
||||
test("skips projects not on v22", () => {
|
||||
const result = transformProjectV22ToV23({
|
||||
project: {
|
||||
id: "project-v21",
|
||||
version: 21,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("not v22");
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,11 @@ import { V18toV19Migration } from "./v18-to-v19";
|
||||
import { V19toV20Migration } from "./v19-to-v20";
|
||||
import { V20toV21Migration } from "./v20-to-v21";
|
||||
import { V21toV22Migration } from "./v21-to-v22";
|
||||
import { V22toV23Migration } from "./v22-to-v23";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 22;
|
||||
export const CURRENT_PROJECT_VERSION = 23;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -49,4 +50,5 @@ export const migrations = [
|
||||
new V19toV20Migration(),
|
||||
new V20toV21Migration(),
|
||||
new V21toV22Migration(),
|
||||
new V22toV23Migration(),
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
DEFAULT_BACKGROUND_COLOR,
|
||||
} from "@/lib/background/constants";
|
||||
import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants";
|
||||
import { DEFAULT_FPS } from "@/lib/fps/constants";
|
||||
const DEFAULT_FPS = 30;
|
||||
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
|
||||
import type { MediaAssetData } from "@/services/storage/types";
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
import { TICKS_PER_SECOND } from "@/lib/wasm";
|
||||
const ARBITRARY_FPS_DENOMINATOR = 1_000_000;
|
||||
const STANDARD_FRAME_RATES = [
|
||||
{ value: 24_000 / 1_001, numerator: 24_000, denominator: 1_001 },
|
||||
{ value: 24, numerator: 24, denominator: 1 },
|
||||
{ value: 25, numerator: 25, denominator: 1 },
|
||||
{ value: 30_000 / 1_001, numerator: 30_000, denominator: 1_001 },
|
||||
{ value: 30, numerator: 30, denominator: 1 },
|
||||
{ value: 48, numerator: 48, denominator: 1 },
|
||||
{ value: 50, numerator: 50, denominator: 1 },
|
||||
{ value: 60_000 / 1_001, numerator: 60_000, denominator: 1_001 },
|
||||
{ value: 60, numerator: 60, denominator: 1 },
|
||||
{ value: 120, numerator: 120, denominator: 1 },
|
||||
] as const;
|
||||
const STANDARD_FRAME_RATE_TOLERANCE = 0.01;
|
||||
|
||||
export function transformProjectV22ToV23({
|
||||
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 >= 23) {
|
||||
return { project, skipped: true, reason: "already v23" };
|
||||
}
|
||||
if (version !== 22) {
|
||||
return { project, skipped: true, reason: "not v22" };
|
||||
}
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migrateProject({ project }),
|
||||
version: 23,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProject({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const nextProject = { ...project };
|
||||
|
||||
if (isRecord(project.metadata)) {
|
||||
nextProject.metadata = migrateMetadata({ metadata: project.metadata });
|
||||
}
|
||||
|
||||
if (isRecord(project.settings)) {
|
||||
nextProject.settings = migrateSettings({ settings: project.settings });
|
||||
}
|
||||
|
||||
if (isRecord(project.timelineViewState)) {
|
||||
nextProject.timelineViewState = migrateTimelineViewState({
|
||||
timelineViewState: project.timelineViewState,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(project.scenes)) {
|
||||
nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
|
||||
}
|
||||
|
||||
return nextProject;
|
||||
}
|
||||
|
||||
function migrateMetadata({
|
||||
metadata,
|
||||
}: {
|
||||
metadata: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
return migrateTimeFields({
|
||||
record: metadata,
|
||||
keys: ["duration"],
|
||||
});
|
||||
}
|
||||
|
||||
function migrateSettings({
|
||||
settings,
|
||||
}: {
|
||||
settings: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const nextSettings = { ...settings };
|
||||
if ("fps" in settings) {
|
||||
nextSettings.fps = migrateFrameRate({ fps: settings.fps });
|
||||
}
|
||||
return nextSettings;
|
||||
}
|
||||
|
||||
function migrateTimelineViewState({
|
||||
timelineViewState,
|
||||
}: {
|
||||
timelineViewState: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
return migrateTimeFields({
|
||||
record: timelineViewState,
|
||||
keys: ["playheadTime"],
|
||||
});
|
||||
}
|
||||
|
||||
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 (Array.isArray(scene.tracks)) {
|
||||
nextScene.tracks = scene.tracks.map((track) => migrateTrack({ track }));
|
||||
}
|
||||
|
||||
return nextScene;
|
||||
}
|
||||
|
||||
function migrateTrack({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
if (!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)) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
if (!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 = migrateCurveHandle({
|
||||
handle: keyframe.leftHandle,
|
||||
});
|
||||
}
|
||||
|
||||
if (isRecord(keyframe.rightHandle)) {
|
||||
nextKeyframe.rightHandle = migrateCurveHandle({
|
||||
handle: keyframe.rightHandle,
|
||||
});
|
||||
}
|
||||
|
||||
return nextKeyframe;
|
||||
}
|
||||
|
||||
function migrateCurveHandle({
|
||||
handle,
|
||||
}: {
|
||||
handle: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
return migrateTimeFields({
|
||||
record: handle,
|
||||
keys: ["dt"],
|
||||
});
|
||||
}
|
||||
|
||||
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] = migrateTimeValue({ value: record[key] });
|
||||
}
|
||||
|
||||
return nextRecord;
|
||||
}
|
||||
|
||||
function migrateTimeValue({ value }: { value: unknown }): unknown {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return secondsToTicks({ value });
|
||||
}
|
||||
|
||||
function secondsToTicks({ value }: { value: number }): number {
|
||||
return Math.round(value * TICKS_PER_SECOND);
|
||||
}
|
||||
|
||||
function migrateFrameRate({ fps }: { fps: unknown }): unknown {
|
||||
if (isRecord(fps)) {
|
||||
return fps;
|
||||
}
|
||||
|
||||
if (typeof fps !== "number" || !Number.isFinite(fps) || fps <= 0) {
|
||||
return fps;
|
||||
}
|
||||
|
||||
const standardFrameRate = STANDARD_FRAME_RATES.find(
|
||||
(candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE,
|
||||
);
|
||||
if (standardFrameRate) {
|
||||
return {
|
||||
numerator: standardFrameRate.numerator,
|
||||
denominator: standardFrameRate.denominator,
|
||||
};
|
||||
}
|
||||
|
||||
if (Number.isInteger(fps)) {
|
||||
return { numerator: fps, denominator: 1 };
|
||||
}
|
||||
|
||||
const scaledNumerator = Math.round(fps * ARBITRARY_FPS_DENOMINATOR);
|
||||
const divisor = greatestCommonDivisor({
|
||||
left: scaledNumerator,
|
||||
right: ARBITRARY_FPS_DENOMINATOR,
|
||||
});
|
||||
|
||||
return {
|
||||
numerator: scaledNumerator / divisor,
|
||||
denominator: ARBITRARY_FPS_DENOMINATOR / divisor,
|
||||
};
|
||||
}
|
||||
|
||||
function greatestCommonDivisor({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: number;
|
||||
right: number;
|
||||
}): number {
|
||||
let nextLeft = Math.abs(left);
|
||||
let nextRight = Math.abs(right);
|
||||
|
||||
while (nextRight !== 0) {
|
||||
const remainder = nextLeft % nextRight;
|
||||
nextLeft = nextRight;
|
||||
nextRight = remainder;
|
||||
}
|
||||
|
||||
return nextLeft || 1;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV22ToV23 } from "./transformers/v22-to-v23";
|
||||
|
||||
export class V22toV23Migration extends StorageMigration {
|
||||
from = 22;
|
||||
to = 23;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV22ToV23({ project });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user