From 081a2c105713915761b5d433a5523fa9c5bd9518 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 10 Jun 2026 21:48:04 +0100 Subject: [PATCH] fix(export): render gradient backgrounds correctly in exported video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradient wallpapers were parsed with `params.split(",")`, which splits the commas inside `rgba()`/`hsla()` color functions. The color regex then matched the bare token `rgba`, `addColorStop` threw, and the outer try/catch fell back to a solid black background — so every rgba()/radial preset exported as black. For hex presets the leading direction token (`120deg`, `to right`, …) consumed color-stop index 0, pushing the first color to offset 0.5 and ignoring the explicit `0%/100%` stops, so even those gradients rendered wrong. Extract a parenthesis-aware, position-honoring parser into a pure, unit-tested helper (`parseGradientBackground`) and use it from both the modern and legacy frame renderers. Canvas geometry is unchanged to keep the fix minimal. --- src/lib/exporter/frameRenderer.ts | 35 +--- src/lib/exporter/gradientBackground.test.ts | 145 +++++++++++++++++ src/lib/exporter/gradientBackground.ts | 168 ++++++++++++++++++++ src/lib/exporter/modernFrameRenderer.ts | 25 +-- 4 files changed, 327 insertions(+), 46 deletions(-) create mode 100644 src/lib/exporter/gradientBackground.test.ts create mode 100644 src/lib/exporter/gradientBackground.ts diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 049c25bb..7397b9aa 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -76,6 +76,7 @@ import { isVideoWallpaperSource } from "@/lib/wallpapers"; import { renderAnnotations } from "./annotationRenderer"; import { renderCaptions } from "./captionRenderer"; import { ForwardFrameSource } from "./forwardFrameSource"; +import { parseGradientBackground } from "./gradientBackground"; import { resolveMediaElementSource } from "./localMediaSource"; import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur"; @@ -665,43 +666,21 @@ export class FrameRenderer { wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient") ) { - const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); - if (gradientMatch) { - const [, type, params] = gradientMatch; - const parts = params.split(",").map((s) => s.trim()); - + const parsedGradient = parseGradientBackground(wallpaper); + if (parsedGradient && parsedGradient.stops.length > 0) { let gradient: CanvasGradient; - if (type === "linear") { + if (parsedGradient.type === "linear") { gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height); - parts.forEach((part, index) => { - if (part.startsWith("to ") || part.includes("deg")) return; - - const colorMatch = part.match( - /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/, - ); - if (colorMatch) { - const color = colorMatch[1]; - const position = index / (parts.length - 1); - gradient.addColorStop(position, color); - } - }); } else { const cx = this.config.width / 2; const cy = this.config.height / 2; const radius = Math.max(this.config.width, this.config.height) / 2; gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius); + } - parts.forEach((part, index) => { - const colorMatch = part.match( - /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/, - ); - if (colorMatch) { - const color = colorMatch[1]; - const position = index / (parts.length - 1); - gradient.addColorStop(position, color); - } - }); + for (const stop of parsedGradient.stops) { + gradient.addColorStop(stop.position, stop.color); } bgCtx.fillStyle = gradient; diff --git a/src/lib/exporter/gradientBackground.test.ts b/src/lib/exporter/gradientBackground.test.ts new file mode 100644 index 00000000..a6ecea3d --- /dev/null +++ b/src/lib/exporter/gradientBackground.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { parseGradientBackground } from "./gradientBackground"; + +describe("parseGradientBackground", () => { + it("returns null for non-gradient input", () => { + expect(parseGradientBackground("#ff0000")).toBeNull(); + expect(parseGradientBackground("/wallpapers/tahoe.jpg")).toBeNull(); + expect(parseGradientBackground("")).toBeNull(); + }); + + it("keeps rgba() colors intact instead of splitting on their inner commas", () => { + // Regression: the previous `params.split(",")` shredded `rgba(...)` into + // fragments, producing the literal color "rgba" and a black fallback. + const parsed = parseGradientBackground( + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(249,202,86,1) 86.3% )", + ); + expect(parsed).not.toBeNull(); + expect(parsed?.type).toBe("linear"); + expect(parsed?.stops).toEqual([ + { color: "rgba(114,167,232,1)", position: 0.094 }, + { color: "rgba(253,129,82,1)", position: 0.439 }, + { color: "rgba(249,202,86,1)", position: 0.863 }, + ]); + }); + + it("preserves spaces inside rgba() color functions", () => { + const parsed = parseGradientBackground( + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(231, 148, 6, 1) 88.6% )", + ); + expect(parsed?.stops.map((stop) => stop.color)).toEqual([ + "rgba(0,56,68,1)", + "rgba(231, 148, 6, 1)", + ]); + }); + + it("places the first color at offset 0 when a direction token is present", () => { + // Regression: the leading `120deg` used to consume index 0, pushing the + // first color to offset 0.5 and ignoring the explicit 0%/100% stops. + const parsed = parseGradientBackground( + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + ); + expect(parsed?.stops).toEqual([ + { color: "#d4fc79", position: 0 }, + { color: "#96e6a1", position: 1 }, + ]); + }); + + it("strips `to ` direction keywords", () => { + const parsed = parseGradientBackground( + "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", + ); + expect(parsed?.stops).toEqual([ + { color: "#4facfe", position: 0 }, + { color: "#00f2fe", position: 1 }, + ]); + }); + + it("strips the radial shape/position descriptor and keeps rgba stops", () => { + const parsed = parseGradientBackground( + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + ); + expect(parsed?.type).toBe("radial"); + expect(parsed?.stops).toEqual([ + { color: "rgba(80,12,139,0.87)", position: 0 }, + { color: "rgba(161,10,144,0.72)", position: 0.836 }, + ]); + }); + + it("distributes stops evenly when no explicit positions are given", () => { + const parsed = parseGradientBackground( + "linear-gradient(135deg, #FBC8B4, #2447B1)", + ); + expect(parsed?.stops).toEqual([ + { color: "#FBC8B4", position: 0 }, + { color: "#2447B1", position: 1 }, + ]); + + const triple = parseGradientBackground( + "linear-gradient(90deg, #ff0000, #00ff00, #0000ff)", + ); + expect(triple?.stops.map((stop) => stop.position)).toEqual([0, 0.5, 1]); + }); + + it("interpolates interior stops that omit an explicit position", () => { + const parsed = parseGradientBackground( + "linear-gradient(90deg, #000 0%, #888, #fff 100%)", + ); + expect(parsed?.stops.map((stop) => stop.position)).toEqual([0, 0.5, 1]); + }); + + it("keeps offsets non-decreasing and clamped to [0, 1]", () => { + const parsed = parseGradientBackground( + "linear-gradient(90deg, #111 50%, #222 20%, #333 150%)", + ); + const positions = parsed?.stops.map((stop) => stop.position) ?? []; + expect(positions).toEqual([0.5, 0.5, 1]); + for (const position of positions) { + expect(position).toBeGreaterThanOrEqual(0); + expect(position).toBeLessThanOrEqual(1); + } + }); + + it("supports named colors", () => { + const parsed = parseGradientBackground("linear-gradient(to top, red, blue)"); + expect(parsed?.stops).toEqual([ + { color: "red", position: 0 }, + { color: "blue", position: 1 }, + ]); + }); + + it("parses every built-in gradient preset into valid, ordered stops", () => { + const presets = [ + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", + "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", + "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", + "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", + "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", + "linear-gradient(135deg, #FBC8B4, #2447B1)", + "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", + "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", + "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", + ]; + + for (const preset of presets) { + const parsed = parseGradientBackground(preset); + expect(parsed, preset).not.toBeNull(); + expect(parsed?.stops.length ?? 0, preset).toBeGreaterThanOrEqual(2); + + let previous = -1; + for (const stop of parsed?.stops ?? []) { + // No fragment should ever look like a torn-apart rgba() call. + expect(stop.color, preset).not.toBe("rgba"); + expect(stop.color, preset).not.toBe("rgb"); + expect(stop.color, preset).toMatch(/^(#|rgba?\(|hsla?\(|[a-z]+$)/); + expect(stop.position, preset).toBeGreaterThanOrEqual(0); + expect(stop.position, preset).toBeLessThanOrEqual(1); + expect(stop.position, preset).toBeGreaterThanOrEqual(previous); + previous = stop.position; + } + } + }); +}); diff --git a/src/lib/exporter/gradientBackground.ts b/src/lib/exporter/gradientBackground.ts new file mode 100644 index 00000000..ea0dbe54 --- /dev/null +++ b/src/lib/exporter/gradientBackground.ts @@ -0,0 +1,168 @@ +/** + * Parsing for CSS gradient strings used as editor backgrounds so the export + * pipeline can reproduce them on a canvas. + * + * The renderers used to parse gradients with a naive `params.split(",")`, which + * is wrong for two reasons: + * 1. It splits the commas *inside* `rgba()`/`hsla()` color functions, turning + * `rgba(114,167,232,1)` into the fragments `rgba(114`, `167`, `232`, `1)`. + * `addColorStop` then receives the literal `"rgba"` and throws, so the + * whole background fell back to solid black in the exported video. + * 2. It used the split-token index for the colour-stop offset, so a leading + * direction token (`120deg`, `to right`, …) pushed the first colour to + * offset 0.5 instead of 0 and the explicit `0%/100%` stops were ignored. + * + * This module tokenizes the gradient parenthesis-aware, strips the optional + * leading direction/shape descriptor, and honours explicit percentage stops + * (distributing evenly when they are omitted) so exports match the editor + * preview. It is pure (no canvas/DOM) and therefore unit-testable. + */ + +export interface GradientColorStop { + color: string; + /** Normalized stop offset in the [0, 1] range. */ + position: number; +} + +export interface ParsedGradientBackground { + type: "linear" | "radial"; + stops: GradientColorStop[]; +} + +const GRADIENT_PATTERN = /^(linear|radial)-gradient\((.+)\)$/s; + +// Leading token describing the gradient line for a linear gradient +// (`to bottom`, `120deg`, `0.25turn`, …) rather than a colour stop. +const LINEAR_DIRECTION_PATTERN = /^(to\s|[+-]?\d*\.?\d+(deg|grad|rad|turn)\b)/i; + +// Leading token describing the shape/size/position of a radial gradient +// (`circle`, `ellipse farthest-corner`, `at 10% 20%`, a length/percentage, …). +const RADIAL_CONFIG_PATTERN = + /^(circle\b|ellipse\b|closest-|farthest-|at\s|[+-]?\d*\.?\d+(px|%|em|rem|vw|vh|vmin|vmax)\b)/i; + +// Colour at the start of a colour-stop token. rgba()/hsla() keep their inner +// commas intact because tokenization is parenthesis-aware. +const COLOR_PATTERN = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/; + +// Trailing explicit stop position, e.g. the `43.9%` in `rgba(…) 43.9%`. +const PERCENT_POSITION_PATTERN = /(-?\d*\.?\d+)%\s*$/; + +function splitTopLevelCommas(input: string): string[] { + const tokens: string[] = []; + let depth = 0; + let current = ""; + for (const char of input) { + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth = Math.max(0, depth - 1); + } + if (char === "," && depth === 0) { + tokens.push(current); + current = ""; + } else { + current += char; + } + } + tokens.push(current); + return tokens.map((token) => token.trim()).filter((token) => token.length > 0); +} + +function clamp01(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + if (value < 0) { + return 0; + } + if (value > 1) { + return 1; + } + return value; +} + +/** + * Parses a CSS `linear-gradient(...)` / `radial-gradient(...)` string into the + * gradient type and its normalized colour stops. Returns `null` when the input + * is not a recognizable gradient or contains no usable colour stops. + */ +export function parseGradientBackground(value: string): ParsedGradientBackground | null { + const match = value.trim().match(GRADIENT_PATTERN); + if (!match) { + return null; + } + + const type = match[1] as "linear" | "radial"; + const tokens = splitTopLevelCommas(match[2]); + if (tokens.length === 0) { + return null; + } + + // Drop a single leading direction/shape descriptor so it is not mistaken + // for a colour stop (e.g. `to right` would otherwise match as the named + // colour `to`). + const directionPattern = type === "linear" ? LINEAR_DIRECTION_PATTERN : RADIAL_CONFIG_PATTERN; + const colorTokens = directionPattern.test(tokens[0]) ? tokens.slice(1) : tokens; + + const rawStops = colorTokens + .map((token) => { + const colorMatch = token.match(COLOR_PATTERN); + if (!colorMatch) { + return null; + } + const positionMatch = token.match(PERCENT_POSITION_PATTERN); + return { + color: colorMatch[1], + position: positionMatch ? Number(positionMatch[1]) / 100 : Number.NaN, + }; + }) + .filter((stop): stop is { color: string; position: number } => stop !== null); + + const count = rawStops.length; + if (count === 0) { + return null; + } + + // Resolve offsets: explicit percentages win, the first/last default to + // 0/1, and interior gaps are interpolated between their neighbours (CSS + // colour-stop semantics). + const positions = rawStops.map((stop) => stop.position); + if (Number.isNaN(positions[0])) { + positions[0] = 0; + } + if (Number.isNaN(positions[count - 1])) { + positions[count - 1] = count === 1 ? 0 : 1; + } + let index = 0; + while (index < count) { + if (!Number.isNaN(positions[index])) { + index += 1; + continue; + } + let end = index; + while (end < count && Number.isNaN(positions[end])) { + end += 1; + } + const previous = positions[index - 1]; + const next = positions[end]; + const span = end - (index - 1); + for (let gap = index; gap < end; gap += 1) { + positions[gap] = previous + ((next - previous) * (gap - (index - 1))) / span; + } + index = end; + } + + // Clamp into [0, 1] and keep offsets non-decreasing so `addColorStop` never + // rejects an out-of-range or out-of-order value. + let runningMax = 0; + const stops = rawStops.map((stop, stopIndex) => { + let position = clamp01(positions[stopIndex]); + if (position < runningMax) { + position = runningMax; + } + runningMax = position; + return { color: stop.color, position }; + }); + + return { type, stops }; +} diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 6ab918d8..390090e3 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -87,6 +87,7 @@ import { renderAnnotationToCanvas, } from "./annotationRenderer"; import { ForwardFrameSource } from "./forwardFrameSource"; +import { parseGradientBackground } from "./gradientBackground"; import { resolveMediaElementSource } from "./localMediaSource"; import { getShadowFilterPadding, @@ -1254,15 +1255,13 @@ export class FrameRenderer { wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient") ) { - const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); - if (!gradientMatch) { + const parsedGradient = parseGradientBackground(wallpaper); + if (!parsedGradient || parsedGradient.stops.length === 0) { bgCtx.fillStyle = "#000000"; bgCtx.fillRect(0, 0, this.config.width, this.config.height); } else { - const [, type, params] = gradientMatch; - const parts = params.split(",").map((value) => value.trim()); const gradient = - type === "linear" + parsedGradient.type === "linear" ? bgCtx.createLinearGradient(0, 0, 0, this.config.height) : bgCtx.createRadialGradient( this.config.width / 2, @@ -1273,19 +1272,9 @@ export class FrameRenderer { Math.max(this.config.width, this.config.height) / 2, ); - parts.forEach((part, index) => { - if (type === "linear" && (part.startsWith("to ") || part.includes("deg"))) { - return; - } - - const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/); - if (!colorMatch) { - return; - } - - const position = index / Math.max(parts.length - 1, 1); - gradient.addColorStop(position, colorMatch[1]); - }); + for (const stop of parsedGradient.stops) { + gradient.addColorStop(stop.position, stop.color); + } bgCtx.fillStyle = gradient; bgCtx.fillRect(0, 0, this.config.width, this.config.height);