* 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
* fix(studio): contain project IDs across client and server routes
* test(studio): use a portable project directory fixture
* fix(studio): reject drive-relative IDs and filter project discovery
* 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>
The static-frame dedup predictor walked tween intervals from
window.__timelines but was blind to onUpdate callbacks — motion driven
from a timeline's onUpdate in a tween-free window was predicted static.
The verifier compounded the gap by seeking with suppressEvents: true,
so the onUpdate never fired and the frozen frame passed verification.
Two changes:
1. Predictor: when a timeline carries vars.onUpdate, mark its full span
as animated so those frames are never predicted static.
2. Verifier: seek with suppressEvents: false so the verification page
behaves identically to the capture page. The verification page is
already isolated (separate Page instance), so out-of-order event
side effects cannot corrupt sequential capture.
Fixes#3793
* test(producer): pin unpinned drawElement stall retry
* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing (#3172)
* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing
A single drawElement frame could kill an entire render. On one comp
(caption-editorial-emphasis) drawElementImage returns normally and the renderer
then stops draining its task queue: the setTimeout(…, 0) that schedules
toDataURL never fires, the capture page.evaluate never settles, and 60s later
the stage watchdog fails the whole render. Deterministic on 152.0.7977.30,
always the same frame. Root-cause detail in PRINFRA-488.
Two gaps, both closed here.
1. Nothing below the 60s stage watchdog bounded a frame. Adds a per-frame
deadline (HF_DE_FRAME_TIMEOUT_MS, default 15000, 0 disables) around the WHOLE
frame operation, not just the drawElementImage call — the stall surfaces at
whichever page round-trip comes next, and was observed at both the capture
and the seek's background-image decode. Bounding one call missed it.
Deliberately NO per-frame screenshot fallback for this error: once the
renderer stops scheduling it is wedged for every subsequent round-trip on
that page. Measured — the screenshot fallback blew the same deadline. The
frame fails fast instead, so the recovery that actually works can run.
2. That recovery was ineligible. shouldRetryViaPinnedFallback only retried a
generic capture failure when the worker count was PINNED by the inversion or
the router; a comp that engaged drawElement on the ordinary single-worker
path had no whole-render fallback at all. A renderer stall is now retryable
on any routing — the failure is a property of drawElement itself, and the
retry re-renders on a fresh page via screenshot. Reported as
de_fallback_reason "de_renderer_stall" so it is distinguishable from
capture_error in telemetry, and counted per-session as deFrameTimeouts.
Verified end to end on the repro: previously exit 1 with "stalled: no frame
progress for 60000ms"; now the deadline fires, the render retries via
screenshot, and RENDER_OK with a valid 1920x1080 / 240-frame / 8.0s MP4.
Tests cover the predicate both ways plus the cross-package error match. Engine
1,481 and producer 587 green (audioPadTrim.integration flakes only under
full-suite parallel load — passes in isolation, and on clean HEAD it is green
too, so it is not from this change).
Refs PRINFRA-488
* fix(engine,producer,cli): count drawElement frame timeouts where they happen
`session.deFrameTimeouts` was incremented inside `captureFrameCore`'s catch,
but the deadline races that function from OUTSIDE it — `withFrameDeadline` is
applied by `captureFrameToBuffer`, and `DeFrameTimeoutError` is constructed
nowhere else. So the branch was unreachable: on a wedged renderer the counter
stayed 0, the engine-side stall log never printed, and
`CapturePerfSummary.deFrameTimeouts` reported 0 on every affected render. The
end-to-end recovery worked throughout — `isDeRendererStallError` matches by
name at the producer — only the observability was dead.
Moved the increment and the log into an `onTimeout` hook that fires from the
deadline itself, deleted the unreachable branch and the now-unused
`isDeFrameTimeoutError`, and threaded the count out to
`render_completed.de_frame_timeouts` so the rate is graphable apart from the
other `capture_error` fallbacks. A stall always costs a whole-render re-run,
which is why it deserves its own series.
Also corrected the `fallbackReason` docstring, which still listed only
`psnr | blank | oom | capture_error` after this branch added
`de_renderer_stall`.
`withFrameDeadline` is exported for a two-case fake-timer test: the deadline
fires and calls `onTimeout` once, and a resolving inner promise passes its
value through with no late timer. The first case fails if the counter moves
back inside the work promise.
(cherry picked from commit ec2b2b5b2a)
* fix(render): retry sequential screenshot stalls
* fix(render): bound parallel worker capture phases
---------
Co-authored-by: Vance Ingalls <vance@heygen.com>
* feat(lint): catch leftover marker heads and guessed marked shafts
Dash-draw on a marked path shows the arrowhead before the shaft exists.
A long marked path that misses every node in both user and screen space
is the same detach as the CTM-paste bug, without the counterfactual.
* feat(check): flag orphan connectors and unbalanced style tags
Catch a visible shaft while fewer than two nodes are on stage (enter-early /
exit-late), and extra </style> that dumps CSS onto the frame.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(check): judge connector_orphan by the shaft's own endpoints
The rule counted anchors stage-wide and skipped whenever two were on. That
tier is every visible text-bearing or opaque element under 15% of the
stage, so a title and a footer alone satisfied it — the check could not
fire on a composition that had any chrome, which is all of them.
Endpoints are now resolved per shaft, against candidates gathered by
layout instead of by visibility, so a node hidden with opacity:0 still
owns the endpoint that meets it and its visibility is what the rule tests.
An endpoint that meets no node is left to connector_detached.
* revert(lint): drop the marker rules this repo would only duplicate
marker_orient_typo and marker_dash_draw_on already run in Zephyr's Python
connector lint, down to the same regexes and the same message and fixHint
strings. Zephyr concatenates its local findings onto this bridge's result
with no dedupe, so shipping them here reports each one twice and counts it
twice, and every later edit has to land in two places.
The browser-check work stays: a marked shaft that meets no node, and an
orphan shaft, are both runtime facts this repo can see and a source-reading
rule cannot.
* chore(check): drop the comments added with the orphan rewrite
* fix(check): score orphan and detach on overflow-opted connector layers
Allow-overflow is a clip opt-out, not a lifecycle skip. Orphan now uses
the same connector names as detach so flow and arrow shafts are judged.
* fix(check): see axis-aligned shafts, and script tags closed with a space
connector_orphan gated on isVisibleElement, which rejects anything whose
bounding box has no height or width. A straight horizontal or vertical
connector is exactly that, and it is the common shape, so the rule was
blind to most of what it exists to catch: across 47 production
compositions it produced nothing. It now tests paint -- display,
visibility and the opacity chain -- and leaves size to the 80px chord
floor it already had. The same 47 compositions now yield three findings,
each naming the endpoint that is still dark.
unbalanced_style_tags stripped scripts with a regex that only matched
`</script>`. `</script >` is valid, so the script survived the strip and
a `"</style>"` string literal inside it counted toward the tag balance,
reporting an error on a composition whose tags are paired. Both the strip
and the closer count now tolerate whitespace before the `>`, which is also
what CodeQL flagged on this branch.
* fix(lint): count style tags in one pass instead of stripping scripts
CodeQL reads the `source.replace(/<script.../)` as an incomplete HTML
sanitizer. It was never one — the rule only needs to not count `<style`
tokens that live inside a script string. A single alternation scan, where
the script branch consumes the whole block, gets the same counts without
a replace.
* test(check): cover the two orphan guards that no fixture was crossing
Neutering `shaftIsPainted`'s opacity check, or the endpoint proximity
threshold, left the suite green. Both are false-positive guards: a
connector staged hidden before its reveal, and a hidden element that
happens to be the nearest thing to an endpoint without belonging to it.
The hidden-shaft case now runs over all four ways a shaft can be
invisible, so the display and visibility clauses are crossed too.
* test: pin the two boundaries raised in review
A closer inside an html comment is already dropped upstream by
stripHtmlComments, so the rule never sees it. The test fails if that
stripping regresses; adding a comment branch to the rule's own scan
would have reintroduced the polynomial-redos pattern that stripper
exists to avoid.
A node hidden with display:none has no geometry, so nothing can place it
near an endpoint and connector_orphan stays silent. connector_detached
does not pick it up either, since it allows a half-attached shaft by
design. That case is uncovered, and the fixture says so.
* fix(check): type the hidden-shaft fixture table as a style record
* fix(check): key connector_orphan by geometry and let a live node win the endpoint
Two defects found in review.
connector_orphan was persistence-tiered but absent from the geometry key,
so several id-less shafts orphaning at one sample each collapsed into a
single finding that then read as held rather than transient. On the 47
corpus compositions this recovers 9 findings across 5 compositions where
3 across 2 were reported; one composition fades its nodes and its
connectors on a shared stagger, so four shafts each outlive a different
node and only one of the four survived the collapse.
The endpoint scan took the nearest candidate and asked whether that one
was hidden, without asking whether a visible box was also in range. A
staged halo sitting on the node it belongs to produced a finding naming
the halo. A visible candidate within threshold now settles the endpoint.
Fixtures added for both, and for the two guards a mutation sweep found
unpinned: the connector-shape test and the dash-offset skip.
* test(check): kill every surviving mutant in the connector_orphan path
Seven guards still passed the suite when deleted: the four candidate
filters, the connector layer's own visibility gate, the defs/marker skip
and the chord floor. Each now has a fixture where that guard alone
decides the outcome.
The earlier sweep that missed them was mutating the wrong copy of a
shared line — connectorAnchorRects and connectorEndpointCandidates carry
the same filter text, and a first-match replace edited the detached one.
Mutations are scoped by enclosing function now.
* test(check): order the halo before the node it hides under
The halo fixture proved nothing. Both boxes contain the endpoint, so both
score gap 0, and the strict tie-break keeps whichever comes first in
document order — which was the visible node. The old code picked the same
winner and stayed silent too.
Putting the halo first makes the tie-break hand it the slot, so only the
visible-candidate check keeps the endpoint attached.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
connectedCallback defers init to a setTimeout(0) so the parser can append
the element's children first. With the bundle loaded from <head>, as
`hyperframes present` emits it, headless Chromium fires that timer while
the parser is still inside <hyperframes-slideshow>: init finds no player
or a half-streamed island, returns, and the deck never binds.
While document.readyState is "loading", init now retries once at
DOMContentLoaded. After parsing there is nothing to wait for, so a missing
or malformed island still fails quietly as before. The subtree check is
split out as locateSlideshowParts so the classification is testable
without the element lifecycle.