feat(core): video, audio and image length come from one shared resolver - #4166
Conversation
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
terencecho
left a comment
There was a problem hiding this comment.
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
resolveMediaDurationinpackages/parsers/src/mediaDuration.tsowns the rule: authoreddata-duration> authoreddata-end- start > source length (video/audio, offset-adjusted, rate-divided) > image default (3s) > pending. - Core runtime is a reader:
packages/core/src/runtime/playbackRate.tsnow importsclampPlaybackRate,readMediaOffsetSeconds,readPlaybackRate,resolveMediaDuration,resolveNaturalDurationSecondsfrom@hyperframes/parsers/media-duration.normalizePlaybackRate = clampPlaybackRate(re-export).resolveMediaElementDurationSecondsandresolveNaturalMediaTimelineDurationnow call through to the parsers resolver. - 2x rate parsing, offset fall-through (
data-playback-start→data-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 viaresolveMediaElementDurationSeconds, img viaresolveTimedImageDurationSeconds), so a reader that drifts fails a test. - Image default (3s) exported as
DEFAULT_IMAGE_TIMELINE_DURATION_SECONDS.resolveTimedImageDurationSecondsgates ondata-start/data-track-indexso a bare<img>stays a static layer (test pinsnull). - Runtime write-sites all read through the resolver:
clipTree.tsdurationFromMedia(new branch callsresolveTimedImageDurationSeconds),startResolver.ts(media branch →resolveNaturalMediaTimelineDuration, timed-image branch →resolveTimedImageDurationSeconds),timeline.tscollectRuntimeTimelinePayload(new fallback →resolveTimedImageDurationSeconds(node, start)so adata-endtrim gets honored, per new test). - Lint change coherent:
media_missing_durationcode removed fromstructure.ts+ both test allowlists;MEDIA_TAGS = {video, audio, img}now excluded from themissing-timingcheck because the runtime gives all three a length. Bare<img>still not flagged. - Docs update coherent:
docs/reference/html-schema.mdxandskills/hyperframes-core/references/data-attributes.mdboth drop the "required for img" language and describe the 3s image default + source/trim rule. PENDING_MEDIA_DURATION_READERSlists the 9 readers that still compute length their own way, one per follow-up PR — includingdescribeProject.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 clampPlaybackRate → readPlaybackRate → resolveMediaDuration, 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
normalizePlaybackRateusing [0.1, 10] viaplaybackRateBounds. #4166's diff ofnormalizePlaybackRate(which starts fromMath.max(0.1, Math.min(5, raw))) won't apply cleanly — merge conflict. If resolved by taking #4166's version, the corenormalizePlaybackRatesilently 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)
- #4129 imports MIN/MAX from
parsers/mediaDuration, deletingplaybackRateBounds.ts. Clean fit — parsers is the neutral home. Matches this PR's intent ("one resolver in parsers owns the rule"). Requires either updating #4166'sMAX_PLAYBACK_RATEto 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. - #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 onmediaDuration.test.tsand the "parsers does not import core" audit line in the PR body). - 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(viaresolveMediaDuration) andresolveNaturalMediaTimelineDuration(viaresolveNaturalDurationSeconds) both compute the source-length arithmetic. Same helper on both paths (resolveNaturalDurationSeconds), so no drift risk today. Cosmetic. PENDING_MEDIA_DURATION_READERSguard:mediaDuration.test.tsassertslength > 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 diffreturns 22 files, allpackages/{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.
905b18e to
aff1476
Compare
terencecho
left a comment
There was a problem hiding this comment.
APPROVING at aff1476a — clamp ownership unified in parsers; blocker from my prior COMMENT HOLD is closed
Delta from 905b18e2 → aff1476a 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
resolveMediaDurationinparsers/mediaDuration.tsstill owns the rule; branch order authored > default (img, 3s) >resolveNaturalDurationSecondsfor video/audio > pending. Same fixture table (MEDIA_DURATION_FIXTURES) still runs through parsers + runtime readers, so any drift fails a test.clampPlaybackRateused insideresolveNaturalDurationSeconds(remaining / clampPlaybackRate(playbackRate)) — one clamp, one arithmetic.- Runtime readers unchanged in shape:
resolveMediaElementDurationSeconds(video/audio) +resolveTimedImageDurationSeconds(img gates ondata-start/data-track-index) both callresolveMediaDuration.clipTree/startResolver/timelineall read via those; newtimeline.tsfallback still passesstartsodata-endtrim survives on images. - Lint change coherent:
media_missing_durationremoved fromstructure.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_READERSlist unchanged — 9 pending readers, one per follow-up PR (starting withdescribeProject.tsin #4138).
Non-blocking observations (carried from prior hold, unchanged)
resolveMediaElementDurationSecondsandresolveNaturalMediaTimelineDurationboth compute source-length arithmetic through the same helper (resolveNaturalDurationSeconds) — cosmetic duplication, no drift risk.PENDING_MEDIA_DURATION_READERStest 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)
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.data-start(ordata-track-index) and nodata-durationnow 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.data-durationordata-endtrims it. While the file's length is unknown the result is "pending", never a guessed number.2xrate form and the offset fall-through now have one implementation.data-durationis 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-durationand@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 inPENDING_MEDIA_DURATION_READERSand 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.oxlintandoxfmt --checkclean on changed files; package subpaths and package cycles verified.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-startoccupies 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-endwas 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.2x, offset fall-through,data-durationin 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-endonly); no leftover references to the removed lint rule; parsers does not import coretsc(one existing missing generated-file error in that package); a real render of a project with an untimed image; the other readers listed abovePlayback 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.tsis 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.