mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
9bb4f5cd571dcf2910078a99efcb65a5d616f917
4401
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9bb4f5cd57 |
feat(catalog): browse the catalog as a live grid, not a static index (#4051)
* feat(catalog): browse the catalog as a live grid, not a static index
The Catalog overview page now mounts a browse grid (posters, tags,
per-category rows, expandable sections) generated over the existing
~380 registry items, instead of one static hero video plus two links.
docs/snippets/catalog-gallery.jsx and docs/catalog-gallery.css are
ported from a reference implementation of this same UX; the CSS keeps
its original comments as-is (NO_ESSAY_COMMENTS_OK: vendored file).
scripts/sync-docs-catalog.mjs and build-docs-gallery-nav.mjs are new,
generating the gallery's data and the Catalog tab's nav directly from
this repo's registry/*/registry-item.json and docs.json, so no sparse
clone or snapshot of another project is needed. scripts/check-docs-catalog.mjs
proves every catalog page from before this change still exists, on
disk and in the sidebar, with an identical page list.
No catalog page or snippet is removed; the previous overview content
(catalog-overview-player.jsx) is unused on this page but stays in the
repo.
* refactor(catalog): share the docs-catalog helpers across the sync scripts
Three independent adversarial reviews (reuse, quality, efficiency) all
converged on the same shape: sync-docs-catalog.mjs, build-docs-gallery-nav.mjs
and check-docs-catalog.mjs each hand-wrote the same docs-root resolution,
the same catalog-gallery-data.mdx parse, and the same Catalog-tab lookup.
Extracted into scripts/docs-catalog-shared.mjs.
sync-docs-catalog.mjs also ran build-docs-gallery-nav.mjs as a dynamic
import purely for its side effect, forcing a same-process re-read and
re-parse of the data file it had just written. build-docs-gallery-nav.mjs
now exports buildNav(docs), called directly with the path already
resolved; its CLI entry is guarded for standalone use.
slug() dropped the /^-+|-+$/ trim in favor of the character-scan trim
packages/core/src/tokenSlug.ts already uses for the same reason (CodeQL
flags that regex as polynomial ReDoS, js/polynomial-redos); not imported
directly since tokenSlug.ts is compiled TS with a different domain
(CSS variable naming) and no existing scripts/*.mjs imports packages/core
source at plain node runtime.
Fixed one bug found while re-verifying after the refactor: the recursive
nav walk wasn't idempotent against its own generated output — re-running
sync-docs-catalog.mjs a second time saw the "Catalog" wrapper buildNav()
adds and misattributed every flat, single-section group under it, 9
groups collapsing to 7. walk() now treats "Catalog" as transparent, the
same way it already treated "Overview". Verified: three consecutive runs
from a fresh origin/main checkout now produce identical 9 groups / 400
items each time, and check-docs-catalog.mjs still passes.
* chore(catalog): scrub an internal decision code from a code comment
* fix(catalog): stop losing a section's label when its nav is flattened
An independent adversarial review found real data loss: build-docs-gallery-nav.mjs
collapsed a group with exactly one section into a flat page list (a clean sidebar
for the common case), but sync-docs-catalog.mjs's walk() derives an item's section
from nav depth, so a second sync run over that flattened nav could no longer
recover the section name and silently fell back to the group's own label. Data &
charts / Data was the one real casualty in this catalog; Carousels and Blocks
happened to already have a section named the same as their group, so the loss
was invisible there.
Fix: only flatten when the section label equals the group label, so nothing
non-recoverable is thrown away. Verified with a deliberate mutation (reverted the
condition, reproduced the exact bug from a clean origin/main checkout, confirmed
the new check-docs-catalog.mjs assertion goes red, then restored the fix and
confirmed 3 consecutive runs produce identical, correct section labels).
check-docs-catalog.mjs now cross-checks every item's section against the nav
itself (not just its href), closing the gap the review found: the old check only
proved the page list matched, never that section identity survived a re-sync.
Also deletes docs/snippets/catalog-overview-player.jsx: unreferenced anywhere in
the repo now that catalog/index.mdx mounts CatalogGallery instead (confirmed with
a repo-wide grep before removing).
* chore(catalog): allowlist the deliberate catalog-overview-player.jsx deletion
* feat(catalog): live-hover preview, fuller sidebar, landing order
Three product asks against the open PR's Mintlify preview, plus one
review finding:
- Sidebar: every top-level Catalog group now defaults to expanded, so
the column shows real content instead of nine collapsed rows over
an empty column. Subsections stay collapsed one level down, the
same depth the reference site itself uses.
- Landing order: Carousels moves to first position (3D motion, not
yet in this repo's registry, will sort first once a separate
initiative lands it - the priority list already accounts for it).
Only the pinned group shows two rows instead of one, matching the
reference site's own convention rather than every group.
- Hover-to-play: items whose composition is live-capable (a paused,
seekable GSAP timeline, no sub-composition mount, no WebGPU) get a
real hover-preview wired to their own composition HTML, extracted
once as a static sidecar file from data this repo already serves
at each item's own doc page. Items with a recorded preview video
fall back to that; everything else shows its poster. Verified no
WebGL/three.js item is ever marked live (238 live, 30 video, 132
poster-only of 400).
- The vendored player CDN reference was already fixed to @latest in
the prior commit; noting it here since a reviewer flagged the same
line independently on the prior head.
docs/public/catalog/gallery-live/**/*.html (238 files, NO_ESSAY_COMMENTS_OK)
are generated sidecars: each one is a composition's existing HTML, already
tracked verbatim in docs/public/catalog/{blocks,components}/*.json, written
out as a plain file so the hover-preview snippet can fetch it as text. Not
hand-authored; their comments are the original composition authors', copied
unchanged. Regenerated by scripts/sync-docs-catalog.mjs on every run.
* refactor(catalog): merge the item and preview passes, one group-label source
itemFrom() and walk() computed the same group-label fallback expression
independently; itemFrom now returns it once alongside the item instead
of the caller re-deriving it. The item/preview construction and the
separate "for (const item of items)" preview pass are merged into one
walk() as items are built, dropping the mutate-then-delete item.live
scratch field. Byte-identical generated output, confirmed by diff.
* chore(catalog): scrub the private upstream repo name from code comments
An independent adversarial review found the same leak class as an
earlier fix (commit cfe14f813, an internal decision code) in four
provenance comments naming a private individual's private GitHub
repository and an exact commit sha, in a public repo. Replaced with
a functional description of the port; no identifying reference to
the source repo or its owner remains in tracked file content.
The PR description had the same reference (a live markdown link);
edited directly on the open PR, no code change needed for that half.
* chore(catalog): restore the adapted-from-elsewhere signal without the name
* fix(docs-catalog): fail hard when the page-list merge-base diff cannot be computed
* fix(catalog): mark canvas-draw-element items unsupported instead of dropping their payload
* fix(catalog): collapse every sidebar group, fill column with descriptions
Mintlify's group schema has no description field, so each group's blurb is
a CSS ::after keyed off the sidebar's own data-title/data-group-tag attributes instead.
NO_ESSAY_COMMENTS_OK: false positive.
The real /* */ block is 3 lines. The hook flags plain CSS declaration lines
(long selectors plus content:var() strings) as comments; they are code, not prose.
* feat(catalog): fold the 3D-motion group into the browse grid
Regenerated after merging feat/ad93-3d-motion-catalog: 407 items, 10
groups, 3D motion leading and pinned on the landing order.
* chore(catalog): regenerate hover sidecars for newly-live-eligible items
heygen-avatar-promo-card, hw-write-title, slack-notification-ad, orbit-card.
NO_ESSAY_COMMENTS_OK: generated composition HTML, not hand-authored.
* fix(docs-catalog): the page-list check should catch loss, not additions
It was written for a pure refactor (this PR's original scope: same pages,
different nav shape) so identical-set was the right proof. Stacking a
sibling feature branch on top legitimately adds pages; the real invariant
is nothing lost and no duplicates, so only that half stays an assertion.
* fix(catalog): mount the player from each item's own payload, no CDN needed
Root cause of the grey Carousels/3D-motion tiles: those registry items
never had a CDN poster/video, and the .html hover sidecar 404s on the
deployed docs host (Mintlify drops .html/.js/.css from docs/public with
no error). Cards now fetch the same repo-served .json payload the detail
page already uses, mount a player, seek to 3s and rest there, play on
hover -- viewport-gated, capped per tier (DOM vs WebGL) so a heavy row
can't blow the page's GPU budget. Also fixes a real regression: the
canvas-draw-element marker file was making detail pages embed a broken
player (srcdoc=undefined) instead of falling back correctly; it now
renders an honest tile naming the required flag. Deletes the now-dead
.html sidecar mechanism and its vendored gating helper.
* fix(catalog): move player-mount consts inside the component scope
Mintlify's snippet bundler drops top-level const/function declarations that sit
outside the exported binding, so every reference to MAX_DOM_PLAYERS and
MAX_WEBGL_PLAYERS threw ReferenceError on the deployed build, silently killing
the mount before any fetch ran.
Also stop suppressing the title placeholder for player-mode cards: it must
stay visible until a frame actually paints, not disappear into an empty box
the moment the observer decides to mount.
* fix(catalog): make mount failures loud and add a bundler-scope check
Widen mount's try/catch to cover the entire body, including the tier lookup
that previously threw outside it -- any failure now logs once and leaves the
titled placeholder in place instead of silently dropping into an empty box.
Add a check-docs-catalog.mjs assertion (via the TypeScript compiler API) that
fails the build if catalog-gallery.jsx gains a top-level const or function
outside its exported component, so the next contributor cannot reintroduce
the bundler-drop bug silently.
* fix(catalog): fall back to placeholder when a preview never reaches ready
A player that neither errors nor fires ready within a few seconds left a bare
mounted element with nothing painted. Also fixes unmount() keying its map
lookup by the host DOM node while mount() stores state under item.href,
which meant scrolled-away previews never freed their tier cap slot.
* docs(catalog): trim an essay-length comment in docs-catalog-shared.mjs
* fix(catalog): drop the WebGPU tile message, keep the headline mounted
A WebGPU-gapped item showed a dedicated "Needs WebGPU" card instead of
falling back to its poster/video like every other unrenderable-live
item already does; previewFor() now treats every preview gap the
same way, so the tile draws whatever media the item has instead of a
special-cased message. Also memoises MotionWord so the headline
animation keeps its component identity across hover/filter re-renders
instead of remounting and restarting.
* chore(catalog): regenerate catalog data and nav after retargeting to main
The seven 3D-motion items only exist on the 3D-motion-catalog branch;
regenerating against main drops their dead nav/gallery entries so the
grid ships over the existing 400 items with no broken link or empty
tile. They come back automatically once that branch lands and this
generator runs again.
* fix(docs): clear format, lint and complexity gates on the catalog PR
Format/Preflight/Windows-tests were failing on two unformatted files;
ran oxfmt to fix. Fallow's high-crap-score gate was newly flagging six
pre-existing functions that only became "new" findings because the
branch was retargeted directly onto main. Collapsed textureSampleWord's
23-branch if-chain into an equivalent lookup array (verified identical
output against 1547 real registry slugs plus adversarial ordering
cases), and exempted the remaining pre-existing, unauthored findings
using this repo's established fallow-ignore-next-line and
.fallowrc.jsonc health.ignore conventions.
* fix(catalog): drop the stale index prose, give the new item a CDN preview
The old static-index page's "Use an item" tutorial and group tour sat
unstyled below the new browse grid and named groups that no longer
exist; the grid already shows the current groups, so the block is
removed rather than rewritten. Kept the required Related topics
section.
heygen-avatar-promo-card now carries a poster/video CDN preview like
every other item, so its gallery tile has something to draw at rest.
* fix(catalog): correct an inaccurate fallow-exemption justification
A reviewer caught that the generate-catalog-payloads.ts exemption
claimed the file was unmodified by this PR when it is not; describe
the actual change instead.
* fix(catalog): style the Related topics section instead of leaving it bare
The page renders in Mintlify's frame mode, which drops normal prose
styling, so the plain markdown heading and links below the gallery
rendered as unstyled grey text with no spacing. Replaced with a small
styled section matching the gallery's own visual language (shared
--hf-* theme tokens, same page gutter), rather than dropping the links.
* refactor(catalog): use this repo's own CardGroup pattern for Related topics
An independent review caught that the hand-rolled CSS from the prior
commit duplicated a pattern this repo already has: docs/studio/index.mdx
styles its own Related topics section with Mintlify's built-in
CardGroup/Card components. Switched to that instead and dropped the
custom CSS. Also trimmed an over-long fallow-exemption comment another
reviewer flagged as narrating process rather than stating the invariant.
* refactor(catalog): drop unreachable player branch in gallery hover effect
* fix(catalog): keep nav landing page and recorded videos, free player slots, check in CI
The generator dropped catalog/index and replaced recorded videos with a flag notice.
A card scrolled away before ready released its player slot twice.
check-docs-catalog now runs in CI.
* fix(catalog): release player slots in one place, run catalog check on docs-only PRs
Unmount, error, timeout and catch now share one idempotent release.
The Lint job also runs when only docs or registry files change.
* fix(catalog): a superseded failed mount no longer marks the new card unavailable
* revert(catalog): move the CI wiring out of this PR
The CI change ships separately as chore(ci).
* fix(catalog): the generator prunes generated pages and keeps hand-written ones
Replaces the re-add guard: a group holding catalog/index survives with the index intact.
|
||
|
|
a7ec66b124 |
fix(producer): cache the google fonts css lookup per process (#4097)
* fix(producer): cache the google fonts css lookup per process Studio compiles every composition in a project separately for its sidebar thumbnails and for gsap-animation extraction. Each compile called injectDeterministicFontFaces, which re-fetched the same font family's CSS metadata from fonts.googleapis.com over the network every single time -- only the woff2 binary files were cached to disk, not the CSS lookup itself. A project with a dozen small compositions and three declared font families fired dozens of redundant network round trips serially, holding the editor's asset-readiness overlay well past its own 8-second timeout. Adds an in-process cache for the CSS lookup keyed by the exact request URL, self-evicting on failure so a later call still retries. Measured on a real project: gsap-animation extraction across 12 compositions dropped from ~16.4s to ~2.9s wall time, and per-composition thumbnail generation dropped from 9-11s each to 10-14ms each once the cache is warm. * fix(producer): isolate the google fonts css cache from any one caller's abort An adversarial review of the process-wide CSS-lookup cache found two real bugs before it shipped. First, the cached promise carried whichever caller reached it first's AbortSignal, so one render's cancellation could fail a completely unrelated concurrent render sharing the same font family. Second, the cache has no reset between test files running in one process, so two sibling test suites asserting per-call fetch counts against the same Google Fonts URL silently saw each other's cached result. Strips the caller-specific AbortSignal from the shared fetch and races each caller's own signal against it independently, so a caller can still bail out on its own cancellation without affecting anyone else awaiting the same lookup. Clears the cache in the two sibling test files' own beforeEach. * fix(producer): key the google fonts css cache by fail-closed mode and drop transient statuses |
||
|
|
15460ec541 |
fix(core): resolve already-errored media immediately in the asset-readiness gate (#4096)
The shared composition-readiness gate only listened for future canplay/error events on pending media, so an asset whose error already fired before the gate started listening was never seen -- a DOM error event doesn't refire. That left the gate burning its full 8s timeout on every load. Measured on a production composition with 85 duplicate small audio elements pointing at missing files: 8.68s to 0.83s with this fix, no timeout warning. |
||
|
|
a0e7c43921 |
fix(catalog): fall back to recorded video when directory is over budget (#4084)
* fix(catalog): fall back to recorded video when directory is over budget hostItemDirectory() returned "" for both "not needed" and "over the 2MB host budget", so an item that needed its own hosted directory but exceeded the cap (texture-mask-text: 5.6MB of mask PNGs referenced via runtime JS template strings, invisible to the static asset scanner) silently shipped a payload with no <base href> and dead relative image references, 404ing on the docs site. It now returns a tagged result so the caller can fall back to the recorded video on over-budget, same as the existing unresolved-assets fallback. * test(catalog): cover the over-budget skip path in the payload generator * chore(catalog): allowlist the deliberate over-budget payload deletion * chore(catalog): dedupe hosted-directory assertions, note pre-existing complexity Fallow's new-only gate flagged two problems this branch introduced: five near-identical hostItemDirectory assertions in the file-race test collapse into a shared expectHosted() helper, and the over-budget branch added to buildPayload pushed it one branch past the complexity threshold on top of already-high pre-existing debt (CRAP 240 before this branch, unrelated to the fix). Suppressed with the repo's existing fallow-ignore-next-line convention rather than refactoring an unrelated 115-line function inside a bugfix PR. |
||
|
|
cc36905c29 | fix(studio): resolve a multi-clip selection's network probes in parallel (#4088) | ||
|
|
d6a0c407d7 | fix(registry): scope the 12 vscode-theme blocks' root id and fix their asset path (#4094) | ||
|
|
2279333990 |
fix(registry): register 4 async-built timelines only after they are built (#4086)
code-3d-extrude, code-particle-assemble, code-shader-dissolve and code-snippet-flight each registered window.__timelines[id] with an empty, paused timeline before document.fonts.ready populated it. The sub-composition readiness gate treats key presence as ready and nests the child once, so mounting any of these as a sub-composition rendered blank. Moved registration to the end of the fonts.ready callback and added the hfForceTimelineRebind re-nest call, matching the pattern already used by code-highlight, code-typing, code-morph and code-scroll. |
||
|
|
81ca5f4187 |
fix(registry): vfx-iphone-device stops gating its screens on rAF (#4083)
* fix(registry): vfx-iphone-device stops gating its screens on rAF The device screens only started rendering after two requestAnimationFrame ticks, which run on wall-clock time and are not guaranteed to fire in every render environment, risking a render that never registers its timeline. Swapped in the same canvas.requestPaint()/setTimeout paint-wait helper the liquid-glass items already use for the same one-time screen-capture handoff. * fix(registry): wait for both capture canvases on vfx-iphone-device The raf-desync fix only waited on the laptop canvas before capture, leaving the phone canvas with no explicit paint confirmation. |
||
|
|
42f947f4c3 |
fix(studio): select and reveal a timeline clip after a drop (#4067)
* fix(studio): select and reveal a timeline clip after it's dropped Dropping an asset, block, or composition onto the timeline placed the clip but never selected it, so the user had to go find it. Wire the same select+reveal pair AssetCard/AudioRow already use into all three drop paths via a shared selectAndRevealTimelineElement helper. Root-caused a shared bug along the way: useTimelineSelectionPreviewSync compared the resolved selection count against only the ids already present in timelineElements, not against every id actually wanted. A freshly-selected id not yet in timelineElements (a just-dropped, pasted, or duplicated clip) made that comparison pass on zero resolved out of zero expected, falling through into a loud selection clear instead of the intended quiet retry. Fixed the comparison and covered drop, paste, and duplicate-of-a-group. * refactor(studio): drop the dead fallback in the new store-key helper Simplify-pass finding: both new selectAndRevealTimelineElement call sites always pass a DOM id they just minted, so the `?? fallback` after deriveTimelineStoreKey (which only returns undefined for a domId-less, selector-less caller) could never run, and the pattern was duplicated at both call sites. Added a non-optional variant for callers that already hold a guaranteed DOM id, and dropped the now-redundant "old code" narration from a test comment. * fix(studio): cap the selection-sync retry so a dead member cannot stall the group The bail-and-retry added for the drop/paste/duplicate race had no upper bound: a group member that can never resolve (deleted out of band, a stale id) made the whole group's selection retry forever instead of settling. Cap it at a few attempts, then apply whatever did resolve, same as the pre-fix graceful degradation, only bounded now instead of unconditional on every sync. * fix(studio): select and reveal a block dropped onto the preview canvas handlePreviewBlockDrop (a real drag-and-drop onto the preview pane, wired from usePreviewBlockDrop) was the fourth caller of addBlockToProject's hostKey but never selected the new element, unlike the other three drop paths this change wires up. * fix(studio): close a stale retry-budget gap and dedupe the store-key helpers Post-rebase simplify pass and independent review against the new head: deriveTimelineStoreKeyForDomId now calls deriveTimelineStoreKey instead of reimplementing its body, the composition-insert path uses the same helper instead of hand-building the sourceFile#domId format, and the selection-sync retry counter resets on deselect so reselecting an already-exhausted, permanently-unresolvable id gets a fresh grace window instead of degrading on the first attempt. Adds coverage for the asset-drop selection wiring and the deselect/reselect retry-budget reset. * style(studio): shorten a retry-budget docstring past the comment-line cap * fix(studio): quiet the just-dropped-clip toast and reveal the full clip The selection-not-found toast fired on the routine first miss while a freshly dropped or pasted clip was still resolving; it now waits until the retry budget is exhausted. The timeline focus coordinator likewise cleared a reveal request on its first miss before the dropped element had landed in logicalRows, cutting the clip off at the viewport edge; it now tolerates a bounded number of retries like its selection-sync sibling, so AssetCard and AudioRow reveals benefit too. * refactor(studio): share one retry-budget tracker between the two hooks The selection-sync and timeline-focus retry counters were the same shape, incremented and reset independently. Extracted recordRetryAttempt so both hooks call one tracker instead of maintaining their own copy. * fix(studio): stop the post-edit scroll restore from undoing a fresh reveal A dropped clip's reveal could still land cut off at the viewport edge even with the retry fix: Timeline's "restore scroll after an edit re-derives the elements" effect and the focus coordinator's reveal both react to the same element-list change, but the restore effect captured lastScrollLeftRef before the reveal had a chance to update it, so its deferred frame stomped the reveal's scroll position back to the stale pre-drop value. The reveal now keeps lastScrollLeftRef in sync with its own write, and the restore effect reads the ref inside its frame instead of capturing it early, so whichever effect runs last within the commit wins with the freshest value. Confirmed against the original repro (a Playwright drop scripted through the real dev server) that scrollLeft converges to the fully-revealed value and stays there. * docs(studio): note the load-bearing hook order behind the scroll-race fix Flagged by an independent review of the prior commit: nothing in the suite would catch a future refactor that reorders these two hook calls and reintroduces the stale-scroll race. * fix(studio): bound an unresolved focus request by wall time, not just retries Answers two review questions on the scroll-race fix. (1) The retry budget only advances when this effect re-runs, which needs some other dependency to change; a request whose target never appears and which nothing else ever re-renders sat unresolved forever, staying eligible to resolve against an unrelated element that later reused its id. A setTimeout backstop now clears it after 4s regardless of re-renders, independent of the retry-count path. New test proves the leak and the fix (fails without the timeout, passes with it). (2) The "hook order is load-bearing" comment in Timeline.tsx no longer holds once the geometry effect's ref read is deferred into its own frame: a new test co-renders both hooks in both registration orders and both converge, so the order claim is false for the current code. Deleted the comment; the same test fails in geometry-first order when run against the pre-deferral code, confirming order mattered before and does not now. * refactor(studio): drop a nonce check the store already performs Simplify pass on the wall-time backstop commit. The timeout callback guarded clearTimelineFocus behind a nonce match, but the store action already no-ops on a mismatched nonce (a zustand same-reference return short-circuits it) — the guard duplicated a check the setter owns, and was inconsistent with the sibling budget-exhausted branch two lines up that already calls it unguarded. Confirmed no other findings: the two-mechanism clearing (retry count plus wall time) is genuine, not redundant, and the timer has no leak path. |
||
|
|
27303658c1 |
fix(core): scope selector arrays within compositions (#4035)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com> Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com> |
||
|
|
e36a53d6b6 |
fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration Adds the compute and paint-and-idle inputs to the composition readiness gate, plus window.__hf.buildReady: a promise registry a piece registers when it has setup work no adapter can see (mesh building, shader compiles). The runtime holds render-ready on it and the player shows its loading state instead of a blank frame while it resolves. * refactor(core): dedupe the paint-and-idle readiness test setup Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput tests as a 12-line clone; extracted the shared doc/tracking setup. * fix(core): stop the readiness settled-tracker hanging on 2+ pending promises Promise.all allocates a new promise every call, so comparing ITS identity across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never observes settled once 2+ promises are combined: every poll rebuilds a new combined promise, re-arms, and republishes forever without the caller ever seeing true. Reproduced with two already-resolved window.__hf.buildReady entries, which is the exact multi-piece usage the new authoring doc describes. Fixes both trackers at once with a shared createSettledTracker that compares the source promise array by element instead of the Promise.all wrapper. * fix(core): cancel readiness inputs once settleCompositionReadiness settles computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept running after the shared timeout won the race, since nothing told the losing inputs to stop. One AbortSignal now threads through all three inputs and is aborted the instant the race settles. * docs(core): fix a stale sync-callback claim in settleCompositionReadiness paintAndIdleReadinessInput always has a frame to wait on for any real document, so the sync fast path this docstring described is no longer reachable with the default inputs except for a viewless document. Found by the simplify pass on this branch. * fix(core): clear a stale buildReady entry on runtime teardown A never-resolved window.__hf.buildReady promise from a torn-down composition survived into the next init in the same window (window.__hf is never reset), permanently blocking render-ready for whatever loaded next -- the exact black-screen class this branch fixes, reproduced through its own new mechanism. Found by the independent review. Also fixes a stale hyperframes-player.ts docstring (media/images/fonts had not been updated to compute + paint-and-idle) and documents the buildReady key-collision hazard in three.md. * fix(core): skip the compute readiness wait when no runtime ever ran computeReadinessInput polled window.__renderReady for the full shared timeout on any same-origin document with no HyperFrames runtime injected (a probe-only composition using native video/duration, or a blank test iframe) — nothing was ever going to set that flag. Checking window.__hf first (the same signal composition-probe.ts already treats as "has a runtime") lets it return null instead of waiting. * fix(player): debounce the loading overlay and diagnose compute timeouts paintAndIdleReadinessInput always has a frame to wait on for an attached iframe, so settleCompositionReadiness's synchronous-settle path is unreachable in practice now — the loading overlay showed on every single Play, even ones with nothing genuinely pending. Debounce the overlay itself instead: a wait that resolves fast never shows it. Also names compute (window.__renderReady) in the stuck-assets timeout diagnostic, which previously only re-scanned media, images and fonts. * fix(engine): name a stuck buildReady key in the zero-duration diagnostic A build promise a composition registers on window.__hf.buildReady that never resolves left the export path's zero-duration timeout naming nothing useful — hasHf/hasSeek/duration all looked fine, since the runtime was genuinely present and just waiting on that one promise. Probes buildReady's keys for pending ones and names them in the hint. * fix(engine): stop the buildReady diagnostic from crashing on a rejection A microtask race against a bare resolved promise rejected the whole diagnostic expression on any rejected buildReady entry (the runtime's own createSettledTracker rejects deliberately) and mis-sorted a settled non-native thenable as pending, since its extra adoption tick loses the race. Both measured directly, not inferred. Settle each key onto its own record past one macrotask instead: no throw, and pending/resolved/rejected are distinguished correctly. A rejected key gets its own hint, separate from a merely pending one. * fix(player): report a backgrounded document instead of overclaiming a paint bound MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames ever fire) still rides the full shared 8s timeout, same as before this input existed. Corrected the comments that claimed otherwise. That gap is exactly why the stuck-assets diagnostic left paint-and-idle unreported: there was no accurate way to say it might be the cause. Added documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded tab is the reachable, directly observable case. * refactor(engine): simplify the buildReady diagnostic rewrite Skip the macrotask wait entirely when a composition registers no buildReady keys — the common case, since it's opt-in. Nothing to settle means nothing to wait on. Collapse buildZeroDurationDiagnostic's inline parameter type into the existing HfDiagnostic interface instead of maintaining two copies of the same shape. * docs(engine): note the setTimeout(0)-thenable gap the review found An independent adversarial review, executing hostile inputs rather than tracing, found the macrotask wait can still misreport a thenable that resolves via its own internal setTimeout(0), since the diagnostic's own timer is registered before the thenable's. Bounded and self-correcting (only feeds the message text, and the diagnostic re-runs every ~1s), so documenting the boundary rather than chasing an unbounded fix. |
||
|
|
7b4f3a22b0 | chore: release v0.8.48 (#4085) v0.8.48 | ||
|
|
637ce57994 |
fix(registry): svg dash animations measure the path after its d is set (#4082)
getTotalLength() was called on a path whose d attribute was set through a fresh getElementById(...).setAttribute(...) chain rather than the variable being measured. this is behavior-neutral at runtime (same DOM node either way, measured synchronously in the same task), but the lint's static rule can only verify a d assignment happened when it traces the same variable. hw-path-text and hw-text-cloud now assign d through the same variable they measure, matching how every other registry item with a drawn path already does it. |
||
|
|
2a9912ec99 |
fix(cli): stop the cancellable-process test reading a pid file mid-write (#4080)
Same defect shape as the earlier preflight cancel test fix: the wait condition only checked that the ready file existed, which goes true as soon as the write opens (and truncates) it, before its content lands. A read landing in that window parsed an empty string to 0. The wait condition now also validates the parsed value is a real positive pid before accepting it, retrying otherwise. Reproduced with a busy-wait harness that hit the empty-file race in roughly two of five attempts and confirmed clean after the fix. |
||
|
|
8fe480c0a0 |
feat(studio): operators get a magnetic main track that closes gaps on delete (#4069)
* feat(studio): closing a gap on the main track closes it automatically Adds a Ripple toggle to the timeline toolbar (default on). With it on, deleting a clip on the main track shifts every later main-track clip left to close the gap, folded into the delete's own undo step. Linked overlays and the auto-snap/insert behaviors are separate follow-ups. * refactor(studio): one shared resolver for gap shifts, no scaffolding ahead of its caller Consolidates three separate shift-to-element lookups (the gap-close menu's two and the delete-ripple's one) into a single resolveShiftedElements, built without a non-null assertion. Also drops resolveOpenGapShifts and its tests from this branch: it ships with its actual caller in the next PR. * fix(studio): canvas Delete on a timeline clip uses the timeline's own delete op Clicking a clip then pressing Delete went through the DOM/canvas delete path, which never ripples the main track — only the timeline's own right-click Delete did. Both paths now converge: a DOM/canvas selection that resolves to an exact timeline row hands off to the timeline delete op instead of the REST remove-elements path, so ripple and undo-coalescing apply from every gesture, not just one of two. * test(studio): cover the delete-routing handoff and a stale-session ripple persist Adds an end-to-end test that a canvas selection resolving to a timeline row is handed to the timeline delete op rather than the REST path, plus a test proving an SDK-eligible ripple persist after a delete rebases on the post-delete disk bytes instead of resurrecting the deleted element from a stale in-memory session. Also drops the unused single-element handleDomEditElementDelete plumbing in useDomEditCommits.ts / useElementLifecycleOps.ts, dead since before this branch. * test(studio): cover the multi-member delete-routing handoff Closes an adversarial-review should-fix: the marquee-group path (expandGroup, 2+ members all resolving to timeline rows) was verified at the resolver level but not through the useDomEditSession integration wrapper end to end. * fix(studio): ripple delete closes only the gap it opened resolveMainTrackDeleteRippleShifts compacted the whole main track (resolveAllTrackGaps) instead of the deleted clip's own span, so a delete also swallowed any pre-existing gap further down the lane, or pulled every clip to zero when nothing followed the deleted clip. Now each survivor shifts left by exactly the duration of the deleted clips before it. * chore(studio): trim playerStore.ts under the file-size cap Rebasing onto main put this file's line count over the cap once combined with this branch's own additions; tightens the playbackRequest doc comment, no behavior change. * test(studio): cover multi-select ripple delete of non-adjacent clips Closes the should-fix from the second adversarial review: a marquee delete of two or more non-adjacent main-track clips in one batch had no regression coverage, though the shift-sum logic already handled it. * fix(studio): undo after a rippled delete names the delete, not the move editHistory.ts coalesces a folded ripple move into the delete's own undo entry by keeping the LAST recordEdit call's label — which was always "Move timeline clips", so the toast read "Undid Move timeline clips" after a keyboard Delete instead of naming what the user did. handleTimelineGroupMove now accepts a label override, and the ripple delete path passes its own "Delete timeline clip" label through it. * fix(studio): surface and de-dup the ripple's post-delete failure path A ripple that failed to persist after a committed delete was only logged to the console, leaving the user with an unexplained gap; now shows an error toast. The delete's history label was also duplicated as a literal at both call sites (the delete write and the folded ripple move) with no shared source; both now read from one constant so they cannot drift apart. * test(studio): dedupe the two delete-ops harness mounts Fallow's duplication check flagged the two new ripple tests' harness setup as a clone group; extracted a shared mountDeleteHarness helper. * fix(studio): one toast, not two, on a ripple-persist failure The generic group-move failure toast and the delete op's own specific one both fired on the same ripple-persist error, telling the user about one action twice. handleTimelineGroupMove now accepts a suppressFailureToast option (console-logged instead, not silenced) for a caller with its own more specific message, and the delete op's unconditional "Deleted X" toast is skipped when the ripple failed. * test(studio): cover suppressFailureToast through the real move hook The prior test mocked handleTimelineGroupMove entirely, so it proved the call site passes the option but not that the hook's own catch honors it. A missing project id makes enqueueGroupOperation reject before any persist runs, reaching the real catch block without faking the SDK/server path. * test(studio): dedupe the shared move-change fixture Fallow flagged the two suppressFailureToast tests' identical "change" object as a within-file clone group; hoisted to the describe scope. * test(studio): unmount the React root in the group-move harness |
||
|
|
2679ebdd0d |
fix(registry): corner brackets fade in on cue in north-korea-locked-down (#4081)
The corner brackets sat at CSS opacity:0 and the intro gsap.from() also targeted opacity:0, so it animated 0 to 0 (a no-op) instead of revealing them. They only appeared later via an unrelated tween, losing the intended synchronized pop-in with the scale animation. |
||
|
|
856ac34c53 |
test(engine): time the bun --eval spawn in resolveHeadlessShellPath tests (#4078)
These tests spawn a bun process to check platform-specific chrome binary resolution, and have hit the default test timeout on Windows CI runners before. The log only ever shows the bare timeout, with no way to tell whether the spawn itself was slow or something else in the test body stalled. Wrapping each spawn in console.time/ console.timeEnd costs nothing when the test is fast and names the slow step the next time it isn't. No behavior change. |
||
|
|
700b94834d |
refactor(studio): extract hotkey dispatch out of useAppHotkeys (#4079)
useAppHotkeys.ts sits right at the studio 600-line cap on its own, so any unrelated growth in the same file (as landed independently in #4064) tips it over on the merge ref, where the file-size check actually runs. Moves dispatchModifierKey, dispatchPlainKey, their shared HotkeyCallbacks type, and handleUndoRedoKey into a new appHotkeysDispatch.ts, the same split useTimelineDeleteOps.ts already uses for this file's sibling. Pure code motion: no behavior change, same exports, same call sites. |
||
|
|
3306dbcdc0 |
fix(studio): keep the volume slider editable while automation owns the level (#4066)
* fix(studio): keep the volume slider editable while automation owns the level The Design panel's Volume slider disabled itself outright once a volume automation lane existed, with no alternative write path ever wired in — so turning automation on froze the control permanently. A commit now inserts or moves a keyframe at the playhead instead, reusing the exact merge logic the timeline automation lane's own double-click gesture already uses. The slider also now shows the envelope's live value at the playhead rather than the static, possibly stale attribute. * fix(studio): sample the live playhead for volume automation, not the stale store The store's currentTime only updates once at the end of playback, so a keyframe committed mid-transport landed at the last seek instead of the real playhead and could silently overwrite the lane's seed point. useLivePlayheadTime subscribes to the same live signal the timeline follows, matching how the audio FX panel already reads it. Also stops treating a video element's automation as invisible on the timeline: groupAutomationLanes, its paired row-height count, and the lane-drawing filter all gated on audio tag, so a volume lane written from the property panel never showed a track to expand or a keyframe to see. All three now key off "has lanes" (or audio-or-video where the element itself, not its attribute, has to gate a live edit), matching groupAutomationLanes and useTimelineTrackLayout's documented invariant that the row's reserved height and its drawn lanes count the same clips. * refactor(studio): simplify pass on the volume-automation fix Extracted mergeInsertPoint (automationLaneGeometry.ts) so the panel's withPointAt and the timeline lane's own double-click gesture share one merge/seed/sort rule instead of two copies of the same three lines. Fixed a real duplicate subscription: useVolumeAutomation was calling useLivePlayheadTime() itself even though its only caller already reads the same value via the prop PropertyPanel.tsx computes and passes down. Threads currentTime through as a parameter instead. Unified the three places that decide whether an element can draw an automation lane (groupAutomationLanes, automationLaneCountOf, TimelineAutomationLaneSlot's clip filter) behind one exported predicate, isAudioOrVideoTimelineElement, so the row's reserved height and its drawn lanes can't independently drift on what counts. * test(studio): pin the clip-local time translation for volume automation The adversarial reviewer mutated out `currentTime - elStart` and every existing test still passed — the duration clamp happened to mask its absence in every case that touched it. New case starts the clip at t=10 so the clamp can't hide a missing subtraction. |
||
|
|
5ef5419a9e |
feat(studio): the segment ease control is discoverable at rest (#4074)
* feat(studio): the segment ease control is discoverable at rest The per-segment ease diamond only ever appeared on hover, so nothing on the timeline hinted a segment had an editable ease at all. Now it sits at low emphasis by default and reaches full opacity on hover or focus, same as every other reveal-on-interaction control in this file. Left-click still selects and seeks (the frequent gesture); the right-click menu item is unchanged. The existing title attribute already names the control on hover. * fix(studio): keep the resting ease button clear of narrow diamonds At fit zoom on short segments the always-on button sat on top of both neighboring diamonds and read as a grey smear. Add hasRoomForRestingEase, a pure width check (button box plus a gap each side), and only show the button at rest when the segment clears it; narrower segments stay hover-only, same as before this feature. |
||
|
|
a7cc1d2cff |
fix(cli): stop the preflight cancel test reading a pid file mid-write (#4077)
The test polled a pid file written by `echo $$ > path`, whose shell redirection creates (truncates) the file before the echo writes to it. A read landing in that window parsed an empty string to 0, which satisfied "not undefined" and exited the wait loop early, failing the next assertion with "expected 0 to be greater than 0" — the exact shape of the intermittent CI failure. The loop now only accepts a parsed value that's a positive integer, reproduced with a busy-wait harness that hit the empty-file race in most iterations and confirmed clean after the fix. |
||
|
|
de9206e0f8 |
fix(cli): detect a missing or stale studio build before it crashes (#4076)
* fix(cli): name the missing or stale workspace package before preview crashes Running hyperframes preview from source with an unbuilt or stale package previously crashed deep inside that package's own import graph with a raw resolver error naming neither the package nor the fix. Add a preflight that runs before the preview command's own module import, so it also catches the crash that happens at CLI load time before the studio dev server ever spawns. * test(cli): cover the preview workspace-build gate wiring Adds a test that exercises the real CLI dispatch path (mocked module loaders, not source-text matching) to prove the preview command's build-check runs before its module loads and that a failure exits non-zero without double-printing. Also guards the exports-map reader against a malformed package.json instead of crashing raw. * fix(cli): correct why the audio-fx generated file is checked The comment claimed core/src/index.ts imports both generated files directly. Only runtime-inline.ts is; audio-fx-runtime-inline.ts is reachable solely via core's own audio-fx-runtime export subpath, which the engine package imports. The check itself was already correct to require both, only the stated reason was wrong. |
||
|
|
e174b1715b |
feat(studio): host apps can open the ask-agent flow from a picked element (#4075)
* feat(studio): host apps can open the ask-agent flow from a picked element Exports AskAgentModal and its props type from the public barrel so a host app embedding only the player can render the same modal. Factors the DOM-edit prompt builders' shared formatting into functions keyed on a minimal common shape, then adds buildPickerAgentPrompt and buildPickerAgentContextPreview built from the runtime picker's own payload (no Studio DOM access required) instead of DomEditSelection. An empty selection yields a plain comment prompt. * docs(studio): clarify agent-prompt-builder comments Simplify pass: buildElementInfoLines's comment omitted the conditional text line it emits, and selectorIndex's always-zero value from the picker path was undocumented as expected rather than a gap. * refactor(studio): share the selector/text line pair between context previews Simplify pass: buildAgentContextPreview and buildPickerAgentContextPreview each built the same [selector-tag-line, text-line] pair inline. Extracts buildSelectorAndTextLines() so both call the one place that owns it. * fix(studio): cut two over-length comment blocks to the 4-line cap Both docstrings had grown past the repo's 4-contiguous-comment-line limit before this branch's first push. Re-wraps each to 2 content lines, keeping the same meaning. * feat(studio): surface the picker's label and media src in agent prompts A host app embedding only the player has no DOM to read an element's label or image source from, so the picker-built prompt needs to carry both explicitly. The Studio DOM-based builder is unaffected: neither field is set on its mapping, so its existing output is unchanged (covered by the golden test). |
||
|
|
a8d44b2596 |
fix(studio): keep clips on their row through group, ungroup, and composition recovery (#4068)
* fix(studio): keep a split clip on its original track row Splitting an unauthored clip cloned the DOM node without stamping data-track-index, so the runtime's positional-index fallback renumbered it onto a different track on insertion. Both halves now pin to the element's current resolved track. Also adds A, matching CapCut, to return from the razor tool to select while it is armed. * fix(studio): stop the in-point seek from also firing when A exits the razor tool Both A bindings register on window in capture phase; the razor's listener mounts after the playback one, so relying on preventDefault ordering let the seek fire before the tool switch. Playback now checks activeTool directly instead. * fix(studio): stamp the resolved track through group, ungroup, and composition recovery Same defect class as the split fix: an unauthored clip's track falls back to its position in document order, and any DOM insertion elsewhere can shift that position. Group and ungroup move elements into and out of a wrapper; composition recovery clones a missing element back in. All three now stamp each element's already-resolved track explicitly, the same way split does, so none of them can drop a clip onto a different row. Also closes a gap in the earlier "A" hotkey fix: the razor-armed guard was unconditionally swallowing Shift+A and Alt+A, which should still seek to the in-point since only plain A exits the razor tool. * refactor(studio): reuse the existing patch-target and selection-match types Simplify pass on the track-stamping fix: resolveGroupChildTracks built its patch target by hand instead of calling buildDomEditPatchTarget, already used one function up for the same shape. resolveAuthoredTrack's selection param was a hand-typed copy of ElementMatchSelection instead of importing it. Collapses the three duplicated optional-integer track checks added to the wrap/unwrap/split routes into one isOptionalInteger helper. No behavior change. * fix(studio): match an ungroup child with no DOM id by its stable id findMatchingTimelineElementId had no branch for hfId, the stable content-hash id every element gets regardless of whether it has an authored id. An ungroup child identified only by that id fell through every branch and kept its old track instead of getting the resolved one stamped, leaving it exposed to the same row-drift this PR closes for every other element. * refactor(studio): resolve review lint suppressions with real fixes Three lint suppressions from this branch's earlier commits, resolved instead of left as noise: - unwrapElementsFromHtml's complexity: extracted the child-track map build into its own named function. - handlePlaybackKeyDown's complexity: extracted the razor/A-key check into its own named function. - The duplicated param block between UseDomEditSessionParams and UseDomSelectionParams: pulled the shared fields into one DomEditTimelineParams type both interfaces now extend. No behavior change. * fix(studio): fall back to the resolved track when nothing was authored resolveAuthoredTrack only ever returned authoredTrack, so an implicit or ungrouped child with no explicit authoredTrack resolved to undefined instead of its actual resolved track, silently skipping the row-pin for exactly the unauthored population this change exists to fix. Falls back to the resolved track, matching the function's own existing comment. * fix(studio): pin the razor split's resolved track for unauthored clips buildCutTarget had the identical gap resolveAuthoredTrack did: it only stamped an explicitly authored track, omitting the field entirely for an implicit/unauthored clip, so a split of it still drifted onto a new row. Falls back to the element's resolved track, same as the group and ungroup paths. * refactor(studio): share one track-fallback rule between split and group resolveAuthoredTrack and buildCutTarget each re-implemented "authored track, else the runtime's resolved track" with a different rounding rule (one rounded only the authored branch, the other neither). Extract resolveElementTrack so both flows share the same rule and can't drift out of sync again. |
||
|
|
3867d8da5d |
test(cli): time each native Sharp call in the flaky Windows contact-sheet test (#4070)
This test has hit its 60s ceiling on Windows CI runners before, and the log only ever shows the bare timeout, not which of the four native Sharp calls actually stalled. Wrapping each one in console.time/console.timeEnd costs nothing when the test is fast and names the slow step the next time it isn't. No behavior change. |
||
|
|
b0e4316a20 |
feat(studio): open a project's root composition automatically on load (#4057)
* feat(studio): open a project's root composition automatically on load
Opening any project landed on an empty canvas ("Drop media here") with
Play disabled, even when a root index.html composition existed, because
nothing auto-selected it. Studio now opens the root composition (or the
only one, if there's just one) as soon as the file tree loads and no
URL already named a selection, and marks it "Root" in the comps list.
The root path is resolved once, in LeftSidebar from the shared file
tree, and passed down as a prop rather than re-derived independently
in the comps list from a different, driftable source array.
The settle guard is keyed to projectId, not a bare boolean, so
switching projects in-session (the studio's hash-based project switch,
same component instance) still gets its own auto-open instead of
silently inheriting the previous project's settled state.
* fix(studio): reset selection state on an in-session project switch
Switching projects in-session left activeCompPath from the old project
in place, which read as "already open" and made the new project's
auto-open settle without ever firing. useResetSelectionOnProjectSwitch
clears both activeCompPath and activeCompPathHydrated when projectId
changes, composed inside useAutoOpenRootComposition so the same commit
sees the reset before auto-open's own effect runs.
Also extracts the composition-selection wiring (state, URL hydration,
auto-open, this reset) out of App.tsx into one hook,
useActiveComposition, so App.tsx loses more lines than this fix adds
and stays under the 600-line cap without trimming unrelated lines.
* refactor(studio): un-nest the project-switch reset from auto-open
An independent review of 0bd281127 found useAutoOpenRootComposition had
grown three pass-through params purely to forward into a nested
useResetSelectionOnProjectSwitch call; none were read by its own
auto-open decision. React's render-phase state adjustment gives the
same commit-timing guarantee for a sibling call, so this calls the
reset hook directly from useActiveComposition instead, restoring
useAutoOpenRootComposition to its original 5-param signature.
Also applies two smaller review findings: a shared isHydratedFromUrlState
helper replaces three copies of the same URL-state check, and
useActiveComposition's setEditingFile/showToast params use the
existing EditingFile/AppToast types instead of restating their shape.
* fix(studio): make the file tree's project-switch staleness impossible
An independent review of ca3776e14 traced a live regression: on an
in-session project switch A->B where B's URL carries no comp param,
the reset added in this stack lets useAutoOpenRootComposition's effect
fire in the same commit-flush, but masterCompPath (derived from
fileManager.fileTree) still held project A's data for at least one
render + effect cycle, because useFileTree only cleared fileTree/
fileTreeLoaded inside a useEffect keyed on projectId, itself timed one
cycle behind. Auto-open would call onSelectComposition with A's master
path for project B and permanently mark itself settled, so B's real
root composition never opens for the rest of the session when the two
projects don't share an entry filename.
Root cause fixed in useFileTree.ts: the fetched tree is now held
together with the projectId it was fetched for, and fileTree/
fileTreeLoaded/compositions/projectDir are derived by comparing that
stored id against the live projectId, returning empty/not-loaded
whenever they don't match. This makes reading another project's data
structurally impossible, instead of adding a second reset timed to
race the first. The now-unreachable raw setFileTree setter is removed
(zero call sites repo-wide).
Every other consumer of these four fields was exposed to the same
stale window and is fixed by the same change: App.tsx's URL-hydration
guard and useDomEditSession's fileTree/projectDir, StudioLeftSidebar's
composition/asset lists, StudioRightPanel's assets/fontAssets/
projectDir, and VariablesPanel's fileTree-derived fallback comp path.
Regression test added (useResetSelectionOnProjectSwitch.test.tsx):
real useFileTree + useActiveComposition wired together with a mocked
fetch for two projects with different root files. Verified it fails
on the pre-fix useFileTree.ts (asserts B's root, gets A's stale one)
and passes on the fix.
* refactor(studio): stop the file-tree catch handler re-zeroing state
The effect already resets fileTree/compositionPaths/projectDir to
empty synchronously before the fetch starts, so the catch branch only
needs to flip loaded:true; re-writing the same empty arrays clobbered
any concurrent refreshFileTree() result for the same projectId that
landed first. Found by a simplify-pass review of 9555e920f.
* fix(studio): resolve the auto-opened composition from the filtered list
resolveMasterCompositionPath scanned the raw file list, which can include
non-composition .html (a vendored preset, a file with no
data-composition-id). Auto-open now reads the server's filtered
compositions list instead, and settles once per project either way, so a
stray .html earlier in the list can no longer become the opened target.
* fix(studio): close the same master-composition mismatch at its two sibling call sites
resolveMasterCompositionPath had two more callers still reading the raw
file tree: the sidebar's Root badge and the Variables/Slideshow panel's
master-view target. Both already had the server's filtered composition
list in scope; switch them to it too, and reword the docstring so a future
caller reaches for the right list.
* fix(studio): refresh the composition list along with the file list
refreshFileTree only patched the raw file list, leaving compositions
frozen at the project's initial load. Two callers now read compositions
for their root-composition target, so a file operation that adds or
removes a composition (create, duplicate, block install, background
removal) would leave the sidebar's Root badge and the Variables/Slideshow
panel's master target stale until the next full project switch.
* fix(studio): fix refreshFileTree ordering race and stale-field wipe
Two overlapping refreshFileTree calls for the same project could apply
out of order, and a response missing the compositions field could wipe
a known-good list. Adds a request-id guard and a stale-only fallback,
plus regression coverage for both and for LeftSidebar's root badge
sourcing from the filtered compositions list.
* refactor(studio): cancel superseded refreshFileTree requests
Simplify pass on the previous commit: reuses the id+abort pattern
already established in useFileManager.ts's openSourceForSelection
instead of only discarding the stale response after it completes.
* test(studio): assert the superseded refresh's fetch actually aborts
Closes the coverage gap the independent review flagged: the abort
call had no test asserting on it, so a dropped abort() would have
stayed green. Also cuts two comment blocks over the repo's 4-line cap.
* refactor(studio): extract shared test harness setup in useFileTree.test.tsx
Simplify pass on
|
||
|
|
2a559714ee | fix(cli): distinguish agent and execution harness telemetry (#4071) | ||
|
|
bf39a72787 |
fix(studio): dropped timeline assets land where you drop them (#4062)
* fix(studio): dropped timeline assets land where you drop them Dropping an asset, an OS file, or a block onto the timeline always placed the new clip at the current playhead time, discarding the actual drop position; only the track (vertical position) tracked the pointer. Composition drops already used the correct pointer position. Unify all drop kinds onto that same, correct placement path, and drop the now-pointless duration clamp so a drop past the end of the timeline extends it, matching what composition drops already did. * fix(studio): grow the timeline when a drop lands past its end Asset/file and composition drops could place a clip past the root composition's duration without growing it, so the editor showed the clip fine but the render truncated it. Every sibling edit path (block install, move, resize) already grows the duration on the same rule; these two didn't. Also restores the pointer-placement decision's provenance in the drop docblock and PR description, and marks the now-production-dead clamp branch in resolveTimelineAssetDrop so the next reader doesn't assume the default is live. * fix(studio): revert the composition-drop duration extension The composition-drop path already grows the root composition's duration server-side (compositionInsertion.ts, before the response is even returned): this was verified wrong via two independent review passes after being merged in by mistake. The client-side extend added on top of that was redundant, and in edge cases could re-apply a slightly different rounding than the server's own. * fix(studio): remove the dead clampStartToDuration option resolveTimelineAssetDrop had one production caller and it always passed clampStartToDuration: false; the clamped branch and the duration field that only fed it were exercised solely by this file's own tests, not by anything shipping. Drop the option, the branch, and duration/durationRef everywhere they were only feeding it (timelineDragDrop.ts, Timeline.tsx, and the touched test fixtures). No test asserted a drop past duration relying on clamping, so this is behavior-neutral for every existing test. |
||
|
|
096459e76d |
fix(cli): collapse startup lint output to a one-line summary (#4052)
* fix(cli): collapse startup lint output to a one-line summary preview, publish, and render's non-abort lint check all dumped every finding to the terminal before their real result line, drowning it in noise unrelated to whether the project is in good shape. Full findings already surface in Studio's own Lint badge; all three now print a one-line summary by default, with --lint-verbose for the old full output. One formatter (formatLintStartupMessage), three call sites. * fix(cli): show lint findings in full on every abort path Any path that calls presentRenderLintAbort now prints full findings even without --lint-verbose, since the one-line collapse was hiding the reason a render stopped. * fix(cli): scope the lint summary pointer to what each command has open The one-line lint summary always pointed at "the Lint badge in Studio", even from render and publish where no Studio is running. It now points at --lint-verbose for those, and preview additionally names the hyperframes lint command since its own stdout is what an agent reads, not the badge. publish now forces full findings on its own entry-mismatch abort, the same fix render already shipped, so the reason for the abort isn't hidden behind a bare count. * docs(cli): trim a doc comment past the 4-line limit |
||
|
|
415967befa |
Merge pull request #4063 from heygen-com/release/v0.8.47
chore: release v0.8.47v0.8.47 |
||
|
|
72b223d57f |
fix(player): gate play on real composition readiness, not just duration (#4054)
* feat(player): wait for a composition's media, images and fonts before playing
The player used to fire ready/autoplay the moment the timeline resolved,
even if the composition's video, images or fonts hadn't loaded yet -
visible as a flash of missing content or a frozen first frame. It now
waits for those assets (readyState >= HAVE_FUTURE_DATA for media, decoded
images, document.fonts.ready) before play() proceeds, showing its own
loading overlay (reusing the shader-transition loading panel) while it
waits, bounded by an 8s timeout that plays anyway and logs which assets
were still stuck.
Settles synchronously with no overlay when nothing needs waiting (already
loaded, or a cross-origin composition the player has no DOM access to
inspect), so today's immediate-autoplay behavior is unchanged for the
common case. A new assetsReady getter and assetsready event mirror the
gate for hosts that want to observe it.
* chore(player): drop internal decision-ref from a public-repo comment
An internal tracking id leaked into a code comment and a test name in the
previous commit. This repo is public; internal references don't belong here.
* fix(player): stop a stale asset-wait from settling a newer composition
An independent review found that a composition swap mid-wait (a new src or
srcdoc arriving while the previous composition's media/images/fonts were
still loading) had no way to invalidate the old wait. If the old wait's
promise resolved after the swap, it could mark the NEW composition ready
and even play it, while the new composition's own assets were still
pending - defeating the point of waiting in the first place. The same gap
let a late-resolving wait flip a disconnected player's paused state back to
false and fire a spurious play event after removal.
Fixed both with one generation counter: every new wait captures the current
generation, disconnect and reload each bump it, and a wait's eventual
settle no-ops if its generation is no longer current.
Also fixed: the loading overlay's aria-label ("Loading assets") never got
restored once the asset wait was done, so a later shader-transition overlay
on the same player kept misannouncing itself to assistive tech.
* fix(player): invalidate the asset wait from every navigation path, not two
The generation-counter fix only bumped at two of the six places that set
_ready = false ahead of a navigation, reload, or disconnect - src/srcdoc
attribute changes, a sandbox-origin policy reload, and a shader-options
reload all started a new navigation without invalidating the previous
wait, leaving the same race open through those paths.
One _invalidateAssetsWait() helper now owns the reset (assetsReady,
pendingPlay, generation, the loading attribute, hiding the overlay) and
every one of the six sites calls it before starting whatever comes next.
* fix(player): gate play on real composition readiness, not just duration
Add a shared composition-readiness gate in packages/core, exported as the
@hyperframes/core/composition-readiness subpath. It resolves once every
declared input settles (media for now: video/audio/image/font loads), or
after a timeout, and calls back synchronously when nothing is pending so a
caller that plays immediately after sees the decision already applied.
The player element now queues an early play() call instead of dropping it
when called before the runtime is ready, and both of Studio's readiness
call sites (the direct-adapter path and the message-based fallback) go
through one store action, requestTimelineReady, instead of duplicating a
generation-counter-and-settle pattern across two hook files.
* fix(player): stop a queued early play() and a project switch from double-firing readiness
An independent review of the readiness gate found two real races:
play() queued before the runtime was ready re-ran its full body once ready,
dispatching the public "play" event twice for one user action. It now
dispatches "play" only on the call that actually starts playback.
Studio's requestTimelineReady generation guard was never invalidated by a
project switch or a full timeline reset, so a slow-loading project's
in-flight wait could resolve after the switch and mark the new, unchecked
project ready. Resetting the timeline now bumps the generation, and setting
timelineReady directly does too.
* fix(player): cancel a queued play on pause or seek during the asset wait
A play() queued while assets buffer (_pendingPlay) used to fire once they
settled even after an explicit pause or seek during the wait. pause() and
seek() now clear the flag, so a user pause/seek wins.
|
||
|
|
5b7ff519a5 | chore: release v0.8.47 | ||
|
|
eb2ac41f3e |
Merge pull request #4055 from heygen-com/worktree-glowing-giggling-toucan
feat(studio): flip the SDK cutover and the GSAP writer to default-on |
||
|
|
9f3a778fd1 |
feat(studio): report why a fallback happened, and when a session is missing
Three silent fallback paths, three instruments: 1. resolverDisagreement on sdk_cutover_declined. Composition.getElement is canonical-only for a bare id, while dispatch resolves a bare id anywhere (resolveScoped). An element under a scoped id is therefore dispatchable yet still fails getElement -- a dispatchable edit refused, and invisible in both telemetry events (the shadow stays silent when its broader resolver hits). resolveSnapshot is now exported so the cutover path can tell the two target_not_found cases apart. 2. sdk_session_unavailable. With no session every cutover chokepoint takes the server path and emits nothing, and the shadow never runs either -- a total, silent SDK bypass. It has exactly three origins, all in useSdkSession's open effect: unreadable content, a failed ownership check, and openComposition throwing. Each now emits with its stage (and the error message for "open"). 3. isResolverDisagreement is deliberately fail-safe: a session shape without getElements reports no disagreement rather than throwing, so diagnostic context can never turn a clean decline into a failure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
2de3ca5f86 |
fix(player): the interactive playback clock absorbs a main-thread stall instead of skipping past it (#4053)
* fix(player): the interactive playback clock absorbs a main-thread stall instead of skipping past it TransportClock's monotonic time is a bare wall-clock read with no protection against a long gap between reads. On a composition whose first real paint blocks the main thread for several seconds, nothing reads the clock during that time; the next read reported the whole gap as elapsed playback, so the composition jumped forward instead of continuing from where it froze. A gap over 500ms is now treated as a stall and corrected to a 33ms advance instead of reported whole, matching GSAP's own ticker default. The correction lives inside now() itself so every reader benefits, since this clock is read every real frame by more than one driver. Render/export capture is unaffected: it always pauses and seeks to an exact time rather than playing, so the correction never engages there. * chore(player): drop internal tracker references from stall-policy comments * fix(player): stop the stall correction from corrupting the clock while audio is authoritative An independent review found that the previous commit's correction ran unconditionally on every read, including while an attached audio source was providing the time. Audio keeps playing through a JS-thread stall, so a gap between reads there is not a stall — but the correction still shifted the clock's internal reference point, and that corruption only surfaced later, on an unrelated read, once the clock fell back to monotonic timing (audio pausing or buffering). The correction now runs only in the monotonic branch, after audio has had its chance to answer. Added a regression test that fails against the previous placement and passes against this one. * fix(player): keep the stall correction's read timestamp fresh through an audio-authoritative stretch A second independent review found the previous commit's fix was incomplete: it stopped a stall correction from firing while audio was answering a read, but the internal timestamp it compares against only moved on monotonic reads. Any monotonic read from before audio took over stayed the comparison point for as long as audio kept answering, so the whole audio-authoritative stretch read as one giant stall the moment audio dropped out again — reproducing the exact corruption the previous commit set out to fix, through a different door. The same gap also meant a real, non-stalled audio outage got smoothed away to almost nothing instead of advancing normally. The read timestamp now moves on every read, audio-answered or not; only the correction itself stays scoped to the monotonic branch. Two regression tests reproduce both failure modes and pass here; both were confirmed to fail against the prior placement. * fix(player): stop comparing a monotonic read against a timestamp audio made meaningless A third review found the previous commit's fix was still incomplete: keeping the read timestamp fresh during audio-authoritative reads stops it going stale, but a real gap with no reads at all — the main thread genuinely frozen while an attached audio element kept playing on its own, unaffected by JS scheduling — still read as a stall the moment audio next failed to answer, discarding real elapsed time that audio had been correctly tracking the whole way. An audio-answered read now clears the timestamp instead of stamping it, the same as a seek. The next monotonic read then starts from `_playStartMs`, which nothing but a genuine discontinuity (play, seek, rate change, detach) ever touches — an anchor that already reconstructs real elapsed time correctly on its own, without needing the correction to reason about a comparison point it has no way to make meaningful once audio was involved. A stall between two purely monotonic reads, with no audio in between, is unaffected and still corrected exactly as before. Two regression tests cover the transition: a real unread gap while audio was tracking correctly must not be discarded, and a sustained audio outage must still advance at real speed rather than being smoothed to nothing. Both were confirmed to fail against the prior commit's placement. * chore(core): trim clock comments |
||
|
|
9ef37e5163 |
feat(studio): emit sdk_cutover_declined with reason + family
Declines fell back to the server silently; post-flip we could not tell 'SDK took it' from 'SDK bowed out'. Payload is reason + family only. All 23 sdkCutover.ts decline sites now pass their family; the one pre-family site (no_change) emits family:null. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
407cacf0ae |
feat(studio-server): default the GSAP writer to acorn
HYPERFRAMES_GSAP_WRITER=recast stays as the fallback for one watch window before recast is deleted. acornDefaultBlockers -> behavioralParityOps: the 18 ops without a byte-diff oracle are now pinned in a test, not a gate. files.test.ts "stale semantic no-op" pinned to the recast writer: its interleave seam is recast's lazy import, which acorn (statically imported, fully synchronous) never performs. The 409 revalidation it covers is writer-independent. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
26ea8a0740 |
feat(studio): enable SDK cutover by default for every op family
Master flag defaults true; unset family list now means all five families. VITE_STUDIO_SDK_CUTOVER_ENABLED=false / VITE_STUDIO_SDK_CUTOVER_FAMILIES=... remain as runtime kill switches via __HF_STUDIO_ENV__. Gate: 0 divergences on 0.8.41-0.8.46 shadow tile, 3455/0 success/failed on dev builds (30d). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
82f49262ec |
feat(registry): blur every transform driver, and ship the reference case as shutter-slam (#4038)
The motion-blur snippet integrated translation only. Its output stage was one SVG filter per target whose copies were feOffset, and feOffset only translates, so a beat that only scaled or only turned sampled a displacement of zero and rendered sharp. No SVG filter primitive can give a copy its own affine transform, so the sampling loop was never the problem: the output stage was. Copies are now DOM clones of the element in an isolation:isolate group, each at 1/N opacity with mix-blend-mode plus-lighter, each carrying the element's resolved transform at its sample time. plus-lighter adds premultiplied colour, so N + 1 copies at 1/N sum to the average the shutter integral asks for; the isolation is load-bearing rather than tidy, because without a transparent backdrop of its own the first copy adds onto the page and blows a light background out to white. Reading the resolved matrix instead of a list of named GSAP properties is what makes every driver work at once: translation, scale, rotation, 3D rotation and skew arrive in the same one value. Measured against the After Effects export the shutter model was fitted to, per beat, previous stage to this one: scale +0.62 dB, rotate X +0.43, rotate Y +0.84, rotate Z +1.15, translate X -0.18, translate Y -0.04. The two small translate regressions are inherent and not tunable: the filter offset one rasterisation so every copy was pixel-identical, where each copy is now rasterised independently at its own fractional transform with its own subpixel antialiasing. Three consequences of the new stage, each carrying its own fix: - mix-blend-mode flattens preserve-3d, so a copy cannot inherit the parent's 3D context. Each copy carries the parent's perspective as its own first transform function instead. - A copy is styled by nothing that selected the original, since dropping the id also drops every #id rule that gave it size, colour and font. Each copy carries its own resolved style inline, read once and replayed. Those values are px, so a container-relative element needs them again when its box changes: a ResizeObserver re-snapshots rather than paying a subtree walk every frame. - A beat that moved and faded at once kept a full-strength smear behind a vanishing element. The element's opacity now rides the group; the 1/N weight stays on the copies. The axis option is gone. It selected which of x and y to sample, a question the resolved matrix does not have. shutter-slam is the new component: one word through six single-property beats, two translate slams, a scale punch, then a full turn about each axis, which exercises every driver the model covers in one mount. Easing is power2.inOut because it measured best, not because it was the default. A bezier fitted to the reference's own bbox centres looks like the better curve and renders worse: a blurred bbox centre is the shutter window's average rather than the instant, so on an accelerating segment the fit measures its own blur. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
a8a9fdb0fe |
chore: prepare v0.8.46 (#4033)
* chore: prepare v0.8.46 Draft only, not tagged: bun run release:prepare 0.8.46 --no-tag. Changelog rewritten in plain words for the one change since v0.8.45 (#4031, adaptive per-frame motion-blur sample count). Cutting the release is a separate step on Miguel's word. * docs(release): fix false render-time savings claim in v0.8.46 notes Adaptive sampling never drops below the old fixed-16 default; it only adds cost on frames that measure real spatial motion. Per Miga's review on #4033.v0.8.46 |
||
|
|
2ab7b86868 |
feat(engine): adaptive per-frame sample count for motion blur (#4031)
* feat(engine): adaptive per-frame sample count for motion blur Measured against the accuracy bar set in #4010 (72.4dB mean PSNR vs the 960fps shutter integral), the fixed default of 16 samples misses it for hard-edge content while paying nothing extra for static/slow content is still important. This picks a sample count per frame from measured motion, floor 16 ceiling 64 (issue #4029). Two probe captures at the true shutter-window edges (never a sample that enters the average) measure motion magnitude, mapped through a monotone step table to a sample count. Frames confirmed to move only non-spatial properties (opacity, colour) skip the probe and use the floor directly, since a fade's shutter integral is a smooth blend that does not need many samples. MotionBlurPlan's window (from shutter angle/phase alone) is now independent of how many samples fill it, which is what lets the count vary per frame without moving the window; motionBlurWindowIsStatic is bounded by the widest sample inset any chosen K could produce so the static-dedup safety guarantee holds for every K in [16, 64]. * refactor(engine): simplify the adaptive motion-blur sample selection Two findings from an independent code-reuse/quality/efficiency review of the previous commit: - armStaticDedup ran the GSAP-timeline walk (computeStaticFrameSet) twice on the same page when adaptive motion blur and static dedup both apply, once for each purpose. Shares the one result instead. - The non-spatial floor path read the sample count from adaptiveSampleCount(0), which only worked because the step table happens to be sorted with the first bucket covering zero. Reads the now-exported DEFAULT_SAMPLES_PER_FRAME directly instead. * fix(engine): read raw options for the adaptive motion-blur gate Independent review caught it: every armStaticDedup call site runs before finalizeSessionInit resolves session.motionBlur, so the adaptive-classification gate's `session.motionBlur && session.motionBlur.fixedSamplesPerFrame === null` was always false at runtime. The non-spatial probe-skip described in the PR never engaged outside tests that hand-set motionBlurNonSpatialFrames directly, bypassing real init order. Reads session.options.motionBlur (set unconditionally at session creation) instead, which carries the same "adaptive requested" information the resolved plan would have. Adds a source-order lock test plus direct coverage of computeStaticFrameSet's nonSpatialOnlyFrameSet, mutation-proved against the exact regression. * fix(engine): add xPercent/yPercent to the spatial-property list Round 2 of the independent review: the spatial-property list deciding whether a frame's motion needs the adaptive probe omitted GSAP's percentage-based position properties (xPercent/yPercent) and two 3D transform properties (perspective/transformPerspective) that two other lists already in this repo (packages/parsers/src/gsapConstants.ts, packages/studio/.../gsapAnimatesProperty.ts) both carry. A composition positioned via xPercent/yPercent would have been silently floored to K=16 with no probe and no log line, the same failure shape as the round-1 bug through a data gap instead of a dead gate. Moves the list to an exported SPATIAL_TWEEN_PROPERTIES array in motionBlur.ts, passed into computeStaticFrameSet's page.evaluate as data rather than duplicated inline, so it stays a single list and is directly testable. Adds tests that execute the real page-side closure (the existing tests all mocked the whole result and never exercised the matching code), mutation-proved against the exact gap found. * fix(engine): add rotate to the spatial-property list Round 3 of the independent review, comparing SPATIAL_TWEEN_PROPERTIES side by side against the two sibling lists rather than trusting the prior commit's summary: GSAP's individual "rotate" transform property (distinct from "rotation", carried in packages/studio/.../gsapAnimatesProperty.ts's GSAP_TRANSFORM_PROPS) was still missing, same failure shape as the two prior rounds. The same pass also raised transformOrigin, which is deliberately NOT added: gsapConstants.ts's own classifyTweenPropertyGroup excludes it as a pivot-point modifier with no independent visual effect, and a test now locks that reasoning in rather than leaving it to be "fixed" again by a future pass that does not read that far. |
||
|
|
484a06fb65 |
chore: release v0.8.45 (#4027)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>v0.8.45 |
||
|
|
f4cdccd995 |
test(cli): add a real-render e2e check for render telemetry properties (#4018)
Extends scripts/ci/cli-telemetry-e2e.mjs (already used to verify the `add` command's telemetry) to spawn the built `render` command against two generated fixture compositions and assert on the captured PostHog batch, rather than a unit fixture that stubs the browser or ffmpeg. One fixture sets every probed property (audio and image elements present, root/body CSS size deliberately mismatched from the declared composition size, a skill flag, one injected environment override) and its pair sets none, so each property is asserted on both its present-and-correct side and its absent-or-zero side in the same pair of runs: audio_count, image_count, root_body_mismatch and its delta bucket, authoring_skill_source, hf_env_overrides, and ffmpeg/browser_version_major. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
77a45f6080 |
fix(producer): fail loudly when a capture route cannot blur (#4026)
A render on the layered HDR route accepted `motionBlur` and returned frames with no blur in them. The HDR compositor runs its own capture loop, seeking and blitting each layer itself, so a frame never reaches the sub-frame accumulation branch. The engine's own guard cannot catch it, because from inside the session everything looks supported: screenshot capture mode, PNG frames, and no video injector on a composition with no video. This is the third time the question "does this route support motion blur" has been answered implicitly by whichever code path happened to run, and the third time it came out wrong and silent. `assertMotionBlurSupported` gives that question one owner and one answer. The route it takes is the capture plan's own `kind`, already the single value the capture stages consume, so it cannot drift from the route that actually runs, and a new plan kind with no entry in its table fails the typecheck rather than inheriting silence. It sits inside `syncCapturePlan`, which runs on the initial plan and after every replan, so a route a render falls back into is checked too. Distributed renders are out of scope on purpose and recorded as such: `DistributedRenderConfig` has no `motionBlur` field and is not built from `RenderConfig`, so motion blur cannot be requested on one. There is nothing to reject and no dead call site. The explicit unsupported-mode error for layered HDR is the design in #4013 by Dante-dan. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
71bb88719e |
feat(telemetry): render output-shape, adapter, count, and environment properties (#4017)
* feat(telemetry): add output-shape request facts to render events output_resolution_preset, output_format, hdr_mode, video_frame_format, and gif_fps_capped join render_complete/render_error, all threaded from already-resolved RenderOptions/RenderPlan fields rather than recomputed: createRenderPlan already computes gifFpsCapped (clamping a requested --fps above 30 to 30 for --format gif) but only used it for a console warning; this threads that same decision into RenderOptions and telemetry. All five are known before the pipeline starts, so both events carry them even when a render fails before a perfSummary exists. Also wires output_format/output_resolution_preset into Studio's separate emitStudioRenderComplete/emitStudioRenderError path, since Studio's request genuinely varies those two per-request. hdr_mode, video_frame_format, and gif_fps_capped stay CLI-only: Studio exposes no HDR-mode, video-frame-format, or GIF output control to users at all, so there is no per-request value to report for those three. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add adapters_used to render events adapters_used lists the runtime deterministic adapters (packages/core/src/runtime/adapters/) a composition exercises. Investigated the actual codebase rather than the ticket's guessed 7-adapter list and found 12 real registered adapters (gsap, css, waapi, animejs, lottie, three, typegpu, d3, leaflet, mapbox, maplibre, google-maps) — the map/data-source adapters share the exact same window.__hf<Name> registration mechanism and are equally real "adapters used" facts, so all 12 are included. Detection unions a live page.evaluate probe (piggybacked on the same probeSession resolveCompositionElementCount already uses) with a static regex fallback over the compiled HTML, since this is purely observational with no gating role (unlike compositionElementCount) — union strictly increases recall rather than needing one source of truth. Threaded through the same plumbing chain as the already-merged composition_element_tags/aroll_video_count/heygen_video_count. Studio's emitStudioRenderComplete/emitStudioRenderError get this field automatically, with no Studio-side code change, via the same shared capture-observability spread heygen_video_count already uses. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add audio/image/sub-composition/color-grading counts audio_count, image_count, sub_composition_count, audio_group_count, color_grading_count, and has_lut join composition_element_tags in the same single scanElementTags pass — audio/image/audio-group counts read from the scan's uncapped tag map so a 50+-distinct-tag composition can't push a named count into the "other" bucket. sub_composition_count counts data-composition-src mounts directly rather than reusing collectSubCompositionSrcs, which dedupes by src and drops placeholder/remote mounts (a different, smaller fact than the mount count this field reports). has_lut decodes the HTML-entity-escaped form of data-color-grading before parsing, since linkedom's serializer re-emits it "-escaped on every compile-pipeline round-trip — without the decode this silently reported false for every LUT-graded composition. The LUT-presence check now mirrors normalizeLut's own definition of "has a LUT" exactly, closing a gap where a blank or empty lut value would have reported true. Threaded through the same plumbing chain as the already-merged per-tag/provenance/adapter fields; Studio's emitter picks these up automatically via the shared capture-observability spread. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add browser_version_major and ffmpeg_version_major Both are already-run subprocess calls whose output was discarded: readToolVersion (checkFFmpeg's preflight) already runs `ffmpeg -version`, and chromeLaunchOutcome already runs `--version` as a Chrome launch sanity probe on every render. A shared extractMajorVersion helper pulls the first X.Y-shaped number from either banner, threaded onto EnvironmentCheckOutcome/ EnvironmentCheckResult, then RenderOptions right after runEnvironmentChecks resolves in the local render path only. Docker renders never populate these two RenderOptions fields (the containerized producer runs its own preflight the host CLI never sees), so both properties come back absent there rather than reporting the host's own toolchain versions for a render that didn't use them. Studio does not call runEnvironmentChecks or this ffmpeg/Chrome preflight at all before creating a render job, so its render_complete/ render_error events will not carry these two properties unless Studio's own startup path is separately instrumented — a genuine parity gap, not an oversight in this change. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add authoring_skill_source and authoring_skill_invalid Both derive from resolution logic createRenderPlan already runs: authoring_skill_source names whichever step actually won attribution (an explicit --skill flag, or the skill persisted in hyperframes.json) using the same flagSkill/projectConfigSkill values already computed for authoringSkill itself, not a second lookup. authoring_skill_invalid surfaces the raw --skill value when it fails slug-shape validation (normalizeSkillSlug rejects malformed input, not unregistered skill names) — this value already exists as RenderPlan.invalidAuthoringSkill and already drives a CLI warning in present.ts; this change threads the same field into telemetry rather than adding a parallel one. Threaded through both RenderOptions builders in execute.ts (single and batch render) and all three telemetry call sites in render.ts, including the Docker path — skill attribution is a request-level fact independent of which backend executes the render. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add root_body_mismatch and root_body_delta_px_bucket Measures the composition root's authored data-width/data-height against the scaffold's own html/body CSS size, straight from the compiled document string scanElementTags already scans — the source-level fact, not runtime DOM state. init.ts's applyCompositionSizing (the #4001 fix) only patches the live DOM after the browser loads this same HTML, so this measurement stays meaningful whether or not that fix ran for a given render. Reuses @hyperframes/parsers' canvasScaffoldPatterns regexes (the same ones packages/lint's root_dimensions_mismatch rule reads) for the CSS side. Root-tag detection here is a lighter first-data-composition-id-match heuristic, not lint's more thorough findRootTag (which also handles a leading decorative <svg> defs block) — that helper isn't part of @hyperframes/lint's public API, and expanding it is out of scope here. The delta is bucketed (0 / 1-10 / 11-50 / 51+ px) rather than reported raw, and both fields come back absent — not a false/"0" default — when the root or the CSS block can't be read at all. Threaded through the same plumbing as every other static-scan field this week, including the live-capture-observability fallback used on a crash that never reaches perfSummary. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * feat(telemetry): add hf_env_overrides (names only, capped at 20) Reports the sorted names of HF_*/HYPERFRAMES_* env vars present when createRenderPlan resolves — never their values, since several of these carry filesystem paths. Snapshotting at plan-resolution time (the very first line of the render command, before any pipeline code runs) matters: this render's own preflight later injects HYPERFRAMES_FFMPEG_PATH/HYPERFRAMES_FFPROBE_PATH into its own process.env, which would otherwise make those two always read back as "operator overrides" whether or not the operator ever set them. Reports an empty array, never an absent key, when nothing is set, matching every other capped/bucketed field added this week. Capped at 20 names, documented alongside the cap constant. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(cli): stop clobbering studio render_complete fields, fix an env leak trackRenderComplete's explicit audio_count/image_count/root_body_mismatch/ adapters_used/etc. keys, sourced from perfSummary.drawElement and only ever populated on the CLI's own render.ts path, always won the object spread over the observability-capture fallback those same keys share with trackRenderError, even when the direct value was undefined. studioRenderTelemetry.ts's emitStudioRenderComplete never populates drawElement, only the capture fallback, so 24 fields silently came back missing from every studio-triggered render_complete event despite Studio having computed and sent the capture value. Each field now falls back to its captureXxx counterpart via a new directOrCapture() helper when the direct field is absent, with regression tests proving both the fallback and that the direct value still wins when both are present. Also excludes HF_SHADER_WORKER_ENTRY from hf_env_overrides: cli.ts sets this on every invocation to point the bundled shader worker pool at its own file, sharing the HF_ prefix by coincidence rather than because an operator set it. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * style: remove em and en dashes from added code comments No behavior change. Rewrites 22 comment lines this PR's own diff introduced, using a comma or a period where the dash separated two clauses. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * test(producer): add a temporary diagnostic for a CI-only rootBodyMismatch failure Not a real fix. The rootBodyMismatch test group fails deterministically in CI (confirmed on two runs of the same head) but will not reproduce locally by any method tried, including an isolated file run, the exact combined producer:test:unit command, a worker-count-constrained run, and a fresh clone with a clean install and build matching CI's own steps. This dumps the intermediate regex-match state for the simplest failing fixture to stderr so the next CI run's log says what actually differs. To be removed once that answer lands. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * test(producer): widen the rootBodyMismatch diagnostic to compare import paths Still not a real fix. The previous diagnostic ( |
||
|
|
9117b36471 |
fix(engine): skip motion-blur dedup when the shutter window moves (#4025)
The static-frame dedup reuses the previous frame's buffer when a frame is byte-identical to its predecessor. With motion blur on that test is not enough: the shutter window reads content either side of the frame instant, so a still frame next to a moving one reused a buffer that carries none of that motion. At 30 fps and 16 samples frame 11's window runs 10.75 to 11.25 at a 180 degree shutter and 10.06 to 11.94 at 720, so both reach past the frame instant and the default is affected too. motionBlurWindowIsStatic now owns whether a blurred frame may reuse its predecessor's buffer. It requires every frame the window reads from to be static, and covers the previous frame's window as well, because reuse claims the two blurred outputs are equal. The range is derived from the plan's own sample tick offsets, so it cannot drift from where the samples land. The guard's contract comes from #4013 by Dante-dan, which carried it before we did. One existing test fixture is widened: it asserted reuse with only the frame itself marked static, which the guard now correctly rejects. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
b64a7a3a67 |
feat(producer): make sub-frame motion blur reachable from a render (#4020)
* feat(producer): make sub-frame motion blur reachable from a render Second deliverable for issue #4010. The engine gained the accumulation path, but nothing set CaptureOptions.motionBlur, so the feature could not be switched on from a real render. RenderConfig now carries it and the producer threads it into the capture options. Doing that surfaced the defect that would have made it fail silently. "Does this render capture PNG frames" was spelled four times, every one of them re-derived from needsAlpha, which describes the OUTPUT container rather than the capture: probeStage picked the probe capture format renderOrchestrator picked the render capture format encodeStage built the encoder's frame_%06d input pattern, twice captureStage built the disk worker's frame extension Motion blur is a second, independent reason to capture PNG: the sub-frame samples are averaged pixel by pixel, so JPEG samples would be averaged after a lossy quantization. Add that reason to only one of those four and the encoder goes looking for frame_%06d.jpg against files written as .png, and the render finds no frames at all rather than reporting an error. So the rule now has one owner, resolveCaptureImageFormat, which names both reasons. Everyone downstream reads the resolved capture format instead of re-deriving it: the encoder and the disk worker share frameFileExtension with writeCapturedFrame, which is what actually names the files. needsAlpha keeps only the question it really answers, whether the output preserves alpha. captureStage no longer needs needsAlpha at all, which is the tell that the re-derivation was its only reason to know. No JPEG encoder is involved. The PNG capture path is already load-bearing, since every transparent-output render uses it and the streaming encoder already selects png as its input codec. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(engine): resolve the motion-blur plan on every initialization exit A real render produced no blur at all. The plan was correct, the producer wiring was correct, and the sub-frame sample times were genuinely distinct, but K=16 and K=64 rendered identically to no blur because neither ever ran. initializeSession has two exits: screenshot mode finishes its own block and returns, every other capture mode falls through to the end. The plan was resolved at the fall-through exit only, and screenshot is the one capture mode motion blur supports, so on exactly the path the feature runs on session.motionBlur was never assigned. captureFrameCore then read it as undefined and took the single-capture branch: one capture per output frame, no accumulation, no error. finalizeSessionInit is now the single owner of what finishing initialization means. It sets the plan and isInitialized together and is called at both exits, so a third exit cannot mark a session ready while forgetting the plan. Why the existing tests passed, which is the part worth keeping: the accumulation test set session.motionBlur directly on a stub session. It exercised the mechanism while bypassing the only code that decides whether the mechanism runs at all. A test that proves something works and never asks whether it is reachable is not end to end, however much behaviour it drives. Two tests. A structural guard that session.isInitialized = true appears exactly once and that the same place resolves the plan, which fails if the defect is reintroduced at either exit. And a sample-distinctness assertion that hashes the K buffers captured within one output frame and requires K distinct ones, which covers the different failure of every sub-frame seek landing on one instant. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
e4b1832fad | feat(cli): add hls output format (#4022) | ||
|
|
9aa60a844f |
fix(registry): make motion-blur match After Effects shutter-based blur (#4008)
* fix(registry): make motion-blur match After Effects shutter-based blur The motion-blur catalog snippet estimated speed from the x/y delta between consecutive timeline updates and drew three one-sided ghost copies with decaying alpha plus a Gaussian on top. After Effects does temporal supersampling: each frame integrates the layer over a shutter window (shutter angle, shutter phase, samples per frame), producing a symmetric box smear of length speed x shutter time. The snippet now does the same inside the page. After every timeline update a deferred pass seeks the GSAP timeline to N sub-frame times across the shutter window (events suppressed), reads each target's x/y, restores the frame time, and drives an SVG filter that averages N feOffset copies with equal weight (feComposite arithmetic, 1/N, sRGB). Defaults follow AE: shutterAngle 180, shutterPhase -90, samplesPerFrame 16; fps comes from the render config, then the root data-fps, then 30. Displacements are mapped through the inverse of the element's own scale/rotation; percent x/y values are resolved to px; SVG targets use their bbox; an existing inline filter is kept and the blur appended to it. The pass runs in a microtask rather than inside the tracker tween's onUpdate, because seeking GSAP from within its own render reads stale tween state and suppresses the composition's callbacks on forward seeks. The heuristic options blurScale/blurMax/stretchScale/stretchMax are removed. Reference: an AE-algorithm reconstruction of the same composition rendered without the component at an effective 960 fps and averaged over the 16 sub-frames of the 180 degree window per output frame. Against it (1920x1080, 30 fps, solid + text translating linear then power2.inOut): old mean PSNR 53.2 dB (min 26.7), SSIM 0.9930; new mean PSNR 72.4 dB (min 52.6), SSIM 0.9998. The 10-90 edge ramp at 66.7 px/frame is 26.8 px in the reference, 27.5 px new, 14.5/6.3 px (one-sided) old; at the ease peak 79.3 vs 79.5 vs 7.9/17.7 px, with smear centres matching to 0.1 px. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(registry): drop render-config fps probe from motion-blur and refresh its docs The snippet resolved fps by reconstructing the renderer's private seek config key so the compiler's inline-script guard would not strip it. A public catalog snippet must not depend on that key, so fps is now the explicit option, then the root data-fps, then 30; renders with an fps override pass fps explicitly. The catalog page's Usage, How it works and Options sections still described the old velocity heuristic and are rewritten for the shutter model. The 1/N weight keeps full precision. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> * fix(registry): match the After Effects reference export's shutter window The shutter model landed on this branch was built against a reconstruction of After Effects' algorithm, not against After Effects. A real 1920x1080 / 30 fps export of translating text disagrees with it on three points, all measurable off the export itself. The window is two frames wide, not half a frame. On the measured frame the outermost duplicate on the trailing side sits 128 px back while the previous frame is 125.8 px back, and the outermost leading duplicate sits 189 px ahead while the next frame is 184.9 px ahead: the window ends land on the neighbouring frames. Defaults become shutterAngle 720, shutterPhase -360. Duplicates sit on the sub-interval boundaries, both ends included, not at sub-interval centres. The staircase across a stroke steps every 15.6 px on a 125.8 px displacement and every 23.0 px on a 184.9 px one, i.e. an eighth of a frame, with the run closing exactly on the neighbouring frame's position. samplesPerFrame now means sub-intervals of the window, so there are N+1 copies at k/N rather than N copies at (k+0.5)/N. The frame-time instance is drawn sharp over the smear. At full ink the core bounding box is the whole word at every frame and total ink rises to 1.44x the unblurred frame's, neither of which an average can produce. A final feComposite over of SourceGraphic reproduces it. Each duplicate stays at 1/N: the staircase plateaus step by 0.063 +- 0.002 of the sharp text intensity, flat, with no taper toward the window edges, and triangle weighting scores 1.6 dB worse against the export. Verified by replaying the export's translating section as a composition whose sprite is the export's own rest-frame ink and whose eight legs follow its own measured per-frame positions, rendered at 1920x1080 / 30 fps and compared frame by frame over the text band. Mean PSNR against the export rises from 24.19 dB (min 17.07) to 32.51 dB (min 27.57) across 72 frames, and from 19.83 dB (min 17.07) to 32.03 dB (min 27.57) across the 26 frames that actually move. Ghost extents match to a few px and the rendered staircase plateaus match the export's to two decimal places. The export's later scale, skew, flip and 3D rotation sections are out of reach here: integrating anything but translation inside the shutter is engine work tracked in issue #4010. The filter region now clamps its bounds through zero. It was derived from the duplicate offsets alone, which was correct while the output was nothing but duplicates; the sharp instance draws at offset zero, so a window sitting entirely on one side of the frame time - any shutterPhase above 0 or below -shutterAngle - produced a region starting inside the element's own box and cut the instance off. The defaults straddle the frame time and were never affected, so the measured numbers above are unchanged. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com> --------- Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
462f9db73d |
feat(producer): add hls output format (#4014)
* feat(engine): add packageHls for HLS VOD packaging Adds `packageHls` next to `muxVideoWithAudio`: stream-copies an already-encoded H.264 video and optional AAC sidecar into an HLS VOD directory (`master.m3u8` + split video/audio renditions, MPEG-TS segments) with `-c copy`, so no re-encode happens at package time. Depends on the locked GOP from the previous commit. `-hls_time` can only cut at existing keyframes, so `gopSize = segmentSeconds x fps` is what makes every segment exactly `segmentSeconds` long and IDR-aligned. A test packages an unlocked encode to pin that: the same call yields one 4s segment instead of four 1s ones. Deliberately omits the mp4 mux's provenance tags, which MPEG-TS drops and whose `-movflags` is invalid for `-f hls`, and `-avoid_negative_ts`, which would discard the AAC priming and shift audio against video (see issue 3487). A real-ffmpeg test asserts the first presentable audio sample lands on the first video frame. No caller yet; `format: "hls"` in the producer follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(engine): zero HLS mux base and reject % in packageHls outputDir - Pass -muxdelay 0 -muxpreload 0 so the first segment starts at PTS 0 instead of the mpegts muxer's 1.4 s base; A/V alignment through the AAC priming packet is unchanged - Throw on an outputDir containing %, which ffmpeg expands as a printf conversion in -hls_segment_filename and fails with a misleading "No such file or directory" - Assert alignment on relative spacing rather than on how one ffmpeg build clamps the priming packet's negative PTS - Encode the test fixture with the full software GOP lock (lockedGopCodecParams), not just appendLockedGopArgs - Trim comments to the non-obvious rationale Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(engine): satisfy the ffprobe argv contract in packageHls test `producer/src/utils/ffprobeArgvContract.test.ts` sweeps every package at the source level and requires `--` to be the argv entry immediately before an ffprobe input path. The `probe()` helper here passed `join(outputDir, HLS_VIDEO_PLAYLIST)` in that slot, which the contract's comma-splitting parser reads as two entries, so the producer suite was red on this branch while the engine suite stayed green. `probe()` now takes the input path as a parameter and appends the terminator itself. The sibling ffmpeg `run()` helper switches from a spread-in-array-literal to `.concat`, because a verbosity-flag-then-spread literal is indistinguishable from the ffprobe wrapper shape the contract demands a terminator on. The matcher reads raw source including comments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(engine): drop the audio-only variant from the HLS master playlist With `a:0,agroup:aud` in `-var_stream_map`, ffmpeg lists the audio rendition twice in `master.m3u8`: as the `#EXT-X-MEDIA:TYPE=AUDIO` group the video variant references, and again as a standalone `#EXT-X-STREAM-INF` variant with no `RESOLUTION`. That is valid HLS, but a player choosing by bandwidth can select it and play sound with no picture, and the VOD consumer asked for a single rendition. `stripAudioOnlyVariants` removes any variant tag without a `RESOLUTION` attribute together with its URI line; `packageHls` applies it to the master after a successful audio+video run. The rendition group and the audio playlist are untouched, so `audio.m3u8` is still referenced from the master. Pinned by a unit test on the playlist text and by the real-ffmpeg test, which now asserts exactly one `#EXT-X-STREAM-INF`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(producer): extract hdrMode warning and decision-log helpers `resolveEffectiveHdrMode` was already over the fallow complexity gate on `main` (21 cyclomatic / 25 cognitive / CRAP 462) and only surfaces now because the HLS format work needs to touch its downgrade warning. Pull the downgrade message into `formatDowngradeWarning` and the "which mode and why" info line into `logHdrDecision`, which puts the function under the threshold. No behavior change: every log string is byte-identical and `hdrMode.test.ts` asserts them unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(producer): add hls output format Adds `format: "hls"` to `@hyperframes/producer`. An HLS render is the same opaque H.264 + AAC encode as `mp4`, stream-copied at assemble time (no re-encode) into a directory: `master.m3u8`, `video.m3u8` + `video_%05d.ts`, and `audio.m3u8` + `audio_%05d.ts` when the composition has audio. The load-bearing part is that `-c copy -f hls` can only cut a segment at an existing keyframe, so the encoder's GOP is locked to `hlsSegmentSeconds x fps` frames. `resolveHlsEncoderGopLock` returns that pair as one value spread into both encoder inputs, so the streaming and disk/chunked paths cannot disagree — a render reaching only one of them still succeeds, it just emits variable-length segments silently. - `renderFormat.ts`: `hls` is opaque (`outputNeedsAlpha` is now `!== "mp4" && !== "hls"` — missing this flips HLS to RGBA screenshot capture with nothing failing), supports page-side compositing, and `outputUsesH264Pipeline` replaces the three `outputFormat === "mp4"` router gates. - `hlsConfig.ts`: `hlsSegmentSeconds` (default 4, positive integer), the GOP derivation, and the fail-fast gates. `force-hdr` is rejected (v1 is SDR-only; HDR10 needs HEVC in fMP4), and so is `useGpu` — the GOP lock is software-only, so a GPU HLS render segments correctly by accident on videotoolbox and wrong on nvenc. - `assembleStage.ts`: `hls` branches to `packageHls` instead of mux/faststart, after the same `padOrTrimAudioToVideoFrameCount` the mp4 mux uses. - `artifactTransaction.ts`: a directory artifact can now carry a `probeTarget` + `requiredFiles`, so the #3395 truncation gate still runs for HLS against `video.m3u8`. `expectedFrames` is inert there (ffprobe reports no frame count through the hls demuxer); duration carries it. - `renderRequest.ts`: accepts `hls` + `hlsSegmentSeconds`; distributed conversion rejects it alongside `gif`. `DistributedFormat` is unchanged. `RENDER_REQUEST_VERSION` stays at 1: the new field is optional, the parser ignores unknown option keys, and an older worker already rejects `format: "hls"` with "Render request format is invalid". - `hdrMode.ts`: the non-mp4 SDR downgrade warning no longer claims "HDR + alpha" for `hls`; the helper extraction landed in the preceding refactor commit. Verified: `hlsRender.integration.test.ts` renders a real 10 s composition and asserts 3 segments, the first two exactly 4.000 s, every segment starting on an IDR frame, and PTS 0. Removing the GOP wiring collapses it to 2 segments, so the test is load-bearing. Both producer lanes (unit and integration) run on every PR in `ci.yml`, so that wiring check is on the PR gate. CLI flags and docs are deliberately left to the next PR in the series. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(engine): close the HLS master playlist rewrite race CodeQL flagged two high-severity alerts on the `stripAudioOnlyVariants` rewrite: a check-then-use race (`existsSync` then a separate path-based read and write) and insecure creation of a file at a predictable path in the OS temp dir. Both came from resolving `master.m3u8` three times. Open it once with `r+` and owner-only mode — which neither creates the playlist nor widens its permissions, ffmpeg owns that — then read, truncate and write through that one descriptor. Absence is still tolerated for the argument-level tests that stub ffmpeg, now via ENOENT rather than a prior existence check. No behavior change: `hlsRender.integration.test.ts` still renders a real composition with audio and asserts the stripped master playlist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |