summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/renderer/compositor/frame-descriptor.ts36
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts89
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts83
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts114
-rw-r--r--apps/web/src/services/storage/migrations/v26-to-v27.ts16
6 files changed, 256 insertions, 86 deletions
diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
index 8b8201d5..f93eae27 100644
--- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts
+++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts
@@ -401,6 +401,11 @@ function buildMaskArtifacts({
}
const definition = masksRegistry.get(mask.type);
+
+ if (definition.isActive?.(mask.params) === false) {
+ return { mask: null, strokeLayer: null };
+ }
+
const elementMaskCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
@@ -416,7 +421,12 @@ function buildMaskArtifacts({
let strokePath: Path2D | null = null;
let feather = mask.params.feather;
- if (mask.params.feather > 0 && definition.renderer.renderMask) {
+ const canRenderMaskDirectly = Boolean(definition.renderer.renderMask);
+ const shouldRenderMaskDirectly =
+ canRenderMaskDirectly &&
+ (!definition.renderer.buildPath ||
+ (mask.params.feather > 0 && definition.renderer.renderMaskHandlesFeather));
+ if (shouldRenderMaskDirectly && definition.renderer.renderMask) {
definition.renderer.renderMask({
resolvedParams: mask.params,
ctx: elementMaskCtx,
@@ -424,13 +434,18 @@ function buildMaskArtifacts({
height: Math.round(transform.height),
feather: mask.params.feather,
});
- feather = 0;
+ if (definition.renderer.renderMaskHandlesFeather) {
+ feather = 0;
+ }
strokePath = definition.renderer.buildStrokePath?.({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
}) ?? null;
} else {
+ if (!definition.renderer.buildPath) {
+ return { mask: null, strokeLayer: null };
+ }
const path2d = definition.renderer.buildPath({
resolvedParams: mask.params,
width: transform.width,
@@ -472,7 +487,7 @@ function buildMaskArtifacts({
});
let strokeLayer: FrameItemDescriptor | null = null;
- if (mask.params.strokeWidth > 0 && strokePath) {
+ if (mask.params.strokeWidth > 0 && (strokePath || definition.renderer.renderStroke)) {
const strokeCanvas = createOffscreenCanvas({
width: Math.round(transform.width),
height: Math.round(transform.height),
@@ -482,9 +497,18 @@ function buildMaskArtifacts({
| OffscreenCanvasRenderingContext2D
| null;
if (strokeCtx) {
- strokeCtx.strokeStyle = mask.params.strokeColor;
- strokeCtx.lineWidth = mask.params.strokeWidth;
- strokeCtx.stroke(strokePath);
+ if (definition.renderer.renderStroke) {
+ definition.renderer.renderStroke({
+ resolvedParams: mask.params,
+ ctx: strokeCtx,
+ width: transform.width,
+ height: transform.height,
+ });
+ } else if (strokePath) {
+ strokeCtx.strokeStyle = mask.params.strokeColor;
+ strokeCtx.lineWidth = mask.params.strokeWidth;
+ strokeCtx.stroke(strokePath);
+ }
const fullStrokeCanvas = createOffscreenCanvas({
width: renderer.width,
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index c9e13261..86384328 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -3,16 +3,9 @@ import type { TextElement } from "@/lib/timeline";
import type { EffectPass } from "@/lib/effects/types";
import type { Transform } from "@/lib/rendering";
import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/lib/text/background";
-import {
- drawTextDecoration,
- getTextBackgroundRect,
- setCanvasLetterSpacing,
-} from "@/lib/text/layout";
+ drawMeasuredTextLayout,
+} from "@/lib/text/primitives";
import type { MeasuredTextElement } from "@/lib/text/measure-element";
-import { clamp } from "@/utils/math";
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
@@ -46,22 +39,6 @@ export function renderTextToContext({
const x = resolved.transform.position.x + node.params.canvasCenter.x;
const y = resolved.transform.position.y + node.params.canvasCenter.y;
const baseline = node.params.textBaseline ?? "middle";
- const {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
- resolvedBackground,
- } = resolved.measuredText;
- const lineCount = lines.length;
- const resolvedBackgroundWithColor = {
- ...resolvedBackground,
- color: resolved.backgroundColor,
- };
ctx.save();
ctx.translate(x, y);
@@ -70,60 +47,14 @@ export function renderTextToContext({
ctx.rotate((resolved.transform.rotate * Math.PI) / 180);
}
- ctx.font = fontString;
- ctx.textAlign = node.params.textAlign;
- ctx.textBaseline = baseline;
- ctx.fillStyle = resolved.textColor;
- setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
-
- if (
- node.params.background.enabled &&
- node.params.background.color &&
- node.params.background.color !== "transparent" &&
- lineCount > 0
- ) {
- const backgroundRect = getTextBackgroundRect({
- textAlign: node.params.textAlign,
- block,
- background: resolvedBackgroundWithColor,
- fontSizeRatio,
- });
- if (backgroundRect) {
- const p =
- clamp({
- value: resolvedBackgroundWithColor.cornerRadius,
- min: CORNER_RADIUS_MIN,
- max: CORNER_RADIUS_MAX,
- }) / 100;
- const radius =
- (Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
- ctx.fillStyle = resolvedBackgroundWithColor.color;
- ctx.beginPath();
- ctx.roundRect(
- backgroundRect.left,
- backgroundRect.top,
- backgroundRect.width,
- backgroundRect.height,
- radius,
- );
- ctx.fill();
- ctx.fillStyle = resolved.textColor;
- }
- }
-
- for (let index = 0; index < lineCount; index++) {
- const lineY = index * lineHeightPx - block.visualCenterOffset;
- ctx.fillText(lines[index], 0, lineY);
- drawTextDecoration({
- ctx,
- textDecoration: node.params.textDecoration ?? "none",
- lineWidth: lineMetrics[index].width,
- lineY,
- metrics: lineMetrics[index],
- scaledFontSize,
- textAlign: node.params.textAlign,
- });
- }
+ drawMeasuredTextLayout({
+ ctx,
+ layout: resolved.measuredText,
+ textColor: resolved.textColor,
+ background: resolved.measuredText.resolvedBackground,
+ backgroundColor: resolved.backgroundColor,
+ textBaseline: baseline,
+ });
ctx.restore();
}
diff --git a/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
new file mode 100644
index 00000000..730d958d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v26-to-v27.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV26ToV27 } from "../transformers/v26-to-v27";
+
+describe("V26 to V27 Migration", () => {
+ test("converts custom mask paths from JSON strings to typed point arrays", () => {
+ const result = transformProjectV26ToV27({
+ project: {
+ id: "project-v26-custom-mask",
+ version: 26,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "track-1",
+ type: "video",
+ elements: [
+ {
+ id: "element-1",
+ type: "image",
+ masks: [
+ {
+ id: "mask-custom",
+ type: "custom",
+ params: {
+ path: JSON.stringify([
+ {
+ id: "point-1",
+ x: 0,
+ y: 0,
+ inX: 0,
+ inY: 0,
+ outX: 0.1,
+ outY: 0,
+ },
+ ]),
+ closed: false,
+ },
+ },
+ {
+ id: "mask-rectangle",
+ type: "rectangle",
+ params: {
+ width: 0.5,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(27);
+
+ const scenes = result.project.scenes as Array<Record<string, unknown>>;
+ const tracks = scenes[0].tracks as Record<string, unknown>;
+ const mainTrack = tracks.main as Record<string, unknown>;
+ const elements = mainTrack.elements as Array<Record<string, unknown>>;
+ const masks = elements[0].masks as Array<Record<string, unknown>>;
+ const customParams = masks[0].params as Record<string, unknown>;
+ const rectangleParams = masks[1].params as Record<string, unknown>;
+
+ expect(customParams.path).toEqual([
+ {
+ id: "point-1",
+ x: 0,
+ y: 0,
+ inX: 0,
+ inY: 0,
+ outX: 0.1,
+ outY: 0,
+ },
+ ]);
+ expect(rectangleParams.width).toBe(0.5);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index c5a2e237..b290b713 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -25,10 +25,11 @@ import { V22toV23Migration } from "./v22-to-v23";
import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25";
import { V25toV26Migration } from "./v25-to-v26";
+import { V26toV27Migration } from "./v26-to-v27";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 26;
+export const CURRENT_PROJECT_VERSION = 27;
export const migrations = [
new V0toV1Migration(),
@@ -57,4 +58,5 @@ export const migrations = [
new V23toV24Migration(),
new V24toV25Migration(),
new V25toV26Migration(),
+ new V26toV27Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
new file mode 100644
index 00000000..186ae1e9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v26-to-v27.ts
@@ -0,0 +1,114 @@
+import { parseCustomMaskPath } from "@/lib/masks/custom-path";
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV26ToV27({
+ 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 >= 27) {
+ return { project, skipped: true, reason: "already v27" };
+ }
+ if (version !== 26) {
+ return { project, skipped: true, reason: "not v26" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 27,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ if (!Array.isArray(project.scenes)) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: project.scenes.map((scene) => migrateScene({ scene })),
+ };
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene) || !isRecord(scene.tracks)) {
+ return scene;
+ }
+
+ const tracks = scene.tracks;
+ const nextTracks: ProjectRecord = { ...tracks };
+
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+
+ return {
+ ...scene,
+ tracks: nextTracks,
+ };
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !Array.isArray(element.masks)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ masks: element.masks.map((mask) => migrateMask({ mask })),
+ };
+}
+
+function migrateMask({ mask }: { mask: unknown }): unknown {
+ if (!isRecord(mask) || mask.type !== "custom" || !isRecord(mask.params)) {
+ return mask;
+ }
+
+ const path = mask.params.path;
+ if (typeof path !== "string") {
+ return mask;
+ }
+
+ return {
+ ...mask,
+ params: {
+ ...mask.params,
+ path: parseCustomMaskPath({ path }),
+ },
+ };
+}
diff --git a/apps/web/src/services/storage/migrations/v26-to-v27.ts b/apps/web/src/services/storage/migrations/v26-to-v27.ts
new file mode 100644
index 00000000..2df5329d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v26-to-v27.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV26ToV27 } from "./transformers/v26-to-v27";
+
+export class V26toV27Migration extends StorageMigration {
+ from = 26;
+ to = 27;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV26ToV27({ project });
+ }
+}