fix: restore source copy export fast path

This commit is contained in:
EtienneLescot
2026-05-06 14:28:39 +02:00
parent 722f630117
commit 34e22d001c
2 changed files with 251 additions and 1 deletions
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { isSourceCopyFastPathEligible, type VideoExporterConfig } from "./videoExporter";
function createConfig(overrides: Partial<VideoExporterConfig> = {}): 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);
});
});
+134 -1
View File
@@ -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<ReturnType<StreamingVideoDecoder["loadMetadata"]>> | 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);
}