fix(cli): preserve media at snapshot end boundary#2475
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 55d2b9e742987f5e27f95f269922749785eb5ac5.
What this does
Fixes the hyperframes snapshot --at <exact-clip-duration> case going black. Root cause: at exact clipEnd, the renderer's inclusive media window still activates the clip, but FFmpeg cannot decode a frame at a source's exclusive duration boundary. Pre-PR the snapshot passed relTime = duration to ffmpeg -ss, which extracted zero frames.
New pure helper resolveSnapshotVideoFrameTime at snapshot.ts:65-83 returns the frame time to actually pass to FFmpeg:
- Out of window (
globalTime < clipStart,> clipEnd, orrelativeTime < 0) →null(drop from active set). - At the exact clip end (
|globalTime - clipEnd| <= 1e-9) →Math.max(0, Math.min(relativeTime, sourceEnd - 1/30))— one nominal 30fps frame back from the source's decodable edge. - Otherwise →
relativeTimeunchanged.
The call-site refactor at snapshot.ts:389-434 also moves the activation decision out of page.evaluate: the browser now returns the full candidate list, and the Node side filters via resolveSnapshotVideoFrameTime before scheduling the FFmpeg extract. Same active-set logic, but now unit-testable in isolation.
Three regressions at snapshot.test.ts:40-76 cover the three branches (exact clip end, in-window, past-end).
Verification
- Boundary semantics.
globalTime > clipEndis strict>, soglobalTime === clipEndfalls through to the atClipEnd branch instead of being dropped — matches the "media active at inclusive end" contract the renderer holds elsewhere. ✓ - Backoff math.
Math.min(relativeTime, sourceEnd - 1/30)back-offs 33.3ms from source end. Safely inside any real video source (min VFR frame delta ≪ 33ms).Math.max(0, ...)clamps against negative for pathologically-short sources (sourceDuration < 1/30 ≈ 33ms). ✓ sourceEnd = sourceDuration > 0 ? sourceDuration : relativeTimefallback — when source duration is unavailable (v.duration === NaNorInfinity), uses the relativeTime as the reference, so the back-off degenerates to the requested time itself. Preserves callsite behavior when video hasn't reported metadata. ✓1e-9at-clip-end tolerance — appropriate for float-time comparisons. Wider than double-precision epsilon at 15-second timestamps; tighter than any frame duration. ✓- In-window unchanged. Second test pins
globalTime=7.5, clipDuration=15, relativeTime=7.5→7.5untouched. So the code path only diverges at exact end boundary. ✓ - Refactor preserves per-video filtering. Old: filter inside
page.evaluate. New: return all candidates, filter in Node viaflatMap(candidate => frameTime === null ? [] : [{...candidate, relTime: frameTime}]). Same output set. ✓ - CI: 19/19 focused tests + typecheck/format/oxlint green at head. ✓
Concerns
-
🟢 Hardcoded 30 fps back-off. For a 60fps source,
1/30= two frame durations back, sampling two frames before end — safe, but slightly conservative. For a 24fps source,1/30 ≈ 0.83frame durations back, still inside the last valid frame.1/24would work but the 30fps constant is fine as a shared conservative default. Could parameterize asSNAPSHOT_FRAME_BACKOFF_SECONDS = 1 / 30at module scope for future tuning. Note-only. -
🟢 The 1e-9 tolerance boundary is untested. Third test pins
15.001(well outside tolerance) → null, which proves the strict> clipEndgate. A test pinningglobalTime = clipEnd + 5e-10returning the backed-off frame would prove the tolerance is load-bearing on both sides. Non-blocking. -
🟢 The Node-side filter is per-candidate; the
page.evaluatepayload is now slightly larger (includes candidates that will get dropped). At snapshot-invocation frequency this is negligible, and separating browser state-collection from Node activation logic is the right shape — activation is deterministic in Node and now unit-testable. ✓ Note-only. -
🟢
resolveSnapshotVideoFrameTimeis exported but the module doc-comment focuses on FFmpeg's decode limit — worth mentioning it also serves as the single-source activation gate for the snapshot pipeline (i.e. what makes it null-returning matters for the callsite). Wordsmith. Note-only.
Verdict framing
Clean fix on a specific, reproducible boundary bug. Extracting the activation predicate into a pure helper unit-tests the decision that used to live inside page.evaluate, which is a nice refactor beyond the strict bug fix. LGTM from my side.
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewing at head 55d2b9e7. Fresh checkout, no prior R1 posted at this SHA (Rames co-reviewing — no cross-post yet).
Position: RIGHT — the fix mechanism (helper predicate + inclusive clipEnd + clamp to sourceEnd - 1/30) is the correct shape for the "FFmpeg can't decode at exclusive-source-duration" boundary. Blocker below is on the regression envelope around it, not the shape.
Strengths:
packages/cli/src/commands/snapshot.ts:63-83— the extraction into a pureresolveSnapshotVideoFrameTimecleanly separates the boundary decision from the browser-side candidate enumeration; the helper is now unit-testable and the caller (snapshot.ts:421-431) is a tightflatMapthat reads well.snapshot.ts:79— the strict-less/strict-greater inclusive check (< clipStart || > clipEnd) plus1e-9tolerance at the clip-end equality is the right float-safety wedge for accumulated global-time arithmetic.snapshot.ts:84-85— thesourceEnd - 1/30clamp withMath.max(0, …)correctly floors negative results for near-zero source durations.
Blockers:
- blocker (Miguel R1 directive #4 — parameterization):
packages/cli/src/commands/snapshot.test.ts:40-76— the newdescribe("resolveSnapshotVideoFrameTime")block covers 3 of the ≥5 distinct branches the helper takes. Missing regression coverage:globalTime < clipStart→ null (predicate short-circuit at head ofsnapshot.ts:79)relativeTime < 0→ null (defensive branch on line 79 — reachable viamediaStart > 0+ very-early global time)sourceDuration <= 0fallback (sourceEnd = relativeTime) →snapshot.ts:84clipStart != 0offset clip at inclusive-end (e.g.clipStart: 5, clipDuration: 10, globalTime: 15) — the realistic production shape- tolerance boundary that is near-but-not-exactly
clipEnd(e.g.globalTime: 15 + 5e-10,clipEnd: 15) — asserts the1e-9wedge does what it advertises
Please parameterize across every branch — the current tests would pass even if branches 1/3/4 regressed to the wrong behavior. Table-driven or a.eachloop keeps this compact.
Important:
- important:
snapshot.ts:407-410+snapshot.ts:79-85— the fix quietly changes behavior on clips configured such thatmediaStart + clipDuration * playbackRate > sourceDuration(e.g.playbackRate: 2, clipDuration: 10, srcDur: 15). Under the old code,activeNow = t < start + durationgated the media OFF atglobalTime === clipEnd; the new helper hits theatClipEndbranch and samplessourceEnd - 1/30. The last-decodable-frame behavior is likely what you want, but it's an unadvertised behavioral delta relative to "restore boundary frame." Worth a one-line note in the PR body or the helper's JSDoc so a future reader doesn't read this as pure inclusive-only. - important: runtime parity —
packages/player/src/parent-media.ts:141,161,190gates media with strictrelTime >= m.duration(exclusive end). AtglobalTime === clipEndthe runtime hides the video, but this snapshot path now paints its last decodable frame. That's plausibly the desired snapshot semantics (users expect a non-blank final frame) but it means snapshot ≠ what parent-media renders in the live player at that exact instant. If parity with the runtime is required for any downstream comparator (parity-harness, preview-regression, etc.), this is a divergence to document; if snapshot semantics are intentionally distinct here, add a JSDoc line toresolveSnapshotVideoFrameTimecalling that out.
Nits:
- nit:
snapshot.ts:85— the1/30frame-step is a fixed 30fps assumption. For higher-fps sources (60fps, 120fps) it steps back one 30fps-frame worth of source time, which is safely inside the decodable range but wastes ~1 frame of resolution at the tail. If the source's actual fps is available via<video>.getVideoPlaybackQuality()or an authoring-sidedata-fps, prefer that; otherwise fine. - nit: the helper accepts
clipDuration: Number.POSITIVE_INFINITY(from the browser-sidedurationfallback atsnapshot.ts:406) →clipEnd = Infinity→Math.abs(globalTime - Infinity) <= 1e-9is always false →atClipEnd = false→ pass-through. Behaves correctly but is worth a comment near the helper: "InfinityclipDurationintentionally never hits the atClipEnd branch."
Notes:
- CI at this SHA: all required checks green (Format, Preflight, Test, Build, Typecheck, Lint, Fallow audit, CLI smoke, SDK, CodeQL).
mergeStateStatus=BLOCKEDis reviewer-gate only. - Audited call-site scope:
syncVideoFrameVisibility(engine,screenshotService.ts:776-843) consumes only theactive.map(a => a.id)set — no independent boundary computation, no sibling site with the same bug.injectVideoFramesBatchreceives the pre-extracted dataUri, so the boundary decision lives entirely in the CLI helper. No Rule-2 fanout finding for this PR. - No prior reviews on GitHub at this head; Rames is co-reviewing per the request in
#hyperframes-internal.
Verdict: REQUEST CHANGES
Reasoning: Fix approach is RIGHT and the extraction is clean, but the regression envelope skips ≥3 branches of the helper (offset-clip inclusive end, defensive relativeTime<0, sourceDuration-fallback, tolerance-wedge) — Miguel R1 directive #4 asks these to be parameterized before the fix ships so a future refactor doesn't silently regress a branch that has zero coverage today.
— Via
|
Note re. overlap with @james-russo-rames-d-jusso's review (posted ~3 min before mine — parallel-reviewer race, I hadn't seen his when I drafted):
— Via |
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 3c18d20ee — R1 (55d2b9e7) → R2 delta is +72 / -5 across two files.
R2 delta
Closes my R1 🟢 on the tolerance-boundary test and fixes an asymmetric-tolerance predicate I hadn't flagged.
clipEndTolerance = 1e-9extracted as a named local atsnapshot.ts:79, referenced in both the out-of-window check and the at-clip-end check.- Tolerance predicate corrected — the pre-PR out-of-window check was
globalTime > clipEnd(strict), but the at-clip-end check wasMath.abs(globalTime - clipEnd) <= 1e-9(tolerance-aware). So aglobalTimeslightly overclipEndby a float-precision amount would fall into the "past-end null" branch before reaching the at-clip-end check. R2 makes the out-of-window guard tolerance-aware too:globalTime > clipEnd + clipEndTolerance— so aclipEnd + 5e-10input reliably hits the backed-off-frame branch. - 5-branch parametrized regression at
snapshot.test.ts:77-134:before clip start— null.negative relative time— null.unknown source duration(sourceDuration = 0) — falls back torelativeTime - 1/30.offset clip inclusive end(clipStart=5, clipDuration=10, sourceDuration=10) — the non-zero-start case →10 - 1/30.clip end within floating-point tolerance—globalTime = 15 + 5e-10→10 - 1/30. This pins the exact tolerance boundary I flagged. ✓
- Doc comment expanded at
snapshot.ts:63-68:- "This intentionally differs from the live player's exclusive-end visibility so an explicit end-boundary review does not become blank." — makes the design intent explicit (snapshot ≠ player-exclusive-end).
- "This also clamps clips whose configured media window extends beyond the source." — documents the
Math.min(relativeTime, sourceEnd - 1/30)behavior. - "An infinite clip duration intentionally never enters the end-boundary branch." — documents that
clipDuration = Infinity→clipEnd = Infinity→Math.abs(globalTime - Infinity) = Infinity→ not-atClipEnd → returns rawrelativeTime.
Verification
- Tolerance boundary now symmetric. Out-of-window check + at-clip-end check use the same
clipEndTolerance; aglobalTime = clipEnd + 5e-10input consistently maps to the backed-off frame branch instead of falling through the crack. Test pins this exact case. ✓ - Non-zero-start clip case (clipStart=5, clipDuration=10, sourceDuration=10, relativeTime=10) — verifies the arithmetic works with
clipStart ≠ 0. Under R1, this case was implicitly covered but not explicitly tested. ✓ - Unknown-source-duration branch —
sourceDuration = 0triggerssourceEnd = relativeTime, and the final clampMath.min(relativeTime, relativeTime - 1/30) = relativeTime - 1/30. Correct behavior. ✓ - Infinity clipDuration branch documented but not tested.
Math.abs(globalTime - Infinity)isInfinity,> 1e-9, soatClipEnd = false→ returnsrelativeTimeunchanged. Acknowledged as intentional in the comment. ✓ - CI: 24/24 focused tests + typecheck/hooks green per Magi's summary. ✓
R1 concerns status
- 🟢 (R1) Hardcoded 30fps back-off — unchanged (still note-only, non-blocking).
- 🟢 (R1) 1e-9 tolerance boundary untested → CLOSED via the
15 + 5e-10branch of the parametrized test. Plus the extraction into a namedclipEndTolerancelocal + the predicate-symmetry fix means the tolerance is now enforced consistently. ✓ - 🟢 (R1) Node-side filter payload size — unchanged; the refactor's shape is preserved.
- 🟢 (R1) Docstring focus — closed via the expanded comment that names the semantic divergence from the player.
Verdict framing
R2 closes the tolerance-boundary gap I flagged plus a latent asymmetric-tolerance predicate that would have been a real edge-case bug. The 5-branch parametrization + expanded documentation is exactly the level of guard-rail this helper wanted. LGTM from my side.
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at 3c18d20ee. R1 (55d2b9e7, pullrequestreview-4703461523) → R2 delta: +72/-5 across snapshot.ts and snapshot.test.ts. Verifying against R1's blocker + two important findings.
Strengths (new at R2):
packages/cli/src/commands/snapshot.ts:79-83— extractingclipEndTolerance = 1e-9as a named local and reusing it in both the out-of-window guard (line 80) and the at-clip-end predicate (line 83) also closes a latent asymmetric-tolerance bug that neither R1 nor I had flagged as a blocker: pre-R2, aglobalTime = clipEnd + 5e-10would trip the strict> clipEndcheck and short-circuit to null before reaching the tolerance-aware at-clip-end branch. Rames caught this on his end; nice pickup.snapshot.ts:63-69— the JSDoc now names both divergences the helper introduces relative to a naive read: (a) intentionally differs from the live player's exclusive-end visibility, and (b) clamps clips whose configured media window extends beyond the source. Reads as the contract, not the mechanic.snapshot.test.ts:77-137—it.each-style parameterization pins each branch on one line; if a future refactor drops a branch, the failing case is unambiguous.
R1 findings status:
- ✅ [F1 — blocker, regression parameterization] —
snapshot.test.ts:77-137covers all five branches I asked for:before clip start→ null (predicate short-circuit onglobalTime < clipStart) ✓negative relative time→ null (defensiverelativeTime < 0) ✓unknown source duration(sourceDuration = 0) → falls back tosourceEnd = relativeTime, resolves torelativeTime − 1/30✓offset clip inclusive end(clipStart = 5, clipDuration = 10, sourceDuration = 10) →10 − 1/30✓clip end within floating-point tolerance(globalTime = 15 + 5e-10) →10 − 1/30✓
The tolerance branch also functions as the regression test for the asymmetric-tolerance predicate fix..eachlayout keeps the branch list scannable.
- ✅ [F2 — important, playbackRate behavioral delta / configured-window-exceeds-source] —
snapshot.ts:67-68"This also clamps clips whose configured media window extends beyond the source" is exactly the one-line note I asked for. TheMath.min(relativeTime, sourceEnd − 1/30)clamp is now advertised in the contract; future readers won't mistake the at-clip-end branch for pure inclusive-end pass-through. - ✅ [F3 — important, parent-media runtime parity gap] —
snapshot.ts:64-66"This intentionally differs from the live player's exclusive-end visibility so an explicit end-boundary review does not become blank." Verified againstpackages/player/src/parent-media.ts:141,161which still gates withrelTime >= m.duration(exclusive end) — divergence is real and now documented as intentional. Downstream comparators reading the helper's JSDoc will see the semantic split.
Adversarial pass:
- Documented behavior vs implementation.
- "An infinite clip duration intentionally never enters the end-boundary branch" →
clipDuration = Infinity→clipEnd = Infinity→Math.abs(globalTime − Infinity) = Infinity > 1e-9→atClipEnd = false→ returns rawrelativeTime. Matches. ✓ - "Clamps clips whose configured media window extends beyond the source" → at
atClipEnd,Math.min(relativeTime, sourceEnd − 1/30)withsourceEnd = sourceDurationwhen> 0; ceiling atsourceDuration − 33.3ms. Matches. ✓ - "Sample one nominal 30fps frame inside the source" →
sourceEnd − 1/30 ≈ 33.3msbefore source end. Matches. ✓
- "An infinite clip duration intentionally never enters the end-boundary branch" →
- New predicate coverage. The tolerance-symmetric
globalTime > clipEnd + clipEndToleranceguard (line 80) has both sides pinned:15.001(far past tolerance) → null in the existing "does not activate media after the clip end" test, and15 + 5e-10(well inside tolerance) → back-off frame in the parameterized case. The gap between+1e-9and+0.001is huge; a granular+1.5e-9case (just past tolerance) would tighten the assertion but is a nit at best given the current spread. - Call-site scope unchanged.
snapshot.ts:394-436still filters via aflatMap(candidate => resolveSnapshotVideoFrameTime(...) === null ? [] : [{...candidate, relTime: frameTime}]); no new fan-out.syncVideoFrameVisibility/injectVideoFramesBatchconsumers unchanged. - No new branch lacks coverage. The R2 delta introduces one new predicate (tolerance-symmetric out-of-window guard) and one refactored local (
clipEndTolerance); both are exercised.
Notes:
- CI at head: 24/24 focused tests + Format, Lint, Typecheck, Test, Build, Fallow audit, CLI smoke, SDK, CodeQL all green.
mergeStateStatus = BLOCKEDis reviewer-gate only. - No new call-site or contract fan-out from R1 → R2.
Verdict: APPROVE
Reasoning: F1 blocker's five-branch regression envelope is in place; F2 and F3 doc updates name both divergences in the helper's contract; the R2 delta additionally fixes an asymmetric-tolerance predicate that would have been a real edge-case bug. Adversarial pass clean.
— Via
No description provided.