diff --git a/packages/producer/src/services/render/stages/extractVideosStage.test.ts b/packages/producer/src/services/render/stages/extractVideosStage.test.ts index 72028e41b..640d90f52 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.test.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { appendAutoDetectedVideoAudio } from "./extractVideosStage.js"; +import { appendAutoDetectedVideoAudio, shouldCopyExtractedFrames } from "./extractVideosStage.js"; import type { ExtractedFrames, VideoElement } from "@hyperframes/engine"; function makeVideo(overrides: Partial = {}): VideoElement { @@ -81,3 +81,14 @@ describe("appendAutoDetectedVideoAudio", () => { expect(composition.audios).toHaveLength(1); }); }); + +describe("shouldCopyExtractedFrames", () => { + it("copies frames on Windows (symlinkSync throws EPERM without Developer Mode)", () => { + expect(shouldCopyExtractedFrames("win32")).toBe(true); + }); + + it("symlinks on macOS and Linux (cheaper, symlinks allowed)", () => { + expect(shouldCopyExtractedFrames("darwin")).toBe(false); + expect(shouldCopyExtractedFrames("linux")).toBe(false); + }); +}); diff --git a/packages/producer/src/services/render/stages/extractVideosStage.ts b/packages/producer/src/services/render/stages/extractVideosStage.ts index 012a1b904..7f133b789 100644 --- a/packages/producer/src/services/render/stages/extractVideosStage.ts +++ b/packages/producer/src/services/render/stages/extractVideosStage.ts @@ -96,6 +96,18 @@ export interface ExtractVideosStageResult { videoExtractMs: number; } +/** + * Whether the extract stage should COPY frames into the compiled dir instead of + * symlinking them. Windows without Developer Mode / Administrator can't create + * symlinks (`symlinkSync` throws EPERM), which failed local video renders; copy + * there instead. Elsewhere symlinking is cheaper, so keep it. (The distributed + * `plan()` path already forces copying for a different reason — a self-contained + * planDir — by passing `materializeSymlinks: true` explicitly.) + */ +export function shouldCopyExtractedFrames(platform: NodeJS.Platform): boolean { + return platform === "win32"; +} + export async function runExtractVideosStage( input: ExtractVideosStageInput, ): Promise { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 824f4a625..0854bae35 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -110,7 +110,10 @@ import { import { type HdrPerfCollector, type HdrPerfSummary } from "./render/hdrPerf.js"; import { runCompileStage } from "./render/stages/compileStage.js"; import { runProbeStage } from "./render/stages/probeStage.js"; -import { runExtractVideosStage } from "./render/stages/extractVideosStage.js"; +import { + runExtractVideosStage, + shouldCopyExtractedFrames, +} from "./render/stages/extractVideosStage.js"; import { runAudioStage } from "./render/stages/audioStage.js"; import { runCaptureStage } from "./render/stages/captureStage.js"; import { runCaptureStreamingStage } from "./render/stages/captureStreamingStage.js"; @@ -1350,6 +1353,9 @@ export async function executeRenderJob( composition, abortSignal, assertNotAborted, + // Copy (don't symlink) extracted frames on Windows — symlinkSync throws + // EPERM there without Developer Mode/admin, which failed local renders. + materializeSymlinks: shouldCopyExtractedFrames(process.platform), }), ); const {