mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
fix(studio,core): groups open by default, headers fit, and two contracts stop being promises
The four items left after the browser pass, plus the two architectural findings from the review that were held for a decision. Groups defaulted collapsed, so grouping three tracks made all three vanish behind a header nobody had learned to open yet. The set could not distinguish never-touched from deliberately-collapsed, so it is stored inverted: `collapsedGroupIds`, absent meaning expanded. Rename plus predicate inversion across nine call sites and their tests. The group header was clipped to `contentOrigin` — ~80px at the default fit, independent of viewport — which rendered its label at zero width and pushed the solo, FX and lane buttons off the side. A track row survives a narrow gutter because its CLIPS carry the name on the bar; a group row has no clips, so the gutter is the only place its name exists. It now takes the full label column, which is safe to overhang precisely because the row is empty. Measured 80 -> 232, label 0 -> 45px. Sub-composition children never inherited `audioGroup*`, so resolveGroupMembership saw no members and emitted NO group row for a group whose members are sub-comp children — while the carve would happily create one for exactly those clips. Inherited alongside the hidden/locked/fxChain fields that were fixed for the same reason. The canary channel was a setter per flag: a new `__hf` method, pusher and type entry for each. Replaced with one `__hf.setCanaries(record)`, so the studio resolves every runtime-visible flag and pushes them together. Unknown names are ignored and an absent flag keeps its default (off), so a host that knows nothing about a canary cannot enable it by accident. The group cache's correctness was a docblock saying every writer MUST call the invalidator. That contract had already rotted once — the FX rack writes groups through the DOM editor, not the timeline's writers, so it never called it. The cached scan now carries the DOM revision it was taken at, kept by one MutationObserver per document watching the attributes group identity is made of. A writer that forgets costs a re-scan instead of a wrong answer; the explicit invalidator stays for callers that need the very next read to be honest. Verified in the browser: group expanded on load with no seeding, header 232px with the label and all four controls visible, `setCanaries` present on the runtime and the per-flag setter gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
08e47897ed
commit
784aa64ace
@@ -1321,7 +1321,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
// Behind the `audio-track-mute` canary — off until the host pushes it, so a
|
||||
// composition that already carries data-hidden on an audio element keeps
|
||||
// playing in preview for anyone not enrolled.
|
||||
window.__hf?.setAudioMuteHidden?.(true);
|
||||
window.__hf?.setCanaries?.({ "audio-track-mute": true });
|
||||
|
||||
const decodeSpy = vi
|
||||
.spyOn(WebAudioTransport.prototype, "decodeAudioElement")
|
||||
|
||||
@@ -192,18 +192,22 @@ export function initSandboxRuntimeModular(): void {
|
||||
soloedIds = new Set(ids);
|
||||
webAudio.setSolo(soloedIds);
|
||||
};
|
||||
// A2's preview/export parity fix — silencing `data-hidden` audio the way the
|
||||
// render already does — behind the `audio-track-mute` canary, which is what
|
||||
// that canary was declared for. Core cannot read the registry (canaries are
|
||||
// resolved from the studio's install id), so the host pushes the resolved
|
||||
// state on the same channel as solo. Default OFF = the shipped behaviour: a
|
||||
// composition carrying `data-hidden` on an audio element keeps playing in
|
||||
// preview until its author is enrolled. Non-studio hosts (CLI preview, the
|
||||
// bare player) never push, so they stay on the old behaviour too.
|
||||
let silenceHiddenAudio = false;
|
||||
window.__hf.setAudioMuteHidden = (enabled) => {
|
||||
if (silenceHiddenAudio === enabled) return;
|
||||
silenceHiddenAudio = enabled;
|
||||
// Canary states the HOST resolved, keyed by registry name. Core cannot
|
||||
// resolve one itself — bucketing needs an install id it has no access to —
|
||||
// so every runtime-visible flag arrives through this one channel rather than
|
||||
// growing an `__hf` setter of its own.
|
||||
//
|
||||
// Every flag defaults OFF, which is the shipped behaviour: a host that never
|
||||
// pushes (CLI preview, the bare player) behaves exactly as before.
|
||||
const canaries: Record<string, boolean> = {};
|
||||
// A2's preview/export parity fix: silence `data-hidden` audio the way the
|
||||
// render already does. Off until enrolled, so a composition carrying
|
||||
// `data-hidden` on an audio element keeps playing in preview meanwhile.
|
||||
const silenceHiddenAudioEnabled = (): boolean => canaries["audio-track-mute"] === true;
|
||||
window.__hf.setCanaries = (states) => {
|
||||
const wasSilencing = silenceHiddenAudioEnabled();
|
||||
for (const [name, enabled] of Object.entries(states)) canaries[name] = enabled === true;
|
||||
if (silenceHiddenAudioEnabled() === wasSilencing) return;
|
||||
// The active-clip set is built with this predicate baked in, so a flip
|
||||
// mid-session has to rebuild it. `stopAll()` first: bumping the generation
|
||||
// only rejects future STALE schedules, it does not stop sources already
|
||||
@@ -2103,7 +2107,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
||||
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
||||
isAudibleUnderSolo: (el) => isAudibleUnderSolo(soloedIds, el.id, audioGroupOf(el)),
|
||||
silenceHiddenAudio,
|
||||
silenceHiddenAudio: silenceHiddenAudioEnabled(),
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
@@ -3006,7 +3010,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
let foundActive = false;
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (silenceHiddenAudio && rawEl.closest("[data-hidden]")) continue;
|
||||
if (silenceHiddenAudioEnabled() && rawEl.closest("[data-hidden]")) continue;
|
||||
const start = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
||||
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
|
||||
@@ -3114,7 +3118,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (silenceHiddenAudio && rawEl.closest("[data-hidden]")) continue;
|
||||
if (silenceHiddenAudioEnabled() && rawEl.closest("[data-hidden]")) continue;
|
||||
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
if (!Number.isFinite(compStart)) continue;
|
||||
const mediaStart = readElementPlaybackStart(rawEl);
|
||||
|
||||
@@ -223,7 +223,7 @@ export function syncRuntimeMedia(params: {
|
||||
* isn't wired up at all, which reads as "always audible". */
|
||||
isAudibleUnderSolo?: (el: HTMLMediaElement) => boolean;
|
||||
/** Silence media under a `data-hidden` ancestor, matching the render. Opt-in:
|
||||
* the host pushes it via `__hf.setAudioMuteHidden` when the `audio-track-mute`
|
||||
* the host pushes it via `__hf.setCanaries` when the `audio-track-mute`
|
||||
* canary is on. Absent/false = the shipped behaviour (hidden audio still
|
||||
* plays in preview). */
|
||||
silenceHiddenAudio?: boolean;
|
||||
|
||||
+10
-4
@@ -44,11 +44,17 @@ declare global {
|
||||
*/
|
||||
setAudioSolo?: (ids: readonly string[]) => void;
|
||||
/**
|
||||
* Studio's `audio-track-mute` canary state: silence audio under a
|
||||
* `data-hidden` ancestor in preview, the way the render already does.
|
||||
* Off until pushed — core cannot resolve a canary itself.
|
||||
* Canary states resolved by the HOST and pushed in, because core cannot
|
||||
* resolve one itself: bucketing needs an install id, which lives in the
|
||||
* studio's localStorage or the CLI's seed.
|
||||
*
|
||||
* One channel for every flag rather than a setter each — a per-flag
|
||||
* setter meant a new `__hf` method, a new pusher and a new type entry
|
||||
* for every runtime-visible canary. Unknown names are ignored, and any
|
||||
* flag absent from the record keeps its default (off), so a host that
|
||||
* knows nothing about a given canary cannot silently enable it.
|
||||
*/
|
||||
setAudioMuteHidden?: (enabled: boolean) => void;
|
||||
setCanaries?: (states: Readonly<Record<string, boolean>>) => void;
|
||||
};
|
||||
__playerReady?: boolean;
|
||||
__renderReady?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user