* fix(sdk): read a composition's variables from its root when its html has none
* fix: composition metadata and lint also read variables declared on the root
* fix(sdk): keep root declarations on the root through undo and prefer them like the runtime
* fix(sdk): read variables from both <html> and the root, editing each where declared
Rebased onto main again now that #4413 has landed. No behaviour change.
`composition.ts` applied cleanly for the second time — the negative-z rule sits
at a different insertion point from both the zoom rule and the viewport-cap
rule. The collision is the test file only: all three describe blocks append at
the same position in the outer describe. Purely additive, so the resolution
keeps all three and edits none.
Verified against 9af8ec21, the head under review, diffed against ITS OWN parent
rather than the branch's original base:
79 lines this PR added to composition.ts -> 0 absent after the rebase
81 lines this PR added to composition.test.ts -> 0 absent after the rebase
every non-blank line of main -> 0 absent after the rebase
Both files parse with zero syntax errors and are oxfmt-clean.
The message under review is untouched: the mechanism stated as fact, the hiding
conditional on an opaque root background, and the transparent-root case named
explicitly.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebased onto main now that #4412 has landed. No behaviour change.
The conflict was an append collision in both files: #4412's zoom rule and this
viewport-cap rule are appended at the same position in `compositionRules`, and
their test blocks at the same position in the outer describe. Both were purely
additive, so the resolution keeps both and changes neither.
Verified rather than asserted, against the approved head 75f9c993:
38 lines this PR added to composition.ts -> 0 absent after the rebase
35 lines this PR added to composition.test.ts -> 0 absent after the rebase
every non-blank line of main -> 0 absent after the rebase
So nothing of either side was dropped and nothing was edited; the rules only
moved. Both files parse with zero syntax errors and are oxfmt-clean.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(lint): flag a canvas-level CSS zoom that rescales a fixed frame
The capture frame is sized from the root's data-width/data-height, which are
layout pixels. CSS `zoom` on the canvas itself -- the composition root, or
html/body above it -- rescales the painted content inside that frame without
changing the frame, so the two disagree.
Measured at 0.8.71 on an 800x400 composition holding two 100x100 boxes at
left:100 and left:600, rendered to a PNG sequence with the boxes located by
colour:
root zoom:0.5 canvas stays 800x400; boxes paint at (50,25) and (300,25)
at 50x50 -- the composition occupies the top-left quarter
and the rest of every frame is dead space.
root zoom:2 canvas stays 800x400; the left:100 box paints at (200,100)
at 200x200, and the left:600 box renders ZERO PIXELS. It is
inside the declared composition and absent from the output,
and nothing in lint, check or the render log says so.
A `zoom` on a DESCENDANT is deliberately not flagged. Measured in the same
run: a child at left:100 with zoom:2 paints at (200,100) at 200x200, which is
exactly what CSS `zoom` specifies. It is honoured, not ignored, so flagging
every `zoom` would be flagging correct authoring.
The `(?<![\w-])` lookbehind on the declaration pattern is load-bearing:
`--zoom: 2` and `--panel-zoom: 0.5` are author variables, and a plain \b
boundary flags both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style(lint): format to the repo's 100-column width
`oxfmt --check` is a CI gate and these lines exceeded the repo's 100-column
printWidth. Formatting only: verified whitespace- and trailing-comma-identical
to the previous revision, with the comparison shown to detect a real change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(lint): satisfy noUncheckedIndexedAccess in the zoom rule
Two strictness failures under the package's `noUncheckedIndexedAccess` +
`strict`, both mine, both breaking `@hyperframes/core`'s build (and every job
downstream of it):
- `hits[0]` is `T | undefined`, and a preceding `hits.length === 0` check does
not narrow it. Guard on the element instead of the length.
- The local `hits` type declared `snippet: string`, stricter than
`truncateSnippet`, which returns `string | undefined` — it returns undefined
for an empty normalised input, and `Finding.snippet` is optional for exactly
that reason. Mirrored that contract rather than coercing with `?? ""`, so an
absent snippet stays absent instead of becoming "".
Reproduced both in isolation against the real signatures before fixing, and
confirmed each mutation reproduces the original error verbatim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(lint): split canvas-zoom collection by source
The `Fallow audit` gate flagged the rule's callback as critical:
`fallow/high-crap-score`, CRAP 116.3 against a threshold of 30, cyclomatic 21.
That is a fair reading — the callback collected hits from two unrelated sources
and then built the finding, all inline.
Split the collection into named functions by SOURCE (`inlineCanvasZoomHits`,
`stylesheetCanvasZoomHits`, composed by `canvasZoomHits`), leaving the callback
to resolve the first hit and word the finding. Decision points in the callback
go 21 -> 7.
Behaviour-preserving, and measured rather than asserted: both revisions were
executed against 16 inputs covering inline/<html>/<body>, stylesheet selectors,
comma lists, at-rules, comments, `!important`, class-matched roots, multiple
style blocks, and the inline-before-stylesheet precedence. Output is identical
on all 16, and the comparison was shown to detect a difference by swapping the
concatenation order, which diverges on exactly the precedence case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(lint): split the stylesheet zoom walk into selector, rule, walk
The first split took `Fallow audit` from critical (CRAP 116.3, cyclomatic 21) to
minor (37.1, cyclomatic 11) — right direction, still over the threshold of 30.
CRAP weights complexity against COVERAGE, and I cannot measure coverage on this
machine, so rather than tune to the margin this splits the stylesheet walk into
the three things it was doing: find the first canvas-targeting selector in a
comma list (`firstCanvasSelector`), turn one CSS rule into at most one hit
(`canvasZoomInRule`), and walk the rules. No function in the rule now exceeds 6
decision points, which clears the threshold at any plausible coverage rather
than at the measured one.
`firstCanvasSelector` returning the first match preserves the previous `break`
exactly: one hit per rule, not one per selector.
Behaviour re-verified against the ORIGINAL revision of this branch — identical
on all 16 inputs, with the comparison shown to detect a real difference by
dropping the `targetsCanvasRoot` guard, which diverges on two cases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since the source-location mapping landed, building the whitespace-collapsed copy of a file grew a
string with += and called endsWith on it per character. V8 flattens that string on every call, so a
1.2 MB index.html cost ~31 s of CPU in every lint run, including the one preview runs before it
listens.
* feat(core): a composition with no authored length takes it from its clips
A new resolver in @hyperframes/parsers returns a composition length as authored,
derived from its latest clip end, or unresolved, and counts clips whose length is
still pending. The runtime uses it in its two existing max-end loops with identical
results, and as a last resort only when no timeline, floor or caller length exists,
so a composition that has a length today keeps it. The derived source is published
on window.__hf.durationSource for render telemetry.
Lint reports a root that has timed clips but no data-duration as a warning that says
where to author it, and still errors when there is nothing to derive from.
* refactor(core): resolve the runtime duration floor in one call
The declared duration and sub-composition ends were resolved separately and
maxed by hand; one resolver call over both gives the same latest end.
* fix(core): a derived composition length waits for every clip length and reports honestly
Review follow-up. The runtime returns a content-derived length only when no clip is
still pending, so a renderer that reads duration once cannot lock in a short one.
window.__hf.durationSource is cleared whenever a timeline or floor supplies the length,
and the diagnostic fires only for a derived length. Lint says "at least Ns", counts
reference-start clips as known only at runtime, and ignores a data-end before data-start.
* fix(core): a sub-composition with no known length counts as pending
A host whose own timeline is not registered yet no longer lets the content-derived length
show early. Tests pin the pending host and that the diagnostic fires only for a derived length.
* fix(core): a loaded Lottie with no registered animation keeps the length pending
Lottie registers from author scripts with no DOM marker, and nothing re-derives the length
after it registers. A composition with clips and a loaded Lottie library therefore stays at
zero, as before, instead of locking in the clips length and cutting the animation short.
* test(core): a registered Lottie supplies the length and the clips length is not used
Pins the other side of the pending Lottie rule. Once an animation reports a length it enters the
duration floor, so the content-derived fallback is never consulted.
* test(core): the declared and DotLottie pending branches are covered
* fix(engine): audio source-not-found names the file and shares one src resolver with lint
* fix(engine): audio source-not-found prints the resolved path with forward slashes on Windows
* fix(parsers): the shared src resolver ignores a media fragment like the browser
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.
* feat(core): speed ramps as a rate lane in data-automation
A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.
* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests
* test(engine): read the playback-rate clamp from core instead of a literal
* feat(lint): flag nested structure, missing timing and caption tracks on the root composition
One rule set for the root composition: nested layout inside a top-level
element, a timed element or media without data-duration, captions not marked
data-track-kind=captions, and captions spread over more than one track.
Findings are errors when the host is Studio and warnings elsewhere.
Sub-composition files are left free to hold layout.
* test(lint): keep the structure rules silent on the shipped catalog and skills
Runs the rules over every registry and skills html file and asserts no
findings, and treats a bare media element with no timing as a static layer.
* fix(lint): run the structure rules as Studio in both adapters and skip non-root files
The Vite Studio adapter now lints as the studio host, and HTML the project
lint did not treat as a root (components, snippets) is linted as a
sub-composition. Style, script and template content no longer count as nested
layout, sub-composition hosts need no duration, and a caption row without a
lane is not a second track.
* fix(lint): count legacy data-end as a length and pin the catalog test to real roots
* chore: prepare v0.8.46
Draft only, not tagged: bun run release:prepare 0.8.46 --no-tag.
Changelog rewritten in plain words for the one change since v0.8.45
(#4031, adaptive per-frame motion-blur sample count). Cutting the
release is a separate step on Miguel's word.
* docs(release): fix false render-time savings claim in v0.8.46 notes
Adaptive sampling never drops below the old fixed-16 default; it only
adds cost on frames that measure real spatial motion. Per Miga's review
on #4033.
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the
false positive v0.8.43 shipped with; a top-level mismatch is still reported
and a viewport-meta-only mismatch gets its own wording (#4012)
- The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the
same arguments as the disk encoder. Off by default, so an existing render
produces an unchanged FFmpeg arg list (#3973)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(lint): guard root_dimensions_mismatch against sub-compositions
Sub-compositions mounted via data-composition-src are fetched with
DOMParser and only their matched <template>/<body> subtree is imported
into the live document. A sub-composition authored as a full standalone
HTML document still carries its own <html>/<head>/<meta viewport>, but
none of that ever reaches the rendering document, so a mismatch there was
always a false positive. Guard the rule with options.isSubComposition,
matching the sibling missing_timeline_registry rule's existing pattern.
Split the warning wording: html/body CSS actually sizes the element that
clips the root, but the CDP viewport is set from the root's own
data-width/data-height (frameCapture.ts), never from <meta viewport>. A
viewport-only mismatch is real drift but not a clipping risk.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* fix(cli,parsers): consolidate scaffold resolution regexes into one definition
applyResolutionPreset kept its own prefix-capturing regex literals for the
html/body CSS size and meta viewport content, duplicating the same three
patterns already defined in @hyperframes/parsers for lint's read-only
root_dimensions_mismatch comparison. Merge to one definition per pattern
that captures both the prefix (for in-place replace) and the digits (for
comparison), so the two callers can no longer drift on where a scaffold's
resolution lives. Existing applyResolutionPreset tests stay green.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
- Portrait compositions render the full frame when the project was
scaffolded at a different size: html/body are sized to the composition
root at runtime; new lint warning root_dimensions_mismatch (#4005,
fixes#4001)
- render_complete telemetry adds composition_element_tags,
aroll_video_count, heygen_video_count; render_error adds error_name and
failed_stage_code (#4004)
- Catalog docs "Source" accordion titles are plain strings, so docs
search no longer shows [object Object] (#4006)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
A composition whose root declares portrait data-width/data-height renders
at the correct output size, but a project scaffolded landscape (init
without --resolution) still ships html/body CSS and a viewport meta at the
old landscape size. The stale body's overflow:hidden then clips the
correctly-sized root at the old height, so everything past it renders as
page background instead of real content.
applyCompositionSizing now mirrors the SAME forced width/height it already
computes for the root onto documentElement/body, reusing rather than
recomputing them. overflow:hidden is left untouched (a deliberate
anti-white-bar guard set earlier in the same init pass): once body's own
size agrees with the root it contains, it clips nothing that matters.
Runs in both preview and render, since both load the one bundled runtime
artifact this file builds into.
Adds a warning-level root_dimensions_mismatch lint rule alongside
root_missing_dimensions, comparing the root's dims against the html/body
CSS block and the viewport meta - the two other places a scaffold's
resolution lives besides the root itself. The read-only regexes live in
@hyperframes/parsers (packages/lint cannot depend on packages/cli, which
owns the closest existing pattern in applyResolutionPreset) so a future
caller has one place to update if the scaffold's shape changes, rather
than a rule silently comparing against a guess.
Closes#4001.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>