fix(producer): copy extracted frames on Windows to avoid symlink EPERM [P0] - #2025
Conversation
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.
vanceingalls
left a comment
There was a problem hiding this comment.
R1 — #2025 at 14ece7e532ba2ac4c5376467342a7b28e0dd03a5
🟢 LGTM. Small, well-scoped, correct. Two flags: one required-check blocker, one cosmetic nit.
Correctness
materializeSymlinks?: booleanis a pre-existing field onExtractVideosStageInput(verified at head,extractVideosStage.ts:~74— the doc comment already explains why the distributedplan()path forcestrue). The localrenderOrchestratorcaller was the only site that omitted it, silently defaulting tofalse→symlinkSync→ EPERM on Windows without Dev Mode. This PR wires the flag through cleanly.- Helper is pure and platform-string-driven — trivial to unit test, trivially correct. Naming is a bit meta (
shouldCopyExtractedFramesreturns the value we pass asmaterializeSymlinks), but the two names semantically agree so it's fine. - Complementary to open PR #1959 (EPERM →
cpSyncfallback inside the extract stage itself): this PR sidesteps the symlink preemptively on win32; #1959 catches at throw-time. They compose well — this one covers the intended path, #1959 catches any escape.
Coverage
Helper unit-tested for the three relevant platforms. The orchestrator wiring is validated by the Windows render verification CI job (SUCCESS on the older SHA at 18:32; re-triggered at 19:03 for the current head, Render on windows-latest still IN_PROGRESS at review time — worth waiting for green before merge).
Blocker
Semantic PR titleCI check FAILED on the current head (SUCCESS on the earlier SHA at 18:24, FAILURE at 19:02). The[P0]prefix breaks conventional-commits parsing. Either drop the prefix from the title (keep the body as-is), or move the[P0]marker to the end / a label. If this is a required check, it'll block merge.
Optional nit
- Windows users who have Developer Mode or admin enabled don't need the copy —
symlinkSyncwould work for them, and copying is measurably slower for large extractions. This PR always copies on win32 regardless. Right call for now (correctness > perf, and detecting Dev Mode is a syscall dance you'd rather not add here), but worth remembering if the copy step ever shows up in perf profiles from win32 users.
R1 by Via
…copy path Addresses review nits on the frame-staging fallback: - Widen the symlink no-privilege catch from EPERM/EACCES to also include UNKNOWN (some Windows builds surface a symlink privilege denial as UNKNOWN). - Wrap the EEXIST stale-entry recovery around BOTH staging branches, not just the symlink one: after #2025 Windows uses the eager cpSync path, which can collide with a dangling symlink left by a prior Linux run — now it clears the stale entry and re-stages either way. - Emit a one-time INFO log when symlinking degrades to copying, so a heavier Windows render is self-explanatory.
|
@via thanks for the review. The semantic-title check was failing on the |
…s EPERM * fix(producer): fall back to copying extracted frames when symlink hits EPERM materializeExtractedFramesForCompiledDir stages each video's extracted frames into the compiled dir via a single symlink (the in-process renderer's default; distributed plan() already copies via materializeSymlinks). Windows without Developer Mode (or Administrator) cannot create symlinks and rejects with EPERM, so high/standard-quality renders failed there — while draft quality worked because it avoids the symlinked-cache path entirely. Fix: a new stageExtractedFrameDir helper catches EPERM/EACCES from symlinkSync and falls back to the same recursive cpSync the materializeSymlinks path already uses. The extra disk is far better than a hard render failure on a default Windows configuration. Non-permission errors (ENOSPC, etc.) still propagate so real failures aren't masked as silent copies. Extracting the helper also keeps the main function under the complexity gate. Test: two new cases via the injected fileSystem — symlinkSync throwing EPERM triggers exactly one recursive cpSync (frames still remapped under compiledDir), and a non-permission error (ENOSPC) rethrows without falling back to copy. Full renderOrchestrator suite (81) passes. * fix(producer): recover from a stale dangling frame-symlink (EEXIST) Follow-up to this PR's EPERM copy fallback, from a further Windows report: the symlink fails with EEXIST after the extraction cache is GC'd. A prior render's symlink at the compiled linkPath dangles once its target is removed; the caller's existsSync() guard follows the dead link and reads it as absent, so staging runs again, but the link file still exists and symlinkSync collides with EEXIST -> the render hard-fails. Catch EEXIST in stageExtractedFrameDir, clear the stale entry (rmSync), and re-stage (link, or copy on EPERM/EACCES). Factored the link-or-copy into a helper reused by both the first attempt and the retry. rmSync is an optional injected fs method (default fs supplies it; only the EEXIST path calls it). New unit test covers the dangling-symlink recovery. * fix(producer): widen symlink fallback to UNKNOWN and cover EEXIST on copy path Addresses review nits on the frame-staging fallback: - Widen the symlink no-privilege catch from EPERM/EACCES to also include UNKNOWN (some Windows builds surface a symlink privilege denial as UNKNOWN). - Wrap the EEXIST stale-entry recovery around BOTH staging branches, not just the symlink one: after #2025 Windows uses the eager cpSync path, which can collide with a dangling symlink left by a prior Linux run — now it clears the stale entry and re-stages either way. - Emit a one-time INFO log when symlinking degrades to copying, so a heavier Windows render is self-explanatory.
…s EPERM * fix(producer): fall back to copying extracted frames when symlink hits EPERM materializeExtractedFramesForCompiledDir stages each video's extracted frames into the compiled dir via a single symlink (the in-process renderer's default; distributed plan() already copies via materializeSymlinks). Windows without Developer Mode (or Administrator) cannot create symlinks and rejects with EPERM, so high/standard-quality renders failed there — while draft quality worked because it avoids the symlinked-cache path entirely. Fix: a new stageExtractedFrameDir helper catches EPERM/EACCES from symlinkSync and falls back to the same recursive cpSync the materializeSymlinks path already uses. The extra disk is far better than a hard render failure on a default Windows configuration. Non-permission errors (ENOSPC, etc.) still propagate so real failures aren't masked as silent copies. Extracting the helper also keeps the main function under the complexity gate. Test: two new cases via the injected fileSystem — symlinkSync throwing EPERM triggers exactly one recursive cpSync (frames still remapped under compiledDir), and a non-permission error (ENOSPC) rethrows without falling back to copy. Full renderOrchestrator suite (81) passes. * fix(producer): recover from a stale dangling frame-symlink (EEXIST) Follow-up to this PR's EPERM copy fallback, from a further Windows report: the symlink fails with EEXIST after the extraction cache is GC'd. A prior render's symlink at the compiled linkPath dangles once its target is removed; the caller's existsSync() guard follows the dead link and reads it as absent, so staging runs again, but the link file still exists and symlinkSync collides with EEXIST -> the render hard-fails. Catch EEXIST in stageExtractedFrameDir, clear the stale entry (rmSync), and re-stage (link, or copy on EPERM/EACCES). Factored the link-or-copy into a helper reused by both the first attempt and the retry. rmSync is an optional injected fs method (default fs supplies it; only the EEXIST path calls it). New unit test covers the dangling-symlink recovery. * fix(producer): widen symlink fallback to UNKNOWN and cover EEXIST on copy path Addresses review nits on the frame-staging fallback: - Widen the symlink no-privilege catch from EPERM/EACCES to also include UNKNOWN (some Windows builds surface a symlink privilege denial as UNKNOWN). - Wrap the EEXIST stale-entry recovery around BOTH staging branches, not just the symlink one: after heygen-com#2025 Windows uses the eager cpSync path, which can collide with a dangling symlink left by a prior Linux run — now it clears the stale entry and re-stages either way. - Emit a one-time INFO log when symlinking degrades to copying, so a heavier Windows render is self-explanatory.
Problem
Local video renders on Windows without Developer Mode/Administrator fail at the
video_extractstage:renderOrchestrator → runExtractVideosStagematerializes each video's extracted frames into the compiled dir viasymlinkSyncwith nomaterializeSymlinksflag, andsymlinkSyncthrows EPERM there. Reported repeatedly (users worked around it by patchingcli.jsto passmaterializeSymlinks: process.platform==='win32', or by falling back to snapshot-frame + manual ffmpeg mux).The distributed
plan()path already copies (materializeSymlinks: true) — only the local render caller was missing it.Fix
Pass
materializeSymlinks: shouldCopyExtractedFrames(process.platform)at the localrunExtractVideosStagecall inrenderOrchestrator.ts:New pure
shouldCopyExtractedFrames(platform)helper inextractVideosStage.tsdocuments the rationale and is unit-tested. This sidesteps the symlink entirely on Windows — independent of, and complementary to, the EPERM→cpSyncfallback in open PR #1959 (which catches the throw if a symlink is ever attempted elsewhere).Tests
extractVideosStage.test.ts—shouldCopyExtractedFrames: win32 → copy (true), darwin/linux → symlink (false). Pre-commit typecheck + fallow + oxlint/oxfmt all green.