Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 7 additions & 28 deletions src/lib/exporter/frameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
145 changes: 145 additions & 0 deletions src/lib/exporter/gradientBackground.test.ts
Original file line number Diff line number Diff line change
@@ -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 <side>` 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;
}
}
});
});
168 changes: 168 additions & 0 deletions src/lib/exporter/gradientBackground.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading