Commit Graph
4732 Commits
Author SHA1 Message Date
Miguel Ángel f888db950e fix(core): paint no timed clip until the runtime decides its visibility (#4460)
* fix(core): paint no timed clip until the runtime decides its visibility

Until its first visibility pass, a paused page showed every timed clip at once: an embed without
a loader flashed a later scene before the first, and a player's hidden iframe still decoded every
scene's images. The runtime now hides timed non-media clips from the moment its script runs,
and the first pass that decides them removes that rule, so each clip is shown only by the pass.

* refactor(core): name media with the realm-safe helper in the first-pass check

* fix(core): share the first-pass hide across runtime copies and shader captures

A second evaluation of the runtime (a sub-composition carrying its own runtime tag, or a page that
includes it twice) added a hide rule nothing removed, so the film stayed invisible. The rule and its
flag now live on the page: a copy that finds either, or finds the runtime already initialised, adds
none, and the first pass removes every copy. HyperShader's capture clone forces its scene visible
with !important, so a transition captured before the first pass still shows the scene's clips.
The runtime test now also pins evaluation-time hiding, inline visibility, media, and two copies.

* fix(core): grade media inside a clip once the first pass shows it

Color grading registers only media it can see. Media with no timing of its own inside a timed clip
inherited the first-pass hide, so grading skipped it, and lifting the rule is not a body mutation,
so nothing registered it again: the image or video stayed ungraded. The pass now refreshes grading
when it lifts the rule. The second-copy test now asserts on a paused page, before any seek.

* chore(core): keep the comment share of the first-pass hide files at or under their base

The comment ratchet counts one added comment line each in entry.ts, init.ts and capture.ts, and a
new file above its package's share. The three lines are gone, and the one note that has to stay
(why the hide runs at script evaluation) now lives in the new module's shorter header.
2026-09-24 23:04:23 -04:00
Miguel Ángel 2623b666c2 test(studio): wait for the off-canvas indicator instead of a fixed number of frames (#4458) 2026-09-24 22:45:17 -04:00
Miguel Ángel a95cb96a5d Merge pull request #4461 from heygen-com/release/v0.8.75
chore(release): cut v0.8.75
v0.8.75
2026-09-24 22:33:47 -04:00
Miguel Ángel 8c539621b7 Merge branch 'main' into release/v0.8.75 2026-09-24 22:17:20 -04:00
Miguel Ángel 3520c8225c ci: a package file's comment share may not rise (#4445) 2026-09-24 22:06:50 -04:00
Miguel Angel Simon Sierra a08433acef chore: release v0.8.75 2026-09-24 21:51:48 -04:00
Miguel Ángel 1f0cfa7c3e ci: skip Windows runs for player and Studio UI-only changes (#4442) 2026-09-24 21:48:20 -04:00
Miguel Ángel 225c22b6b9 fix(studio): a playhead scrolled off to the left no longer draws over the track names (#4436)
* fix(studio): a playhead scrolled off to the left no longer draws over the track names

The playhead layer (z 100) drew over the sticky track headers when the timeline was scrolled past it. Once a scroll settles, its layer is clipped at the header edge, less the glow's half-width so 00:00 looks as before. The settle-time scroll subscription moves out of the ruler into useSettledScrollLeft, now shared by both.

* test(studio): find the timeline playhead through its layer, not its z-index
2026-09-24 21:28:03 -04:00
Miguel Ángel b8dd023a97 feat(core): a composition's script can load its own files with __hyperframes.assetUrl (#4434)
* feat(core): a composition's script can load its own files with __hyperframes.assetUrl

* test(core): a template composition inside a mounted file resolves assets from that file

* docs: assetUrl is for paths beside the composition, not project-root paths

* docs: say assetUrl misses a project-root path
2026-09-24 21:13:27 -04:00
Miguel Ángel 177464b4c1 feat(cli): preview keeps project history, and Studio can claim its own edits (#4437)
* feat(studio-server): preview keeps a project history and names the next undo and redo

The history engine now reaches `hyperframes preview`, and its routes tell
Studio what Cmd+Z and Cmd+Shift+Z would revert next.

- The CLI preview server gives the shared API a project history, opened
  on first use under ~/.cache/hyperframes/history (overridable with
  historyRoot), and its file watcher feeds every change it sees to it.
  Shutdown closes it, so open windows are committed, within the same
  bounded wait as renders, so a history still opening cannot hold exit.
- GET /projects/:id/history also returns `back` and `forward`: the entry
  each step would revert, with its label, for the Undo and Redo buttons.
- POST /projects/:id/history/window takes an optional idleMs, so a drag's
  burst of writes can share one window that ends by itself (capped at
  10 minutes, below where a timer overflows).

Nothing in Studio calls these yet.

* feat(studio-server): a writer that records after writing can claim its writes

Studio records an edit after writing it, and many of its writes are
server mutations deep in its save paths, so opening a window before each
write would restructure them. claim() fits that order instead.

- ProjectHistory.claim(who, label, paths, { coalesceKey?, idleMs? })
  sweeps now and moves the not yet committed outside changes to `paths`
  into one entry of who's, under the edit's label. Other outside writes
  stay outside.
- Claims with one coalesceKey merge into one entry (a drag's burst) until
  a claim with another key, idleMs (default quietMs) without one, or any
  operation; Cmd+Z right after a drag undoes all of it.
- The ceiling is named where it lives: another writer's write to a
  claimed file between Studio's write and its claim folds into Studio's
  entry, and a write filed to another writer's open window stays there.
- Route: POST /projects/:id/history/claim { label, paths, coalesceKey?,
  idleMs? } -> { claimed: { id } | null }.

* fix(studio-server): a drag back to its start claims nothing, and next steps name their files
2026-09-24 20:55:58 -04:00
Miguel Ángel 65a266b29d fix(studio): a deleted clip leaves the timeline as soon as the file drops it (#4447)
Deleting a sub-composition clip left it drawn on the timeline indefinitely. The preview still held the removed host until its reload landed, composition enrichment re-read it from there and put it back, and the merge then kept it on every shorter scan. The delete now takes its clips out of the live preview at once, and updates the timeline right after its write instead of after the ripple finishes saving; a ripple that fails to save puts the gap back.
2026-09-24 20:41:14 -04:00
Miguel Ángel 3194c2ae87 ci: grade the comments each PR touches (#4444) 2026-09-24 20:26:15 -04:00
Miguel Ángel 8af615a48e fix(cli): catalog --json carries each item's preview video and poster (#4443)
The catalog already loads every item's manifest, preview included, but the
--json rows dropped it, so an app listing the catalog had no way to show an
item before add downloads it. Rows now carry preview when the item has one.
2026-09-24 18:14:13 -04:00
Miguel Ángel 2fe37e3406 fix(studio): a preview reload keeps a playing film playing where it was (#4438)
An edit that reloads the preview used to stop playback: the reload saved the
time, paused the live frame and marked the player paused, and the promoted
document started paused. Now forward playback runs on in the live frame while
the new document loads. At promotion the new document takes the live frame's
time, so playback, a stop at the end or a seek made during the load carries
over, and it keeps playing if the live frame was, unless the edit cut the film short of that time. A paused film, a reverse
shuttle and a dropped reload behave as before.
2026-09-24 17:58:43 -04:00
Miguel Ángel 359106aa3a fix(cli): the block snippet from add carries the block's own composition id (#4432)
* fix(cli): the snippet hyperframes add prints for a block passes hyperframes check

* fix(cli): the block snippet carries the id the block's own root declares

* fix(cli): warn when an added block declares no composition id for its snippet

* fix(cli): add <tag> --json reports each block's warnings
2026-09-24 17:39:13 -04:00
Miguel Ángel 7129340ae8 Merge pull request #4440 from heygen-com/release/v0.8.74
chore: release v0.8.74
v0.8.74
2026-09-24 17:18:37 -04:00
Miguel Angel Simon Sierra 3a47b46057 chore: release v0.8.74 2026-09-24 17:04:02 -04:00
Miguel Ángel 56c48a79be feat(studio-server): every write to a project is kept in a history that can be undone or restored (#4431)
* feat(studio-server): every write to a project is kept in a history that can be undone or restored

* fix(studio-server): rewrite a history removed while open and report damaged log lines

* fix(studio-server): history ignores foreign ids, ends idle windows and budgets only past history
2026-09-24 16:19:00 -04:00
Miguel Ángel 7556b95ac6 feat(cli): warn when canvas content touches the frame edge (#4427)
* feat(cli): warn when canvas content touches the frame edge

* test(cli): pin which frame edge the canvas warning reads

* test(cli): type the canvas edge stub like the file's other getContext stubs
2026-09-24 16:04:44 -04:00
WaterrrForeverandClaude Opus 5 42ae3da5b6 feat(lint): warn on negative z-index, which renders invisibly behind the root (#4403)
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>
2026-09-25 03:40:04 +08:00
WaterrrForeverandClaude Opus 5 5325891726 feat(lint): warn when a composition is wider than the inspection viewport cap (#4413)
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>
2026-09-25 03:24:06 +08:00
WaterrrForeverandClaude Opus 5 201c829c45 feat(lint): flag a canvas-level CSS zoom that rescales a fixed frame (#4412)
* 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>
2026-09-25 02:29:54 +08:00
WaterrrForeverandClaude Opus 5 9d4836902b fix(engine): name the data-no-timeline opt-out in the timeline-timeout warning (#4410)
* fix(engine): name the data-no-timeline opt-out in the timeline-timeout warning

When the sub-composition timeline wait times out, two things are emitted:
the stderr line inside pollSubCompositionTimelines, which names the pending
composition ids and the data-no-timeline opt-out, and the structured warning
on the session, which said only "Sub-composition timelines did not become
ready within 45000ms".

The structured one is what a programmatic caller reads: render re-prints it
in the completed_with_warnings summary and returns it in the result's
`warnings` array, so a CI consumer or an SDK caller sees the symptom with no
remedy attached. It also reads as a fault to diagnose, when the commonest
cause is a composition that legitimately has no GSAP timeline -- CSS
animations or rAF -- and is paying the full playerReadyTimeout on every
render because nobody told the author the wait is theirs to switch off.

The message now says the wait can be intentional, names data-no-timeline as
the opt-out, and keeps the async-setup case as the other branch. The pending
composition ids were already computed for the stderr line and thrown away;
an optional onPending callback carries them to the session so the structured
warning and its details can name them too, matching the script-failure
branch, which already names its failed sources. The stderr text is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(engine): normalise the pending-id result instead of casting it

The enumerate step cast `page.evaluate`'s result with `as string[]`, which is a
claim the call site cannot enforce: `evaluate` is loosely typed, and anything
that returns a non-array turned the cast into a TypeError thrown on the very
path that exists to REPORT a problem. Normalise the result instead.

`pendingCompositionIds` was also never declared on `CaptureWarning.details`,
whose type is closed, so the engine build failed and every downstream package
failed with it.

Adds coverage for the poll's own `onPending` handoff, which the existing tests
in this PR could not see: they exercised `recordSubTimelineWarning` (the
message text) and never `pollSubCompositionTimelines` (where the throw was).
The non-array case is the one that discriminates this fix from the tempting
alternative of leaving the cast and adjusting the stubs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 02:29:35 +08:00
WaterrrForeverandClaude Opus 5 250c9bd509 fix(engine): resolve the version by walking to our own package root (#4409)
readEngineVersion resolved ../../package.json relative to import.meta.url.
That path is correct from packages/engine/src/utils/ in the repo and
overshoots the package root from <pkg>/dist/ in the published CLI, which
bundles the engine. The require threw MODULE_NOT_FOUND, a bare catch
swallowed it, and every render stamped the 0.0.0-dev fallback.

No single relative literal is correct in both layouts, so resolve by walking
up to our own package root instead of by a fixed depth.

The name check is the fix, not a refinement. A bare "first package.json
found while walking up" resolves to whatever sits above in a hoisted install
and returns a confident wrong version - the same class of defect as the
sentinel, and harder to notice because the answer looks plausible. Only a
package.json whose own name is hyperframes or @hyperframes/* may answer.
A test pins this: removing the name check fails that case and only that case.

Also replaces the sentinel. 0.0.0-dev parses as a version, compares against
one, and sorts below every release, so a total resolution failure was
indistinguishable from a dev build - which is why it shipped unnoticed. The
fallback is now "unresolved", deliberately not semver-shaped, and every
failure path warns with the reason. A failed lookup still never breaks a
render.

The old comment claimed the engine ships as raw TS and exports
./package.json. The published package has no exports field and bundles the
engine, so that premise does not hold where the code runs; it is replaced
with the two layouts and why they conflict.

Refs #4365

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 02:29:30 +08:00
WaterrrForeverandClaude Opus 5 480beb62bd docs: state the 45 s cost of a missing data-no-timeline (#4402)
The attribute is in the HTML schema reference and enforced by the missing_data_no_timeline lint rule, but no page said what omitting it costs: a composition that never registers a GSAP timeline waits out player-ready-timeout (45 s by default) on every render.

That matters for triage because it is a fixed cost. "The render is slow" currently lists only causes that scale with the project - draft quality, media size, filter complexity - so an author whose renders are uniformly 45 s long reads the section, tunes the things it names, and sees no change.

- troubleshooting: add a symptom-keyed entry above "The render is slow", and point that section at it for the constant-penalty case.
- html-schema: give the attribute row the consequence and the lint code.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 02:29:25 +08:00
Miguel Ángel 861fcc4dd0 fix(core): a preview that is never seeked shows only the clips of its current time (#4422)
The visibility pass ran only on seek, pause, play and during playback, and at init for video
and img alone, so a paused page published __renderReady with every caption and composition host
visible until someone seeked. Readiness now runs that pass for non-media timed elements; media
keeps its init pass and seeks, which own color grading and audio scheduling.
2026-09-24 14:18:53 -04:00
Miguel Ángel 69fea290a9 fix(studio): a ruler label that slides under the track header is hidden, not sliced (#4424)
* fix(studio): a ruler label that slides under the track header is covered once the scroll settles

The track-header corner of the ruler is sticky and opaque and tick labels scroll natively under it, so the ruler could rest on a fragment ('0:05', '0'). A single mask subscribes to the viewport scroll with useSyncExternalStore, finds the one label the header slices, and covers it when the scroll settles. Moving it live on every tick crossing cost 2-3x the viewport gate's budget on a large unvirtualized timeline, so mid-scroll a fragment can still show.

* fix(studio): the ruler mask never stays off after its scroll listener is swapped

Unsubscribing mid-scroll left scrollingRef true, so the mask stayed hidden until the next scroll. Reset it with the settle timer.
2026-09-24 14:03:23 -04:00
Miguel Ángel 6a5fcd4cf2 fix(studio-server): preview keeps its base URL when a script mentions a <base> tag (#4426)
* fix(studio-server): preview keeps its base URL when a script mentions a <base> tag

* test(studio-server): pin the sub-composition preview's base check

* fix(studio-server): find a <base> with the HTML parser, not a regex
2026-09-24 13:40:52 -04:00
Miguel Ángel ce2b338710 fix(producer): a mounted composition's local script runs after the inline script before it (#4425) 2026-09-24 13:25:39 -04:00
Miguel Ángel 8dabc8af63 fix(registry): glass-shard-title renders when mounted, and 4 skipped blocks install again (#4423)
* fix(registry): glass-shard-title renders when mounted, and 4 skipped blocks install again

* fix(registry): glass-shard-title loads in Studio and ?play, and its docs payload fails loudly

* test(scripts): pin the glass payload's hdr guard for any quote style
2026-09-24 13:08:15 -04:00
Miguel Ángel b9e5a16fcf fix(player): a play() made while the composition loads still plays (#4429)
* fix(player): a play() made while the composition loads still plays

The iframe's own load event reset the asset wait, and that reset also cleared the queued play,
so a play() between the first ready and load was dropped with no error. Play intent now leaves
the asset-wait reset: only pause, seek, disconnect and a new src or srcdoc clear it.

Also adds assets-loading-ui (player | none): none never shows the loading-assets card, while
the assets-loading attribute and the assetsready and painted events still fire.

* fix(player): switching the loading card off keeps a shader load on screen

* fix(player): the loading card and a shader load no longer hide each other
2026-09-24 12:48:55 -04:00
Miguel Ángel 8399a1d904 Merge pull request #4428 from heygen-com/release/v0.8.73
chore: release v0.8.73
v0.8.73
2026-09-24 12:32:22 -04:00
Miguel Ángel 6f7f463fc5 chore: release v0.8.73 2026-09-24 11:53:31 -04:00
Miguel Ángel a3d7736d40 fix(studio): trimming a scene no longer shrinks it to the length of a video inside (#4421)
A clip's source length came from the first video, audio or image found inside
its element. For a composition that is media on the scene's own timeline, so a
scene holding a 1.8 s video could not be longer than 1.8 s: trimming its end by
a few pixels collapsed it to that length. A composition host no longer lends its
inner media to the clip; a plain wrapper around one video still does. An inline
composition still shows its inner video or image as its thumbnail.
2026-09-24 11:49:11 -04:00
Miguel Ángel 096a586b41 ci: editing a PR's text no longer cancels its running checks (#4420)
* ci: editing a PR's text no longer cancels its running checks

CI, regression and Windows render re-run on pull_request edited (for base changes) with cancel-in-progress, so a body or title edit cancelled the running checks and the fail-closed Test aggregator went red on the cancelled run. Only a push or a base change cancels now; a text edit queues behind the running run.

* test(ci): read the workflows folder by path, not as a module URL

Fallow treated new URL('../.github/workflows/', import.meta.url) as an unresolvable import. Use import.meta.dirname and join, as scripts/check-skill-mirror.mjs does.
2026-09-24 11:19:00 -04:00
Miguel Ángel cbaddec51a fix(engine): download tests stop racing real deadlines, and failures name their cause (#4419)
* test(engine): hold the download attempt deadline so slow runners cannot trip it

urlDownloader tests passed a real 1s deadline that a slow Windows attempt outlasted.

The deadline timer is now held; the one test that needs it fires it by hand.

* fix(engine): name the underlying error when a download fails, without its URL

* test(engine): fire the stalled-body deadlines by hand instead of racing a real 20 ms timer
2026-09-24 10:58:53 -04:00
Miguel Ángel fed45bac7c fix: serve projects and assets whose names contain @ % # & or ? (#4415)
* fix(studio-server): serve projects whose names contain @ % # & or ?

Five routes cut the decoded project id out of Hono's c.req.path, which leaves %40 %25 %23 %26 %3F
encoded, so the cut missed and files, preview assets, sub-compositions, thumbnails and waveforms
404ed or 403ed for those names. One helper now takes the sub-path by segment from the raw URL.

* fix(studio-server): thumbnails and waveforms stay inside the project for any name

The thumbnail route built its preview URL from the raw project id and path, so # ? and % broke it,
and neither route checked that the decoded path stays inside the project. Thumbnails now use the
composition guard and an encoded URL; waveforms use the read-only asset guard.

* fix(cli): play and present serve assets whose names contain @ % # & or ?

Both /composition/* routes cut a prefix out of Hono's c.req.path with no decode. They now share
studio-server's requestSubPath (renamed from projectSubPath and generalised to any route) with the
five studio-server routes, so one helper owns how a request path becomes a file path.

* fix(studio-server): a thumbnail request for the project folder answers 404

An empty thumbnail path resolved to the project directory and crashed reading it (500).

* fix(studio-server): read the thumbnail's composition with one open

Checking the path with stat and then reading it let the file change in between (CodeQL
js/file-system-race). One open now serves the type check, the mtime and the read; a missing file
still thumbnails from the preview as before, anything that is not a file answers 404.
2026-09-24 10:38:56 -04:00
Miguel Ángel 1926903b6a fix(studio): an edit still reaches the preview when its reload is slow (#4418)
A shadow reload that was not ready within one 15 s budget was discarded for
good: the stage kept the previous document, the edit never showed, and nothing
retried. On a busy machine that dropped deletes, trims and agent writes. The
shadow now keeps loading for up to three budgets before it is dropped, so a
slow load still lands and a load that never becomes ready still fails.
2026-09-24 10:24:34 -04:00
Miguel Ángel 7e8d2bd1f3 fix(cli): stop check --at false alarms and name a stale CLI version (#4417)
* fix(cli): stop check --at failing still frames as a frozen timeline

* fix(cli): warn when the running CLI is older than the project's pin

* fix(cli): keep judging spread samples when check --at is combined with other gates
2026-09-24 10:09:21 -04:00
Miguel Ángel 881481f56b feat(studio): an embedder can change what the shortcuts panel lists (#4416)
* feat(studio): an embedder can change what the shortcuts panel lists

The panel read a private list, so an app that embeds PlayerControls could not drop a key Studio binds but it does not (bare S split). The list is now exported as DEFAULT_SHORTCUT_SECTIONS, PlayerControls takes shortcutSections, and the dispatcher names its bare F, S and R keys from the same module. Defaults unchanged.

* fix(studio): an embedder's shortcuts list may repeat a key or a section title

Rows were keyed by their key text and sections by title, so a relabelled list with a repeat hit React duplicate keys. The list is static, so rows and sections are keyed by position.
2026-09-24 09:45:49 -04:00
Miguel Ángel 6c9216e8fd fix(producer): report render progress from the first frame, at most four times a second (#4414)
* fix(producer): report render progress from the first frame, at most four times a second

Capture loops reported every 30th (or 10th) frame, or every single frame, so a render sat silent through browser warm-up and the first 30 frames, then flooded on the sequential paths. One helper now owns the cadence for every capture stage: first frame, last frame, and every 250 ms in between. Browser GPU check, capture calibration and worker start-up each report their own step.

* fix(producer): always report the first captured frame and keep one telemetry code for browser start-up
2026-09-24 08:46:45 -04:00
Miguel Ángel aa2c2c68fe fix(studio): a timeline at 00:00 stays at 00:00 when the window resizes or the zoom is set (#4411)
The centre-anchor step ran on every scale change, including a window resize and a zoom set by a host, and from scroll 0 it always moved the view right, hiding 00:00 under the track header. A view at the start now stays there; a scrolled view still keeps its centre time.
2026-09-24 08:32:05 -04:00
Miguel Ángel 55bd0ceba9 Merge pull request #4407 from heygen-com/release/v0.8.72
chore: release v0.8.72
v0.8.72
2026-09-24 08:19:21 -04:00
Miguel Ángel 6a0fd68584 chore: release v0.8.72 2026-09-24 07:23:30 -04:00
Miguel Ángel 326df8def0 feat(studio): let an embedder draw the preview edge to edge with fillBox (#4408)
NLEPreview always drew its picture 8px in from each side on a dark band, so an embedder that sizes the box to the film saw a smaller picture in a black frame. fillBox drops that inset from both the layout and the stage-size math. Default look is unchanged.
2026-09-24 07:22:26 -04:00
Miguel Ángel 261af22c16 fix(core): frame captures wait for a seeking video to land its frame (#4406)
* fix(core): frame captures wait for a seeking video to land its frame

A paused seek set video.currentTime and returned; the seek-completion barrier resolved while the
video was still seeking, so a capture right after the first seek on a fresh preview painted the
previous frame. The media sync now joins each seeking video to that barrier until seeked, error,
emptied or abort.

* style(core): format the seek barrier test
2026-09-24 07:07:59 -04:00
Miguel Ángel 115332f4f6 fix(core): a section whose root sets pointer-events:none stays pickable (#4399)
* fix(core): a section whose root sets pointer-events:none stays pickable

* fix(core): the picker enables one sheet per hit test instead of mutating the DOM

* fix(core): the picker override lives in an adopted sheet, never in the saved document

* fix(core): the picker override wins over an author !important on a section root

* docs(core): name the picker override ceiling and why the fallback stays unlayered

* fix(core): a click-through overlay host keeps passing picks through

The override now targets section inner roots only, so a host the parent author set to pointer-events:none stays pass-through. Inner roots that were none before the override are never pick targets themselves, an inner root gets a unique selector instead of its bare tag, and a page with no pass-through root skips the override and its restyle.

* fix(core): picks reach content under a composition's own pointer-events:none root
2026-09-24 06:21:33 -04:00
Miguel Ángel 58c873d816 chore: release v0.8.71 (#4395) v0.8.71 2026-09-23 23:32:31 -07:00
Vance IngallsandClaude Fable 5.1 84e36fc16a feat(runtime): data-vfx-chain backdrop capture and two-source kernels (luma-matte, noise) (#4330)
* feat(runtime): data-vfx-chain backdrop capture and ref second sources

Interface v1.1's two remaining capture shapes, in the runtime.

`backdrop`: a host resolves its source from a sibling
`<canvas layoutsubtree class="hf-vfx-src" data-vfx-for="<host id>">`
before its own child wrapper, so the same kernel reads either its own
pixels or every layer below it — which element the wrapper holds is the
exporter's placement, not a def's declaration. `VfxEntry.capture` now
reports what was resolved rather than what the defs asked for.

The trap that shape creates: a `backdrop` wrapper lives OUTSIDE the host,
and children of a `layoutsubtree` canvas are never painted by the page
compositor. Skipping a hidden host the way a `self` host is skipped would
therefore delete every layer below the adjustment instead of passing it
through. A hidden backdrop host now still captures and KEEPS the bitmap,
painting no kernel.

`ref` params: a node's second source, captured into `u_src2` from the
wrapper the exporter emitted around the referenced element. Per node, not
per entry, and shared when two nodes name the same element. Every source
is drawn at the host's device size so `u_src`/`u_src2`/`.hf-vfx-out`
share one coordinate space, and each source's canvas gets its own
`paint` wait — paint records are per canvas. A source the clip runtime
has hidden captures EMPTY rather than waiting out a paint that cannot
come and then throwing "No cached paint record" every frame; an empty
matte is also what After Effects shows outside a matte layer's window.

Also: a `.hf-vfx-in` that measures 0×0 is now reported once instead of
capturing nothing in silence, and one that is not the canvas's immediate
child is refused at registration instead of throwing every frame. Both
are measured Chrome rules (vault `layoutsubtree-capture-rules`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(runtime): luma-matte and noise two-source vfx kernels

`luma-matte` is the first def with a `ref` param: `out = src · coverage(u_src2)`
with Alpha / Alpha Inverted / Luma / Luma Inverted coverage. It is not one
After Effects effect but the runtime form of all of them — unsupported
track-matte sources, stencils, silhouettes, Set Matte — which the exporter
distinguishes by choosing what the host wraps and what `matte` names. `src`
is premultiplied, so one scalar multiply covers all four channels, and Luma
reads the matte over black, which is what a premultiplied texel already holds.

`noise` ports `ADBE Noise` from `AEFX_Noise.metallib`: Bob Jenkins' 1997
integer mix over `(x, y, seed32)`, the two glibc-style LCG rounds, and the
`0x7F80`/`0xFF` byte the IR XORs out of them — no table, no state, so it is
seek-safe by construction. Reading the metallib's struct metadata rather
than the deep dive's prose settles two of its open questions:
`ADBE Noise-0002` is `inUseColorNoise` (a checkbox), not a Uniform/Gaussian
popup, and the mystery seed floats are `inRandomSeed_High`/`_Low`, the two
halves of one 32-bit seed. What the CPU puts in those halves per frame is
still unprobed (plan Task 4.3), so the kernel seeds the low half with the
frame index; the tests assert distribution and seek determinism, not parity.
The shader declares `precision highp int` — the whole port rests on 32-bit
wraparound and a fragment shader's integers default to mediump, which ESSL
guarantees only 16 bits of.

Both defs declare `capture: "self"`, which says a texture is needed and
nothing about whose: `noise` on an adjustment layer is the same kernel, and
the runtime reads the `backdrop` placement off the DOM.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(vfx): browser checks for backdrop capture, luma-matte and noise

Task 2.6 Step 5 and Task 2.7's owed browser checks, folded into the
existing puppeteer harness: an identity backdrop kernel composites two
stacked blocks captured through a data-vfx-for sibling wrapper back
over themselves (PSNR >= 40dB vs a plain-DOM control panel); a backdrop
host outside its own clip window still keeps those blocks on the
wrapper canvas's own bitmap via capturePassThrough; luma-matte's four
modes are checked against alpha- and luma-varying matte strips at four
sample points each; noise is checked for amount=0 identity, same-time
determinism, and a changed pattern when its only seed (seek time)
changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(runtime): external displacement maps and visible ref sources

Two gaps the exporter side found in the `ref` plumbing.

1. `displacement-map` had no `map` ref param, so the exporter's
   `params.map = "<element id>"` was dropped by `normalizeVfxParams` (it
   iterates the def) and the node silently self-displaced. `map` is now a
   ref param, bound to `u_src2` and selected by a new `u_hasSrc2` uniform
   the runtime sets from whether the ref resolved. Refs gain
   `optional: true` — empty `map` is the self-referential form every
   corpus instance uses, while an empty `matte` stays a loud failure — and
   a ref naming its own host resolves to self rather than capturing the
   same pixels twice (on a backdrop host it would otherwise have read the
   layers below as the map).

2. Interface v1.1 assumed a ref source is invisible in After Effects.
   retro-wave `Logo Anim` layer 5 is layer 4's displacement map AND an
   enabled, opaque layer, and the runtime cleared its wrapper's bitmap
   after the upload, deleting it from the frame. A wrapper marked
   `data-vfx-ref-visible` now keeps its bitmap and is captured at the
   REF's own box instead of the host's, so it paints at its own size and
   resolution. The three capture shapes are one `{ upload, keepBitmap }`
   mode: texture only, bitmap only (a backdrop pass-through), or both.

Sampling is unaffected by the box choice, and the browser test proves it
rather than assuming it: `drawElementImage` scales the element into the
whole canvas, so a 320px-wide matte's four strips still land as four
40px bands in a 160px host. The v1 rule — a ref is scaled into the host's
box, not placed in composition space — is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(runtime): capture a visible ref while its host is hidden; review nits

Addresses PR #4330 review feedback (jrusso1020, CHANGES_REQUESTED).

Blocking: both the engine-mode and preview-mode capture paths treated
"host not paintable" as binary — a backdrop entry got a pass-through
capture, everything else was skipped outright, including a ref source
that lives on a different, independently-visible element (e.g.
retro-wave's Logo Anim layer, which is simultaneously layer 4's
displacement map and its own fully opaque visible layer). Adds
visibleRefSources()/captureVisibleRefsOnly() and a unified
FrameCaptureMode discriminator so a visible ref still gets captured
even while its own host is hidden, on both paths.

Non-blocking, addressed:
- luma-matte's mandatory ref that names its own host now resolves to
  an explicit RefResolution {kind:"self"} and binds u_src2 to unit 0,
  instead of relying on an unbound sampler's implicit default.
  displacement-map's optional self-ref is unaffected (still folds into
  {kind:"none"}, using its existing u_hasSrc2 shader branch).
- noise shader now enforces rgb<=a (valid premultiplied alpha) after
  adding noise, independent of u_clipping — documented why u_clipping
  is currently a permanent no-op on this runtime's 8-bit render
  targets.
- added a hash-pinning test for hfJenkinsMix; documented, with a
  brute-force proof, which one of the reviewer's suggested mutations
  is unobservable within this fixture's reachable seed range.
- fixed two misplaced doc comments.

fallow flagged resolveVfxCapture/resolveRefSource as newly over the
complexity threshold after this diff; extracted resolveEmptyRefParam()
and captureHiddenEntry() to bring both back under.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(runtime): capture a visible ref while a hidden BACKDROP host passes through

Re-review of #4330 at b6d51bd9: the visible-ref-while-hidden fix only
covered non-backdrop hosts. captureHiddenEntry returned
capturePassThrough(entry) for a backdrop and never reached
captureVisibleRefsOnly; frameCaptureMode picked "passThrough" before
considering refs and sourcesForFrame("passThrough") was [entry.src]
alone. An adjustment layer whose displacement map is itself a visible
layer still lost that layer outside the adjustment's window.

- captureHiddenEntry runs the pass-through AND the visible-ref capture,
  OR-ing the results.
- sourcesForFrame("passThrough") is the backdrop wrapper plus any
  visible refs, and capturePaintedHost's pass-through branch also calls
  captureVisibleRefsOnly.
- Tests for the backdrop+visible-ref shape in both engine and preview
  modes (the reviewer's probe).

Also closes the non-blocking display:none item: isPaintableHost(src.inner)
missed a ref whose OWNING element is display:none, since display does not
inherit — the preview path waited out the 2 s ceiling, then failed with a
misleading 0×0 report. New isPaintableSource walks inner's ancestors for
display:none (visibility inherits, so one read suffices there); used at
both source-side sites. checkVisibility()/offsetParent are absent or
always-null in the unit harness's DOM. Test added; verified it fails on
the old inner-only check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 23:25:20 -07:00
Miguel Ángel d1048cf98f fix(studio): ship component styles as their own stylesheet for embedders (#4393)
* fix(studio): ship component styles as their own stylesheet for embedders

Studio's component rules (timeline clips, loaders, range inputs, toasts and
the rest) lived only in the app sheet, so a page that embeds Studio's
components with theme.css got unstyled clips and dark-on-dark labels. They
move unchanged into components.css, exported as
@hyperframes/studio/components.css; studio.css keeps the app shell and
imports it, so Studio's compiled CSS has the same rules.

* test(studio): read moved component rules from components.css

* test(studio): catch class rules nested in the app sheet's layers and media blocks
2026-09-24 01:35:04 -04:00