mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
1524f4e57df8061ae020b7fcf086a7c368fd4d6b
31
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
16942c0c12 | fix(player): own connection and media resources | ||
|
|
dcefdd98ca | fix(player): version runtime protocol | ||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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) | ||
|
|
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) |
||
|
|
35eab94e69 |
fix(core): parent-frame proxy bypass, data-preload-eager opt-out, configurable threshold
- Player: _adoptIframeMedia now skips media with preload="metadata" or "none", preventing parent-frame proxies from bypassing the preloader. MutationObserver extended to watch preload attribute changes so proxies are created just-in-time when the preloader promotes a clip. - init.ts: lazy-mode demotion loop skips elements with data-preload-eager, letting power users keep specific clips eagerly buffered. - mediaPreloader: reads window.__HF_LAZY_PRELOAD_THRESHOLD as an override, falling back to the default 6. |
||
|
|
dd375e2784 |
fix(player): clamp scrubber progress when postMessage frame exceeds duration (#700)
The postMessage state path set `_currentTime` without clamping, while the direct timeline path already used `Math.min(currentTime, _duration)`. A final-frame state message with a frame count slightly past the end would set `_currentTime > _duration`, causing the progress bar (position: absolute, no overflow guard) to bleed out of the scrubber track and visually cover the volume button, and the time display to show values like "0:05 / 0:04". - Clamp `_currentTime` in `_onMessage` to match the direct timeline path - Clamp defensively in `updateTime` so the display layer never overflows - Add `overflow: hidden` + `min-width: 0` to `.hfp-scrubber` as a CSS safety net; remove now-redundant `border-radius` from `.hfp-progress` (parent `overflow: hidden` handles clipping to the rounded shape) - Apply the same `overflow: hidden` fix to `.hfp-volume-slider` for consistency; remove redundant `border-radius` from `.hfp-volume-fill` - Add regression test covering the postMessage over-duration case Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
f761ee2624 |
fix(player): drive src URL timelines without runtime (#673)
* fix(player): drive src URL timelines without runtime * fix(player): pause direct timeline after seek |
||
|
|
a7a6648852 |
feat(player): add volume/mute controls (#651)
* feat(player): add volume/mute controls to the player Adds a mute toggle button and volume slider to the controls bar, positioned between the time display and speed selector. The slider expands on hover for a compact default footprint. - `volume` attribute/property (0–1, clamped) with `volumechange` event - `muted` attribute now syncs to the controls UI (icon updates) - Three volume icons: high, low, muted — updates reactively - Volume forwarded to parent-frame audio proxies and iframe runtime via `set-volume` postMessage control - 9 new tests covering volume clamping, events, controls rendering, mute toggle, and iframe message forwarding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(player): wire set-volume through runtime bridge + address review feedback Addresses the blocker from PR review: the iframe runtime now handles `set-volume` messages end-to-end (types → bridge → init → media sync). Runtime side: - Add `set-volume` to RuntimeBridgeControlAction union - Add `volume` field to RuntimeBridgeControlMessage - Handle `set-volume` in bridge.ts with [0,1] clamping - Store bridgeVolume in RuntimeState, apply to media elements - syncRuntimeMedia composes userVolume × clip author volume Player side: - Muted toggle now dispatches `volumechange` (HTML5 spec compliance) - Volume slider auto-unmutes when scrubbed above 0 while muted - Touch support on volume slider (touchstart/move/end) Tests: 5 new (3 bridge, 2 media) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(player): add ARIA keyboard controls, fix icon collision and clipVolume parity - Volume slider: role="slider", aria-label, aria-valuemin/max/now, tabindex=0, arrow key support (5% steps, auto-unmutes) - Volume=0 unmuted now shows low-volume icon instead of muted icon - Fix clipVolume divergence: init.ts uses Number.isFinite() matching media.ts semantics (preserves data-volume="0") - 3 new tests: ARIA attributes, volumechange on mute, icon collision Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
632731b076 |
fix(player): replay from start when play is pressed after video ends (#649)
* fix(player): replay from start when play is pressed after video ends When a non-looping composition reaches its end, pressing play again had no effect because the playhead stayed at the final frame. Now play() detects the ended state and seeks to 0 before resuming. * chore: fix pre-existing format issues in registry files |
||
|
|
a7b308b667 |
feat: cache shader transition preview frames (#634)
* feat: cache shader transition preview frames * fix: move shader transition loading to player |
||
|
|
7affa4a4e9 |
fix: handle player loop and render exit (#617)
## Problem Two newly reported runtime issues break common local workflows: - Fixes #615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping. - Fixes #616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open. The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items. ## What this fixes - detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state - wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame - keeps non-looping players dispatching the existing `ended` flow - lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render - keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in - adds regression coverage for the player loop end-state and successful render exit scheduling - renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key - keeps visible block/app copy friendly and named `VPN` - updates catalog-preview CI to ignore deleted registry paths when computing changed preview items ## Root cause The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped. For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed. The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths. ## Verification ### Local checks - `bun run build:hyperframes-runtime` - `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts` - `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts` - `bun run --filter @hyperframes/player typecheck` - `bun run --filter @hyperframes/cli typecheck` - `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts` - `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts` - `bun run --filter @hyperframes/player build` - `bun run --filter @hyperframes/studio build` - `bun run --filter @hyperframes/cli build` - `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json` - `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json` - `bunx oxfmt --check .github/workflows/catalog-previews.yml` - `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot` - `npx tsx scripts/sync-schemas.ts --check` - `npx mint validate` from `docs/` - `npx mint broken-links` from `docs/` - `git diff --check` - Lefthook pre-commit: format pass - Lefthook commit-msg: commitlint pass ### Browser verification - Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP. - Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition. - Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start. - Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`. - Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible. ### Composition verification - `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning. - `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling. - `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully. - `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`. ### Render verification - Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`. - Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`. ## Notes - I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion. - Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`. - The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`. - The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`. |
||
|
|
150d9348bc |
perf(player): srcdoc composition switching for studio (#398)
## Summary
Adds `srcdoc` support to `<hyperframes-player>` and uses it from studio's `Player.tsx` so composition switches no longer trigger an iframe navigation. Studio fetches the composition HTML on the parent and hands it to the iframe inline; the browser skips the navigation request, preconnect/handshake, and a redundant cache lookup.
## Why
Step `P3-2` of the player perf proposal. Profiling studio's project switcher showed that ~30–80 ms of every composition swap was spent in the iframe's own navigation pipeline — DNS / TCP / TLS reuse checks, request hand-off to the network process, and the second cache lookup against the same origin we just fetched from. For same-origin previews (`/api/projects/.../preview`) this is pure overhead: the parent already has the bytes (or can pull them from its own HTTP cache).
`srcdoc` lets us skip that pipeline entirely. The iframe loads from an in-memory string and the parent's `fetch` reuses any existing response from the page's HTTP cache, so the second-and-Nth composition switch in a session is essentially free at the network layer.
## What changed
### `<hyperframes-player>` (`packages/player/src/hyperframes-player.ts`)
- Added `srcdoc` to `observedAttributes` so runtime swaps actually fire `attributeChangedCallback`.
- On connect, both `srcdoc` and `src` are forwarded to the inner iframe — no manual precedence; the HTML spec already says `srcdoc` wins when both are present, so the browser handles arbitration.
- New `srcdoc` branch in `attributeChangedCallback`:
- Resets `_ready = false` on every change so the next iframe `load` event re-runs probe/control/poster setup against the fresh document.
- Distinguishes `setAttribute("srcdoc", "")` (deliberate empty document) from `removeAttribute("srcdoc")` (fall back to `src`) — the former propagates an empty-string srcdoc; the latter strips the attribute so a previously-set `src` can take over.
### Studio `Player.tsx` (`packages/studio/src/player/components/Player.tsx`)
- Hoisted `AbortController` and resolved `url` outside the dynamic-import `.then()` so the cleanup function can cancel an in-flight composition fetch when the user navigates away mid-load.
- After the player module loads, `fetch(url, { signal })` pulls the composition HTML on the parent.
- Success → `player.setAttribute("srcdoc", html)`.
- Network error / non-2xx → fall back to `player.setAttribute("src", url)`. Same code path the player has always taken, so this optimization is strictly a win — never a regression.
- `AbortError` → bail without touching the DOM (component is unmounting).
- Attributes are set **before** `appendChild` so the iframe never loads an intermediate `about:blank`. That matters because:
1. The first iframe `load` event must fire for the real composition; the existing handler treats `loadCountRef > 1` as a hot-reload and replays the reveal animation. An extra `about:blank` load would trigger the reveal on initial mount.
2. `useTimelinePlayer` hangs setup off the first load — running it against an empty document is wasted work.
## Test plan
- [x] 7 new unit tests in `hyperframes-player.test.ts` covering:
- `srcdoc` is in `observedAttributes`.
- Initial `srcdoc` set before connect forwards to the iframe on connect.
- Runtime `srcdoc` set after connect forwards via `attributeChangedCallback`.
- `_ready` resets when `srcdoc` changes so `onIframeLoad` replays setup.
- `removeAttribute("srcdoc")` strips the attribute on the iframe so `src` can take over.
- Empty-string `srcdoc` is preserved (not treated as removal).
- Both `src` and `srcdoc` set together: both get forwarded to the iframe and the browser arbitrates per spec.
- [x] Studio fallback path verified manually — disabling fetch falls back to the original `src` flow with no regression.
## Stack
Step `P3-2` of the player perf proposal. Builds on `P3-1` (sync seek) — both target the studio editor's interactive feel. With sync seek removing scrub latency and `srcdoc` removing composition-switch latency, the editor's two most-frequent interactions both shed their iframe-navigation overhead.
|
||
|
|
ef3de5bcd3 |
feat(player): synchronous seek() API with same-origin detection (#397)
## Summary Formalizes the same-origin shortcut Studio has been using privately (`iframe.contentWindow.__player.seek` in `useTimelinePlayer.ts`) as a first-class behavior of `<hyperframes-player>`'s public `seek()` method. Same-origin seeks now land in the same task as the input event — no postMessage hop, no extra microtask, no perceived scrub lag. Cross-origin embeds fall through to the existing async bridge transparently. ## Why Step `P3-1` of the player perf proposal. The current `seek()` always posts a message to the iframe runtime, which means a single user scrub incurs: 1. JS task: fire postMessage from parent 2. Browser task switch into iframe context 3. Microtask: handler dispatches 4. Frame: runtime calls `markExplicitSeek` and updates DOM Same-origin embeds (Studio, preview pane, embedded compositions) can skip all four by calling the runtime's `seek` directly. Studio was already doing this manually but had to duplicate the local-state bookkeeping (`_currentTime`, `paused`, controls UI) — making it a first-class behavior of the player removes the workaround and gives every same-origin consumer the win for free. ## What changed - New `_trySyncSeek(time)` helper attempts a synchronous call into the iframe's `window.__player.seek`. Returns `true` on success, `false` on cross-origin or pre-bootstrap. - `seek()` calls `_trySyncSeek` first, falls through to the existing `_sendControl` postMessage path when sync isn't available. - Detection is a `try/catch` on `contentWindow` access (real cross-origin iframes throw `SecurityError`) plus a `typeof` guard on `__player.seek`. - Local `_currentTime`, the `paused` flag, and the controls UI update on both paths so scrubs never leave stale state. - Runtime-side `seek` is the same wrapped function the postMessage handler calls — `installRuntimeControlBridge` routes through `player.seek`, so `markExplicitSeek()` and downstream runtime state are identical between the two paths. ## Test plan - [x] 11 new unit tests in `hyperframes-player.test.ts` covering: - Same-origin sync path executes `__player.seek` synchronously and skips postMessage. - Cross-origin (simulated `SecurityError` on `contentWindow`) falls back to postMessage. - Pre-bootstrap (no `__player` installed) falls back to postMessage. - `__player.seek` not a function falls back to postMessage. - `_currentTime`, `paused`, and controls all stay in sync on both paths. - Errors thrown from `__player.seek` propagate without corrupting state. ## Stack Step `P3-1` of the player perf proposal. Independent of the `P1-*` work — this is a pure latency win on the seek/scrub path. Combined with `P3-2` (srcdoc composition switching, next in the stack) it removes most of the iframe-bridge overhead from the studio scrubber. |
||
|
|
f906797222 |
perf(player): coalesce _mirrorParentMediaTime writes (#396)
## Summary Coalesce writes to `el.currentTime` inside `_mirrorParentMediaTime` so a single jitter sample no longer triggers a parent-media seek. A drift correction now requires **two consecutive samples** above the threshold (~`MIRROR_DRIFT_THRESHOLD_SECONDS`) before the player writes back. One-shot alignment paths (`promoteToParentProxy`, `_onIframeMediaAdded`) opt out via `force: true` so initial alignment stays immediate. ## Why Step `P1-4` of the player perf proposal. `_mirrorParentMediaTime` is called every animation frame on parent media proxies. Even without true drift, browser internals report tiny jitter on `currentTime` reads — typically below 30 ms but occasionally crossing the threshold for a frame. Writing to `currentTime` triggers a seek, which is expensive *and* invalidates pipeline buffers, which causes the next frame's reading to jitter further. The result was unnecessary seek thrash on otherwise-aligned media. By requiring two consecutive over-threshold samples, transient jitter is filtered out while real drift (a sustained offset) still corrects within ~1 frame of latency. This eliminates the most common cause of dropped frames on the studio thumbnail grid. ## What changed - Each `_parentMedia` entry gains a `driftSamples` counter that increments while the absolute drift is above `MIRROR_DRIFT_THRESHOLD_SECONDS` and resets to 0 on the first sample below. - `_mirrorParentMediaTime(el, opts)` only writes back when `driftSamples >= 2`, except when `opts.force === true`. - `promoteToParentProxy` and `_onIframeMediaAdded` pass `force: true` so the first alignment after registration is still immediate (these are user-visible state transitions, not steady-state telemetry). ## Test plan - [x] 11 new unit/integration tests in `hyperframes-player.test.ts` covering: - Single-sample jitter does not trigger a write. - Two-sample sustained drift does trigger a write. - Trending drift correction (gradually increasing offset) is detected within 2 samples. - `force: true` override bypasses the sample requirement. - Out-of-range proxies (proxies whose source has been removed) do not panic. - Multiple proxies maintain independent counters — drift on one does not affect the other. - `_promoteToParentProxy` alignment is immediate. ## Stack Step `P1-4` of the player perf proposal. Builds on `P1-1` (shared adopted stylesheets) and `P1-2` (scoped media observer). Together these three target the studio multi-player render path — `P0-1*` perf gate scenarios will pick up the wins automatically. |
||
|
|
a6e14da45c |
perf(player): scope MutationObserver to composition hosts (#395)
## Summary Replace the body-wide `MutationObserver` in `<hyperframes-player>` with one scoped to top-level `[data-composition-id]` hosts. The wide observer fired on every body-level mutation — analytics scripts, runtime telemetry markers, dev overlays — even though only composition subtrees can introduce new timed media (`<audio data-start>`, etc.). ## Why Step `P1-2` of the player perf proposal. The previous implementation observed `iframe.contentDocument.body` with `subtree: true` to pick up sub-composition `<audio data-start>` elements added after initial mount. That worked, but it was paying for callbacks from every unrelated DOM mutation in the iframe — most of which are just runtime instrumentation. Hot paths in the studio (timeline updates, telemetry markers) end up triggering the observer dozens of times per frame. Scoping to composition hosts cuts the noise by ~10× in the studio without losing any of the timed-media wiring guarantees. ## What changed - New `selectMediaObserverTargets(doc)` helper in `packages/player/src/mediaObserverScope.ts` that selects all top-level `[data-composition-id]` elements **excluding** nested ones — sub-composition hosts whose media is already covered by the parent observer's `subtree: true`. - The player now attaches a single `MutationObserver` instance per top-level host (`subtree: true`), so callbacks still batch across hosts but skip out-of-host noise. - Falls back to observing `body` when no composition hosts exist (e.g. blank iframe between `src` changes) — preserves prior behavior for non-composition documents and avoids breaking the bootstrap path. ## Test plan - [x] 8 new unit tests in `mediaObserverScope.test.ts` covering empty docs, single host, multiple hosts, nested-host filtering, and the body-fallback path. - [x] 2 new integration tests in `hyperframes-player.test.ts` spying on `MutationObserver.prototype.observe` to confirm the targets and options the player actually attaches in a real custom-element bootstrap. ## Stack Step `P1-2` of the player perf proposal. Sits between `P1-1` (shared adopted stylesheets) and `P1-4` (coalescing parent media-time mirror writes) — together they target the studio multi-player render path. The perf gate scenarios in `P0-1*` will pick up the wins automatically. |
||
|
|
ed62894d01 |
perf(player): share PLAYER_STYLES via adoptedStyleSheets (#394)
## Summary Replace per-instance `<style>` injection in `<hyperframes-player>` with a lazily constructed `CSSStyleSheet` adopted via `shadowRoot.adoptedStyleSheets`. One parsed stylesheet, many adopters — the studio thumbnail grid renders dozens of players concurrently and was paying for N parses of the same CSS. ## Why Step `P1-1` of the player perf proposal. The previous implementation appended a `<style>` element to every shadow root, which means: - N shadow roots → N copies of the same CSS string parsed into N independent style sheets. - Each `<style>` lives in the DOM and contributes to layout/style invalidation work when its shadow root churns. - The studio's project grid mounts ~30 players on initial load — that's 30 redundant parses of the same ~1 KB stylesheet on the critical path. `adoptedStyleSheets` flips this: parse once at module load, hand the same `CSSStyleSheet` reference to every shadow root. ## What changed - New `getSharedPlayerStyleSheet()` in `packages/player/src/styles.ts` — module-scoped and memoized; the sheet is built once per process and returned to every adopter. - New `applyPlayerStyles(shadow)` is the single integration point. It **appends** (never replaces) the shared sheet so any pre-adopted sheets — host themes, scoped overrides, future caller-side injections — survive intact, and is idempotent so repeated calls don't multiply adoptions. - SSR-safe via a `typeof CSSStyleSheet` guard. Failures (e.g. `replaceSync` throw, no constructor) are cached as `null` so we don't retry constructor failures forever. - Defensive fallback path creates a per-instance `<style>` element when `adoptedStyleSheets` is unavailable (older runtimes, hostile environments). Behavior on those paths is unchanged from before. - `PLAYER_STYLES`, `PLAY_ICON`, and `PAUSE_ICON` exports preserved — no public API change. ## Test plan - [x] Unit tests in `styles.test.ts` cover sharing across instances, fallback when `CSSStyleSheet` is undefined or `replaceSync` throws, fallback when `adoptedStyleSheets` is unsupported on the shadow root, idempotency, and preservation of pre-existing adopted sheets. - [x] Integration test in `hyperframes-player.test.ts` confirms two real `<hyperframes-player>` elements adopt the same `CSSStyleSheet` instance and inject zero `<style>` elements. - [x] Build size delta is negligible (utility code replaces `container.appendChild` calls). ## Stack Step `P1-1` of the player perf proposal. Followed by `P1-2` (scoping the media `MutationObserver`) and `P1-4` (coalescing parent media-time mirror writes) — all three target the studio multi-player render path. |
||
|
|
c49181f1fa |
fix(player): address #298 review — tighter drift, dynamic proxies, ownership event (#307)
Follow-up to PR #298 addressing @jrusso1020's review. Each item below maps to a point in his comment. ## Significant ### 1\. Drift threshold 150 ms → 50 ms _mirrorParentMediaTime_ was too loose for lip-synced talking-head content. ITU-R BT.1359 puts A/V perceptibility at ±45 ms; 150 ms sat well inside the "unacceptable" zone. Dropped to 50 ms, extracted as a static constant for clarity. **Verified live on factory-series-c-video (agent-browser):** steady-state offset under parent ownership sampled five times over 400 ms = `[35.7, 33.5, 31.2, 27.2, 36.9]` ms — below the perceptibility floor. Before this PR the same measurement could drift up to 150 ms before correction. ### 2\. Dynamic sub-composition media proxies Under parent ownership, a sub-composition that attaches a new `<audio data-start>` mid-playback was correctly silenced in the iframe (sticky `outputMuted`) but had no parent-frame counterpart to play → silent hole in the audio track. Added a `MutationObserver` on the iframe body watching for `audio[data-start]` / `video[data-start]` additions. New elements are adopted through the same `_adoptIframeMedia` helper the initial scan uses, and if parent ownership is already active the new proxy gets its `currentTime` mirrored and `play()` called immediately (gated on `!this._paused`). Observer disconnects on iframe reload + component disconnect. ### 3\. `bridgeMuted` sticky in `syncRuntimeMedia` The asymmetry James flagged: `outputMuted` was sticky per-tick, `bridgeMuted` was one-shot via `onSetMuted`. A sub-composition activating after a user mute would briefly play at author volume before the next bridge message. `syncRuntimeMedia` now accepts `userMuted` and the per-clip loop uses a single combined `shouldMute` gate. One invariant, two inputs. ### 4\. Reset `_audioOwner` on iframe reload The latch never cleared. On composition switch the player would stay in `parent` ownership against a fresh runtime that hadn't received `set-media-output-muted` and whose autoplay-blocked latch was clean — a brief double-audio window until the next `NotAllowedError` re-promoted (idempotently). `_onIframeLoad` now resets `_audioOwner = "runtime"`, pauses any parent proxies, and disconnects the old MutationObserver before a fresh one attaches to the new document. If the player had been in `parent` ownership, a corresponding `audioownershipchange` event fires with `reason: "iframe-reload"`. ## Worth addressing ### 5\. Promotion → observable event + reason Promotion was invisible. Added `CustomEvent("audioownershipchange", { detail: { owner, reason } })` fired on every owner transition. `reason` is either `"autoplay-blocked"` (promote → parent) or `"iframe-reload"` (reset → runtime). Gives host apps an SLO-ready signal for "% of sessions in parent ownership" without exposing internal state. **Verified live:** dispatching a synthetic `media-autoplay-blocked` in the live studio produced `{ owner: "parent", reason: "autoplay-blocked" }` on the web component exactly once. ### 6\. Parent proxy play() rejection → `playbackerror` event Previously swallowed silently. Now re-emitted as `CustomEvent("playbackerror", { detail: { source: "parent-proxy", error } })` so embedding apps can recover or fall back. ### 7\. Mobile verification on real hardware Tested with a tunnel in a real iOS device. ## Test gaps (from review) - `userMuted` stickiness (mirror of the existing `outputMuted` test). - **OR invariant** between `outputMuted` and `userMuted` — explicit test that setting one false while the other is true keeps `el.muted === true`. - **Contract pin:** `syncRuntimeMedia` fires `onAutoplayBlocked` on **every** rejection (no internal dedupe) — so a future refactor can't quietly move the latch and break the caller's posting logic. - **Caller-side latch pattern:** a 5-rejection simulation with the init.ts-style wrapper posts exactly once. - **`audioownershipchange`** **dispatch** on promotion + once per transition (no duplicate on idempotent re-promote). - **Mid-playback promotion:** `_paused = false` at flip time fires `_playParentMedia` immediately. - **`playbackerror`** **surface** on parent proxy rejection with the right `source` tag. ## Minor - One-line comment on `_promoteToParentProxy` explaining the `postMessage` async race (the mute lands after ~one message-loop tick; the autoplay gate that triggered promotion keeps the iframe rejecting `play()` during that window, so the double-play bug doesn't reappear). ## What's good (from the review) Kept as-is — noted for posterity: - `muted` vs `volume` framing (orthogonal channels). - Probing reality via `NotAllowedError` instead of `matchMedia('(pointer: coarse)')` / UA sniffing. - Two orthogonal mute channels. - Backwards compat (new actions / messages safely ignored by either side). ## Test results - `packages/core/src/runtime/media.test.ts` — **42 tests pass** (+4 new: `userMuted` sticky, OR invariant, fires-every-rejection, caller-latch dedupe) - `packages/core/src/runtime/bridge.test.ts` — **15 tests pass** - `packages/player/src/hyperframes-player.test.ts` — **26 tests pass** (+3 new: `audioownershipchange` dispatch, mid-playback promotion, `playbackerror` surface) - Typecheck green on `core` + `player` - `tsup` build green on `core` / `player` / `cli` - Live factory-series-c-video repro via agent-browser: runtime ownership still zero `volumechange` thrash, zero `PARENT.play()` calls; parent ownership measures 27–37 ms steady-state drift, well inside the 50 ms threshold. ## Test plan - [x] Unit tests (83 total across touched files) - [x] Typecheck clean - [x] Build clean - [x] Live studio repro on factory-series-c-video: runtime path unchanged, parent path drift tightened - [x] `audioownershipchange` event fires with correct detail on synthetic autoplay block - [x] Physical iOS / Android device verification (unchanged since #298) |
||
|
|
3256551a5e |
fix(player): single-owner audio to prevent double voice in preview (#298)
## Summary Fixes the double-voice issue in studio preview where narration plays twice with a drifting offset (measured 23ms → 80ms over a 28s clip). ## Root cause Two audio pipelines were playing the same source in parallel: 1. The iframe runtime played `<audio data-start>` elements via `syncRuntimeMedia` — the intended path. 2. `<hyperframes-player>` also created parent-frame `<audio>` copies on iframe load and auto-played them in response to every runtime `state` message. The existing `_muteIframeMedia` tried to silence the iframe copies via `el.volume = 0`, but `syncRuntimeMedia` re-asserts `el.volume` from `data-volume` every tick, so the mute never held. Studio seeks went through `__player.seek()`, which only updated the iframe timeline; parent copies kept their stale `currentTime` and drift compounded across seeks. Confirmed via agent-browser instrumentation on `factory-series-c-video`: - 6 `volumechange` events per play cycle (mute-fight signature) - Both copies audible at `volume=1`, offset growing 23ms → 80ms - Every seek widened the drift further PR #295 (v0.4.2) actually **made it audible** — before that, parent copies 404'd on the wrong URL and played silently. Fixing the URL exposed the latent double-playback. ## Fix Explicit single-owner audio ownership between `<hyperframes-player>` and the runtime. - **Default ownership is `runtime`**: iframe drives audible playback; parent proxies stay paused and inert. Matches every desktop / studio code path. No parent `play()`, no `volumechange` thrash. - **On `NotAllowedError`** from the runtime's `play()` attempt (autoplay-gated iframes), the runtime posts `media-autoplay-blocked` once. The player promotes to `parent` ownership: sends `set-media-output-muted: true` to the runtime, starts parent proxies, mirrors `currentTime` from state messages with a 150ms correction threshold. Two orthogonal mute channels replace the volume fight: | Channel | Purpose | |---|---| | `set-muted` | User's mute preference (existing, unchanged) | | `set-media-output-muted` | Internal ownership handoff (new) | `syncRuntimeMedia` now accepts `outputMuted` and asserts `el.muted = true` per active tick — sticky against sub-composition media that arrives mid-playback. Uses native `muted` (orthogonal to `volume`) so no other code path can clobber it. ## Why this shape - **Single owner, explicit transition.** No races, no tug-of-war. - **Probes reality, not device class.** We flip on an actual `NotAllowedError`, not on `matchMedia('(pointer: coarse)')` or user-agent sniffing. - **Uses `muted` instead of abusing `volume`.** `muted` is orthogonal to `volume`; `syncRuntimeMedia` doesn't write to it; author / user settings stay intact. - **Parent proxies become a thin mirror.** Under parent ownership, their `currentTime` is slaved to the iframe timeline via state messages — no independent drift. - **Backwards compatible.** Old runtimes without the new bridge action ignore the message; old players without the new message just get the previous behavior. - **Capture engine unaffected** — it bypasses both DOM pipelines and muxes audio from source files. ## Files changed - `packages/core/src/runtime/types.ts` — `set-media-output-muted` action + `media-autoplay-blocked` outbound message types. - `packages/core/src/runtime/state.ts` — `mediaOutputMuted` + `mediaAutoplayBlockedPosted` fields. - `packages/core/src/runtime/bridge.ts` — route new action to `onSetMediaOutputMuted`. - `packages/core/src/runtime/media.ts` — `outputMuted` param asserts `el.muted = true` per tick; `NotAllowedError` detection fires `onAutoplayBlocked`. - `packages/core/src/runtime/init.ts` — wire new bridge handler; coordinate with `set-muted`; post `media-autoplay-blocked` once per session. - `packages/player/src/hyperframes-player.ts` — `_audioOwner` state; delete `_muteIframeMedia`; `_promoteToParentProxy`; mirror parent `currentTime`; gate all parent play/pause/seek on ownership. ## Verified end-to-end with agent-browser on `factory-series-c-video` **Runtime ownership (default — desktop studio):** | | Before | After | |---|---|---| | `PARENT.play()` calls per play cycle | 1 | **0** | | iframe `volumechange` events | 6 | **0** | | Audible streams | 2 (drifting) | **1 (iframe)** | **Parent ownership (simulated autoplay block — direct message):** | | Value | |---|---| | iframe audio | `muted=true`, `volume=1` (untouched) | | parent audio | `muted=false`, `volume=1`, audible | | Parent ↔ iframe `currentTime` offset | ~6 ms steady state | | Offset > 150 ms | corrected by mirror sync | **Mobile path simulated with iPhone 14 emulation + injected `NotAllowedError` from iframe `<audio>.play()`:** Event timeline captured via agent-browser instrumentation: ``` t=0.0 ms IFRAME.play() called ← runtime attempts playback t=0.4 ms IFRAME.play() REJECTED: NotAllowedError ← simulated mobile gate t=0.4 ms →IFRAME bridge set-media-output-muted=true ← player promotes t=0.6 ms PARENT.play() called ← parent proxy starts t=0.8 ms ←IFRAME msg media-autoplay-blocked ← runtime signal t=1.0 ms PARENT.play() resolved ← audible t=1.3 ms IFRAME muted=true, volume=1 ← iframe silenced via native muted ``` Steady state at t=4 s under promoted parent ownership: | Element | currentTime | paused | volume | muted | |---|---|---|---|---| | Parent audio | 4.060 s | false | 1.0 | **false** (audible) | | Iframe audio | 4.068 s | false | 1.0 | **true** (silent) | **Offset: 8 ms**, single audible stream, orthogonal mute channel respected. ## Test plan - [x] `bunx vitest run` under `packages/core` — **467 / 467 pass** (incl. 4 new `media.test.ts` + 2 new `bridge.test.ts`) - [x] `bunx vitest run` under `packages/player` — **23 / 23 pass** (3 rewrites for new contract, 2 new for promotion flow) - [x] `bun run build` — all packages green - [x] Fresh preview + browser repro on `factory-series-c-video`: - [x] Runtime ownership: single audio stream, no drift - [x] Parent ownership promotion via direct `media-autoplay-blocked` message: iframe muted, parent audible - [x] iPhone 14 emulation + injected `NotAllowedError`: full promotion chain verified in ~1 s, 8 ms steady-state offset - [x] No `volumechange` thrash in either ownership mode - [x] One round of QA on a physical iOS / Android device before release — exercises real `NotAllowedError` path (expected behavior identical to simulation above) |
||
|
|
b23b0751da |
fix(player): parent-frame media playback for mobile (#266)
* fix(player): parent-frame media playback for mobile Mobile browsers block media.play() inside iframes when the user gesture happened in the parent frame — postMessage doesn't transfer user activation (per the User Activation v2 spec). ## Problem The player renders compositions in a sandboxed iframe. When a user taps play in the parent frame, the player sends a postMessage to the iframe's runtime, which calls audio.play(). On mobile, this fails silently because the iframe has no user activation context. ## Solution The player now extracts ALL timed media elements (audio/video with data-start) from the iframe's DOM (same-origin access), creates parent-frame copies, and disables the iframe originals. On play(), parentMedia.play() runs synchronously in the gesture call stack, satisfying mobile autoplay policy. ### Generic media handling - Finds all `audio[data-start], video[data-start]` in the iframe - Creates a parent-frame copy for each (Audio or Video element) - Preserves data-start offsets for correct seek positioning - Strips data-start from iframe elements so the runtime ignores them - Falls back to iframe media for cross-origin iframes ### `audio-src` attribute Convenience for the common single-narration case. When set, the player starts preloading audio immediately — before the iframe loads. This eliminates the loading delay that caused jittery playback. ### No active sync Both parent media and the GSAP timeline are real-time systems. When started simultaneously, they naturally stay within ~10ms — no drift correction needed. Active sync with coarse granularity (50ms polling) caused MORE jitter than it prevented via repeated audio seeks. ## CI - Added unified `test` job replacing separate per-package test jobs - Added root `test` script: `bun run --filter '*' test` - New packages with test scripts are automatically included - Added happy-dom for player DOM tests ## Tests - 10 new tests for parent-frame media: preloading, play, pause, seek, muted/rate sync, cleanup, attribute changes - All 21 player tests pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(shader-transitions): pass CI when no test files exist Add --passWithNoTests to vitest run so the unified test job doesn't fail on packages that have a test script but no test files yet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): update tests for new id field and GSAP lint rule - normalize.test.ts: loadTranscript now assigns id fields (w0, w1, etc.) to SRT/VTT results and empty string for words-json passthrough - lintProject.test.ts: add GSAP CDN script to validHtml() fixture to satisfy the missing_gsap_script lint rule added in core Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): add missing data-start/data-duration to validHtml fixture The validHtml() test fixture was missing data-start and data-duration attributes, triggering the root_composition_missing_data_start and root_composition_missing_data_duration lint warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ci): fetch LFS objects for producer test job Producer regression tests compare rendered output against reference MP4 files stored in git LFS. Without lfs: true, checkout fetches pointer files instead of actual videos, causing "moov atom not found" errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: remove redundant test-producer job The regression workflow already runs the same 28 producer fixtures in a Docker container with prod-matching Chrome/fonts/ffmpeg, sharded across 8 parallel matrix jobs with 40-min timeouts. The CI test-producer job was a duplicate that ran on bare runners with worse determinism and a 15-min timeout too short for all fixtures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
0da93cea3d |
feat(player): add speed control with popup menu and CSS theming (#241)
Add playback speed control to the player controls bar: - Popup menu with logarithmic presets (0.25x-4x) - Custom presets via speed-presets attribute - Full CSS custom property theming (--hfp-accent, --hfp-controls-bg, etc.) - ratechange event dispatch - Exports: SPEED_PRESETS, formatSpeed, ControlsOptions - Fix package.json export condition ordering |
||
|
|
5655dabff6 |
feat: allow clip animation + ship <hyperframes-player> web component (#209)
## Summary Two independent initiatives that improve agent DX and expand HyperFrames' reach. ### Initiative 1: Fix the Clip Animation Footgun - `gsap_animates_clip_element` lint rule now uses smart detection — only errors when GSAP animates `visibility` or `display` on a clip element - All other properties (opacity, transform, x, y, scale, etc.) are allowed silently - This was the #1 agent failure in QA (10/10 agents hit it on v0.2.1) ### Initiative 2: `<hyperframes-player>` Web Component - New `@hyperframes/player` package — zero dependencies, 3.3KB gzipped - Iframe-based web component with Shadow DOM for perfect isolation - Video-like API: `play()`, `pause()`, `seek()`, `currentTime`, `duration`, events - Controls overlay with play/pause, scrubber (mouse + touch), time display, auto-hide - Full docs page at `docs/packages/player.mdx` ## Before / After ### Clip animation lint **Before (10/10 agents hit this):** ``` ✗ gsap_animates_clip_element: GSAP animation targets a clip element. Selector "#title" resolves to element <div id="title" class="clip">. The framework manages clip visibility — animate an inner wrapper instead. Fix: Wrap content in a child <div> and target that with GSAP. ``` **After (only errors on actual conflicts):** ``` # This passes lint — no error: tl.from("#title", { opacity: 0, y: -50, scale: 0.8 }, 0); # This still errors — actual conflict with runtime: tl.to("#title", { visibility: "hidden" }, 3); ✗ gsap_animates_clip_element: GSAP animation sets visibility on a clip element. Fix: Remove the visibility/display tween. Use opacity for fade effects. ``` ### Embeddable player **Before:** No way to embed a composition in a web page. **After:** ```html <script src="https://cdn.jsdelivr.net/npm/@hyperframes/player"></script> <hyperframes-player src="./composition/index.html" controls></hyperframes-player> ``` ```js const player = document.querySelector('hyperframes-player'); player.play(); player.pause(); player.seek(2.5); player.addEventListener('ready', (e) => console.log('Duration:', e.detail.duration)); ``` ## Test plan - [x] 427 core tests pass (20 GSAP lint tests with smart detection) - [x] 7 player tests pass (formatTime + element registration) - [x] TypeScript compiles cleanly (core + player) - [x] Lint: GSAP animating clip with safe props → 0 errors - [x] Lint: GSAP animating clip with `visibility` → 1 error (correct) - [x] Player builds to 3.3KB gzipped ESM - [x] Lockfile updated for CI - [x] Docs page added at `docs/packages/player.mdx` |