mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: Clip effects, asset sorting, and timeline improvements
Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders).
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import type { CanvasRenderer } from "../canvas-renderer";
|
||||
import { createOffscreenCanvas } from "../canvas-utils";
|
||||
import { BaseNode } from "./base-node";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import {
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
CORNER_RADIUS_MAX,
|
||||
CORNER_RADIUS_MIN,
|
||||
} from "@/constants/text-constants";
|
||||
import {
|
||||
getMetricAscent,
|
||||
@@ -17,6 +20,10 @@ import {
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
function scaleFontSize({
|
||||
fontSize,
|
||||
@@ -32,6 +39,9 @@ function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
|
||||
return `"${fontFamily.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
const TEXT_DECORATION_THICKNESS_RATIO = 0.07;
|
||||
const STRIKETHROUGH_VERTICAL_RATIO = 0.35;
|
||||
|
||||
function drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration,
|
||||
@@ -51,7 +61,7 @@ function drawTextDecoration({
|
||||
}): void {
|
||||
if (textDecoration === "none" || !textDecoration) return;
|
||||
|
||||
const thickness = Math.max(1, scaledFontSize * 0.07);
|
||||
const thickness = Math.max(1, scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO);
|
||||
const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize });
|
||||
const descent = getMetricDescent({ metrics, fallbackFontSize: scaledFontSize });
|
||||
|
||||
@@ -65,7 +75,7 @@ function drawTextDecoration({
|
||||
}
|
||||
|
||||
if (textDecoration === "line-through") {
|
||||
const strikeY = lineY - (ascent - descent) * 0.35;
|
||||
const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO;
|
||||
ctx.fillRect(xStart, strikeY, lineWidth, thickness);
|
||||
}
|
||||
}
|
||||
@@ -89,8 +99,6 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.context.save();
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: time,
|
||||
elementStartTime: this.params.startTime,
|
||||
@@ -106,15 +114,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const x = transform.position.x + this.params.canvasCenter.x;
|
||||
const y = transform.position.y + this.params.canvasCenter.y;
|
||||
|
||||
renderer.context.translate(x, y);
|
||||
renderer.context.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
renderer.context.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
|
||||
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = this.params.fontStyle === "italic" ? "italic" : "normal";
|
||||
const scaledFontSize = scaleFontSize({
|
||||
@@ -122,83 +125,147 @@ export class TextNode extends BaseNode<TextNodeParams> {
|
||||
canvasHeight: this.params.canvasHeight,
|
||||
});
|
||||
const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
|
||||
renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
renderer.context.textAlign = this.params.textAlign;
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
|
||||
const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
const letterSpacing = this.params.letterSpacing ?? 0;
|
||||
const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(
|
||||
renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
const lines = this.params.content.split("\n");
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
const fontSizeRatio = this.params.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
|
||||
const baseline = this.params.textBaseline ?? "middle";
|
||||
|
||||
renderer.context.textBaseline = baseline;
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
const lineCount = lines.length;
|
||||
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
fallbackFontSize: scaledFontSize,
|
||||
});
|
||||
|
||||
const prevAlpha = renderer.context.globalAlpha;
|
||||
renderer.context.globalCompositeOperation = (
|
||||
const blendMode = (
|
||||
this.params.blendMode && this.params.blendMode !== "normal"
|
||||
? this.params.blendMode
|
||||
: "source-over"
|
||||
) as GlobalCompositeOperation;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
|
||||
if (
|
||||
this.params.background.color &&
|
||||
this.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const { color, cornerRadius = 0 } = this.params.background;
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: this.params.textAlign,
|
||||
block,
|
||||
background: this.params.background,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
renderer.context.fillStyle = color;
|
||||
renderer.context.beginPath();
|
||||
renderer.context.roundRect(
|
||||
backgroundRect.left,
|
||||
backgroundRect.top,
|
||||
backgroundRect.width,
|
||||
backgroundRect.height,
|
||||
cornerRadius,
|
||||
);
|
||||
renderer.context.fill();
|
||||
renderer.context.fillStyle = this.params.color;
|
||||
renderer.context.save();
|
||||
renderer.context.font = fontString;
|
||||
renderer.context.textBaseline = baseline;
|
||||
if ("letterSpacing" in renderer.context) {
|
||||
(renderer.context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
const lineMetrics = lines.map((line) => renderer.context.measureText(line));
|
||||
renderer.context.restore();
|
||||
|
||||
const lineCount = lines.length;
|
||||
const block = measureTextBlock({ lineMetrics, lineHeightPx, fallbackFontSize: scaledFontSize });
|
||||
|
||||
const drawContent = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
|
||||
ctx.font = fontString;
|
||||
ctx.textAlign = this.params.textAlign;
|
||||
ctx.textBaseline = baseline;
|
||||
ctx.fillStyle = this.params.color;
|
||||
if ("letterSpacing" in ctx) {
|
||||
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
|
||||
if (
|
||||
this.params.background.enabled &&
|
||||
this.params.background.color &&
|
||||
this.params.background.color !== "transparent" &&
|
||||
lineCount > 0
|
||||
) {
|
||||
const { color, cornerRadius = 0 } = this.params.background;
|
||||
const backgroundRect = getTextBackgroundRect({
|
||||
textAlign: this.params.textAlign,
|
||||
block,
|
||||
background: this.params.background,
|
||||
fontSizeRatio,
|
||||
});
|
||||
if (backgroundRect) {
|
||||
const p = clamp({ value: cornerRadius, min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX }) / 100;
|
||||
const radius = Math.min(backgroundRect.width, backgroundRect.height) / 2 * p;
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(backgroundRect.left, backgroundRect.top, backgroundRect.width, backgroundRect.height, radius);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = this.params.color;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const lineY = i * lineHeightPx - block.visualCenterOffset;
|
||||
ctx.fillText(lines[i], 0, lineY);
|
||||
drawTextDecoration({
|
||||
ctx,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applyTransform = (ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) => {
|
||||
ctx.translate(x, y);
|
||||
ctx.scale(transform.scale, transform.scale);
|
||||
if (transform.rotate) {
|
||||
ctx.rotate((transform.rotate * Math.PI) / 180);
|
||||
}
|
||||
};
|
||||
|
||||
const enabledEffects = this.params.effects?.filter((effect) => effect.enabled) ?? [];
|
||||
|
||||
if (enabledEffects.length === 0) {
|
||||
renderer.context.save();
|
||||
applyTransform(renderer.context);
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
drawContent(renderer.context);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const y = i * lineHeightPx - block.visualCenterOffset;
|
||||
renderer.context.fillText(lines[i], 0, y);
|
||||
drawTextDecoration({
|
||||
ctx: renderer.context,
|
||||
textDecoration: this.params.textDecoration ?? "none",
|
||||
lineWidth: lineMetrics[i].width,
|
||||
lineY: y,
|
||||
metrics: lineMetrics[i],
|
||||
scaledFontSize,
|
||||
textAlign: this.params.textAlign,
|
||||
// Effects path: render text to a same-size offscreen canvas so the blur
|
||||
// can spread into the surrounding transparent area without hard clipping.
|
||||
const offscreen = createOffscreenCanvas({ width: renderer.width, height: renderer.height });
|
||||
const offscreenCtx = offscreen.getContext("2d") as OffscreenCanvasRenderingContext2D | null;
|
||||
|
||||
if (!offscreenCtx) {
|
||||
renderer.context.save();
|
||||
applyTransform(renderer.context);
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
drawContent(renderer.context);
|
||||
renderer.context.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
offscreenCtx.save();
|
||||
applyTransform(offscreenCtx);
|
||||
drawContent(offscreenCtx);
|
||||
offscreenCtx.restore();
|
||||
|
||||
let currentSource: CanvasImageSource = offscreen;
|
||||
for (const effect of enabledEffects) {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations: this.params.animations,
|
||||
localTime,
|
||||
});
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: resolvedParams,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
}),
|
||||
}));
|
||||
currentSource = webglEffectRenderer.applyEffect({
|
||||
source: currentSource,
|
||||
width: renderer.width,
|
||||
height: renderer.height,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
|
||||
renderer.context.globalAlpha = prevAlpha;
|
||||
renderer.context.save();
|
||||
renderer.context.globalCompositeOperation = blendMode;
|
||||
renderer.context.globalAlpha = opacity;
|
||||
renderer.context.drawImage(currentSource, 0, 0);
|
||||
renderer.context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveOpacityAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel";
|
||||
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
||||
import { getEffect } from "@/lib/effects";
|
||||
import { webglEffectRenderer } from "../webgl-effect-renderer";
|
||||
@@ -127,11 +128,16 @@ export abstract class VisualNode<
|
||||
let currentResult: CanvasImageSource = elementCanvas;
|
||||
|
||||
for (const effect of enabledEffects) {
|
||||
const resolvedParams = resolveEffectParamsAtTime({
|
||||
effect,
|
||||
animations: this.params.animations,
|
||||
localTime: animationLocalTime,
|
||||
});
|
||||
const definition = getEffect({ effectType: effect.type });
|
||||
const passes = definition.renderer.passes.map((pass) => ({
|
||||
fragmentShader: pass.fragmentShader,
|
||||
uniforms: pass.uniforms({
|
||||
effectParams: effect.params,
|
||||
effectParams: resolvedParams,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
|
||||
|
||||
const v8ProjectWithText = {
|
||||
id: "project-v8-text",
|
||||
version: 8,
|
||||
metadata: {
|
||||
id: "project-v8-text",
|
||||
name: "V8 Project with Text",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 30,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
currentSceneId: "scene-main",
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "text",
|
||||
content: "With color",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
background: {
|
||||
color: "#ff0000",
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "el-2",
|
||||
type: "text",
|
||||
content: "Transparent",
|
||||
startTime: 5,
|
||||
duration: 5,
|
||||
background: {
|
||||
color: "transparent",
|
||||
paddingX: 30,
|
||||
paddingY: 42,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
describe("V8 to V9 Migration", () => {
|
||||
test("adds background.enabled from color (transparent => false, otherwise true)", () => {
|
||||
const result = transformProjectV8ToV9({ project: v8ProjectWithText });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(9);
|
||||
|
||||
const track = (
|
||||
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
|
||||
)[0].tracks[0];
|
||||
const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
|
||||
|
||||
expect(elements[0].background.enabled).toBe(true);
|
||||
expect(elements[0].background.color).toBe("#ff0000");
|
||||
|
||||
expect(elements[1].background.enabled).toBe(false);
|
||||
expect(elements[1].background.color).toBe("transparent");
|
||||
});
|
||||
|
||||
test("preserves existing background.enabled if already present", () => {
|
||||
const projectWithEnabled = {
|
||||
...v8ProjectWithText,
|
||||
scenes: [
|
||||
{
|
||||
...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: "text",
|
||||
content: "Already has enabled",
|
||||
startTime: 0,
|
||||
duration: 5,
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#00ff00",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
const result = transformProjectV8ToV9({ project: projectWithEnabled });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
const elements = (
|
||||
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
|
||||
)[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
|
||||
expect(elements[0].background.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("skips non-text elements and tracks", () => {
|
||||
const projectWithVideoOnly = {
|
||||
...v8ProjectWithText,
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-video",
|
||||
type: "video",
|
||||
name: "Video Track",
|
||||
isMain: true,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
|
||||
|
||||
const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(9);
|
||||
});
|
||||
|
||||
test("skips projects that are already v9", () => {
|
||||
const result = transformProjectV8ToV9({
|
||||
project: { ...v8ProjectWithText, version: 9 },
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v9");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV8ToV9({
|
||||
project: {
|
||||
version: 8,
|
||||
scenes: [],
|
||||
} as Parameters<typeof transformProjectV8ToV9>[0]["project"],
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,11 @@ import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
import { V6toV7Migration } from "./v6-to-v7";
|
||||
import { V7toV8Migration } from "./v7-to-v8";
|
||||
import { V8toV9Migration } from "./v8-to-v9";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 8;
|
||||
export const CURRENT_PROJECT_VERSION = 9;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
@@ -21,4 +22,5 @@ export const migrations = [
|
||||
new V5toV6Migration(),
|
||||
new V6toV7Migration(),
|
||||
new V7toV8Migration(),
|
||||
new V8toV9Migration(),
|
||||
];
|
||||
|
||||
@@ -388,7 +388,7 @@ async function transformMediaTrack({
|
||||
);
|
||||
|
||||
const validElements = transformedElements.filter(
|
||||
(el): el is VideoElement | ImageElement => el !== null,
|
||||
(element): element is VideoElement | ImageElement => element !== null,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -450,17 +450,18 @@ function transformTextTrack({
|
||||
value: textElement.color,
|
||||
fallback: "#000000",
|
||||
}),
|
||||
background: {
|
||||
color: getStringValue({
|
||||
value: textElement.backgroundColor,
|
||||
fallback: "transparent",
|
||||
}),
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
background: {
|
||||
enabled: false,
|
||||
color: getStringValue({
|
||||
value: textElement.backgroundColor,
|
||||
fallback: "transparent",
|
||||
}),
|
||||
cornerRadius: 0,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
},
|
||||
textAlign: (getStringValue({
|
||||
value: textElement.textAlign,
|
||||
fallback: "left",
|
||||
@@ -486,7 +487,7 @@ function transformTextTrack({
|
||||
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
|
||||
};
|
||||
})
|
||||
.filter((el): el is TextElement => el !== null);
|
||||
.filter((element): element is TextElement => element !== null);
|
||||
|
||||
return {
|
||||
id: getStringValue({ value: track.id, fallback: "" }),
|
||||
@@ -529,7 +530,7 @@ function transformAudioTrack({
|
||||
trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }),
|
||||
};
|
||||
})
|
||||
.filter((el): el is AudioElement => el !== null);
|
||||
.filter((element): element is AudioElement => element !== null);
|
||||
|
||||
return {
|
||||
id: getStringValue({ value: track.id, fallback: "" }),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV8ToV9({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV9Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v9" };
|
||||
}
|
||||
|
||||
const migratedProject = migrateProjectTextElements({ project });
|
||||
|
||||
return {
|
||||
project: { ...migratedProject, version: 9 },
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProjectTextElements({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) return project;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedScenes = scenesValue.map((scene) => {
|
||||
const migrated = migrateSceneTextElements({ scene });
|
||||
if (migrated !== scene) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return project;
|
||||
return { ...project, scenes: migratedScenes };
|
||||
}
|
||||
|
||||
function migrateSceneTextElements({ 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 = migrateTrackTextElements({ track });
|
||||
if (migrated !== track) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return scene;
|
||||
return { ...scene, tracks: migratedTracks };
|
||||
}
|
||||
|
||||
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) return track;
|
||||
if (track.type !== "text") return track;
|
||||
|
||||
const elementsValue = track.elements;
|
||||
if (!Array.isArray(elementsValue)) return track;
|
||||
|
||||
let hasChanges = false;
|
||||
const migratedElements = elementsValue.map((element) => {
|
||||
const migrated = migrateTextElement({ element });
|
||||
if (migrated !== element) hasChanges = true;
|
||||
return migrated;
|
||||
});
|
||||
|
||||
if (!hasChanges) return track;
|
||||
return { ...track, elements: migratedElements };
|
||||
}
|
||||
|
||||
function migrateTextElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element)) return element;
|
||||
if (element.type !== "text") return element;
|
||||
|
||||
const bg = element.background;
|
||||
if (!isRecord(bg)) return element;
|
||||
if (typeof bg.enabled === "boolean") return element;
|
||||
|
||||
const color = typeof bg.color === "string" ? bg.color : "transparent";
|
||||
const enabled = color !== "transparent";
|
||||
|
||||
return {
|
||||
...element,
|
||||
background: { ...bg, enabled },
|
||||
};
|
||||
}
|
||||
|
||||
function isV9Project({ project }: { project: ProjectRecord }): boolean {
|
||||
return typeof project.version === "number" && project.version >= 9;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
|
||||
|
||||
export class V8toV9Migration extends StorageMigration {
|
||||
from = 8;
|
||||
to = 9;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV8ToV9({ project });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user