Skip to content

feat(core): video, audio and image length come from one shared resolver - #4166

Merged
miguel-heygen merged 1 commit into
mainfrom
feat/media-duration-resolver
Sep 19, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
feat/media-duration-resolver

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

What changes

The length of a video, audio or image on the timeline is now decided in one place, a resolver in @hyperframes/parsers, instead of by several readers with their own arithmetic.

  • An image with data-start (or data-track-index) and no data-duration now gets the same 3 second default a dropped image gets in Studio. Before, the runtime gave it no end at all. A bare <img> with neither attribute is still a static layer.
  • Video and audio take their length from the file (less the playback offset, over the playback rate). An authored data-duration or data-end trims it. While the file's length is unknown the result is "pending", never a guessed number.
  • The core runtime start resolver, clip manifest and clip tree all read length through the resolver. The playback-rate clamp, the 2x rate form and the offset fall-through now have one implementation.
  • The lint rule that asked images for data-duration is removed, since the runtime now gives them a length. The docs no longer say an image requires it.

The resolver's attribute readers, the image default and a shared fixture table are exported from @hyperframes/parsers/media-duration and @hyperframes/parsers/media-duration-fixtures. The same fixtures run through the parsers reader and the core runtime readers, so a reader that drifts fails a test.

Not in this PR: the other readers (HTML parser, compiler, producer, engine, Studio, CLI timeline, the runtime visibility end and clock in init.ts) still compute length their own way. They are listed in PENDING_MEDIA_DURATION_READERS and each follow-up PR converts one and removes its entry.

Verification

Run on a Linux box, capped at 4 workers:

  • packages/parsers: 37 files, 1028 passed.
  • packages/core/src/runtime: 57 files, 1189 passed.
  • packages/lint/src/rules: 11 files, 578 passed.
  • oxlint and oxfmt --check clean on changed files; package subpaths and package cycles verified.
  • Mutations that each turn a test red: default 3 to 4, playback-rate clamp, negative offset, the pending branch, the timed-image line in the start resolver, in the clip tree, in the clip manifest, and treating a data-track-index-only image as timed.

Before / After

No Studio or player files change, so there is nothing to capture. Behaviour change: an image timed only by data-start occupies 3 seconds in the runtime clip manifest instead of the rest of its composition.

Pre-review

An independent reviewer read the diff against the runtime and the rest of the repo. No blocking findings. Two should-fix findings are fixed: an image trimmed by data-end was not honoured in the manifest, and three call sites were not pinned by tests. Its list of readers that still disagree with the image default is the follow-up list above.

Audited Trusting Not exercised
Old and new arithmetic for rate clamp, 2x, offset fall-through, data-duration in producer and engine callers; readers of every new export; hostile inputs (metadata not loaded, non-finite duration, zero authored duration, offset past the end, track-index-only image, data-end only); no leftover references to the removed lint rule; parsers does not import core Author's Linux-box suite runs Core tsc (one existing missing generated-file error in that package); a real render of a project with an untimed image; the other readers listed above

Playback rate bound

The clip rate bound (0.1 to 10) is owned by parsers; core re-exports it and a test pins them equal. Studio's own 0.1 to 5 clip-rate clamp in timelineElementHelpers.ts is a second owner and moves onto the shared clamp in the Studio reader follow-up, not here, because a Studio change needs Before/After captures. The player's 0.1 to 5 limit is the viewer transport speed, a different invariant from a clip rate, and stays.

@mintlify

mintlify Bot commented Sep 19, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Sep 19, 2026, 10:53 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

COMMENT HOLD at 905b18e2 — centralization within #4166 verified; cross-PR clamp duplication with #4129 is the blocker to resolve first

Miguel's own dispatch names this — "the playback-rate range must have one owner shared with the speed-ramp core PR." Diff confirms.

What #4166 centralizes correctly (within itself)

  • One resolver resolveMediaDuration in packages/parsers/src/mediaDuration.ts owns the rule: authored data-duration > authored data-end - start > source length (video/audio, offset-adjusted, rate-divided) > image default (3s) > pending.
  • Core runtime is a reader: packages/core/src/runtime/playbackRate.ts now imports clampPlaybackRate, readMediaOffsetSeconds, readPlaybackRate, resolveMediaDuration, resolveNaturalDurationSeconds from @hyperframes/parsers/media-duration. normalizePlaybackRate = clampPlaybackRate (re-export). resolveMediaElementDurationSeconds and resolveNaturalMediaTimelineDuration now call through to the parsers resolver.
  • 2x rate parsing, offset fall-through (data-playback-startdata-media-start), data-duration > 0 required to be authored: all unified in the parsers reader. The same fixture table (MEDIA_DURATION_FIXTURES) runs through both the parsers reader (mediaDuration.test.ts) and the runtime readers (playbackRate.test.ts — video/audio via resolveMediaElementDurationSeconds, img via resolveTimedImageDurationSeconds), so a reader that drifts fails a test.
  • Image default (3s) exported as DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS. resolveTimedImageDurationSeconds gates on data-start/data-track-index so a bare <img> stays a static layer (test pins null).
  • Runtime write-sites all read through the resolver: clipTree.ts durationFromMedia (new branch calls resolveTimedImageDurationSeconds), startResolver.ts (media branch → resolveNaturalMediaTimelineDuration, timed-image branch → resolveTimedImageDurationSeconds), timeline.ts collectRuntimeTimelinePayload (new fallback → resolveTimedImageDurationSeconds(node, start) so a data-end trim gets honored, per new test).
  • Lint change coherent: media_missing_duration code removed from structure.ts + both test allowlists; MEDIA_TAGS = {video, audio, img} now excluded from the missing-timing check because the runtime gives all three a length. Bare <img> still not flagged.
  • Docs update coherent: docs/reference/html-schema.mdx and skills/hyperframes-core/references/data-attributes.md both drop the "required for img" language and describe the 3s image default + source/trim rule.
  • PENDING_MEDIA_DURATION_READERS lists the 9 readers that still compute length their own way, one per follow-up PR — including describeProject.ts #4138, which is the natural next convert.

The blocker: MAX_PLAYBACK_RATE duplicated across PRs with different values

#4166 in packages/parsers/src/mediaDuration.ts:

const MIN_PLAYBACK_RATE = 0.1;
const MAX_PLAYBACK_RATE = 5;

Used by clampPlaybackRatereadPlaybackRateresolveMediaDuration, and by the core normalizePlaybackRate = clampPlaybackRate re-export.

#4129 (speed-ramp core, currently APPROVED by somanshreddy at head 21241b49) in packages/core/src/runtime/playbackRateBounds.ts:

export const MIN_PLAYBACK_RATE = 0.1;
export const MAX_PLAYBACK_RATE = 10;

#4129 also explicitly widens normalizePlaybackRate from the old hardcoded Math.max(0.1, Math.min(5, raw)) to Math.max(MIN, Math.min(MAX, raw)) — i.e. [0.1, 10] — because a speed-ramp lane can reach 10x. Somu's approval body cites "rate clamped to [0.1,10] at the sink" as the invariant that keeps sourceTimeAt/timeAtSourceTime log-safe and monotonic.

#4166 is not stacked on #4129 (both baseRefName=main). So when both land:

  • If #4129 lands first: main has normalizePlaybackRate using [0.1, 10] via playbackRateBounds. #4166's diff of normalizePlaybackRate (which starts from Math.max(0.1, Math.min(5, raw))) won't apply cleanly — merge conflict. If resolved by taking #4166's version, the core normalizePlaybackRate silently narrows back to [0.1, 5], reverting #4129's deliberate widening in the media-length reader.
  • If #4166 lands first: parsers owns [0.1, 5]. Then #4129 lands its playbackRateBounds.ts [0.1, 10] and both files coexist as parallel authorities.
  • Either order: the "one owner" invariant for the playback-rate clamp is broken across the two files. A ≤5x automation rate is fine; a 6-10x rate resolves through two different clamps depending on the reader.

What "fixed" looks like (any of)

  1. #4129 imports MIN/MAX from parsers/mediaDuration, deleting playbackRateBounds.ts. Clean fit — parsers is the neutral home. Matches this PR's intent ("one resolver in parsers owns the rule"). Requires either updating #4166's MAX_PLAYBACK_RATE to 10, or #4129 rebasing onto #4166 to consume the parsers export. Since #4129 is already approved-and-blocked-on-REVIEW_REQUIRED (no, it's currently APPROVED but not yet merged), the update flows better through #4129 requesting the parsers value.
  2. #4166 imports MIN/MAX from #4129's playbackRateBounds (and updates its max to 10). Trade-off: parsers depends on core, which the PR body avoids explicitly (per the diff-of-diffs on mediaDuration.test.ts and the "parsers does not import core" audit line in the PR body).
  3. Land as a pair with one stacked on the other, so the second PR can import from the first's location.

Option 1 reads as the cleanest fit for this PR's stated intent, and Somu's approval on #4129 nailed [0.1, 10] as the correct range — so MAX_PLAYBACK_RATE should be 10 in parsers/mediaDuration.ts, not 5. Bumping it here (and letting #4129 later delete its bounds file) unblocks both.

Non-blocking observations

  • Duplication inside core: resolveMediaElementDurationSeconds (via resolveMediaDuration) and resolveNaturalMediaTimelineDuration (via resolveNaturalDurationSeconds) both compute the source-length arithmetic. Same helper on both paths (resolveNaturalDurationSeconds), so no drift risk today. Cosmetic.
  • PENDING_MEDIA_DURATION_READERS guard: mediaDuration.test.ts asserts length > 0 — appropriate ratchet-shape (only shrinks). No per-entry format guard, so follow-up PRs deleting entries could typo a remaining entry with the test still green. Cheap to add a per-entry non-empty-string assertion; nit-severity.
  • Rebase noise: PR-body says 22 files. gh pr diff returns 22 files, all packages/{core,lint,parsers} + docs/ + skills/. Matches PR body claim ("no Studio or player files change") — no cross-package leakage.

CI

Running at post time — Windows tests, regression shards, Player perf, Producer integration, Typecheck, CLI smoke still in progress. Green so far. Not the gate here — the code-level cross-PR clamp is.

Stamp mechanics

hf-oss require_last_push_approval=true. Withholding APPROVE at 905b18e2 — this is a COMMENT-state review, not a rejection of the design; the centralization is right and the blocker is exactly the one Miguel self-flagged. Once the clamp ownership is unified (any of the three options above), re-request and I'll stamp at the new head after re-verifying the reader.

— Review by tai (pr-review)

A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.

The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.

The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

APPROVING at aff1476a — clamp ownership unified in parsers; blocker from my prior COMMENT HOLD is closed

Delta from 905b18e2aff1476a is 4 ahead / 1 behind — the "behind" is the merged tip that mattered (main now includes #4163 + #4129 + #4164), and the "ahead" is the single squashed rework of this PR replayed on that new base.

The blocker fix — cleanest of my three suggested paths (parsers owns MIN/MAX; core re-exports)

packages/parsers/src/mediaDuration.ts (the neutral home):

export const MIN_PLAYBACK_RATE = 0.1;
export const MAX_PLAYBACK_RATE = 10;

packages/core/src/playbackRateBounds.ts (previously the definer, now a re-export):

export { MIN_PLAYBACK_RATE, MAX_PLAYBACK_RATE } from "@hyperframes/parsers/media-duration";

Value moved to 10 (matching Somu-approved #4129 speed-ramp invariant "rate clamped to [0.1,10] at the sink"), and there's only one definition in the tree.

Equality test guards against future drift (packages/core/src/runtime/playbackRate.test.ts):

import * as parsersBounds from "@hyperframes/parsers/media-duration";
import * as coreBounds from "../playbackRateBounds";
// …
expect([coreBounds.MIN_PLAYBACK_RATE, coreBounds.MAX_PLAYBACK_RATE]).toEqual([
  parsersBounds.MIN_PLAYBACK_RATE,
  parsersBounds.MAX_PLAYBACK_RATE,
]);

And a value-pin: expect([MIN_PLAYBACK_RATE, MAX_PLAYBACK_RATE]).toEqual([0.1, 10]). Both would fail if a future PR redefines either symbol locally or drifts the value.

Downstream centralization intact: core normalizePlaybackRate = clampPlaybackRate (parsers version), all clampPlaybackRate call sites route through parsers, speedRamp.ts lane clamping (from merged #4129) reads MIN/MAX — which now transitively resolves to parsers via the core re-export. readPlaybackRate tests pin parseFloat("50") clamps to MAX_PLAYBACK_RATE (i.e. 10), no longer to a hardcoded 5.

Rest of the surface — same centralization I verified on 905b18e2, replayed on merged main

  • resolveMediaDuration in parsers/mediaDuration.ts still owns the rule; branch order authored > default (img, 3s) > resolveNaturalDurationSeconds for video/audio > pending. Same fixture table (MEDIA_DURATION_FIXTURES) still runs through parsers + runtime readers, so any drift fails a test.
  • clampPlaybackRate used inside resolveNaturalDurationSeconds (remaining / clampPlaybackRate(playbackRate)) — one clamp, one arithmetic.
  • Runtime readers unchanged in shape: resolveMediaElementDurationSeconds (video/audio) + resolveTimedImageDurationSeconds (img gates on data-start/data-track-index) both call resolveMediaDuration. clipTree/startResolver/timeline all read via those; new timeline.ts fallback still passes start so data-end trim survives on images.
  • Lint change coherent: media_missing_duration removed from structure.ts; MEDIA_TAGS = {video, audio, img} still excluded from the missing-timing check.
  • Docs coherent: schema and skills reference reflect the 3s default + source/trim rule (unchanged from prior head).
  • PENDING_MEDIA_DURATION_READERS list unchanged — 9 pending readers, one per follow-up PR (starting with describeProject.ts in #4138).

Non-blocking observations (carried from prior hold, unchanged)

  • resolveMediaElementDurationSeconds and resolveNaturalMediaTimelineDuration both compute source-length arithmetic through the same helper (resolveNaturalDurationSeconds) — cosmetic duplication, no drift risk.
  • PENDING_MEDIA_DURATION_READERS test is length-based (> 0); no per-entry format guard. Cheap nit.

CI

Running at post — SETTLED green expected via poll.

Stamp mechanics

hf-oss require_last_push_approval=true → this APPROVE binds to aff1476a only. Prior review 5255337084 (COMMENT at 905b18e2) remains as history — its blocker is closed by this head.

Miguel miguel-heygen on trusted stamp list; stamping to merge per dispatch.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen merged commit 09aadd8 into main Sep 19, 2026
105 of 107 checks passed
@miguel-heygen
miguel-heygen deleted the feat/media-duration-resolver branch September 19, 2026 12:01
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