Skip to content

fix(hyperframes-media): fall back to ffmpeg when ffprobe is missing#1877

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/tts-ffprobe-duration-ffmpeg-fallback
Jul 3, 2026
Merged

fix(hyperframes-media): fall back to ffmpeg when ffprobe is missing#1877
miguel-heygen merged 2 commits into
mainfrom
fix/tts-ffprobe-duration-ffmpeg-fallback

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Summary

ffprobeDuration() in skills/hyperframes-media/scripts/lib/tts.mjs returned NaN whenever the ffprobe spawn failed for any reason, conflating two very different situations: "the file is genuinely unreadable/corrupt" and "the ffprobe binary itself isn't installed."

Some ffmpeg-only distributions (a handful of curated Windows "essentials" builds) ship ffmpeg.exe without ffprobe.exe. On those installs, every TTS line hits the missing-binary case, and audio.mjs's caller reads the resulting NaN as "bad voice duration" and silently omits an already-successfully-synthesized line from the final composition audio — with no indication the actual cause was a missing tool.

Fix

ffprobeDuration now distinguishes the two cases: when the ffprobe spawn fails with ENOENT specifically, it falls back to ffmpeg -i <file>, which reliably prints a Duration: HH:MM:SS.ms banner to stderr even though it exits non-zero with no output requested. Any other non-zero exit (a real unreadable/corrupt file) still returns NaN as before — that path is unchanged.

The banner-parsing logic is extracted into a pure parseFfmpegDurationBanner() so it can be unit tested independently of what's actually installed on the machine running the tests.

Test plan

  • node --test skills/hyperframes-media/scripts/lib/tts.test.mjs — 5 tests pass
  • New tests cover: banner parsing (with/without hours, malformed input), and an end-to-end fallback test that builds an isolated PATH with a fake ffmpeg stub and no ffprobe at all, proving the real ENOENT branch recovers a duration instead of returning NaN
  • A control test confirms NaN is still returned when neither tool resolves

@miguel-heygen miguel-heygen marked this pull request as ready for review July 2, 2026 22:48

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 8f6a3ffed7 (batch review, Group A TTS/audio; layering on Magi's own vetting).
Note: bot-authored (Magi via miguel-heygen); COMMENT-quality — stamp routing per protocol.

Summary — When ffprobe binary is missing (ENOENT specifically), fall back to parsing Duration: from ffmpeg -i <file>'s stderr banner; extract the parser as a pure function for testability.

Scope is exactly right — only the ENOENT branch takes the fallback; every other non-zero exit still returns NaN (preserving the "corrupt file" signal), so the caller's !isFinite(dur) || dur <= 0 gate at audio.mjs:153 behaves identically for the real "bad WAV" case. The regression test with an isolated PATH containing only a fake ffmpeg stub is the right shape — proves the ENOENT branch recovers instead of just testing the parser in isolation.

Concerns

  • 🟡 lib/tts.mjs:94-97 — silent fallback is now the load-bearing bug-preventer, but there's no log line indicating which duration source was used. If ffmpeg's banner ever regresses (parse-changes across versions, or a locale/build variant that formats duration differently), the caller will silently start dropping lines again with no diagnostic. One console.error("ffprobe missing, using ffmpeg banner for %s", absPath) line on first ENOENT would leave a forensic trail. Non-blocking — the tests do gate parser regressions — but the whole point of the PR is the last such silent path bit users.
  • 🟡 lib/tts.mjs:96 — no memoization: every TTS line will spawn ffprobe first, get ENOENT, then spawn ffmpeg. On a machine without ffprobe, that's 2× subprocess spawns per line. At the concurrency cap #1862 introduces (default 4) it's tolerable, but a one-shot hasFfprobe = spawnSync("ffprobe", ["-version"], …).status === 0 cached at module scope would halve the spawn cost on the missing-ffprobe path. Nit, not required for this PR to land.

Questions

  • ↩️ Precision — ffprobe's format=duration returns full float seconds (microsecond-ish), ffmpeg's banner is HH:MM:SS.ms (10ms precision). Caller rounds to 3 decimals via r3(dur) at audio.mjs:157, so downstream totalDuration sum is fine. But is duration_s fed to anything that needs tighter precision than 10ms elsewhere (e.g. word-timing alignment, subtitle sync)? Grep suggests no, but flagging.

Coordination note#1845 also modifies lib/tts.mjs (adds injectable spawnP params for the npx-shell fix). No functional conflict (different functions, different regions), but both touch skills-manifest.json's hyperframes-media hash — trivial rebase for whichever merges second.

— Rames D Jusso

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — verified CI green (0 fail / 0 pending) + no open CR at this head. Non-author stamp clearing the review gate on the Magi self-initiated draft-pass batch, which James greenlit and RDJ batch-cleared: both security holds re-verified at R2 (#1866 chrome-shell reclaim-race closed via the reclaim-gate + mtime recheck; #1845 Windows npx shell-injection closed — no cmd.exe, node <npx-cli.js> with pure argv), zero drift on the other nine, all green.

Merge via Magi's normal path (no admin-merge). Ordering note: the skills-manifest triple-conflict on #1877 / #1862 / #1845 (all bump hyperframes-media.hash) needs sequential rebase + regen at merge time; the rest land in any order.

Rames Jusso

ffprobeDuration() returned NaN whenever the ffprobe spawn failed for any
reason, conflating "ffprobe binary not installed" with "file is corrupt".
Some ffmpeg-only distributions (common in curated Windows installs) ship
ffmpeg.exe without ffprobe.exe, so every TTS line hit the missing-binary
case and audio.mjs read the NaN as a bad WAV, silently dropping an already
successfully synthesized line. Now falls back to parsing ffmpeg's own
`Duration:` stderr banner when ffprobe specifically ENOENTs, and only
returns NaN when the file itself can't be probed by either tool.
@miguel-heygen miguel-heygen force-pushed the fix/tts-ffprobe-duration-ffmpeg-fallback branch from 8f6a3ff to b9c5c94 Compare July 3, 2026 00:48

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — verified CI green (0 fail / 0 pending) + no open CR at this head. Non-author stamp clearing the review gate on the Magi self-initiated draft-pass batch, which James greenlit and RDJ batch-cleared: both security holds re-verified at R2 (#1866 chrome-shell reclaim-race closed via the reclaim-gate + mtime recheck; #1845 Windows npx shell-injection closed — no cmd.exe, node <npx-cli.js> with pure argv), zero drift on the other nine, all green.

Merge via Magi's normal path (no admin-merge). Ordering note: the skills-manifest triple-conflict on #1877 / #1862 / #1845 (all bump hyperframes-media.hash) needs sequential rebase + regen at merge time; the rest land in any order.

Rames Jusso

@miguel-heygen miguel-heygen merged commit e2b4358 into main Jul 3, 2026
39 checks passed
@miguel-heygen miguel-heygen deleted the fix/tts-ffprobe-duration-ffmpeg-fallback branch July 3, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants