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:
Vance Ingalls
2026-08-20 17:28:43 -07:00
parent 5112965fc0
commit 952401d12a
16 changed files with 88 additions and 305 deletions
-26
View File
@@ -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"));
});
});
-30
View File
@@ -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 {
+15 -38
View File
@@ -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();
+8 -37
View File
@@ -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);
+9 -14
View File
@@ -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);
+4 -12
View File
@@ -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
View File
@@ -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(