Commit Graph
4667 Commits
Author SHA1 Message Date
Miguel Ángel fe0a4b69f5 chore(media-use): merge main into the theSVG logos branch 2026-09-23 13:02:42 -04:00
Miguel Ángel 0618fc0dc3 fix(parsers): render bare fragment sub-compositions (#4335) 2026-09-23 12:49:04 -04:00
Val 30f548f62b fix: a stalled remote asset download no longer hangs a Cloud Run render (#4343)
* fix(gcp-cloud-run): run the Cloud Run image on Bun 1.4.2 so remote downloads cannot hang

* fix(engine): make the download deadline abort the body pipeline
2026-09-23 16:38:58 +00:00
Miguel Ángel b1cf88aeba ci: generate catalog artifacts through a standing publish PR (#4337)
* ci: generate catalog artifacts through a standing publish PR

* fix(catalog): reconcile obsolete publication snapshots

* refactor(catalog): report failures after staging cleanup

* refactor(catalog): separate generation and publication decisions

* fix(catalog): unpublish deleted items with their sources

* docs(catalog): keep scaffolding guidance concise
2026-09-23 12:09:46 -04:00
Miguel Ángel df97c599ee perf(studio): stop the manual-edit loop from running while the preview is paused (#4333)
* perf(studio): stop the manual-edit reapply loop from running while paused

* fix(studio): judge nested timelines by their parent, not the player alone

* fix(studio): bound the paused-ancestor walk and ignore unknown parents

* style(studio): format the manual-edit tests
2026-09-23 11:10:11 -04:00
Miguel Ángel aabfba5445 chore: release v0.8.65 (#4336) v0.8.65 2026-09-23 11:03:11 -04:00
Miguel Ángel 2c70e65784 fix(studio): timeline clips keep their pictures while you scroll (#4305)
* fix(studio): timeline clips keep their pictures while you scroll

With row virtualization on, the timeline dropped every clip's picture for the length of a scroll
gesture and put it back 100ms after the gesture stopped, so a zoomed-in timeline showed grey boxes
the whole time a person was moving through it. Clip content now stays mounted while scrolling;
pictures already loaded come straight from the thumbnail cache.

* perf(studio): clips that scroll into view get their pictures when the scroll stops

Keeping every clip's content mounted through a scroll made each jump of the 50,000-clip viewport
gate mount and lay out a screenful of new thumbnails, a 90 to 130 ms long task per run where main
has none. A clip's content now mounts only while the timeline is at rest and then stays through
later scrolls, so a picture already on screen never blanks and a scroll never mounts new ones.
2026-09-23 10:39:22 -04:00
Miguel Ángel 8cb7d06db6 perf(studio): re-render only the edited scene's thumbnails after an edit (#4315)
* perf(studio): refresh only the edited scene's thumbnails and let the preview load first

One scene edit regenerated every sidebar and timeline thumbnail, and those renders starved the new preview's requests on the Studio server. The thumbnail cache now keys on what a composition renders from, the server names the compositions a write affects, only those thumbnails refresh, and composition thumbnails wait while a shadow preview reload is in flight.

* test(studio): pin scene-scoped thumbnail reuse and the preview-reload hold

Memoizes the scoped thumbnail signature on the project signature so requests keep reusing the adapter's cached one, keeps the fresh-signature check that catches a write the watcher has not reported yet, and adds tests for sibling-scene reuse, root-head invalidation, the affected-composition set, per-path revisions and the scheduler hold.

* refactor(studio): share the file-change payload guard

* fix(studio): match thumbnail revisions for scenes mounted with a ./ path

* perf(studio): keep signature cache keys constant-size

* style(studio): format thumbnail-scope changes

* docs(studio): trim thumbnail input comments
2026-09-23 10:29:01 -04:00
Miguel Ángel 8055961740 feat(registry): a Lottie character walk-in, and looping Lottie that keeps playing (#4316)
* fix(core): looping Lottie animations keep cycling instead of freezing after one pass

* feat(registry): add a Lottie character walk-in and document the character route

* chore(registry): index lottie-character-walk in the catalog and search vectors

* chore(docs): list lottie-character-walk in the catalog gallery

* docs(skills): say that lottie-web loops when loop is left out

* fix(core): a finished Lottie holds its last frame instead of disappearing

* fix(core): hold a finished Lottie on its exact last frame

* chore(skills): refresh the manifest hash for the Lottie skill edits

* fix(core): wrap looping Lottie by frame so a loop never holds at its seam

* fix(core): a looping Lottie starts each cycle on frame 0 despite float error
2026-09-23 09:20:48 -04:00
Miguel Ángel 8f7e902f5c fix(player): drop the empty transition label from the Loading assets card (#4327)
The Loading assets card reuses the shader-transition panel, and resetting it
hid the transition-frame row but not the transition row, so a bare
"transition" label stayed under the progress bar. Each progress row now shows
only while it has a value, which also covers a shader load that reports no
count yet.
2026-09-23 09:20:36 -04:00
Miguel Ángel e02120fd0e perf(studio): find timeline transition seams once per track, not once per row (#4324)
Every timeline render re-sorted every clip in the project once per visible row to find transition
seams, then kept the row's own. On the 50,000-clip viewport fixture that sort was the largest CPU
cost in a scroll. Seams are now derived once per change of the clip set, grouped by track, and each
row looks up its own. Pairing within a track also fixes a missing transition badge when a clip on
another track starts between the transition's two clips.
2026-09-23 09:20:19 -04:00
Miguel Angel Simon Sierra cb8bfa4951 test(media-use): size payloads that fail the old scans fast instead of hanging 2026-09-23 07:44:51 -04:00
Miguel Angel Simon Sierra 502d8d6cd9 fix(media-use): make the sanitizer's CSS scans linear on hostile input
The url() check used /url\(\s*(['"]?)([^)]*)\1\s*\)/ and the function
check /([-\w]*)\(/: on attacker padding these backtracked cubically and
quadratically (an unclosed url( plus 4,000 spaces took 20s; an 80,000
character word took 7.7s), and freeze runs the sanitizer synchronously on
bodies up to 256MB. Both are now single-pass indexOf scans (a million
characters in under 15ms), with the same verdict on ordinary input. A url()
value holding "(" is rejected, so a nested url( can't hide inside one.
A size test holds both payloads under 2s.
2026-09-23 07:38:38 -04:00
Vance Ingalls ed75203cb6 chore: release v0.8.64 (#4326) v0.8.64 2026-09-23 04:03:02 -07:00
Miguel Angel Simon Sierra a10ce57559 style(media-use): format the parity test corpus 2026-09-23 06:49:29 -04:00
Vance IngallsandClaude Opus 5 6e5f460a0c fix(studio): split the empty-read reason and stop blaming the composition (#4325)
Two residuals from the cutover-dashboard work, both found in the data the
first pass shipped (0.8.61/0.8.62).

`absent_or_empty` was one label over two different failures: the route
answers `content: ""` both from the `optional=1` shim (nothing resolved at
that path) and for a real 0-byte file. The route now marks the shim with
`missing: true`, and the read path with `missing: false` so the field's
PRESENCE is what tells the client this server draws the distinction — a new
server's empty file would otherwise look exactly like either case from an
old one. Client splits them into `absent` and `empty_file`; a server without
the field still reports `absent_or_empty`, so the existing series stays
honest instead of silently folding into one of the new ones.

Why it is worth splitting: on 0.8.62, 75 tabs hit this class and not one of
them ever landed an SDK edit afterwards — it is terminal for the tab, while
the tab keeps playing back and navigating, so nothing surfaces to the user.
Every measured case with a loaded tree carries `path_in_tree: true`: a file
the tree lists and this read cannot get. Which of the two causes that is
decides the fix, and until now the event could not say.

`res.json()` at the end of the read was unguarded, so a 200 carrying HTML —
an SPA fallback or a proxy answering in the route's place — rejected out of
the function entirely and the effect's outer catch filed it as
`stage: "open"`, which asserts the COMPOSITION failed to parse. Observed in
production on 0.8.62: `Unexpected token '<', "<!-- /*!"... is not valid
JSON`. That is the third label to make the same wrong claim, after the
fetch-rejection split in #4240 and #4269. Now `reason: "invalid_json"` with
the response's content-type.

`path_in_tree` now rides all three empty-read reasons, not just the combined
one, so the split keeps the signal that justified it.

Tests: 4 new (absent / empty_file / combined-label fallback / non-JSON 200)
plus a server test pinning `missing` on both answers. 1172 tests green
across studio hooks + studio-server routes; oxlint, oxfmt, tsc clean.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 03:44:04 -07:00
Miguel Angel Simon Sierra b16f675918 refactor(media-use): share one capped body reader for freeze and manifest
fetchJson copied freezeUrl's declared-length check and streaming size cap
line for line. Both now call readCappedBody in media-fetch.mjs, which both
already import; it has its own tests, since freeze's cap had none. The
freeze cap errors now read "freeze failed for <url>: ..." (no caller or
test matches the old wording). Also fixes freeze.mjs's comment, which
still called the sanitizer a lexical pass.
2026-09-23 06:36:58 -04:00
Miguel Angel Simon Sierra 79e6680a0d fix(media-use): close CSS-escape and image-set bypasses in sanitizeSvg
The url()/@import checks scanned raw text for the literal "url(" and
"@import". A browser decodes CSS escapes first, so fill="\75 rl(...)",
url\28...\29 and @\69mport all loaded an external resource, and
image-set("https://...") loads one with no url() at all. Reproduced in
headless Chrome: 8 of 8 payloads fired a request through the old code.

CSS can only load a resource through a function or @import, so CSS text
now passes only if it has no @import, every function is on a compute-only
allowlist (color, transform, filter and math functions), and every url()
is a same-document fragment, all checked on escape-decoded, lowercased
text. Same fix reaches media-use through the regenerated bundle.

Checked against all 13,384 files of the pinned theSVG corpus: 13,383
sanitize byte-identically; one drops an unreferenced id="XRP-(XRP)".
The parity oracle now fails on any surviving attacker host, so it no
longer shares the sanitizer's parsing blind spot.
2026-09-23 06:36:18 -04:00
Vance IngallsandClaude Sonnet 5 b32b9a7dab feat(runtime): data-vfx-chain — a seek-deterministic per-pixel effect element (#4306)
* feat(core): data-vfx-chain defs and chain parsing (mirrors data-fx-chain)

Mirrors audioFx.ts's shape for the per-pixel VFX chain: a versioned
JSON chain attribute (data-vfx-chain), a def registry with typed
params (number/enum/bool/ref), and parse/serialize/normalize helpers.
HF_VFX holds one def, fractal-noise, with a placeholder frag; the
kernel and its consumers (packages/core/src/runtime/vfx.ts) land in
Task 1.2/1.3 of the vfx-chain-runtime plan, so vfx.ts is added to
.fallowrc.jsonc's entry list for now.

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

* feat(runtime): paint data-vfx-chain canvases on every seek

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

* feat(vfx): fractal-noise kernel (Basic/fBm approximation) with CPU reference

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

* feat(vfx): self capture through layoutsubtree with the page-composite readiness protocol

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

* feat(vfx): wave-warp kernel (Sine)

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

* feat(vfx): displacement-map kernel (self-referential map)

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

* test(vfx): chain order is nesting order; preceding CSS effects are captured

...and following ones apply outside. The plan's one-line message for Task 2.4
is 105 characters; commitlint caps the header at 100, so the tail moved here.

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

* test(vfx): seek determinism and CPU-reference PSNR for fractal-noise

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

* fix(vfx): size the output canvas from the host's layout box, not its transformed AABB

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

* feat(telemetry): vfx node count, capture class and types on render_complete

Threads vfxNodeCount, vfxCapture and vfxTypes from a static scan of the
compiled composition's data-vfx-chain hosts (mirrors the existing
compositionElementCount/colorGradingCount scan) through
RenderPerfSummary.drawElement into the render_complete event, next to
de_capture_mode. Exports chainCapture/parseVfxChain/HfVfxCapture from
@hyperframes/core's index for the producer to consume. The PostHog
dashboard tile is not created here — left for the coordinator.

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

* docs(vfx): data-vfx-chain guide — element shape, capture table, Studio flag, known constraints

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

* fix(vfx): register chain hosts that arrive with a sub-composition mount

`initVfx(document.body, fps)` ran once, synchronously, three lines after
`void loadExternalCompositions(...)` — so every `data-vfx-chain` host that
enters the DOM only when a `data-composition-src` mount resolves was never
registered. An unregistered host paints nothing and reports nothing: no
`[HyperFrames] composition script error`, the `.hf-vfx-out` canvas keeps its
300x150 default, and `paintVfx` never even arms the page-composite flag.
Measured on a real multi-file export served raw: `#main-l5-text`'s output
canvas stayed 300x150 and `window.__timelines` never gained the sub-comp.

Re-scan in the loader's `.finally`, before `maybePublishRenderReady()`, so
registration precedes readiness. `initVfx` now releases the outgoing
registry's programs, capture textures and ping-pong targets first — the
output canvas hands back the same WebGL2 context on a re-scan, so the
previous pass's GL objects would otherwise leak.

Two browser cases in `vfxDeterminism.test.ts`, both against the real runtime
bundle: a host whose `.hf-vfx-in` IS the mount (already worked — the mount
replaces the wrapper's children, not the wrapper, so the registered source
node survives), and a host that arrives INSIDE a mounted sub-composition
(failed with `armed: false` and no error; passes now).

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

* fix(vfx): runtime tsconfig typecheck passes

Under noUncheckedIndexedAccess, resolveCaptureSource's null return
widened VfxEntry.src past its VfxCaptureSource | undefined type; the
function is internal-only, so its return type moves to
VfxCaptureSource | undefined instead. Indexing PingPong.framebuffers
by a non-literal index produced | undefined against
WebGLFramebuffer | null, coalesced to null. No behavior change.

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

* fix(vfx): structural DOM predicates instead of realm-bound instanceof

vfx.ts checked `instanceof HTMLCanvasElement`/`HTMLElement` directly, which
returns false for inner-realm nodes when the Studio previews across an
iframe realm. domRealm.ts had no canvas predicate yet, so this adds
isCanvasElement mirroring the existing isImageElement pattern, then swaps
all three vfx.ts checks to the structural predicates the lint guard requires.

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

* fix(vfx): paint self-capture hosts under hyperframes snapshot, not only the engine path

`paintVfx(t, { engineMode: true })` armed the page-composite protocol and
returned, betting that every capturing chain runs under `frameCapture.ts`.
`hyperframes snapshot` (and check/compare/validate/layout, and Studio's
thumbnail capture) seek through the same `renderSeek`, never read
`__hf_page_composite_pending` and never call `__hf_page_composite_resolve` —
so a `self` chain painted nothing there, in silence, because its content
lives in a `layoutsubtree` canvas the browser never paints directly.

Engine mode now arms the preview-side capture as well and paints on whichever
completes first. A seek token stops a superseded async capture from repainting
a newer frame, and a registry-identity check stops it painting an entry
`initVfx` already released. `resolveVfxCapture` stays non-idempotent on
purpose: the engine resolves only after its own before-capture hooks have
mutated the DOM, so its capture is still the authoritative one.

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

* fix(vfx): an exporter-emitted .hf-vfx-out gets the box the spec fixes for it

findOrCreateOut adopted a canvas the exporter had already emitted without
applying the layout cssText, so it kept the default position:static;
display:inline and wrapped to the line AFTER the layer-wide .hf-vfx-src
canvas — painting a second, misplaced copy of the layer one full layer
height below the host. Measured on retro-wave #main-l6-text: .hf-vfx-src
rect top 315.1 h 93.0, .hf-vfx-out rect top 409.2, both style=null,
position static (ae-mcp findings Task 3.5b). Hosts with no emitted
out-canvas were never affected, which is why only this one host moved.

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

* refactor(telemetry): vfx_node_count counts hosts, not chain nodes — rename to vfx_host_count

The field increments once per `data-vfx-chain` attribute occurrence, never
per node inside a chain's JSON, so the name promised something the scan does
not measure. Renames the internal `vfxNodeCount` and the wire property
`vfx_node_count` to `vfxHostCount`/`vfx_host_count` across the orchestrator
scan, the perf summary, the CLI render path and the telemetry event. No
dashboard consumes the field yet, so the wire rename costs nothing now and
would cost a migration later.

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

* fix(vfx): join the preview capture to the seek-completion barrier, not race it

`paintVfx` fired `capturePreviewThenPaint` and forgot it, so nothing the CLI
awaits waited for a `self` chain's paint. Every snapshot-family command
(`snapshot`, `check`, `compare`, `validate`, `layout`) seeks through
`seekCompositionTimeline`, which drains `window.__hfWaitForSeekCompletion`
and then allows one settle race before screenshotting — and only the
`"hf-seek"` event's `waitUntil()` could put anything in that barrier. The
screenshot therefore landed on however many hosts happened to have finished,
which varied with host count and machine speed.

Extracts the "track, untrack on settle, retain the first rejection" block
`dispatch()` built inline into an exported `registerSeekCompletion`, and has
`paintVfx` register the preview capture through it. Also awaits the per-host
paints with `Promise.all` instead of sequentially: N hosts cost about two
animation frames now, not 2N.

Measured on the pre-fix bundle with two `self` hosts, both output canvases
read `[0,0,0,0]` at the moment the barrier returned; they painted only
because the settle race that follows happened to be long enough. Both tests
added here fail on that bundle.

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

* fix(vfx): report WebGL context loss loudly instead of silently no-op painting

One WebGL2 context per `data-vfx-chain` host, against a browser cap that is
commonly 16 and shared with `colorGrading.ts`'s per-element contexts. A lost
context makes every later call on it do nothing, per spec — so the chain kept
"painting" a blank canvas with no error, which is exactly what the module's
loud-failure contract exists to prevent.

Adds the `webglcontextlost` listener pair's first half, following
`colorGrading.ts`'s precedent: `preventDefault()`, mark the entry, report
once through `reportVfxError`. The entry is then skipped by `paintVfx`,
`capturePreviewThenPaint` and `resolveVfxCapture` — silently, since the one
loud report already happened and repeating it per frame is spam.

Restore is left unhandled on purpose, with the reasoning in the doc comment:
`initVfx`'s existing re-scan already rebuilds every GL object, and a second
narrower rebuild path for `webglcontextrestored` would duplicate it. The
shared-context redesign the review floated is likewise out of scope here.

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

* perf(vfx): cache uniform locations at pass-build time instead of querying every paint

`setPassUniforms` asked the driver for `u_size`, `u_t`, `u_fps` and every
param's location on every paint of every pass, and `bindPass` did the same
for `u_src` on every bind. `buildPasses` already constructs each `VfxPass`
once at init, right after the program links, and a linked program's
locations never change — so they are resolved there now and read from
`pass.locations` in the hot path.

Same values, same `null`-means-optimised-out semantics (`gl.uniform*(null,
…)` is a defined no-op, which is what the per-frame query produced too), so
the existing vfx.test.ts and the browser determinism suite cover it
unchanged — both still green, the latter against a rebuilt runtime bundle.

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

* fix(vfx): bound the paint wait per host so a stalled compositor can't hang the seek barrier

`paintVfx` registers `capturePreviewThenPaint` into the shared seek-completion
barrier (48aaa2bbb), and that promise settled only via `awaitCanvasPaint` — a
race between the canvas `paint` event and a double-rAF chain. Under BeginFrame
control (Linux headless-shell, `drawelement` capture) neither can fire before
the barrier is drained: `frameCapture.ts` awaits the barrier in
`prepareFrameForCapture` (~2692), while the frame's
`HeadlessExperimental.beginFrame` is issued later, in the per-frame capture
stage (~3797). Fire-and-forget that was harmless; registered it can stall every
`__hfWaitForSeekCompletion` caller, the engine's render path included.

rAF-under-BeginFrame: CONFIRMED gated, from this repo's own engine notes —
`frameCapture.ts` ~2445 ("waitForFunction uses rAF polling internally, which
won't fire in beginFrame mode") and ~4755 ("headless only fires rAF when a
frame is produced, and nothing produces one until a screenshot asks"). Not
reproduced locally: every environment here is macOS with a free-running
compositor (`beginFrameTimeTicks === 0`). `setTimeout` is not compositor-gated
there — `drawElementService.ts` ~341 describes its 250 ms paint fallback as
burning on every BeginFrame frame, which requires the timer to fire — so a
timer ceiling is a sound escape.

`awaitCanvasPaint` now resolves `"painted" | "timeout"` with a 2000 ms ceiling
(the repo's own `BEGINFRAME_PROBE_TIMEOUT_MS`; ~20x a real two-frame paint,
far short of the ~180 s CDP `protocolTimeout` a hang would burn). The race is
per host, so one stalled compositor no longer delays hosts that would have
painted; a timed-out host is skipped (no capture, no guessed pixels) and
reported loudly. The report is not suppressed in engine mode: `frameCapture.ts`
~2708 skips `__hf_page_composite_resolve` in `drawelement`/`beginframe` modes,
so there is no authoritative second attempt to defer to there.

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

* chore(ci): re-pin test-classification.mjs hash after registering vfxDeterminism.test.ts

An earlier commit added vfxDeterminism.test.ts to INTEGRATION_TEST_FILES;
check-test-reachability.mjs pins a sha256 of that file to force a conscious
review whenever it changes. Reviewed: runner[1]'s glob already covers the
file structurally, the classification move (unit -> integration) is correct
for a Chrome-launching test, and the pinned hash is now current.

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

* chore(core): regenerate position-edits-render-inline.ts for Linux esbuild output

check:position-edits-render (bun run build:position-edits-render && git diff
--exit-code) failed on both Linux CI runners (Test (runtime), Tests on
windows-latest: studio-core) but passed on macOS: esbuild's minifier named
two identically-scoped WeakMap/function bindings differently on Linux than
on the mac that last committed this artifact, and the divergence appeared
only on this branch (main's own current tip passes CI cleanly), so it is
tied to a change on this branch rather than a random flake, even though it
is not attributable to any specific line of that change.

Confirmed stable across two independent Linux CI runs (byte-identical diff
both times) and reconstructed directly from that diff rather than guessed:
the committed content, byte length (3817), and 3/3 existing artifact tests
all match. A future contributor rebuilding on macOS will see this same
diff again in the other direction until the build script's cross-platform
determinism is fixed at the esbuild-invocation level, which is out of this
PR's scope.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 03:14:08 -07:00
miga-heygenandMiguel Angel Simon Sierra 99221c50a5 chore: release v0.8.63 (#4312)
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
v0.8.63
2026-09-23 05:49:08 -04:00
Miguel Angel Simon Sierra c952f05e06 refactor(media-use): make the parity test's marker check table-driven
Fallow gives .mjs scripts no coverage credit, so its CRAP gate needs each
function at cyclomatic 4 or less. The extracted survivingUnsafeMarkers
still scored 9 (CRAP 90). The four regex markers are now a table, and the
data:image-on-<a> and non-local url() checks are small named helpers.
Same checks, same order, same failure strings.
2026-09-23 05:37:12 -04:00
Miguel Ángel 06340f656e fix(studio): clip pictures at full strength and a seam where touching clips meet (#4309)
* fix(studio): composition and text clips show their frame at full strength

CompositionThumbnail drew its frames at 0.7 opacity with a lighten blend, the only timeline
picture dimmed that way; on a light theme the frame nearly vanished into the clip. Opacity and
blend are now theme tokens, untinted by default like video and image clips.

* fix(studio): touching clips show a hairline seam where they meet

Two clips that meet end to start used to read as one long bar. Each exact join on a row now
gets a 1px line in the row colour, drawn over the clips. Clip positions, widths and keyframe
diamonds do not move, so the timeline still tells the truth about time.

* fix(studio): hide clip join seams while a clip is being moved

A moved clip is drawn by the drag overlay, not at its element position, so its join line stayed
behind at the old boundary until the drop. Rows draw no join lines during a move or multi-clip
drag; they come back on drop. The join math now reuses the lane helpers for sort order and the
touching tolerance.

* refactor(studio): drop a multi-drag check the move-drag check already covers

A multi-clip drag preview only exists once a move drag has started, so the join lines only need
the move-drag check.
2026-09-23 05:19:39 -04:00
Miguel Angel Simon Sierra a3a2d53385 refactor(media-use): reduce complexity flagged by fallow audit
isSafeAttribute (10 cyclomatic) and the parity test's aggregate-marker
check (11 cyclomatic, 26 cognitive, CRAP 132) both exceeded fallow's
per-function thresholds after the rebase pulled in a stricter baseline.
Extracted isXmlnsAttribute/isKnownAttribute as named predicates and
survivingUnsafeMarkers as a named per-output helper; no behavior change,
same guard order, same failure strings. Regenerated svg-sanitize.mjs.
2026-09-23 03:18:34 -04:00
Miguel Angel Simon Sierra f8ebd385c9 fix(format): ignore generated svg-sanitize.mjs bundle in oxfmt
Same generated esbuild bundle that needed an oxlint exclusion in the
previous commit also fails  (minified, no formatting to
check against), cascading Preflight into Player perf, regression,
Windows render verification, and preview-regression on CI. Same
established pattern as the existing registry/blocks/**/*.iife.js and
skills/**/*.min.js entries in .prettierignore.
2026-09-23 03:05:02 -04:00
Miguel Angel Simon Sierra f74e90991c fix(lint): ignore generated svg-sanitize.mjs bundle in oxlint
Minified esbuild output (packages/cli/src/media-use/lib/svg-sanitize.mjs,
generated by scripts/generate-media-use-svg-sanitize.mjs) trips oxlint's
correctness rules 504 times and fails `bun run lint`. Same shape as the
existing registry/blocks/**/*.iife.js exclusions: a generated, minified,
non-hand-edited bundle, not hand-written source.
2026-09-23 02:44:32 -04:00
Miguel Angel Simon Sierra b2ef6192ed fix(media-use): replace regex SVG sanitizer with a DOM allowlist
Two rounds of regex/tokenizer patches to sanitizeSvg found new bypasses
in the same shape each time (unquoted href, style= url(), aliased
xlink prefix; then presentation-attribute url(), unquoted '=' handling,
SMIL animate/set href rewrite, self-closing-slash tag corruption,
data:image/ navigation via <a>). Denylisting attribute names against a
hand-rolled tokenizer was the wrong approach regardless of how careful
the regex.

Rewrote sanitizeSvg (packages/core/src/figma/sanitizeSvg.ts) as a real
DOM walk (linkedom, already a dependency of core and cli) with an
allowlist: known-safe elements and attributes only, everything else
dropped by construction rather than pattern-matched as dangerous.
href/xlink:href (any namespace prefix) kept only for a same-document
fragment, or data:image/ off non-<a> elements. Any attribute value or
<style> text containing url() kept only when every url() argument is a
fragment; @import/expression(/javascript: dropped from style content.
Serializing from a real DOM means output is always well-formed, so the
self-closing-slash corruption class of bug is gone by construction.

media-use's own copy (packages/cli/src/media-use/lib/svg-sanitize.mjs)
can't import linkedom directly: its CI test lane runs bare `node --test`
with no install step, so even an external dependency fails to resolve,
not just a workspace import. It's now a generated file instead of a
hand-copy: scripts/generate-media-use-svg-sanitize.mjs bundles the core
source (esbuild, minified, zero remaining imports) and
scripts/check-media-use-svg-sanitize-generated.test.mjs fails the build
on drift. Verified the generated file runs with plain node in an empty
directory with no node_modules on the path.

Every payload from both independent-review reports is now a
table-driven test in sanitizeSvg.test.ts (46 cases) and the same corpus
in check-svg-sanitize-parity.test.mjs (39 cases) asserts core and the
generated media-use copy agree and each independently rejects every
non-local url(), javascript:, data: href on <a>, and on* handler.
Mutation-proved against the real pre-fix blob, not a synthetic case.

Sampled 56 real theSVG icons across all 7 collections to check the
allowlist doesn't over-strip legitimate content: found and fixed three
missing attributes (fx/fy, enable-background, a blanket data-*
allowance) and one real behavioral bug - <style> elements were being
dropped outright, silently unstyling any Illustrator-exported logo that
colors paths via CSS classes. Fixed by keeping <style> on the allowlist
and content-checking its text the same way as an inline style=
attribute, with a regression test using that exact shape.

Full suite after the fix: sanitizeSvg.test.ts 46/46, parity+drift tests
39/39, media-use suite 298/298, tsc/oxlint/oxfmt clean.
2026-09-23 02:09:49 -04:00
Miguel Angel Simon Sierra a12a50fc45 fix(media-use): close three sanitizer bypasses, add fetch timeout
Independent review of the maintainer pass found the SVG sanitizer
(packages/core/src/figma/sanitizeSvg.ts, reused by media-use's own
port) was itself bypassable: an unquoted href/xlink:href survived,
inline style="..." attributes were never checked, and a href bound
through a renamed XML namespace prefix (xmlns:x=".../1999/xlink",
x:href=...) slipped past the literal "xlink:href" match.

Rewrote sanitizeSvg as a tag/attribute tokenizer instead of a set of
literal-spelling regexes: parse each tag's attributes in any quoting
form, resolve the local name after stripping any namespace prefix,
then drop by local name (any on* handler, any *href whose value is
not a #fragment or data:image/ embed, any style attribute containing
url(, @import, expression( or javascript:). Ported the fix into
media-use's svg-sanitize.mjs identically. Legitimate url(#id)
references on non-style attributes (clip-path, fill) are untouched.

Also adds a 10s AbortSignal.timeout to freezeUrl's fetch, matching
every other fetchMedia call in this package - it had a size cap but
no deadline.

New cases for all three bypasses in both sanitizeSvg.test.ts and the
parity corpus, each reproducibly failing against the prior head.
2026-09-23 02:09:48 -04:00
Miguel Angel Simon Sierra d570e96f5d fix(media-use): drop the sanitizer's workspace import, add parity test
freeze.mjs importing @hyperframes/core/sanitize-svg broke the
test-skills CI lane: packages/cli/src/media-use tests run with node
builtins only, zero install, by design (media-use is vendored
standalone into other projects, e.g. packages/desktop). Port the
sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the
now-unused core export.

Parity between the port and packages/core/src/figma/sanitizeSvg.ts is
enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs,
run in the test:scripts lane, which does have an install) over a
hostile-SVG corpus, so the two cannot drift silently.
2026-09-23 02:09:46 -04:00
Miguel Angel Simon Sierra 77c687431a chore(skills): regenerate skills-manifest for media-use/motion-graphics
The original submission edited skills/media-use and
skills/motion-graphics docs without regenerating skills-manifest.json;
its own CI never ran (fork approval gate), so the drift went
unnoticed until this pass's first CI run caught it.
2026-09-23 02:09:44 -04:00
Miguel Angel Simon Sierra 527cd707f4 fix(media-use): sanitize fetched SVGs and gate theSVG by license
freezeUrl/freezeLocalFile wrote every fetched SVG to disk unsanitized;
the theSVG tier is now the sole SVG-returning logo provider, so an
inline script tag or on-event handler would land verbatim in a user
project. Route .svg destinations through the existing Figma sanitizer
(new @hyperframes/core/sanitize-svg subpath, synced via
package-subpaths).

thesvgMatch never checked theSVG's own license field: entries theSVG
itself marks unresolved (Proprietary, Unknown, Custom, TODO, or an
explicit no-redistribution-license string) could still resolve and
freeze into a project. Gate matches to the ten SPDX ids theSVG's
submission form accepts; a rejected entry falls through to a lower
ranked theSVG match, then to the github/favicon tiers.

Also caps the theSVG manifest fetch at 20MB, streamed, matching
freeze.mjs's existing cap discipline. It had a timeout but no bound.
2026-09-23 02:09:43 -04:00
Imad PhelpsandClaude Opus 5.5 c19f9c873c feat(media-use): resolve brand logos from theSVG
Replace the svgl and simple-icons tiers of the `logo` cascade with a
single theSVG (thesvg.org) tier. theSVG ships 7,400+ official brand
SVGs with color, mono, light/dark and wordmark variants.

The tier fetches one manifest pinned to a glincker/thesvg commit on
jsDelivr, memoizes it per process, and matches entities locally by
normalized slug, title, or alias. That drops the hand-kept svgl alias
and simple-icons slug maps. GitHub org avatar and favicon stay as the
fallbacks for brands theSVG lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 02:09:42 -04:00
miga-heygenandMiguel Ángel 29378b6c7f fix(engine): classify a single-keyframe video as problematic, not healthy (#3998)
* fix(engine): classify a single-keyframe video as problematic, not healthy

analyzeKeyframeIntervals short-circuited to isProblematic:false whenever
it found fewer than 2 keyframes, but exactly one keyframe is the worst
case for seek performance, not a healthy result: every seek past frame 0
must decode the entire file.

The single-keyframe branch treats the rest of the video stream's own
duration (not a container duration, which a longer audio track can
inflate) as one uninterrupted GOP. ffprobe's frame timestamps are
absolute presentation timestamps rather than relative to stream start,
so a re-muxed or trimmed clip with a nonzero start time is normalized
the same way extractFinalVideoFrameTimestamp already does for the same
quirk. The cache key now includes both values, matching that sibling
function's convention, since a stale cache entry from a different
duration/start pair would otherwise win silently.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* refactor(engine): share stream-window normalization between keyframe check and final-frame lookup

extractFinalVideoFrameTimestamp and analyzeKeyframeIntervals each
independently computed the same stream-start fallback, finite guard, and
cache-key format from the same metadata shape. Extracted resolveStreamWindow
so the two can't drift out of sync with each other.

Also: match ffprobe's real trailing-comma csv output in one keyframe mock,
add a test at the exact 2s threshold boundary, and comment the
noUncheckedIndexedAccess-only fallback at the single-keyframe branch.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* style(engine): trim long comments to one-to-three lines

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-23 02:05:51 -04:00
Miguel Ángel b9fe13ad8c feat(studio): export AudioWaveform, ImageThumbnail, useRenderClipContent (#4301)
@hyperframes/studio's public index doesn't export the clip-content
thumbnail primitives, forcing a deep import. Desktop's Canva timeline
lane needs to draw audio waveforms and image thumbnails from a host
app without reaching into the package's internals.

Adds a test that mounts each export through the package name the way
a host app would (not a relative import), so a future removal or
accidental un-export fails a test instead of a downstream deep-import
workaround.
2026-09-23 02:05:42 -04:00
Miguel Ángel b73df3549e refactor(studio): remove unreachable expanded-child timeline code (#4219)
* refactor(studio): keep legacy fixture state out of rendered rows

* fix(studio): keep timeline keyboard actor mounted

* refactor(studio): remove expansion telemetry and prove stable nested rows

* test(studio): type nested timeline fixture broadly

* test(studio): remove expansion lane assertions

* test(studio): assert nested row identity by key

* fix(studio): satisfy removal and format guards

* fix(studio): clear fallow findings in timeline cleanup

* test(studio): update timeline removal expectations

* fix(studio): remove stale timeline test helpers

* fix(studio): keep automation lanes mounted without disclosure

* refactor(studio): keep audio lanes while removing clip expansion

* fix(studio): restore audio disclosure state

* test(studio): remove obsolete expanded fixture assertions

* fix(studio): resolve provider rebase without clip auto expansion

* fix(studio): remove inline keyframe expansion rows

* fix(studio): restore automation disclosure state

* fix(studio): remove stale timeline model inputs

* fix(studio): pass animation state to timeline layout

* fix(studio): remove stale expansion tests and helpers

* test(studio): cover shared audio lane labels

* fix(studio): remove unused keyframe callback plumbing

* fix(studio): clear rebase lint leftovers

* fix(studio): remove unused timeline header export

* style(studio): format timeline cleanup

* fix(studio): restore composable timeline exports

* fix(studio): keep audio lanes open across selection

* fix(studio): align timeline lane test contract

* fix(studio): remove stale lane test import

* fix(studio): align track header test callback

* fix(studio): remove stale track header test prop

* test(studio): preserve ruler half pixel alignment

* test(studio): keep audio lane fixtures drawable

* test(studio): assert shared lane owner label

* fix(studio): align audio lane expansion with layout state

* test(studio): use the audio lane owner label

* fix(studio): unify audio lane expansion state

* fix(studio): align automation lane labels

* fix(studio): remove stale lane height import

* test(studio): assert automation labels against the real curve, not a formula

The previous test recomputed the expected top with the same formula
the fix uses, so a regression in TimelineAutomationLaneSlot's own
offset would still read green. Moved it to TimelineLanes.test.tsx,
which mounts both the header and the canvas, and compares their
rendered [data-automation-lane-label]/[data-automation-lane] tops
directly.

* revert(studio): restore the keyframe-row feature from main

Miguel: keep the per-property keyframe rows (diamonds, prev/next
arrows, disclosure, auto-open) exactly as main. This PR's real scope
is removing dead expandedParentStart consumers, not the keyframe-row
UX.

* refactor(studio): remove dead expanded-child timeline code

expandedParentStart is never set anywhere on main (the inline
sub-composition child-row expansion that produced it was already
removed in #4132/7797c5ead). Delete its now-dead consumers: the
TimelinePane move/resize/delete/split rebase wrappers, the ZMirror
call site that routed through them, the timelineDragLanding topology
guards, isMainTrackElement's expanded-child exclusion, laneGapFloor's
host-window-start math, and the paired expanded-clip-edit telemetry.
Keeps every keyframe-row feature (diamonds, prev/next arrows,
disclosure, auto-open, telemetry) untouched.
2026-09-23 01:38:50 -04:00
Miguel Ángel 8027c758d1 ci: compare test reachability against the merge ref's base parent (#4303)
* ci: compare test reachability against the merge ref's base parent

* ci: compare catalog-previews changed items against the merge ref's base parent
2026-09-23 01:29:12 -04:00
Leonel RivasandClaude Opus 5.5 42eb6e9ad2 fix(cli): replace pre-planted links when writing capture files (#4298)
* fix(cli): replace pre-planted links when writing capture files

writeCaptureFileSync wrote through whatever sat at the target path, so a
symlink or hard link planted in the capture directory redirected the write to
its target. Stage each file in a private directory next to the target and
rename it over the target instead, the same publication pattern the font
cache uses since #3669. Rename swaps the directory entry, so a planted link is
replaced rather than followed, while intentional overwrites such as the
deadline partial bundle keep working. Cleanup only removes the staging
directory this call created. A caller's own flag (wx for meta.json) is passed
through unchanged.

Two capture writes bypassed the writer and stayed exposed: the contact sheets
(sharp's toFile opens the path itself) and the BLOCKED.md failure note. Route
both through writeCaptureFileSync.

Fixes #4243

* fix(cli): address CodeQL findings on capture writes

Read the captured file before lstat-ing it in the regression tests so
CodeQL does not report a check-then-use race, and route both write modes
of writeCaptureFileSync through a single writeFileSync call, the one
write site the writer already had on main.

* fix(cli): route media previews through the capture writer

The Lottie and video previews in mediaCapture.ts still let Puppeteer write
the PNG itself via page.screenshot({ path }), which opens whatever sits at
that path, so a pre-planted link there redirected the write. Take the
screenshot as a buffer and write it with writeCaptureFileSync, like
screenshotCapture.ts already does.

Also restructure writeCaptureFileSync so its only write is the same
writeFileSync(path, ...) call main has. Publication now stages through a
separate helper that calls back into the writer with O_EXCL (wx) on a
fresh staging path, then renames over the target. Behavior is unchanged,
and CodeQL keeps tracking the existing network-data-to-file finding on
that line instead of reporting it as new.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2KDat7mo5Dr8wFkFzobyb

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 06:24:21 +01:00
Miguel Ángel bc1c60ae5b fix(catalog): regenerating the texture-mask page no longer duplicates its section (#4302)
* fix(catalog): regenerating the texture-mask page no longer duplicates its Texture masks section

The page generator carries forward any section whose heading it does not
own, and it emits "## Texture masks" without owning it. Every run kept the
old copy and appended a new one; main's page had three.

* chore(catalog): drop the two duplicate Texture masks sections from the texture-mask page

* chore(catalog): run oxfmt on the texture-masks regression test
2026-09-23 01:09:08 -04:00
1d6a0d5141 fix(producer): bound nested media by a host's data-duration when it has no data-end (#3975)
* fix(producer): bound nested media by a host's data-duration when it has no data-end

The runtime hides a slot's descendants at data-start + data-duration, and
the compiler only stamps data-end on media tags, so a composition host
authored with data-duration was unbounded for the media planner: audio
and video nested in a shortened scene kept playing over the next scene.
The template-wrapped compile test asserted that unbounded end (a 4s clip
in a 2s slot ending at 6); it now expects the slot end.

The host end now comes from the runtime's own resolveAuthoredTimingWindow
rather than a local re-implementation, so precedence matches the runtime
exactly: a positive data-duration wins over a conflicting data-end, and a
zero or negative data-duration is treated as absent (falling back to
data-end, or unbounded) instead of collapsing the host window to its start
and dropping every nested clip. Tests cover the conflicting-attribute case
and the non-positive-duration fallback.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* style(producer): trim long comments to one-to-three lines

---------

Co-authored-by: Val <val@codejam.info>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-23 01:08:58 -04:00
CasbaLandCasbaL e5eeeef972 fix(studio): stop VideoFrameThumbnail error→cleanup infinite loop (#4037)
The error handler called cleanup(), which sets video.src = "" and calls
load(). Clearing src itself fires another error event, re-entering the
handler at event-loop speed — every pass allocating a React update via
setFailed(true). The seeked path triggers the same loop: its cleanup
provokes the synthetic error right after a successful frame extract.

Detach the error listener inside cleanup() and ignore the synthetic
empty-src error. Instrumentation on a real session measured ~475k src
assignments/second and ~8.6 MB/s of update allocations before the fix.

Fixes #4036

Co-authored-by: CasbaL <26603379+CasbaL@users.noreply.github.com>
2026-09-23 01:00:15 -04:00
Miguel Ángel 1b8f8a43d4 fix(cli): stopping the preview server no longer leaves a Chrome running (#4183)
* fix(cli): stop the preview server's browser when the server exits

Cancel in-flight renders and thumbnail launches before draining the
browser pool on shutdown, instead of only closing whatever browser was
already registered. A render whose Chrome died from the shutdown signal
itself was being misclassified as a transient failure and retried with a
fresh, untracked browser that outlived the process. Reject new render and
thumbnail requests once shutdown has begun, and await an in-flight
thumbnail launch before closing it.

* fix(cli): close preview browsers before a hung render, keep SIGINT armed

shutdown() awaited renders before closing browsers, so a render slower
than preview.ts 3s exit watchdog left Chrome running when it fired.
Close the thumbnail browser and drain the pool concurrently with, not
after, the render wait, and bound the wait under that watchdog.

A second Ctrl+C/SIGTERM during shutdown removed the one-shot signal
handlers, so it hit the OS default and killed the process before
cleanup ran. Use persistent handlers guarded by the existing
shuttingDown flag instead.

Also: getThumbnailBrowser could still hand a live lease to a request
that lands after shuttingDown flips true; trim a comment over budget;
replace a fixed-sleep test race with a drain-signal barrier.

* fix(engine): make browser pool shutdown terminal, not just draining

drain() resets its drainPromise to null once it settles, so acquire()
only waits for an in-flight drain -- a render still unwinding after
shutdown could relaunch Chrome the instant that drain resolved
(probeStage.ts:449-465 has exactly this gap between an abort check
and a later acquireBrowser call). No non-shutdown caller reuses the
pool after draining it (checked every drainBrowserPool()/drain()
call site), but added a separate terminal close() rather than
changing drain()'s own semantics, so a future reuse caller stays
safe by default.

BrowserLeasePool.close() sets a permanent closed flag before
draining, and acquire() checks it both before and after its one
await point, so a request already mid-await when close() lands still
sees it once that await resolves. studioServer's shutdown() now
calls the new closeBrowserPool() instead of drainBrowserPool().

Also bounds drain()'s own wait: a close() that hangs past 1s now
gets escalated to a force-close instead of blocking the caller
indefinitely, keeping total shutdown time under preview.ts's 3s exit
watchdog alongside the existing render-wait bound.

* fix(engine): trim closeBrowserPool JSDoc to house comment length
2026-09-22 22:49:44 -04:00
cc19b8c812 fix(server): contain static file requests within their served roots (#4217)
* fix(server): contain static file requests within their served roots

Static asset handlers joined the raw request path onto their root
directory without checking where it landed. Hono decodes percent
escapes while routing, so an encoded backslash (%5c) arrives as a real
separator on Windows and dot segments arrive already collapsed —
"/assets/..%5c..%5csecret" and "/%2e%2e%2fsecret" could read files
above the studio bundle root (embedded Studio server) and above
compiledDir/projectDir (engine render file server). Browsers never
request those shapes, but anything that can reach the bound port can:
raw local clients, LAN peers when the preview binds 0.0.0.0, and a
shared-machine process hitting the render server.

Both handlers now resolve the request path first and refuse anything
that collapses outside its root, using the same lexical containment
the preview asset route already applies — bundle/project assets behind
symlinked directories keep being served, only dot-segment escapes are
rejected. Route-level pathSafety tests pin the behavior in both
packages, including the Windows backslash shape and the
symlinked-asset cases from the descriptor-pinning regressions.

Validated: new containment suites fail on the prior handlers (200 +
marker file content served from outside the root) and pass with the
guards; cli server (140), engine services (1024+, matching the main
baseline incl. pre-existing browserManager failures), typecheck,
oxlint, oxfmt and the full workspace build all clean.

* fix: close fallow audit findings, prove containment cross-platform

Fallow audit (health/complexity): createFileServer's request handler
exceeded the CRAP-score threshold once the new containment checks were
added. Extracted resolveContainedPaths, a small pure function that
resolves and validates both roots, cutting the handler's own branching
back down.

Fallow audit (duplication): the new pathSafety test files copied their
temp-root/studioDir setup and teardown from the existing staticAssets
test wholesale. Extracted the shared parts into
studioServerTestFixture.ts (makeStudioServerRoot, writeStudioIndexHtml,
cleanupStudioServerRoot); only the per-file vi.mock("node:path", ...)
call stays duplicated, since Vitest requires it declared statically in
each test file and it cannot be moved behind a shared function -- that
one block keeps a narrow, targeted ignore comment instead of a
file-wide one.

Security claim: isWithinProjectRoot's %5c-decoded-backslash escape is
real, but only on Windows -- POSIX never treats a literal backslash as
a separator, and Hono leaves %2f undecoded in c.req.path, so none of
the new tests' encoded-traversal payloads produce an actual escape on
Linux (verified empirically: they pass identically with the fix
reverted). isWithinProjectRoot now takes an optional path-module
override (defaulting to the host's native node:path, so every existing
caller is unchanged) so its win32 and posix branches can be unit
tested directly from one OS, the same pattern already used by
producer/fileServer.ts's isPathInside. New tests cover a backslash
traversal, a mixed backslash/forward-slash traversal, and a
drive-letter path escaping the root under path.win32, plus the
existing posix case -- confirmed each one fails when the check is
removed. The HTTP-level tests stay as-is: real regression coverage for
them comes from the windows-latest CI job, which runs both new
pathSafety suites unfiltered.

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: orphan4183 <kingenmaww@gmail.com>
2026-09-22 22:18:56 -04:00
dd97478175 fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice (#4179)
* fix(media-use): remove clicks and ~25ms gaps at every transcript-cut splice

transcript-cut extracts each kept range to its own final-codec file, then
concat-copies them. That produces two audible artifacts at every cut:

1. No boundary fades. Concat splices raw segment edges together, so the
   waveform steps discontinuously and each cut clicks.

2. Encoder priming silence. Each segment is encoded to AAC independently,
   so the encoder pads every one with priming samples. Stream-copy concat
   bakes that padding in as a silent gap at each splice. Measured 24.4 ms
   and 24.1 ms at the two splices of a three-range cut. It accumulates:
   the nth splice lands roughly 25n ms late, so splice positions drift
   away from the sum of the preceding segment durations.

The second survives the first -- adding fades alone leaves the gap.

Fix: intermediates carry PCM (.mkv for video, .wav for audio-only) instead
of the final codec, so audio is encoded exactly once, at concat. Boundary
fades of 30 ms are applied per segment, scaled down on segments shorter
than 4x the ramp and skipped on degenerate ones.

Measured on the same three-range cut, widest run of digital silence at
each splice:

  before  24.4 ms / 24.1 ms
  after    0.1 ms /  0.1 ms

--copy is unchanged: stream copy cannot filter, so it keeps producing
final-codec segments and a copy concat. Audio-only output is byte-identical
in duration before and after. scripts/lib test suite: 34/35, the one
failure (lut-preset-provider) pre-exists on main and is unrelated.

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

* chore(skills): regenerate manifest for media-use

* fix(media-use): honor the fadeIn/fadeOut flags fadeFilterFor was passed

The caller already computed {fadeIn, fadeOut} per segment, but
fadeFilterFor still took one parameter and always emitted both
afade ramps -- so a single kept segment (no splice at all) and every
segment's true start/end got faded regardless. Extracted the pure
function into lib/transcriptCutFade.mjs, made it honor both flags,
and added its first unit test: first/middle/last/single-segment
filter strings, no ffmpeg needed, fails against the previous version.

Also installs ffmpeg in the Test: skills CI job (same action the
Producer job uses), so transcript-cut.test.mjs's real-tone RED/GREEN
proof runs in CI instead of skipping silently for lack of the binary.

* fix(media-use): fix the packed-CLI import and lint on transcript-cut.mjs

build-copy.mjs replaces dist/skills/media-use/scripts/lib with a copy
of packages/cli/src/media-use/lib, so transcriptCutFade.mjs needed a
byte-identical twin there (and in MEDIA_USE_COPY_NAMES) or the packed
CLI's transcript-cut.mjs imported a file that no longer existed.
Moved its test alongside, matching every other lib helper's tests.

Also drops three redundant Boolean(args.copy) wrappers (a ternary
condition already coerces) that oxlint's no-extra-boolean-cast
flagged.

* style(media-use): reflow the shortened ext ternary to oxfmt's shape

Dropping the Boolean() wrapper shortened the line enough that oxfmt
0.41 now collapses the ternary onto one line; format:check was failing
on the stale multi-line version.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-22 20:28:50 -04:00
James RussoandClaude Opus 5.5 ec3bc93b90 docs: alphabetize supported coding agents (#4297)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 22:21:34 +00:00
Brun3y 105984cac4 feat(cli): add IBM Bob runtime support (#3930) 2026-09-22 22:01:42 +00:00
Miguel Ángel d4485b51f1 chore: release v0.8.62 (#4293) v0.8.62 2026-09-22 16:12:35 -04:00
James RussoandClaude Opus 5.5 16959deeac fix(studio): don't fail a shadow reload while the tab is hidden (#4292)
The shadow reload promotes on frame-driven readiness (the player's
paint-and-idle, then two requestAnimationFrames) but failed on a
wall-clock 15s setTimeout. A hidden tab renders no frames, so every
external edit made while Studio was in the background failed with
"it took too long to load", the toasts stacked, and the stale preview
stayed up even after returning to the tab.

The budget now only runs while the document is visible: hiding the tab
clears the timer, and showing it again restarts the full budget for the
pending shadow, which then promotes normally.

Fixes #4290

— Rames Jusso

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-22 20:05:15 +00:00
Miguel ÁngelandJake Moran 0945de83c7 feat(registry): text-match-cut (#4288)
* feat(registry): add text-match-cut — one text becomes another mid-move on a single 85 % curve

Video primitive reversed from an After Effects tutorial (Editing Snapped, "The Match
Cut Technique Every After Effects User Needs"), measured per frame at 60 fps. Both
texts ride one keyframe pair, cubic-bezier(.85, 0, .15, 1) over 2 s, and hard-swap at
the speed peak (1.0 s). A `mode` variable picks the property that carries the cut:
scale down (the tutorial), scale up, tracking in, or a same-direction slide in four
directions. Optional cyan-to-blue gradient sweep on its own 87 % curve, navy or
transparent background.

* chore(registry): integrate text-match-cut with current main

---------

Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
2026-09-22 12:14:46 -04:00
Vance IngallsandMiguel Angel Simon Sierra 8d2b6e7bdc chore: release v0.8.61 (#4283)
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
v0.8.61
2026-09-22 11:42:28 -04:00
b22bc2c979 feat(registry): extended-keyframe (#4281)
* feat(registry): add extended-keyframe, the never-fully-lands arrival with motion blur

A hyperframes:component. An element covers most of its move in the first fifth of the
duration and creeps the rest to the last frame (After Effects' extended-last-keyframe
technique), exits along the vector it arrived on, and carries shutter motion blur:
slides via the bundled feOffset filter smear, scale beats via a velocity-driven blur.

  var a = attachExtendedArrival("#word", tl, { at: 0.3, from: { x: -1400 } });
  attachExtendedArrival("#next", tl, { at: a.end, from: { scale: 0.35 } });

Direction and property agnostic (x / y / scale). The ease is exposed on its own as
EK.ease() for any tween, with EK.next() for sequencing and EK.gate() as a numeric check
(single velocity peak, no rises after it, tail above zero to the last frame).

registry/registry.json, docs/docs.json and docs/public/catalog-index.json regenerated
with the two generator scripts; they also pick up drift already present on main.

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

* feat(registry): publish live extended keyframe payload

* fix(registry): tear down extended keyframe blur

* fix(catalog): refresh extended keyframe payload

* fix(catalog): preserve merged catalog entries

* fix(catalog): regenerate catalog index

* fix(registry): trim extended-keyframe comments

* chore(registry): retrigger checks

* fix(registry): refresh extended-keyframe artifacts

* fix(catalog): refresh extended-keyframe payload

* fix(registry): refresh extended-keyframe artifacts against current main

---------

Co-authored-by: Jake Moran <jakemoran2011@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 11:41:48 -04:00
Miguel Ángel 2c71a3a112 fix(catalog): registry set-delta check accepts intentional additions (#4287)
check-registry-set-delta.mjs only recognized removals: an added name in
registry.json had no matching allowance, so any pure-addition PR failed
with "unexpected in registry.json: <name>" even though the item's own
registry-item.json was added in the same diff. Mirror the deletion rule
for additions: a name is expected when its own registry-item.json was
added or deleted in the same diff, same condition either side already
required.

CI's audit tool flagged the CLI entry itself (high-crap-score rule,
scripts/check-registry-set-delta.mjs:30 main, CRAP 42.0 threshold 30.0,
cyclomatic 6): main() shelled to git and parsed JSON inline. The git/fs
boundary is now injected as runRegistrySetDeltaCheck(base, { run,
readRegistryJson }), covered directly by a test with a fake run/read, so
main() is a thin print-and-exit wrapper.
2026-09-22 11:24:51 -04:00