diff options
Diffstat (limited to 'apps/web/src')
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/settings.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/canvas-gradients.ts | 169 | ||||
| -rw-r--r-- | apps/web/src/lib/timeline-renderer.ts | 16 |
3 files changed, 186 insertions, 3 deletions
diff --git a/apps/web/src/components/editor/media-panel/views/settings.tsx b/apps/web/src/components/editor/media-panel/views/settings.tsx index 6cdd1b65..d2d21f36 100644 --- a/apps/web/src/components/editor/media-panel/views/settings.tsx +++ b/apps/web/src/components/editor/media-panel/views/settings.tsx @@ -178,9 +178,9 @@ const BackgroundPreviews = memo( }) => { return useMemo( () => - backgrounds.map((bg) => ( + backgrounds.map((bg, index) => ( <div - key={bg} + key={`${index}-${bg}`} className={cn( "w-full aspect-square rounded-sm cursor-pointer border border-foreground/15 hover:border-primary", isColorBackground && diff --git a/apps/web/src/lib/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts new file mode 100644 index 00000000..a3b93c02 --- /dev/null +++ b/apps/web/src/lib/canvas-gradients.ts @@ -0,0 +1,169 @@ +function splitBackgroundLayers(input: string): string[] {
+ const layers: string[] = [];
+ let depth = 0;
+ let start = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const ch = input[i] as string;
+ if (ch === "(") depth += 1;
+ else if (ch === ")") depth -= 1;
+ else if (ch === "," && depth === 0) {
+ layers.push(input.slice(start, i).trim());
+ start = i + 1;
+ }
+ }
+ layers.push(input.slice(start).trim());
+ return layers;
+}
+
+function extractColorFromStop(stop: string): string {
+ const s = stop.trim();
+ const funcs = ["rgba(", "rgb(", "hsla(", "hsl("] as const;
+ for (const fn of funcs) {
+ if (s.startsWith(fn)) {
+ let depth = 0;
+ for (let i = 0; i < s.length; i += 1) {
+ const ch = s[i] as string;
+ if (ch === "(") depth += 1;
+ else if (ch === ")") {
+ depth -= 1;
+ if (depth === 0) {
+ return s.slice(0, i + 1);
+ }
+ }
+ }
+ return s;
+ }
+ }
+ const firstToken = s.split(" ")[0] as string;
+ return firstToken;
+}
+
+function drawLinearGradient(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ layer: string
+): void {
+ const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
+ const parts = splitBackgroundLayers(inside);
+ const dir = (parts.shift() || "").trim();
+ let x0 = 0,
+ y0 = 0,
+ x1 = width,
+ y1 = 0;
+ if (dir.endsWith("deg")) {
+ const deg = parseFloat(dir);
+ const rad = ((90 - deg) * Math.PI) / 180;
+ const cx = width / 2;
+ const cy = height / 2;
+ const r = Math.hypot(width, height) / 2;
+ x0 = cx - Math.cos(rad) * r;
+ y0 = cy - Math.sin(rad) * r;
+ x1 = cx + Math.cos(rad) * r;
+ y1 = cy + Math.sin(rad) * r;
+ } else if (dir.startsWith("to ")) {
+ const d = dir.slice(3).trim();
+ if (d === "right") {
+ x0 = 0;
+ y0 = 0;
+ x1 = width;
+ y1 = 0;
+ } else if (d === "left") {
+ x0 = width;
+ y0 = 0;
+ x1 = 0;
+ y1 = 0;
+ } else if (d === "bottom") {
+ x0 = 0;
+ y0 = 0;
+ x1 = 0;
+ y1 = height;
+ } else if (d === "top") {
+ x0 = 0;
+ y0 = height;
+ x1 = 0;
+ y1 = 0;
+ }
+ } else {
+ parts.unshift(dir);
+ }
+ const grad = ctx.createLinearGradient(x0, y0, x1, y1);
+ const colorStops = parts;
+ const n = Math.max(1, colorStops.length - 1);
+ for (let i = 0; i < colorStops.length; i += 1) {
+ const color = extractColorFromStop(colorStops[i] as string);
+ grad.addColorStop(Math.min(1, Math.max(0, i / n)), color);
+ }
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, width, height);
+}
+
+function drawRadialGradient(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ layer: string
+): void {
+ const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
+ const parts = splitBackgroundLayers(inside);
+ const first = (parts.shift() || "").trim();
+ let cx = width / 2;
+ let cy = height / 2;
+ if (first.startsWith("circle at")) {
+ const pos = first.replace("circle at", "").trim();
+ const [px, py] = pos.split(" ");
+ const parsePos = (p?: string, full?: number): number => {
+ if (!p) return (full || 0) / 2;
+ if (p.endsWith("%")) return (parseFloat(p) / 100) * (full || 0);
+ if (p === "left") return 0;
+ if (p === "right") return full || 0;
+ if (p === "top") return 0;
+ if (p === "bottom") return full || 0;
+ if (p === "center") return (full || 0) / 2;
+ return (full || 0) / 2;
+ };
+ if (px && py) {
+ cx = parsePos(px, width);
+ cy = parsePos(py, height);
+ } else if (px) {
+ cx = parsePos(px, width);
+ cy = parsePos(px, height);
+ }
+ } else {
+ parts.unshift(first);
+ }
+ const r = Math.hypot(width, height);
+ const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
+ const colorStops = parts;
+ const n = Math.max(1, colorStops.length - 1);
+ for (let i = 0; i < colorStops.length; i += 1) {
+ const color = extractColorFromStop(colorStops[i] as string);
+ grad.addColorStop(Math.min(1, Math.max(0, i / n)), color);
+ }
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, width, height);
+}
+
+export function drawCssBackground(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ css: string
+): void {
+ const layers = splitBackgroundLayers(css).filter(Boolean);
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
+ const layer = layers[i] as string;
+ if (layer.startsWith("linear-gradient(")) {
+ drawLinearGradient(ctx, width, height, layer);
+ } else if (layer.startsWith("radial-gradient(")) {
+ drawRadialGradient(ctx, width, height, layer);
+ } else if (
+ layer.startsWith("#") ||
+ layer.startsWith("rgb") ||
+ layer.startsWith("hsl")
+ ) {
+ ctx.fillStyle = layer;
+ ctx.fillRect(0, 0, width, height);
+ }
+ }
+}
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index ddc361b5..4d00129c 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -2,6 +2,7 @@ import type { TimelineTrack } from "@/types/timeline"; import type { MediaFile } from "@/types/media"; import type { BlurIntensity } from "@/types/project"; import { videoCache } from "./video-cache"; +import { drawCssBackground } from "./canvas-gradients"; export interface RenderContext { ctx: CanvasRenderingContext2D; @@ -48,11 +49,24 @@ export async function renderTimelineFrame({ }: RenderContext): Promise<void> { // Background ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { + if ( + backgroundColor && + backgroundColor !== "transparent" && + !backgroundColor.includes("gradient") + ) { ctx.fillStyle = backgroundColor; ctx.fillRect(0, 0, canvasWidth, canvasHeight); } + // If backgroundColor is a CSS gradient string, draw it using JS (no foreignObject) to avoid CORS tainting + if (backgroundColor && backgroundColor.includes("gradient")) { + try { + drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor); + } catch { + // best-effort; ignore failures + } + } + const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1; const scaleY = projectCanvasSize ? canvasHeight / projectCanvasSize.height |
