mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio,core): a volume and a living meter on the group row
B7: the group bus strip — droppable, and deliberately minimal per the casual-user design constraints (groups doc §5): a volume slider, a level bar that moves with the sound, and the words "Too loud" when it clips. No dB numbers, no peak-hold readout, no routing row. Transport (core): groupInput() now routes each group through input -> [FX chain or dry passthrough] -> output -> master, with one AnalyserNode per group tapped off `output` (post-FX, so the meter reads what the bus actually outputs) — fftSize 256, level not spectrum. groupLevel(groupId) returns RMS-ish level 0..1 + a clipped flag off a reused per-group buffer (no per-frame allocation), or null when the group is idle/unknown. The runtime posts group-levels messages only while playing, piggybacking the existing message channel rather than adding a new poll loop. Studio: groupLevels.ts is a plain pub-sub store (mirrors liveTime.ts's shape) fed by useTimelinePlayer's message handler via parseGroupLevelsMessage; useGroupLevel throttles re-renders to ~33ms. TimelineGroupBusStrip renders in the group row's own `∿` lane area (STRIP_H, already sized in B2's row-height pipeline) — drag writes live via onSetAudioGroupAttributeLive, release commits one undo entry via onSetAudioGroupAttributeQuiet (packages/studio/src/hooks/ timelineAudioGroupVolume.ts, extracted from timelineTrackVisibility.ts to stay under the 600-line cap; mirrors FxParamRow's live/commit split). "Too loud" holds for ~2s after the last clipped block, tracked in the component, not the transport. volumeByGroup mirrors labelByGroup in useTimelineTrackDerivations.ts so the strip's slider round-trips the group's own data-volume. Fixed two pre-existing group-routing tests in webAudioTransport.test.ts that hardcoded gain-node creation order/count — B7 inserts an extra `output` gain node between the group's input and master (for the meter to tap), which shifted node indices the tests asserted on directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8052f3e68f
commit
e1c9b50948
@@ -2140,6 +2140,25 @@ export function initSandboxRuntimeModular(): void {
|
||||
scheduleRootStageLayoutDiagnostics();
|
||||
};
|
||||
|
||||
/** One meter reading per group with an active member — polled from the
|
||||
* transport's analyser, not the DOM, so an idle group (never played, no
|
||||
* matching `<hf-audio-group>`) is simply absent rather than reported as
|
||||
* zero. Cheap when nothing is grouped: `groupIds()` is empty. */
|
||||
const postGroupLevels = () => {
|
||||
const groupIds = webAudio.groupIds();
|
||||
if (groupIds.length === 0) return;
|
||||
const levels = groupIds
|
||||
.map((groupId) => {
|
||||
const reading = webAudio.groupLevel(groupId);
|
||||
return reading ? { groupId, ...reading } : null;
|
||||
})
|
||||
.filter(
|
||||
(entry): entry is { groupId: string; level: number; clipped: boolean } => entry !== null,
|
||||
);
|
||||
if (levels.length === 0) return;
|
||||
postRuntimeMessage({ source: "hf-preview", type: "group-levels", levels });
|
||||
};
|
||||
|
||||
const finitePositiveDuration = (value: number | null | undefined): number =>
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
|
||||
@@ -2897,6 +2916,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (transportTickCount % 30 === 0) {
|
||||
bindMediaMetadataListeners();
|
||||
}
|
||||
if (clock.isPlaying()) {
|
||||
postGroupLevels();
|
||||
}
|
||||
|
||||
// Sync clock duration with the resolved timeline each tick (catches async
|
||||
// rebinds, live data-duration edits). Never shrink while playing — transient
|
||||
|
||||
@@ -197,6 +197,15 @@ export type RuntimePerformanceMessage = {
|
||||
tags: Record<string, string | number | boolean | null>;
|
||||
};
|
||||
|
||||
/** One audio group's live meter reading, polled from the transport each tick
|
||||
* while playing. A group id absent from `levels` is idle/unknown (no active
|
||||
* member) — the studio side treats that as "no reading", not zero. */
|
||||
export type RuntimeGroupLevelsMessage = {
|
||||
source: "hf-preview";
|
||||
type: "group-levels";
|
||||
levels: Array<{ groupId: string; level: number; clipped: boolean }>;
|
||||
};
|
||||
|
||||
export type RuntimeOutboundMessage =
|
||||
| RuntimeStateMessage
|
||||
| RuntimeTimelineMessage
|
||||
@@ -210,7 +219,8 @@ export type RuntimeOutboundMessage =
|
||||
| RuntimeMediaAutoplayBlockedMessage
|
||||
| RuntimeReadyMessage
|
||||
| RuntimeAnalyticsMessage
|
||||
| RuntimePerformanceMessage;
|
||||
| RuntimePerformanceMessage
|
||||
| RuntimeGroupLevelsMessage;
|
||||
|
||||
export type RuntimePlayer = {
|
||||
_timeline: RuntimeTimelineLike | null;
|
||||
|
||||
@@ -635,6 +635,12 @@ describe("WebAudioTransport", () => {
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}[] = [];
|
||||
const analysers: {
|
||||
fftSize: number;
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
getFloatTimeDomainData: ReturnType<typeof vi.fn>;
|
||||
}[] = [];
|
||||
const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
|
||||
const ctx = {
|
||||
currentTime,
|
||||
@@ -654,10 +660,20 @@ describe("WebAudioTransport", () => {
|
||||
gainNodes.push(node);
|
||||
return node;
|
||||
}),
|
||||
createAnalyser: vi.fn(() => {
|
||||
const node = {
|
||||
fftSize: 2048,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: vi.fn(),
|
||||
};
|
||||
analysers.push(node);
|
||||
return node;
|
||||
}),
|
||||
destination: {},
|
||||
close: vi.fn(),
|
||||
};
|
||||
return { ctx, gainNodes, masterGain };
|
||||
return { ctx, gainNodes, analysers, masterGain };
|
||||
}
|
||||
|
||||
function setupGroupTransport(currentTime = 100) {
|
||||
@@ -715,12 +731,14 @@ describe("WebAudioTransport", () => {
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
|
||||
// Member gain nodes: index 0 (a) and index 2 (b) — index 1 is the
|
||||
// group's own input gain, built inside a's schedule call.
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(3);
|
||||
// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
|
||||
// group's own input/output gain pair (B7's meter taps `output`),
|
||||
// built inside a's schedule call.
|
||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
|
||||
const groupInput = firstGroupInput(mock);
|
||||
const groupOutput = mock.gainNodes[2]!;
|
||||
const aGain = mock.gainNodes[0]!;
|
||||
const bGain = mock.gainNodes[2]!;
|
||||
const bGain = mock.gainNodes[3]!;
|
||||
|
||||
// Neither member connects straight to master — both feed the shared bus.
|
||||
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
|
||||
@@ -728,9 +746,12 @@ describe("WebAudioTransport", () => {
|
||||
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
|
||||
// The bus itself is what reaches master — a plain sum, no processing,
|
||||
// since neither member's group has a chain-bearing `<hf-audio-group>`.
|
||||
expect(groupInput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
// The bus's input never reaches master directly — it lands on the
|
||||
// output gain (the dry passthrough, since neither member's group has a
|
||||
// chain-bearing `<hf-audio-group>`), and THAT reaches master.
|
||||
expect(groupInput.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||
expect(groupInput.connect).toHaveBeenCalledWith(groupOutput);
|
||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
|
||||
it("a second member of an already-open group does not rebuild the group bus", async () => {
|
||||
@@ -749,7 +770,9 @@ describe("WebAudioTransport", () => {
|
||||
|
||||
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
|
||||
|
||||
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
const groupOutput = mock.gainNodes[2]!;
|
||||
expect(firstGroupInput(mock).connect).toHaveBeenCalledWith(groupOutput);
|
||||
expect(groupOutput.connect).toHaveBeenCalledWith(mock.masterGain);
|
||||
});
|
||||
|
||||
it("group volume rides the group's own data-volume via its automation lane, not the member's", async () => {
|
||||
@@ -784,6 +807,66 @@ describe("WebAudioTransport", () => {
|
||||
// Still only one group-input gain ever created for "vo".
|
||||
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("groupLevel meter (B7)", () => {
|
||||
it("groupLevel returns null for an unknown/idle group id", () => {
|
||||
const { transport } = setupGroupTransport();
|
||||
expect(transport.groupLevel("never-played")).toBeNull();
|
||||
});
|
||||
|
||||
it("creates exactly one analyser per group, lazily, on first member", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
expect(mock.analysers).toHaveLength(0);
|
||||
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
expect(mock.analysers).toHaveLength(1);
|
||||
expect(mock.analysers[0]!.fftSize).toBe(256); // level, not spectrum
|
||||
|
||||
await scheduleGrouped(transport, gen, "b", "vo");
|
||||
expect(mock.analysers).toHaveLength(1); // second member reuses the bus
|
||||
|
||||
expect(transport.groupIds()).toEqual(["vo"]);
|
||||
});
|
||||
|
||||
it("groupLevel reads RMS off the group's own analyser once a member is scheduled", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
|
||||
const analyser = mock.analysers[0]!;
|
||||
analyser.getFloatTimeDomainData.mockImplementation((buf: Float32Array) => {
|
||||
buf.fill(0.5);
|
||||
});
|
||||
|
||||
const reading = transport.groupLevel("vo");
|
||||
expect(reading).not.toBeNull();
|
||||
expect(reading!.level).toBeCloseTo(0.5, 5);
|
||||
expect(reading!.clipped).toBe(false);
|
||||
});
|
||||
|
||||
it("flags clipped when any sample hits the ceiling", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
|
||||
const analyser = mock.analysers[0]!;
|
||||
analyser.getFloatTimeDomainData.mockImplementation((buf: Float32Array) => {
|
||||
buf.fill(0.1);
|
||||
buf[0] = 0.995;
|
||||
});
|
||||
|
||||
expect(transport.groupLevel("vo")!.clipped).toBe(true);
|
||||
});
|
||||
|
||||
it("disposes the analyser along with the rest of the group bus", async () => {
|
||||
const { transport, mock, gen } = setupGroupTransport();
|
||||
await scheduleGrouped(transport, gen, "a", "vo");
|
||||
const analyser = mock.analysers[0]!;
|
||||
|
||||
transport.destroy();
|
||||
|
||||
expect(analyser.disconnect).toHaveBeenCalled();
|
||||
expect(transport.groupLevel("vo")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeAudioElement retry policy (late-asset self-heal)", () => {
|
||||
|
||||
@@ -127,7 +127,10 @@ export class WebAudioTransport {
|
||||
// group is scheduled. Lives for the session (mirrors `_masterGain`'s own
|
||||
// lifecycle) rather than being torn down on every `stopAll()`, so replaying
|
||||
// a group does not rebuild its chain; only `destroy()` disposes these.
|
||||
private _groups = new Map<string, { input: GainNode; dispose(): void }>();
|
||||
private _groups = new Map<
|
||||
string,
|
||||
{ input: GainNode; analyser: AnalyserNode; levelBuf: Float32Array; dispose(): void }
|
||||
>();
|
||||
// Composition-time reference frame: at AudioContext time `_rateAnchorCtx`,
|
||||
// composition time was `_rateAnchorComp`, and time has been advancing at
|
||||
// `_rate` composition-seconds per wallclock-second since.
|
||||
@@ -294,22 +297,39 @@ export class WebAudioTransport {
|
||||
if (!this._ctx || !this._masterGain) return null;
|
||||
|
||||
const input = this._ctx.createGain();
|
||||
// Stable point the FX chain (or, when there's none, the dry passthrough —
|
||||
// see `attachElementFxChain`'s `detach()`) always lands on before master,
|
||||
// regardless of whether a chain is attached/detached/rebuilt later. B7's
|
||||
// meter taps here. B5's group mute gain MUST splice in before `output`
|
||||
// (between the FX chain and here), never after — the meter is defined to
|
||||
// read the group's true, honestly-muted level (design doc §5), and this
|
||||
// node is that contract's anchor.
|
||||
const output = this._ctx.createGain();
|
||||
output.connect(this._masterGain);
|
||||
const analyser = this._ctx.createAnalyser();
|
||||
analyser.fftSize = 256; // level, not spectrum
|
||||
output.connect(analyser);
|
||||
|
||||
const groupEl = doc.getElementById(groupId);
|
||||
const fx = attachElementFxChain(
|
||||
this._ctx,
|
||||
groupEl ?? { getAttribute: () => null },
|
||||
input,
|
||||
this._masterGain,
|
||||
output,
|
||||
timing,
|
||||
);
|
||||
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
||||
|
||||
this._groups.set(groupId, {
|
||||
input,
|
||||
analyser,
|
||||
levelBuf: new Float32Array(analyser.fftSize),
|
||||
dispose: () => {
|
||||
try {
|
||||
fx?.dispose();
|
||||
input.disconnect();
|
||||
output.disconnect();
|
||||
analyser.disconnect();
|
||||
} catch {
|
||||
// Already torn down.
|
||||
}
|
||||
@@ -318,6 +338,30 @@ export class WebAudioTransport {
|
||||
return input;
|
||||
}
|
||||
|
||||
/** Every group id currently routing audio (built lazily by `groupInput` —
|
||||
* a group with no active member yet has no entry here). */
|
||||
groupIds(): string[] {
|
||||
return [...this._groups.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* RMS-ish level 0..1 and whether the last block clipped, for the group's
|
||||
* meter — or null when the group has no active member (idle/unknown).
|
||||
* Reuses a per-group buffer; no per-frame allocation.
|
||||
*/
|
||||
groupLevel(groupId: string): { level: number; clipped: boolean } | null {
|
||||
const g = this._groups.get(groupId);
|
||||
if (!g) return null;
|
||||
g.analyser.getFloatTimeDomainData(g.levelBuf);
|
||||
let sumSquares = 0;
|
||||
let clipped = false;
|
||||
for (const sample of g.levelBuf) {
|
||||
sumSquares += sample * sample;
|
||||
if (Math.abs(sample) >= 0.99) clipped = true;
|
||||
}
|
||||
return { level: Math.sqrt(sumSquares / g.levelBuf.length), clipped };
|
||||
}
|
||||
|
||||
/** Master, unless `el` belongs to a group — then that group's bus (built on
|
||||
* first use, per `groupInput`). */
|
||||
private resolveDestination(
|
||||
|
||||
@@ -548,6 +548,7 @@ export function StudioApp() {
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
|
||||
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
||||
setAudioGroupAttribute={timelineEditing.setAudioGroupAttribute}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||
handleRazorSplit={timelineEditing.handleRazorSplit}
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("EditorShell timeline selection sync", () => {
|
||||
handleTimelineElementResize={vi.fn()}
|
||||
handleTimelineGroupResize={vi.fn()}
|
||||
handleToggleTrackHidden={vi.fn()}
|
||||
setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
|
||||
handleBlockedTimelineEdit={vi.fn()}
|
||||
handleTimelineElementSplit={vi.fn()}
|
||||
handleRazorSplit={vi.fn()}
|
||||
|
||||
@@ -85,6 +85,7 @@ export function EditorShell({
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
@@ -135,6 +136,7 @@ export function EditorShell({
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -104,6 +104,7 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
|
||||
handleTimelineElementResize: vi.fn(),
|
||||
handleTimelineGroupResize: vi.fn(),
|
||||
handleToggleTrackHidden: vi.fn(),
|
||||
setAudioGroupAttribute: { setLive: vi.fn(), setQuiet: vi.fn() },
|
||||
handleBlockedTimelineEdit: vi.fn(),
|
||||
handleTimelineElementSplit: vi.fn(),
|
||||
handleRazorSplit: vi.fn(),
|
||||
|
||||
@@ -38,6 +38,10 @@ export interface TimelineEditCallbackDeps {
|
||||
) => Promise<void> | void;
|
||||
handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
||||
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
setAudioGroupAttribute: {
|
||||
setLive: (groupId: string, attr: string, value: string | null) => void;
|
||||
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
||||
};
|
||||
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
@@ -99,6 +103,7 @@ export function useTimelineEditCallbacks({
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
@@ -185,6 +190,8 @@ export function useTimelineEditCallbacks({
|
||||
onResizeElement: handleTimelineElementResize,
|
||||
onResizeElements: handleTimelineGroupResize,
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
|
||||
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
|
||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||
onSplitElement: handleTimelineElementSplit,
|
||||
onRazorSplit: handleRazorSplit,
|
||||
@@ -378,6 +385,7 @@ export function useTimelineEditCallbacks({
|
||||
handleTimelineElementResize,
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -34,6 +34,8 @@ export function TimelineEditProvider({
|
||||
value.onMoveElements,
|
||||
value.onResizeElement,
|
||||
value.onToggleTrackHidden,
|
||||
value.onSetAudioGroupAttributeLive,
|
||||
value.onSetAudioGroupAttributeQuiet,
|
||||
value.onBlockedEditAttempt,
|
||||
value.onSplitElement,
|
||||
value.onRazorSplit,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useCallback } from "react";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
buildPatchTarget,
|
||||
readFileContent,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type {
|
||||
MutableRef,
|
||||
UseTimelineElementVisibilityEditingInput,
|
||||
} from "./timelineTrackVisibility";
|
||||
|
||||
/** Direct DOM write on the group element for the gesture in progress — no
|
||||
* file write, no history entry (mirrors FxParamRow's live/commit split). */
|
||||
function patchLiveGroupAttribute(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
groupId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
): void {
|
||||
const target = iframe?.contentDocument?.getElementById(groupId);
|
||||
if (!target) return;
|
||||
if (value === null) target.removeAttribute(attr);
|
||||
else target.setAttribute(attr, value);
|
||||
}
|
||||
|
||||
interface SetAudioGroupAttributeInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
groupId: string;
|
||||
attr: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRef<number>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one attribute on the group element itself — e.g. the bus strip's
|
||||
* volume slider writing `data-volume` on release. One undo entry; mirrors
|
||||
* `createAudioGroupAndAssignMembers`'s save shape but for a single element
|
||||
* and attribute rather than a member-assignment sweep.
|
||||
*/
|
||||
async function setAudioGroupAttribute({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
groupId,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: SetAudioGroupAttributeInput): Promise<string[]> {
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
const patchTarget = buildPatchTarget({ domId: groupId });
|
||||
if (!patchTarget) return [];
|
||||
|
||||
const previousValue =
|
||||
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null;
|
||||
patchLiveGroupAttribute(previewIframe, groupId, attr, value);
|
||||
|
||||
const before = await readFileContent(projectId, targetPath);
|
||||
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch audio group ${groupId} in ${targetPath}`);
|
||||
}
|
||||
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
||||
const patched = applyPatchByTarget(before, patchTarget, operation);
|
||||
|
||||
pendingTimelineEditPathRef.current.add(targetPath);
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
const changedPaths = await saveProjectFilesWithHistory({
|
||||
projectId,
|
||||
label,
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patched },
|
||||
readFile: async (path) => (path === targetPath ? before : readFileContent(projectId, path)),
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
return changedPaths;
|
||||
} catch (error) {
|
||||
// The optimistic live write already ran; unwind it on a save failure so
|
||||
// the preview doesn't show a value that never reached disk.
|
||||
patchLiveGroupAttribute(previewIframe, groupId, attr, previousValue);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* B7's bus strip: live-write the group's own attribute (`data-volume`, so
|
||||
* far — B5's mute will reuse this too) while dragging, persist one undo entry
|
||||
* on release. Unlike `useAudioGroupCarveAssignment`, this never touches
|
||||
* member elements — the group id doubles as its own DOM id, so no selection
|
||||
* or expanded-rows resolution is needed to find it.
|
||||
*/
|
||||
export function useSetAudioGroupAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
}: UseTimelineElementVisibilityEditingInput): {
|
||||
setLive: (groupId: string, attr: string, value: string | null) => void;
|
||||
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
||||
} {
|
||||
const setLive = useCallback(
|
||||
(groupId: string, attr: string, value: string | null) => {
|
||||
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
||||
},
|
||||
[previewIframeRef],
|
||||
);
|
||||
const setQuiet = useCallback(
|
||||
async (groupId: string, attr: string, value: string | null, label: string) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
try {
|
||||
await setAudioGroupAttribute({
|
||||
projectId: pid,
|
||||
activeCompPath,
|
||||
groupId,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe: previewIframeRef.current,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to set group attribute", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to update group";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
return { setLive, setQuiet };
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
|
||||
interface MutableRef<T> {
|
||||
export interface MutableRef<T> {
|
||||
current: T;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
|
||||
forceReloadSdkSession?: () => void;
|
||||
}
|
||||
|
||||
interface UseTimelineElementVisibilityEditingInput extends Omit<
|
||||
export interface UseTimelineElementVisibilityEditingInput extends Omit<
|
||||
ToggleTimelineElementHiddenInput,
|
||||
"projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
|
||||
> {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* A group's live meter reading (0..1 RMS-ish level, whether it just clipped),
|
||||
* or null when the group is idle/unknown — never zero for "not playing yet".
|
||||
*
|
||||
* Mirrors useLivePlayheadTime's shape: the runtime posts readings at its own
|
||||
* cadence (only while playing — see `postGroupLevels` in init.ts), this just
|
||||
* throttles the re-render, it does not add its own polling loop.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { groupLevels, type GroupLevelReading } from "../player/store/groupLevels";
|
||||
|
||||
const THROTTLE_MS = 33;
|
||||
|
||||
export function useGroupLevel(groupId: string): GroupLevelReading | null {
|
||||
const latestRef = useRef<GroupLevelReading | null>(groupLevels.get().get(groupId) ?? null);
|
||||
const [, forceRender] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let timerId: ReturnType<typeof setTimeout> | 0 = 0;
|
||||
const unsubscribe = groupLevels.subscribe((levels) => {
|
||||
latestRef.current = levels.get(groupId) ?? null;
|
||||
if (!timerId) {
|
||||
timerId = setTimeout(() => {
|
||||
timerId = 0;
|
||||
forceRender((v) => v + 1);
|
||||
}, THROTTLE_MS);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [groupId]);
|
||||
|
||||
return latestRef.current;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./timelineTimingSync";
|
||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
||||
import {
|
||||
useAudioGroupCarveAssignment,
|
||||
useTimelineElementVisibilityEditing,
|
||||
@@ -401,6 +402,18 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -572,6 +585,7 @@ export function useTimelineEditing({
|
||||
handleToggleTrackHidden,
|
||||
handleToggleElementHidden,
|
||||
handleAutoGroupCarveSources,
|
||||
setAudioGroupAttribute,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementsDelete,
|
||||
handleTimelineElementSplit: handleRazorSplit,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
||||
import { defaultTimelineTheme } from "./timelineTheme";
|
||||
import { groupLevels } from "../store/groupLevels";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
groupLevels.notify(new Map());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderStrip(overrides: Partial<React.ComponentProps<typeof TimelineGroupBusStrip>> = {}) {
|
||||
const onVolumeChange = vi.fn();
|
||||
const onVolumeCommit = vi.fn();
|
||||
act(() => {
|
||||
root.render(
|
||||
<TimelineGroupBusStrip
|
||||
groupId="vo"
|
||||
volume={1}
|
||||
memberLabels={["vo-1", "vo-2"]}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onVolumeCommit={onVolumeCommit}
|
||||
theme={defaultTimelineTheme}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { onVolumeChange, onVolumeCommit };
|
||||
}
|
||||
|
||||
function slider(): HTMLInputElement {
|
||||
return container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
}
|
||||
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
|
||||
/** React tracks the DOM's own value setter to detect real changes — a plain
|
||||
* `input.value = ...` assignment is invisible to it, so onChange never
|
||||
* fires. Go through the native setter, same as this codebase's other
|
||||
* range/text input tests (e.g. propertyPanelFlatStyleSections.test.tsx). */
|
||||
function setSliderValue(input: HTMLInputElement, value: string) {
|
||||
nativeInputValueSetter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("TimelineGroupBusStrip", () => {
|
||||
it('renders "Holds …" from the member labels, comma-joined', () => {
|
||||
renderStrip({ memberLabels: ["vo-1", "vo-2"] });
|
||||
expect(container.textContent).toContain("Holds vo-1, vo-2");
|
||||
});
|
||||
|
||||
it("falls back to a neutral line when a group has no members yet", () => {
|
||||
renderStrip({ memberLabels: [] });
|
||||
expect(container.textContent).toContain("Holds nothing yet");
|
||||
});
|
||||
|
||||
it("live-writes on every drag tick, but only commits once on release", () => {
|
||||
const { onVolumeChange, onVolumeCommit } = renderStrip({ volume: 1 });
|
||||
const input = slider();
|
||||
|
||||
act(() => setSliderValue(input, "1.2"));
|
||||
act(() => setSliderValue(input, "1.5"));
|
||||
expect(onVolumeChange).toHaveBeenCalledTimes(2);
|
||||
expect(onVolumeChange).toHaveBeenLastCalledWith(1.5);
|
||||
expect(onVolumeCommit).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
input.value = "1.5";
|
||||
input.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
|
||||
});
|
||||
expect(onVolumeCommit).toHaveBeenCalledTimes(1);
|
||||
expect(onVolumeCommit).toHaveBeenCalledWith(1.5);
|
||||
});
|
||||
|
||||
it("clamps the volume slider to the 0..2 range", () => {
|
||||
const { onVolumeChange } = renderStrip();
|
||||
const input = slider();
|
||||
expect(input.min).toBe("0");
|
||||
expect(input.max).toBe("2");
|
||||
|
||||
act(() => setSliderValue(input, "2"));
|
||||
expect(onVolumeChange).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
|
||||
it("the level bar tracks a live reading and shows nothing extra when it isn't clipping", () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip();
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.4, clipped: false }]]));
|
||||
vi.advanceTimersByTime(40); // past useGroupLevel's 33ms throttle
|
||||
});
|
||||
expect(container.textContent).not.toContain("Too loud");
|
||||
});
|
||||
|
||||
it('shows "Too loud" while clipped and holds it for ~2s after clipping stops', () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip();
|
||||
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent).toContain("Too loud");
|
||||
|
||||
// Clipping stops — the warning must still hold for a couple seconds.
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.2, clipped: false }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent).toContain("Too loud");
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2001);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Too loud");
|
||||
});
|
||||
|
||||
it("never renders a dB number anywhere in the strip (design constraint: no dB, no peak-hold readout)", () => {
|
||||
vi.useFakeTimers();
|
||||
renderStrip({ volume: 1.5 });
|
||||
act(() => {
|
||||
groupLevels.notify(new Map([["vo", { level: 0.9, clipped: true }]]));
|
||||
vi.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.textContent ?? "").not.toMatch(/dB/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* B7: the group's own volume slider + a living level bar + "Holds …" — the
|
||||
* bus, not the mechanism. No dB numbers, no peak-hold readout, no routing
|
||||
* row (groups doc §5, casual-user section) — a slider, a bar that moves with
|
||||
* the sound, and the words "Too loud" when it clips.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGroupLevel } from "../../hooks/useGroupLevel";
|
||||
import { STRIP_H, TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
|
||||
/** How long "Too loud" stays lit after the last clipped block. */
|
||||
const CLIP_HOLD_MS = 2000;
|
||||
|
||||
function clampVolume(value: number): number {
|
||||
return Math.min(2, Math.max(0, value));
|
||||
}
|
||||
|
||||
interface TimelineGroupBusStripProps {
|
||||
groupId: string;
|
||||
volume: number;
|
||||
memberLabels: readonly string[];
|
||||
onVolumeChange: (value: number) => void;
|
||||
onVolumeCommit: (value: number) => void;
|
||||
theme: TimelineTheme;
|
||||
}
|
||||
|
||||
export function TimelineGroupBusStrip({
|
||||
groupId,
|
||||
volume,
|
||||
memberLabels,
|
||||
onVolumeChange,
|
||||
onVolumeCommit,
|
||||
theme,
|
||||
}: TimelineGroupBusStripProps) {
|
||||
const [dragValue, setDragValue] = useState<number | null>(null);
|
||||
const reading = useGroupLevel(groupId);
|
||||
const [clipped, setClipped] = useState(false);
|
||||
const clipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reading?.clipped) return;
|
||||
setClipped(true);
|
||||
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
||||
clipTimerRef.current = setTimeout(() => setClipped(false), CLIP_HOLD_MS);
|
||||
}, [reading?.clipped]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (clipTimerRef.current) clearTimeout(clipTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const shownVolume = dragValue ?? volume;
|
||||
const level = Math.min(1, reading?.level ?? 0);
|
||||
const holdsText =
|
||||
memberLabels.length > 0 ? `Holds ${memberLabels.join(", ")}` : "Holds nothing yet";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-center gap-2 px-2 text-[10px] text-white/70"
|
||||
style={{ top: TRACK_H, height: STRIP_H }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Group volume"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.01}
|
||||
value={shownVolume}
|
||||
className="h-1 w-20 shrink-0 accent-[#3CE6AC]"
|
||||
onChange={(event) => {
|
||||
const next = clampVolume(Number(event.currentTarget.value));
|
||||
setDragValue(next);
|
||||
onVolumeChange(next);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const next = clampVolume(Number(event.currentTarget.value));
|
||||
setDragValue(null);
|
||||
onVolumeCommit(next);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative h-1.5 w-16 shrink-0 overflow-hidden rounded-full"
|
||||
style={{ background: theme.gutterBorder }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: `${level * 100}%`,
|
||||
background: clipped ? "#ff5c5c" : "#3CE6AC",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{clipped && <span className="shrink-0 font-medium text-[#ff5c5c]">Too loud</span>}
|
||||
<span className="min-w-0 flex-1 truncate" title={holdsText}>
|
||||
{holdsText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||
import { TimelineTrackRow } from "./TimelineTrackRow";
|
||||
import { TimelineGroupHeader } from "./TimelineGroupHeader";
|
||||
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
import { LABEL_COL_W } from "./timelineLayout";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
|
||||
interface TimelineGroupRowProps {
|
||||
index: number;
|
||||
@@ -46,6 +48,12 @@ export function TimelineGroupRow({
|
||||
const memberElements = group.memberTracks.flatMap(
|
||||
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
|
||||
);
|
||||
const memberLabels = group.memberTracks.map((track, i) => {
|
||||
const owner = tracks.find(([t]) => t === track)?.[1]?.find((el) => el.audioGroup);
|
||||
return owner?.label ?? owner?.id ?? `track ${i + 1}`;
|
||||
});
|
||||
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
||||
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
|
||||
return (
|
||||
<TimelineTrackRow
|
||||
index={index}
|
||||
@@ -67,11 +75,30 @@ export function TimelineGroupRow({
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
||||
laneCount={groupAutomationLanes(memberElements).length}
|
||||
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
|
||||
isLaneOpen={isLaneOpen}
|
||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
theme={theme}
|
||||
/>
|
||||
{isLaneOpen && (
|
||||
<TimelineGroupBusStrip
|
||||
groupId={group.id}
|
||||
volume={group.volume}
|
||||
memberLabels={memberLabels}
|
||||
onVolumeChange={(value) =>
|
||||
onSetAudioGroupAttributeLive?.(group.id, "data-volume", String(value))
|
||||
}
|
||||
onVolumeCommit={(value) =>
|
||||
onSetAudioGroupAttributeQuiet?.(
|
||||
group.id,
|
||||
"data-volume",
|
||||
String(value),
|
||||
"Set group volume",
|
||||
)
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</TimelineTrackRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,15 @@ export interface TimelineEditCallbacks {
|
||||
options?: { coalesceKey?: string },
|
||||
) => Promise<void> | void;
|
||||
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
||||
/** B7's bus strip: live-write the group's own attribute while dragging. */
|
||||
onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
|
||||
/** ...and persist one undo entry on release. */
|
||||
onSetAudioGroupAttributeQuiet?: (
|
||||
groupId: string,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
|
||||
@@ -7,10 +7,10 @@ export const GUTTER = 32;
|
||||
export const LABEL_COL_W = 232;
|
||||
export const TRACK_H = 48;
|
||||
export const LANE_H = 28;
|
||||
export const STRIP_H = 40; // group bus strip (B7) — one fixed block, not per-property like LANE_H
|
||||
export const RULER_H = 24;
|
||||
export const CLIP_Y = 3;
|
||||
export const CLIP_HANDLE_W = 18;
|
||||
|
||||
export interface TimelineBeatEntry {
|
||||
readonly index: number;
|
||||
readonly time: number;
|
||||
|
||||
@@ -16,31 +16,68 @@ export interface TimelineTrackGroupInfo {
|
||||
anchorKey: number;
|
||||
/** Member track numbers, ascending. */
|
||||
memberTracks: number[];
|
||||
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface GroupMembership {
|
||||
trackToGroupId: Map<number, string>;
|
||||
memberTracksByGroup: Map<string, number[]>;
|
||||
labelByGroup: Map<string, string>;
|
||||
volumeByGroup: Map<string, number>;
|
||||
}
|
||||
|
||||
/** Which track belongs to which group, and each group's label — one pass over raw tracks. */
|
||||
/** Which track belongs to which group, and each group's label/volume — one pass over raw tracks. */
|
||||
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
|
||||
const trackToGroupId = new Map<number, string>();
|
||||
const memberTracksByGroup = new Map<string, number[]>();
|
||||
const labelByGroup = new Map<string, string>();
|
||||
const volumeByGroup = new Map<string, number>();
|
||||
for (const [trackNum, elements] of rawTracks) {
|
||||
const owner = elements.find((el) => el.audioGroup);
|
||||
if (!owner?.audioGroup) continue;
|
||||
trackToGroupId.set(trackNum, owner.audioGroup);
|
||||
if (!labelByGroup.has(owner.audioGroup)) {
|
||||
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
||||
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
||||
}
|
||||
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
||||
members.push(trackNum);
|
||||
memberTracksByGroup.set(owner.audioGroup, members);
|
||||
}
|
||||
return { trackToGroupId, memberTracksByGroup, labelByGroup };
|
||||
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup };
|
||||
}
|
||||
|
||||
/** One group's resolved row info, built once the first time its id is seen. */
|
||||
function buildGroupInfo(
|
||||
groupId: string,
|
||||
fallbackTrackNum: number,
|
||||
membership: GroupMembership,
|
||||
): TimelineTrackGroupInfo {
|
||||
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
return {
|
||||
id: groupId,
|
||||
label: membership.labelByGroup.get(groupId) ?? groupId,
|
||||
anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
|
||||
memberTracks,
|
||||
volume: membership.volumeByGroup.get(groupId) ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Push a group's synthetic anchor row plus its members' rows, contiguously. */
|
||||
function emitGroupRows(
|
||||
info: TimelineTrackGroupInfo,
|
||||
rawByTrack: ReadonlyMap<number, TimelineElement[]>,
|
||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>,
|
||||
tracks: [number, TimelineElement[]][],
|
||||
): void {
|
||||
tracks.push([info.anchorKey, []]);
|
||||
for (const member of info.memberTracks) {
|
||||
trackGroupOf.set(member, info);
|
||||
tracks.push([member, rawByTrack.get(member) ?? []]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +91,7 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
||||
groups: TimelineTrackGroupInfo[];
|
||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
||||
} {
|
||||
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
|
||||
const membership = resolveGroupMembership(rawTracks);
|
||||
const rawByTrack = new Map(rawTracks);
|
||||
const groups: TimelineTrackGroupInfo[] = [];
|
||||
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
|
||||
@@ -62,26 +99,16 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
||||
const tracks: [number, TimelineElement[]][] = [];
|
||||
|
||||
for (const [trackNum, elements] of rawTracks) {
|
||||
const groupId = trackToGroupId.get(trackNum);
|
||||
const groupId = membership.trackToGroupId.get(trackNum);
|
||||
if (!groupId) {
|
||||
tracks.push([trackNum, elements]);
|
||||
continue;
|
||||
}
|
||||
if (emitted.has(groupId)) continue;
|
||||
emitted.add(groupId);
|
||||
const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
|
||||
const info: TimelineTrackGroupInfo = {
|
||||
id: groupId,
|
||||
label: labelByGroup.get(groupId) ?? groupId,
|
||||
anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
|
||||
memberTracks,
|
||||
};
|
||||
const info = buildGroupInfo(groupId, trackNum, membership);
|
||||
groups.push(info);
|
||||
tracks.push([info.anchorKey, []]);
|
||||
for (const member of memberTracks) {
|
||||
trackGroupOf.set(member, info);
|
||||
tracks.push([member, rawByTrack.get(member) ?? []]);
|
||||
}
|
||||
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
|
||||
}
|
||||
return { tracks, groups, trackGroupOf };
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||
import {
|
||||
STRIP_H,
|
||||
TRACK_H,
|
||||
createTimelineRowGeometry,
|
||||
type TimelineRowGeometry,
|
||||
trackHeights,
|
||||
type TimelineTrackHeightClip,
|
||||
} from "./timelineLayout";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
|
||||
export { getTrackStyle } from "./timelineIcons";
|
||||
|
||||
@@ -127,13 +129,35 @@ function computeLaneCounts(
|
||||
return laneCounts;
|
||||
}
|
||||
|
||||
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
|
||||
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
|
||||
* looks at a row's clips — always gives them TRACK_H. Override those
|
||||
* specific rows post-hoc: TRACK_H while collapsed, +STRIP_H once the
|
||||
* group's own `∿` (bus strip) is open. */
|
||||
function applyGroupStripHeights(
|
||||
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
|
||||
rowHeights: number[],
|
||||
groups: readonly TimelineTrackGroupInfo[],
|
||||
expandedLaneOwnerIds: ReadonlySet<string>,
|
||||
): number[] {
|
||||
if (groups.length === 0) return rowHeights;
|
||||
const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
|
||||
return tracks.map(([track], index) => {
|
||||
const group = groupByAnchor.get(track);
|
||||
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
|
||||
return TRACK_H + STRIP_H;
|
||||
});
|
||||
}
|
||||
|
||||
function useTimelineRowHeights(
|
||||
tracks: [number, TimelineElement[]][],
|
||||
gsapAnimations: Map<string, GsapAnimation[]>,
|
||||
selectedElementId: string | null,
|
||||
selectedElementIds: ReadonlySet<string>,
|
||||
groups: readonly TimelineTrackGroupInfo[],
|
||||
) {
|
||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
|
||||
const { laneCounts, rowGeometry } = useMemo(() => {
|
||||
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
||||
// Keyframe lanes follow only the active clip, so a track with several
|
||||
@@ -163,7 +187,12 @@ function useTimelineRowHeights(
|
||||
},
|
||||
];
|
||||
});
|
||||
const rowHeights = trackHeights(heightTracks, expandedClipIds);
|
||||
const rowHeights = applyGroupStripHeights(
|
||||
tracks,
|
||||
trackHeights(heightTracks, expandedClipIds),
|
||||
groups,
|
||||
expandedLaneOwnerIds,
|
||||
);
|
||||
return {
|
||||
laneCounts,
|
||||
rowGeometry: createTimelineRowGeometry(
|
||||
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
|
||||
rowHeights,
|
||||
),
|
||||
};
|
||||
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
||||
}, [
|
||||
expandedClipIds,
|
||||
expandedLaneOwnerIds,
|
||||
gsapAnimations,
|
||||
groups,
|
||||
tracks,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
]);
|
||||
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
|
||||
rowGeometryRef.current = rowGeometry;
|
||||
return {
|
||||
@@ -197,6 +234,7 @@ export function useTimelineTrackLayout(
|
||||
gsapAnimations,
|
||||
selectedElementId,
|
||||
selectedElementIds,
|
||||
groups,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/
|
||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
||||
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
|
||||
import { timelineElementsChanged } from "./timelinePlayerSync";
|
||||
|
||||
export function useTimelinePlayer() {
|
||||
@@ -500,6 +501,11 @@ export function useTimelinePlayer() {
|
||||
if (data?.source === "hf-preview") {
|
||||
if (!acceptStudioRuntimeMessage(data)) return;
|
||||
}
|
||||
if (data?.source === "hf-preview" && data?.type === "group-levels") {
|
||||
const levels = parseGroupLevelsMessage(data);
|
||||
if (levels) groupLevels.notify(levels);
|
||||
return;
|
||||
}
|
||||
if (data?.source === "hf-preview" && data?.type === "state") {
|
||||
try {
|
||||
if (usePlayerStore.getState().elements.length === 0) {
|
||||
|
||||
@@ -70,16 +70,24 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
const groupLabelCache = new WeakMap<Document, Map<string, string>>();
|
||||
const groupInfoCache = new WeakMap<Document, Map<string, { label: string; volume: number }>>();
|
||||
|
||||
function groupLabelFor(doc: Document | null | undefined, groupId: string): string {
|
||||
if (!doc) return groupId;
|
||||
let labels = groupLabelCache.get(doc);
|
||||
if (!labels) {
|
||||
labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
|
||||
groupLabelCache.set(doc, labels);
|
||||
function groupInfoFor(
|
||||
doc: Document | null | undefined,
|
||||
groupId: string,
|
||||
): { label: string; volume: number } {
|
||||
if (!doc) return { label: groupId, volume: 1 };
|
||||
let info = groupInfoCache.get(doc);
|
||||
if (!info) {
|
||||
info = new Map(
|
||||
resolveAudioGroups(doc).map((group) => [
|
||||
group.id,
|
||||
{ label: group.label, volume: group.volume },
|
||||
]),
|
||||
);
|
||||
groupInfoCache.set(doc, info);
|
||||
}
|
||||
return labels.get(groupId) ?? groupId;
|
||||
return info.get(groupId) ?? { label: groupId, volume: 1 };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
@@ -156,7 +164,9 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
const audioGroup = hostEl.getAttribute("data-audio-group");
|
||||
if (audioGroup) {
|
||||
entry.audioGroup = audioGroup;
|
||||
entry.audioGroupLabel = groupLabelFor(doc ?? hostEl.ownerDocument, audioGroup);
|
||||
const info = groupInfoFor(doc ?? hostEl.ownerDocument, audioGroup);
|
||||
entry.audioGroupLabel = info.label;
|
||||
entry.audioGroupVolume = info.volume;
|
||||
}
|
||||
const fxChain = hostEl.getAttribute("data-fx-chain");
|
||||
if (fxChain) entry.fxChain = fxChain;
|
||||
@@ -379,7 +389,9 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
const domAudioGroup = el.getAttribute("data-audio-group");
|
||||
if (domAudioGroup) {
|
||||
entry.audioGroup = domAudioGroup;
|
||||
entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
|
||||
const domGroupInfo = groupInfoFor(doc, domAudioGroup);
|
||||
entry.audioGroupLabel = domGroupInfo.label;
|
||||
entry.audioGroupVolume = domGroupInfo.volume;
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// ponytail: mirrors liveTime.ts's plain pub-sub — same throttle-at-the-edge shape.
|
||||
/** A group's live meter reading, or absent when idle/unknown (never zero). */
|
||||
export type GroupLevelReading = { level: number; clipped: boolean };
|
||||
|
||||
type Listener = (levels: ReadonlyMap<string, GroupLevelReading>) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let latest: ReadonlyMap<string, GroupLevelReading> = new Map();
|
||||
|
||||
export const groupLevels = {
|
||||
notify: (levels: ReadonlyMap<string, GroupLevelReading>) => {
|
||||
latest = levels;
|
||||
listeners.forEach((listener) => listener(levels));
|
||||
},
|
||||
subscribe: (listener: Listener) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
get: () => latest,
|
||||
};
|
||||
|
||||
/** Turns the runtime's `group-levels` postMessage payload into the map `groupLevels.notify` wants. */
|
||||
export function parseGroupLevelsMessage(
|
||||
data: unknown,
|
||||
): ReadonlyMap<string, GroupLevelReading> | null {
|
||||
const levels = (data as { levels?: unknown } | null)?.levels;
|
||||
if (!Array.isArray(levels)) return null;
|
||||
return new Map(
|
||||
(levels as Array<{ groupId: string; level: number; clipped: boolean }>).map((entry) => [
|
||||
entry.groupId,
|
||||
{ level: entry.level, clipped: entry.clipped },
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,8 @@ export interface TimelineElement {
|
||||
audioGroup?: string;
|
||||
/** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
|
||||
audioGroupLabel?: string;
|
||||
/** The owning group's `data-volume` (defaults to 1) — resolved once per parse. */
|
||||
audioGroupVolume?: number;
|
||||
/**
|
||||
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||
* child: the absolute master-timeline start of the sub-comp host the child
|
||||
|
||||
Reference in New Issue
Block a user