feat: text / custom masks, fix preview pan, refactor selection

This commit is contained in:
Maze Winther
2026-04-15 15:45:50 +02:00
parent 66e4367bc4
commit c46d28891f
53 changed files with 8082 additions and 3636 deletions
@@ -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,
@@ -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();
}
@@ -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);
});
});
@@ -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(),
];
@@ -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 }),
},
};
}
@@ -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 });
}
}