Skip to content

fix(engine): harden ffprobe parsing and command arguments - #2740

Merged
jrusso1020 merged 1 commit into
heygen-com:mainfrom
santhiprakash:fix/ffprobe-robustness
Jul 30, 2026
Merged

fix(engine): harden ffprobe parsing and command arguments#2740
jrusso1020 merged 1 commit into
heygen-com:mainfrom
santhiprakash:fix/ffprobe-robustness

Conversation

@santhiprakash

@santhiprakash santhiprakash commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What

Hardens packages/engine/src/utils/ffprobe.ts against three edge cases:

  • Malformed frame-rate strings (e.g. "30/", "30/0", "abc/def") were returning NaN from parseFrameRate.
  • File paths starting with - were passed as bare arguments to ffprobe, which can be misinterpreted as option flags.
  • The PNG cICP color-space handler returned immediately, so a cICP chunk seen before IHDR could emit width: 0, height: 0.

Why

These bugs caused downstream metadata (fps, colorSpace) to become NaN/null or, in the path case, allowed accidental option injection when user-provided paths begin with a dash.

How

  • parseFrameRate now checks Number.isFinite on both numerator and denominator and requires den !== 0; invalid ratios fall back to 0 instead of NaN.
  • Every runFfprobe invocation now inserts -- before the user-supplied filePath.
  • extractPngMetadataFromBuffer stores any cICP color space in a local variable and only emits it after IHDR has supplied valid width/height.
  • Added regression tests for all three behaviors.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

Ran bunx vitest run src/utils/ffprobe.test.ts and bunx oxlint / bunx oxfmt --check on the changed files. Three existing fixture-dependent tests fail locally because hdr-photo-pq.png is stored via Git LFS and is not present in this working tree; they pass in CI with the LFS object.

@santhiprakash
santhiprakash force-pushed the fix/ffprobe-robustness branch from 939901e to e740f1c Compare July 23, 2026 04:35

@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.

Reviewed packages/engine/src/utils/ffprobe.ts end-to-end at HEAD (e740f1c) plus the new tests. No prior reviews/comments on the PR.

Strengths

  • parseFrameRate (fix #1, ~L239) is correct and thoroughly table-tested — Number.isFinite on both operands + den !== 0, with "30/", "30/0", "0/0", "abc/def", and bare "60" all pinned. 0 is the right fallback: downstream avgFps || rFps and the still-image path already tolerate fps: 0, whereas NaN poisoned every frame-count calc.
  • cICP ordering (fix #3, L184/L208) is correct — the color space is stashed in a local and only emitted once IHDR supplies valid dims. Handles cICP-before-IHDR, normal cICP-after-IHDR, and cICP-with-no-IHDR (→ null). All three covered by the new test.

Blocker

  • Fix #2 is incomplete. The PR body says "Every runFfprobe invocation now inserts -- before the user-supplied filePath," but the AAC packet-count probe misses it — ffprobe.ts:388 passes filePath bare (right after -print_format json). Three of the four call sites got -- (L274, L366, L457); this fourth did not. So the same audio file at a --prefixed path is separator-protected on the main audio probe (L366) but not on the AAC sub-probe just below it — the exact arg-injection class this PR sets out to close, left open on one path. The "uses -- for audio and keyframe probes too" test can't catch it because the audio fixture is pcm_s16le, so the if (audioCodec === "aac") branch never executes (it asserts 2 --, not 3).
    Fix: add "--" before filePath at L388, and add an aac-codec case to that test so the count becomes 3.

Important — merge-readiness, not code

  • CI has not actually run. Every real job — CI, regression, preview-regression, Player perf, CodeQL, Windows render verification — is in action_required (0s, awaiting maintainer approval to run); the only completed check is WIP (pass). So the body's claim that the 3 LFS-fixture tests "pass in CI with the LFS object" is currently unverified — nothing green confirms it, and this is why the PR shows BLOCKED. A maintainer needs to approve the workflow runs and confirm the vitest CI job (with the hdr-photo-pq.png LFS object) is green before merge.

Verdict: REQUEST CHANGES
Reasoning: Fixes #1 and #3 are correct and well-tested, but the headline command-arg hardening (#2) misses the AAC packet-count call site — so the "every invocation" claim is false and the injection path stays open there — and CI hasn't executed, so the test-pass claim is unconfirmed.

— Rames Jusso

Comment thread packages/engine/src/utils/ffprobe.ts Outdated
const probePromise = (async (): Promise<AudioMetadata> => {
const stdout = await runFfprobe(
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath],
["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "--", filePath],

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.

Fix is incomplete: the AAC packet-count sub-probe a few lines down (ffprobe.ts:388, inside if (audioCodec === "aac" && sampleRate > 0)) still passes filePath bare — no --. The same audio file at a --prefixed path is separator-protected on this probe but not on that one, which contradicts the PR body's "every runFfprobe invocation" claim. The "uses -- for audio and keyframe probes too" test can't catch it because its fixture is pcm_s16le (the aac branch never runs). Add "--" before filePath at L388, plus an aac case that bumps the assertion to 3 --.

@santhiprakash
santhiprakash force-pushed the fix/ffprobe-robustness branch 2 times, most recently from b4a2a4a to 6575caf Compare July 23, 2026 16:01
@santhiprakash

Copy link
Copy Markdown
Contributor Author

Good catch — I missed the -- in the AAC packet-count probe. I added it before filePath there and updated the regression test so it exercises the AAC path and asserts all three -- separators. The relevant tests now pass locally. PTAL.

@santhiprakash

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @jrusso1020! A couple of factual corrections on the two points raised:

1. AAC packet-count probe — -- IS present

The AAC sub-probe call site already has -- before filePath. Here's the exact diff from the commit:

@@ -383,6 +385,7 @@ export async function extractAudioMetadata(
         "stream=nb_read_packets",
         "-print_format",
         "json",
+        "--",
         filePath,
       ]);

All four runFfprobe call sites received -- in the same commit:

  • extractMediaMetadata (media probe, ~L274)
  • extractAudioMetadata main probe (L365-366)
  • extractAudioMetadata AAC sub-probe (L385-388) ← this is the one in question
  • analyzeKeyframeIntervalsUncached (keyframe probe, L455-457)

2. Test fixture — already uses aac, asserts 3 --

The test fixture is aac, not pcm_s16le:

// ffprobe.test.ts:453
streams: [{ codec_type: "audio", codec_name: "aac", sample_rate: "48000", channels: 2 }]

And the assertion checks for 3 -- separators (not 2):

// ffprobe.test.ts:475
expect(args.filter((arg) => arg === "--")).toHaveLength(3);

The three -- separators cover: audio main probe, AAC sub-probe, and keyframe probe. The image/media probe is covered separately in the "uses -- to separate options from file paths" test. So all four call sites are tested.

3. CI — agreed

The CI concern is valid. All workflow runs are in action_required awaiting maintainer approval. I can't trigger them from a fork. The test suite passes locally (bun test packages/engine/src/utils/ffprobe.test.ts).

Could you re-review with the above corrections in mind? Happy to add more test coverage if anything specific is still unclear.

@santhiprakash

Copy link
Copy Markdown
Contributor Author

Quick follow-up on the outstanding review: the current head () still includes the AAC sub-probe separator and the AAC regression assertion.\n\nSpecifically on current HEAD:\n- includes before in the AAC packet-count probe\n- uses an fixture for that path and asserts \n\nSo the requested command-arg hardening is present on the branch now. If you have a moment, could you please re-review the latest head? Happy to add more coverage if there is still a remaining gap.

@santhiprakash

Copy link
Copy Markdown
Contributor Author

Quick follow-up on the outstanding review: the current head (2af3a114d) still includes the AAC sub-probe separator and the AAC regression assertion.

Specifically on current HEAD:

  • packages/engine/src/utils/ffprobe.ts includes "--" before filePath in the AAC packet-count probe
  • packages/engine/src/utils/ffprobe.test.ts uses an aac fixture for that path and asserts expect(args.filter((arg) => arg === "--")).toHaveLength(3)

So the requested command-arg hardening is present on the branch now. If you have a moment, could you please re-review the latest head? Happy to add more coverage if there is still a remaining gap.

- parseFrameRate now rejects malformed ratios (e.g. "30/", "30/0") instead of NaN.

- Add "--" before file paths so names starting with "-" are not parsed as options.

- cICP PNG chunk no longer returns before IHDR supplies width and height.

- Add regression tests for option injection, frame rates, and cICP ordering.
@santhiprakash
santhiprakash force-pushed the fix/ffprobe-robustness branch from 2af3a11 to 6d0b397 Compare July 30, 2026 19:06
@santhiprakash

Copy link
Copy Markdown
Contributor Author

@jrusso1020 Re-review request on rebased head 6d0b3972f.

Addressed:

  1. AAC packet-count probe missing -- — Fixed by centralizing the separator in runFfprobe() (packages/engine/src/utils/ffprobe.ts L51): spawn(command, ["-v", "error", ...argsWithoutInput, "--", filePath]). Every call site — media probe, audio main probe, AAC sub-probe, and keyframe probe — now gets -- automatically. No per-call-site gaps remain.
  2. Test fixture / assertion countffprobe.test.ts uses an aac codec fixture for the audio+keyframe probe test and asserts expect(args.filter((arg) => arg === "--")).toHaveLength(3) (main audio probe + AAC sub-probe + keyframe probe).
  3. Branch drift — Rebased onto current upstream/main (7b3d3db8a); merge conflicts in the refactored runFfprobe(filePath, argsWithoutInput) API resolved by keeping the centralized -- insertion.

Regression check: cd packages/engine && bun run test -- src/utils/ffprobe.test.ts → 29/32 pass. All new option-separator, frame-rate, and cICP-ordering tests pass. The 3 failures are the checked-in HDR PNG LFS fixture (hdr-photo-pq.png) unavailable locally — same class as your CI note; happy to re-run once workflows are approved on the fork PR.

Status: Ready for re-review. No further code changes needed for the requested command-arg hardening.

@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.

Re-reviewed at 6d0b3972f. My blocker is resolved — dismissing it.

The -- fix is now structural rather than per-call-site. main had already centralized probing into runFfprobe(filePath, argsWithoutInput), and this PR adds the separator to that single spawn (packages/engine/src/utils/ffprobe.ts:51). There is exactly one spawn in the file, so no call site can omit it — that is stronger than the per-site patch I originally asked for, because it also can't regress when a fifth probe gets added later. And the audio fixture is now aac, so the packet-count sub-probe actually executes in the test rather than being skipped.

One note for the record, since it cost a couple of round-trips: my earlier review was pinned to e740f1c, where the AAC call site genuinely did pass filePath bare. Your "the -- IS present" correction was reading a later commit. We were each right about a different SHA — nothing to fix on your side.

What still blocks merge is on our side, not yours. All six required workflows at this head — CI, regression, preview-regression, Player perf, CodeQL, Windows render verification — are sitting at conclusion=action_required: queued but never executed, awaiting maintainer authorization for a fork PR. So the 29/32 local result is still unconfirmed by CI, and that is what makes the PR read BLOCKED. A maintainer needs to approve the runs. I'm holding at comment rather than approving until that comes back green; that is a CI-evidence gate, not a code concern.

Two non-blocking observations:

  1. ffprobe.test.tsexpect(args.filter((arg) => arg === "--")).toHaveLength(3) counts across the flattened args of all three calls, so it would also pass if one probe carried two separators and another carried none. Asserting per call, the way the first test does (args[args.indexOf(filePath) - 1]), pins it exactly. Cosmetic now that the guarantee is structural.

  2. extractPngMetadataFromBuffer — replacing the early return at cICP with a stash means the walk now continues to IEND, CRC32-checking every chunk including IDAT, where previously it stopped at cICP. Two consequences, both specific to HDR PNGs (the only ones carrying cICP): every still-image probe now pays a full-buffer pass through the pure-JS crc32 (L170), and a corrupt trailing chunk now turns a previously-successful probe into null and falls through to the ffprobe path. Both fall out of a fix that is otherwise correct, and neither is wrong. If you want the old cost profile back, if (seenIdat && width > 0 && height > 0) break; is safe and consistent with the !seenIdat guard the cICP branch already relies on.

Worth noting the normal IHDR-then-cICP ordering is exercised only by the hdr-photo-pq.png LFS fixture — one of the three tests that can't run locally — so that path specifically needs the CI run to be confirmed.

Verdict: code concern cleared, prior blocker dismissed. Holding for a green CI run that a maintainer has to trigger.

— Rames Jusso

@jrusso1020
jrusso1020 dismissed their stale review July 30, 2026 19:16

Resolved at 6d0b397 — the option separator is now enforced centrally in runFfprobe, so the AAC sub-probe gap is closed structurally. See my re-review comment. Remaining gate is the CI run, which needs maintainer authorization.

@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.

Terminal-green at 6d0b3972f. All six required workflows (CI, regression, preview-regression, Player perf, CodeQL, Windows render verification) came back success. That was the only thing I was holding for; my earlier blocker was already dismissed once main's runFfprobe centralization made the separator structural rather than per-call-site.

Worth recording that the HDR path is genuinely covered here rather than skipped: the three hdr-photo-pq.png tests are declared unconditionally, and the run fetched the LFS objects, so they executed. That also explains the 29/32 you saw locally. Those three were reading the LFS pointer file instead of the actual PNG.

The two notes from my previous review stay non-blocking, yours to take or leave:

  1. The -- assertion counts across the flattened args of all three probes, so asserting per call would pin it exactly.
  2. extractPngMetadataFromBuffer now walks to IEND instead of returning early at cICP, so every still-image probe CRC32s the whole buffer in pure JS. if (seenIdat && width > 0 && height > 0) break; restores the old cost profile and is consistent with the !seenIdat guard the cICP branch already relies on.

Thanks for staying with this one. The week of silence was on our side, not yours: the required workflows were sitting queued awaiting authorization for a fork PR.

Approving.

— Rames Jusso

@jrusso1020
jrusso1020 merged commit f75ca07 into heygen-com:main Jul 30, 2026
51 checks passed
vanceingalls added a commit that referenced this pull request Jul 31, 2026
…site

#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.

Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.

Terminated at all of them:
  producer/services/render/audioPadTrim.ts (x2)
  producer/plan-parity-analysis.ts
  cli/commands/init.ts
  cli/utils/webmAlphaCheck.ts
  cli/whisper/transcribe.ts (x2)
  core/mediaGradeAnalyzer.ts
  lint/hevcPreviewLint.ts

audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Jul 31, 2026
…ontract

The previous commit claimed "all nine now terminate their options". That
was false: `producer/src/utils/audioRegression.ts:307` still passed the
path bare, and it is production source used by the regression harness.
A repo-wide audit found two more in studio-server
(`mediaValidation.ts`, `mediaMetadata.ts`) — their current callers pass
absolute paths, so they were defence-in-depth rather than live bugs, but
the exhaustiveness claim should be true rather than narrowed.

Eleven sites total, all terminated.

Adds a SOURCE-level contract test, which is the gap that let this
happen twice. #2740 fixed one of ten sites and shipped a regression
asserting the argv of that single site, so CI reported the class closed
while nine invocations still parsed `-intro.mp4` as an option. A
per-site unit test has the same blind spot for site twelve; scanning the
tree does not. The test also asserts its own coverage list has not
shrunk.

Verification: engine 1300, lint 511, core 1431, studio-server 398, cli
init/webmAlphaCheck/whisper 146, producer utils 51, audioPadTrim 18.
Removing any single terminator fails the contract test by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Aug 1, 2026
…site

#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.

Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.

Terminated at all of them:
  producer/services/render/audioPadTrim.ts (x2)
  producer/plan-parity-analysis.ts
  cli/commands/init.ts
  cli/utils/webmAlphaCheck.ts
  cli/whisper/transcribe.ts (x2)
  core/mediaGradeAnalyzer.ts
  lint/hevcPreviewLint.ts

audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Aug 1, 2026
…ontract

The previous commit claimed "all nine now terminate their options". That
was false: `producer/src/utils/audioRegression.ts:307` still passed the
path bare, and it is production source used by the regression harness.
A repo-wide audit found two more in studio-server
(`mediaValidation.ts`, `mediaMetadata.ts`) — their current callers pass
absolute paths, so they were defence-in-depth rather than live bugs, but
the exhaustiveness claim should be true rather than narrowed.

Eleven sites total, all terminated.

Adds a SOURCE-level contract test, which is the gap that let this
happen twice. #2740 fixed one of ten sites and shipped a regression
asserting the argv of that single site, so CI reported the class closed
while nine invocations still parsed `-intro.mp4` as an option. A
per-site unit test has the same blind spot for site twelve; scanning the
tree does not. The test also asserts its own coverage list has not
shrunk.

Verification: engine 1300, lint 511, core 1431, studio-server 398, cli
init/webmAlphaCheck/whisper 146, producer utils 51, audioPadTrim 18.
Removing any single terminator fails the contract test by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Aug 4, 2026
…site

#2740 added `--` to one of nine independent ffprobe invocations, so the
bug class it closed stayed open everywhere else while CI reported it
fixed — the regression test asserts the argv of that single site.

Reproduced on ffprobe 8.1.1: an asset named `-intro.mp4` probes fine
through extractMediaMetadata but fails with "Missing argument for option
'intro.mp4'" in audio pad/trim (mid-render), `hyperframes init`, whisper
duration probing and webmAlphaCheck. hevcPreviewLint catches and returns
false, so a dash-prefixed HEVC preview silently passes the lint rule.

Terminated at all of them:
  producer/services/render/audioPadTrim.ts (x2)
  producer/plan-parity-analysis.ts
  cli/commands/init.ts
  cli/utils/webmAlphaCheck.ts
  cli/whisper/transcribe.ts (x2)
  core/mediaGradeAnalyzer.ts
  lint/hevcPreviewLint.ts

audioPadTrim's runFfprobeJson is a near-verbatim clone of the engine's
runFfprobe and structurally cannot add the terminator itself, because
callers bake the input path into `args`. It now asserts the terminator
is present rather than letting a dash-prefixed path through, takes the
same stdio ["ignore", ...] as the engine helper, and redacts its stderr
— it was throwing raw ffprobe output, which echoes the input path, into
logs and telemetry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vanceingalls added a commit that referenced this pull request Aug 4, 2026
…ontract

The previous commit claimed "all nine now terminate their options". That
was false: `producer/src/utils/audioRegression.ts:307` still passed the
path bare, and it is production source used by the regression harness.
A repo-wide audit found two more in studio-server
(`mediaValidation.ts`, `mediaMetadata.ts`) — their current callers pass
absolute paths, so they were defence-in-depth rather than live bugs, but
the exhaustiveness claim should be true rather than narrowed.

Eleven sites total, all terminated.

Adds a SOURCE-level contract test, which is the gap that let this
happen twice. #2740 fixed one of ten sites and shipped a regression
asserting the argv of that single site, so CI reported the class closed
while nine invocations still parsed `-intro.mp4` as an option. A
per-site unit test has the same blind spot for site twelve; scanning the
tree does not. The test also asserts its own coverage list has not
shrunk.

Verification: engine 1300, lint 511, core 1431, studio-server 398, cli
init/webmAlphaCheck/whisper 146, producer utils 51, audioPadTrim 18.
Removing any single terminator fails the contract test by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
…#2740)

- parseFrameRate now rejects malformed ratios (e.g. "30/", "30/0") instead of NaN.

- Add "--" before file paths so names starting with "-" are not parsed as options.

- cICP PNG chunk no longer returns before IHDR supplies width and height.

- Add regression tests for option injection, frame rates, and cICP ordering.
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.

2 participants