mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(audio): ship the audio FX, group and mute features to everyone
The three audio canaries (`audio-fx-rack`, `audio-track-mute`, `audio-groups`) sat at 0% while the stack was in review. Open them to 100% by deleting them rather than raising the percentage — a canary that gates nothing is a branch every future reader has to evaluate. Removed: - the three `CANARIES` registry entries; - every `isCanaryEnabled` branch in the studio (FX button, group pointer, rack section, track-mute affordances) — the features now render on their own preconditions; - the runtime's `canaries` record and its `__hf.setCanaries` handler, plus the `setCanaries` type surface; - `syncRuntimeMedia`'s `silenceHiddenAudio` option. Its only caller always passed `true`, so hidden audio is now unconditionally silent in preview, matching what `audioMixer` already renders. Tests assert the unconditional behaviour instead of the enrolment transition: the FX button is present on any audio track and absent on a visual one, the group pointer follows clip count rather than enrolment, and the hidden-clip zero is paired with a visible-clip control so the assertion can still fail.
This commit is contained in:
@@ -426,29 +426,3 @@ describe("canary reason property", () => {
|
||||
expect(canaryReasonKey("de-parallel-router")).toBe("canary_reason_de_parallel_router");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The audio FX rack ships dark.
|
||||
*
|
||||
* Pinned as a test rather than trusted to review: the registry's own procedure
|
||||
* is "start at percentage: 0 and merge that", and the whole point of landing a
|
||||
* 47-PR stack behind a canary is defeated if the entry reaches main at anything
|
||||
* else. A ramp is a deliberate edit to this number, and it should have to break
|
||||
* a test that says so.
|
||||
*/
|
||||
describe("the audio-fx-rack canary", () => {
|
||||
const entry = CANARIES.find((c) => c.name === "audio-fx-rack");
|
||||
|
||||
it("is registered", () => {
|
||||
expect(entry, "audio-fx-rack missing from the registry").toBeDefined();
|
||||
});
|
||||
|
||||
it("ships at 0%", () => {
|
||||
expect(entry?.percentage).toBe(0);
|
||||
});
|
||||
|
||||
it("carries a sunset date, so the fork cannot outlive the rollout", () => {
|
||||
expect(entry?.sunsetAfter).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(Date.parse(`${entry?.sunsetAfter}T00:00:00Z`)).toBeGreaterThan(Date.parse("2026-08-12"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,36 +80,6 @@ export const CANARIES: readonly CanaryDefinition[] = [
|
||||
owner: "vance",
|
||||
sunsetAfter: "2026-09-15",
|
||||
},
|
||||
// ── Audio FX ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "audio-fx-rack",
|
||||
percentage: 0,
|
||||
description:
|
||||
"Show the Audio FX rack on audio tracks in Studio — effects, presets, the voiceover carve and levelling. Gates the AUTHORING surface only: a composition that already carries data-fx-chain still plays and renders it, because a canary should stage who can reach a feature, not make a written attribute silently inert.",
|
||||
owner: "vance",
|
||||
sunsetAfter: "2026-11-15",
|
||||
},
|
||||
{
|
||||
name: "audio-track-mute",
|
||||
percentage: 0,
|
||||
description:
|
||||
"Label the visibility control as Mute on audio tracks, and make preview " +
|
||||
"silence data-hidden audio the way the render already does. Fixes a " +
|
||||
"shipped preview/export mismatch, so it is gated separately.",
|
||||
owner: "vance",
|
||||
sunsetAfter: "2026-12-15",
|
||||
},
|
||||
{
|
||||
name: "audio-groups",
|
||||
percentage: 0,
|
||||
description:
|
||||
"Group audio tracks under a shared label, FX chain, and automation " +
|
||||
"clock. Gates the Studio UI for creating and managing groups; the " +
|
||||
"underlying <hf-audio-group> element and data-audio-group membership " +
|
||||
"parse and play regardless of enrollment.",
|
||||
owner: "vance",
|
||||
sunsetAfter: "2027-01-15",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function findCanary(name: string): CanaryDefinition | undefined {
|
||||
|
||||
@@ -1442,10 +1442,6 @@ describe("initSandboxRuntimeModular", () => {
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
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?.setCanaries?.({ "audio-track-mute": true });
|
||||
|
||||
// `scheduleMediaElementPlayback`, not `decodeAudioElement`: the media-element
|
||||
// transport is the path the runtime tries FIRST for audio, and the decoded
|
||||
@@ -1463,7 +1459,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(scheduleSpy.mock.calls[0]?.[0]).toBe(audibleAudio);
|
||||
});
|
||||
|
||||
it("still schedules a data-hidden audio clip when the host has not opted in", () => {
|
||||
it("reschedules only when a data-hidden mutation actually moved something", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
@@ -1484,39 +1480,27 @@ describe("initSandboxRuntimeModular", () => {
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const scheduleSpy = vi
|
||||
.spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback")
|
||||
.mockResolvedValue(null);
|
||||
const stopSpy = vi.spyOn(WebAudioTransport.prototype, "stopAll");
|
||||
|
||||
const player = window.__player;
|
||||
player?.play();
|
||||
|
||||
// A seek stops the transport itself, so the count is the measure: the
|
||||
// visibility pass over a data-hidden element must add NO second stop while
|
||||
// un-enrolled. The skips the reschedule exists to re-run are inert with the
|
||||
// flag off, so the set it rebuilds is identical and the only observable
|
||||
// effect is an audible stop-and-restart across the whole mix.
|
||||
// The dirty flag is set by a data-hidden MUTATION, so the toggle is the
|
||||
// gesture — a plain seek never reaches the reschedule at all.
|
||||
hiddenAudio.removeAttribute("data-hidden");
|
||||
player?.seek(1, { keepPlaying: true });
|
||||
const unenrolled = stopSpy.mock.calls.length;
|
||||
|
||||
window.__hf?.setCanaries?.({ "audio-track-mute": true });
|
||||
// A seek stops the transport itself, so the COUNT is the measure. Without
|
||||
// the dirty gate the reschedule fired on every visibility pass, adding a
|
||||
// second stop — an audible stop-and-restart across the whole mix — to
|
||||
// rebuild an identical active set.
|
||||
stopSpy.mockClear();
|
||||
hiddenAudio.setAttribute("data-hidden", "");
|
||||
player?.seek(1, { keepPlaying: true });
|
||||
const seekOnly = stopSpy.mock.calls.length;
|
||||
|
||||
// The dirty flag is set by a data-hidden MUTATION, so the toggle is the
|
||||
// gesture; a plain seek never reaches the reschedule at all.
|
||||
stopSpy.mockClear();
|
||||
hiddenAudio.removeAttribute("data-hidden");
|
||||
player?.seek(2, { keepPlaying: true });
|
||||
const enrolled = stopSpy.mock.calls.length;
|
||||
const afterToggle = stopSpy.mock.calls.length;
|
||||
|
||||
expect(unenrolled).toBe(1);
|
||||
expect(enrolled).toBe(unenrolled + 1);
|
||||
|
||||
// And the hidden clip is still audible to the transport un-enrolled: the
|
||||
// canary flip handler reschedules, and with the flag off the hidden skip
|
||||
// does not fire, so the element IS scheduled.
|
||||
window.__hf?.setCanaries?.({ "audio-track-mute": false });
|
||||
expect(scheduleSpy.mock.calls.map((call) => call[0])).toContain(hiddenAudio);
|
||||
expect(seekOnly).toBe(1);
|
||||
expect(afterToggle).toBe(seekOnly + 1);
|
||||
});
|
||||
|
||||
it("batches a mid-playback data-hidden toggle into exactly one Web Audio reschedule", () => {
|
||||
@@ -1550,10 +1534,6 @@ describe("initSandboxRuntimeModular", () => {
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
// The reschedule IS the audio-track-mute feature — un-enrolled, hidden-ness
|
||||
// does not change the active set, so nothing should stop or restart (see
|
||||
// the un-enrolled test below). Enrol before asserting on it.
|
||||
window.__hf?.setCanaries?.({ "audio-track-mute": true });
|
||||
|
||||
const player = window.__player;
|
||||
// play() alone (no seek) already runs one visibility pass while the clock
|
||||
@@ -1602,9 +1582,6 @@ describe("initSandboxRuntimeModular", () => {
|
||||
|
||||
window.__timelines = { main: createMockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
// Enrolled: the stop-before-reschedule pairing this test pins only runs for
|
||||
// the audio-track-mute feature.
|
||||
window.__hf?.setCanaries?.({ "audio-track-mute": true });
|
||||
const player = window.__player;
|
||||
player?.play();
|
||||
|
||||
|
||||
@@ -180,38 +180,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
webAudioReady = ok;
|
||||
});
|
||||
window.__hf = window.__hf || {};
|
||||
// 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;
|
||||
/** Hidden by an ancestor, or by the BUS this clip belongs to. The bus is
|
||||
* never an ancestor — membership is on the member's `data-audio-group` — so
|
||||
* `closest()` alone could not see a muted group, which the render drops. */
|
||||
const isSilencedByHidden = (el: Element): boolean =>
|
||||
el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el);
|
||||
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
|
||||
// started, and there is no per-element dedup — so rescheduling on its own
|
||||
// starts a second buffer source for every in-window clip on top of the ones
|
||||
// still playing. `applyWebAudioRate` pairs the two for the same reason.
|
||||
if (clock.isPlaying()) {
|
||||
webAudio.stopAll();
|
||||
scheduleWebAudioForActiveClips();
|
||||
}
|
||||
};
|
||||
// `_auto` is a Studio-internal keyframe marker (an auto-tracked endpoint the
|
||||
// parser reads back), NOT an animatable property. Register it as a no-op GSAP
|
||||
// plugin so GSAP doesn't log "Invalid property _auto" on every tween build —
|
||||
@@ -1967,7 +1940,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
// one per toggled node.
|
||||
//
|
||||
// The reschedule is paired with `stopAll()` below, for the reason
|
||||
// `setCanaries` and `applyWebAudioRate` already spell out: scheduling does
|
||||
// `applyWebAudioRate` already spells out: scheduling does
|
||||
// NOT replace the active set. It bumps a generation, which only rejects
|
||||
// stale schedules still in flight — every source already started keeps
|
||||
// playing, and there is no per-element dedup. This comment used to claim the
|
||||
@@ -2061,12 +2034,11 @@ export function initSandboxRuntimeModular(): void {
|
||||
rawNode.style.display = "none";
|
||||
}
|
||||
}
|
||||
// Gated like every other consumer of this canary. The skips this reschedule
|
||||
// exists to re-run (`silenceHiddenAudioEnabled() && …data-hidden`, below)
|
||||
// are inert while the flag is off, so un-enrolled the whole stop/reschedule
|
||||
// rebuilt an IDENTICAL active set — an audible stop-and-restart across the
|
||||
// entire mix on every visibility toggle, at 100%, for a feature at 0%.
|
||||
if (hiddenAudioDirty && silenceHiddenAudioEnabled() && clock.isPlaying()) {
|
||||
// Only when a `data-hidden` mutation actually moved something: the skips
|
||||
// this reschedule exists to re-run are what change the active set, so
|
||||
// firing it otherwise was an audible stop-and-restart across the whole mix
|
||||
// that rebuilt an identical set.
|
||||
if (hiddenAudioDirty && clock.isPlaying()) {
|
||||
webAudio.stopAll();
|
||||
scheduleWebAudioForActiveClips();
|
||||
}
|
||||
@@ -2132,7 +2104,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
webAudio.setElementVolume(el, authorVolume),
|
||||
isWebAudioOwned: (el) => webAudio.ownsElement(el),
|
||||
isWebAudioRouted: (el) => webAudio.routesElement(el),
|
||||
silenceHiddenAudio: silenceHiddenAudioEnabled(),
|
||||
onAutoplayBlocked: () => {
|
||||
if (state.mediaAutoplayBlockedPosted) return;
|
||||
state.mediaAutoplayBlockedPosted = true;
|
||||
@@ -3013,7 +2984,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
let foundActive = false;
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (silenceHiddenAudioEnabled() && isSilencedByHidden(rawEl)) continue;
|
||||
if (isSilencedByHidden(rawEl)) continue;
|
||||
const start = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
||||
const end = durAttr != null && durAttr > 0 ? start + durAttr : Infinity;
|
||||
@@ -3121,7 +3092,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 (silenceHiddenAudioEnabled() && isSilencedByHidden(rawEl)) continue;
|
||||
if (isSilencedByHidden(rawEl)) continue;
|
||||
const compStart = Number.parseFloat(rawEl.dataset.start ?? "");
|
||||
if (!Number.isFinite(compStart)) continue;
|
||||
const mediaStart = readElementPlaybackStart(rawEl);
|
||||
|
||||
@@ -583,14 +583,13 @@ describe("syncRuntimeMedia", () => {
|
||||
hiddenAncestor.appendChild(clip.el);
|
||||
return clip;
|
||||
};
|
||||
const volumeSeen = (clip: ReturnType<typeof hiddenClip>, silenceHiddenAudio?: boolean) => {
|
||||
const volumeSeen = (clip: ReturnType<typeof hiddenClip>) => {
|
||||
let seen = -1;
|
||||
syncRuntimeMedia({
|
||||
clips: [clip],
|
||||
timeSeconds: 1,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
...(silenceHiddenAudio === undefined ? {} : { silenceHiddenAudio }),
|
||||
onElementVolume: (_el, v) => {
|
||||
seen = v;
|
||||
},
|
||||
@@ -599,19 +598,16 @@ describe("syncRuntimeMedia", () => {
|
||||
};
|
||||
|
||||
it("zeroes effective volume for a clip under a data-hidden ancestor", () => {
|
||||
expect(volumeSeen(hiddenClip(), true)).toBe(0);
|
||||
expect(volumeSeen(hiddenClip())).toBe(0);
|
||||
});
|
||||
|
||||
// The `audio-track-mute` canary sits at 0%: an existing composition that
|
||||
// carries data-hidden on an audio element must keep playing in preview
|
||||
// until its author is enrolled, or the upgrade silences them with no way
|
||||
// back short of a revert.
|
||||
it("leaves a hidden clip audible when the host has not opted in", () => {
|
||||
expect(volumeSeen(hiddenClip(), false)).toBe(0.8);
|
||||
});
|
||||
|
||||
it("defaults to audible when the flag is absent entirely", () => {
|
||||
expect(volumeSeen(hiddenClip())).toBe(0.8);
|
||||
// A visible clip is the control: the zero above has to come from the
|
||||
// ancestor, not from the fixture reading 0 for some other reason.
|
||||
it("leaves a visible clip at its authored volume", () => {
|
||||
const clip = createMockClip({ start: 0, end: 10, volume: 0.8 });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
|
||||
document.body.appendChild(clip.el);
|
||||
expect(volumeSeen(clip)).toBe(0.8);
|
||||
});
|
||||
|
||||
it("does not touch el.muted when silencing a hidden clip (RULES trap: transport owns el.muted)", () => {
|
||||
@@ -623,7 +619,6 @@ describe("syncRuntimeMedia", () => {
|
||||
timeSeconds: 1,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
silenceHiddenAudio: true,
|
||||
});
|
||||
|
||||
expect(clip.el.muted).toBe(false);
|
||||
|
||||
@@ -218,11 +218,6 @@ export function syncRuntimeMedia(params: {
|
||||
/** Native media routed through WebAudio keeps its upstream element volume at
|
||||
* unity; do not mistake that transport write for an authored volume edit. */
|
||||
isWebAudioRouted?: (el: HTMLMediaElement) => boolean;
|
||||
/** Silence media under a `data-hidden` ancestor, matching the render. Opt-in:
|
||||
* 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;
|
||||
forceSync?: boolean;
|
||||
}): void {
|
||||
const forceMuteAll = !!(params.outputMuted || params.userMuted);
|
||||
@@ -322,18 +317,15 @@ export function syncRuntimeMedia(params: {
|
||||
}
|
||||
|
||||
// A data-hidden ancestor is silent in the export (audioMixer.ts drops
|
||||
// it); preview matches once the host opts in (`silenceHiddenAudio`, the
|
||||
// `audio-track-mute` canary — see init.ts). Folded into the per-tick
|
||||
// volume, not el.muted (RULES trap: el.muted is the transport's ownership
|
||||
// flag).
|
||||
// it), so preview matches. Folded into the per-tick volume, not
|
||||
// el.muted (RULES trap: el.muted is the transport's ownership flag).
|
||||
// Two independent ways to be silent, and the second is not an ancestor
|
||||
// question: membership lives on the MEMBER's `data-audio-group`, so a
|
||||
// muted BUS is invisible to `closest()`. The render drops such members
|
||||
// (`memberGroupHidden`), so without this the export was silent where the
|
||||
// fallback played at full level.
|
||||
const silencedByHidden = params.silenceHiddenAudio
|
||||
? el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el)
|
||||
: false;
|
||||
const silencedByHidden =
|
||||
el.closest("[data-hidden]") !== null || isMemberGroupHidden(el.ownerDocument, el);
|
||||
const effectiveVolume = silencedByHidden ? 0 : clampVolume(authorVolume * userVol);
|
||||
el.volume = effectiveVolume;
|
||||
lastRuntimeAppliedVolume.set(el, effectiveVolume);
|
||||
|
||||
-12
@@ -37,18 +37,6 @@ declare global {
|
||||
onSwallowed?: (label: string, err: unknown) => void;
|
||||
seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void;
|
||||
duration?: number;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
setCanaries?: (states: Readonly<Record<string, boolean>>) => void;
|
||||
};
|
||||
__playerReady?: boolean;
|
||||
__renderReady?: boolean;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
|
||||
import { FlatLayoutSection } from "./propertyPanelFlatLayoutSection";
|
||||
import { FlatMotionSection } from "./propertyPanelFlatMotionSection";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { AudioFxGroup } from "./propertyPanelAudioFxGroup.js";
|
||||
import { useVolumeAutomation } from "./useVolumeAutomation";
|
||||
import { useAudioFxRevealSection } from "./useAudioFxRevealSection";
|
||||
@@ -470,10 +469,7 @@ export function PropertyPanelFlat({
|
||||
});
|
||||
}
|
||||
}
|
||||
// Behind `audio-fx-rack`, at 0%. Gates the AUTHORING surface only: the runtime
|
||||
// and render still honour a `data-fx-chain` already on an element, so a
|
||||
// composition written through the skill does not go silently dry off-cohort.
|
||||
if (sections.audioFx && isCanaryEnabled("audio-fx-rack")) {
|
||||
if (sections.audioFx) {
|
||||
groups.push({
|
||||
id: "audio-fx",
|
||||
title: "Audio FX",
|
||||
|
||||
@@ -46,17 +46,10 @@ vi.mock("./timelineRowVirtualizationFlag", () => ({
|
||||
STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false,
|
||||
}));
|
||||
|
||||
/** Enrolled in nothing by default, matching a user outside every canary. */
|
||||
const enabledCanaries = new Set<string>();
|
||||
vi.mock("../../telemetry/canary", () => ({
|
||||
isCanaryEnabled: (name: string) => enabledCanaries.has(name),
|
||||
}));
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
enabledCanaries.clear();
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -310,7 +303,6 @@ describe("Timeline provider boundary", () => {
|
||||
// while TimelineGroupRow called the THROWING context hook — one grouped clip
|
||||
// and the whole timeline render died, not just the row.
|
||||
it("renders without the provider even when a group row is on screen", () => {
|
||||
enabledCanaries.add("audio-groups");
|
||||
const host = createSizedTimelineHost(640);
|
||||
usePlayerStore.setState({
|
||||
duration: 4,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { TimelineTrackHeader } from "./TimelineTrackHeader";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
@@ -14,17 +14,6 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/** Enrolled canaries for the render under test. Both audio canaries sit at 0%,
|
||||
* so the default here is "enrolled in nothing" — the state a real user is in. */
|
||||
const enabledCanaries = new Set<string>();
|
||||
vi.mock("../../telemetry/canary", () => ({
|
||||
isCanaryEnabled: (name: string) => enabledCanaries.has(name),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
enabledCanaries.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
@@ -814,29 +803,32 @@ describe("TimelineTrackHeader", () => {
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
it("hides the FX button outside the audio-fx-rack canary", () => {
|
||||
it("offers the FX button on every audio track", () => {
|
||||
const view = renderHeader({
|
||||
keyframeClip: VOICE,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
});
|
||||
expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull();
|
||||
enabledCanaries.add("audio-fx-rack");
|
||||
view.rerender({
|
||||
keyframeClip: VOICE,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
isAudioTrack: true,
|
||||
});
|
||||
expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
// The group-pointer variant WRITES a group, so it needs the groups canary
|
||||
// too — otherwise an unenrolled user creates a group and then has no UI to
|
||||
// manage it.
|
||||
it("hides the group pointer unless BOTH audio canaries are on", () => {
|
||||
// A visual track has no chain to open, so the button must not follow the
|
||||
// header onto every row.
|
||||
it("withholds the FX button from a non-audio track", () => {
|
||||
const view = renderHeader({
|
||||
keyframeClip: ELEMENT,
|
||||
animations: [],
|
||||
expanded: false,
|
||||
});
|
||||
expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
// A chain belongs to ONE bus, so a track carrying several ungrouped clips
|
||||
// gets the pointer instead of the FX button — group first, then mix.
|
||||
it("shows the group pointer on a multi-clip ungrouped audio track", () => {
|
||||
const opts = {
|
||||
keyframeClip: VOICE,
|
||||
trackElements: [VOICE, VOICE_2],
|
||||
@@ -848,13 +840,11 @@ describe("TimelineTrackHeader", () => {
|
||||
const pointer = (host: HTMLElement) =>
|
||||
host.querySelector('button[aria-label="Effects — group these clips first"]');
|
||||
const view = renderHeader(opts);
|
||||
expect(pointer(view.host)).toBeNull();
|
||||
enabledCanaries.add("audio-fx-rack");
|
||||
view.rerender({ ...opts });
|
||||
expect(pointer(view.host)).toBeNull();
|
||||
enabledCanaries.add("audio-groups");
|
||||
view.rerender({ ...opts });
|
||||
expect(pointer(view.host)).not.toBeNull();
|
||||
// One clip needs no grouping — the real FX button takes its place.
|
||||
view.rerender({ ...opts, trackElements: [VOICE], clipCount: 1 });
|
||||
expect(pointer(view.host)).toBeNull();
|
||||
expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull();
|
||||
act(() => view.root.unmount());
|
||||
});
|
||||
|
||||
@@ -868,7 +858,6 @@ describe("TimelineTrackHeader", () => {
|
||||
// itself therefore centred the two lines in the FULL height, so opening a
|
||||
// lane pushed the name and its controls down on top of the lane rows.
|
||||
it("pins the two lines to the top TRACK_H, whatever the header grows to", () => {
|
||||
enabledCanaries.add("audio-fx-rack");
|
||||
const automated: TimelineElement = {
|
||||
...VOICE,
|
||||
automation: JSON.stringify({
|
||||
@@ -898,8 +887,6 @@ describe("TimelineTrackHeader", () => {
|
||||
// what let a stray third child overflow the 48px box; now there is a single
|
||||
// row and the controls share one right-aligned group.
|
||||
it("keeps the name and every control on one line, controls to the right", () => {
|
||||
enabledCanaries.add("audio-fx-rack");
|
||||
enabledCanaries.add("audio-groups");
|
||||
const view = renderHeader({
|
||||
keyframeClip: VOICE,
|
||||
trackElements: [VOICE, VOICE_2],
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditConte
|
||||
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
||||
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
||||
import { runtimeAudioId } from "../lib/timelineElementHelpers";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { TimelineFxButton } from "./TimelineFxButton";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData";
|
||||
@@ -285,7 +284,7 @@ export function TimelineTrackHeader({
|
||||
// On the control line rather than a third row of its own.
|
||||
trailing={
|
||||
<>
|
||||
{singleAudioClip && isCanaryEnabled("audio-fx-rack") && (
|
||||
{singleAudioClip && (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
@@ -307,14 +306,9 @@ export function TimelineTrackHeader({
|
||||
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
||||
/>
|
||||
)}
|
||||
{/* The rack shelf is `audio-fx-rack`; the group-pointer variant WRITES
|
||||
a group, so it needs `audio-groups` too — without it a user outside
|
||||
that canary could create a group and then have no UI to manage it. */}
|
||||
{clipCount > 1 &&
|
||||
!isTrackGrouped &&
|
||||
(isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) &&
|
||||
isCanaryEnabled("audio-fx-rack") &&
|
||||
isCanaryEnabled("audio-groups") && (
|
||||
(isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && (
|
||||
<TimelineFxButton
|
||||
variant="group-pointer"
|
||||
clipCount={trackElements.length}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
import type { ResizingClipState } from "./timelineClipDragTypes";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
@@ -34,11 +33,11 @@ export function timelineNeedsLabelColumn(
|
||||
): boolean {
|
||||
return (
|
||||
hasKeyframedTimelineClips(animationsByElement) ||
|
||||
// Gated exactly as the group ROWS are (`useTimelineTrackDerivations`):
|
||||
// un-enrolled there are no group rows, so widening the column bought a
|
||||
// permanent 232px shift of every clip with nothing on screen to explain it.
|
||||
// The two must agree — the column exists FOR those rows.
|
||||
(isCanaryEnabled("audio-groups") && elements.some((element) => Boolean(element.audioGroup)))
|
||||
// The column exists FOR the group rows, so this must stay in step with
|
||||
// whatever decides they are drawn (`useTimelineTrackDerivations`) —
|
||||
// widening it with no group row on screen is a 232px shift of every clip
|
||||
// with nothing to explain it.
|
||||
elements.some((element) => Boolean(element.audioGroup))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { getTrackStyle, type TrackVisualStyle } from "./timelineIcons";
|
||||
|
||||
/** One resolved audio group, positioned in the row order. */
|
||||
@@ -196,16 +195,10 @@ export function useTimelineTrackDerivations(expandedElements: TimelineElement[])
|
||||
}, [expandedElements]);
|
||||
|
||||
const collapsedGroupIds = usePlayerStore((s) => s.collapsedGroupIds);
|
||||
const { tracks, groups, trackGroupOf } = useMemo(() => {
|
||||
if (!isCanaryEnabled("audio-groups")) {
|
||||
return {
|
||||
tracks: rawTracks,
|
||||
groups: [],
|
||||
trackGroupOf: new Map<number, TimelineTrackGroupInfo>(),
|
||||
};
|
||||
}
|
||||
return groupTimelineTracks(rawTracks, collapsedGroupIds);
|
||||
}, [rawTracks, collapsedGroupIds]);
|
||||
const { tracks, groups, trackGroupOf } = useMemo(
|
||||
() => groupTimelineTracks(rawTracks, collapsedGroupIds),
|
||||
[rawTracks, collapsedGroupIds],
|
||||
);
|
||||
|
||||
const trackStyles = useMemo(() => {
|
||||
const map = new Map<number, TrackVisualStyle>();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { LANE_H, TRACK_H } from "./timelineLayout";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
@@ -12,13 +12,7 @@ import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineT
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const enabledCanaries = new Set<string>();
|
||||
vi.mock("../../telemetry/canary", () => ({
|
||||
isCanaryEnabled: (name: string) => enabledCanaries.has(name),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
enabledCanaries.clear();
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -60,7 +54,6 @@ describe("collapsed audio groups", () => {
|
||||
layout: ReturnType<typeof useTimelineTrackLayout>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
enabledCanaries.add("audio-groups");
|
||||
if (collapsed) usePlayerStore.setState({ collapsedGroupIds: new Set(["voiceover"]) });
|
||||
const elements = [member("voice-1", 0), member("voice-2", 1)];
|
||||
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
||||
@@ -79,7 +72,6 @@ describe("collapsed audio groups", () => {
|
||||
// is what "expanding automation on a group doesn't show the automation"
|
||||
// looked like from outside.
|
||||
it("reserves room for the group's own automation rows, not just the strip", () => {
|
||||
enabledCanaries.add("audio-groups");
|
||||
const automation = JSON.stringify({
|
||||
version: 1,
|
||||
lanes: [
|
||||
|
||||
@@ -90,31 +90,21 @@ describe("scrubPreviewAudio", () => {
|
||||
});
|
||||
|
||||
describe("applyPreviewAudioFlags", () => {
|
||||
function fakeIframe(): { iframe: HTMLIFrameElement; calls: Record<string, unknown[]> } {
|
||||
const calls: Record<string, unknown[]> = {};
|
||||
const win = {
|
||||
__hf: {
|
||||
setCanaries: (states: Record<string, boolean>) => {
|
||||
calls.canaries = [states];
|
||||
},
|
||||
},
|
||||
};
|
||||
const iframe = {
|
||||
contentWindow: win,
|
||||
contentDocument: null,
|
||||
querySelector: () => null,
|
||||
} as unknown as HTMLIFrameElement;
|
||||
return { iframe, calls };
|
||||
}
|
||||
// Everything pushed here is state the runtime loses on reload and nothing else
|
||||
// re-sends, so the push has to carry all of it every time. Volume in
|
||||
// particular: the transport comes back at unity, so a preview the author had
|
||||
// turned down came back loud.
|
||||
it("re-pushes mute and volume together", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
|
||||
|
||||
// Everything pushed here is state the runtime loses on reload and nothing
|
||||
// else re-sends, so the push has to carry all of it every time.
|
||||
it("re-pushes the whole audio state", () => {
|
||||
const { iframe, calls } = fakeIframe();
|
||||
applyPreviewAudioFlags(iframe, true, 0.4);
|
||||
|
||||
applyPreviewAudioFlags(iframe, false, 1);
|
||||
|
||||
// Every runtime-visible flag in one push, each resolved by the host.
|
||||
expect(calls.canaries?.[0]).toMatchObject({ "audio-track-mute": expect.any(Boolean) });
|
||||
const actions = postMessage.mock.calls.map(
|
||||
(call) => (call[0] as { action?: string }).action ?? "",
|
||||
);
|
||||
expect(actions).toContain("set-muted");
|
||||
expect(actions).toContain("set-volume");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import type { IframeWindow } from "./playbackTypes";
|
||||
import { isCanaryEnabled } from "../../telemetry/canary";
|
||||
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
||||
import {
|
||||
getTimelineElementSelector,
|
||||
@@ -144,35 +143,10 @@ export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume:
|
||||
}
|
||||
|
||||
/**
|
||||
* Every canary the preview runtime can act on, resolved here and pushed as one
|
||||
* record (see `window.__hf.setCanaries`). Core has no install id, so it cannot
|
||||
* bucket for itself; a flag missing from this list simply stays off in the
|
||||
* runtime, which is the shipped behaviour.
|
||||
*
|
||||
* Adding a runtime-visible canary means adding its name here and reading it in
|
||||
* core — no new `__hf` method, pusher or type entry per flag.
|
||||
*/
|
||||
const RUNTIME_CANARIES = ["audio-track-mute", "audio-groups", "audio-fx-rack"] as const;
|
||||
|
||||
function setPreviewCanaries(iframe: HTMLIFrameElement | null): void {
|
||||
if (!iframe) return;
|
||||
try {
|
||||
const win = iframe.contentWindow as
|
||||
| (Window & { __hf?: { setCanaries?: (states: Record<string, boolean>) => void } })
|
||||
| null;
|
||||
if (!win?.__hf?.setCanaries) return;
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const name of RUNTIME_CANARIES) states[name] = isCanaryEnabled(name);
|
||||
win.__hf.setCanaries(states);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the preview runtime has to be told about audio after it loads:
|
||||
* the transport's mute and the canary flags core
|
||||
* cannot resolve for itself. Called from `applyPreviewAudioState`, which is the
|
||||
* path that re-runs after a preview reload — the runtime comes back with every
|
||||
* one of these at its default and nothing else pushes them again.
|
||||
* Everything the preview runtime has to be told about audio after it loads.
|
||||
* Called from `applyPreviewAudioState`, which is the path that re-runs after a
|
||||
* preview reload — the runtime comes back with the transport at its defaults
|
||||
* and nothing else pushes them again.
|
||||
*/
|
||||
export function applyPreviewAudioFlags(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
@@ -183,7 +157,6 @@ export function applyPreviewAudioFlags(
|
||||
// Volume too: the transport comes back at unity after a reload, so a preview
|
||||
// the author had turned down came back loud.
|
||||
setPreviewMediaVolume(iframe, volume);
|
||||
setPreviewCanaries(iframe);
|
||||
}
|
||||
|
||||
export function setPreviewPlaybackRate(
|
||||
|
||||
Reference in New Issue
Block a user