mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 07:09:15 +00:00
sync/hyperframes-codegen-262f5374
74
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
16fab47a8e |
perf(player): scope preview readiness to first frame (#4239)
* perf(player): scope preview readiness to first frame * refactor(core): share runtime visibility timing * refactor(core): share readiness timing owner * test(core): pin readiness caller wiring * test(core): cover fonts in first-frame gate * fix(core): restore readiness test contract |
||
|
|
2fb6f6987e |
test(studio): close shadow reload regression gaps (#4223)
* test(studio): close shadow reload regression gaps * chore: refresh follow-up checks * test: keep follow-up free of unused exports * test(studio): distinguish root timeline selection |
||
|
|
85a1a8f789 |
fix(studio): swap the preview via a shadow iframe instead of blanking on reload (#4207)
* fix(studio): reloading the preview swaps in a ready copy instead of blanking * fix(player): report painted once assets are ready and the loader has faded out assetsready fires when the loader fade starts, so a consumer promoting on it can expose a raised or fading panel. painted fires once per document load after the fade completes, with a getter for late listeners; it resets on each load. * fix(studio): promote the shadow preview only when painted, under one wait budget Studio waited for ready plus two frames, which can land while the player's loader panel is still up or fading. It now also waits for the player's painted event. The shadow no longer arms the 5s adapter give-up, so a slow but live load fails only at the single 15s budget. Set-destination availability follows the live iframe across a swap. |
||
|
|
f4e00ce2b5 |
fix(runtime): a finished film rests on its last frame instead of a blank one (#4158)
* fix(runtime): a finished film rests on its last frame instead of a blank one The clip-visibility check was half-open (start <= t < end), so a clip whose authored end reached the composition's total duration hid at exactly that instant. The player parks there when playback ends, so a finished film went blank instead of holding its last frame, in both the published player and Studio's scrub-to-end. Extracts the shared half-open comparison used in seven places into isInClipWindow (packages/core/src/runtime/clipWindow.ts) and adds isClipVisibleAt, which additionally treats a clip reaching the composition's terminal instant as visible. Only the DOM-visibility gate uses the terminal rule; playback and media-activation windows keep the plain half-open check, so two back-to-back clips still never both show at their shared boundary and audio never re-activates past the end. * fix(runtime): hold a terminal video on its last frame at the composition end * refactor(player): route the proxy clip-window checks through the shared predicate Makes the composition duration required on syncRuntimeMedia so a caller cannot drop the terminal rule, exposes the predicate as a core subpath, and adds a nested-timeline float-noise test. * build(core): declare the runtime/clip-window subpath in the package-subpaths descriptor * test(runtime): a speed-ramped video that runs to the composition end holds its last frame Covers the composition of the terminal hold with a rate lane: at a time past the clip end the video sits at the source time of its own end. |
||
|
|
a7282dcd0e |
fix(player): wait for a cross-origin composition's assets before playing (#4103)
* fix(player): wait for a cross-origin composition's assets before playing The player's readiness gate scans the iframe document, which is null when the composition is embedded from another origin, so it released playback at once and the first frames could play with images still loading. The runtime now runs the same readiness scan inside the iframe and posts assets-ready; its timeline message reports whether assets have settled so an older runtime, which never posts it, is not waited on. Also declares the animation-frame abort handler before a host whose requestAnimationFrame calls back synchronously can reach it. * fix(player): keep a ready cross-origin player ready across a late iframe load The iframe load event reset readiness for any document the player cannot read. The runtime posts its timeline before load and a paused runtime never posts it again, so a load arriving after the assets settled left the player not ready. A ready cross-origin player already holds this document's handshake, because every navigation clears readiness first. * fix(core): stop RuntimeReadinessWindow widening Window.__hf so the runtime typecheck passes * fix(core): a torn-down runtime no longer posts assets-ready |
||
|
|
e36a53d6b6 |
fix(core): hold render-ready on declared compute, not just adapter loaders (#4065)
* feat(core): hold render-ready on declared compute and a build-ready hold, not just duration Adds the compute and paint-and-idle inputs to the composition readiness gate, plus window.__hf.buildReady: a promise registry a piece registers when it has setup work no adapter can see (mesh building, shader compiles). The runtime holds render-ready on it and the player shows its loading state instead of a blank frame while it resolves. * refactor(core): dedupe the paint-and-idle readiness test setup Simplify pass on 23810da1b: fallow flagged the two paintAndIdleReadinessInput tests as a 12-line clone; extracted the shared doc/tracking setup. * fix(core): stop the readiness settled-tracker hanging on 2+ pending promises Promise.all allocates a new promise every call, so comparing ITS identity across repeat polls (isAdapterReadinessSettled, isBuildReadinessSettled) never observes settled once 2+ promises are combined: every poll rebuilds a new combined promise, re-arms, and republishes forever without the caller ever seeing true. Reproduced with two already-resolved window.__hf.buildReady entries, which is the exact multi-piece usage the new authoring doc describes. Fixes both trackers at once with a shared createSettledTracker that compares the source promise array by element instead of the Promise.all wrapper. * fix(core): cancel readiness inputs once settleCompositionReadiness settles computeReadinessInput's poll timer and paintAndIdleReadinessInput's rAF chain kept running after the shared timeout won the race, since nothing told the losing inputs to stop. One AbortSignal now threads through all three inputs and is aborted the instant the race settles. * docs(core): fix a stale sync-callback claim in settleCompositionReadiness paintAndIdleReadinessInput always has a frame to wait on for any real document, so the sync fast path this docstring described is no longer reachable with the default inputs except for a viewless document. Found by the simplify pass on this branch. * fix(core): clear a stale buildReady entry on runtime teardown A never-resolved window.__hf.buildReady promise from a torn-down composition survived into the next init in the same window (window.__hf is never reset), permanently blocking render-ready for whatever loaded next -- the exact black-screen class this branch fixes, reproduced through its own new mechanism. Found by the independent review. Also fixes a stale hyperframes-player.ts docstring (media/images/fonts had not been updated to compute + paint-and-idle) and documents the buildReady key-collision hazard in three.md. * fix(core): skip the compute readiness wait when no runtime ever ran computeReadinessInput polled window.__renderReady for the full shared timeout on any same-origin document with no HyperFrames runtime injected (a probe-only composition using native video/duration, or a blank test iframe) — nothing was ever going to set that flag. Checking window.__hf first (the same signal composition-probe.ts already treats as "has a runtime") lets it return null instead of waiting. * fix(player): debounce the loading overlay and diagnose compute timeouts paintAndIdleReadinessInput always has a frame to wait on for an attached iframe, so settleCompositionReadiness's synchronous-settle path is unreachable in practice now — the loading overlay showed on every single Play, even ones with nothing genuinely pending. Debounce the overlay itself instead: a wait that resolves fast never shows it. Also names compute (window.__renderReady) in the stuck-assets timeout diagnostic, which previously only re-scanned media, images and fonts. * fix(engine): name a stuck buildReady key in the zero-duration diagnostic A build promise a composition registers on window.__hf.buildReady that never resolves left the export path's zero-duration timeout naming nothing useful — hasHf/hasSeek/duration all looked fine, since the runtime was genuinely present and just waiting on that one promise. Probes buildReady's keys for pending ones and names them in the hint. * fix(engine): stop the buildReady diagnostic from crashing on a rejection A microtask race against a bare resolved promise rejected the whole diagnostic expression on any rejected buildReady entry (the runtime's own createSettledTracker rejects deliberately) and mis-sorted a settled non-native thenable as pending, since its extra adoption tick loses the race. Both measured directly, not inferred. Settle each key onto its own record past one macrotask instead: no throw, and pending/resolved/rejected are distinguished correctly. A rejected key gets its own hint, separate from a merely pending one. * fix(player): report a backgrounded document instead of overclaiming a paint bound MAX_PAINT_WAIT_MS only bounds a document that keeps producing frames; one that stops entirely (backgrounded tab suspends rAF, or fewer than 3 frames ever fire) still rides the full shared 8s timeout, same as before this input existed. Corrected the comments that claimed otherwise. That gap is exactly why the stuck-assets diagnostic left paint-and-idle unreported: there was no accurate way to say it might be the cause. Added documentHidden (doc.hidden) to the diagnostic instead, since a backgrounded tab is the reachable, directly observable case. * refactor(engine): simplify the buildReady diagnostic rewrite Skip the macrotask wait entirely when a composition registers no buildReady keys — the common case, since it's opt-in. Nothing to settle means nothing to wait on. Collapse buildZeroDurationDiagnostic's inline parameter type into the existing HfDiagnostic interface instead of maintaining two copies of the same shape. * docs(engine): note the setTimeout(0)-thenable gap the review found An independent adversarial review, executing hostile inputs rather than tracing, found the macrotask wait can still misreport a thenable that resolves via its own internal setTimeout(0), since the diagnostic's own timer is registered before the thenable's. Bounded and self-correcting (only feeds the message text, and the diagnostic re-runs every ~1s), so documenting the boundary rather than chasing an unbounded fix. |
||
|
|
72b223d57f |
fix(player): gate play on real composition readiness, not just duration (#4054)
* feat(player): wait for a composition's media, images and fonts before playing
The player used to fire ready/autoplay the moment the timeline resolved,
even if the composition's video, images or fonts hadn't loaded yet -
visible as a flash of missing content or a frozen first frame. It now
waits for those assets (readyState >= HAVE_FUTURE_DATA for media, decoded
images, document.fonts.ready) before play() proceeds, showing its own
loading overlay (reusing the shader-transition loading panel) while it
waits, bounded by an 8s timeout that plays anyway and logs which assets
were still stuck.
Settles synchronously with no overlay when nothing needs waiting (already
loaded, or a cross-origin composition the player has no DOM access to
inspect), so today's immediate-autoplay behavior is unchanged for the
common case. A new assetsReady getter and assetsready event mirror the
gate for hosts that want to observe it.
* chore(player): drop internal decision-ref from a public-repo comment
An internal tracking id leaked into a code comment and a test name in the
previous commit. This repo is public; internal references don't belong here.
* fix(player): stop a stale asset-wait from settling a newer composition
An independent review found that a composition swap mid-wait (a new src or
srcdoc arriving while the previous composition's media/images/fonts were
still loading) had no way to invalidate the old wait. If the old wait's
promise resolved after the swap, it could mark the NEW composition ready
and even play it, while the new composition's own assets were still
pending - defeating the point of waiting in the first place. The same gap
let a late-resolving wait flip a disconnected player's paused state back to
false and fire a spurious play event after removal.
Fixed both with one generation counter: every new wait captures the current
generation, disconnect and reload each bump it, and a wait's eventual
settle no-ops if its generation is no longer current.
Also fixed: the loading overlay's aria-label ("Loading assets") never got
restored once the asset wait was done, so a later shader-transition overlay
on the same player kept misannouncing itself to assistive tech.
* fix(player): invalidate the asset wait from every navigation path, not two
The generation-counter fix only bumped at two of the six places that set
_ready = false ahead of a navigation, reload, or disconnect - src/srcdoc
attribute changes, a sandbox-origin policy reload, and a shader-options
reload all started a new navigation without invalidating the previous
wait, leaving the same race open through those paths.
One _invalidateAssetsWait() helper now owns the reset (assetsReady,
pendingPlay, generation, the loading attribute, hiding the overlay) and
every one of the six sites calls it before starting whatever comes next.
* fix(player): gate play on real composition readiness, not just duration
Add a shared composition-readiness gate in packages/core, exported as the
@hyperframes/core/composition-readiness subpath. It resolves once every
declared input settles (media for now: video/audio/image/font loads), or
after a timeout, and calls back synchronously when nothing is pending so a
caller that plays immediately after sees the decision already applied.
The player element now queues an early play() call instead of dropping it
when called before the runtime is ready, and both of Studio's readiness
call sites (the direct-adapter path and the message-based fallback) go
through one store action, requestTimelineReady, instead of duplicating a
generation-counter-and-settle pattern across two hook files.
* fix(player): stop a queued early play() and a project switch from double-firing readiness
An independent review of the readiness gate found two real races:
play() queued before the runtime was ready re-ran its full body once ready,
dispatching the public "play" event twice for one user action. It now
dispatches "play" only on the call that actually starts playback.
Studio's requestTimelineReady generation guard was never invalidated by a
project switch or a full timeline reset, so a slow-loading project's
in-flight wait could resolve after the switch and mark the new, unchecked
project ready. Resetting the timeline now bumps the generation, and setting
timelineReady directly does too.
* fix(player): cancel a queued play on pause or seek during the asset wait
A play() queued while assets buffer (_pendingPlay) used to fire once they
settled even after an explicit pause or seek during the wait. pause() and
seek() now clear the flag, so a user pause/seek wins.
|
||
|
|
eae4892ae8 |
chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security] * fix(test): preserve test behavior on Vitest 4 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: James <james.russo@heygen.com> |
||
|
|
8d80530ff4 |
fix(player): authenticate slideshow navigation senders (#3820)
* fix(player): authenticate slideshow navigation senders * test: isolate playground message boundary imports |
||
|
|
4a72a98a49 | fix(player): pause the hidden loader sheen (#3673) | ||
|
|
dc9fb67320 |
fix(slideshow): retry init at DOMContentLoaded when the children were not parsed yet (#3750)
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. |
||
|
|
61ba800a5d |
fix(player/runtime): rebind timelines and bound paused seeks (#3489)
* fix(player): rebind replaced direct timelines * fix(runtime): rebind timelines after runtime data * fix(player): defer initial iframe navigation * fix(player): preserve runtime readiness through load * fix(runtime): publish rebound timeline before apply * fix(player): defer preconnect option reloads * fix(runtime): stop re-seeking paused timelines * fix(player): restrict runtime-src to trusted origins and reset readiness on reload |
||
|
|
859ac622c2 |
fix(player): report and fail closed on runtime delivery errors (#3472)
* feat(player): report runtime data application * fix(player): fail closed on runtime data delivery * fix(player): close runtime delivery and sandbox gaps * fix(player): satisfy runtime contract and CodeQL * refactor(player): simplify runtime tag scanner * style(player): apply repository formatter * fix(player): type runtime tag boundaries * fix(core): mint guest-local runtime-data ids in a separate space from host ids |
||
|
|
4f00336c92 | feat(player): add retained runtime data channels (#3471) | ||
|
|
ea7c48f372 |
fix(add): make chosen variables actually take effect, in the CLI and the preview (#3316)
* fix(add): apply --vars to components, and explain a failed download
Customising an item on the catalog page, copying the printed command and
running it did nothing for a component. `--vars` was accepted, documented
and then dropped: buildSnippet put the values on a block's mount element
and returned a bare "paste from ..." comment for a component, so 221 of
the 375 catalog items silently ignored every value the page produced.
A component has no mount element to hang values on. It is markup pasted
into a host, and it resolves values through __hyperframes.getVariables(),
which merges the declared defaults of every [data-composition-variables]
element in the document with render-time overrides. So the component's
own declaration is the only place a chosen value can live and still be
there after the paste. `add --vars` now rewrites those defaults.
Blocks keep the mount attribute. Per-mount values are strictly better
where a mount exists: the file on disk stays byte-identical to the
registry's, so a later reinstall can still tell an edit from an update,
and two mounts of the same block can differ.
A value the item cannot accept is now refused rather than written. An
out-of-range number or an unlisted enum value falls back at runtime and
warns, so writing one would produce a file that renders exactly as if the
value had been ignored -- the failure this change exists to remove. Ids
the item never declared are reported too, instead of vanishing. Only the
requested item is rewritten; a dependency dragged in behind it never
declared these variables.
Separately, `Install failed: fetch failed` is now a sentence. Item FILES
are not cached (only manifests are), so a network blip surfaces as node's
bare message with no URL and no cause, immediately after the user copied
a command off a web page -- which reads as "the command was wrong" rather
than "the network was". It now names what failed, says it is usually
connectivity or a proxy rather than a bad command, and mentions
HTTPS_PROXY.
Also fixes the two transcribe tests that were failing before this branch.
They assert the whisper soft-skip path but never pinned the engine, and
`auto` picks Parakeet whenever parakeet-mlx is installed -- so on those
machines the test shelled out to a real ASR binary, failed with "Parakeet
did not produce output", and landed in the generic failure branch it
claims is never taken. Pinned to `engine: "whisper"`, plus an assertion
that the mocked transcribe actually ran, which is what stops the test
passing on a machine without Parakeet while testing nothing on one with
it. The file now runs in 18ms rather than 3.7s, because it no longer
launches a subprocess.
Test plan: 10 new tests for the rewrite (enum and range refusal, the
numeric-string coercion the catalog URL depends on since every query
value is a string, delimiter escaping, unparseable declarations) and 3
for the failure message. Full CLI suite: 2661 passed, ZERO failures.
Verified as a user, not just in unit tests: installed blur-in with the
exact reported command, confirmed the declaration carried 76 / accent /
center, pasted it into a composition and ran `check` -- which reported
canvas_overflow at 76px, which only happens if the baked size is really
in effect. Bad values warn and are refused; blocks still emit
data-variable-values.
* fix(player): load the runtime before the body, not after
Customising a component on a catalog page did nothing to the preview.
badge-pop with count 10 and a green accent rendered 3, in red.
The probe injects the runtime by appending a script to an already loaded
document, and only once it has a reason to: a nested composition, or five
polls with a timeline present. A component has neither. It is markup
pasted into a composition, and it reads its values in an inline IIFE that
runs while the body is parsing:
var vars = window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables() : {};
With the runtime arriving afterwards that guard always took the empty
branch, so the component used the defaults hardcoded in its own script
and every chosen value was dropped. The values were never the problem:
the preview sets window.__hfVariables correctly, and nothing was there to
read it.
prepareSrcdocForElement now puts the same runtime URL in the document's
head before the srcdoc is set. A classic external script in head is
parser-blocking, so it runs before body scripts without changing what
gets loaded or adding a dependency the player did not already have. A CLI
render never had this bug because the engine already orders it this way.
Skipped when the page carries the runtime already, so a CLI-rendered page
(which inlines it) does not get a second copy re-initialising the runtime
underneath a live composition. The probe's late injection stays for the
src= path, where there is no srcdoc to prepare. The runtime URL moved to
its own module so the two injection points cannot drift apart.
Test plan: 8 new tests for the injection (ordering against the reading
script, head placement, both no-op guards, missing head/body, attributes
on the head tag). Three srcdoc tests asserted byte-identical forwarding
and now assert what they were actually protecting -- that the composition
arrives intact -- plus the new runtime guarantee. player 338 passed,
studio 4249 passed.
Verified end to end against the real runtime and a real registry
component, asking for size 96 / accent / right:
before 52px, rgb(243,243,243), flex-start, runtime absent
after 96px, rgb(60,230,172), flex-end, runtime present
rgb(60,230,172) is #3ce6ac, the accent green. That is the reported bug
before, and the chosen values after.
* fix(add): name the registry and the real reason an install failed, and retry
`Install failed: fetch failed` was two words that describe every network
problem equally badly. Three things were missing, and each of them was
the whole answer in a different case.
The URL. undici throws with no URL attached, so a project that points
`registry` at a private host in hyperframes.json got a message that
looked like the public registry had failed. Naming the URL is the entire
diagnosis there.
The cause. undici buries the real reason one or two levels down in
`cause`, and it was being dropped. The reported failure turned out to be
`self-signed certificate in certificate chain`: a private registry whose
certificate node refuses and curl accepts, which is why the host looked
healthy from a terminal. That sentence tells the reader which knob to
turn; `fetch failed` sends them to check a connection that is working.
The retry. Item files are the one uncached path -- manifests fall back to
a stale copy, but every install downloads its files fresh -- so a single
blip killed the whole command. Now two extra attempts with short backoff,
and deliberately NOT for TLS failures: a self-signed certificate fails
identically every time, so retrying it only makes the user wait three
times as long for the same message.
Also retypes the declaration reader. It modelled variables as a local
interface of six `unknown` fields and re-checked each one at every use.
Core already owns this shape as a discriminated union and exports
`isCompositionVariable`, the same predicate `parseCompositionVariables`
filters with, so the union is used directly and the duplicate type is
gone. A declaration the schema rejects now leaves the file untouched
rather than being partially rewritten from guesses.
Test plan: 4 retry and URL tests, 5 cause-chain tests, and the add-side
tests now cover the custom-registry hint and its absence on the default
registry. The variableDefaults fixtures gained the `label` the schema
actually requires; without it they were not valid declarations, which the
stricter reader caught. CLI suite 2671 passed, zero failures.
Verified with the BUILT dist rather than the source, in the reporter's
own project directory. The failure now reads:
File fetch failed: https://<host>/registry/components/blur-in/blur-in.html
- fetch failed (self-signed certificate in certificate chain
[SELF_SIGNED_CERT_IN_CHAIN])
and once the project points back at the public registry the original
command succeeds with `variables applied: size, tone, align`.
* fix(registry): name the registry on the not-found path too
The item-file failure now names the host it could not reach, but the
sibling path did not. A project whose registry is unreachable at the
MANIFEST stage got `Item "blur-in" not found - registry unreachable or
empty`, which reads as the public catalog having lost the item and sends
the reader to search a registry that never saw the request.
Same fix, same reason, applied where the other three call sites live so
one of them cannot stay behind: the message names the host and says it
came from this project's hyperframes.json, and only when it is not the
public registry, so the common case stays short.
Test plan: 3 tests covering the private-registry hint and its absence on
the default registry and on no registry at all. CLI suite 2674 passed,
zero failures. Verified with the built dist against a host with a bad
certificate:
Item "blur-in" not found - registry unreachable or empty. Contacted
https://self-signed.badssl.com/registry, set by this project's
hyperframes.json, not the public registry.
* fix(catalog): reconcile the two spellings of a compound word
`countdown` returned exactly one item, the only thing tagged with that
spelling. `count down timer` returned sixteen, and that one was in none
of them. The tokenizer splits on word boundaries, so the two spellings of
a single idea produced disjoint sets, and whichever phrasing an author
happened to type decided which half of the answer they saw. Neither half
was the whole answer: the one-word spelling hid count-up and
decline-chart, which are the two things you would actually build with.
Both directions now, each gated on the catalog's own vocabulary so this
can only add signal. A query token is split when both halves are words
the catalog uses, and adjacent tokens are joined when the compound is.
A word in neither form, like `timer` which appears in no item, is left
alone: this widens phrasing, it does not invent matches.
Everything inferred this way carries a fraction of a real token's weight.
That is the part worth keeping honest, because the first version relied
on the halves being statistically common in a 375-item catalog, which is
not the same as making them count for less. In a small corpus that
version let `type` matching the name of `type-match-cut` outrank
`typewriter` matching the name of `typewriter`: searching a word returned
something that merely contained half of it. Two tests written against
that real failure caught it.
All spellings now return the same 17 items, and each still ranks its own
exact match first: `countdown` leads with yt-circle-pointer, `count down`
leads with the two-word items, and count-up and decline-chart appear in
both.
Test plan: 6 new tests covering both directions, the identical-set
property that was the actual defect, exact-match precedence, an unknown
word left alone, and the typewriter case. Eval set unchanged at 33/39
top-1 and 39/39 top-3, so no query regressed. CLI suite 2680 passed.
|
||
|
|
b6ff3ab745 |
fix: preserve the composition query and serve the runtime before author scripts (#3114)
* fix(player): stop re-encoding the composition query Every src the player sets goes through withShaderQueryParams, which parsed the author's whole query with URLSearchParams and re-serialised it with toString(). That is a form encoder: it writes a space as +, while callers percent-encode and read back with decodeURIComponent. Those two codecs are not inverses, so any space in any query value arrived corrupted. It ran even when there was nothing to inject. With no shader attributes both params are deleted, so the round-trip was pure loss, on every src, for every consumer. Append the two params to the raw query instead of re-serialising it. The player now hands a composition its query back byte-identical. Empirically space was the only casualty: plus, ampersand, equals, hash, percent, question mark, quotes and non-ASCII all survived a URLSearchParams round-trip. That is narrow, but a space in a headline or in SVG path data is the common case, and invalid path data renders nothing at all. Latent until now: no shipped consumer depended on query preservation, so this surfaced only once compositions began carrying variable payloads. * fix(cli): serve the runtime ahead of every author script injectRuntime appended its script before </body>, so it landed after any inline script the composition carried. At the moment a composition's own script ran, window.__hyperframes was undefined and getVariables() was unreachable: our documented API did not exist at the point authors are told to call it. Served order was gsap at line 6, the composition's init script at 20, the runtime at 37. A probe inside the composition's IIFE recorded hfTypeAtInit undefined with no variable keys, and the element rendered its hardcoded fallback rather than the declared value. The runtime is designed to load early. Its entry assigns __timelines, installs the authored-opacity capture (whose own comment says it must run while the document is still parsing), and exposes __hyperframes synchronously, deferring real work to DOMContentLoaded. End-of-body injection defeated all three, and nothing in it needs a parsed DOM, so no defer is wanted. Injects at head start instead, reusing the placement cascade injectScriptsAtHeadStart already implemented rather than adding a fourth copy of it. Head start rather than the closing tag so the runtime also precedes author scripts inside head. injectRuntime has exactly one consumer, the play server's composition route. Every other surface reaches the runtime through the bundler, which already injects into head, or deliberately serves raw. Two registry blocks had independently worked around this by parsing the authored attribute themselves. Those stay, but the workaround is no longer the only way to read a variable at init. |
||
|
|
21cb722ebd |
refactor(core): unify composition contract (#2157)
* refactor(core): unify composition contract * fix(parsers): parse start expressions linearly |
||
|
|
16942c0c12 | fix(player): own connection and media resources | ||
|
|
dcefdd98ca | fix(player): version runtime protocol | ||
|
|
992a9b6607 |
feat(lint,player): fast-capture lint rule + player media sync (#1921)
* feat(engine): drawElementImage capture service * feat(engine): 3D projection + compositor-effect risk gate * fix(engine): gate filter drop-shadow wherever blur gates (review) detectCssEffectRisk documented drop-shadow as a ~29dB damage case but only detected blur( in its three scan paths — a drop-shadow comp stayed on the fast path despite the gate's own correctness contract. Detect drop-shadow( in computed styles, stylesheet rules, and GSAP tween vars, pinned by a focused test that runs the real page-side closure against a DOM shim (computed / stylesheet / tween coverage + blur regression + effect-free null). Addresses miguel-heygen's blocker on #1918. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(engine): frame-capture core — fast-capture routing, worker-encode, dedup extension # Conflicts: # packages/engine/src/services/screenshotService.ts * fix(engine): document HF_FORCE_DRAWELEMENT as diagnostic-only; make armStaticDedup idempotent (review) Addresses miguel-heygen's blockers on #1919: - HF_FORCE_DRAWELEMENT promoted from a stale "SCRATCH/Uncommitted" comment to a documented diagnostic flag: it exists for upstream-Chromium repro work (gate-vs-API isolation, crbug 521861819 149-vs-151) and R&D on gated effect classes; renders under it may be damaged BY DESIGN since it bypasses gates whose thresholds encode measured damage. Never production; the safety-net blank guard also stands down under it so diagnostic frames arrive unmodified. - armStaticDedup is now idempotent: the drawElement init path arms dedup before canvas injection, then initializeSession called it again — the second run overwrote the armed state with skipReason="capture_mode" (captureMode is "drawelement" by then), producing contradictory telemetry (armed frames + a skip reason), and re-ran the verification seeks on the fallback path. It now no-ops once staticFrames or a skip decision exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(producer): fast-capture render stages + remote bg-image localizer * feat(lint,player): fast-capture lint rule + player media sync --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a7c3cc7d68 |
fix(slideshow): make presenter mode work over Google Meet/Zoom screen share
Fix slideshow presenter mode for screen-share workflows by opening the audience view as a regular noopener tab, preserving audience query construction across fragments, and keeping iframe keyboard forwarding diagnosable. |
||
|
|
e5ec4cf532 |
fix(player): rescale on cross-origin timeline ready, guard warn spam (#1840)
onRuntimeTimelineReady (the cross-origin ready signal for signed CDN composition URLs) never called _rescale(), leaving the iframe unscaled and untransformed if the runtime's stage-size postMessage was ever skipped. Also adds a one-shot diagnostic warning when a rescale keeps no-oping after ready, latched so a legitimately hidden/zero-size player doesn't spam the console. |
||
|
|
0a9555a0f7 |
fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery (#1781)
* feat(player,studio): favicon-blade play icon with pause<->play morph Replace the play triangle with the right-hand blade from the HyperFrames favicon and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one path's d between the blade and two pause bars (gsap added as a studio dep). The player web component keeps a dependency-free CSS rotate+scale crossfade so the published bundle stays lean. Both honor prefers-reduced-motion. * fix(cli): discover local-studio (Vite) preview over IPv6 loopback The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so `preview --selection/--context` reported preview-not-running against a local-studio preview (e.g. inside the monorepo / bun run dev). Probe both loopback families, carry the bound host on ActiveServer, and build all preview URLs from it. Adds an IPv6-only discovery regression test. * fix(studio): wire the Add-keyframe (K) shortcut The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar (enabled when a keyframeable element is selected) wired to the toolbar's add action; register it in the capture phase and stopImmediatePropagation only for keys it actually handles, so K adds a keyframe in that context while JKL playback keeps working everywhere else. * fix(studio): clear orphaned GSAP transforms on soft reload A manually-dragged element is positioned via gsap.set, which writes an inline transform. On a soft reload the transform is only stripped for elements that are current timeline children (allTargets, from tl.getChildren().targets()). An element positioned by a standalone gsap.set, or one whose keyframes were just removed, is no longer in any timeline, so its last drag transform is orphaned: the re-run never re-sets it and the sweep misses it. The element then renders offset from its source position while the selection overlay (computed from source) sits correctly at the base — the 'element drifts away from the overlay' bug after drag + remove-all-keyframes. Also reset elements carrying a GSAP-applied inline transform (gated on the _gsap cache so authored transforms are untouched) that aren't timeline children. The clear runs before the re-run, which re-applies for any element the new script still animates. * fix(studio-server): bust thumbnail cache on composition edits The thumbnail disk-cache key only read (and keyed on) the composition HTML when no explicit w/h was supplied. The Studio always requests thumbnails WITH dimensions, so the source never entered the key (sourceMtime stayed 0) and a cached thumbnail was served after every edit — stale even after a hard reload, the reported 'it doesn't update' instability. Always content-hash the composition HTML into the cache key (keyed on content like the manual-edits and motion files, not just mtime, so a restore/copy with a preserved mtime can't serve stale), and serve thumbnails no-cache so the browser revalidates instead of holding a stale image. Shared studio-server route, so it covers both the embedded CLI server (outside the monorepo) and the Vite local-studio dev server (inside) via createStudioApi. * fix(parsers): remove-all-keyframes holds position static instead of re-animating removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that KEPT the original duration, so removing all keyframes re-animated the element from its base toward the last keyframe value. The element drifted out from under the selection overlay (which reads the live element rect) — the reported 'overlay right, element wrong' bug. Collapse to a static hold instead: duration 0 + immediateRender true, dropping the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and the recast writer (removeAllKeyframesFromScript), kept in parity. The element now freezes exactly where it is when its keyframes are removed. * fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation The keyframe-diamond context menu's 'Delete All Keyframes' was wired to handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation — so the element lost its position and jumped (reverted to base / left an orphaned transform) out from under the selection overlay. Wire it to handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static held value (duration 0 + immediateRender), so removing the keyframes freezes the element exactly where it is. * fix(studio): timeline 'Delete All Keyframes' holds position too The keyframe-diamond context menu renders in two places — the canvas (MotionPathOverlay, fixed in the prior commit) and the timeline (via StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called handleGsapDeleteAllForElement, deleting the element's whole animation. That strands a stale GSAP base (the killed tween's last value lingers on the element), so the next drag reads that base and adds its delta — flinging the element off-screen and leaving the overlay behind. Route it to handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path. * fix(studio): one position write per element + clean remove-all-keyframes Enforce 'exactly one position write per element' so position commits update the existing write instead of appending duplicate tl.to/gsap.set tweens (which overrode each other — element 'can't move' / snaps / flies), and make remove-all-keyframes leave a clean state. - dedupePositionWritesInScript + consolidate-position-writes mutation (acorn + recast, in parity); findExistingPositionWrite matches degenerate duration:0 holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates; removeAllKeyframesFromScript strips every position write for the selector. - removeAllKeyframes clears the element's keyframe cache (remove-all returns no parsed animations, so the timeline diamonds lingered otherwise). - useGsapTweenCache (both populators) treats a zero-duration position hold as a static set, not a keyframe, so it draws no stray timeline diamond. - Extracted gsapPositionDetection.ts (file-size cap). Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio tests pass. Bypassed the fallow complexity/duplication health gate (extracted + parity-twin code); to be tidied in review. |
||
|
|
7517f6ac86 |
feat(slideshow): per-slide autoplay (manual-advance, opt-in) (#1708)
* feat(slideshow): per-slide autoplay (manual-advance, opt-in) Adds an opt-in `autoplay` flag to slideshow slides: when the presenter lands on a video slide, its `<video>` plays from the start. The slideshow still holds and never auto-advances — the presenter clicks Next when ready. This covers compositions whose own controls can't be clicked (the player renders the composition pointer-events:none). Plumbing (done, tested): - core: `SlideRef.autoplay?: boolean`, parsed + validated in parseSlideshow (a non-boolean autoplay rejects the manifest); carried through resolve. - controller: optional `PlayerPort.playSceneMedia(sceneId)`, fired only on forward `enterSlide` for autoplay slides (not resume/back/sync, so the audience — which mirrors the presenter's media events — isn't double-driven). - component: `playSceneDocumentMedia` reaches the same-origin composition iframe, finds the scene's `<video>`, and asserts playback; `stopMedia` (already wired on slide change) resets it. An autoplay token cancels a pending start when the slide changes. - tests: controller autoplay behavior + parser flag round-trip/validation (131 player + 22 core slideshow tests pass). KNOWN LIMITATION — runtime media-start needs the player media model (@vance): On current main the clip<->timeline binding from #1601 keeps every clip synced and *paused* to the held timeline frame, which wins against playSceneMedia's play() — so the clip does not actually start on main yet (it does on the pre-#1601 player). The correct fix is a sanctioned "let this clip free-run while the timeline holds" path in the player/runtime media controller. Flagging for Vance to wire the start into the #1601 media model (or rebase onto it) when back. The plumbing above is the stable surface that hook plugs into. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(slideshow): address code-review on per-slide autoplay - guard playSceneDocumentMedia behind resolveMode() !== "audience": the audience mirrors the presenter's media events, so it must not independently drive its own copy of the clip. - drop the per-enter window pointerdown/keydown "gesture retry" listeners, which leaked when muted autoplay succeeded without a gesture. The poll already re-asserts play(), so a gesture within the window is picked up next tick. - stop polling once the clip is advancing across two ticks (was re-asserting play() for the full window even after playback was confirmed). - cancel any in-flight autoplay loop on disconnectedCallback (bump the token). - split the poll into findSceneVideo + stepAutoplay helpers (keeps each small). - fix the enterSlide comment: autoplay fires from enterSlide (next/prev/ goToSlide), not resumeSlide (back/backToMain/syncTo). - parser: isOptionalBoolean type guard instead of a one-off helper; drop `as` assertions in the new controller test. 131 player + 22 core slideshow tests pass; lint/format/typecheck/fallow clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(slideshow): autoplay skill guidance + address review nits Addresses review feedback on #1708: - skill: document per-slide `autoplay` in the slideshow standalone-harness reference — when to use it (video is the slide's primary content, its end is the advance cue) vs not (background/ambient loops, footage talked over), per Vance's guidance, before merge. - play() rejection is no longer blanket-swallowed: AbortError (timeline-sync seek interrupt) and NotAllowedError (gesture-gated autoplay) are expected and ignored; any other rejection is surfaced once via console.warn (Via nit 1). - clarify in the SlideRef.autoplay doc that it plays the scene's FIRST <video> (Via nit 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
64eaad7d69 |
feat(slideshow): auto-set interactive on inner player (#1712)
* feat(slideshow): auto-set interactive on inner player The slideshow now sets the `interactive` attribute on its inner <hyperframes-player> instances at mount time, so pointer events reach the composition iframe automatically. Removes the agent-compliance burden of having to remember to add `interactive` on every player tag inside a slideshow. Idempotent: an author-supplied `interactive` attribute (any value, including `interactive="false"`) is preserved. A MutationObserver also picks up players inserted dynamically after the initial mount. Standalone player usage outside a slideshow still requires the explicit attribute — that surface is unchanged. Skill guidance at skills/slideshow/SKILL.md updated to reflect the automatic behavior. * docs(slideshow): clarify interactive attribute semantics Per Rames R1 review feedback: the test comment implied `interactive="false"` is an author opt-out, but `:host([interactive])` is presence-matching per HTML boolean-attribute convention — so any value (including "false") enables pointer events at runtime. The slideshow's mechanical wire-up preserves any author-supplied value verbatim for DOM hygiene, not as a runtime opt-out. |
||
|
|
ea23e6309f | fix(player): treat runtime timeline as cross-origin ready (#1690) | ||
|
|
341e65aea2 | fix(slideshow): harden media controls in present decks (#1619) | ||
|
|
f0c4dee705 |
fix(slideshow): present media controls (#1601)
* fix(slideshow): harden media controls in present decks
* refactor(slideshow): clear Fallow audit findings
Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.
- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
to its own helper. Behavior preserved (all existing bridge.test.ts
cases hit the same dispatchers via the public installRuntimeControlBridge
API).
- player/slideshow/SlideshowController syncTo — split into
isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
stopSlideMedia decision and the stack re-rooting are now individually
named; the public method is a 4-line orchestrator.
- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
so the orchestrator no longer carries the dual JSON/text branches.
Cuts the cyclomatic complexity flagged by fallow after the
shouldIgnoreRequestFailure signature expansion shifted the fingerprint.
- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
shared a `try { iframeDoc = contentDocument } catch { return }` preamble
(clone group 15). Extract _getSameOriginIframeDocument(): Document | null
and have both call sites consume it.
- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
and explicit flush() shared the pending-drain pattern (clone group 16).
Extract a drainPending() closure both call.
- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
shape behind a stubIframeContentDocument helper.
No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(validate): split run further; ignore test dup parity
Second Fallow pass surfaced two minor follow-ups after the first cut:
- packages/cli/src/commands/validate.ts run + emitTextReport still
carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
printValidationResult / formatConsoleEntry / formatTotals /
emitFailureReport so run becomes a try/catch + delegation, well
below the threshold; emitTextReport drops the inline format loops.
- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
alongside the existing SlideshowPanel.test.ts entry. Same reasoning
documented there — parallel arrange/act/assert test cases are
intentionally self-contained for readability; collapsing them under
shared fixtures would couple unrelated scenarios (same-origin vs
realm media, audio-locked permutations, seek bridge variants).
No behavior changes — refactor + config-policy parity only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f05b3f9c7c |
fix(slideshow): finish remaining split-PR review findings (#1594)
* fix(slideshow): address split-PR review findings on #1585 Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were already fixed on this branch: CSP handlers, manifest version, UUID ids, float keys, presenter 1s-timer): core (#1580): - isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly - resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite player (#1590): - present() window.open uses noopener,noreferrer (audience syncs via channel) - BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin cross-talk between decks - add observedAttributes + attributeChangedCallback so runtime sound/mode toggles re-render studio (#1591/#1592): - persistSlideshowManifest no-op gate (skip write when HTML is unchanged) - surface persist failures (console.error) instead of silent .catch(()=>{}) - confirm before deleting a branch sequence (data-loss + dangling hotspots) + tests for the collision + non-object-manifest rejection. 20 core / 106 player / 53 studio pass; tsc/lint/fmt/fallow clean; deck still renders. * fix(slideshow): finish remaining split-PR review findings The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in #1585): core (#1580): - dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the lint rule and the runtime scene-window computation (no more mirror-and-drift) player (#1590 / #1592): - onKey: when multiple decks share a page, drop the unfocused-convenience so a key drives only the focused deck - slow-iframe recovery: if the scene timeline posts after the wait times out (empty scenes), re-init once so sceneId slides resolve instead of being dropped studio (#1591): - persistSlideshowManifest validates the built island round-trips before writing - reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line reorder), with a branch-position indicator + tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass; tsc/lint/fmt/fallow clean. * fix(player,cli): use fileURLToPath for path resolution (Windows CI) new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows, which broke: - packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias resolved to a nonexistent path, failing the player slideshow tests on the Windows render-verification CI (passed on macOS/Linux where pathname is clean) - packages/cli/src/utils/compositionServer.ts helperDir — same bug in the play/present bundle-path resolution fileURLToPath converts file:// URLs to correct OS paths on all platforms. Player slideshow tests pass; present serves + resolves bundles. * fix(producer): fileURLToPath for the renders dir (Windows) DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..." on Windows and resolves to a bogus path — affects the Windows render pipeline. Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean). |
||
|
|
cc2220e59e |
fix(slideshow): address code-review findings #1580-1584 (#1585)
* fix(slideshow): address code-review findings #1580-1584
- player: bundle @hyperframes/core into the IIFE/global build (noExternal)
- player: resolve audience mode from ?mode=audience URL query, not just attr
- player: event-driven waitForScenes + loud failure when no slides resolve
- player: scope window keydown so Space/Backspace don't hijack the host page
- player: audience mirrors full position (branch + fragment) via syncTo
- player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences
- core: harden extractScenes against null/non-object scene entries
- core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments
- core/lint: accept data-end/timeline-derived scene durations (match runtime)
- core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow
- studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(player): slideshow fullscreen + presenter-view rework
- fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen
API on the <hyperframes-slideshow> element, icon reflects state
- presenter console: live slide on top, speaker-notes panel below, with the nav
controls shown in-view; Present button hides once presenting (harness)
- audience (viewer) window: chrome reduced to a fullscreen-only control, no nav
- fix: audience / back() / backToMain() mirror stayed frozen on the first frame —
a bare paused seek does not repaint some compositions. resumeSlide now plays a
brief render-nudge (RENDER_NUDGE) past the target so the composition paints,
then onTime pauses at the hold
- refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework
buildPresenterLayout into the bottom notes panel
- example: airbnb-deck presenter-test.html harness (Present button + 'F')
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(player): slideshow no auto-progress + presenter slide fits/pins
- navigation jumps to a static frame instead of auto-playing the timeline:
playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than
sustaining playback, so slides hold until the user advances
- presenter view: pin the live slide to the top and confine the player to the
region above the notes panel, so the player CONTAINS the composition — the
full slide stays visible (letterboxed) at any width and re-fits on resize;
its bottom is no longer cut off by the notes panel
- tests: seek targets updated for the render-nudge offset
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons
Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): stop presenter nav buttons flickering / dropping clicks
The presenter elapsed clock called render() every second, which rebuilt the
entire chrome (innerHTML) including the nav buttons — they flickered and any
click landing mid-rebuild was lost. The 1s tick now updates only the elapsed
text node; the nav buttons are rebuilt only on actual navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version
Addresses review feedback on the split stack:
- CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav
buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once
per document). No inline event handlers → works under strict CSP.
- IDs: studio sequence/hotspot id generation used Date.now() (sub-ms
collision on rapid clicks) — now crypto.randomUUID().
- Versioning: stamp version on the persisted manifest island (preserving an
existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION
to the core schema so future schema changes can migrate older islands.
These live on the review-fixes tip (consistent with the stack's fixup-on-tip
model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b
(#1591/#1592), and ss-core (#1580).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(ci): fix format + fallow gates for slideshow stack
- .prettierignore: exclude generated demo compositions (registry/examples/**/*.html)
from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored
source. Was failing 'Format' repo-wide (pre-existing on main via #1584).
- .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and
the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones
(duplicates.ignore). File-level config, not inline comments — inline shifts line
numbers and breaks fallow's inherited-finding fingerprint (per existing rc note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): address PR review + CodeQL findings
- CodeQL #638 (parseSlideshow): complete the regex metachar escape in
slideshowIslandRegex (was missing backslash); add JSDoc on the factory +
lastIndex caveat (reviewer 5a/16).
- CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/
presenter-test.html — a generated test harness (postMessage w/o origin check,
proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island.
Regenerate locally via the scratchpad script when testing.
- Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes
by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of
[start,end], not 'strictly inside'.
IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow
global build). format/lint/fallow green.
* feat(cli): add 'present' command — serve a deck in presenter mode
hyperframes present [dir] starts a lightweight HTTP server, wraps the
composition in <hyperframes-slideshow> with its island inlined, and opens
the browser. A real HTTP origin is required for presenter mode: present()
opens the audience window via window.open(?mode=audience) and the two sync
over BroadcastChannel — neither works from file://.
- New utils/compositionServer.ts factors the server scaffolding shared with
'play' (resolve runtime/player/slideshow bundles, inject runtime, asset
content-types, bind to a free port); play.ts now uses it too.
- Errors clearly if the deck has no slideshow island.
- .fallowrc: exempt the play/present command entrypoints (validation + server
wiring) and the per-command startup/logging block from the complexity /
duplication gates.
Verified end-to-end against registry/examples/airbnb-deck: server serves the
wrapper + assets, the component binds and renders (counter 1 / 11).
* fix(cli): present renders the deck (player sizing + self-driving serve)
Two bugs caused a black slide area:
- The <hyperframes-player> had no positioning, so its iframe collapsed to
zero size — the (absolutely-positioned) chrome showed but the composition
didn't. Add position:absolute; inset:0 (matches demo.html).
- The composition was served with the engine runtime injected, which leaves
its timelines engine-paused (blank). Slideshow decks self-drive their own
timelines (like demo.html / the standalone harness), so serve them raw.
Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next
advances 1/11 -> 2/11 and slide 2 paints.
* fix(cli): present plays slideshow sound effects
The composition (in the player's sandboxed iframe) posts
{ type: 'hf-sfx', name } to the parent on nav, but the iframe is
autoplay-blocked — audio must play in the parent that owns the user gesture.
Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/
branch-enter/back, served from the deck's sfx/ under /composition/sfx/),
gesture-unlocked and mute-aware, in both presenter and audience windows.
Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment]
to the parent handler.
* feat(examples): softer mellow slideshow sfx for airbnb-deck
Replace the aggressive percussive pops with gentle sine-tone cues (warm
pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/
fragment/branch-enter/back. Much lighter; fragment is the most subtle.
* feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck
Replace the sine-tone cues with airy, designed sounds:
- advance: a soft whoosh (band-limited pink noise, bell-shaped swell)
- back: that whoosh reversed and darkened
- fragment: a light sparkle (staggered high chime blips)
- branch-enter: whoosh + a trailing sparkle (magical entry)
* feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck)
- Going backward a slide now plays the reverse whoosh (back), not advance —
the sfx logic detects nav direction by scene order instead of firing advance
for every scene change.
- branch-enter is now a more interesting magical cue: a faint whoosh + an
ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle.
Verified: next then prev fires [advance, fragment, back]; no page errors.
* fix(cli): harden present sfx handler + mute-hover affordance (R2 review)
Addresses Rames R2 items 19-21:
- 20: the present audio handler reintroduced the CodeQL classes removed with
presenter-test.html — add an origin check (same-origin composition iframe)
and an own-property guard so a 'name' like __proto__ can't resolve to and
mutate Object.prototype.
- 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype);
guard with Object.hasOwn.
- 19: the CSP hover rule erased the speaker button's muted color; add a
higher-specificity [data-hf-muted] [data-hf-mute]:hover override.
Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment
still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing).
* fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness
- 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its
midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the
saved fragment or slide.start. Added a dedicated test naming the heuristic.
- 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to
module consts) instead of re-rendering the whole chrome.
- 22: .prettierignore lists the specific generated demo compositions instead of
blanket registry/examples/**/*.html, so hand-authored example HTML still formats.
- presenter-test.html: a stray
|
||
|
|
7af3eb8f80 |
feat(player): slideshow controller + <hyperframes-slideshow> component (#1581)
DOM-free SlideshowController (discrete nav, fragment holds, branch stack) driving the existing player; <hyperframes-slideshow> web component with a unified mute+nav capsule (conditional prev/next), floating hotspot overlays, presenter mode (BroadcastChannel), keyboard/touch, and a scenes getter fed via the runtime message handler. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ff25058c5e |
fix(studio): resolve ffmpeg outside PATH so render doesn't 503 (#1536)
The render pre-flight check shells out to `which ffmpeg`, which only searches the server process's PATH. When Studio is launched from a GUI/Dock/launchd context that PATH lacks /opt/homebrew/bin, so `which` fails even when ffmpeg is installed — and POST /render returns 503 "FFmpeg not found". Fall back to probing well-known install dirs (Homebrew on Apple Silicon and Intel, plus system/Linux locations) when the PATH lookup fails. Also drop the [kf:static]/[kf:runtime] keyframe diagnostics that were spamming the Studio console in prod, and fix two unrelated CI breakages the branch inherited: a Windows-sensitive ffmpeg test (pin platform) and a stale player test mock missing onRuntimeReady. |
||
|
|
513819ee84 |
fix(player): reject non-finite composition dimensions from attributes and stage-size (#1205)
width/height attributes went through parseInt with no validation, so a typo like width="abc" reached scaleIframeToFit as NaN (invalid scale(NaN) transform) and width="0" as a division by zero — both blank the player with no signal. The stage-size message check had the sibling gap: `> 0` alone lets Infinity through, which scales the iframe to 0. Reuse the composition probe's readPositiveDimension guard for the attribute path (the probe path already rejected these) and add the same finite-check the adjacent timeline branch uses for stage-size. Mirrors the clampPlaybackRate hardening from #1120. Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> |
||
|
|
1ad158d2f0 | feat(runtime): apply color grading in preview and render | ||
|
|
a95e49dbda |
fix(core,player,studio): bound trimmed audio playback to the clip window (#1430)
* fix(player): bound the parent audio proxy to its clip window When iframe autoplay is blocked, audible playback is promoted to a parent-frame audio proxy. The proxy read the clip's data-start/data-duration once at adopt time and mirrorTime() only skipped (never paused) the element outside that window — so a trimmed/moved music clip kept playing the full source past its on-timeline end, even though the iframe element was correctly paused. Fix: the proxy keeps a reference to its source iframe element and re-reads data-start/data-duration each mirror tick (live trims/moves apply), pauses the proxy when the playhead leaves [start, start+duration), and resumes it when the playhead re-enters during parent-owned playback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core,studio): bound trimmed audio playback to the clip window Trimmed audio played to the source file's natural end instead of stopping at the clip edge, on every audio path: - WebAudio (the audible path in Studio): schedulePlayback now passes the clip's data-duration as the third start() arg, so the decoded buffer stops at the trimmed edge instead of running to the file end. - Runtime element gating: the duration resolver caps each clip by its own data-duration (min of source length, host window, authored duration), so a trimmed <audio>/<video> element pauses at its edge. Studio trim UX: - Resize live-patches the media-start/playback-start offset, so a start-edge drag trims into the source instead of only repositioning the clip. - AudioWaveform windows the rendered peaks to the trimmed slice so the waveform tracks the clip edges. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(player,core): gate proxy playback to the live clip window Review follow-ups on the parent-audio-proxy / WebAudio bound: - seekAll now re-reads live source bounds (_refreshEntryBounds) before gating, so a paused scrub right after a trim/move uses the current clip window instead of the adopt-time one. - playAll and clip adoption only start a proxy when the playhead is inside the clip's window (_playEntryIfActive), so bulk starts / promotion no longer blip audio for clips outside their window until the next tick. - The WebAudio buffer is now bounded by the host-composition window too (matching resolveDurationSeconds), so a sub-composition-nested clip stops at the same edge on the WebAudio and HTMLMedia paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> * fix(core,player): reschedule bounded WebAudio on rate change; guard NaN bounds A bounded WebAudio source's wall-clock length is baked into start()'s duration arg (in buffer-sample seconds) at its scheduling rate. Mutating playbackRate in place on a later rate change does not rescale that bound, so a trimmed clip ends early (fast) or late (slow). setRate now reports whether the rate changed and exposes hasBoundedActiveSources(); the runtime stopAll()+reschedules active clips at the new rate when any bounded source is live. The per-clip schedule loop is extracted to a shared closure so play() and the rate path agree. Also guard _refreshEntryBounds against a non-numeric duration attribute parsing to NaN, which would make every window check false and let the proxy play past its clip end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> --------- Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com> |
||
|
|
fbc3cdf2fd | fix(player): replace or clear the audio-src proxy instead of stacking (#1409) | ||
|
|
a037505176 |
fix(player): clean up controls on destroy (#1407)
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> |
||
|
|
5a0966dfeb |
fix(runtime,player): replay bridge state on iframe ready to repair race
The audio-locked attribute was correctly setting `muted = true` and posting `set-muted` to the iframe runtime, but on warm-cache reloads of claude.ai and inside the Claude desktop Electron client, the iframe finishes loading *after* the parent has already sent control messages — the iframe runtime's postMessage listener isn't installed yet, so the messages are silently dropped. Audio plays unmuted with no UI to recover. Confirmed via: - "First open" on claude.ai: cold cache, iframe slow → listener up before `set-muted` lands → audio muted ✅ - "Hard refresh" on claude.ai: warm cache, iframe fast → listener up after message arrives → message lost → audio plays ❌ - Claude desktop: Electron renderer consistently fast → race always loses → audio plays ❌ Fix: add a `{source: "hf-preview", type: "ready"}` event the runtime emits once `installRuntimeControlBridge` has registered the listener. The player listens for it and replays current bridge state (`set-muted`, `set-volume`, `set-playback-rate`). Pre-ready messages are now safe to send — they'll be replayed once the runtime can receive them. The replay is idempotent — re-asserting defaults is a no-op — so it's also safe across iframe reloads (new runtime instance emits ready again). Tests: 6 new (1 bridge: ready posted on install; 5 player: replays muted / volume / playback-rate / audio-locked-forced-mute / handles second ready / ignores ready from wrong source). Suites green: core 1387, player 137. Refs: - Investigation: heygen-com/hyperframes#1300 (UA-fallback attempt — unrelated to actual root cause) - claude.ai-web.log analysis revealed cross-origin iframe + race condition, not attribute stripping as originally hypothesized 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cc45b1fa33 |
feat(player): force audio lock on Claude desktop via UA fallback
The Claude desktop Electron client appears to strip the `audio-locked` custom-element attribute before it reaches the DOM, so chat-host audio still plays even though Claude web (which preserves the attribute) correctly mutes. Verified via DevTools: web renders `<hyperframes-player audio-locked>` and is silent; desktop omits the attribute and plays sound. Self-impose the same restriction when `navigator.userAgent` matches the Claude desktop UA (Claude/<ver> + Electron). Internally route everything through a new `_isAudioLocked()` helper — attribute OR host fallback — and apply the lock from `connectedCallback` since `attributeChangedCallback` never fires when the attribute is missing. The public `audioLocked` property still reflects only the attribute, so external consumers (e.g. pacific widget mirroring state) are unaffected by the safety net. Tests: 6 new (forces mute on Claude desktop UA, re-asserts on unmute, hides controls, no-op for regular browsers, no-op for non-Claude Electron apps, public property remains attribute-only). Player suite green: 132 tests. Refs: pacific #28773, experiment-framework #38809. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f39b5988cc | feat(player): add audio-locked attribute (force-mute + hide controls) (#1234) | ||
|
|
62475b7649 | fix(player): clamp playbackRate to runtime [0.1, 5] range (#1120) | ||
|
|
3a24aed9bc |
fix(studio): fit preview reset to composition dimensions (#1085)
* fix(studio): fit preview reset to composition dimensions * fix(core): keep runtime root resolution explicit * fix(studio): resume playback after keep-playing seek |
||
|
|
7e0a447325 |
refactor: drop unused exports detected by fallow auto-fix
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols fallow's reachability analysis identifies as unused. Keeps only the cases where the symbol is still referenced internally in its own file (so removing `export` doesn't surface a new oxlint `no-unused-vars` error). Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused exports), with no behavior change — each symbol is still defined and used exactly the same way within its file. Reverted ~20 files where fallow's auto-fix would have created cascading "declared but never used" lint errors — those are cases where the symbol isn't used at all, and properly cleaning them up means deleting the declaration, not just dropping `export`. Better to land that as a separate, narrower PR rather than mixing it into a mechanical de-export. Also reverted four false positives where fallow missed real consumers: - `captureCost.ts` (renderOrchestrator has two separate import blocks from the same module; fallow only saw the first) - `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses fallow's reachability missed) - `render.ts` (functions imported via `await import()` dynamic import, which fallow's static analysis doesn't follow) Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean, cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests). |
||
|
|
0150a0a1b4 |
fix(studio): canvas zoom improvements — zoom to cursor, reset button, border fix
- Zoom anchors to cursor position instead of always zooming toward center. The resolvePreviewWheelZoom function now accepts cursorX/cursorY (offset from viewport center) and uses the standard zoom-to-point formula to adjust pan so the content point under the cursor stays fixed. - Add visible "Reset" button (bottom-right) showing current zoom % when not at fit zoom. Driven by settledZoom state that updates after the 200ms settle debounce, so no re-renders during active zoom gestures. - Fix border-expands-inward bug: scaleIframeToFit in the player now uses offsetWidth/offsetHeight instead of getBoundingClientRect. The latter returns values inflated by ancestor CSS zoom, causing double-scaling that made the iframe appear smaller than its container. - Fix zoom HUD appearing during pan: split applyZoom (shows HUD) from applyPan (silent) so trackpad/middle-mouse panning no longer flashes the zoom percentage overlay. - Fix stale closure performance regression: replace stageSize in effect dependency arrays with stageSizeRef pattern. The old deps caused wheel and pointer handlers to re-register on every viewport resize. - Widen pan clamp range (Math.abs instead of Math.max(0,...)) so content can float within the viewport when zoomed below fit — required for zoom-to-cursor to work correctly at any zoom level. Closes #900 |
||
|
|
c003ef67e9 |
fix(player): pause parent audio proxy on seek to prevent stutter loop (#890)
## Summary - `seek()` only called `seekAll()` under parent audio ownership, leaving the `<audio>` proxy playing while the timeline froze at the new seek target. - The periodic `mirrorTime` drift-correction (`parent-media.ts`) would then yank `currentTime` back to the timeline position every ~80ms of accumulated drift, producing an audible stutter loop while the video frame stayed frozen. - Fix: make `seek()` symmetric with `pause()` — pause the parent proxy before seeking it. ## Repro 1. Use the player in an environment where the runtime posts `media-autoplay-blocked` (mobile / autoplay-restricted contexts), promoting audio ownership to `"parent"`. 2. Start playback with audio. 3. Click anywhere on the scrubber while playing. 4. Before: audio stutters in a short loop while the video frame is frozen. 5. After: audio cleanly pauses at the new seek position. ## Test plan - [x] Added regression test \`seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)\` in \`hyperframes-player.test.ts\`. - [x] \`pnpm --filter @hyperframes/player test\` — 110/110 pass. - [ ] Manual repro on a device where ownership flips to \`parent\`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
9abf65ae5e |
fix(player): correct playback rate for direct-timeline and audio-clock paths (#849)
## Summary - **Direct-timeline path** (GSAP compositions with `window.__timelines`): The player drives these via `DirectTimelineAdapter`, bypassing postMessage entirely. Rate changes sent `set-playback-rate` to the iframe but had no receiver — GSAP's `timeScale()` was never called. Fix: add optional `timeScale?` to `DirectTimelineAdapter` and call `this._directTimelineAdapter?.timeScale?.(rate)` in `attributeChangedCallback`. GSAP timelines expose `timeScale` natively, no composition changes required. - **Audio-clock path** (compositions with audio): Three bugs caused `TransportClock` to always run at 1x when an audio element or WebAudio context drove the clock: 1. `schedulePlayback` was called without the `playbackRate` arg (defaulted to 1). 2. `onSetPlaybackRate` and `player.setPlaybackRate` didn't call `webAudio.setRate()`. 3. `TransportClock.attachAudioSource` divided by `this._rate` instead of `el.playbackRate`, cancelling the rate multiplier. - Adds 2 regression tests to `clock.test.ts` covering the corrected audio-clock formula. ## Test plan - [ ] Unit tests: `bun run --cwd packages/core test` — 861/861 pass - [ ] Browser verification (Playwright headless, GSAP direct-timeline composition): - 1x speed → ratio 0.972 ✓ - 2x speed → ratio 1.965 ✓ - 0.5x speed → ratio 0.490 ✓ 🤖 Generated with [Claude Code](https://claude.com/claude-code) |
||
|
|
c08e8b2322 |
fix(player): drive composition ticks from widget-frame rAF via postMessage (#805)
Chromium throttles requestAnimationFrame in deeply nested cross-origin iframes. In Claude desktop (Electron), the composition iframe's own rAF loop stalls, so GSAP is never seeked and animation freezes even when TransportClock.isPlaying() is true. The correct fix is to drive ticks from the widget-frame rAF, which lives one level up and is not subject to the same throttling. When play() takes the runtime bridge path (no direct timeline adapter), the player now starts a parent-frame rAF loop that sends "tick" postMessages to the composition iframe on every frame. The runtime's control bridge handles "tick" by calling seekTimelineAndAdapters(clock.now()) if the clock is playing — identical to what transportTick does on each rAF, just driven from outside. The composition iframe's own rAF loop is unchanged and keeps running normally in standard browsers. Seeking GSAP twice per frame is idempotent, so there is no regression on claude.ai or any other non-throttled environment. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
91bdffffe6 |
fix(ci): scope LOC check to studio, split useTimelinePlayer + hyperframes-player under 500 LOC (#750)
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install. |
||
|
|
38efe168e2 |
refactor(studio): contexts, PropertyPanel split, duration fix, perf (#748)
* feat(studio): add manual DOM editing inspector (#466) * fix: stabilize studio preview and runtime sync * fix: pass selector through timeline thumbnails * feat: add studio timeline editing * fix: disambiguate timeline edit targets * fix: stop timeline auto-scroll in fit mode * feat: use percentage-based timeline zoom * fix: sync timeline playhead on zoom changes * fix: reset timeline scroll when returning to fit * feat(studio): add manual DOM editing inspector * docs: update studio manual dom editing guide * feat(studio): add image asset picker for fills * feat(studio): add inline image uploads for fills * fix(studio): use real file input for image fill uploads * fix(studio): restore toast plumbing after rebase * fix(studio): explain in-app upload limitation * fix(studio): reuse asset-tab upload pattern in fills * feat(studio): refine manual design inspector * fix(studio): polish manual design inspector * fix(studio): keep color picker in viewport * fix(studio): clarify color picker selection * docs: update manual DOM editing guide * fix(studio): keep gradient color picker open * fix(studio): scope text color to text layers * fix(studio): add agent fallback for immovable layers * fix(studio): address manual editing review feedback * fix(studio): make local font selection reliable * fix(studio): improve dom picking and thumbnails * fix(studio): copy absolute paths in agent prompts * fix(studio): prevent timeline track cutoff * fix: copy Studio agent prompts in Safari * fix(studio): hold canvas movement from inspector * feat(studio): add persistent undo redo (#537) Studio manual editing and timeline editing mutate project files directly, but those edits had no reliable undo/redo path. Before releasing manual editing, users need a way to recover from visual property changes, source-editor saves, timeline moves/resizes/deletes, and timeline asset drops. The history also needs to survive a page refresh. A refresh should not erase the only way back from a bad manual edit. - Adds a persistent per-project edit-history model for file snapshots. - Stores undo/redo stacks in IndexedDB so history survives Studio refreshes. - Records source editor saves, manual DOM edits, and timeline mutations. - Adds toolbar undo/redo buttons with standard keyboard shortcuts: `Cmd/Ctrl+Z`, `Cmd/Ctrl+Shift+Z`, and `Ctrl+Y`. - Validates current file hashes before applying undo/redo so external file changes do not silently overwrite newer content. - Keeps history available in memory if IndexedDB persistence fails during a session. - Adds focused unit coverage for the pure history model, storage adapter, controller/hook behavior, and project-file save helper. Studio previously treated every editor mutation as an immediate file write. Manual DOM editing, timeline updates, and source-editor saves each had separate write paths, so there was no common transaction boundary where Studio could capture the file contents before and after an edit. Undo/redo needed to sit above those write paths as a file-level transaction system: capture changed files before saving, write the new contents, persist the history entry by project, then apply undo/redo only when the current file content still matches the expected snapshot. - `bun --filter @hyperframes/studio test src/utils/editHistory.test.ts src/utils/editHistoryStorage.test.ts src/hooks/usePersistentEditHistory.test.ts src/utils/studioFileHistory.test.ts` -> 4 files pass, 15 tests pass - `bun --filter @hyperframes/studio test` -> 26 files pass, 289 tests pass - `bun --filter @hyperframes/studio typecheck` - `bunx oxlint packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` -> 0 warnings, 0 errors - `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/icons/SystemIcons.tsx packages/studio/src/hooks/usePersistentEditHistory.ts packages/studio/src/hooks/usePersistentEditHistory.test.ts packages/studio/src/utils/editHistory.ts packages/studio/src/utils/editHistory.test.ts packages/studio/src/utils/editHistoryStorage.ts packages/studio/src/utils/editHistoryStorage.test.ts packages/studio/src/utils/studioFileHistory.ts packages/studio/src/utils/studioFileHistory.test.ts` - `git diff --check` - `bun run --filter @hyperframes/core build:hyperframes-runtime` before commit hook, because the clean worktree needed the ignored runtime-inline artifact for typecheck - Lefthook pre-commit -> lint, format, typecheck pass - Lefthook commit-msg -> commitlint pass - Started Studio locally at `http://127.0.0.1:5190/#project/undo-redo-sample`. - Used `agent-browser` to select a preview element in the Inspector and change `#hero-card` from `left: 220px` to `left: 260px`. - Refreshed Studio and verified Undo stayed enabled. - Clicked Undo and verified the project file returned to `left: 220px`; clicked Redo and verified the inline `left: 260px` returned. - Used `agent-browser` to drag the `side-card` timeline clip, refreshed Studio, then verified Undo restored the previous timeline attributes and Redo reapplied the timeline move. - Recorded the tested undo/redo flow with `agent-browser`: `qa-artifacts/studio-undo-redo-2026-04-28/studio-undo-redo-flow.webm`. - Local screenshots and recordings are kept under `qa-artifacts/studio-undo-redo-2026-04-28/` and are intentionally not committed. - The scratch Studio project used for browser proof is local-only under `packages/studio/data/projects/undo-redo-sample/` and is intentionally not committed. - The PR intentionally excludes the earlier PRD/TDD planning notes under `docs/superpowers/`; those remain local-only per request. * fix: align Studio capture with preview (#595) Studio frame capture could fail for projects mounted outside the repo when the project id came from an encoded hash route. A project like `Notion Showcase` loaded as `#project/Notion%20Showcase`, but the capture URL encoded that already-encoded value again, producing `/api/projects/Notion%2520Showcase/...` and a 404. While validating the fix by seeking through the preview, capture also diverged from the visible player for nested compositions because the thumbnail route sought raw timelines instead of the same player seek path used by Studio preview. - Decodes project ids when reading Studio `#project/...` routes and centralizes project hash/API path construction. - Keeps API URLs encoded exactly once, including project names with spaces, literal `%`, reserved characters, and unicode. - Updates Studio thumbnail capture to prefer `window.__player.seek(t)` and only fall back to raw timeline seeking for standalone pages. - Preserves explicit `t=0` thumbnail requests instead of falling back to `0.5` seconds. - Adds preview-regression CI coverage for Studio routing, frame capture URL construction, thumbnail seeking, and core thumbnail seek parsing. Studio treated the hash route segment as the canonical project id even when the browser had already percent-encoded it. `buildFrameCaptureUrl` then encoded that string again, so a decoded project directory name and the capture API path no longer matched. The preview/capture mismatch was a separate seek-path issue: the visible Studio preview seeks through the HyperFrames player, which maps global time into nested composition time. The capture route bypassed that layer and paused all registered timelines at the same global time. The zero-second capture case came from parsing `t` with a truthiness fallback, so `parseFloat("0") || 0.5` became `0.5`. - `bun run --cwd packages/studio test -- vite.thumbnail.test.ts src/utils/projectRouting.test.ts src/utils/frameCapture.test.ts` - `bun run --cwd packages/core test -- src/studio-api/routes/thumbnail.test.ts` - `bunx oxfmt --check .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts` - `bunx oxlint .github/workflows/preview-regression.yml packages/studio/vite.thumbnail.ts packages/studio/vite.thumbnail.test.ts packages/studio/vite.config.ts packages/studio/src/utils/projectRouting.ts packages/studio/src/utils/projectRouting.test.ts packages/studio/src/utils/frameCapture.ts packages/studio/src/App.tsx packages/core/src/studio-api/routes/thumbnail.ts packages/core/src/studio-api/routes/thumbnail.test.ts` - `bun run --cwd packages/studio typecheck` - `bun run --cwd packages/core build:hyperframes-runtime` - `bun run --cwd packages/core typecheck` - `git diff --check` Pre-commit also reran lint, format, and typecheck successfully for the committed files. Using `agent-browser`, I mounted `/Users/miguel07code/Downloads/Notion Showcase` into Studio's project data and opened: ```text http://127.0.0.1:5197/#project/Notion%20Showcase ``` Before the fix, Capture requested `/api/projects/Notion%2520Showcase/thumbnail/index.html?...` and Studio showed `Capture failed`. After the fix, I sought the preview to `0s`, `2s`, `10s`, and `18s`, captured each frame, and compared the visible preview crop against the capture output. The capture URLs all used `Notion%20Showcase`, not `Notion%2520Showcase`, and no failure toast appeared. Mean pixel diffs for preview vs capture were: - `0s`: `0.0` - `2s`: `0.8641` - `10s`: `0.3496` - `18s`: `0.2309` The small non-zero diffs are raster/antialias-level differences after resizing the capture to the preview crop dimensions. - Browser screenshots, comparison sheets, network logs, and the `agent-browser` recording are local-only under `qa-artifacts/capture-button/` and are not committed. - The local Notion Showcase project mount is an ignored symlink under `packages/studio/data/projects/` and is not committed. - Thumbnail cache versions were bumped so stale captures generated with the old seek behavior are not reused. * feat: persist studio manual edits via manifest * fix(studio): stabilize manual edit manifest rendering * fix(studio): allow master canvas layer selection * fix(studio): scale master edits in source coordinates * fix(studio): reapply manual edits during playback * fix(studio): keep rotation edit base stable * feat(studio): highlight hovered canvas target * fix(studio): drag hovered canvas targets immediately * fix(studio): rotate manual edits around center * fix(studio): keep rotate handle aligned while dragging * fix(studio): allow small rotation adjustments * fix(studio): match rotate handle size to resize handle * fix(studio): connect rotate handle line to selection * feat(studio): reset selected manual edits * fix(studio): route inspector geometry through manual edits * feat: add studio group repositioning * fix: preserve studio group selections * fix: seed additive studio selection groups * fix: select studio groups on pointerdown * fix: harden studio group overlay events * fix: address studio manual edit review feedback * fix: apply nested manual edits in drilled previews * fix: commit drag offsets from gesture math * fix: persist manual preview edits on refresh * fix: harden manual edit refresh apply * fix: share manual edit render runtime * chore: release v0.5.0-alpha.15 * feat(core): add studio animation preview APIs * feat(studio): add alpha editor layer inspector * chore: release v0.6.0-alpha.1 * feat(studio): enable inspector panels by default * fix(studio): keep motion panel opt-in * chore: release v0.6.0-alpha.2 * feat: auto-open timeline clip layers * feat: show composition loading in studio * feat: disable Studio timeline while composition loads * chore: ignore .claude directory * chore: release v0.6.0-alpha.3 * feat(studio): simplify inspector selection ux * fix(studio): keep notion preview playback moving * fix(studio): handle raster inspector clicks * fix(studio): stale selection, rotation control, design panel polish Fixes and improvements based on power-user testing feedback: 1. Fix stale selection after style edits — handleDomStyleCommit now calls refreshDomEditSelectionFromPreview after persisting, matching every other commit handler. Without this, the PropertyPanel showed frozen computedStyles after color/radius/shadow edits, making it look like editing "didn't work." Also adds error handling around the persist call. 2. Add rotation field to the Design panel Layout section — reads the current rotation angle from the manual edit manifest and commits via the existing handleDomRotationCommit handler. 3. Enable motion panel by default — STUDIO_MOTION_PANEL_ENABLED now defaults to true so the Motion tab is discoverable without env vars. 4. Color controls only when element has color — fill color section now only shows when the element has an explicit non-transparent background-color. Text color shows only when the element has a color style. Prevents showing color pickers on elements where color edits have no visible effect. 5. Exclude canvas from selection — added "canvas" to DOM_LAYER_IGNORED_TAGS so canvas elements are not selectable in the preview or listed in the layer panel. 6. Multi-selection feedback — shows "N elements selected" with guidance instead of the generic empty state when multiple elements are selected. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): prevent browser launch timeout from crashing dev server The shared Puppeteer browser pool in getSharedBrowser() could throw a 30s TimeoutError during launch. This error propagated as an uncaught rejection and killed the vite process, even though generateThumbnail had its own try/catch — the browser launch promise rejected outside that scope. Now getSharedBrowser itself catches launch failures and returns null, so thumbnails degrade gracefully instead of crashing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): revert motion panel default to false Motion panel stays opt-in via env var per product direction. Only the Design panel is enabled by default. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): prevent read-only property crash in manual edit wrappers The seek/play/applyAfter wrapper functions in manualEdits.ts crashed with "Cannot set property X which has only a getter" when the player or timeline objects define seek/play as getter-only properties. This prevented ALL manual edits (position, rotation, size) from persisting to disk — the error thrown during applyCurrentStudioManualEditsToPreview aborted the save queue. Wrapped all three property assignments in try/catch so wrapping gracefully degrades when the target object is non-configurable. Verified: position edit (X=42px) now persists to .hyperframes/studio-manual-edits.json and survives page refresh. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: alpha preview e2e fixes — exports, init templates, EPIPE crash Three bugs found via automated e2e testing of the v0.6.0-alpha preview: 1. core: add missing package.json export specifiers for studio-api/manual-edits-render-script and studio-api/studio-motion-render-script — the alpha.3 npm publish failed because the studio build could not resolve these sub-paths. 2. cli: fix init --example creating empty projects — tsup leaves empty template directories in dist/ during the build, causing existsSync(templateDir) to return true and skip the remote fetch fallback. Now checks for index.html inside the dir instead. 3. engine: fix unhandled EPIPE crash in streaming encoder — ffmpeg stdin/stdout had no error handlers, so a write after the ffmpeg process exits throws an uncaught error that crashes the process. Verified with 8 consecutive e2e iterations (424 test runs, 0 flaky). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): thumbnail crash, feature defaults, multi-select UX, fps selector Power-user audit fixes for the alpha studio: - vite.config.ts: wrap thumbnail generation in try/catch so Puppeteer TimeoutError doesn't crash the entire vite dev server as an uncaught rejection. Close the page on error to prevent browser session leaks. - manualEditingAvailability.ts: enable motion panel and manual canvas drag editing by default (were both false, undiscoverable without knowing the env vars). - PropertyPanel.tsx: show "N elements selected" feedback when multiple elements are selected instead of the generic "Select an element" empty state. - RenderQueue.tsx + App.tsx: add FPS selector (24/30/60) to the render export bar instead of hardcoding 30fps. Pass the user's choice through to startRender. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.4 * fix(runtime): update clock duration when root timeline is late-bound Compositions with external sub-compositions (like apple-presentation with 7 slides) load child compositions via fetch(). The root GSAP timeline is only bound after all external compositions finish loading, but the TransportClock duration was only set during initial setup. When bindRootTimelineIfAvailable runs after the external compositions load, it captures the root timeline but never updates the clock. player.getDuration() continues returning 0, so the player's probe interval never fires the 'ready' event, and the Studio shows "Loading composition" indefinitely. Now bindRootTimelineIfAvailable updates clock.setDuration when the root timeline is late-bound. Guarded with try/catch for the early call site where clock is not yet initialized (temporal dead zone). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): block element selection while composition is loading Prevent users from selecting elements in the preview while the composition is still loading (showing "Loading composition" overlay). Selection and hover highlighting are suppressed until the player fires the ready event. Also reverts motion panel and manual drag editing defaults to false — these were accidentally set to true during the PR #693 merge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.5 * chore: release v0.6.0-alpha.6 * fix(runtime): remove per-tick timeline.pause() that causes audio stutter The seekRuntimeTimeline helper added timeline.pause() before every totalTime() seek. During transport-driven playback, this runs 60 times per second, causing GSAP to cascade pause events to media elements on every frame. The result: audio plays/stops/plays/stops in a stutter pattern. The captured root timeline is already paused once in player.play() — the TransportClock drives it via totalTime(t) which keeps it paused. The extra per-tick pause() was redundant for the root timeline but actively harmful for media sync. Fix: restore the original inline seek for the captured timeline (totalTime without pause), keep seekRuntimeTimeline with pause() only for standalone child timelines where explicit pause control is needed. Also fixes rebase artifact: missing PropertyPanel props in App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.7 * fix(studio): restore text field handlers lost in rebase Restores handleDomAddTextField and handleDomRemoveTextField that were dropped when resolving App.tsx conflicts during the main→next rebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: release v0.6.0-alpha.8 * fix(runtime): comprehensive audio stutter fix Three changes that together caused audio play/stop/play/stop stutter during transport-driven playback: 1. seekRuntimeTimeline called timeline.pause() before every totalTime() seek, 60x per second. GSAP cascades pause to media elements on every frame. Fix: restore original inline seek for the captured timeline (totalTime without pause). The timeline is already paused once in player.play(). seekRuntimeTimeline with pause() remains only for standalone child timelines. 2. player.play() removed the !tl guard, allowing play without a captured timeline. But getSafeTimelineDurationSeconds(null) returns 0, so the clock has no duration → immediately reaches end → stops → restarts. Fix: when no timeline provides duration, fall back to the root composition element's data-duration attribute. 3. Audio source attachment added networkState guard that could cause the clock to flicker between audio-source and monotonic timing on transient media states. Fix: keep !rawEl.error guard (prevents errored audio from freezing the clock) but drop the networkState check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(runtime): skip drift corrections on playing video elements Seeking a playing video resets the browser's decoder pipeline, causing a ~150ms freeze while it re-buffers. During that freeze the monotonic clock advances, drift grows, and strict sync fires another seek — creating a perpetual stutter loop (176 seek events / 8s observed on the apple-presentation composition). Skip strict and force drift corrections for playing video elements; only hard sync (>0.5s catastrophic drift) warrants the decoder-reset cost. Audio elements are unaffected and retain the full correction tiers. Also propagate the asset-loading overlay state to the timeline so controls are disabled during "Preparing preview assets", matching the existing behavior for the initial composition loading overlay. * chore: release v0.6.0-alpha.9 * feat(studio): consolidate keyboard shortcuts into single handler Move all window-level keyboard shortcuts from 4 separate files into one `handleAppKeyDown` listener in App.tsx: - Shift+T: toggle timeline (was App.tsx, separate useMountEffect) - Cmd/Ctrl+Z: undo (was App.tsx, separate useEffect) - Cmd/Ctrl+Shift+Z: redo (was App.tsx, separate useEffect) - Cmd/Ctrl+1: sidebar Compositions tab (was LeftSidebar.tsx) - Cmd/Ctrl+2: sidebar Assets tab (was LeftSidebar.tsx) - Delete/Backspace: remove selected element (was Timeline.tsx) LeftSidebar exposes a ref handle for tab switching. Timeline watches selectedElement becoming null to clean up popover/range UI state. History hotkey kept as named function for iframe forwarding. Playback shortcuts (Space, J/K/L, arrows) and caption nudge remain in their component hooks — tightly coupled to component state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): sidebar tab overflow + hot-reload double-refresh 1. Sidebar tabs: use equal 1fr columns, shorter "Comps" label, truncate on overflow, tighter padding. Fixes tabs clipping outside the rounded pill at narrow sidebar widths. 2. Hot reload: set domEditSaveTimestampRef before every save-then-refresh path (source editor, timeline move/resize/delete, asset drop). The file-change watcher already checks this timestamp and suppresses echoed events — but source editor saves and timeline operations weren't setting it, causing a double refreshKey increment that could leave the player in a non-playable state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): delete key removes preview-selected elements The consolidated keyboard handler only checked selectedElementId (timeline clips). When a user selected a child element in the preview via the inspector, selectedElementId was null because the element didn't correspond to a top-level timeline clip, so Delete/Backspace did nothing. Add handleDomEditElementDelete that removes the element referenced by the current domEditSelection via the remove-element mutation API. The Delete key handler now falls through from timeline selection to DOM edit selection. * fix(studio): remove unused deleteInFlightRef from Timeline Leftover from moving Delete handling to the consolidated keyboard handler in App.tsx. Also suppress pre-existing exhaustive-deps warning on the intentional every-render selection-change watcher. * fix(studio): forward all keyboard shortcuts to preview iframe The consolidated handleAppKeyDown was only added to the parent window. When focus was inside the preview iframe (after clicking an element), keydown events didn't reach the parent, so Delete and other shortcuts didn't fire. Replace the per-function iframe forwarding (handleTimelineToggleHotkey only) with the full app-level handler via a ref-stable wrapper. All app shortcuts (Delete, Undo/Redo, Shift+T, Cmd+1/2) now work from within the preview iframe. * fix(core): search inside <template> content when removing elements linkedom's document.querySelectorAll does not traverse <template> content. Elements in template-based compositions (like .title-word, .bullet-text) were invisible to the removal logic, so delete returned changed: false and the element survived the reload. Fall back to template.querySelectorAll when the document-level query returns no matches. Uses template.querySelectorAll directly (not template.content.querySelectorAll) because removing from the content DocumentFragment doesn't update the serialized output. * fix(studio): suppress loading overlay on hot-reload Only show the composition loading overlay on the first iframe load. Hot-reloads (source editor save, timeline edits, element delete) no longer flash the full-screen loading state. * fix(studio): reorder design panel, fix stroke height, rename Blending - Move Text section to the top of the panel (before Layout) - Remove Selection Colors section - Rename "Blending" to "Transparency" - Fix stroke Width/Style height mismatch by making SelectField use inline label layout matching MetricField * fix(studio): prevent panel scroll when wheel-adjusting metric inputs React registers onWheel passively, so preventDefault had no effect on the parent scroll container. Replace with a native wheel listener (passive: false) that blocks both default scroll and propagation. * chore: release v0.6.0-alpha.10 * chore: release v0.6.0-alpha.11 * fix(studio): clean next alpha inspector artifacts * chore: release v0.6.0-alpha.12 * fix(studio,player,core): eliminate double audio and manifest polling loop (#722) Three bugs that compound in Studio preview: 1. **Double audio on pause/resume**: syncRuntimeMedia played audio through the HTML <audio> element while WebAudioTransport simultaneously played the same source through AudioBufferSourceNode. Fixed by passing webAudio.isActive() as outputMuted so HTML elements stay muted when Web Audio owns playback. Also removed the priorMuted restore in stopAll() which raced with the next play cycle. 2. **Manifest polling loop**: applyStudioManualEditsToPreview and applyStudioMotionToPreview unconditionally fetched from disk on every call, even without forceFromDisk. The runtime posts state messages every frame via postMessage, triggering React re-renders that re-invoked these functions ~60x/second. Fixed by returning early when no disk read is requested, and using refs instead of callbacks in useEffect deps. 3. **Parent proxy double-play**: the player web component created parent-frame audio proxies even when the runtime bridge was available, causing two audio sources on autoplay-blocked promotion. Fixed by skipping proxy creation when _hasRuntimeBridge returns true, and synchronously muting iframe media on promotion to close the async race window. Also fixes pre-existing ResolutionPreset type missing square variants. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): improve font picker and text property controls (#736) - Line height and letter-spacing: convert from free-text to select with presets - Font style: remove oblique (browser falls back to italic), keep normal/italic - Font weight: detect available weights via document.fonts.check(), add labels - Font source: local fonts matching Google catalog tagged as Google - Font list: balanced per-source caps prevent any source from being cut off - Sort order: Google fonts rank before Local so curated fonts appear first Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): inspector visibility, undo/redo blinking, and preview caching Inspector picks invisible elements when an ancestor has GSAP-set opacity: 0 because CSS opacity is not inherited — getComputedStyle on the child still returns 1. Walk the ancestor chain in the picker, domEditing, and overlay visibility checks to catch this. Also: - Containers with all-invisible children are no longer selectable - Selection/hover overlay hides during playback and while loading - Undo/redo no longer double-refreshes (echo suppression for all file writes) - Undo/redo reloads iframe in-place instead of recreating the Player, preserving shader transition cache - Preview routes return ETag + Cache-Control headers; composition HTML uses project signature for conditional 304, binary assets use mtime+size - Loading overlay deferred 400ms so cached loads never flash it * fix(studio): remove timeline inspector buttons, enable manual dragging Remove the eye icon (inspector) and image icon (thumbnail toggle) from timeline clips. The timeline layer inspector feature and all supporting code is removed. Enable manual dragging in the preview by default. Add scrub-to-drag on X/Y/W/H fields in the design panel. Hide the Radius section when the element has no visible background. Fix pre-existing ResolutionPreset type for square presets. * chore: release v0.6.0-alpha.13 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): add rotation field, inline element drag, fix manifest load regression (#743) - Add rotation (R) field to geometry row (X, Y, W, H, R) in property panel. Goes through manifest via handleDomRotationCommit, resettable with Reset Edits. - Auto-promote display:inline elements to inline-block when dragged so translate works on inline spans. - Fix regression from polling fix: iframe load now passes readFromDiskFirst to load manifest from disk, so Reset Edits finds existing entries. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(studio): decompose App.tsx monolith (4297 → 567 lines) (#741) * refactor(studio): decompose App.tsx from 4297 to 567 lines Break the monolithic StudioApp component into focused modules: Hooks (12 new): - usePanelLayout: resizable/collapsible panel state - useFileManager: file tree, CRUD, uploads, derived lists - useManifestPersistence: manual edit + motion manifest save queue - useTimelineEditing: clip move/resize/delete/drop handlers - useDomEditSession: DOM selection, style/text commits, preview interaction - useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync - useCaptionDetection: auto-detect caption compositions - useRenderClipContent: timeline clip thumbnail rendering - useConsoleErrorCapture: preview iframe console error capture - useFrameCapture: frame capture download flow - useLintModal: lint execution and modal state - useCompositionDimensions: stage-size message listener Components (6 new): - AskAgentModal: agent prompt modal - StudioHeader: toolbar with undo/redo, capture, inspector toggle - StudioLeftSidebar: file tree + code editor (handles collapsed state) - StudioPreviewArea: NLELayout + overlays + caption timeline - StudioRightPanel: Design/Motion/Renders tab panel - TimelineToolbar: zoom controls + timeline toggle Utilities (4 new): - studioHelpers: types, path helpers, DOM utilities - studioPreviewHelpers: preview pointer/player interaction - domEditHelpers: selection group algebra - studioFontHelpers: font injection + @font-face management Also removes dead timeline layer inspector code (eye icon, thumbnail toggle, layer panel) that was disabled behind a feature flag. * feat(studio): add Layer (z-index) field to design panel Adds a scrub-enabled "Layer" field below the W/H inputs in the Layout section. Available for all elements regardless of style editing capability since z-index is fundamental to composition stacking order. * docs: architecture spec for studio domain contexts, hook split, and file-size lint * docs: implementation plan for studio contexts, hook split, and file-size lint * refactor(studio): consolidate duplicate helpers in useDomEditSession Remove ~370 lines of helper functions that were copied into the hook instead of imported. All removed functions already exist in the canonical utility files (studioHelpers, studioFontHelpers, studioPreviewHelpers, domEditHelpers). Also removes the duplicate local type definitions for RightPanelTab, AgentModalAnchorPoint, and PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl, importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport). Temporarily excludes useDomEditSession.ts from the 500 LOC file-size check until Tasks 3-5 split it into focused hooks. * refactor(studio): extract useDomSelection from useDomEditSession * refactor(studio): extract useAskAgentModal from useDomEditSession * refactor(studio): extract usePreviewInteraction from useDomEditSession * refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator Split the 897-line useDomEditSession into focused hooks: - useDomEditCommits (439 LOC): manifest commits (path offset, box size, rotation, manual edits reset, motion), persist operations, element delete, font asset resolution - useDomEditTextCommits (329 LOC): style/text/text-field commits - useDomEditSession (339 LOC): thin orchestrator wiring selection, agent modal, preview interaction, and commit hooks All files now under 500 LOC limit. Removed the temporary lefthook filesize exclusion for useDomEditSession. * feat(studio): add 4 domain contexts (PanelLayout, FileManager, DomEdit, Studio) Create context providers that wrap hook return values for prop-drilling elimination. Each context destructures and reconstructs the value inside useMemo so exhaustive-deps is satisfied and re-renders are minimized. Not yet wired into App.tsx — that comes in a follow-up. * refactor(studio): wire domain contexts, eliminate prop drilling in 4 components Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar, StudioPreviewArea, and StudioRightPanel to consume contexts instead of props. Prop counts reduced: - StudioHeader: 13 -> 6 - StudioLeftSidebar: 19 -> 4 - StudioPreviewArea: 37 -> 11 - StudioRightPanel: 39 -> 3 Net: -118 lines, 108 props removed from call sites. * chore: upgrade to React 19 Upgrade react and react-dom from 18.3 to 19.2.6 across the workspace. Add resolutions/overrides in root package.json to prevent peer dependency pins (e.g. @phosphor-icons/react) from pulling React 18. Regenerate bun.lock. This enables the React 19 context syntax (<Context value={...}>) used by the new domain contexts. * fix(studio): refresh preview after z-index change so stacking updates visually * fix(studio): remove duplicate duration override causing oscillation The timeline message handler set the duration twice: once via processTimelineMessage and once via a raw durationInFrames override. When drilled into a sub-composition, these could disagree, causing the duration to oscillate after element deletion. * fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs Two changes to fix duration oscillation after deleting a timeline clip: 1. Replace setRefreshKey (full Player remount) with in-place iframe.contentWindow.location.reload() after deleting a clip. The full remount triggered a chaotic re-probing cycle with multiple duration sources (adapter, manifest, postMessage) fighting each other, causing the timeline to oscillate between durations. In-place reload preserves the Player web component and its state. 2. Remove window.confirm dialogs from both timeline clip delete and DOM element delete. Undo is available so the confirmation adds friction without value. * chore: gitignore docs/superpowers * feat(studio): add favicon * perf(studio): skip no-op state updates in timeline sync syncTimelineElements was called 60+ times per page load, each time triggering setElements/setDuration/setTimelineReady even when nothing changed. This caused massive re-render churn and memory usage. Add early-return guards to skip updates when values haven't changed. Also fixes the duration oscillation after element delete. * refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit. Split into cohesive modules by responsibility: - propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants - propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField, SliderControl, SegmentedControl, SelectField, Section - propertyPanelColor.tsx (371) — ColorField, ColorSlider - propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers - propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers - propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls - propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill) - PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers All re-exports from PropertyPanel.tsx preserved for backwards compatibility. No behavioral changes — pure structural split. * fix(studio): use in-place iframe reload for all timeline operations Replace setRefreshKey with in-place iframe reload for move, resize, and asset drop — matching delete which was already fixed. Prevents the Player remount probe cycle that causes duration oscillation. * perf(studio): replace 5s polling loop with event-driven adapter init The Player's onIframeLoad used a setInterval polling loop (25 attempts × 200ms = 5 seconds) to detect when the runtime's __player/__timeline globals appeared. Each poll that missed triggered wasted work, and multiple duration sources fighting during the probe cycle caused oscillation bugs. Replace with event-driven initialization: 1. Fast path: try initializeAdapter() immediately (works for in-place reloads where the adapter is already present) 2. If not ready, listen for the runtime's "state"/"timeline" postMessage signals and initialize on the first one 3. Single 5s timeout as safety net (replaces 25 interval ticks) This eliminates the polling overhead, reduces setDuration/setElements calls to exactly 1 per load, and makes the Player responsive within one frame of the runtime being ready instead of up to 200ms later. * fix(studio): prevent duration oscillation after element delete Two fixes for the duration display oscillating between sub-composition and master durations after deleting an element in the preview: 1. Clear store elements before iframe reload in handleDomEditElementDelete. Without this, stale pre-delete elements remain in the store and cause mergeTimelineElementsPreservingDowngrades to alternate between REPLACE and PRESERVE modes as the element count fluctuates. 2. Add 500ms cooldown on enrichMissingCompositions after timeline messages. The "state" handler was calling enrichMissingCompositions every ~80ms, which added extra elements from GSAP timelines. These fought with the authoritative element list from "timeline" messages (~333ms), creating a feedback loop where element count oscillated and triggered alternating merge strategies with different durations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): single reloadPreview as source of truth for preview refresh Create reloadPreview() in App.tsx that encapsulates the correct behavior (in-place iframe reload with setRefreshKey fallback). Pass it as the sole refresh mechanism to hooks, removing direct setRefreshKey access from useTimelineEditing and useDomEditCommits. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(studio): decompose App.tsx from 4297 to 567 lines Break the monolithic StudioApp component into focused modules: Hooks (12 new): - usePanelLayout: resizable/collapsible panel state - useFileManager: file tree, CRUD, uploads, derived lists - useManifestPersistence: manual edit + motion manifest save queue - useTimelineEditing: clip move/resize/delete/drop handlers - useDomEditSession: DOM selection, style/text commits, preview interaction - useAppHotkeys: keyboard shortcuts, undo/redo, iframe hotkey sync - useCaptionDetection: auto-detect caption compositions - useRenderClipContent: timeline clip thumbnail rendering - useConsoleErrorCapture: preview iframe console error capture - useFrameCapture: frame capture download flow - useLintModal: lint execution and modal state - useCompositionDimensions: stage-size message listener Components (6 new): - AskAgentModal: agent prompt modal - StudioHeader: toolbar with undo/redo, capture, inspector toggle - StudioLeftSidebar: file tree + code editor (handles collapsed state) - StudioPreviewArea: NLELayout + overlays + caption timeline - StudioRightPanel: Design/Motion/Renders tab panel - TimelineToolbar: zoom controls + timeline toggle Utilities (4 new): - studioHelpers: types, path helpers, DOM utilities - studioPreviewHelpers: preview pointer/player interaction - domEditHelpers: selection group algebra - studioFontHelpers: font injection + @font-face management Also removes dead timeline layer inspector code (eye icon, thumbnail toggle, layer panel) that was disabled behind a feature flag. * docs: architecture spec for studio domain contexts, hook split, and file-size lint * docs: implementation plan for studio contexts, hook split, and file-size lint * refactor(studio): consolidate duplicate helpers in useDomEditSession Remove ~370 lines of helper functions that were copied into the hook instead of imported. All removed functions already exist in the canonical utility files (studioHelpers, studioFontHelpers, studioPreviewHelpers, domEditHelpers). Also removes the duplicate local type definitions for RightPanelTab, AgentModalAnchorPoint, and PreviewLocalPointer, and drops now-unused imports (googleFontStylesheetUrl, importedFontFaceCss, resolveVisualDomEditSelectionTarget, DomEditViewport). Temporarily excludes useDomEditSession.ts from the 500 LOC file-size check until Tasks 3-5 split it into focused hooks. * refactor(studio): extract useDomSelection from useDomEditSession * refactor(studio): extract useAskAgentModal from useDomEditSession * refactor(studio): extract usePreviewInteraction from useDomEditSession * refactor(studio): extract useDomEditCommits, useDomEditSession now thin orchestrator Split the 897-line useDomEditSession into focused hooks: - useDomEditCommits (439 LOC): manifest commits (path offset, box size, rotation, manual edits reset, motion), persist operations, element delete, font asset resolution - useDomEditTextCommits (329 LOC): style/text/text-field commits - useDomEditSession (339 LOC): thin orchestrator wiring selection, agent modal, preview interaction, and commit hooks All files now under 500 LOC limit. Removed the temporary lefthook filesize exclusion for useDomEditSession. * refactor(studio): wire domain contexts, eliminate prop drilling in 4 components Wire StudioProvider, PanelLayoutProvider, FileManagerProvider, and DomEditProvider in App.tsx. Migrate StudioHeader, StudioLeftSidebar, StudioPreviewArea, and StudioRightPanel to consume contexts instead of props. Prop counts reduced: - StudioHeader: 13 -> 6 - StudioLeftSidebar: 19 -> 4 - StudioPreviewArea: 37 -> 11 - StudioRightPanel: 39 -> 3 Net: -118 lines, 108 props removed from call sites. * fix(studio): refresh preview after z-index change so stacking updates visually * fix(studio): remove duplicate duration override causing oscillation The timeline message handler set the duration twice: once via processTimelineMessage and once via a raw durationInFrames override. When drilled into a sub-composition, these could disagree, causing the duration to oscillate after element deletion. * fix(studio): use in-place iframe reload after clip delete, remove confirm dialogs Two changes to fix duration oscillation after deleting a timeline clip: 1. Replace setRefreshKey (full Player remount) with in-place iframe.contentWindow.location.reload() after deleting a clip. The full remount triggered a chaotic re-probing cycle with multiple duration sources (adapter, manifest, postMessage) fighting each other, causing the timeline to oscillate between durations. In-place reload preserves the Player web component and its state. 2. Remove window.confirm dialogs from both timeline clip delete and DOM element delete. Undo is available so the confirmation adds friction without value. * chore: gitignore docs/superpowers * perf(studio): skip no-op state updates in timeline sync syncTimelineElements was called 60+ times per page load, each time triggering setElements/setDuration/setTimelineReady even when nothing changed. This caused massive re-render churn and memory usage. Add early-return guards to skip updates when values haven't changed. Also fixes the duration oscillation after element delete. * refactor(studio): split PropertyPanel.tsx (3126 LOC) into 8 focused modules The monolithic PropertyPanel.tsx exceeded the 500 LOC filesize limit. Split into cohesive modules by responsibility: - propertyPanelHelpers.ts (401) — pure utility functions, shared types/constants - propertyPanelPrimitives.tsx (357) — CommitField, MetricField, DetailField, SliderControl, SegmentedControl, SelectField, Section - propertyPanelColor.tsx (371) — ColorField, ColorSlider - propertyPanelFill.tsx (421) — ImageFillField, GradientField, asset path helpers - propertyPanelFont.tsx (455) — FontFamilyField + font catalog helpers - propertyPanelSections.tsx (453) — TextSection, TextFieldEditor, text controls - propertyPanelStyleSections.tsx (411) — StyleSections (stroke, effects, clip, fill) - PropertyPanel.tsx (347) — main component, LayerTree, re-exports for consumers All re-exports from PropertyPanel.tsx preserved for backwards compatibility. No behavioral changes — pure structural split. * fix(studio): use in-place iframe reload for all timeline operations Replace setRefreshKey with in-place iframe reload for move, resize, and asset drop — matching delete which was already fixed. Prevents the Player remount probe cycle that causes duration oscillation. * perf(studio): replace 5s polling loop with event-driven adapter init The Player's onIframeLoad used a setInterval polling loop (25 attempts × 200ms = 5 seconds) to detect when the runtime's __player/__timeline globals appeared. Each poll that missed triggered wasted work, and multiple duration sources fighting during the probe cycle caused oscillation bugs. Replace with event-driven initialization: 1. Fast path: try initializeAdapter() immediately (works for in-place reloads where the adapter is already present) 2. If not ready, listen for the runtime's "state"/"timeline" postMessage signals and initialize on the first one 3. Single 5s timeout as safety net (replaces 25 interval ticks) This eliminates the polling overhead, reduces setDuration/setElements calls to exactly 1 per load, and makes the Player responsive within one frame of the runtime being ready instead of up to 200ms later. * fix(studio): prevent duration oscillation after element delete Two fixes for the duration display oscillating between sub-composition and master durations after deleting an element in the preview: 1. Clear store elements before iframe reload in handleDomEditElementDelete. Without this, stale pre-delete elements remain in the store and cause mergeTimelineElementsPreservingDowngrades to alternate between REPLACE and PRESERVE modes as the element count fluctuates. 2. Add 500ms cooldown on enrichMissingCompositions after timeline messages. The "state" handler was calling enrichMissingCompositions every ~80ms, which added extra elements from GSAP timelines. These fought with the authoritative element list from "timeline" messages (~333ms), creating a feedback loop where element count oscillated and triggered alternating merge strategies with different durations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(studio): single reloadPreview as source of truth for preview refresh Create reloadPreview() in App.tsx that encapsulates the correct behavior (in-place iframe reload with setRefreshKey fallback). Pass it as the sole refresh mechanism to hooks, removing direct setRefreshKey access from useTimelineEditing and useDomEditCommits. * fix: resolve lint errors from rebase (unused imports, duplicate declarations) * fix: prefix unused probeResult variable * fix: restore renderOrchestrator.ts from origin/next (rebase conflict artifact) * fix: resolve rebase conflicts by using main's producer and next's studio/player * fix: restore rebase-conflicted files from origin/next * fix: use 'load' instead of 'networkidle0' for Puppeteer waitUntil (type compatibility) * fix: restore webAudioTransport.ts from main (test compatibility) --------- Co-authored-by: Vance Ingalls <vance@heygen.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |