From 34e22d001c3e80841d11bfa92cfcc0fa8c63040b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 6 May 2026 14:28:39 +0200 Subject: [PATCH] fix: restore source copy export fast path --- src/lib/exporter/videoExporter.test.ts | 117 +++++++++++++++++++++ src/lib/exporter/videoExporter.ts | 135 ++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/lib/exporter/videoExporter.test.ts diff --git a/src/lib/exporter/videoExporter.test.ts b/src/lib/exporter/videoExporter.test.ts new file mode 100644 index 0000000..9eddb87 --- /dev/null +++ b/src/lib/exporter/videoExporter.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { isSourceCopyFastPathEligible, type VideoExporterConfig } from "./videoExporter"; + +function createConfig(overrides: Partial = {}): VideoExporterConfig { + return { + videoUrl: "recording.mp4", + width: 1920, + height: 1080, + frameRate: 60, + bitrate: 30_000_000, + wallpaper: "#000000", + zoomRegions: [], + trimRegions: [], + speedRegions: [], + showShadow: false, + shadowIntensity: 0, + showBlur: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + ...overrides, + }; +} + +describe("isSourceCopyFastPathEligible", () => { + it("allows a no-op MP4 export at source dimensions", () => { + expect( + isSourceCopyFastPathEligible(createConfig(), { + width: 1920, + height: 1080, + }), + ).toBe(true); + }); + + it("rejects timeline edits and frame-level effects", () => { + const videoInfo = { width: 1920, height: 1080 }; + + expect( + isSourceCopyFastPathEligible( + createConfig({ trimRegions: [{ id: "trim", startMs: 100, endMs: 200 }] }), + videoInfo, + ), + ).toBe(false); + expect( + isSourceCopyFastPathEligible( + createConfig({ + speedRegions: [{ id: "speed", startMs: 100, endMs: 200, speed: 1.5 }], + }), + videoInfo, + ), + ).toBe(false); + expect( + isSourceCopyFastPathEligible( + createConfig({ + zoomRegions: [ + { + id: "zoom", + startMs: 100, + endMs: 200, + depth: 2, + focus: { cx: 0.5, cy: 0.5 }, + }, + ], + }), + videoInfo, + ), + ).toBe(false); + expect(isSourceCopyFastPathEligible(createConfig({ showBlur: true }), videoInfo)).toBe(false); + }); + + it("rejects resizing and overlays", () => { + const videoInfo = { width: 1920, height: 1080 }; + + expect(isSourceCopyFastPathEligible(createConfig({ width: 1280 }), videoInfo)).toBe(false); + expect( + isSourceCopyFastPathEligible( + createConfig({ + cursorScale: 2, + cursorRecordingData: { + version: 2, + provider: "native", + assets: [ + { + id: "cursor", + platform: "win32", + imageDataUrl: "data:image/png;base64,AA==", + width: 32, + height: 32, + hotspotX: 0, + hotspotY: 0, + }, + ], + samples: [{ timeMs: 0, cx: 0.5, cy: 0.5, visible: true, assetId: "cursor" }], + }, + }), + videoInfo, + ), + ).toBe(false); + expect( + isSourceCopyFastPathEligible( + createConfig({ + cursorHighlight: { + enabled: true, + style: "ring", + sizePx: 24, + color: "#ffffff", + opacity: 1, + onlyOnClicks: false, + clickEmphasisDurationMs: 350, + offsetXNorm: 0, + offsetYNorm: 0, + }, + cursorTelemetry: [{ timeMs: 0, cx: 0.5, cy: 0.5 }], + }), + videoInfo, + ), + ).toBe(false); + }); +}); diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 8da602b..d84e95f 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -20,7 +20,7 @@ import type { ExportConfig, ExportProgress, ExportResult } from "./types"; const ENCODER_STALL_TIMEOUT_MS = 15_000; const ENCODER_FLUSH_TIMEOUT_MS = 20_000; -interface VideoExporterConfig extends ExportConfig { +export interface VideoExporterConfig extends ExportConfig { videoUrl: string; webcamVideoUrl?: string; wallpaper: string; @@ -53,6 +53,84 @@ interface VideoExporterConfig extends ExportConfig { onProgress?: (progress: ExportProgress) => void; } +const SOURCE_COPY_EPSILON = 0.0001; + +function hasActiveTimeRegions(regions?: Array<{ startMs: number; endMs: number }>) { + return Boolean(regions?.some((region) => region.endMs - region.startMs > SOURCE_COPY_EPSILON)); +} + +function hasActiveSpeedRegions(regions?: SpeedRegion[]) { + return Boolean( + regions?.some( + (region) => + region.endMs - region.startMs > SOURCE_COPY_EPSILON && + Math.abs(region.speed - 1) > SOURCE_COPY_EPSILON, + ), + ); +} + +function hasNativeCursorOverlay(config: VideoExporterConfig) { + return Boolean( + (config.cursorScale ?? 0) > 0 && + config.cursorRecordingData?.provider === "native" && + config.cursorRecordingData.samples.length > 0 && + config.cursorRecordingData.assets.length > 0, + ); +} + +function hasCursorHighlightOverlay(config: VideoExporterConfig) { + return Boolean( + config.cursorHighlight?.enabled && config.cursorTelemetry && config.cursorTelemetry.length > 0, + ); +} + +function isDefaultCrop(cropRegion: CropRegion) { + return ( + Math.abs(cropRegion.x) <= SOURCE_COPY_EPSILON && + Math.abs(cropRegion.y) <= SOURCE_COPY_EPSILON && + Math.abs(cropRegion.width - 1) <= SOURCE_COPY_EPSILON && + Math.abs(cropRegion.height - 1) <= SOURCE_COPY_EPSILON + ); +} + +export function isSourceCopyFastPathEligible( + config: VideoExporterConfig, + videoInfo: { width: number; height: number }, +) { + return ( + config.width === videoInfo.width && + config.height === videoInfo.height && + !config.webcamVideoUrl && + !hasActiveTimeRegions(config.trimRegions) && + !hasActiveSpeedRegions(config.speedRegions) && + !hasActiveTimeRegions(config.zoomRegions) && + !hasActiveTimeRegions(config.annotationRegions) && + !hasNativeCursorOverlay(config) && + !hasCursorHighlightOverlay(config) && + isDefaultCrop(config.cropRegion) && + (config.padding ?? 0) <= SOURCE_COPY_EPSILON && + (config.videoPadding ?? 0) <= SOURCE_COPY_EPSILON && + (config.borderRadius ?? 0) <= SOURCE_COPY_EPSILON && + !config.showShadow && + config.shadowIntensity <= SOURCE_COPY_EPSILON && + !config.showBlur && + (config.motionBlurAmount ?? 0) <= SOURCE_COPY_EPSILON + ); +} + +function isMp4Source(videoUrl: string, blob: Blob) { + if (blob.type.toLowerCase().includes("mp4")) { + return true; + } + + try { + const path = new URL(videoUrl, window.location.href).pathname; + return path.toLowerCase().endsWith(".mp4"); + } catch { + return videoUrl.toLowerCase().split(/[?#]/, 1)[0].endsWith(".mp4"); + } +} + export class VideoExporter { private config: VideoExporterConfig; private streamingDecoder: StreamingVideoDecoder | null = null; @@ -133,6 +211,11 @@ export class VideoExporter { const streamingDecoder = new StreamingVideoDecoder(); this.streamingDecoder = streamingDecoder; const videoInfo = await streamingDecoder.loadMetadata(this.config.videoUrl); + const sourceCopyResult = await this.trySourceCopyFastPath(videoInfo); + if (sourceCopyResult) { + return sourceCopyResult; + } + let webcamInfo: Awaited> | null = null; if (this.config.webcamVideoUrl) { webcamDecoder = new StreamingVideoDecoder(); @@ -566,6 +649,56 @@ export class VideoExporter { return ["prefer-hardware", "prefer-software"]; } + private async trySourceCopyFastPath(videoInfo: { width: number; height: number }) { + if (!isSourceCopyFastPathEligible(this.config, videoInfo)) { + return null; + } + + const sourceBlob = await this.loadSourceBlob(); + if (!sourceBlob || !isMp4Source(this.config.videoUrl, sourceBlob)) { + return null; + } + + if (this.cancelled) { + return { success: false, error: "Export cancelled" }; + } + + this.reportProgress({ + currentFrame: 1, + totalFrames: 1, + percentage: 100, + estimatedTimeRemaining: 0, + phase: "finalizing", + }); + + return { + success: true, + blob: sourceBlob.type ? sourceBlob : new Blob([sourceBlob], { type: "video/mp4" }), + } satisfies ExportResult; + } + + private async loadSourceBlob() { + const videoUrl = this.config.videoUrl; + const isRemoteUrl = /^(https?:|blob:|data:)/i.test(videoUrl); + + if (!isRemoteUrl && window.electronAPI?.readBinaryFile) { + const result = await window.electronAPI.readBinaryFile(videoUrl); + if (!result.success || !result.data) { + return null; + } + + const type = videoUrl.toLowerCase().split(/[?#]/, 1)[0].endsWith(".mp4") ? "video/mp4" : ""; + return new Blob([result.data], type ? { type } : undefined); + } + + const response = await fetch(videoUrl); + if (!response.ok) { + return null; + } + + return response.blob(); + } + private reportProgress(progress: ExportProgress): void { this.config.onProgress?.(progress); }