From 322cf8b1ca5462d762a16b17aaf21109f9f2af29 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Wed, 6 May 2026 23:30:11 +0700 Subject: [PATCH] fix(export): materialize native custom backgrounds --- ...rnVideoExporter.nativeStaticLayout.test.ts | 34 +++ src/lib/exporter/modernVideoExporter.ts | 213 ++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 1bbd93e2..9b4fd62f 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -209,6 +209,40 @@ describe("ModernVideoExporter native static-layout eligibility", () => { }); }); + it("materializes uploaded data-url image backgrounds for native static-layout", async () => { + const jpegBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xd9]); + const dataUrl = `data:image/jpeg;base64,${Buffer.from(jpegBytes).toString("base64")}`; + const exporter = createExporter({ wallpaper: dataUrl }); + const electronAPI = window.electronAPI as typeof window.electronAPI & { + openExportStream: ReturnType; + writeExportStreamChunk: ReturnType; + closeExportStream: ReturnType; + }; + electronAPI.openExportStream = vi.fn(async () => ({ + success: true, + streamId: "background-stream", + tempPath: "C:/Temp/unused.jpg", + })); + electronAPI.writeExportStreamChunk = vi.fn(async () => ({ success: true })); + electronAPI.closeExportStream = vi.fn(async () => ({ + success: true, + tempPath: "C:/Temp/recordly-background.jpg", + bytesWritten: jpegBytes.byteLength, + })); + + await expect(exporter.resolveNativeStaticLayoutBackground()).resolves.toEqual({ + backgroundColor: "#101010", + backgroundImagePath: "C:/Temp/recordly-background.jpg", + temporaryPath: "C:/Temp/recordly-background.jpg", + }); + expect(electronAPI.openExportStream).toHaveBeenCalledWith({ extension: "jpg" }); + expect(electronAPI.writeExportStreamChunk).toHaveBeenCalledTimes(1); + const [, position, chunk] = electronAPI.writeExportStreamChunk.mock.calls[0]; + expect(position).toBe(0); + expect(Array.from(chunk as Uint8Array)).toEqual(Array.from(jpegBytes)); + expect(electronAPI.closeExportStream).toHaveBeenCalledWith("background-stream"); + }); + 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 52620011..bd2da3b3 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -216,10 +216,12 @@ type NativeStaticLayoutBackground = | { backgroundColor: string; backgroundImagePath?: null; + temporaryPath?: string; } | { backgroundColor: string; backgroundImagePath: string; + temporaryPath?: string; }; type NativeStaticLayoutWebcamOverlay = { @@ -1274,6 +1276,14 @@ export class ModernVideoExporter { }; } + if (wallpaper.startsWith("data:image/") || wallpaper.startsWith("blob:")) { + return this.materializeNativeStaticLayoutImageSource(wallpaper); + } + + if (wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient")) { + return this.materializeNativeStaticLayoutGradientBackground(wallpaper); + } + if ( isVideoWallpaperSource(wallpaper) || wallpaper.startsWith("data:") || @@ -1306,6 +1316,205 @@ export class ModernVideoExporter { return localPath ? { backgroundColor: "#101010", backgroundImagePath: localPath } : null; } + private async materializeNativeStaticLayoutImageSource( + imageSource: string, + ): Promise { + if (typeof fetch !== "function") { + return null; + } + + try { + const response = await fetch(imageSource); + if (!response.ok) { + return null; + } + + const blob = await response.blob(); + const mimeType = (blob.type || this.getDataUrlMimeType(imageSource)).toLowerCase(); + if (!mimeType.startsWith("image/")) { + return null; + } + + const extension = this.getNativeStaticLayoutImageExtension(mimeType); + if (!extension) { + return null; + } + + const tempPath = await this.writeNativeStaticLayoutTempAsset( + new Uint8Array(await blob.arrayBuffer()), + extension, + ); + return tempPath + ? { + backgroundColor: "#101010", + backgroundImagePath: tempPath, + temporaryPath: tempPath, + } + : null; + } catch (error) { + console.warn("[VideoExporter] Unable to materialize native background image", error); + return null; + } + } + + private async materializeNativeStaticLayoutGradientBackground( + wallpaper: string, + ): Promise { + if (typeof document === "undefined") { + return null; + } + + try { + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(this.config.width)); + canvas.height = Math.max(1, Math.round(this.config.height)); + const ctx = canvas.getContext("2d"); + if (!ctx) { + return null; + } + + const gradient = this.createNativeStaticLayoutGradient(ctx, wallpaper); + if (!gradient) { + return null; + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) { + return null; + } + + const tempPath = await this.writeNativeStaticLayoutTempAsset( + new Uint8Array(await blob.arrayBuffer()), + "png", + ); + return tempPath + ? { + backgroundColor: "#101010", + backgroundImagePath: tempPath, + temporaryPath: tempPath, + } + : null; + } catch (error) { + console.warn("[VideoExporter] Unable to materialize native gradient background", error); + return null; + } + } + + private createNativeStaticLayoutGradient( + ctx: CanvasRenderingContext2D, + wallpaper: string, + ): CanvasGradient | null { + const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/); + if (!gradientMatch) { + return null; + } + + const [, type, params] = gradientMatch; + const parts = params.split(",").map((part) => part.trim()); + const colorStops = parts + .map((part) => part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/)?.[1]) + .filter((color): color is string => Boolean(color)); + if (colorStops.length === 0) { + return null; + } + + const gradient = + type === "linear" + ? ctx.createLinearGradient(0, 0, 0, this.config.height) + : ctx.createRadialGradient( + this.config.width / 2, + this.config.height / 2, + 0, + this.config.width / 2, + this.config.height / 2, + Math.max(this.config.width, this.config.height) / 2, + ); + + if (colorStops.length === 1) { + gradient.addColorStop(0, colorStops[0]); + gradient.addColorStop(1, colorStops[0]); + return gradient; + } + + colorStops.forEach((color, index) => { + gradient.addColorStop(index / (colorStops.length - 1), color); + }); + return gradient; + } + + private async writeNativeStaticLayoutTempAsset( + bytes: Uint8Array, + extension: string, + ): Promise { + if (typeof window === "undefined") { + return null; + } + + const api = window.electronAPI; + if ( + !api?.openExportStream || + !api.writeExportStreamChunk || + !api.closeExportStream || + bytes.byteLength === 0 + ) { + return null; + } + + let streamId: string | undefined; + try { + const openResult = await api.openExportStream({ extension }); + if (!openResult.success || !openResult.streamId) { + return null; + } + + streamId = openResult.streamId; + const writeResult = await api.writeExportStreamChunk(streamId, 0, bytes); + if (!writeResult.success) { + throw new Error(writeResult.error || "Failed to write native background temp file"); + } + + const closeResult = await api.closeExportStream(streamId); + streamId = undefined; + return closeResult.success && closeResult.tempPath ? closeResult.tempPath : null; + } catch (error) { + console.warn("[VideoExporter] Unable to write native background temp file", error); + if (streamId) { + await api.closeExportStream(streamId, { abort: true }).catch(() => undefined); + } + return null; + } + } + + private async cleanupNativeStaticLayoutBackground( + background: NativeStaticLayoutBackground | null | undefined, + ) { + const temporaryPath = background?.temporaryPath; + if (!temporaryPath || typeof window === "undefined") { + return; + } + + try { + await window.electronAPI?.discardExportedTemp?.(temporaryPath); + } catch { + // Best-effort cleanup for temporary materialized background assets. + } + } + + private getDataUrlMimeType(dataUrl: string) { + return dataUrl.match(/^data:([^;,]+)/)?.[1] ?? ""; + } + + private getNativeStaticLayoutImageExtension(mimeType: string): string | null { + if (mimeType === "image/jpeg" || mimeType === "image/jpg") return "jpg"; + if (mimeType === "image/png") return "png"; + if (mimeType === "image/bmp") return "bmp"; + return null; + } + private async resolveNativeBundledAssetPath(assetPath: string): Promise { const normalizedAssetPath = assetPath.replace(/^\/+/, ""); const [assetDirectory, fileName] = normalizedAssetPath.split("/"); @@ -1717,6 +1926,7 @@ export class ModernVideoExporter { effectiveDuration <= 0 ) { this.nativeStaticLayoutSkipReason = "invalid-layout-or-duration"; + await this.cleanupNativeStaticLayoutBackground(background); return null; } @@ -1755,6 +1965,7 @@ export class ModernVideoExporter { (this.config.speedRegions ?? []).length > 0 ? "invalid-native-speed-timeline" : "invalid-native-trim-timeline"; + await this.cleanupNativeStaticLayoutBackground(background); return null; } const cursorAtlas = @@ -1768,6 +1979,7 @@ export class ModernVideoExporter { : null; if (cursorTelemetry && cursorTelemetry.length > 0 && !cursorAtlas) { this.nativeStaticLayoutSkipReason = "cursor-atlas-unavailable"; + await this.cleanupNativeStaticLayoutBackground(background); return null; } const startedAt = this.getNowMs(); @@ -1985,6 +2197,7 @@ export class ModernVideoExporter { return null; } finally { unsubscribeNativeProgress?.(); + await this.cleanupNativeStaticLayoutBackground(background); if (this.nativeStaticLayoutSessionId === sessionId) { this.nativeStaticLayoutSessionId = null; }