mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: introduce WebGL effects system and Blur effect
This implements the foundational architecture for video effects, starting with a multi-pass WebGL rendering pipeline and a customizable Gaussian Blur effect. Key changes: - WebGL Engine: Added `raw-loader` for `.glsl` shaders, multi-pass framebuffer rendering, and live offscreen canvas previews. - Node Architecture: Replaced hardcoded background blur with `CompositeEffectNode` and added `EffectLayerNode` to apply effects to specific visual elements. - Timeline & DND: Added a new `effect` track type. Upgraded drag-and-drop to support dropping effects directly onto the timeline. Consolidated track constants into a cleaner `TRACK_CONFIG`. - UI/UX: Added an Effects tab in the assets panel with live previews. Added an Effect Properties panel with sliders and inputs for fine-tuning parameters. - Data Model: Added `sourceDuration` to video and audio elements, and wrote a v8 storage migration to update existing projects to the new schema. - Docs: Added `CHANGELOG.md` tracking v0.1.0 and v0.2.0, plus `docs/effects-renderer.md` to document the new WebGL pipeline.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
export function createOffscreenCanvas({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
try {
|
||||
return new OffscreenCanvas(width, height);
|
||||
} catch {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { createOffscreenCanvas } from "./canvas-utils";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
import { applyMultiPassEffect } from "./webgl-utils";
|
||||
import type { EffectPassData } from "./webgl-utils";
|
||||
|
||||
const PREVIEW_SIZE = 160;
|
||||
const PREVIEW_IMAGE_PATH = "/effects/preview.jpg";
|
||||
|
||||
let previewGl: WebGLRenderingContext | null = null;
|
||||
let previewCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
||||
let testSourceCanvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
||||
let previewImageElement: HTMLImageElement | null = null;
|
||||
const programCache = new Map<string, WebGLProgram>();
|
||||
const onReadyCallbacks = new Set<() => void>();
|
||||
|
||||
export function onPreviewImageReady({
|
||||
callback,
|
||||
}: {
|
||||
callback: () => void;
|
||||
}): () => void {
|
||||
onReadyCallbacks.add(callback);
|
||||
return () => onReadyCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
function loadPreviewImage(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
testSourceCanvas = null;
|
||||
for (const callback of onReadyCallbacks) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
image.src = PREVIEW_IMAGE_PATH;
|
||||
previewImageElement = image;
|
||||
}
|
||||
|
||||
loadPreviewImage();
|
||||
|
||||
function buildDefaultParams({
|
||||
effectType,
|
||||
}: {
|
||||
effectType: string;
|
||||
}): EffectParamValues {
|
||||
const definition = getEffect({ effectType });
|
||||
const params: EffectParamValues = {};
|
||||
for (const paramDef of definition.params) {
|
||||
params[paramDef.key] = paramDef.default;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function createTestSource({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): OffscreenCanvas | HTMLCanvasElement | null {
|
||||
const isImageReady =
|
||||
previewImageElement?.complete &&
|
||||
(previewImageElement.naturalWidth ?? 0) > 0;
|
||||
if (!isImageReady || !previewImageElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canvas = createOffscreenCanvas({ width, height });
|
||||
const ctx = canvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (!ctx) {
|
||||
throw new Error("failed to get 2d context for test source");
|
||||
}
|
||||
ctx.drawImage(previewImageElement, 0, 0, width, height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getOrCreatePreviewContext({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): { canvas: OffscreenCanvas | HTMLCanvasElement; gl: WebGLRenderingContext } {
|
||||
if (!previewCanvas || !previewGl) {
|
||||
previewCanvas = createOffscreenCanvas({ width, height });
|
||||
previewGl = previewCanvas.getContext("webgl", {
|
||||
premultipliedAlpha: false,
|
||||
}) as WebGLRenderingContext | null;
|
||||
if (!previewGl) {
|
||||
throw new Error("WebGL not supported");
|
||||
}
|
||||
}
|
||||
if (previewCanvas.width !== width || previewCanvas.height !== height) {
|
||||
previewCanvas.width = width;
|
||||
previewCanvas.height = height;
|
||||
}
|
||||
return { canvas: previewCanvas, gl: previewGl };
|
||||
}
|
||||
|
||||
function getTestSource({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): CanvasImageSource | null {
|
||||
if (
|
||||
!testSourceCanvas ||
|
||||
testSourceCanvas.width !== width ||
|
||||
testSourceCanvas.height !== height
|
||||
) {
|
||||
testSourceCanvas = createTestSource({ width, height });
|
||||
}
|
||||
return testSourceCanvas;
|
||||
}
|
||||
|
||||
function applyWebGlEffect({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
}: {
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPassData[];
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
const { canvas: glCanvas, gl } = getOrCreatePreviewContext({ width, height });
|
||||
|
||||
applyMultiPassEffect({ context: gl, source, width, height, passes, programCache });
|
||||
|
||||
const outputCanvas = createOffscreenCanvas({ width, height });
|
||||
const outputCtx = outputCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (outputCtx) {
|
||||
outputCtx.drawImage(glCanvas, 0, 0, width, height);
|
||||
}
|
||||
return outputCanvas;
|
||||
}
|
||||
|
||||
export function renderPreview({
|
||||
effectType,
|
||||
params,
|
||||
targetCanvas,
|
||||
}: {
|
||||
effectType: string;
|
||||
params: EffectParamValues;
|
||||
targetCanvas: HTMLCanvasElement;
|
||||
}): void {
|
||||
const size = PREVIEW_SIZE;
|
||||
const source = getTestSource({ width: size, height: size });
|
||||
if (!source) return;
|
||||
|
||||
const definition = getEffect({ effectType });
|
||||
const resolvedParams =
|
||||
Object.keys(params).length > 0
|
||||
? params
|
||||
: buildDefaultParams({ effectType });
|
||||
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: resolvedParams,
|
||||
width: size,
|
||||
height: size,
|
||||
}),
|
||||
}));
|
||||
const result = applyWebGlEffect({
|
||||
source,
|
||||
width: size,
|
||||
height: size,
|
||||
passes,
|
||||
});
|
||||
|
||||
const targetCtx = targetCanvas.getContext(
|
||||
"2d",
|
||||
) as CanvasRenderingContext2D | null;
|
||||
if (targetCtx) {
|
||||
targetCanvas.width = size;
|
||||
targetCanvas.height = size;
|
||||
targetCtx.drawImage(result, 0, 0, size, size);
|
||||
}
|
||||
}
|
||||
|
||||
export const effectPreviewService = {
|
||||
renderPreview,
|
||||
onPreviewImageReady,
|
||||
PREVIEW_SIZE,
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { BaseNode } from "./base-node";
|
||||
|
||||
export type BlurBackgroundNodeParams = {
|
||||
blurIntensity: number;
|
||||
contentNodes: BaseNode[];
|
||||
};
|
||||
|
||||
export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
|
||||
private blurIntensity: number;
|
||||
private contentNodes: BaseNode[];
|
||||
|
||||
constructor(params: BlurBackgroundNodeParams) {
|
||||
super(params);
|
||||
this.blurIntensity = params.blurIntensity;
|
||||
this.contentNodes = params.contentNodes;
|
||||
}
|
||||
|
||||
async render({
|
||||
renderer,
|
||||
time,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
time: number;
|
||||
}): Promise<void> {
|
||||
let offscreen: OffscreenCanvas | HTMLCanvasElement;
|
||||
let offscreenCtx:
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| CanvasRenderingContext2D;
|
||||
|
||||
try {
|
||||
offscreen = new OffscreenCanvas(renderer.width, renderer.height);
|
||||
const ctx = offscreen.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("failed to get offscreen canvas context");
|
||||
}
|
||||
offscreenCtx = ctx;
|
||||
} catch {
|
||||
offscreen = document.createElement("canvas");
|
||||
offscreen.width = renderer.width;
|
||||
offscreen.height = renderer.height;
|
||||
const ctx = offscreen.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("failed to get canvas context");
|
||||
}
|
||||
offscreenCtx = ctx;
|
||||
}
|
||||
|
||||
const originalContext = renderer.context;
|
||||
renderer.context = offscreenCtx;
|
||||
|
||||
for (const node of this.contentNodes) {
|
||||
await node.render({ renderer, time });
|
||||
}
|
||||
|
||||
renderer.context = originalContext;
|
||||
|
||||
const zoomScale = 1.4;
|
||||
const scaledWidth = renderer.width * zoomScale;
|
||||
const scaledHeight = renderer.height * zoomScale;
|
||||
const offsetX = (renderer.width - scaledWidth) / 2;
|
||||
const offsetY = (renderer.height - scaledHeight) / 2;
|
||||
|
||||
renderer.context.save();
|
||||
renderer.context.filter = `blur(${this.blurIntensity}px)`;
|
||||
renderer.context.drawImage(
|
||||
offscreen as CanvasImageSource,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
|
||||
export type CompositeEffectNodeParams = {
|
||||
contentNodes: BaseNode[];
|
||||
effectType: string;
|
||||
effectParams: EffectParamValues;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
export class CompositeEffectNode extends BaseNode<CompositeEffectNodeParams> {
|
||||
async render({
|
||||
renderer,
|
||||
time,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
time: number;
|
||||
}): Promise<void> {
|
||||
const offscreen = createOffscreenCanvas({
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
});
|
||||
const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
|
||||
if (!offscreenCtx) {
|
||||
throw new Error("failed to get offscreen canvas context");
|
||||
}
|
||||
|
||||
const originalContext = renderer.context;
|
||||
renderer.context = offscreenCtx;
|
||||
|
||||
for (const node of this.params.contentNodes) {
|
||||
await node.render({ renderer, time });
|
||||
}
|
||||
|
||||
renderer.context = originalContext;
|
||||
|
||||
const effectDefinition = getEffect({ effectType: this.params.effectType });
|
||||
const scale = this.params.scale;
|
||||
const scaledWidth = renderer.width * scale;
|
||||
const scaledHeight = renderer.height * scale;
|
||||
const offsetX = (renderer.width - scaledWidth) / 2;
|
||||
const offsetY = (renderer.height - scaledHeight) / 2;
|
||||
|
||||
const passes = effectDefinition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: this.params.effectParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
}),
|
||||
}));
|
||||
const effectResult = webglEffectRenderer.applyEffect({
|
||||
source: offscreen as CanvasImageSource,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
|
||||
renderer.context.save();
|
||||
renderer.context.drawImage(
|
||||
effectResult,
|
||||
0,
|
||||
0,
|
||||
renderer.width,
|
||||
renderer.height,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import type { EffectParamValues } from "@/types/effects";
|
||||
import { BaseNode } from "./base-node";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
|
||||
const TIME_EPSILON = 1e-6;
|
||||
|
||||
export type EffectLayerNodeParams = {
|
||||
effectType: string;
|
||||
effectParams: EffectParamValues;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
function isInRange({
|
||||
time,
|
||||
timeOffset,
|
||||
duration,
|
||||
}: {
|
||||
time: number;
|
||||
timeOffset: number;
|
||||
duration: number;
|
||||
}): boolean {
|
||||
return (
|
||||
time >= timeOffset - TIME_EPSILON &&
|
||||
time < timeOffset + duration + TIME_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// snapshots whatever is currently on the canvas, applies the effect, draws it back
|
||||
export class EffectLayerNode extends BaseNode<EffectLayerNodeParams> {
|
||||
async render({
|
||||
renderer,
|
||||
time,
|
||||
}: {
|
||||
renderer: CanvasRenderer;
|
||||
time: number;
|
||||
}): Promise<void> {
|
||||
if (
|
||||
!isInRange({
|
||||
time,
|
||||
timeOffset: this.params.timeOffset,
|
||||
duration: this.params.duration,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = renderer.context.canvas as CanvasImageSource;
|
||||
|
||||
const effectDefinition = getEffect({
|
||||
effectType: this.params.effectType,
|
||||
});
|
||||
|
||||
const passes = effectDefinition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: this.params.effectParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
}),
|
||||
}));
|
||||
const effectResult = webglEffectRenderer.applyEffect({
|
||||
source,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
|
||||
renderer.context.save();
|
||||
renderer.context.clearRect(0, 0, renderer.width, renderer.height);
|
||||
renderer.context.drawImage(
|
||||
effectResult,
|
||||
0,
|
||||
0,
|
||||
renderer.width,
|
||||
renderer.height,
|
||||
);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
if (!this.isInRange(time)) {
|
||||
if (!this.isInRange({ time })) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
if (!this.isInRange(time)) {
|
||||
if (!this.isInRange({ time })) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
|
||||
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
|
||||
await super.render({ renderer, time });
|
||||
|
||||
if (!this.isInRange(time)) {
|
||||
if (!this.isInRange({ time })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoTime = this.getSourceLocalTime(time);
|
||||
const videoTime = this.getSourceLocalTime({ time });
|
||||
const frame = await videoCache.getFrameAt({
|
||||
mediaId: this.params.mediaId,
|
||||
file: this.params.file,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { Effect } from "@/types/effects";
|
||||
import type { BlendMode } from "@/types/rendering";
|
||||
import type { Transform } from "@/types/timeline";
|
||||
import type { ElementAnimations } from "@/types/animation";
|
||||
@@ -9,6 +11,8 @@ import {
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
|
||||
export interface VisualNodeParams {
|
||||
duration: number;
|
||||
@@ -19,16 +23,17 @@ export interface VisualNodeParams {
|
||||
animations?: ElementAnimations;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export abstract class VisualNode<
|
||||
Params extends VisualNodeParams = VisualNodeParams,
|
||||
> extends BaseNode<Params> {
|
||||
protected getSourceLocalTime(time: number): number {
|
||||
protected getSourceLocalTime({ time }: { time: number }): number {
|
||||
return time - this.params.timeOffset + this.params.trimStart;
|
||||
}
|
||||
|
||||
protected getAnimationLocalTime(time: number): number {
|
||||
protected getAnimationLocalTime({ time }: { time: number }): number {
|
||||
return getElementLocalTime({
|
||||
timelineTime: time,
|
||||
elementStartTime: this.params.timeOffset,
|
||||
@@ -36,8 +41,8 @@ export abstract class VisualNode<
|
||||
});
|
||||
}
|
||||
|
||||
protected isInRange(time: number): boolean {
|
||||
const localTime = this.getSourceLocalTime(time);
|
||||
protected isInRange({ time }: { time: number }): boolean {
|
||||
const localTime = this.getSourceLocalTime({ time });
|
||||
return (
|
||||
localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
|
||||
localTime < this.params.trimStart + this.params.duration
|
||||
@@ -59,7 +64,7 @@ export abstract class VisualNode<
|
||||
}): void {
|
||||
renderer.context.save();
|
||||
|
||||
const animationLocalTime = this.getAnimationLocalTime(timelineTime);
|
||||
const animationLocalTime = this.getAnimationLocalTime({ time: timelineTime });
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: this.params.transform,
|
||||
animations: this.params.animations,
|
||||
@@ -94,7 +99,58 @@ export abstract class VisualNode<
|
||||
renderer.context.translate(-centerX, -centerY);
|
||||
}
|
||||
|
||||
renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
|
||||
const enabledEffects =
|
||||
this.params.effects?.filter((effect) => effect.enabled) ?? [];
|
||||
|
||||
if (enabledEffects.length === 0) {
|
||||
renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const elementCanvas = createOffscreenCanvas({
|
||||
width: Math.round(scaledWidth),
|
||||
height: Math.round(scaledHeight),
|
||||
});
|
||||
const elementCtx = elementCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (!elementCtx) {
|
||||
renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
elementCtx.drawImage(source, 0, 0, scaledWidth, scaledHeight);
|
||||
|
||||
let currentResult: CanvasImageSource = elementCanvas;
|
||||
|
||||
for (const effect of enabledEffects) {
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: effect.params,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
}),
|
||||
}));
|
||||
currentResult = webglEffectRenderer.applyEffect({
|
||||
source: currentResult,
|
||||
width: Math.round(scaledWidth),
|
||||
height: Math.round(scaledHeight),
|
||||
passes,
|
||||
});
|
||||
}
|
||||
|
||||
renderer.context.drawImage(
|
||||
currentResult,
|
||||
x,
|
||||
y,
|
||||
scaledWidth,
|
||||
scaledHeight,
|
||||
);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,137 @@ import { ImageNode } from "./nodes/image-node";
|
||||
import { TextNode } from "./nodes/text-node";
|
||||
import { StickerNode } from "./nodes/sticker-node";
|
||||
import { ColorNode } from "./nodes/color-node";
|
||||
import { BlurBackgroundNode } from "./nodes/blur-background-node";
|
||||
import { CompositeEffectNode } from "./nodes/composite-effect-node";
|
||||
import { EffectLayerNode } from "./nodes/effect-layer-node";
|
||||
import type { BaseNode } from "./nodes/base-node";
|
||||
import type { TBackground, TCanvasSize } from "@/types/project";
|
||||
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
|
||||
const PREVIEW_MAX_IMAGE_SIZE = 2048;
|
||||
const BLUR_BACKGROUND_ZOOM_SCALE = 1.4;
|
||||
|
||||
function getVisibleSortedElements({
|
||||
track,
|
||||
}: {
|
||||
track: TimelineTrack;
|
||||
}) {
|
||||
return track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
function buildTrackNodes({
|
||||
tracks,
|
||||
mediaMap,
|
||||
canvasSize,
|
||||
isPreview,
|
||||
}: {
|
||||
tracks: TimelineTrack[];
|
||||
mediaMap: Map<string, MediaAsset>;
|
||||
canvasSize: TCanvasSize;
|
||||
isPreview?: boolean;
|
||||
}): BaseNode[] {
|
||||
const nodes: BaseNode[] = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
const elements = getVisibleSortedElements({ track });
|
||||
|
||||
for (const element of elements) {
|
||||
if (element.type === "effect") {
|
||||
nodes.push(
|
||||
new EffectLayerNode({
|
||||
effectType: element.effectType,
|
||||
effectParams: element.params,
|
||||
timeOffset: element.startTime,
|
||||
duration: element.duration,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset?.file || !mediaAsset?.url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mediaAsset.type === "video") {
|
||||
nodes.push(
|
||||
new VideoNode({
|
||||
mediaId: mediaAsset.id,
|
||||
url: mediaAsset.url,
|
||||
file: mediaAsset.file,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
effects: element.effects,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (mediaAsset.type === "image") {
|
||||
nodes.push(
|
||||
new ImageNode({
|
||||
url: mediaAsset.url,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
effects: element.effects,
|
||||
...(isPreview && {
|
||||
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
nodes.push(
|
||||
new TextNode({
|
||||
...element,
|
||||
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
|
||||
canvasHeight: canvasSize.height,
|
||||
textBaseline: "middle",
|
||||
effects: element.effects,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
nodes.push(
|
||||
new StickerNode({
|
||||
stickerId: element.stickerId,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
effects: element.effects,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export type BuildSceneParams = {
|
||||
canvasSize: TCanvasSize;
|
||||
@@ -22,9 +147,14 @@ export type BuildSceneParams = {
|
||||
isPreview?: boolean;
|
||||
};
|
||||
|
||||
export function buildScene(params: BuildSceneParams) {
|
||||
const { tracks, mediaAssets, duration, canvasSize, background } = params;
|
||||
|
||||
export function buildScene({
|
||||
canvasSize,
|
||||
tracks,
|
||||
mediaAssets,
|
||||
duration,
|
||||
background,
|
||||
isPreview,
|
||||
}: BuildSceneParams) {
|
||||
const rootNode = new RootNode({ duration });
|
||||
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
|
||||
|
||||
@@ -39,107 +169,33 @@ export function buildScene(params: BuildSceneParams) {
|
||||
|
||||
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
|
||||
|
||||
const contentNodes = [];
|
||||
|
||||
for (const track of orderedTracksBottomToTop) {
|
||||
const elements = track.elements
|
||||
.filter((element) => !("hidden" in element && element.hidden))
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
if (element.type === "video" || element.type === "image") {
|
||||
const mediaAsset = mediaMap.get(element.mediaId);
|
||||
if (!mediaAsset?.file || !mediaAsset?.url) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mediaAsset.type === "video") {
|
||||
contentNodes.push(
|
||||
new VideoNode({
|
||||
mediaId: mediaAsset.id,
|
||||
url: mediaAsset.url,
|
||||
file: mediaAsset.file,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (mediaAsset.type === "image") {
|
||||
contentNodes.push(
|
||||
new ImageNode({
|
||||
url: mediaAsset.url,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
...(params.isPreview && {
|
||||
maxSourceSize: PREVIEW_MAX_IMAGE_SIZE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (element.type === "text") {
|
||||
contentNodes.push(
|
||||
new TextNode({
|
||||
...element,
|
||||
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
|
||||
canvasHeight: canvasSize.height,
|
||||
textBaseline: "middle",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === "sticker") {
|
||||
contentNodes.push(
|
||||
new StickerNode({
|
||||
stickerId: element.stickerId,
|
||||
duration: element.duration,
|
||||
timeOffset: element.startTime,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
transform: element.transform,
|
||||
animations: element.animations,
|
||||
opacity: element.opacity,
|
||||
blendMode: element.blendMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const allNodes = buildTrackNodes({
|
||||
tracks: orderedTracksBottomToTop,
|
||||
mediaMap,
|
||||
canvasSize,
|
||||
isPreview,
|
||||
});
|
||||
|
||||
if (background.type === "blur") {
|
||||
rootNode.add(
|
||||
new BlurBackgroundNode({
|
||||
blurIntensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
|
||||
contentNodes,
|
||||
new CompositeEffectNode({
|
||||
contentNodes: allNodes.filter(
|
||||
(node) => !(node instanceof EffectLayerNode),
|
||||
),
|
||||
effectType: "blur",
|
||||
effectParams: {
|
||||
intensity:
|
||||
background.blurIntensity ?? DEFAULT_BLUR_INTENSITY,
|
||||
},
|
||||
scale: BLUR_BACKGROUND_ZOOM_SCALE,
|
||||
}),
|
||||
);
|
||||
for (const node of contentNodes) {
|
||||
rootNode.add(node);
|
||||
}
|
||||
} else {
|
||||
if (background.type === "color" && background.color !== "transparent") {
|
||||
rootNode.add(new ColorNode({ color: background.color }));
|
||||
}
|
||||
for (const node of contentNodes) {
|
||||
rootNode.add(node);
|
||||
}
|
||||
} else if (background.type === "color" && background.color !== "transparent") {
|
||||
rootNode.add(new ColorNode({ color: background.color }));
|
||||
}
|
||||
|
||||
for (const node of allNodes) {
|
||||
rootNode.add(node);
|
||||
}
|
||||
|
||||
return rootNode;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createOffscreenCanvas } from "./canvas-utils";
|
||||
import { applyMultiPassEffect } from "./webgl-utils";
|
||||
import type { EffectPassData } from "./webgl-utils";
|
||||
|
||||
export interface ApplyEffectParams {
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPassData[];
|
||||
}
|
||||
|
||||
let gl: WebGLRenderingContext | null = null;
|
||||
let canvas: OffscreenCanvas | HTMLCanvasElement | null = null;
|
||||
const programCache = new Map<string, WebGLProgram>();
|
||||
|
||||
function getOrCreateCanvas({
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
width: number;
|
||||
height: number;
|
||||
}): OffscreenCanvas | HTMLCanvasElement {
|
||||
if (!canvas) {
|
||||
canvas = createOffscreenCanvas({ width, height });
|
||||
gl = canvas.getContext("webgl", {
|
||||
premultipliedAlpha: false,
|
||||
}) as WebGLRenderingContext | null;
|
||||
if (!gl) {
|
||||
throw new Error("WebGL not supported");
|
||||
}
|
||||
}
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function applyEffect({
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
}: ApplyEffectParams): OffscreenCanvas | HTMLCanvasElement {
|
||||
const targetCanvas = getOrCreateCanvas({ width, height });
|
||||
const context = gl;
|
||||
if (!context) {
|
||||
throw new Error("WebGL context not initialized");
|
||||
}
|
||||
|
||||
applyMultiPassEffect({
|
||||
context,
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
programCache,
|
||||
});
|
||||
|
||||
const outputCanvas = createOffscreenCanvas({ width, height });
|
||||
const outputCtx = outputCanvas.getContext("2d") as
|
||||
| CanvasRenderingContext2D
|
||||
| OffscreenCanvasRenderingContext2D
|
||||
| null;
|
||||
if (outputCtx) {
|
||||
outputCtx.drawImage(targetCanvas, 0, 0, width, height);
|
||||
}
|
||||
return outputCanvas;
|
||||
}
|
||||
|
||||
export const webglEffectRenderer = {
|
||||
applyEffect,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
import VERTEX_SHADER_SOURCE from "@/lib/effects/effect.vert.glsl";
|
||||
|
||||
export interface EffectPassData {
|
||||
fragmentShader: string;
|
||||
uniforms: Record<string, number | number[]>;
|
||||
}
|
||||
|
||||
export const QUAD_POSITIONS = new Float32Array([
|
||||
-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1,
|
||||
]);
|
||||
|
||||
export function compileProgram({
|
||||
context,
|
||||
fragmentShaderSource,
|
||||
programCache,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
fragmentShaderSource: string;
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
}): WebGLProgram {
|
||||
const cached = programCache.get(fragmentShaderSource);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const vertexShader = compileShader({
|
||||
context,
|
||||
source: VERTEX_SHADER_SOURCE,
|
||||
type: context.VERTEX_SHADER,
|
||||
});
|
||||
const fragmentShader = compileShader({
|
||||
context,
|
||||
source: fragmentShaderSource,
|
||||
type: context.FRAGMENT_SHADER,
|
||||
});
|
||||
const program = context.createProgram();
|
||||
if (!program) {
|
||||
throw new Error("Failed to create WebGL program");
|
||||
}
|
||||
context.attachShader(program, vertexShader);
|
||||
context.attachShader(program, fragmentShader);
|
||||
context.linkProgram(program);
|
||||
if (!context.getProgramParameter(program, context.LINK_STATUS)) {
|
||||
const info = context.getProgramInfoLog(program);
|
||||
context.deleteProgram(program);
|
||||
throw new Error(`WebGL program link failed: ${info}`);
|
||||
}
|
||||
context.deleteShader(vertexShader);
|
||||
context.deleteShader(fragmentShader);
|
||||
programCache.set(fragmentShaderSource, program);
|
||||
return program;
|
||||
}
|
||||
|
||||
export function compileShader({
|
||||
context,
|
||||
source,
|
||||
type,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
source: string;
|
||||
type: number;
|
||||
}): WebGLShader {
|
||||
const shader = context.createShader(type);
|
||||
if (!shader) {
|
||||
throw new Error("Failed to create WebGL shader");
|
||||
}
|
||||
context.shaderSource(shader, source);
|
||||
context.compileShader(shader);
|
||||
if (!context.getShaderParameter(shader, context.COMPILE_STATUS)) {
|
||||
const info = context.getShaderInfoLog(shader);
|
||||
context.deleteShader(shader);
|
||||
throw new Error(`WebGL shader compile failed: ${info}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
export function createTexture({
|
||||
context,
|
||||
source,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
source: CanvasImageSource;
|
||||
}): WebGLTexture {
|
||||
const texture = context.createTexture();
|
||||
if (!texture) {
|
||||
throw new Error("Failed to create WebGL texture");
|
||||
}
|
||||
context.activeTexture(context.TEXTURE0);
|
||||
context.bindTexture(context.TEXTURE_2D, texture);
|
||||
context.pixelStorei(context.UNPACK_FLIP_Y_WEBGL, 1);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_S,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_T,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MIN_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MAG_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.texImage2D(
|
||||
context.TEXTURE_2D,
|
||||
0,
|
||||
context.RGBA,
|
||||
context.RGBA,
|
||||
context.UNSIGNED_BYTE,
|
||||
source as TexImageSource,
|
||||
);
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function setUniforms({
|
||||
context,
|
||||
program,
|
||||
uniforms,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
program: WebGLProgram;
|
||||
uniforms: Record<string, number | number[]>;
|
||||
}): void {
|
||||
for (const [name, value] of Object.entries(uniforms)) {
|
||||
const location = context.getUniformLocation(program, name);
|
||||
if (location === null) continue;
|
||||
|
||||
if (typeof value === "number") {
|
||||
context.uniform1f(location, value);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 2) {
|
||||
context.uniform2fv(location, new Float32Array(value));
|
||||
} else if (value.length === 3) {
|
||||
context.uniform3fv(location, new Float32Array(value));
|
||||
} else if (value.length === 4) {
|
||||
context.uniform4fv(location, new Float32Array(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function drawFullscreenQuad({
|
||||
context,
|
||||
program,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
program: WebGLProgram;
|
||||
width: number;
|
||||
height: number;
|
||||
}): void {
|
||||
const positionLocation = context.getAttribLocation(program, "a_position");
|
||||
const buffer = context.createBuffer();
|
||||
context.bindBuffer(context.ARRAY_BUFFER, buffer);
|
||||
context.bufferData(context.ARRAY_BUFFER, QUAD_POSITIONS, context.STATIC_DRAW);
|
||||
context.enableVertexAttribArray(positionLocation);
|
||||
context.vertexAttribPointer(positionLocation, 2, context.FLOAT, false, 0, 0);
|
||||
|
||||
context.viewport(0, 0, width, height);
|
||||
context.clearColor(0, 0, 0, 0);
|
||||
context.clear(context.COLOR_BUFFER_BIT);
|
||||
context.drawArrays(context.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
export function createFramebufferTexture({
|
||||
context,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
width: number;
|
||||
height: number;
|
||||
}): { texture: WebGLTexture; framebuffer: WebGLFramebuffer } {
|
||||
const texture = context.createTexture();
|
||||
if (!texture) throw new Error("Failed to create framebuffer texture");
|
||||
context.bindTexture(context.TEXTURE_2D, texture);
|
||||
context.texImage2D(
|
||||
context.TEXTURE_2D,
|
||||
0,
|
||||
context.RGBA,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
context.RGBA,
|
||||
context.UNSIGNED_BYTE,
|
||||
null,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_S,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_WRAP_T,
|
||||
context.CLAMP_TO_EDGE,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MIN_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.texParameteri(
|
||||
context.TEXTURE_2D,
|
||||
context.TEXTURE_MAG_FILTER,
|
||||
context.LINEAR,
|
||||
);
|
||||
context.bindTexture(context.TEXTURE_2D, null);
|
||||
|
||||
const framebuffer = context.createFramebuffer();
|
||||
if (!framebuffer) throw new Error("Failed to create framebuffer");
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, framebuffer);
|
||||
context.framebufferTexture2D(
|
||||
context.FRAMEBUFFER,
|
||||
context.COLOR_ATTACHMENT0,
|
||||
context.TEXTURE_2D,
|
||||
texture,
|
||||
0,
|
||||
);
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, null);
|
||||
|
||||
return { texture, framebuffer };
|
||||
}
|
||||
|
||||
export function applyMultiPassEffect({
|
||||
context,
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
passes,
|
||||
programCache,
|
||||
}: {
|
||||
context: WebGLRenderingContext;
|
||||
source: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
passes: EffectPassData[];
|
||||
programCache: Map<string, WebGLProgram>;
|
||||
}): void {
|
||||
const sourceTexture = createTexture({ context, source });
|
||||
let currentTexture: WebGLTexture = sourceTexture;
|
||||
|
||||
const intermediates: Array<{
|
||||
texture: WebGLTexture;
|
||||
framebuffer: WebGLFramebuffer;
|
||||
}> = [];
|
||||
for (let i = 0; i < passes.length - 1; i++) {
|
||||
intermediates.push(createFramebufferTexture({ context, width, height }));
|
||||
}
|
||||
|
||||
for (let i = 0; i < passes.length; i++) {
|
||||
const pass = passes[i];
|
||||
const program = compileProgram({
|
||||
context,
|
||||
fragmentShaderSource: pass.fragmentShader,
|
||||
programCache,
|
||||
});
|
||||
const isLastPass = i === passes.length - 1;
|
||||
const targetFramebuffer = isLastPass ? null : intermediates[i].framebuffer;
|
||||
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, targetFramebuffer);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: WebGL API method, not a React hook
|
||||
context.useProgram(program);
|
||||
context.activeTexture(context.TEXTURE0);
|
||||
context.bindTexture(context.TEXTURE_2D, currentTexture);
|
||||
|
||||
const uTextureLocation = context.getUniformLocation(program, "u_texture");
|
||||
if (uTextureLocation) {
|
||||
context.uniform1i(uTextureLocation, 0);
|
||||
}
|
||||
|
||||
setUniforms({
|
||||
context,
|
||||
program,
|
||||
uniforms: { ...pass.uniforms, u_resolution: [width, height] },
|
||||
});
|
||||
drawFullscreenQuad({ context, program, width, height });
|
||||
|
||||
if (!isLastPass) {
|
||||
currentTexture = intermediates[i].texture;
|
||||
}
|
||||
}
|
||||
|
||||
context.deleteTexture(sourceTexture);
|
||||
for (const intermediate of intermediates) {
|
||||
context.deleteTexture(intermediate.texture);
|
||||
context.deleteFramebuffer(intermediate.framebuffer);
|
||||
}
|
||||
context.bindTexture(context.TEXTURE_2D, null);
|
||||
context.bindFramebuffer(context.FRAMEBUFFER, null);
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { V3toV4Migration } from "./v3-to-v4";
|
||||
import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
import { V6toV7Migration } from "./v6-to-v7";
|
||||
import { V7toV8Migration } from "./v7-to-v8";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 7;
|
||||
export const CURRENT_PROJECT_VERSION = 8;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -19,4 +20,5 @@ export const migrations = [
|
||||
new V4toV5Migration(),
|
||||
new V5toV6Migration(),
|
||||
new V6toV7Migration(),
|
||||
new V7toV8Migration(),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV7ToV8({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV8Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v8" };
|
||||
}
|
||||
|
||||
const migratedProject = migrateProjectElements({ project });
|
||||
|
||||
return {
|
||||
project: { ...migratedProject, version: 8 },
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProjectElements({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) return project;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedScenes = scenesValue.map((scene) => {
|
||||
const migrated = migrateSceneElements({ scene });
|
||||
if (migrated !== scene) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return project;
|
||||
return { ...project, scenes: migratedScenes };
|
||||
}
|
||||
|
||||
function migrateSceneElements({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) return scene;
|
||||
|
||||
const tracksValue = scene.tracks;
|
||||
if (!Array.isArray(tracksValue)) return scene;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedTracks = tracksValue.map((track) => {
|
||||
const migrated = migrateTrackElements({ track });
|
||||
if (migrated !== track) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return scene;
|
||||
return { ...scene, tracks: migratedTracks };
|
||||
}
|
||||
|
||||
function migrateTrackElements({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) return track;
|
||||
|
||||
const elementsValue = track.elements;
|
||||
if (!Array.isArray(elementsValue)) return track;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedElements = elementsValue.map((element) => {
|
||||
const migrated = migrateElement({ element });
|
||||
if (migrated !== element) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return track;
|
||||
return { ...track, elements: migratedElements };
|
||||
}
|
||||
|
||||
function migrateElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element)) return element;
|
||||
if (element.type !== "video" && element.type !== "audio") return element;
|
||||
if (typeof element.sourceDuration === "number") return element;
|
||||
|
||||
const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0;
|
||||
const duration = typeof element.duration === "number" ? element.duration : 0;
|
||||
const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0;
|
||||
|
||||
return {
|
||||
...element,
|
||||
sourceDuration: trimStart + duration + trimEnd,
|
||||
};
|
||||
}
|
||||
|
||||
function isV8Project({ project }: { project: ProjectRecord }): boolean {
|
||||
return typeof project.version === "number" && project.version >= 8;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV7ToV8 } from "./transformers/v7-to-v8";
|
||||
|
||||
export class V7toV8Migration extends StorageMigration {
|
||||
from = 7;
|
||||
to = 8;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV7ToV8({ project });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user