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.
* 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.
* 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.
* 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.
* fix(build): stop generated files from being read half-written during the build
Two unrelated pull requests kept failing CI for reasons that had nothing to do
with their changes.
Typecheck failed with "TS1002: Unterminated string literal" pointing at a
generated file. The root build compiles several packages at the same time, and
more than one of them regenerates files under a package's src/generated while a
sibling's tsc is already reading them. A plain write empties the file and then
refills it, so for a few milliseconds what is on disk is the first half of the
new file. Whichever build read it in that gap saw a truncated file and stopped.
It never happened locally because locally nothing else is reading.
Every generator now writes the new version under a temporary name and then
renames it over the target, which the filesystem does in one step. A reader
either gets the whole previous version or the whole new one; there is no moment
where it can see half of either. Content that has not changed is not rewritten
at all, so repeat builds no longer touch these files. Four generators were
affected and all four now go through one shared helper, which is where the rule
lives from now on.
The build also rebuilt the core package a second time, in parallel with the
packages that read it. That second rebuild was already redundant, and removing
it takes the writers out of the window entirely. Renaming is what makes the file
safe; dropping the duplicate build makes the build shorter as well.
Separately, a linter performance test failed on four of the last ten failed runs
on main. It timed a scan with a stopwatch and demanded the result come in under
two seconds; one run took 3.7s. That is a statement about how busy the shared
runner was, not about the code, because a stopwatch also counts the time the
machine spent running someone else's job. The test now counts only the processor
time this process actually used, and checks that the cost grows in step with the
input rather than against a fixed number of milliseconds. Measured on a machine
under load, the stopwatch ratio for the same input reached 45x while the
processor-time ratio stayed at 20x.
Both fixes come with a check that fails if the fix is removed.
* fix(build): keep the shared write helper inside the core package
The helper the generators call had been placed in the repo-root scripts folder.
Every container image that builds a package copies the packages folders whole
but cherry-picks root scripts one file at a time, so the image builds failed on
a missing module. The repo already shows both halves of that convention: the one
root script a package build imports has a matching copy line in the image, and
cross-package imports into the core package need none.
The helper now lives with the core package's other build scripts, and the one
generator outside that package reaches it the way the engine package already
reaches core.
* test(engine): keep the probe cache bound test off the filesystem
The eviction test wrote, stat'ed and deleted 129 temp files to exercise an
in-memory LRU rule. Its runtime tracked filesystem contention rather than
the code under test, and on a busy Windows runner the file churn alone
pushed a ~300ms test past the 5s timeout, failing unrelated pull requests.
Cache identity comes from stat, so the test now synthesises stat results
and never touches disk. While here, the test also asserts the LRU touch:
re-probing an entry before the bound is hit must keep it resident and
evict the next-oldest one instead. The previous shape never hit the cache
during the fill, so that branch was untested.
* test(lint): keep the scaling check inside the scanner's linear range
The CPU-ratio version sampled 320k characters, where the output string's
own growth dominates and the whole test cost seconds of CPU; on a shared
runner that tripped the default test timeout, the failure this change
exists to remove. Sample 10k and 80k characters instead, where 8x input
measures ~8x and a quadratic scan still measures 60x or more, and give
the test an explicit timeout so a slow runner reports the ratio.
* test(core): snapshot file identity through a descriptor
The before/after inode and mtime checks read the file through a path stat
and then exercised the writer on the same path, which reads as a
check-then-use race to static analysis. Snapshot through an open
descriptor instead; the assertions are unchanged.
* 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.
* 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.
* 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
* 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>
* 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
* 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>
* 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
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.
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>