Commit Graph
111 Commits
Author SHA1 Message Date
Miguel Ángel 5658f70065 fix(core): color grading applies to canvas compositions (#5860)
* fix(core): color grading applies to canvas compositions

data-color-grading on a <canvas> was ignored by the runtime and refused by
lint, the Studio panel and the CLI writer. One source-tag list in the grading
contract now feeds all of them. A canvas is sized from its drawing buffer and
re-uploaded on every seek and transport tick.

* fix(core): keep the comment share of grading files at the base
2026-10-19 00:38:53 +00:00
Miguel Ángel d1123299f8 fix(lint): back-to-back audio clips no longer count as overlapping after float rounding (#5837)
duplicate_audio_track compared raw float ends, so 21.03 + 0.1 (21.130000000000003)
overlapped a clip starting at 21.13. The one-instant span helpers move from core's
clipFacts to parsers, which lint and core both depend on; core re-exports them and
the rule uses spansOverlap.
2026-10-18 22:15:23 +00:00
Miguel Ángel e31838159d fix(cli): agents can apply a color correction from media-treatment's capabilities (#5850)
The capability catalog listed correction controls as "adjustments" and
finishing controls as "finishing", while the grading payload calls them
"adjust" and "details", and no capability showed a payload. Agents wrapped a
correction in "adjustments" and the validator rejected it with a hint that
named no key. The schema now owns a correction example; the catalog names its
sections after the payload keys and carries that example, correction and
finishing show an apply payload from it, the unknown-key hint lists the real
top-level keys, and a test validates every grading payload in docs and skills.
2026-10-18 16:22:26 +00:00
Miguel Ángel ab2e939558 fix(cli): audio and timeline find media the way preview and render load it (#5846)
audio and timeline joined a clip's src to its composition's folder by hand,
so a project-root asset used from a nested scene, or a percent-encoded file
name, was "not found" although preview, check and render load it.
resolveAuthoredAssetFile now owns that lookup (the sub-composition rewrite,
then the decoded project-root resolve), and audio, timeline and
media-treatment all use it.
2026-10-18 15:01:14 +00:00
Miguel Ángel 09c0307c3f feat(producer): render at 720p and 480p from a larger composition (#5724)
## What a user can do now

Render a video at 720p or 480p: `hyperframes render --resolution 720p` (or `480p`, or the presets `landscape-720p`, `portrait-480p`, `square-720p` and so on) gives a 1280×720 or 854×480 file from a 1080p composition, and the same presets work through the studio-server render route. Before, any preset smaller than the composition was refused ("Downsampling via --resolution is not supported").

## How

- `parsers/src/types.ts`: six presets, `{landscape,portrait,square}-{720p,480p}`, and the `720p` / `480p` aliases (aspect-agnostic, like `1080p` and `4k`). 480p at 16:9 is 854×480: 480 × 16/9 is 853.33, and 4:2:0 encoders need even sides.
- `checkOutputResolutionCompatibility` now allows a preset smaller than the composition. A smaller preset may be off by under one pixel on its long side (the 854); a larger one must still match exactly and be a whole multiple, as before. The aspect remap that `--resolution 1080p` / `4k` apply on their own (`suggestMatchingPreset`, used by the CLI preflight and the compile stage) returns only a preset in the asked tier, never another: with the looser match for smaller presets, a 1922×1080 composition asked for `4k` would otherwise have been remapped to 480p. It is refused instead, as before.
- `resolveDeviceScaleFactor` returns the long side's ratio, so a smaller preset renders at a device scale factor under 1: Chrome lays the page out at composition size and rasterizes each frame straight at 1280×720 or 854×480, the way 4K already renders at 2. For 480p that is 854/1920: the width lands on 854 exactly and the height's 480.375 rounds to 480, so no pad column is added. Output sizes round as Chrome rounds the screenshot.
- The streaming encoder was given the composition's size instead of the output size on both streaming paths (`renderOrchestrator.ts`). 4K never noticed, as both sizes are even; at 480p the encoder skipped its even-size pad and libx264 refused an odd frame (`width not divisible by 2 (853x480)`). It now gets the output size, as the disk-frame encode already did.
- The paths that refused DPR > 1 (BeginFrame, drawElement, the Linux fast-capture gate) now refuse any DPR other than 1, since none of them honor a scale; scaled renders take the screenshot path, as 4K does.
- `render/outputScaleRoute.ts`: the layered compositor (HDR content, and shader transitions in webm and mov) sizes its layers and encoder from the composition and never scales, so a scaled output on that route now stops with a named error, checked where every route and replan is settled (`syncCapturePlan`, beside motion blur's route check). Before, a 1080p request from a 4K composition with shader transitions as mov would have encoded 3840×2160. HDR with `--resolution` was already refused in preflight; 4K on this route had the same gap on main and gets the same error.

## What I measured

- Real renders from source on a Linux box (`bun src/cli.ts render registry/examples/nyt-graph --resolution 720p|480p|1080p`), checked with ffprobe: 720p `h264, 1280x720, yuv420p, 15.0 s`; 480p `h264, 854x480, yuv420p, 15.0 s`; 1080p unchanged at 1920x1080. A frame of each, looked at: same chart and layout, crisp text, and the 480p right-edge column is the page's own color (250,248,242), not a pad.
- Before the encoder-size fix the 480p render failed with `[libx264] width not divisible by 2 (853x480)`; after it, it renders.
- `outputScaleRoute.test.ts`: the real route choice for a 4K composition with shader transitions as mov (`outputSupportsPageSideShaderCompositing`, `shouldUseLayeredComposite`, `createCapturePlan`, `resolveDeviceScaleFactor`) lands on `hdr_layered` at scale 0.5 and is refused. CLI preflight and the compile stage both refuse `4k` on a 1922×1080 composition.
- fails without the fix: without the same-tier rule, the preflight and compile tests for 1922×1080 at `4k` pass the render through at 854×480; without the route check, the mov test does not throw.
- At d33c4458: producer `outputScaleRoute` + `compileStage` 18/18 three runs in a row, `shared` (bun) 9/9, cli `render` 104/104 once and its new test three runs in a row; tsc clean in producer and cli. Earlier at e72f6507: parsers `outputResolutionCompatibility` 20/20, core `index` 26/26, producer `resolveDeviceScaleFactor` 14/14, cli `render` + `parseOutputResolution` 122/122, studio-server `render` 53/53. The new cases fail on main (the presets don't exist; smaller presets are refused). tsc clean in parsers, engine, producer and cli; oxlint and oxfmt clean.

## What I did NOT exercise

- Distributed (Lambda / Cloud Run) renders at 720p/480p: the chunk worker's sizes round the same way, but I did not run a distributed render.
- Studio's render queue keeps its own copy of the rule (`RenderQueue.tsx` `scaleApplies`) and offers no 720p/480p option; unchanged here.
2026-10-16 20:58:15 -07:00
Xuanru Li 45825faa5d fix(inspector): grep timeline windows, continuation, candidates and timeline text from the Canary dogfood (#5595)
* fix(inspector): report nested hosts' authored window, one location per source line

The runtime strips data-duration/data-end from nested composition hosts after
boot and keeps them as data-hf-authored-*. Timeline inspection built its
resolver without authored attrs, so a host with data-duration="4" reported
its GSAP timeline length (3.72s) instead.

Timeline text and events[].sources listed the same file:line once per mapped
snippet (creation, caller, dependency), so a one-line script printed one
location up to nine times.

* refactor(inspector): carry the dedupe reason in the helper's name

* fix(inspector): end continuation on a page that fits no snippet; fill budget with candidates

* fix(inspector): timeline groups by component, names attr values, eases, film end

* fix(studio-server): stamp hf ids into a file without reformatting it

* fix(inspector): find an element's tag before its served hf id reaches the source

* fix(inspector): live element map shows authored classes and text, not the playhead's

* fix(inspector): collapse script-built text so the element map ignores the playhead

Class names stay in source order, a runtime-stripped id is shown as id="…"
rather than a selector, and a grouped row names which children it covers.

* fix(inspector): keep paging, stamping, and the live map still on the reported edges

A snippet larger than the page ended the walk, so later snippets could not
be reached. Raw text ended at a longer tag name, which could stamp an id
inside a script. An empty or mixed source element showed live text, and an
unknown duration read as a film that ends at 0s.

* fix(inspector): mint ids for an empty hf id and keep an unquoted class

An empty data-hf-id was treated as already assigned, so stamping left it
blank while the older stamper minted one. An unquoted class was read as no
class, so the live map dropped it.

* fix(inspector): narrow markup before reading a child tag

skipMarkup returns a boolean, so the build still sees Markup where a tag
is required. Narrow on the kind first so the inspector typechecks.

* fix: quiet check false positives and name the ancestor of an id-less target

Endpoint contact, disjoint fromTo pairs, and a negative z-index under a
stacking-context parent were warnings with bad advice. Layout and contrast
blamed a dot grid, a circular avatar's panel, and a visibility override.
A faint texture veil is no longer the click target.

* fix(lint): drop a history sentence and six gsap comments

The comments check treats "load-bearing" as history, and gsap.ts added six
comment lines. The names already carry what those lines said.

* fix(parsers): ignore an empty id written inside another attribute

The empty-id scan matched the first data-hf-id="" in the tag, including one
inside a quoted title. It now skips quoted text, so the real attribute is minted.

* fix(lint): keep real covers and a negative z-index under a card

A gradient scale-down treated every fill like a radial dot, so a panel
that starts transparent stopped counting as a cover. A stacking context
on any ancestor cleared a negative z-index, and a faint wrapper made
images inside it unpickable.

* fix: keep an emoji from shifting a stamped id

The quote mask replaced one emoji with one space, so the id landed before
the closing quote. A radial cover that turns opaque after a clear stop
stays fully painted.

* fix(lint): score a letter-only dot as a dot, not a color name

namesAColor saw #fff and rgba as words, so those dot grids counted as a
full cover. Hex and rgb colors are stripped before that word check.
2026-10-14 09:16:20 +00:00
Xuanru Li 8e4de38be1 fix(keyframes): report unresolved GSAP timing instead of guessing rest poses (#5526)
* fix(keyframes): report unresolved GSAP timing instead of guessing rest poses

* fix(keyframes): drop later times when a delay cannot be resolved

Boolean(null) treated an unreadable delay as no delay,
so the next cursor-relative tween kept a guessed start.

* fix(keyframes): keep a staggered rest pose in Studio

The parser stops are what Studio holds, so a staggered from keeps its rest
pose there. The CLI reports that pose as unresolved, and an unread position
no longer seeds the next tween at zero.
2026-10-13 04:52:22 +00:00
Miguel Ángel 64c47cf3d1 fix(parsers): find ffmpeg installed after a long-running preview started (#5439)
* fix(parsers): find ffmpeg installed after a long-running preview started

* fix(parsers): remember a missing ffmpeg for a few seconds so per-file probes stay cheap
2026-10-11 00:50:24 +00:00
Miguel Ángel 748cda9069 fix(parsers): find FFmpeg and FFprobe installed by the project's npm installer packages (#5434)
* fix(parsers): find FFmpeg and FFprobe installed by the project's npm installer packages

* fix(parsers): the installer FFmpeg lookup skips non-executable files and works with pnpm

* fix(parsers): a system FFmpeg in a common install dir wins over the npm installer package
2026-10-10 23:40:46 +00:00
Miguel Ángel 9825614d91 fix(studio): Studio's own text reads without em or en dashes (#5414)
* fix(studio): text in Studio reads without em or en dashes

* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable

* test(studio): the dash check test covers every entity form

* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too

* fix(producer): the gate check stops early on the engine's new fallback wording
2026-10-10 19:08:30 +00:00
Miguel Ángel 5ec2dd2e12 fix(lint): check passes valid sub-compositions and scene-relative asset paths (#5337)
* fix(lint): check passes valid sub-compositions and scene-relative asset paths

* fix(lint): keep the browser lint build free of node modules

* refactor(lint): tidy sub-composition lint helpers and guard bare node imports

* fix(lint): resolve stylesheet asset paths from the stylesheet's own folder

* fix(lint): catch every written form of a path that climbs out of the project

* fix(parsers): sub-composition asset paths written with backslashes load in preview

* fix(parsers): an encoded backslash stays in the file name and a leading backslash stays absolute

* fix(lint): an encoded %2f counts as a separator when checking for a climb out of the project
2026-10-09 19:54:58 +00:00
Abdullah M. Nazif 00729a566d fix(slideshow): validate nested hotspot entries (#5211) 2026-10-08 20:20:56 +00:00
Abdullah M. Nazif ea033bcd5a fix(sdk): preserve fragments with inert document markers (#5219) 2026-10-08 19:58:54 +00:00
Miguel Ángel 621ddd4693 fix(studio): stop file names with %, apostrophes or $ from breaking Studio (#5156)
* fix(studio): serve files whose name has a bare percent sign

A browser sends src="100%.png" with the % unescaped, and decoding the whole path threw, so Studio answered 500. Only well-formed escapes are decoded now.

* fix(cli): serve files with a bare percent sign from the check and snapshot server

One shared decode rule for request paths; the image thumbnail route's malformed-escape 400 could no longer fire, so a name that is not a file is a 404.

* fix(cli): name a failed file with a bare percent sign in check

* fix(studio): keep apostrophes and dollar signs in file names when editing attributes

An attribute value is matched by its own quote kind, so src="it's.png" is replaced instead of duplicated, and every insert uses a function replacer, so $1 or $& in a file name is written as is.

* fix(studio): rename references spelled with %20 or &amp; and read quoted media sources

WIP: not yet tested; see the resume note.

* fix(studio): match whole attribute names when editing media sources

* fix(studio): compare decoded names and escape replacement references

* fix(studio): preserve file references across URL and string consumers

* docs: clarify raw file references and URL fields

* fix(studio): keep raw path identity distinct from URL spelling

* fix: share attribute grammar and decode media URLs at their readers

* fix: preserve local filename identity in HTML URL output

* fix: decode entity references before rename candidate matching

* style: format rename entity handling and regressions

* fix: preserve platform file identity when renaming references

* fix(core): decode CSS escapes before rebasing asset URLs

* fix(core): consume CSS continuations before URL encoding

* fix(core): preserve CSS URL identity and escape rebased suffixes

* fix(core): express CSS suffix controls with Unicode categories

* fix(core): preserve URL backslash identity while rebasing

* test(core): respect platform backslash filename semantics

* fix(studio): a dropped file with %, # or ? in its name loads, and an encoded reference renames

* fix(studio): read an authored src as a URL so dropped files with spaces, %, # or ? load

* fix(studio): media routes find files named with ? or #, and the probe reads a src as a URL

* fix(studio-server): one rule for request file paths, so loudness finds files named with %, # or ?

* chore: drop the design note from the change

* fix(core): rename replaced clips whose src is URL-encoded
2026-10-08 16:37:49 +00:00
Miguel Ángel dc244d765c fix(studio): drags after a resize, undo or reload land where they were pressed (#5088)
* test(studio): bench moves pressed right after Add keyframe

* fix(studio): an undo that reloads the preview keeps the selection, so a drag after it moves

* fix(studio): a drag started while a resize saves keeps its animations paused

* fix(parsers): an edit pins no position on a keyframed tween it did not touch

* fix(studio): a move pressed while the preview changes lands on the new preview, at the time pressed

* test(studio): bench waiting presses, a held box resized twice, and reloads at the last playhead

* refactor(studio): tidy the waiting press, bound its wait, and freeze edit stamps after early exits

* test(studio): load the property panel once so its first test stops timing out

* fix(studio): presses queued on a held box keep their release, hover and newest outline

* test(studio): judge bench undo by the history groups Studio claims, not by file writes

* refactor(studio): one waiting-box lookup in the bench and one raf-pause write per release

Behaviour-identical tidy of the waiting-press work: the bench reads the waiting
box through one helper, releaseActiveGesture sets the raf pause once, clearPointerState
writes the false it always computed, and previewGestureStarts calls the opts setters
directly instead of through aliases named like the gesture hook's gated setters.

* fix(studio): a queued press lands on the element it pressed and survives a window switch

* fix(studio): undo cancels a waiting press, a stuck save stops holding presses, scene swaps hold them

* refactor(studio): keep the complexity waiver on clearPointerState itself

* fix(studio): undo of a queued press leaves nothing waiting, and hosts keep their own undo

* fix(studio): a waiting press keeps the box still after a blur, and undo reports what it took back

* style(studio): bring the PR's comments within the comment limits

* fix(studio): bank the four bench cases this fixes and meet the comment, complexity and duplication gates

* test(studio): pin that a waiting press is never a pending edit, and name the new duplication waivers
2026-10-06 20:22:37 +00:00
Miguel Ángel 3c40f37938 test: pin the guards that keep script-placed scenes and unparseable scripts out of the in-place move (#5079)
* test: pin the guards that keep script-placed scenes and unparseable scripts out of the in-place move

* test: make the script-placed scene check able to fail and pin its page order
2026-10-05 21:54:47 +00:00
Miguel Ángel 0d5e395cd3 fix(studio): keyframed animations keep their ease through every Studio edit (#5051)
* fix(parsers): dragging a keyframe keeps the keyframes' ease

* fix(parsers): splitting a keyframed tween into groups keeps its eases

* fix(studio): keyframed tweens keep their eases when Studio rewrites them

Extending a keyframed tween to the playhead and merging a gesture recording into it now carry the tween's eases and each keyframe's own ease. The parsers no longer report a tween-level easeEach, which GSAP ignores, as the keyframes' ease.

* fix(studio): a gesture merged into a tween writes rounded keyframe percentages

* fix(studio): a gesture merged into an eased tween plays at the times it was recorded

Merged keyframes are placed through the tween's run ease, as the extend path already does, and recorded constant-speed segments stay linear.

* fix(studio): the ease card describes a keyframed tween by its keyframes and eases

The summary line read 'no properties yet using a none curve' for every keyframed tween. It now names the keyframed properties, the keyframe count, the segment ease and the run ease that GSAP plays.

* fix(studio): a gesture merged over a strong ease-out keeps its keyframes and the ones before it

The merge keeps or replaces old keyframes by when they play, and rounds percentages only when the run ease does not warp time.

* fix(studio): the ease card labels an unset segment with the ease GSAP plays

* fix(studio): the ease card header shows the segment ease its rows play

The header shows the one ease every segment plays, or Mixed, and a run ease other than none gets its own line.

* fix(parsers): keyframe percentages are written as decimal keys the parsers read back

A percentage below 1e-6 printed as '1.28e-7%', which both parsers reject. One plainPercentKey owner now writes every percentage key in both writers and the unroller.

* fix(studio): the ease card's segment rows show rounded percentages
2026-10-05 19:18:37 +00:00
Miguel Ángel 5d19e23cb4 fix(core): a sub-composition follows its host clip when the host moves without a reload (#5058)
* fix(core): a sub-composition follows its host clip when the host moves without a reload

* perf(studio): move live tweens in place for a sub-composition host's clip too

* fix(core): keep sub-compositions nested where a fresh load nests them across rebinds and re-runs

* fix(studio): match the preview's re-printed script by its tokens and check every other timeline

* fix: compare preview scripts by syntax tree and keep scenes a re-run script places

* fix(parsers): keep a tagged template's raw text and a __proto__ key in the script compare

* fix(parsers): keep only a tagged template's raw text in the script compare

* perf(studio): read each preview script once across repeat timeline drags

* style(studio): format the preview script read test

* perf(studio): keep only script shapes and refresh them on reuse
2026-10-05 16:20:35 +00:00
Miguel Ángel 5c7f11ab6d fix(studio): plain elements keep their place and size when keyframed (#5045)
* test(studio): bench a plain element animated through auto-record and Add keyframe

* fix(studio): seed a first tween from the offset a plain move left in CSS translate

* test(studio): bench resize then drag on a GSAP tween, root and nested

* fix(studio): a resize under auto-record keys size at the playhead, held at all times

* fix(studio): keyframe tweens Studio writes end on their last key, so a seek matches playback

* test(studio): keep the plain-to-keyframes moves inside the nested frame

* fix(studio): a resize moves its anchor from the drag stamp it read at release

* fix(studio): an undo shown in place is no longer replaced by an older reload

* refactor: split the trailing keyframe trim and drop an unused GSAP reader

* test(studio): settle the resize-then-drag bench steps so a two-save resize is one undo

* fix(studio): a preview reload waits for an undo shown before its write lands

* fix(studio): Add keyframe reads a subtracted calc() translate with its sign

* refactor: tidy the keyframe hold, trim and resize code after review

* fix(parsers): the keyframe trim leaves looping, chained and relatively placed tweens alone

* fix(parsers): a size hold pins only a lone key, so an edit never changes frames left alone

* fix(studio): every undo applied in place counts as a preview change, script-only ones too

* fix(studio): a resize keys size only when a move or size is keyframed, not a fade

* test(studio): a bench step whose save never lands fails the case

* fix(parsers): holds and trims change only the tweens an edit touches

* test(studio): bank the plain-to-keyframes and resize-then-drag bench cases
2026-10-05 15:46:50 +00:00
Miguel Ángel 8c7672f105 fix(parsers): splitting a clip keeps keyframes the parser cannot read (#5044)
* fix(parsers): splitting a clip keeps keyframes the parser cannot read

* refactor(parsers): keyframed split branch in its own function

* fix(parsers): no writer rewrites keyframes it cannot read; a split reports an unknown start

* fix(parsers): recast property edits keep keyframes they cannot read

* fix(parsers): keyframe edits refuse unreadable object keyframes at the one locator

* style(parsers): shorten the unroll safety comment

* fix(parsers): a split never invents a start value from unreadable keyframes

* fix(parsers): object keyframe steps that are named, spread or number-keyed are unreadable

* fix(parsers): array steps, getters, stray channels and unresolved array values read as unreadable keyframes
2026-10-05 07:36:55 +00:00
Miguel Ángel b3d2b82fe9 feat(core): the renderer's font pipeline is importable from @hyperframes/core/fonts/embed (#5041)
* feat(core): the renderer's font pipeline is importable from @hyperframes/core/fonts/embed

* style(core): format the font pipeline and state the bundled-subset invariant

* test(producer): repin the test lane map after the font tests moved to core

* fix(core): images build fonts once, CLI bundles one pipeline, font tests pass on Windows

* test(core): font retry tests get a budget no slow machine can exhaust

* test(core): the Retry-After budget test fails when the budget is ignored
2026-10-05 04:54:53 +00:00
Miguel Ángel db12b3e022 fix(studio): place GSAP step lists and duration-less tweens where GSAP plays them (#5024)
* fix(studio): place GSAP step lists and duration-less tweens where GSAP plays them

* refactor(studio): drop narrated comments and unnest the extend clamp

* fix(studio): match keyframes in time, resolve partial step timing, start readout from prior tween

* fix(studio): time step-list keyframes from the parts' last end and format

* refactor(studio): trim comments to the package's comment share

* fix(parsers): apply timeline defaults as GSAP does to step lists and motion paths

* fix(studio): keep GSAP's ease for motion paths with keyframes and read finished tweens

* refactor(studio): read a live motion path in its own step

* refactor(parsers): leave step-list timing to its one owner and skip timeline defaults there

* refactor(studio): move the live motion-path read into its own module

* test(parsers): pin an unresolved length for a step list stretched by a variable duration

* fix(studio): time keyframes linearly under an outer ease that overshoots or steps

* fix(studio): time a converted expression-duration tween from its live tween, and pin no rewrite
2026-10-05 02:44:49 +00:00
Miguel Ángel cd82ce044d perf(studio): drag and resize any number of selected clips, saved in one write per file (#4937)
* feat(studio): hand-move and hand-resize any number of selected clips

* test(studio): bench dragging and resizing every clip, frame cost and drop time

* test(studio): give the bench browser launch the run timeout

* test(studio): bench request timeline offsets

* fix(studio): joining or leaving a multi-clip drag restyles a clip instead of remounting it

* fix(studio): restore the linked-selection import and mark the drag bench complex

* test(studio): find a clip's track row through the per-clip wrapper

* test(studio): resolve track rows through the per-clip wrapper in the remaining cases

* perf(studio): write a group edit's GSAP changes as one request per file

* test(studio): cover the batched GSAP write and keep the owned-chain cases

* test(studio): bench profile of the drag's pointer moves

* perf(studio): sync a group edit's root duration once per file, not once per clip

* feat(studio): a timeline move registers a still-saving revert so Cmd+Z right after it need not wait

* test(studio): the reverted move stays reverted when its save lands

* test(studio): bench prints the heaviest call chain on request

* perf(studio): preflight a many-clip selection against one index of the live tweens

* test(studio): share one timeline across the indexed lookups

* test(studio): bench failure output skips resource 404 noise

* fix(studio): the live-tween index skips timeline entries that are not objects

* fix(studio): the live-tween index skips a null timeline entry

* test(studio): bench reports the first drag frame apart from the steady ones

* test(studio): bench lists inclusive time per function on request

* test(studio): bench reports when the last write of an action landed

* test(studio): bench reports medians of save, frame and first-frame times

* refactor(studio): move the live-tween index out of gsapRuntimeKeyframes to keep it under the line cap

* fix(studio): wire the live-tween index imports and drop an unused test binding

* style(studio): format the files CI flagged

* fix(studio): keep the bench and the scale helper out of fallow's findings

* refactor(studio): one exact tween-index lookup, finish a batch file once, reuse the duration sync

* fix(studio): the tween index lists a tween once per key

* fix(studio): restore the batch-finish and named-parameter helpers

* fix(studio): undo reverts an edit still saving before painting an older predicted step

* style(studio): format the batch duration sync

* chore(studio): keep the new comments within the block and share limits

* test(studio): settle the pending edit even when the undo-order test fails

* perf(studio-server): retime every clip in a GSAP batch with one parse and one hold sync

* test(studio-server): a batch of clip retimes writes what single requests in turn write

* style: format the retime fold and its tests

* perf(parsers): clip matchers in one retime share a DOM query cache

* perf(parsers): look up every clip id in one DOM walk when retiming

* perf(studio): re-resolving the same selected node keeps its source check

* perf(studio): a drop re-renders only the selection that actually changed

* test(studio): report main-thread task time of a bulk drop

* perf(studio): a group drop updates the timeline store once, not once per clip

* fix(studio): re-reading index.html keeps sub-composition keyframes

* refactor(studio): one owner for clip-retime no-op rules and selection options

* fix(studio): reuse a source check only for the same target, and re-check on a new cache version

* fix(studio): undo confirms a reverted save only once its history claim counted

* refactor: split a retime's application out of the one-parse fold; share test fixtures

* perf(studio): drop the per-clip wrapper node so timeline scrolling stays within budget

* refactor(studio): name the drag passenger style after the passenger it styles

* fix(studio): undo keeps a move undone when its history claim landed before the key

* fix(studio): undo a timeline move and its tween shift in one step

* refactor(studio): one owner of a timeline gesture's undo window

* refactor(studio): move single-clip move and resize into their own hook

* fix(studio): a reload or second tab never joins another page's undo step

* fix(studio): a delete and its ripple stay one undo step however late the ripple lands

* fix(studio-server): date a held undo step by its last write, not by the edit that ends it
2026-10-05 01:14:24 +00:00
Miguel Ángel cd0d04e4e9 fix(studio): dragging a keyframe's path node keeps the other keyframes where they were (#4946) 2026-10-04 23:54:27 +00:00
Miguel Ángel 7faf2cc6c3 fix(studio): dragging a keyframed layer from its middle moves the layer, not a path node (#4941)
* fix(studio): a move on a keyframe of an eased tween changes that keyframe in place

GSAP eases a keyframe run as a whole with keyframes.ease || ease. The playhead writer
now edits an existing keyframe under that ease and writes the ease back as the
tween's own; adding a keyframe in eased form stays refused, since the ease moves
where its time lands.

* fix(studio): dragging a keyframed layer from its middle moves the layer, not a path node

The motion path drew an axis the tween does not animate at 0, though GSAP folds the
layer's CSS translate into it, so each node sat beside the layer and its grab ring took
presses aimed at the layer. The path now uses GSAP's live position, the same base the
layer drag reads. A press on the node GSAP renders the layer at, or inside the layer's
box but off another node's drawn dot, starts the layer's own move and write.

* test(studio): stub elementsFromPoint, which happy-dom does not have

* refactor(studio): name the press check for what it answers, and format

* chore(studio): cut the helper comments to the package comment share

* fix(studio): a node press goes to the layer only when the layer takes it

The hand-off to the layer's box falls back to the node drag when the box starts no
gesture or takes no pointer, and a selected node's larger dot stays the node's.

* chore(studio): name the keyframe run ease instead of commenting it

* refactor(studio): share the keyframe-at-percentage check in the playhead writer

* fix(parsers): a keyframed tween keeps GSAP's ease, never a default or a quoted variable

GSAP never gives a tween with keyframes its timeline's default ease, so the parser
no longer does. A keyframes-level ease or easeEach the file names by variable is kept
as that variable, and every ease the writer emits goes through valueToCode, so
ease: E is written back as E instead of the string "__raw:E".

* fix(studio): edit an eased keyframe run only at its ends, and a refused press is not a node drag

GSAP renders a run eased as a whole at ease(t), so only its 0% and 100% keyframes sit
at their clock time; the writer refuses the others as before. A layer press the box
answers with its blocked notice counts as handled, so the node does not drag after it.

* fix(studio): a layer press refused at setup leaves the box live, and variable eases stay pinned

startGesture paused the box's position updates and playback before the drag member
setup could refuse; a press handed off from a path node then left the box frozen, since
its release lands outside the box. It now pauses only once the setup succeeds. Tests pin
that adding, converting and unrolling write an ease the file names by variable as that variable.

* fix(studio): pause playback before drag setup and pin variable eases on convert and unroll

* fix(studio): pause playback before a nudge snapshots timelines, and pin the order

* test(studio): a failed nudge pause test cleans up, and a nudge with nothing to move leaves playback alone
2026-10-04 10:44:48 +00:00
Miguel Ángel b71aa9338b fix(parsers): a keyframe edit no longer overrides an earlier tween at the start (#4935)
* fix(parsers): a keyframe edit no longer overrides an earlier tween at the start

Writing a keyframe into a tween that starts later added a t=0 hold for every position prop of
its first keyframe, so a from() that already moves x before it lost its start value. The hold
now skips props an earlier tween on the same target writes; both writers share one helper.

* fix(parsers): keep the hold for label-placed tweens and re-sync one-line scripts stably

The recast parser leaves a label start unresolved; an unknown start no longer counts as earlier,
so the hold stays. The acorn insert now ends the hold on its own line when the next statement
shares the timeline line, matching what removal takes, so a second sync changes no bytes.

* fix(parsers): the recast hold sync reads tween starts from the acorn parse

The recast parser cannot resolve label positions, so an earlier label-placed tween looked
unordered and kept a hold it should not. Both writers now decide holds on the same parse.

* revert(parsers): keep the recast hold sync on its own parse

Deciding recast holds on the acorn parse lost selectors only recast resolves (an element bound
through a helper). Recast is the migration fallback and keeps its old hold for a start it cannot
resolve; the earlier-label case is decided by the default acorn writer, which resolves labels.

* style(parsers): name the hold insert's line ending instead of commenting it

* style(parsers): fit the hold helper's doc in four lines

* test(studio): bank the from/to edits this fix makes accurate and the resizes #4920 fixed
2026-10-03 15:55:35 -07:00
Miguel Ángel 7dea1e581f fix(studio): playhead keyframe edits keep their ease, undo names its edit and never waits on videos (#4889)
* fix(studio): an edit on a GSAP-animated layer writes its value at the playhead into the file's tween

* fix(studio): undo pressed while an edit saves undoes that edit, not one started after the key

* fix(studio): an edit stays saved when the history reply after it cannot be read

* feat(studio): export ColorField and GradientField; a session style commit takes a map

* refactor(studio): name the map form instead of commenting it

* feat(studio): a host can move layers by exact film px, one undo per call

handleDomGroupMoveBy(moves) on useDomEditSession and DomEditValue moves each
selection by its delta in composition px, for Desktop's align and distribute.

It reuses the group drag's own path: a drag member per element (press-time
route, measured offset map), the delta carried to screen px through the
preview frames, then handleGsapAwareGroupPathOffsetCommit, so one shared
coalesce key gives one undo entry and GSAP members take the GSAP write.
A member that can't move refuses the call before anything is written; the
refusal rejects with its reason and shows no Studio toast (the group commit
takes refusalToast: false; telemetry stays). Zero deltas are skipped.

Tests: a pre-offset element and one under a half-scale parent move by exactly
{10,0} and {0,-5} under one group key; a half-scale parent with a 30 deg
rotated ancestor lands within the 0.001 px a translate is written with; a
GSAP member goes through the GSAP write; three refusal kinds write nothing,
leave the boxes where they were, show no toast and leave no gesture mark,
while a canvas group drag still toasts; the session hands the call to the
group commit with refusalToast: false. Each mutation (no frame mapping, no
zero skip, no restore, no teardown, no capability check, toast on refusal)
fails at least one of them.

* fix(studio): an outside change during a drag waits for the drop to show

While a gesture mark is live in the preview, refreshPlayer takes the shadow load instead of
swapping scenes in place. A swap replaced the node under the pointer mid-drag; the shadow load
is then held by the existing gesture hold and, if the drop saved, reloaded fresh by the stale
rule, so this adds no second "released" signal.

Test: an outside change during a live drag on a preview that can swap scenes leaves the dragged
node in place, shows nothing under the pointer, and after the drop loads fresh with the dropped
position. It fails on the parent without the condition ("swapped under the pointer").

Cost case, measured in a real headless preview on the edit-accuracy bench's per-frame teleport
check. Every GSAP instant patch was forced to miss and fall back to reloadPreview during its own
commit; move and rotate of a held element (gsap.set and tl.set holds), px and xPercent
placement, r0 and r30, root and nested, at 100%:
- The gesture mark was live when the fallback asked for the reload, but had always ended by the
  time refreshPlayer ran (refreshKey is consumed in an effect after the commit settles). So the
  new condition never fired there: root previews offer no scene swap and load shadow on both
  builds, and nested previews swap on both builds.
- Worst post-release frame offset, 32 cases on this build and 32 on the parent: 0.00 px over
  76-106 frames each (release to settle). No frame was off by more than 0.5 px.
- Sensitivity: a 3 px nudge injected 50 ms after the fallback reads back as 3.00 px.

* fix(studio): an edit no longer reopens the live-updates stream, and thumbnails ask one at a time

* fix(studio): an undo no longer waits behind paused preview videos for a connection

* feat(studio): a host whose API needs same-origin cookies can have Studio send them

* docs(studio-server): a host on same-origin cookies sets the Studio credentials opt-out

* test(studio): an undo is not held up by the preview's paused videos

* fix(studio): a resize inside one gesture writes a mixed tween once, in place, never beside it

* chore(studio): keep the carried fixes inside the comment ratchet; telemetry posts through studioApiFetch

* test(studio): a drag on a mixed tween is one write under the gesture key

* fix(studio): freeze-frame, loudness and peak-map requests go through studioApiFetch too

* fix(studio): an undo with no project open reloads in full instead of skipping nested files

* refactor(studio): drop the soft reload's reparse seeding, the held-element sweep covers it

* test(studio): keep the undo-restore browser test off the network and inside its timeout

* test(studio): nested composition reads expect the cookie-free request studioApiFetch sends

* fix(studio): an edit at the playhead rewrites its tween in place, so later tweens keep their times

* fix(studio): a rotate on a tween that turns another axis holds its angle and never turns early

* fix(studio): plan every group member before the first write so one refusal moves none

A group drag or nudge preflighted each member against the move refusal rule only. The value-at-playhead planner ran later, in the commit loop, after earlier members were already flushed to disk, so a tween with extras, eased keyframes or an unknown ease left the group half moved.

The group preflight now runs the commit's own route and keyframe plan for every member with its real offset, so any refusal refuses the whole group with nothing written and one toast attributed to the refusing member. The plan is not reused in the commit: earlier members' writes change the file under it, so each member re-plans against the fresh tween, from the same planValueEdit.

* chore(studio): the fetch lint rule also catches window.fetch, globalThis.fetch and self.fetch

* docs(studio-server): set the credentials flag before Studio loads; a mounting host injects it

* test(studio): the undo socket test needs six pinned videos under Vite and checks each undo restores

* fix(studio): Studio leaves cookies out of its requests only when served from loopback

* fix(studio): undo takes back the first edit, and the first save never waits on media

* fix(studio): undo of a move whose save failed stays undone and steps back no older edit

* fix(studio): a style set by its JavaScript name saves under its CSS name

* test(studio): the undo socket test holds round 0 to a full restore too

* fix(studio): the host revert keeps returning what shows the edit again

* fix(studio): an entrance rewritten at the playhead still shows its start state before it starts

* fix(studio): undo takes back the edit pending at the key even after a later edit claims

* chore(studio): lift the late-write quarantine now that undo never waits on videos; format

* test(studio): pin an entrance's start render on the written tween, not a happy-dom GSAP run

* revert(studio): leave entrances as on main, immediateRender does not render a keyframed start

This reverts commit ee9d130d3c and its written-vars test.

* revert(studio): undo goes back to the newest claim after the key, as on main

This reverts commit 5b9009a823.

* revert(studio): first-undo history opening moves to the next PR

This reverts commit 4e1e75d7f4.

* revert(studio): the undo socket test exempts round 0 again until first-undo lands

This reverts commit 1f8f97047c.

* test(studio): bank the edit accuracy cases this PR makes pass

* refactor(studio): share one project file fetch between the plain and optional reads

* test(studio): share the WebMCP host shim between the browser gates

* test(studio-server): share one stub adapter across the route tests

* refactor(studio): type the preview slot props from the NLE context

* test(studio): share the setup the new editing tests copied between files

* refactor(studio): split playhead tween picking and undo path merging into small tested helpers

* test(studio): pin when a resize at the playhead also writes the anchor move

* test(parsers): pin rewriting a tween in place as keyframes

* chore(studio): cut comments the new helper names already say

* fix(studio): composition thumbnails keep two fetches in flight, as the document lifetime fix expects

* fix(studio): a playhead edit parses a tween not yet played and matches only its own tween

* fix(parsers): a new position argument goes after the last argument, so trailing commas stay valid

* fix(studio): a second undo pressed during one save steps back to the edit before it

* test(studio): pin the slot an aborted thumbnail frees and the shared tween refusal

* refactor(studio): split the unplayed-tween parse into small helpers and name the claim release

* fix(studio): a playhead edit keeps the tween's default ease, delay timing and loop settings

* fix(studio): a group move refuses layers animated in two files and plans at the real drop

* style(studio): format two test files

* refactor(studio): check a group's files inside the preflight helper

* refactor(studio): move the group preflight checks out of the editing hook, under 600 lines

* fix(studio): read an unreached tween without redrawing other layers, and compare write files

* test(studio): use the shared selection helper in main's keyframe-at-time telemetry test

* chore(studio): fix a comment that read as a repo path and cut one added comment

* fix(studio): read a later tween's start as playback does, and count every keyframe add

* fix(studio): two undo presses during one save step back two edits, not one twice

* fix(studio): an undo that succeeds by any route retires the claim on the edit it undid

* fix(studio): a claim is never handed back to an edit another undo has already undone

* fix(studio): an undo picks its claimed edit at the press and skips it once undone

* fix(studio): a press refuses, as main did, once its edit is undone and a newer edit exists

* fix(studio): an unplayed keyframe tween clears every keyframe's channels before it is read

* fix(studio): reading an unplayed tween puts back every transform value its clear removed

* fix(studio): reading an unplayed tween restores each layer's whole style and GSAP state

* fix(studio): reading an unplayed tween skips plain-object targets and restores attributes

* fix(studio): shared tween-target filter uses GSAP's own styled-element test

* fix(studio): match a parsed tween on any target, and check a later keyframe channel's start

* docs(studio): say the unplayed-tween read restores styled elements only

* test(studio): a drag on a fromTo start shows the dropped x through the runtime seek
2026-10-03 13:10:54 -07:00
HEOJUNFO 8aefd91bf8 fix(cli): capture snapshot --at at the exact requested instant (#4827)
snapshot --at captures the frame at the exact requested instant instead of the nearest seek step.
2026-10-02 05:48:06 -07:00
Miguel Ángel d4756f597c fix(studio): edits on GSAP-animated layers keep their keyframes and file positions (#4869)
* fix(studio): resizing an element GSAP only positions saves its CSS width and height

* fix(studio): a resize never saves GSAP's scale mask as the element's CSS

* fix(studio): keep the resize size writer inside the file and comment budgets

* fix(studio): a GSAP-positioned resize saves its CSS size before the GSAP write renders

* fix(studio): undoing a crop leaves a GSAP-animated layer where its file puts it

* test(studio): an undo on a GSAP-parsed layer in a two-script file reloads the preview

* fix(studio): a move keeps an authored xPercent centring set in the saved file

* fix(studio): a drag on a centred element no longer flashes off-centre on release

* fix(studio): a font pick saves the edit and its @font-face in one write

* fix(studio): a focused combobox, switch or plain-text editor counts as an unsaved edit

* test(scripts): pin that a slash after i-- before a quoted slash is division

* fix(studio): an inspector edit saved while a preview reload loads stays on screen

* fix(studio-server): a font save accepts exactly one @font-face rule and nothing after it

* fix(studio-server): a font name or path with braces in quotes still saves its @font-face

* fix(studio-server): a CR or form feed in a quoted font name cannot end the @font-face rule

* test(studio): bank the 99 edit accuracy cases the GSAP fixes now pass
2026-10-01 17:24:35 -07:00
37f30b1d06 feat: keep a video's sound on the clip by default (1/8) (#4813)
* feat(cli): one owner for init media placeholders, audio stays on the video

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(cli): init keeps a video's sound on the clip; silent files scaffold muted

Supersedes the patchVideoSrc changes in #3977 and #3942 and carries their tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: miga-heygen <miga-heygen@users.noreply.github.com>
Co-Authored-By: kapelame <kapelame@users.noreply.github.com>

* feat(registry): example templates keep the a-roll's sound on the video

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lint): video audio rules recommend keeping sound on the video

Update video_missing_muted and video_audio_double_source message and
fixHint text to recommend the new default: keep audio on the video with
data-has-audio="true" (not muted). Tests verify the new wording is in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parsers): a video is either audible (data-has-audio) or muted, never both

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): follow the compiler's audio rule in the audio-track toggle

Confirm before background removal drops a video's sound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): hyperframes-core teaches sound on the video by default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): workflows and scaffold guidance keep a video's sound on the clip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: footage with sound keeps it on the video clip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): init from-file a-roll is timed, muted strip is attribute-exact

The from-file a-roll video gets data-start="0" so a scaffold lints clean, and
the orphaned audio-only slot comment is dropped. setVideoAudio now walks the
tag attribute by attribute, so a class value containing the word muted
survives. The ffprobe-missing warning says the video is assumed to have sound
and how to switch to muted, and DEFAULT_META notes the failure surfaces at
render until lint support lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): keep has-audio in step with the muted toggle, test cutout confirm

Muting a video now clears has-audio and unmuting sets it to true, in both the
Design and flat media panels, matching the Has audio track toggle. Audio
elements are unchanged. Adds coverage for both panels plus the cutout
window.confirm guard: declined leaves src alone, accepted swaps src and mutes,
a silent video never prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parsers): updateElementInHtml hasAudio only applies to videos, true or false

An audio element or an undefined hasAudio no longer picks up a muted attribute;
only an explicit false on a video writes muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: video_missing_muted rule text, silent-footage notes, consistent audible examples

lint.mdx and cli.mdx describe the current video_missing_muted rule instead of
the retired unmuted-video wording. The editing recipes say how to adapt for
silent footage and require the outgoing clip's sound to end at a J-cut. The
Remotion media example shows the audible mapping consistently, data-attributes
states that has-audio is required unless muted, and the hairline frame note no
longer claims the video is muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): encode init media filenames and probe --audio duration

Function replacers stop dollar-sign pattern expansion in filenames; src is percent-encoded.
--audio now passes the probed track length as the composition duration in both init paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lint): audio-state-aware media rules and time-aware double-source check

video_missing_muted honors any explicit data-has-audio.
video_audio_double_source only considers audible videos and only fires
when the same-src clips overlap in time.
duplicate_audio_track now covers audible videos.
audio_file_without_element no longer claims silence when an audible video
carries the sound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): audio-stream check for remotion ports, followable J-cut recipe

Remotion Video maps to data-has-audio only when the file has an audio stream.
The J/L-cut recipe now shows both shots with muted pictures and separate audio.
embedded-captions removes the audio.mp3 scratch file it extracts.
skills-manifest.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(lint): trim comments to satisfy the comment ratchet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): atomic muted edits, cutout keeps sound on a linked audio

Muted and Has audio track commit one batched write (one persist, one
undo entry) and remove data-has-audio instead of writing an empty
marker. Unmuting a video probes its src: audible -> true, silent ->
false, probe failed -> absent. Remove background on an audible video
inserts a linked sibling <audio> with the original sound in the same
write, replacing the drop-the-sound confirm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(producer): skip hidden videos when auto-adding video audio

A video hidden in Studio is dropped by the mixer, so its src never reached
existingAudioSrcs and the render re-added it at full volume. The video parser
now records the shared hidden predicate and the auto-detect step skips hidden
videos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(studio): keep useDomEditSession under the 600-line cap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: miga-heygen <miga-heygen@users.noreply.github.com>
Co-authored-by: kapelame <kapelame@users.noreply.github.com>
2026-10-01 23:56:36 +00:00
Miguel Ángel f1c1f1d69c fix(parsers): moving a clip on the timeline moves its inner animations too (#4761)
* fix(parsers): moving a clip on the timeline moves its inner animations too

* fix(parsers): a timeline move keeps chained and partly known tweens where they belong

* fix(parsers): moving a clip leaves chained outside tweens and template aliases in place

* fix(studio): a clip timing edit syncs its animations once, not twice

* fix(studio): a timing edit falls back to the server when the SDK wrote no animation change

* fix(studio): the SDK sync check reads scripts inside a template too

* fix(studio): the SDK sync check finds template scripts through the DOM walker

* fix(core): a clip retime writes only its own timed tweens, never outside ones

* style(core): format the clip tween import
2026-09-30 20:03:48 +00:00
Miguel Ángel 7734697872 fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name (#4756)
* fix(studio): a copied clip keeps its look and motion, and a duplicated section keeps its name

* fix(studio): a copy's tweens start where their originals do and stay in the timeline's scope

* fix(studio): a copy takes only tweens it can place exactly, and never moves the film's own

* fix(studio): a copy leaves out a tween that sits inside a callback or a guard

* fix(studio): a copy leaves out a set that runs only inside an if or an else

* chore(studio): keep the copy change under the comment ratchet
2026-09-30 12:58:02 +00:00
Miguel Ángel 481dae1ed9 fix: render names a folder composition source instead of crashing (#4613)
* fix(producer): render names a data-composition-src that points at a folder instead of crashing

* fix(lint): name a data-composition-src that points at a folder instead of crashing

* fix(cli): compositions, keyframes and Studio insertion survive a folder composition source

* fix(lint): tell a folder composition source to point at the HTML file inside it

* fix: read a composition source through one file handle so its type check and read cannot race

* fix(parsers): report a named pipe as a composition source instead of blocking on it

* revert(parsers): open composition sources read-only again, which CodeQL reads as no file creation

* fix(parsers): open composition sources with a private mode, skip pipes, and treat bad paths as missing
2026-09-29 07:58:17 +00:00
Miguel Ángel e7a0b6be2c fix(studio): a resize after a drag writes its keyframe below the timeline it uses (#4638)
A canvas drag stores the element position as a global gsap.set above the
timeline declaration. A resize that followed it inserted its tl.set right
after that line, so the saved script referenced the timeline before it was
declared and every reload and render threw a ReferenceError.

Both GSAP writers now bound the "after the last statement" anchor by the
timeline declaration, whatever the timeline variable is named. A drag or a
resize on its own writes the same bytes as before.
2026-09-28 06:48:38 +00:00
Miguel Ángel 13bee74960 fix(lint): warn when a clip ends past the root composition duration (#4633) 2026-09-28 05:58:25 +00:00
Miguel Ángel 11ac9bce69 fix(studio-server): previewing a project never rewrites its files (#4480)
* fix(studio-server): previewing a project never rewrites its files

* fix(studio-server): a GSAP save on a served id writes that id to the file too
2026-09-25 10:04:38 +00:00
Miguel Ángel f873734cc4 perf(studio): open a large film ready to play in about 4 s instead of 32 s (#4401)
* perf(studio): keep lint off the preview open path

The bundler linted every build only to print a StaticGuard warning; a preview bundle now
skips it through a staticGuard option, since Studio lints on its own route. That route now
lints in the CLI child-process worker the render path already uses, so seconds of parsing
no longer stall the preview and every other Studio request on the server event loop.

* perf(parsers): parse identical GSAP source once

Studio re-requests the parsed animations of unchanged source on every open, and several lint
rules parse the same script in one run. A small memo keyed by the script text serves repeats;
each caller gets its own copy.

* perf(studio): build each preview document once per content version

The preview route rebuilt the document for every browser without a cached copy. It now serves
one build per ETag from memory, and the CLI keeps the last build on disk salted with its own
file, so a restarted server serves the previous document without stale code.

* perf(studio): build the opening film's preview while the browser starts

The preview server built the document only when the player asked for it, after
Studio had booted, so a first open waited about a second of idle server time.
The CLI now requests the opening project's preview right after listening, and
requests that arrive while a build runs share it instead of starting another.

* perf(studio): request the preview document at page load

Studio asked for the preview only once the player mounted, about 640 ms into a
first open. It now requests it the moment the page loads, and again when the
hash names another project, so the build or cache read overlaps Studio's boot.

* feat(studio): show the cached frame 0 while the live preview boots

Studio covers the live preview with frame 0 as the thumbnail route last rendered it, sized
from the image so the stage does not shift, and removes it when the live frame is ready to
show. The request is cache-only, so a miss never starts a render on the open path; the
missing poster is rendered after the live frame shows, for the next open.

* perf(cli): start Studio before the startup lint finishes

hyperframes preview linted the whole project before it listened, 4 to 6 s on a large film, and
did so in --json mode where the result is discarded. The lint now runs in the CLI worker while
Studio starts and prints its summary when done; --json skips it.

* perf(studio): render thumbnails from one loaded page, after the preview boots

Every thumbnail opened a page and loaded the whole composition to shoot one frame, and the
Compositions card asked for one while the live preview was still booting. The server now keeps
one loaded page per preview document and seeks it per frame, reloading only when the project
changes; card thumbnails wait for the project first ready preview.

* perf(studio): parse GSAP animations after the preview boots

Studio asked the server to parse the composition animations while the preview was opening; on
a fresh server that cold parse ran on the event loop ahead of the preview build. The request
now waits for the live preview to boot, or to fail loading, since only keyframes need it.

* perf(cli): find orphaned browsers with one process-table read

preview ran pgrep three times and then ps once per matching browser, all
synchronously before listening: about half a second on a machine running
other headless Chrome. One ps listing now finds every orphan in ~20 ms.

* perf(studio): keep lint off the boot

Opening a project started two lint processes while Studio booted: the
terminal's startup lint and Studio's background lint, ~4 s of CPU next to the
boot. Studio's background lint now waits until the live preview has booted,
and lint processes run at the lowest CPU priority so they only take idle CPU.

* perf(studio): open the editing session after the preview boots

Opening a project read the composition source and parsed it for the editing
session (linkedom plus hf-id assignment) on the main thread while the live
preview was booting on the same thread: ~300 ms on a large film. The session
now opens once the preview has booted; nothing the first frame shows needs it.

* perf(studio): park the editor overlays until the preview boots

The overlay frame loop polled preview geometry and off-canvas indicators from
the first frame, forcing layout on a preview document that was still loading,
on the thread its boot runs on: ~200 ms before the first frame on a large film.
The loop now skips its subscribers until the preview has booted, then wakes.

* perf(studio): treat the preview as booted once it can play

Work deferred until the boot (editing session, lint, overlays, GSAP parse,
card thumbnails) started at timeline-ready, which fires before the asset gate,
so the editing-session parse ran inside the wait for play. The Player now
latches the boot when the preview can show and play, or fails to load.

* fix(studio): keep a left project's deferred work out of the one now open

Deferred open-time work (source parse, background lint, editing session) waited on one global
boot flag, so leaving a project before its preview booted let the next project's boot run the
old project's work and write its results. The wait is now per project and resolves false once
another project opens. A preview stuck without an error stops deferring that work after 5 s.

* perf(cli): let the play page revalidate instead of refetching

hyperframes play served the composition, its assets, the player and the runtime with no
validator, so every reload refetched everything, the mp4 in full three times. They now carry
an ETag and stay no-cache, so unchanged bytes cost a 304 and an edit is still seen.

* fix(cli): serve an edited text asset even when its size and mtime match

The play page tagged every asset by mtime and size, so a same-size rewrite within one
filesystem tick kept the old tag and the browser kept stale bytes. Text assets now get a
content-hash tag; media keeps the mtime and size tag Studio already uses.

* fix(studio): close idle thumbnail pages so an idle Studio runs no composition

Kept-loaded thumbnail pages stayed open for the life of the server, each running a live composition,
so an idle Studio cost server CPU and memory that main does not. A page now closes after 10 s unused,
and a page is closed only after the frame still running on it finishes.

* fix(studio): stop a first open from reloading the preview once per scene file

Studio stamped hf-ids into a composition the first time that composition was served, so opening a
film of external scenes wrote to index.html and every scene file mid-session. Each write reached
Studio as an outside edit and reloaded the preview, which failed for a large film. The server now
stamps every composition once before it starts watching the project.

* fix(studio): ignore play until the preview can play, so it is never undone

Space (and any play() caller) could start playback before the preview frame finished loading. Its
load step then paused and rewound to the restore point, so the play was silently lost. play() now
waits for timelineReady, the same gate the Play button already uses.

* fix(studio): hold play on a composition switch until the new preview loads

Readiness was reset only when the project changed. Drilling into another composition kept the
old ready flag while the new preview loaded, so Play or Space could start a play that its load
step then undid.

* refactor(studio): split background lint and thumbnail page reuse into single-purpose steps

Keeps the boot wait in the background lint only, moves page lookup and idle close out of
withPage, and drops a suppression the startup worker no longer needs. Behaviour unchanged.

* chore(cli): declare the render-setup worker as a fallow entry

It is spawned by path from cancellableProcess.ts, so it has no import-graph referrer; the entry list
is where the repo records that, replacing the per-file ignore removed in the previous commit.

* fix(cli): close a thumbnail page whose load fails

The page never leaves the promise that loads it, so when the load throws (a WebGPU composition with
no adapter, or the navigation timeout on a big film) nothing could close it.

* fix(cli): match text assets by exact type and keep Range on them

The pattern anchored only its first alternative, and text assets skipped buildRangeResponse, so a
ranged request for a stylesheet or SVG got a full 200.

* test: open the next project as Studio does, and read the stamped file once

The absent-read guard test switched project without beginning its timeline session, so the
per-project boot wait held the read. The stamp test checked then read the same file.

* test(cli): rewrite the scene file without opening it first

Opening the file by path counted as a check before the later read of the same path.

* perf(cli): take a thumbnail's version from the cached project signature

Each thumbnail walked the project and hashed every text file for its page version; the adapter
already caches that signature and clears it from the watcher. An aborted request now also stops
before its screenshot.

* fix(studio): keep Play off until the preview's load step has run

Play could enable before the preview document's load event, from the runtime's timeline message
or from the blank page's load step settling on the preview's first message. The load step then
paused and rewound the preview, so a Play pressed in between did nothing; a film with large
images loads late enough for this to hit a cold open. Readiness now waits for the document's load
step, whichever path asks for it.

* refactor(studio): move the preview poster's state into usePreviewPoster

NLEPreview.tsx passed the 600-line limit once main grew it; the poster's state and callbacks belong
beside PreviewPoster. No behaviour change.

* fix(cli): take an earlier thumbnail on a fresh page

A composition need not draw the same frame when seeked back, so a kept page that had shown 20 s
drew a different 10 s thumbnail than a fresh load. A kept page now only seeks forward, as a render
does; an earlier time loads a fresh page.

* perf(cli): close a kept thumbnail page a second after its last frame

Held for 10 s, the page went on running its composition after the open's thumbnail
burst, about 5 % of a core in the thumbnail browser while Studio sat idle. A burst's
requests arrive back to back, so it still loads the page once.

* fix(studio): count the preview as booted at its first frame

The boot waited for every video and audio in the preview to finish buffering, which on a
project with media took until the 5 s fallback. The thumbnails, editing session and lint
that wait for the boot then started seconds late and kept an idle Studio busy. They now
start once the first frame is up; media finishes buffering alongside.

* perf(studio): render one thumbnail per composition for its card and clip

A composition's sidebar card asked for its frame at 3.00 s and its timeline clip for the
clip's midpoint, so opening a project rendered every scene twice. The clip now asks for the
card's frame (the midpoint still, for compositions under 3 s), and the thumbnail cache
serves both from one render: 20 scene renders drop to 12 on a 12-scene project.

* chore: keep each changed file's comment share at or under main's

The comment ratchet counts the comment lines this branch added. Each is cut, or kept as one
line where it states an invariant (the GSAP parse cache returns copies; the boot starts waiting
work at the first frame). The orphan-cleanup docblock is condensed and now names the process-table
read it uses instead of the pgrep scan it replaced. No code line changed.

* fix(studio): keep a 3 s clip off its end frame and give card and clip one encoded URL

A composition exactly 3 s long asked for its thumbnail at 3.00 s, its end instant, which after a
fade-out is an empty frame; compositions of 3 s or less now use their midpoint. The card built its
thumbnail URL from the raw path while the clip encoded it, so names with & + [ or % asked for two
different URLs, and a % in the name made the card's request fail on the server; both now encode.
The readiness comment says the boot latches at the first frame, as it has since the boot moved.

* fix(studio): encode the card's live preview URL too, and document the bundler's static guard

The card's hover preview built its URL from the raw composition path, so a % in the file name made
the preview route fail to decode it. The card now builds its composition URL in one place, encoded,
for both its thumbnail and its hover preview. The static-guard bundle option keeps a one-line doc
(its default and what it warns about); a neighbouring docblock is rewrapped to keep the comment
share.

* test(cli): mock studio-server once so the signature spy and the history hook both apply

The main merge brought a second vi.mock of @hyperframes/studio-server into studioServer.test.ts;
the later one replaced the first, so createProjectSignature was no longer a spy and the cached
signature test read .mock of undefined. One mock now carries both overrides.
2026-09-25 04:56:55 +00:00
Miguel Ángel 571c99ce9e fix(sdk): read a composition's variables from its root when its html has none (#4454)
* 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
2026-09-25 00:14:11 -04:00
Miguel Ángel 7977b926d2 fix(lint): map parser diagnostics to exact source positions (#4360) 2026-09-23 17:13:40 -04:00
Miguel Ángel 0618fc0dc3 fix(parsers): render bare fragment sub-compositions (#4335) 2026-09-23 12:49:04 -04:00
cc19b8c812 fix(server): contain static file requests within their served roots (#4217)
* fix(server): contain static file requests within their served roots

Static asset handlers joined the raw request path onto their root
directory without checking where it landed. Hono decodes percent
escapes while routing, so an encoded backslash (%5c) arrives as a real
separator on Windows and dot segments arrive already collapsed —
"/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files
above the studio bundle root (embedded Studio server) and above
compiledDir/projectDir (engine render file server). Browsers never
request those shapes, but anything that can reach the bound port can:
raw local clients, LAN peers when the preview binds 0.0.0.0, and a
shared-machine process hitting the render server.

Both handlers now resolve the request path first and refuse anything
that collapses outside its root, using the same lexical containment
the preview asset route already applies — bundle/project assets behind
symlinked directories keep being served, only dot-segment escapes are
rejected. Route-level pathSafety tests pin the behavior in both
packages, including the Windows backslash shape and the
symlinked-asset cases from the descriptor-pinning regressions.

Validated: new containment suites fail on the prior handlers (200 +
marker file content served from outside the root) and pass with the
guards; cli server (140), engine services (1024+, matching the main
baseline incl. pre-existing browserManager failures), typecheck,
oxlint, oxfmt and the full workspace build all clean.

* fix: close fallow audit findings, prove containment cross-platform

Fallow audit (health/complexity): createFileServer's request handler
exceeded the CRAP-score threshold once the new containment checks were
added. Extracted resolveContainedPaths, a small pure function that
resolves and validates both roots, cutting the handler's own branching
back down.

Fallow audit (duplication): the new pathSafety test files copied their
temp-root/studioDir setup and teardown from the existing staticAssets
test wholesale. Extracted the shared parts into
studioServerTestFixture.ts (makeStudioServerRoot, writeStudioIndexHtml,
cleanupStudioServerRoot); only the per-file vi.mock("node:path", ...)
call stays duplicated, since Vitest requires it declared statically in
each test file and it cannot be moved behind a shared function -- that
one block keeps a narrow, targeted ignore comment instead of a
file-wide one.

Security claim: isWithinProjectRoot's %5c-decoded-backslash escape is
real, but only on Windows -- POSIX never treats a literal backslash as
a separator, and Hono leaves %2f undecoded in c.req.path, so none of
the new tests' encoded-traversal payloads produce an actual escape on
Linux (verified empirically: they pass identically with the fix
reverted). isWithinProjectRoot now takes an optional path-module
override (defaulting to the host's native node:path, so every existing
caller is unchanged) so its win32 and posix branches can be unit
tested directly from one OS, the same pattern already used by
producer/fileServer.ts's isPathInside. New tests cover a backslash
traversal, a mixed backslash/forward-slash traversal, and a
drive-letter path escaping the root under path.win32, plus the
existing posix case -- confirmed each one fails when the check is
removed. The HTTP-level tests stay as-is: real regression coverage for
them comes from the windows-latest CI job, which runs both new
pathSafety suites unfiltered.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: orphan4183 <kingenmaww@gmail.com>
2026-09-22 22:18:56 -04:00
Miguel Ángel fa44d7621d feat(core): a composition with no authored length takes it from its clips (#4174)
* 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
2026-09-19 11:27:33 -04:00
Miguel Ángel 3f3f3b7f10 fix(engine): audio source-not-found names the file and shares one src resolver with lint (#4172)
* 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
2026-09-19 10:07:22 -04:00
Miguel Ángel 7597b80c95 fix(cli): timeline reports real media durations instead of unauthored (#4169)
* fix(cli): timeline resolves real media durations instead of printing unauthored

Media rows call the shared resolveMediaDuration with an ffprobe source length.

Each row shows its duration source, or pending with the reason a probe failed.

Absolute time (absStart/absEnd) is carried across sub-compositions.

Remote, absolute and parent-relative srcs are never probed.

* fix(cli): timeline probe leaves the authored-duration rule to the resolver

The gate test now launches jobs while slots are held.

* fix(cli): timeline reports a zero-length probe as pending instead of a measurement

A still image or duration-less container is pending with a reason, never duration=media.

The runOneLiner test helper now cleans up its own fixture directory.

* fix(cli): timeline probe is injectable and the fixture path works on windows

Two unit tests used real ffprobe, which the Ubuntu Test job lacks, and the audio

fixture path doubled the drive letter on Windows. describeProject now takes the

measure function, the tests pass a recorded fake, and one real-ffprobe test remains.
2026-09-19 09:27:28 -04:00
Miguel Ángel 09aadd86a5 feat(core): video, audio and image length come from one shared resolver (#4166)
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.
2026-09-19 08:01:28 -04:00
Miguel Ángel 559e57d295 fix(parsers): unroll keeps tween order and label positions the parser read (#4157)
* test(parsers): pin unroll order and side-effect soundness

* fix(parsers): resolve helper tweens in source order and unroll only pure tween helpers

* fix(parsers): unroll keeps a helper whose tween chain ends in a non-tween call

A helper like tl.to(...).call(fn) was treated as adding only tweens, so unroll
dropped the callback. Only chains of set/to/from/fromTo count now.
Also reuses isFunctionNode and names the unroll scope.

* fix(parsers): keep chain order, label positions and chain callbacks when unrolling

Review found three ways unroll still changed a timeline:
- a helper call positioned by a label was read at 0 (label order used raw loc)
- a tween chain added by a helper ran in reverse (outer call stamped first)
- an inner chain link with a callback was unrolled and lost the callback

* fix(parsers): resolve a label defined inside an inlined helper at its call site

addLabel was never tagged during helper expansion, so a cloned label kept
its declaration-body offset while its sibling tweens were remapped to the
call site. Comparing the two put every such label before every tween.
addLabel now gets the same site/order stamp, with order as the tie-break
when a label and its own tween share a call site.

* test(parsers): assert the sibling-label unroll rewrite matches its parsed start

The only literal-rewrite path this fix actually exercises (the label-defining
helper itself is never unrolled, since onlyAddsTweens rejects addLabel).

* refactor(parsers): unroll's effect scan reuses acorn-walk like its sibling

containsEffect hand-rolled a generic object walk; hasUnsafeLoopIndexUse in
gsapWriterAcorn.ts already solves the same shape with acorn-walk.full.
2026-09-19 02:47:20 -04:00
Miguel Ángel 144e8023c6 feat(parsers): detect where the gsap timeline disagrees with timing attributes (#4147)
* feat(parsers): mark tweens whose duration is not a static number as unresolved

* feat(parsers): detect timing mismatches between attributes and the gsap timeline

* refactor(parsers): simplify unresolved-duration handling

* fix(parsers): treat every unreadable duration shape as unknown

* fix(parsers): keep helpers that unrolled-away calls still run through

* fix(parsers): unroll keeps every helper a remaining call still reaches

* fix(parsers): unroll matches helper names by identifier and skips unresolved selectors

* test(parsers): pin the identifier boundary used to keep unroll helpers
2026-09-19 00:09:21 -04:00
Miguel Ángel 02c415dd8b feat(parsers): share top-level timeline element and track kind definitions (#4124)
* feat(parsers): share top-level timeline element and track kind definitions

One pure definition of which elements own a timeline row (sub-composition
hosts and timed elements; untimed wrappers are descended) and of a row's
track kind, so the linter and Studio's timeline agree.

* fix(parsers): align top-level rows with the runtime clip selector

Rows now follow the runtime's clip selector (data-start, data-track-index,
media tags), match tags case-insensitively for DOM adapters, and skip noscript.

* fix(parsers): treat a track-index layer without a duration as a wrapper

Matches the runtime, which skips such elements as clips, so their timed
children stay visible to the lint.
2026-09-18 18:47:18 -04:00
miga-heygenandMiguel Ángel 0a1c6f5a45 feat(engine): sub-frame multi-sample motion blur, capture-level accumulation (#4019)
Deliverable one of issue #4010: average K sub-frame captures into each output
frame so rotation, scale and opacity blur the way After Effects does, which the
translation-only catalog component (#4008) cannot do. Opt in per render with
CaptureOptions.motionBlur; absent means the capture path is unchanged.

The blocker was quantizeTimeToFrame flooring every seek onto the output frame
grid, which collapsed all K samples onto one instant and made the averaged frame
byte-identical to the unblurred one. quantizeSeekTime now owns that decision at
all three quantize sites (runtime player, its no-timeline fallback, and the init
transport) and refines the grid by an integer subdivision count, which can only
contain the output grid, never move a frame-time instant off it. The finer grid
rounds rather than floors: a sample time round-trips through the page bridge as
a float, and past a few million ticks that error exceeds the floor epsilon.

Accumulation sits inside captureFrameCore AFTER the static-dedup early return,
so a static frame still costs one reused buffer and only moving frames pay K. It
is not a before-capture hook, which would have disabled dedup outright.

Callback invariant: one eventful seek per output frame at the frame time, then K
suppressed sample seeks, then a suppressed restore to the frame time. The
restore matters because a suppressed seek still moves the playhead, so without
it the next frame's eventful seek would swallow the callbacks inside the shutter
window.

Scope of this deliverable: screenshot capture mode and PNG output. beginframe,
drawelement and JPEG encoding are rejected with a named error rather than
silently rendering unblurred. Video content is rejected for the same reason it
is a documented limitation: injected frames are extracted per output frame and
cannot follow a sub-frame seek.

Blend defaults to sRGB, matching AE's non-linearised 8-bpc working space and
the catalog component. Linear light is an explicit opt-in.

Samples are folded into a running average as they are captured rather than held
until the end, so peak memory is one accumulator plus one decoded sample instead
of all K: at 1080p that is a constant 41 MB rather than 141 MB at 16 samples and
540 MB at the 64 maximum, on every frame of the render.

PNG chunk CRC32 now has one owner in utils/crc32.ts, shared by the new encoder
and ffprobe's metadata reader. The encoder previously imported zlib's crc32 by
name, which throws at module evaluation on Node 22.0 and 22.1; ffprobe already
carried the guarded version and the comment explaining why. The sRGB transfer
function is likewise exported once and shared.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 20:10:06 -04:00
miga-heygenandMiguel Ángel 2229d0d6a6 fix(lint): root_dimensions_mismatch sub-composition false positive + regex SSOT consolidation (#4012)
* 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>
2026-09-16 21:09:32 +00:00