Skip to content

fix(render): consolidate duration and timing correctness#2405

Merged
miguel-heygen merged 3 commits into
mainfrom
consolidate/duration-timing-correctness
Jul 14, 2026
Merged

fix(render): consolidate duration and timing correctness#2405
miguel-heygen merged 3 commits into
mainfrom
consolidate/duration-timing-correctness

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Summary

Verification

  • producer duration probe tests: 21 passed
  • CLI composition timing tests: 3 passed
  • producer and CLI typecheck
  • diff whitespace check

`compositions --json` computed each timed child's start with a bare
parseFloat(data-start ?? "0") in parseCompositions (host duration) and
parseSubComposition (sub-comp duration). A relative reference like
data-start="s1" ("start when clip s1 ends") is not numeric, so parseFloat
returned NaN and that clip's contribution to the max-end was silently
dropped — a host with two 3s clips (2nd data-start="s1") reported duration 3
instead of 6, breaking compositions/inspect/snapshot for composition-clip
relative timing.

Resolve relative references the same way the extractor does (parseStartExpression
from @hyperframes/core + a findReferenceTargetEl/resolveReferencedStart port,
since the engine's referenceResolver isn't a public export across the package
boundary). Verified: host duration now 6; 3 tests pass.
(Implemented via Codex; verified independently.)

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

Runtime-interop + cross-layer contract lens (Rames covers test + observability — do not duplicate). Adversarial-user pass completed on head ac02bd38324406142c40e0b36f15bfe6d435a2c0 — one candidate finding refuted by the actual DOM library behavior; see "Cleared" below.

Strengths

  • Line counts add up exactly across the three absorbed source PRs (2077 → compositions.ts +72/-3 + test +41/-1; 2239 → probeStage.ts +11/-1 + test +24; 2338 → probeStage.ts +1 + test +18/-1). Consolidation is a clean superset.
  • probeStage.ts:212 threads variables: job.config.variables into createCaptureSession at the same shape used by the frame-capture path in renderOrchestrator.ts:2016 and the distributed renderChunk.ts path. Engine consumes it at frameCapture.ts:920 via evaluateOnNewDocument before any page script runs — probe and capture now see the same initial __hfVariables state.
  • FRAME_BOUNDARY_EPSILON = 1e-3 at probeStage.ts:86 is safely above six-decimal serialization error (~5e-7 s at 30fps → ~1.5e-5 frame) and well below a visible frame interval at any realistic fps.
  • CLI's resolveStart (compositions.ts:53-94) reuses the shared parseStartExpression grammar from @hyperframes/core, so the CLI and runtime resolver agree on what a relative ref means at the parse level — divergence is limited to composition-relative (CLI) vs absolute-timeline (runtime) resolution, which is intentional for the two use cases.

Cross-slice interactions checked (all clear)

  • Variable-aware probe vs rounded frame-boundary duration. Both operate on composition.duration after the browser probe returns it. Exact-boundary durations produced by variables (e.g. short: true → 2.0s) pass through durationToFrameCount(2.0, 24) = 48 cleanly (|48.0-48| ≤ 1e-3).
  • Relative data-start vs variable-aware probing. compositions.ts runs in the CLI on static markup and never sees the variable-resolved DOM; the producer's probe uses the engine's runtime resolver, not the CLI helper. Independent code paths, no coupling introduced.

Nits

  • compositions.ts:113parseCompositions newly exported so the new test can call it. Consider a /** @internal */ JSDoc so external CLI consumers don't rely on it accidentally.
  • compositions.ts:215 — inside parseSubComposition, resolveStart(doc, el, ...) is called with the outer doc, not searchDocument. When the sub-comp uses a <template> wrapper, ref targets live inside template.content (a DocumentFragment, inert per HTML spec). This currently works only because linkedom.DOMParser (utils/dom.ts) traverses into template content on getElementById — non-standard behavior verified against linkedom@0.18.12. At runtime the point is moot because packages/core/src/compiler/inlineSubCompositions.ts flattens templates before render. Consider threading searchDocument (or the ref-lookup root) through to findReferenceTargetEl so the CLI is robust if the DOMParser is ever swapped for a spec-compliant one.
  • compositions.ts:50findReferenceTargetEl omits CSS.escape(refId) while the runtime resolver (startResolver.ts:42) uses it. Currently safe because parseStartExpression's regex restricts refIds to [A-Za-z0-9_.:-]+ (all CSS-safe), but the two layers should probably match.

Notes (already acknowledged in the PR body)

  • The mirror-not-import duplication between resolveStart/resolveReferencedDuration in compositions.ts and the runtime resolver in core/runtime/startResolver.ts remains a Fallow warn-level and an architectural smell — parity between the two now depends on manual sync. Not blocking this PR; worth a follow-up ticket to export a shared create<Layer>StartTimeResolver-style helper from @hyperframes/core.

Verdict: APPROVE
Reasoning: All three absorbed contracts are preserved shape-for-shape, cross-slice interactions are semantically consistent, engine actually consumes variables at the point the probe now supplies them, and no substantive runtime-interop drift under the actual (linkedom + inline-runtime) execution environment. CI green, mergeable_state = blocked is reviewer-gate only.

— Via

@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 ac02bd38324406142c40e0b36f15bfe6d435a2c0 — consolidates #2338 / #2239 / #2077. Small (4 files, +167/-6).

Three orthogonal duration-correctness wins

  1. Composition variables threaded into duration discoveryrunProbeStage at probeStage.ts:212 now includes variables: job.config.variables in the capture-session config. Compositions whose data-duration depends on a variable (e.g. short: true shortens scenes) probe under the same variable set the final render will use.
  2. Decimal-frame-boundary tolerancedurationToFrameCount(duration, fps) at probeStage.ts:88-93 rounds to the nearest integer when the raw frame count is within 1e-3 of it (32.866667 × 30 = 986.00001 → 986); otherwise ceilings normally (32.867 × 30 = 986.01 → 987). Fixes the "6-decimal duration written back from an exact frame boundary adds a phantom frame" bug from #2239.
  3. Relative data-start resolution in duration reportingparseCompositions at compositions.ts:58-116 now calls resolveStart(doc, el, ...), walking data-start="s1"-style references through @hyperframes/core's parseStartExpression. Uses shared startCache (memoization) + visiting set (cycle guard). Mirrors #2041's runtime resolver at startResolver.ts:120+.

Verification

  • CLI test covers the sub-composition reference chain end-to-endcompositions.test.ts:12-40 renders a host with s1.data-start="0" data-duration="3" and s2.data-start="s1" data-duration="3", asserts host.duration === 6. Load-bearing for the "relative starts affect reported duration" invariant.
  • durationToFrameCount covers both directionsprobeStage.test.ts:294-318 asserts 32.866667 → 986 (nearest, within ε) AND 32.867 → 987 (ceiling, outside ε). Boundary case covered.
  • variables threading testprobeStage.test.ts:284-292 captures the opts passed to createCaptureSession via the new capturedOptions[] array and asserts the variable map arrives intact.
  • Cache/cycle discipline in resolveStartvisiting.add before recursion, visiting.delete in a finally, startCache.set on every terminating branch. Return-0 short-circuit on cycle detection is a safe silent-fail (composition would then behave as if the ref weren't there).
  • resolveReferencedDuration fallback ordering — explicit data-duration wins; then data-end minus resolved start; then null. If a referenced element only has data-composition-src with no explicit data-duration, resolved = targetStart + offset (position without duration accumulation) — matches how the runtime resolver handles the same shape.

Adversarial pass

  • findReferenceTargetEl uses raw interpolation ([data-composition-id="${refId}"]) vs. CSS.escape(refId) in the runtime resolver at startResolver.ts:80. parseStartExpression's regex constrains refId to [A-Za-z0-9_.:-]+ — none of those need escaping inside a double-quoted attribute selector. Functionally equivalent; consistency nit not worth surfacing.
  • FRAME_BOUNDARY_EPSILON = 1e-3 — 0.001 frames × (1/30) = 33 µs at 30fps, 17 µs at 60fps. Well below any authored precision. Absolute rather than relative epsilon is right here — the target case is round-tripped decimal rendering (X/30 → 6-digit string → parseFloat → drift bounded by IEEE 754 at ~1e-14, then amplified by ×fps).
  • resolveStart recursion has no depth cap — max depth = chain length of data-start refs. For pathological content-authored chains, could stack-overflow. Not a real concern for CLI parsing local files.
  • Cache scope in parseSubComposition — separate startCache/visiting per call vs. shared with parseCompositions. Correct — different DOM instances, no reuse benefit.

Verdict framing

Small, well-scoped, all three named regressions covered by executable tests. Already stamped and green. LGTM from my side.

Review by Rames D Jusso

@miguel-heygen miguel-heygen merged commit 0b3dfb3 into main Jul 14, 2026
50 checks passed
@miguel-heygen miguel-heygen deleted the consolidate/duration-timing-correctness branch July 14, 2026 21:12
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