mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: text / custom masks, fix preview pan, refactor selection
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user