fix: restore source copy export fast path
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user