mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 22:27:29 +00:00
sync/hyperframes-codegen-856bcbbf
662
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
eb0957a497 |
fix(core,lint): keep html/body sized to the composition root, warn when a scaffold's copies drift (#4005)
A composition whose root declares portrait data-width/data-height renders at the correct output size, but a project scaffolded landscape (init without --resolution) still ships html/body CSS and a viewport meta at the old landscape size. The stale body's overflow:hidden then clips the correctly-sized root at the old height, so everything past it renders as page background instead of real content. applyCompositionSizing now mirrors the SAME forced width/height it already computes for the root onto documentElement/body, reusing rather than recomputing them. overflow:hidden is left untouched (a deliberate anti-white-bar guard set earlier in the same init pass): once body's own size agrees with the root it contains, it clips nothing that matters. Runs in both preview and render, since both load the one bundled runtime artifact this file builds into. Adds a warning-level root_dimensions_mismatch lint rule alongside root_missing_dimensions, comparing the root's dims against the html/body CSS block and the viewport meta - the two other places a scaffold's resolution lives besides the root itself. The read-only regexes live in @hyperframes/parsers (packages/lint cannot depend on packages/cli, which owns the closest existing pattern in applyResolutionPreset) so a future caller has one place to update if the scaffold's shape changes, rather than a rule silently comparing against a guess. Closes #4001. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
8886743f44 |
fix(core): tell an opaque document how to actually get its Web Audio back (#3940)
The opaque-origin fix in #3937 routed correctly but sent the diagnostic down a dead end. An opaque document reached the same `cross_origin_no_cors` verdict a genuinely foreign asset gets, so the message told the author to "proxy or download the asset to a same-origin URL". That is impossible advice, because nothing is same-origin with an opaque origin and the asset is usually same-origin by URL already. Split the verdict into two reasons and give each its own note and remedy. The opaque case now names the two fixes that work: `allow-same-origin` on the sandbox, or `crossorigin` plus an `Access-Control-Allow-Origin` of `null` or `*`. Opacity wins when both conditions hold, since it is the blocking one and the one an author cannot fix by moving the asset. The CLI scraper matches on the code prefix only (checkBrowser.ts:309), so the new reason value does not touch that contract. - Rames |
||
|
|
293829a2c1 |
fix(core): route media through Web Audio only when the document can actually read it (#3937)
isCorsSilenced() compared a media URL's origin against window.location.origin, which stays URL-shaped even inside a sandboxed iframe without allow-same-origin. Inside such a frame the document's actual security origin (window.origin) is the string "null", so same-URL-origin media without a crossorigin attribute was waved through createMediaElementSource, and per the Web Audio spec's MediaElementAudioSourceNode security section that node outputs silence without throwing. The composition kept playing with the timeline advancing and no audio at all. Compare against window.origin instead, treating "null" as never same-origin, so an opaque document falls back to the existing decode-only path unless the media element opts in with crossorigin. |
||
|
|
cd975d8c28 |
fix(core/bundler): inline fonts and images so a lone bundle renders (#3590)
`bundleToSingleHtml` documented itself as producing "a single self-contained HTML file", but `INLINE_MIME` covered only `.svg`, `.json`, `.txt`, `.cube` and `.xml`. Every font and raster image stayed a live project-relative reference. That is invisible to every consumer in this repo, because each one serves the bundled string from a server rooted at the project directory, so the relative paths resolve. It breaks the moment the bundle is stored on its own, with no sibling asset directory: the font 404s and the page silently reflows in a fallback face, which is worse than a visible failure. Widen the inline set to fonts (woff2/woff/ttf/otf) and raster images (png/jpg/jpeg/gif/webp/avif), behind a 2 MiB per-asset cap. The cap is measured against this repo's own assets rather than guessed: the largest of 164 tracked `.woff2` files is 105 KB, and the largest of 284 tracked raster images is 2.00 MB, so everything in-tree inlines while a video-sized file cannot. Oversized assets keep their relative URL and warn, reusing the existing "may not be self-contained" wording. Audio and video stay external on purpose: they are large, streamed rather than laid out, and their absence is obvious rather than silent. Scripts already had a better path (`script[src]` is folded in as source), so they are deliberately not added to the MIME table. The five rebasing tests that asserted a relative path survived now assert the data URL's decoded content instead. That is a stronger check: resolving from the wrong base directory finds no file, so nothing inlines and the assertion fails. |
||
|
|
1acc65dbf8 |
fix(core): un-hide a later root-level clip instead of leaving it display:none forever (#3893)
A root-level `[data-start]` clip with no authored `position` starts out `position: static` until the runtime forces it to `position: absolute`. A visibility pass over the clip while it's still inactive can observe the pre-forcing `static` value and cache it as in-flow, which correctly hides the clip with `display: none`. But by the time the clip later becomes active, that same cache has been invalidated and recomputed against the now-forced `absolute` position — so the un-hide check, which re-derived the same cached fact instead of tracking what it had actually done, saw "not in-flow" and skipped the removal. The clip stayed `display: none` (and therefore zero-sized) for the rest of the render. Tracks whether this code applied `display: none` to a given element in its own WeakSet instead of re-deriving the in-flow cache, so the un-hide check can no longer disagree with the hide check that set it. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
4c6fa9a790 |
fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref
`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.
Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.
Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.
* docs(studio): plan for loading the preview during the shell's first layout
* fix(runtime): a paused preview stays still after any seek
Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.
Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.
- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
now returns every timeline it unpaused to paused in a finally. The second
rearm after the child re-seek only ever changed the leaked state and is
gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
timed media element found running. A parked transport runs no ticks, so a
capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
requests playback through the player so adapter, rAF loop and flag agree.
Regression tests fail without each of the three changes.
* perf(studio-server): stop re-encoding videos the browser already plays on open
Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.
One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.
Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.
Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.
* perf(cli): serve the studio bundle compressed and cache hashed assets
The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.
The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.
* perf(cli): warm the preview route before the browser opens
The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.
The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.
* perf(studio): request the player chunk before the shell's first layout
The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.
* fix(studio): editing a text property no longer reloads the preview
Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.
The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.
A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.
shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.
Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.
* fix(runtime): paused-time media playback is borrowed, not banned
The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.
- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
video while paused to render grading previews. It went dark on the first
paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
music track for ~140 ms while paused so a playhead drag is audible. Same path
killed it.
A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.
Two corrections to the previous commit's reasoning:
The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.
Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.
Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing
* test(runtime): a lease ends when the transport plays or the borrower stops
Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.
* test(studio): the event-stream channel must open /api/events
* fix(studio-server): stop printing a proxy diagnostic line per clip on every render
Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.
The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.
The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.
The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".
Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.
* perf(cli): stop gzipping the studio bundle on loopback
Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.
The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.
The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.
* docs(runtime): say what the paused-media probe actually filters
* revert(cli): drop the preview prewarm that held the CLI event loop
The fire-and-forget warm added in
|
||
|
|
e488871ce1 |
fix(audio): pin the slower release and the carve default, fold the requirement, sync the manifest
Review at
|
||
|
|
fe53f51adb |
fix(core): slow the voiceover carve's level release to 2.4s
At 1.6s the bed audibly came back at every sentence break on narrated builds, which reads as the effect switching off rather than the mix breathing. 2.4s lets it swell back over a breath. Band-filter release is unchanged; a notch closing is inaudible. Test timings move with the constant. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
eae4892ae8 |
chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security] * fix(test): preserve test behavior on Vitest 4 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: James <james.russo@heygen.com> |
||
|
|
c752b89bc9 |
fix(runtime): the editor keeps waking during a drag on a preview it did not build (#3850)
The manual-edit gesture watch tested mutation targets with `instanceof Element`. The composition body is adopted into the preview frame, so its nodes answer to another realm's Element and the check is false for every one of them: the watch never sees a gesture, and the paused transport it gates does not wake while the user drags. Routes the check through the runtime's structural predicate, which is what the preview-guard lint added alongside it now requires. Main is red on that lint for this line, so this also unbreaks it. Test adopts an element from a second realm and asserts the marker is seen and cleared; it fails with the identity check restored. |
||
|
|
d4fba55a4e |
perf(runtime): a paused preview stops burning CPU (#3845)
* perf(runtime): stop the preview transport ticking when the editor is paused A paused, untouched preview asked the browser for a fresh frame sixty times a second and re-read the page on each one. Nothing it looked at could change without some observable event firing, so the loop now stands down and wakes on that event instead, with a slow timer as the safety net. Parked, the loop keeps two jobs the 60 Hz version did implicitly: the control bridge's paused heartbeat, on the same interval as before, and a re-read of the timeline registry, which is a plain object no observer and no event can report. Everything else arrives by an event now: timing-attribute edits, mounted or removed timed elements and media metadata through the composition-timing observer that already existed; a manual-edit gesture starting or ending through a new attribute-filtered observer, which also replaces a whole-document query that ran on every paused frame; and playhead or play-state changes through the forced state post every transport mutation already ends in. Three periodic jobs (re-binding the root timeline, posting the clip manifest, binding media-metadata listeners) used a frame counter as a proxy for "the document may have changed". They now ask that question directly, because the counter stops advancing while the loop is parked. The render path is untouched: the loop never parks while an export render is driving frames. That test is the pair of renderCaptureSeekStarted and the producer's injected seek config, not the flag alone, because Studio's own preview falls back to renderSeek for overhanging timelines. Idle, paused, no input, on a 1689-element project: main-thread self time 128 -> 8.5 ms/s and animation-frame callbacks 282 -> 4.3 per second, both measured with the runtime and editor changes in place. * fix(runtime): keep the rebind policy the only owner of "may rebind now" The parked-loop change let a composition-timing change OR its way past shouldAttemptPeriodicTimelineBind, which removed the hold that keeps an async rebind off the first two seconds of playback, and let the clip manifest post on every frame of a composition that mutates the DOM every frame (measured: 30 posts in 30 frames against one). The change is now an input to that policy, which still applies the hold, and the change-driven path is confined to the paused path and rate-limited to the posts per second the frame counter already produced. A change it defers stays pending, and a pending change keeps the loop awake, so deferring can never drop it. Two more holes from the same review: The parked poll compared only the composition timing revision, so an adapter duration floor that grew was never noticed. Adapters infer duration from live animation objects that change with no DOM mutation and no media event, which makes it the second input nothing can push; both are now in one witness. Draining the gesture observer's records to answer within a task suppressed the observer's own callback for them, so a reader could consume the notification that un-parks the transport. Draining now notifies. The watch reports whether it is observing at all, and the loop refuses to park when it is not. That path is unreachable today because the colour-grading runtime constructs a MutationObserver unconditionally during the same init; the flag is the explicit statement of the invariant for the day that changes. * fix(runtime): clear the pending-change latch after the post, not before postTimeline walks author DOM and can throw. The tail scheduler runs in the tick's finally either way, so clearing the latch first let it see nothing owed and park with the change undelivered — and nothing would deliver it until some unrelated change happened to wake the loop again. Clearing after the post means a throw leaves the change owed, the loop stays awake, and the frame counter retries it within twenty frames. Same rule the shared editor loop already follows for its own re-arm. Also rewords the note on draining the gesture observer's records: that is hardening, not a fix for a live lost wake. isActive has exactly one caller today, inside transportTick, and a tick is its own task, so the observer's microtask has already run by then. * fix(runtime): stop the parked heartbeat when the page has gone away The parked transport holds a timer where the old loop held only an animation frame, and a frame is discarded when a page or a test environment is torn down while a timer is not. A test that initialises a runtime and abandons it therefore left an 80ms timer to fire into a dead global, which CI reported as an unhandled ReferenceError attributed to whichever file was running when it landed. Two changes, because the leak has two ends. The heartbeat stops instead of re-arming when window or document is gone: nothing is left to report a state change to, so stopping is the answer rather than throwing. And the one test that initialised a runtime without ever tearing it down now tears it down. |
||
|
|
73dfe35e46 |
fix(preview): show the correct scene instead of every scene at once on some loads (#3848)
* fix(runtime): show the right scene when the preview DOM comes from another window The Studio preview sometimes builds the composition body in the editor window and adopts it into the preview frame's document. Adopted nodes keep the prototypes of the realm that created them, so `node instanceof HTMLElement` is false for every element in the composition even though the elements are ordinary HTML sitting in that document. Every guard in the runtime written as `if (!(node instanceof HTMLElement))` then skipped the whole document, silently: nothing threw, nothing logged, and readiness still reported success. The timed-element visibility pass wrote no inline visibility at all, so on those loads the preview painted every scene on top of every other and the editor drew off-canvas markers for elements the user could not see. The auto-stamp pass stopped stamping too, which is why the composition came up one timeline clip short. Replace every realm-sensitive element check in the runtime with structural predicates that ask what a node IS (node type, namespace, tag name) rather than which window's constructor made it. Two local `doc.defaultView.HTMLElement` workarounds are deleted with it: they fix only the case where the nodes belong to the document's own realm, and adoption is exactly what breaks that. * fix(studio): scrub the music track the timeline named, not the first audio `resolveScrubAudioEl` tested `byId instanceof HTMLAudioElement` on a node from the preview iframe's document. That node is an instance of the IFRAME's `HTMLAudioElement`, never this module's, so the check was false on every load and the `musicId` hint was dead. Scrub fell through to the first `<audio>` in the document, which the comment right above it warns can be the voiceover, so dragging the playhead could preview the wrong track. Ask what the node is instead. Also close the gaps an independent review found in the runtime fix: - the cross-realm predicate test had no `<audio>` and no `<img>`, so reverting `isAudioElement` or `isImageElement` to `instanceof` left it green. It no longer does, and the audio case also pins `isMediaElement`, which composes from it and gates the media sync path. - nothing stopped the runtime regressing. `lint-runtime-preview-guards.ts` gains a second check kind: patterns that must be ABSENT under a directory, seeded with DOM-typed `instanceof` under `src/runtime`, pointing at domRealm.ts. Comment lines and tests are exempt, both on purpose. - domRealm.ts stated the adoption mechanism as settled fact. The mixed prototypes are measured; how the nodes get into the frame is not identified, and the docstring now says which is which. Its ownership claim is scoped to the runtime, since packages/studio still hand-rolls its own checks. |
||
|
|
8bf5b4423e |
perf: sublinear Studio rebuilds and seeks (#3837)
* perf(studio): resolve the overlay coordinate basis once per composition
The iframe->overlay basis (composition root, root scale, iframe and overlay
rects) was resolved inside every geometry call, so measuring a preview cost one
querySelector("[data-composition-id]") plus three layout reads PER ELEMENT to
rediscover something that is a property of the composition and the canvas zoom.
It is now threaded through orientedGroupAwareOverlayRect, groupAwareOverlayRect,
orientedOverlayRect, orientedVisibleOverlayRect and toVisibleOverlayRect the way
toVisibleOverlayRects already batched it, and resolved once by the two callers
that measure many elements in one synchronous pass: the off-canvas indicator
rebuild and the overlay RAF loop.
Both passes only read the DOM, so nothing can move the canvas between two
measurements inside one of them.
* perf(studio): rebuild the layer walk from the mutation, not the document
A MutationObserver marked the off-canvas indicators dirty and the rebuild then
re-derived every element in the preview from scratch. The observer's loudest
source is inline style, which is what animation writes, so a composition just
sitting there re-derived the whole document several times a second, and each
element costs two getComputedStyle reads, two ancestor walks for its source
file, and a textContent read over its whole subtree.
The records now say WHAT to drop, not merely that something changed, and the
per-element derivations are memoized between rebuilds. Two lifetimes, because
they do not go stale together: whether an element renders (and how many layer
children it has) dies on any nearby attribute write, while how it is ADDRESSED
survives every style write and dies only on something that can renumber a
selector. That split is what makes an ordinary animation frame cost no document
queries at all.
Not attributable to specific elements, so still a full rebuild: nodes added or
removed, and any change to an identity attribute, which renumbers every element
sharing a selector.
Two supporting changes:
- getDirectLayerChildren asked getDomLayerPatchTarget for a full patch target
per child and used it as a boolean. The target carries the selector's
occurrence index, which is a whole-document query the yes/no does not depend
on. isDomLayerElement answers it without one, for every caller. Its unused
options parameter goes with it.
- The observer no longer filters attributes. An attribute it never hears about
is one the cache would answer stale for, and the old filter omitted id and the
data-composition-* attributes that decide a layer's identity. Widening only
makes indicators refresh sooner; a rebuild is a pure read and is throttled
either way.
Every other caller of collectDomEditLayerItems passes no cache and is unchanged.
* perf(runtime): visit only the clips a seek can flip
Every seek, and every frame of playback, rebuilt the window of every video and
audio element in the document and handed all of them to the per-clip sync loop.
On a composition of eighty videos that is eighty window derivations and eighty
per-clip passes to conclude that one clip is on screen.
A clip is active only while start <= t < end, so a clip whose window excludes
the new time is inactive there whatever its element state is. Sorting the
windows by each endpoint turns a seek into two binary searches: the clips whose
start or end lies between the old time and the new one, plus the ones that were
in window at the old time. Anything outside both was out of window before and is
out of window now, and was already paused and already evicted by the pass that
last saw it go out. A one-frame step visits the clip or two that flip; a jump
over a hundred clips still visits the hundred boundaries it crosses.
The index carries only the windows, and it rides the revision the duration
floors already ride: the same timing attributes, the same media metadata events,
the same timeline registry signature. Nothing else is cached. Every field handed
to syncRuntimeMedia is re-read from the element on every pass, because
el.duration can be reset under us by the load() retry, and a cached copy of it
would be exactly the stale duration the two-resolver-scope rule exists to
prevent.
The render/export path does not consult the index at all and visits every
element on every frame, and a render seek clears the sweep so a later live seek
cannot inherit a position it did not establish.
Also folds the duration floors' own invalidation into that shared revision.
Draining the observer is what detects a change, and only the first reader in a
task gets the records, so two caches each checking for themselves would have had
the second told nothing changed.
* refactor(studio): split the overlay basis and the layer-walk read into their own modules
Clears the 600-line file cap and the fallow audit on this branch. Behaviour is
unchanged: this moves code, it does not alter any of it.
File size. Both files were already near the cap on main and my three levers
pushed them over (615 vs 598, and 605 vs 584):
- domEditOverlayGeometry.ts -> 561. The iframe->overlay coordinate basis
(OverlayRootScale, computeOverlayRootScale and the root/dimension lookups it
owns) moves to domEditOverlayBasis.ts. It is the one piece of that file every
other piece depends on, and nothing in it is about a single element's
geometry. Its two callers now import it from there.
- domEditingLayers.ts -> 587. The per-element read that the walk performs moves
to readDomEditLayerWalkEntry in domEditLayerWalkCache.ts, the module that owns
the memoization it reads through. The walk keeps the traversal and the depth
bookkeeping, which are the parts that are actually about walking.
No unrelated code was trimmed to make room.
Duplication, all three clone groups fallow flagged:
- The runtime seek fixture (createMockTimeline, the synchronous animation-frame
clock, the CSS.escape shim, stubDuration) was copied between
init.timingResolver.test.ts and init.mediaClipIndex.test.ts. It moves to
runtimeSeekFixture.test-helpers.ts. Each suite keeps its own vi.mock calls,
which are file-scoped and cannot be shared. The file is excluded from
tsconfig.runtime.json alongside the test files it serves, for the same reason.
- domEditLayerWalkCache.test.ts repeated its mount/observe/first-walk setup in
three tests; that is now withWarmWalk.
Complexity. Only one of fallow's three findings is attributable to this branch,
and fallow agrees: it marks the other two inherited and excludes them from the
gate.
- offCanvasIndicatorRefresh.ts update was NEW (absent from main's report, 10
cyclomatic / 31.6 CRAP here). The optional-chain-and-default I added to drain
the observer becomes drainPendingLayerMutations, with its own unit tests, and
the function drops off the report entirely.
- useDomEditOverlayRects.ts update: 37 cyclomatic / 69 cognitive on main AND
here. My change added one statement and no branch; the function grew 139 -> 147
lines, which is what resurfaced it. Left alone.
- domEditingDom.ts escapeCssIdentifier: 24 cyclomatic / 19 cognitive / 148.4
CRAP on main AND here. Untouched by this branch; only its line number moved
(173 -> 182) because the composition-source-map revision counter sits above it.
Left alone.
|
||
|
|
5973273f8d |
fix(runtime): resolve media clip start once for playback, manifest, and audio (#3833)
* fix(core): play and draw media clips at the same time A clip placed inside a scene could be drawn on the editor's timeline at one time and actually play at another. Nothing failed; the timeline just showed the clip in the wrong place, and a clip pushed past the end of the composition disappeared from it entirely. The cause was that the code drawing the timeline and the code playing the video each worked out the clip's start time from the same HTML attributes in their own way, and only one of them knew about the marker that says "this start time is already measured from the beginning of the whole video". Both now ask the same function. The same function also answers for the visibility pass, the audio scheduling paths, and the volume-fade probe, all of which were reading the raw attribute and so placed a nested clip's audio at the wrong moment. Also folds the duplicated media-length helper into one shared version. * refactor(core): resolve the volume probe window once per element * fix(core): resolve media starts through the pass-scoped timing resolver After rebasing onto the resolver-reuse change, the shared media start resolver was building a fresh start-time resolver per call, which is the per-element construction that change removed. Route it through the scoped resolver so one pass shares one set of caches. |
||
|
|
3ea2f909e1 |
perf(runtime): reuse one timing resolver per pass and derive duration only when the composition changes (#3824)
* perf(runtime): reuse one timing resolver per synchronous pass
`createRuntimeStartTimeResolver` memoizes element start and duration
lookups in WeakMaps, but `resolveStartForElement` and
`resolveDurationForElement` each constructed a brand-new resolver per
call and discarded it. The caches never served a second lookup, so a
single pass re-walked the composition ancestry of every element, and
per-seek work scaled with total timeline content rather than with what
changed.
`withTimingResolver(fn)` installs one resolver for the duration of a
synchronous callback and restores the previous one in a `finally`, so a
throw cannot leak the scope. Callers outside a scope keep today's
construct-per-call behaviour unchanged.
Three separate scopes, deliberately not one:
- the media-cache build in `syncMediaForCurrentState`
- the body of `syncTimedElementVisibility`
- the media scan in `resolveMediaWindowDurationSeconds`
They must stay separate. `syncRuntimeMedia` calls `el.load()` on the
seek-past-buffered-range retry, which synchronously resets
`el.duration` to NaN, and `resolveDurationForElement` reads
`element.duration`. A cache spanning that write would serve the
pre-`load()` duration to a post-`load()` read. Splitting at the write
makes the staleness unrepresentable instead of merely handled.
The third scope sits on `resolveMediaWindowDurationSeconds` rather than
its caller `getSafeTimelineDurationSeconds`, which would look like the
tidier boundary: that caller also invokes author-supplied
`timeline.duration()` and third-party adapter
`getInferredDurationSeconds()`, and foreign code inside a cache scope
can mutate the DOM between two resolves.
Resolver constructions per seek plus transport tick go from 15 per media
element to a flat 4, and self time in `resolveStartForElementInternal`
falls about 70% on a 79-video composition.
* perf(runtime): derive composition duration only when the composition changes
The runtime re-derived the composition's total duration on every animation
frame. transportTick called getSafeTimelineDurationSeconds unconditionally,
and deriving it scans every media element in the document and walks each
one's composition ancestry to resolve an absolute start and duration.
That value is a function of the composition, not of the playhead, so a
paused editor with nothing happening recomputed the same answer ~60 times a
second. On a 91-media-element composition the media scan ran 1.05 times per
frame while the editor sat idle and untouched.
Derive it once and reuse it until an input could have changed. The inputs,
and the signal that catches each:
- timing attributes edited (live editing, variables re-applied, the
runtime's own autostamping) -> MutationObserver with an attribute filter
- timed elements added or removed, including nested compositions that
mount asynchronously after init -> the same observer, childList+subtree
- media metadata arriving, or el.load() resetting duration to NaN, neither
of which mutates the DOM -> capture-phase media event listeners
- a timeline registered or lengthened in window.__timelines, a plain
object nothing can observe -> a registry signature compared on read
Observer records are delivered in a microtask, so the queue is drained on
read as well; otherwise an edit read back in the same synchronous block, the
ordinary live-editing case, would be served the pre-edit value.
The render path does not read the cache at all. Capture depends on the exact
duration and a frame rendered against a wrong one cannot be recovered, so it
pays the full derivation on every frame exactly as before.
Measured on the 91-media-element composition, idle and untouched: media
scans per frame 1.05 -> 0.05. Across 100 single-frame seeks: 2.28 -> 0.11
per seek, so seeking does not invalidate the cache either. The residual is
a separate per-20-tick caller in timeline.ts, untouched here.
|
||
|
|
ff749b8705 |
fix(security): verify CDN script integrity before inlining (#3822)
* fix(security): verify CDN script integrity before inlining * fix(producer): normalize SRI algorithm case * fix(producer): abort compilation on integrity mismatch * fix(compiler): preserve nested script integrity requirements * fix(compiler): defer local inlining until integrity is known |
||
|
|
c6010f2eb8 | fix(registry): authenticate deck sound effect messages (#3819) | ||
|
|
05d7261052 | fix: restrict runtime and playground message dispatch (#3816) | ||
|
|
b8328f9573 | fix(core): authenticate runtime control message senders (#3813) | ||
|
|
4233b5c6bf |
fix(core): contain generated HTML CSS and script contexts (#3800)
* fix(core): escape generator metadata attributes * fix(parsers): retain decoded metadata while assigning ids * fix(parsers): preserve runtime html parser semantics * fix(parsers): canonicalize HTML attribute names for stable IDs * fix(parsers): normalize SVG attribute hashes across HTML parsers * fix(core): escape public resolution attribute values * fix(core): contain generated HTML CSS and script contexts * fix(core): preserve empty captions and document authored code trust |
||
|
|
3a7fcd10e0 |
feat(registry): add 25 image carousel blocks (#3790)
* feat(registry): add 25 image carousel blocks (5 families × 5 variants) Five carousel families, each with 5 style variants: - Orbit (1–5): image cards on a spinning 3D Fibonacci sphere - Path (1–5): cards following animated CSS motion paths - Circle (1–5): circular carousel layouts - Vision (1–5): Apple Vision-style spatial presentations - Text Circle (1–5): circular carousels with text overlays All blocks are 1920×1080 at 6s, with 12–24 configurable image slots. Includes catalog preview thumbnails for each block. Co-Authored-By: Jake Moran <jake.moran@heygen.com> * refactor(registry): host carousel block images on the CDN The registry is served straight out of this repository (DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a block ships is permanent history. The 25 carousel blocks added 421 JPEGs, 27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images were distinct: the same 12-24 placeholders were copied into every block. files[] entries gain an optional `url`. When set, the installer fetches the bytes from there instead of joining the registry base. `path` does not change and still says where the file lands relative to the item, so composition HTML, target mirroring and `hyperframes add` behave exactly as before. Keys are content-addressed, so the 396 manifest entries resolve to 23 objects, and a changed image gets a new URL rather than a stale one cached behind `immutable, max-age=31536000`. The catalog preview renderer copies an item's directory and renders it, so it needs the same materialisation step. Without it the preview draws every card blank and reports success, which is worse than failing. Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing; catalog previews are rendered by CI and served from docs/images/catalog. Verified: all 23 objects return 200 from the CDN with hashes matching their keys; `hyperframes add carousel-orbit-1` against a local registry installs 24 real JPEGs; the preview render produces the album art, and produces blank cards when the fetch step is removed. * style(registry): format the carousel composition HTML `oxfmt --check .` covers the whole tree, and these 25 files were never run through it. The pre-commit hook only formats staged files, so nothing local caught it. * feat(catalog): publish the carousel blocks without republishing their images These 25 blocks had no Catalog page. Every other item in the registry has one, so they shipped invisible: installable by name, unfindable by browsing. Generating them naively undid the change they were added by. The Catalog payload copies an item's assets into docs/public/, which is tracked, so the 396 images this PR just removed came back as 43 MB one directory over — worse than the 3 MB they started as, because each block got its own copy. The copy exists because these compositions assemble `img.src` at run time out of a variable value, so there is no `src="..."` in the markup for the payload's asset scan to resolve. An unpredictable path can only be satisfied by serving every file beside it, which is what `needsOwnDirectory` asks for. An absolute URL needs no directory: the scan already skips any `https:` reference. So for the payload path only, hosted files are left undownloaded and the composition's variable defaults are rewritten to their URLs. The preview renderer still downloads them, because it paints real frames and a missing file is a blank card. The explorer posts every value to the preview frame on mount, including untouched ones, so the page's variable list carries the URLs too. Left as local paths they would have overridden the payload's own defaults and asked the frame for a file that was deliberately never published. Result: 25 pages, 25 payloads, zero bytes of image added. Verified: a spike item declaring no assets at all rendered its 24 covers from the CDN, proving the variable-default path; payload generation for a carousel block now writes no item directory and no shared asset; the preview render still produces the album art. mint validate and mint broken-links pass on the new pages. test:scripts is green. * refactor(catalog): split the hosted-asset step out of prepareProjectDir Two functions rather than one: finding the composition and rewriting its variable defaults are separate jobs, and inlining the mode branch pushed prepareProjectDir past the complexity gate it was already sitting on. Behaviour is unchanged. Re-verified both paths after the split: the payload for a carousel block still writes no item directory and no shared asset, and the preview render still produces the album art. * feat(catalog): give the carousels their own shelf 25 image carousels landed in Showcases and were 53% of it, so the scenes that shelf exists for disappeared underneath them. That is the same shape the 24 editor themes made, and it gets the same fix they got. Keyed on the first tag, which is this file's stated grouping rule, rather than on the name. `screen-flow-carousel` leads with `product-demo` and stays on the shelf that says what it is for; a future carousel that is not named `carousel-*` still lands here. Showcases 47 -> 22, Carousels 25, and no existing item changed shelf. * fix(registry): centre the circle-5 carousel path in its composition Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the right edge and most of it fell outside the frame. Only a few cards were ever visible, cropped, with two thirds of the composition empty. The exported path carried absolute coordinates from a layout that was never recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on the composition centre. Handles are relative, so only the anchor points move and the shape is unchanged. carousel-text-circle-5 shares the identical path and had the identical fault. The other three circle variants sit within 12% of centre, which reads as authored placement rather than the same bug, so they are left alone. * fix(catalog): rebuild the circle-5 payloads after recentring the path The Catalog preview plays the payload, not the composition on disk, so recentring the source changed nothing a reader sees. The payload still carried the old vertices and the ring still hung off the right edge of the frame. Verified the consumer this time, not just the producer: both payloads now resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the formatting pass did not reach them. * feat(catalog): promote Carousels to its own section It was a shelf inside Scenes & demos, which is where a scene type belongs by kind but not by weight. At 25 items it is larger than Data & charts (17) and Blocks (13), each of which is already a section holding a single shelf, so the catalog's own precedent puts it one level up. Pulling it out also takes the largest section in the catalog from 120 items to 95, which is the reason the shelf was added in the first place. The two circle-5 pages change because their embedded source block carries the recentred path; nothing else in them moved. --------- Co-authored-by: Jake Moran <jake.moran@heygen.com> Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com> |
||
|
|
0ce0bb13eb |
fix(runtime): preserve fractional final-frame visibility (#3696)
* fix(runtime): preserve fractional final-frame visibility * test(runtime): cover repeated nested fractional tails |
||
|
|
01744f2b0c | fix(media): unify nested start coordinate mapping (#3732) | ||
|
|
00c91d6807 | fix(check): allow missing caption overrides (#3716) | ||
|
|
b94b5bde58 |
fix(core): bound timing compiler opening tag scans (#3719)
* fix(core): bound timing compiler opening tag scans * fix(core): bound ID-targeted duration tag scans |
||
|
|
e78da303f4 |
fix(core): bound inert region scans in timing compiler (#3717)
* fix(core): bound inert region scans in timing compiler * fix(core): use literal search for comment terminators * fix(core): recognize end-bang HTML comment boundaries |
||
|
|
eec04340b3 | fix(core): avoid grade stats regex backtracking (#3706) | ||
|
|
65cf87de38 |
fix(core): scope composition root pattern selectors per instance (#3684)
Port the authored-root selector fix from Thomaswebstich in #2262 to current main. Co-authored-by: Thomaswebstich <thomas.schnerb@gmail.com> |
||
|
|
c8ddbe0a4b | fix(runtime): align nested activation to export frames (#3640) | ||
|
|
723cd0d785 |
fix(studio-server): block dangling symlink upload escapes (#3661)
* fix(studio-server): block dangling symlink upload escapes * fix(studio-server): contain rename reference updates |
||
|
|
bec11b1293 | fix(runtime): wait for a finite dotlottie duration (#3651) | ||
|
|
1a788d62e7 |
fix(core): catch PostCSS parse errors instead of dropping compositions (#3589)
* fix(core): catch PostCSS parse errors instead of dropping compositions
Invalid CSS in a sub-composition style block made postcss.parse throw
inside scopeCssToComposition. The throw propagated to the composition
loader's catch block, which emptied the host — silently dropping the
entire scene. Lint swallowed the same error via catch { continue },
reporting 0 warnings.
Two fixes:
- Runtime: wrap postcss.parse in try/catch and return the original
(unscoped) CSS on failure, so the composition still mounts
- Lint: emit a css_parse_error finding instead of silently continuing
Fixes #3585.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: drop unparseable CSS instead of leaking it unscoped
Return "" on PostCSS parse failure so sub-composition stylesheets
that cannot be scoped are dropped rather than injected unscoped into
the parent document. Updates test fixture to use valid+malformed CSS
that demonstrates the leak risk.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
61ba800a5d |
fix(player/runtime): rebind timelines and bound paused seeks (#3489)
* fix(player): rebind replaced direct timelines * fix(runtime): rebind timelines after runtime data * fix(player): defer initial iframe navigation * fix(player): preserve runtime readiness through load * fix(runtime): publish rebound timeline before apply * fix(player): defer preconnect option reloads * fix(runtime): stop re-seeking paused timelines * fix(player): restrict runtime-src to trusted origins and reset readiness on reload |
||
|
|
859ac622c2 |
fix(player): report and fail closed on runtime delivery errors (#3472)
* feat(player): report runtime data application * fix(player): fail closed on runtime data delivery * fix(player): close runtime delivery and sandbox gaps * fix(player): satisfy runtime contract and CodeQL * refactor(player): simplify runtime tag scanner * style(player): apply repository formatter * fix(player): type runtime tag boundaries * fix(core): mint guest-local runtime-data ids in a separate space from host ids |
||
|
|
3dc1856232 |
fix(core): resolve long nested media in local time (#3535)
* fix(core): resolve long nested media in local time * test(core): cover nested media snapshot sweep |
||
|
|
9eb84c91b6 |
fix(core,producer): stamp render ids on empty-src media and pair the snapshot by them (#3513)
Residual of #3340: runtime-assigned src is skipped by the static parse, so the browser snapshot was still keying clips by author id. Colliding scenes collapsed onto one window. |
||
|
|
e5c7dc75e8 |
fix(core): read CSS animation opacity through color grading's own hide (#3329) (#3507)
When a color-graded element has a CSS entrance animation (e.g. opacity: 0→1), the first drawEntry() copies the animation's initial opacity "0" to sourceOpacityForCanvas, then hideSourceElement() sets opacity:0 !important on the source. On subsequent frames the hiddenByColorGrading guard correctly prevents reading back grading's own hide — but also prevents updating the canvas opacity as the animation progresses, freezing both source and canvas at opacity 0 for the entire render. Fix: when the source is hidden by color grading, temporarily restore the authored inline opacity before reading getComputedStyle, so the CSS animation's current value shows through. The restore–read–rehide is synchronous, so no repaint occurs between the style writes. Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com> |
||
|
|
4f00336c92 | feat(player): add retained runtime data channels (#3471) | ||
|
|
f52ec1c25f |
fix(producer,core): honor relative data-start id-refs in render media scheduling (#3252)
compileTimingAttrs/injectDurations used parseFloat, so data-start="intro" wrote a NaN data-end and extract preferred that over duration; parseNumeric now skips the id-ref (parseVideoElements already resolves it). collectRenderMedia's resolveHostWindow likewise read host data-start with parseFloat, so chained sub-composition slots (data-start="hook") stacked at 0-2s and every scene after the first rendered black. It now resolves host starts through the shared resolveReferencedStart, matching the media parsers. Fixes #3361. |
||
|
|
0c9d234bd8 |
Merge pull request #3481 from heygen-com/fix/web-audio-cross-origin-silence-v2
fix(core): prevent cross-origin Web Audio capture from silencing audio |
||
|
|
f7fc0017a6 | fix(core): evict cached MediaElementSource on src mutation, soften enforcement-point claims | ||
|
|
acc6898255 | fix(core): address review — gate early diagnostic, fix empty crossOrigin, document gaps | ||
|
|
cce17da5a9 |
fix(core): prevent cross-origin Web Audio capture from silencing audio
Classify each <audio> element before Web Audio capture: same-origin, CORS-opted-in, or a non-http(s) scheme stays on the primary createMediaElementSource() path; cross-origin media without a crossorigin opt-in withholds that call (the Web Audio spec makes such a node output silence without throwing) and falls back to fetch + decodeAudioData, preserving the FX graph whenever the server allows CORS. Recheck the route at the transport's irreversible capture boundary, and account for currentSrc, src, and <source> candidates the same way the HTML resource-selection algorithm does. Emit a stable preview diagnostic (`runtime_web_audio_bypass`) at media discovery time, not only from playback scheduling, so `hyperframes check` — which seeks but never plays — can surface it as a `web_audio_bypass` finding. Diagnostics are suppressed during export rendering, where the producer mixes audio offline and already applies the FX chain. The existing non-unit-rate fail-closed rule stays scoped to fx-chain/automation so this fix does not newly mute grouped or above-unity tracks. Takes over #3459 with the data-native-audio escape hatch removed per review feedback: the automatic cross-origin detection already covers the cases that mattered, so the extra per-element opt-in attribute, its route-classifier branch, and its diagnostic path are dropped in favor of a single automatic behavior. Fixes #3458 Original-Author: desenmeng Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com> Co-Authored-By: Miga <noreply@anthropic.com> |
||
|
|
b2fc18b2df |
fix(skills,lint): correct composition-contract claims the code contradicts (#3468)
The runtime absorbed a series of authoring mistakes over time and `runtime/init.ts` says so in its own comments, but the skills kept teaching the old rules. Four of them actively cost an agent a failing run: add `crossorigin` (lint rejects it unconditionally), never build a timeline inside `async` (lint calls that the documented contract), never `gsap.set` later-scene clips (two fixHints instruct exactly that), and 12 copyable media snippets with no `id`, which render silent. Corrected in every place each claim appeared, including `hyperframes-animation`, three workflow scripts, the scaffolded project instructions, the CLI `docs` command, and the public docs site: `data-track-index` is a Studio display lane the render never reads, `class="clip"` is a layout convention rather than a visibility requirement, timed elements may nest, the visibility window is half-open, sub-composition host dimensions are backfilled, and the root-fill rule applies only to the layered-composite path. Behaviour changes, each backed by a render rather than by reading code: - `timeline_registry_missing_init` deleted. The runtime creates the registry before any inline script; a composition without the guard line renders and animates correctly. - `video_nested_in_timed_element` kept, message corrected. A rendered repro shows the nested-with-local-start case really does break, so the rule guards a real defect, but nothing is "FROZEN": the extractor ignores the wrapper's offset while visibility uses it, so the clip shows wrong frames and then vanishes. - `mediaRenderIds` now stamps media whose source is a `<source>` child, closing a duplicate-id gap the old `[src]`-only selector left open. - Stale messages fixed on `subcomposition_root_styled_by_class` and `deprecated_data_layer`. `coreSkillContent.test.ts` pinned the literal sentence that made root `data-start` look required, so it is narrowed to structure plus the regression it genuinely catches. Not covered, and flagged in the PR: the media global-vs-local start heuristic in `runtime/init.ts` is the root cause behind the nested-video defect. Removing it changes the meaning of existing compositions and needs its own deprecation. |
||
|
|
05affaae21 |
feat(studio,core)!: remove solo and the group meter (#3454)
* feat(studio,core)!: remove solo and the group meter * docs(audio): keep removal rationale current * refactor(core): retire studio solo bridge |
||
|
|
16ff1eb145 |
fix(core): align preview transport with grouped audio (#3445)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback * fix(core): align preview transport with grouped audio * test(core): pin audio group gain ceiling * fix(core): preserve solo bridge through stack |
||
|
|
6faf4d6084 |
fix(core): harden audio FX and group identity (#3444)
* fix(core): harden audio FX and group identity * fix(core): address audio group review feedback |
||
|
|
65b2299db2 |
fix(engine): preserve static dedup across caption runs (#3438)
* fix(engine): preserve authored clip boundaries after normalization * perf(engine): bound static verification work across caption runs * fix(core): preserve explicit nonpositive timeline windows |
||
|
|
0e9a4f371d |
feat(audio): open the audio FX, group and mute features to everyone (#3401)
* feat(audio): open the audio FX, group and mute features to everyone The twelve-PR audio stack landed on main with all three of its canaries still at 0%, so the FX rack, the group rows, mute and solo are in the build and reachable by nobody. This removes the gates rather than raising the numbers: a canary that gates nothing is a branch every future reader has to evaluate. Gone: - the `audio-fx-rack`, `audio-track-mute` and `audio-groups` registry entries; - the five studio gates they fed — the Audio FX section in `PropertyPanelFlat`, the mute label, the muted strike-through and the solo button in `TimelineTrackPlainHeader`, and the group-row derivation in `useTimelineTrackDerivations`. Each feature now renders on its own precondition (an audio track, a grouped track) exactly as it did for an enrolled user. The old test pinned `audio-fx-rack` at 0% and asserted it was registered, which is the opposite of what should hold now. Replaced with a pin that no `audio-*` canary exists at all: re-registering one silently re-hides a shipped feature, and nothing else in the tree would say so. Verified it fails when one is added back. The equivalent removal on wa-25-review-fixes (#3363) can no longer land — that branch is 105 commits and 310 files divergent from main now that the stack has squash-merged past it. * docs(audio): retire the last references to the audio canaries Two leftovers the gate removal did not reach. `TimelineTrackPlainHeader.tsx` still said "Gated: the relabel ships behind the canary, unlike the preview fix" above the function that picks Mute vs Hide. Nothing gates it now, so the comment asserted the opposite of the code. `docs/weekly-updates.mdx` is published, and it told readers the audio work is "staged behind a canary at zero percent, so none of it is visible by default" and to "set `HF_CANARY_AUDIO_FX_RACK=on` to use the rack today". That env var maps to no registry entry any more, so following the instruction does nothing at all. The week's record stays — it is a dated entry — but it now says the rollout completed and that the variable is inert. * fix(studio): name the mute action per track, and pin the newly-live audio rows Review findings on the canary removal. All three are in code the 0% gate made unreachable, so this is the first time any of it runs for a user. *blocker* — `visibilityButtonLabel`'s audio branch returned "Muted" / "Mute": the current STATE rather than the action, so nothing told a screen-reader user that activating an already-muted row would unmute it, and it dropped `suffix`, so every audio row shared one accessible name. Music plus VO is the ordinary case, which makes that two identical buttons. Now `Unmute track N` / `Mute track N`, matching the wording `timelineTrackVisibility` already writes into undo history for the same click. `showAsMute` also picks the icon, so this is the control's whole identity, not a tooltip. Tests, for paths that had never executed enabled — a canary at 0% returns `out_of_cohort` before bucketing, and studio additionally excludes `navigator.webdriver`, so no suite could reach them: - `VisibilityButton` — both audio states, two rows staying distinguishable, the visual branch unchanged, and the callback still taking the real track key rather than the display row. Fails on the old label. - `useTimelineTrackDerivations` — an ungrouped project stays in raw ascending order with no groups, and an interleaved group's members become contiguous under an anchor at `memberTracks[0] - 0.5` while the ungrouped track between them keeps its place. Plus label/volume/mute mirroring and the id fallback. Also pins the three retired canary names individually rather than by prefix: `audio-fx-rack` coming back is caught either way, but `fx-rack` escaped a `startsWith("audio-")` check. The family guard stays alongside it. * fix(studio): record the row the mute button announced, not a second derivation Review finding: the header's track number and the undo-history label's are computed from two different orderings, and un-gating `audio-groups` is what makes them diverge. The header's row comes from the group-aware list — `groupTimelineTracks` emits a synthetic anchor row per group and pulls members contiguous. The history's comes from `timelineTrackOrder`, a plain ascending sort of element-bearing keys with no anchors. On the fixture in this PR's own derivation test, grouped order `[-0.5, 0, 2, 1]` against ascending `[0, 1, 2]`: clicking mute on the group's first member said "Mute track 2" and recorded "Mute track 1". Off-cohort this could not happen — the old branch returned raw tracks, so both sides sorted the same way. `onToggleTrackHidden` now carries the display row the clicked control rendered, and `toggleTimelineTrackHidden` prefers it over deriving its own. One number instead of two derivations, which is what `timelineTrackDisplay`'s "one owner of what track number does the user see" already promised. The callback still acts on the real fractional key, so nothing muted the wrong row before or now — only the announced and recorded row was wrong. Also pins the rest of the newly-live surface: the solo button's presence and pressed state, its absence on a visual track, and the strike-through for both a row's own mute and a group mute (with the title that says which). Three existing call-site assertions now check the threaded row too. |
||
|
|
41edbfb2ce |
fix(lint): surface unloadable media variable defaults, stop reading data-var-src ids as paths (#3406)
A data-var-src value the runtime refuses to load is dropped at bind time and the element's authored fallback src renders instead, so the video ships the wrong media and the render still exits 0. lint said nothing, because the scheme allowlist only existed inside the runtime. Move that predicate into @hyperframes/parsers, where both the runtime and the linter can reach it, and error at lint time on any declared default it rejects. The value provably cannot load, so there is no false positive. While reproducing that, lint turned out to report an unrelated missing file: `\bsrc\s*=` also matches the tail of `data-var-src="bg"` (hyphen to `s` is a word boundary), and `[^>]*` is greedy, so the variable id beat the real src earlier in the same tag. Every binding was reported as a missing asset named after the variable, and `<audio data-var-src>` was told the render would be silent. All three copies of that regex now share one helper that requires whitespace before the attribute. |