From e7dcb586c0408255a0bf788e355fdb6d50d9b923 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Mar 2026 13:13:07 +0100 Subject: 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). --- apps/web/src/services/renderer/nodes/text-node.ts | 203 ++++++++++++++------- .../web/src/services/renderer/nodes/visual-node.ts | 8 +- 2 files changed, 142 insertions(+), 69 deletions(-) (limited to 'apps/web/src/services/renderer/nodes') diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts index 0ddb4eae..ffffc895 100644 --- a/apps/web/src/services/renderer/nodes/text-node.ts +++ b/apps/web/src/services/renderer/nodes/text-node.ts @@ -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 { return; } - renderer.context.save(); - const localTime = getElementLocalTime({ timelineTime: time, elementStartTime: this.params.startTime, @@ -106,15 +114,10 @@ export class TextNode extends BaseNode { 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 { 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"; + const blendMode = ( + this.params.blendMode && this.params.blendMode !== "normal" + ? this.params.blendMode + : "source-over" + ) as GlobalCompositeOperation; + 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 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`; + } - const prevAlpha = renderer.context.globalAlpha; - renderer.context.globalCompositeOperation = ( - this.params.blendMode && this.params.blendMode !== "normal" - ? this.params.blendMode - : "source-over" - ) as GlobalCompositeOperation; - renderer.context.globalAlpha = opacity; + 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; + } + } - 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; + 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; + } + + // 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; } - 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, + 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(); } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 15a7b081..6c50aa12 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -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, }), -- cgit v1.3.1