From 0814711d47b4e194b94210b40931cb961bbd88dd Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Thu, 7 May 2026 00:01:53 +0700 Subject: [PATCH] fix(export): parse native gradient backgrounds --- ...rnVideoExporter.nativeStaticLayout.test.ts | 24 +++++ src/lib/exporter/modernVideoExporter.ts | 91 ++++++++++++++++--- 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 9b4fd62f..e4e893cf 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -54,6 +54,10 @@ function createExporter(overrides: Record = {}) { effectiveDurationSec: number, ) => string | null; resolveNativeStaticLayoutBackground: () => Promise; + createNativeStaticLayoutGradient: ( + ctx: CanvasRenderingContext2D, + wallpaper: string, + ) => CanvasGradient | null; }; } @@ -243,6 +247,26 @@ describe("ModernVideoExporter native static-layout eligibility", () => { expect(electronAPI.closeExportStream).toHaveBeenCalledWith("background-stream"); }); + it("parses rgba color stops in native gradient backgrounds", () => { + const exporter = createExporter(); + const gradient = { addColorStop: vi.fn() }; + const ctx = { + createLinearGradient: vi.fn(() => gradient), + createRadialGradient: vi.fn(() => gradient), + } as unknown as CanvasRenderingContext2D; + + const result = exporter.createNativeStaticLayoutGradient( + ctx, + "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(result).toBe(gradient); + expect(gradient.addColorStop).toHaveBeenCalledTimes(3); + expect(gradient.addColorStop).toHaveBeenNthCalledWith(1, 0, "rgba(114,167,232,1)"); + expect(gradient.addColorStop).toHaveBeenNthCalledWith(2, 0.5, "rgba(253,129,82,1)"); + expect(gradient.addColorStop).toHaveBeenNthCalledWith(3, 1, "rgba(249,202,86,1)"); + }); + it("allows non-tail trim timelines with native static-layout", () => { const exporter = createExporter({ trimRegions: [{ id: "trim-1", startMs: 10_000, endMs: 12_000 }], diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index bd2da3b3..38a58964 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -281,6 +281,7 @@ export class ModernVideoExporter { private maxNativeWriteInFlight = 1; private lastNativeExportError: string | null = null; private nativeStaticLayoutSkipReason: string | null = null; + private nativeStaticLayoutBackgroundSkipReason: string | null = null; private nativeH264Encoder: VideoEncoder | null = null; private nativeEncoderError: Error | null = null; private effectiveDurationSec = 0; @@ -318,6 +319,7 @@ export class ModernVideoExporter { this.encoderError = null; this.nativeEncoderError = null; this.nativeStaticLayoutSkipReason = null; + this.nativeStaticLayoutBackgroundSkipReason = null; this.totalExportStartTimeMs = this.getNowMs(); const backendPreference = this.config.backendPreference ?? "auto"; let useNativeEncoder = false; @@ -1267,6 +1269,7 @@ export class ModernVideoExporter { } private async resolveNativeStaticLayoutBackground(): Promise { + this.nativeStaticLayoutBackgroundSkipReason = null; const configuredWallpaper = this.config.wallpaper?.trim() ?? ""; const wallpaper = configuredWallpaper || DEFAULT_WALLPAPER_PATH; if (/^#?[0-9a-f]{6}$/i.test(wallpaper)) { @@ -1277,21 +1280,38 @@ export class ModernVideoExporter { } if (wallpaper.startsWith("data:image/") || wallpaper.startsWith("blob:")) { - return this.materializeNativeStaticLayoutImageSource(wallpaper); + const materialized = await this.materializeNativeStaticLayoutImageSource(wallpaper); + if (materialized) { + return materialized; + } + this.nativeStaticLayoutBackgroundSkipReason = + "unsupported-background-image-materialize-failed"; + return null; } if (wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient")) { - return this.materializeNativeStaticLayoutGradientBackground(wallpaper); + const materialized = + await this.materializeNativeStaticLayoutGradientBackground(wallpaper); + if (materialized) { + return materialized; + } + this.nativeStaticLayoutBackgroundSkipReason = + "unsupported-background-gradient-materialize-failed"; + return null; } - if ( - isVideoWallpaperSource(wallpaper) || - wallpaper.startsWith("data:") || - wallpaper.startsWith("blob:") || - wallpaper.startsWith("http") || - wallpaper.startsWith("linear-gradient") || - wallpaper.startsWith("radial-gradient") - ) { + if (isVideoWallpaperSource(wallpaper)) { + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-video"; + return null; + } + + if (wallpaper.startsWith("data:") || wallpaper.startsWith("blob:")) { + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-data-or-blob"; + return null; + } + + if (wallpaper.startsWith("http")) { + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-remote"; return null; } @@ -1313,7 +1333,12 @@ export class ModernVideoExporter { } const localPath = getLocalFilePath(wallpaper); - return localPath ? { backgroundColor: "#101010", backgroundImagePath: localPath } : null; + if (localPath) { + return { backgroundColor: "#101010", backgroundImagePath: localPath }; + } + + this.nativeStaticLayoutBackgroundSkipReason = "unsupported-background-local-path"; + return null; } private async materializeNativeStaticLayoutImageSource( @@ -1414,9 +1439,12 @@ export class ModernVideoExporter { } const [, type, params] = gradientMatch; - const parts = params.split(",").map((part) => part.trim()); + const parts = this.splitCssGradientArguments(params).map((part) => part.trim()); const colorStops = parts - .map((part) => part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/)?.[1]) + .map( + (part) => + part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+)/i)?.[1], + ) .filter((color): color is string => Boolean(color)); if (colorStops.length === 0) { return null; @@ -1446,6 +1474,40 @@ export class ModernVideoExporter { return gradient; } + private splitCssGradientArguments(params: string): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + + for (const char of params) { + if (char === "(") { + depth++; + current += char; + continue; + } + if (char === ")") { + depth = Math.max(0, depth - 1); + current += char; + continue; + } + if (char === "," && depth === 0) { + if (current.trim()) { + parts.push(current.trim()); + } + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; + } + private async writeNativeStaticLayoutTempAsset( bytes: Uint8Array, extension: string, @@ -1901,7 +1963,8 @@ export class ModernVideoExporter { } const background = await this.resolveNativeStaticLayoutBackground(); if (!background) { - this.nativeStaticLayoutSkipReason = "unsupported-background"; + this.nativeStaticLayoutSkipReason = + this.nativeStaticLayoutBackgroundSkipReason ?? "unsupported-background"; return null; }