fix(producer): copy extracted frames on Windows to avoid symlink EPERM

The local render path (renderOrchestrator → runExtractVideosStage) materialized
each video's extracted frames into the compiled dir via symlinkSync, with no
materializeSymlinks flag. On Windows without Developer Mode/Administrator,
symlinkSync throws EPERM, so local video renders failed at the video_extract
stage (users worked around it with materializeSymlinks patches / snapshot-frame
hacks). The distributed plan() path already copies (materializeSymlinks: true).

Pass materializeSymlinks: shouldCopyExtractedFrames(process.platform) at the
local caller — copy on win32 (symlinks unavailable), symlink elsewhere (cheap).
New pure shouldCopyExtractedFrames() helper + unit tests.
This commit is contained in:
Miguel Ángel
2026-07-07 17:10:14 -04:00
committed by GitHub
parent de27b46680
commit 1aa39d4653
3 changed files with 31 additions and 2 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { appendAutoDetectedVideoAudio } from "./extractVideosStage.js"; import { appendAutoDetectedVideoAudio, shouldCopyExtractedFrames } from "./extractVideosStage.js";
import type { ExtractedFrames, VideoElement } from "@hyperframes/engine"; import type { ExtractedFrames, VideoElement } from "@hyperframes/engine";
function makeVideo(overrides: Partial<VideoElement> = {}): VideoElement { function makeVideo(overrides: Partial<VideoElement> = {}): VideoElement {
@@ -81,3 +81,14 @@ describe("appendAutoDetectedVideoAudio", () => {
expect(composition.audios).toHaveLength(1); 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);
});
});
@@ -96,6 +96,18 @@ export interface ExtractVideosStageResult {
videoExtractMs: number; 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( export async function runExtractVideosStage(
input: ExtractVideosStageInput, input: ExtractVideosStageInput,
): Promise<ExtractVideosStageResult> { ): Promise<ExtractVideosStageResult> {
@@ -110,7 +110,10 @@ import {
import { type HdrPerfCollector, type HdrPerfSummary } from "./render/hdrPerf.js"; import { type HdrPerfCollector, type HdrPerfSummary } from "./render/hdrPerf.js";
import { runCompileStage } from "./render/stages/compileStage.js"; import { runCompileStage } from "./render/stages/compileStage.js";
import { runProbeStage } from "./render/stages/probeStage.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 { runAudioStage } from "./render/stages/audioStage.js";
import { runCaptureStage } from "./render/stages/captureStage.js"; import { runCaptureStage } from "./render/stages/captureStage.js";
import { runCaptureStreamingStage } from "./render/stages/captureStreamingStage.js"; import { runCaptureStreamingStage } from "./render/stages/captureStreamingStage.js";
@@ -1350,6 +1353,9 @@ export async function executeRenderJob(
composition, composition,
abortSignal, abortSignal,
assertNotAborted, 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 { const {