mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +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
6e7c189e14
commit
a11883e6d1
@@ -2140,6 +2140,25 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
scheduleRootStageLayoutDiagnostics();
|
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 =>
|
const finitePositiveDuration = (value: number | null | undefined): number =>
|
||||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
||||||
|
|
||||||
@@ -2897,6 +2916,9 @@ export function initSandboxRuntimeModular(): void {
|
|||||||
if (transportTickCount % 30 === 0) {
|
if (transportTickCount % 30 === 0) {
|
||||||
bindMediaMetadataListeners();
|
bindMediaMetadataListeners();
|
||||||
}
|
}
|
||||||
|
if (clock.isPlaying()) {
|
||||||
|
postGroupLevels();
|
||||||
|
}
|
||||||
|
|
||||||
// Sync clock duration with the resolved timeline each tick (catches async
|
// Sync clock duration with the resolved timeline each tick (catches async
|
||||||
// rebinds, live data-duration edits). Never shrink while playing — transient
|
// rebinds, live data-duration edits). Never shrink while playing — transient
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ export type RuntimePerformanceMessage = {
|
|||||||
tags: Record<string, string | number | boolean | null>;
|
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 =
|
export type RuntimeOutboundMessage =
|
||||||
| RuntimeStateMessage
|
| RuntimeStateMessage
|
||||||
| RuntimeTimelineMessage
|
| RuntimeTimelineMessage
|
||||||
@@ -210,7 +219,8 @@ export type RuntimeOutboundMessage =
|
|||||||
| RuntimeMediaAutoplayBlockedMessage
|
| RuntimeMediaAutoplayBlockedMessage
|
||||||
| RuntimeReadyMessage
|
| RuntimeReadyMessage
|
||||||
| RuntimeAnalyticsMessage
|
| RuntimeAnalyticsMessage
|
||||||
| RuntimePerformanceMessage;
|
| RuntimePerformanceMessage
|
||||||
|
| RuntimeGroupLevelsMessage;
|
||||||
|
|
||||||
export type RuntimePlayer = {
|
export type RuntimePlayer = {
|
||||||
_timeline: RuntimeTimelineLike | null;
|
_timeline: RuntimeTimelineLike | null;
|
||||||
|
|||||||
@@ -626,6 +626,12 @@ describe("WebAudioTransport", () => {
|
|||||||
connect: ReturnType<typeof vi.fn>;
|
connect: ReturnType<typeof vi.fn>;
|
||||||
disconnect: 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 masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
|
||||||
const ctx = {
|
const ctx = {
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -645,10 +651,20 @@ describe("WebAudioTransport", () => {
|
|||||||
gainNodes.push(node);
|
gainNodes.push(node);
|
||||||
return 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: {},
|
destination: {},
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
};
|
};
|
||||||
return { ctx, gainNodes, masterGain };
|
return { ctx, gainNodes, analysers, masterGain };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupGroupTransport(currentTime = 100) {
|
function setupGroupTransport(currentTime = 100) {
|
||||||
@@ -706,12 +722,14 @@ describe("WebAudioTransport", () => {
|
|||||||
await scheduleGrouped(transport, gen, "a", "vo");
|
await scheduleGrouped(transport, gen, "a", "vo");
|
||||||
await scheduleGrouped(transport, gen, "b", "vo");
|
await scheduleGrouped(transport, gen, "b", "vo");
|
||||||
|
|
||||||
// Member gain nodes: index 0 (a) and index 2 (b) — index 1 is the
|
// Member gain nodes: index 0 (a) and index 3 (b) — index 1/2 are the
|
||||||
// group's own input gain, built inside a's schedule call.
|
// group's own input/output gain pair (B7's meter taps `output`),
|
||||||
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(3);
|
// built inside a's schedule call.
|
||||||
|
expect(mock.gainNodes.length).toBeGreaterThanOrEqual(4);
|
||||||
const groupInput = firstGroupInput(mock);
|
const groupInput = firstGroupInput(mock);
|
||||||
|
const groupOutput = mock.gainNodes[2]!;
|
||||||
const aGain = mock.gainNodes[0]!;
|
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.
|
// Neither member connects straight to master — both feed the shared bus.
|
||||||
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
|
expect(aGain.connect).toHaveBeenCalledWith(groupInput);
|
||||||
@@ -719,9 +737,12 @@ describe("WebAudioTransport", () => {
|
|||||||
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
expect(aGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
expect(bGain.connect).not.toHaveBeenCalledWith(mock.masterGain);
|
||||||
|
|
||||||
// The bus itself is what reaches master — a plain sum, no processing,
|
// The bus's input never reaches master directly — it lands on the
|
||||||
// since neither member's group has a chain-bearing `<hf-audio-group>`.
|
// output gain (the dry passthrough, since neither member's group has a
|
||||||
expect(groupInput.connect).toHaveBeenCalledWith(mock.masterGain);
|
// 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 () => {
|
it("a second member of an already-open group does not rebuild the group bus", async () => {
|
||||||
@@ -740,7 +761,9 @@ describe("WebAudioTransport", () => {
|
|||||||
|
|
||||||
await scheduleGrouped(transport, gen, "a", "orphan-group"); // no matching element
|
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 () => {
|
it("group volume rides the group's own data-volume via its automation lane, not the member's", async () => {
|
||||||
@@ -775,6 +798,66 @@ describe("WebAudioTransport", () => {
|
|||||||
// Still only one group-input gain ever created for "vo".
|
// Still only one group-input gain ever created for "vo".
|
||||||
expect(mock.gainNodes.filter((n) => n === groupInput)).toHaveLength(1);
|
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)", () => {
|
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
|
// group is scheduled. Lives for the session (mirrors `_masterGain`'s own
|
||||||
// lifecycle) rather than being torn down on every `stopAll()`, so replaying
|
// lifecycle) rather than being torn down on every `stopAll()`, so replaying
|
||||||
// a group does not rebuild its chain; only `destroy()` disposes these.
|
// 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 reference frame: at AudioContext time `_rateAnchorCtx`,
|
||||||
// composition time was `_rateAnchorComp`, and time has been advancing at
|
// composition time was `_rateAnchorComp`, and time has been advancing at
|
||||||
// `_rate` composition-seconds per wallclock-second since.
|
// `_rate` composition-seconds per wallclock-second since.
|
||||||
@@ -294,22 +297,39 @@ export class WebAudioTransport {
|
|||||||
if (!this._ctx || !this._masterGain) return null;
|
if (!this._ctx || !this._masterGain) return null;
|
||||||
|
|
||||||
const input = this._ctx.createGain();
|
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 groupEl = doc.getElementById(groupId);
|
||||||
const fx = attachElementFxChain(
|
const fx = attachElementFxChain(
|
||||||
this._ctx,
|
this._ctx,
|
||||||
groupEl ?? { getAttribute: () => null },
|
groupEl ?? { getAttribute: () => null },
|
||||||
input,
|
input,
|
||||||
this._masterGain,
|
output,
|
||||||
timing,
|
timing,
|
||||||
);
|
);
|
||||||
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
if (groupEl) scheduleVolumeLane(groupEl, input, timing);
|
||||||
|
|
||||||
this._groups.set(groupId, {
|
this._groups.set(groupId, {
|
||||||
input,
|
input,
|
||||||
|
analyser,
|
||||||
|
levelBuf: new Float32Array(analyser.fftSize),
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
try {
|
try {
|
||||||
fx?.dispose();
|
fx?.dispose();
|
||||||
input.disconnect();
|
input.disconnect();
|
||||||
|
output.disconnect();
|
||||||
|
analyser.disconnect();
|
||||||
} catch {
|
} catch {
|
||||||
// Already torn down.
|
// Already torn down.
|
||||||
}
|
}
|
||||||
@@ -318,6 +338,30 @@ export class WebAudioTransport {
|
|||||||
return input;
|
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
|
/** Master, unless `el` belongs to a group — then that group's bus (built on
|
||||||
* first use, per `groupInput`). */
|
* first use, per `groupInput`). */
|
||||||
private resolveDestination(
|
private resolveDestination(
|
||||||
|
|||||||
@@ -548,6 +548,7 @@ export function StudioApp() {
|
|||||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||||
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
|
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
|
||||||
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
||||||
|
setAudioGroupAttribute={timelineEditing.setAudioGroupAttribute}
|
||||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||||
handleRazorSplit={timelineEditing.handleRazorSplit}
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe("EditorShell timeline selection sync", () => {
|
|||||||
handleTimelineElementResize={vi.fn()}
|
handleTimelineElementResize={vi.fn()}
|
||||||
handleTimelineGroupResize={vi.fn()}
|
handleTimelineGroupResize={vi.fn()}
|
||||||
handleToggleTrackHidden={vi.fn()}
|
handleToggleTrackHidden={vi.fn()}
|
||||||
|
setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
|
||||||
handleBlockedTimelineEdit={vi.fn()}
|
handleBlockedTimelineEdit={vi.fn()}
|
||||||
handleTimelineElementSplit={vi.fn()}
|
handleTimelineElementSplit={vi.fn()}
|
||||||
handleRazorSplit={vi.fn()}
|
handleRazorSplit={vi.fn()}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function EditorShell({
|
|||||||
handleTimelineElementResize,
|
handleTimelineElementResize,
|
||||||
handleTimelineGroupResize,
|
handleTimelineGroupResize,
|
||||||
handleToggleTrackHidden,
|
handleToggleTrackHidden,
|
||||||
|
setAudioGroupAttribute,
|
||||||
handleBlockedTimelineEdit,
|
handleBlockedTimelineEdit,
|
||||||
handleTimelineElementSplit,
|
handleTimelineElementSplit,
|
||||||
handleRazorSplit,
|
handleRazorSplit,
|
||||||
@@ -135,6 +136,7 @@ export function EditorShell({
|
|||||||
handleTimelineElementResize,
|
handleTimelineElementResize,
|
||||||
handleTimelineGroupResize,
|
handleTimelineGroupResize,
|
||||||
handleToggleTrackHidden,
|
handleToggleTrackHidden,
|
||||||
|
setAudioGroupAttribute,
|
||||||
handleBlockedTimelineEdit,
|
handleBlockedTimelineEdit,
|
||||||
handleTimelineElementSplit,
|
handleTimelineElementSplit,
|
||||||
handleRazorSplit,
|
handleRazorSplit,
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => v
|
|||||||
handleTimelineElementResize: vi.fn(),
|
handleTimelineElementResize: vi.fn(),
|
||||||
handleTimelineGroupResize: vi.fn(),
|
handleTimelineGroupResize: vi.fn(),
|
||||||
handleToggleTrackHidden: vi.fn(),
|
handleToggleTrackHidden: vi.fn(),
|
||||||
|
setAudioGroupAttribute: { setLive: vi.fn(), setQuiet: vi.fn() },
|
||||||
handleBlockedTimelineEdit: vi.fn(),
|
handleBlockedTimelineEdit: vi.fn(),
|
||||||
handleTimelineElementSplit: vi.fn(),
|
handleTimelineElementSplit: vi.fn(),
|
||||||
handleRazorSplit: vi.fn(),
|
handleRazorSplit: vi.fn(),
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export interface TimelineEditCallbackDeps {
|
|||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
handleTimelineGroupResize: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
|
||||||
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
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;
|
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||||
@@ -99,6 +103,7 @@ export function useTimelineEditCallbacks({
|
|||||||
handleTimelineElementResize,
|
handleTimelineElementResize,
|
||||||
handleTimelineGroupResize,
|
handleTimelineGroupResize,
|
||||||
handleToggleTrackHidden,
|
handleToggleTrackHidden,
|
||||||
|
setAudioGroupAttribute,
|
||||||
handleBlockedTimelineEdit,
|
handleBlockedTimelineEdit,
|
||||||
handleTimelineElementSplit,
|
handleTimelineElementSplit,
|
||||||
handleRazorSplit,
|
handleRazorSplit,
|
||||||
@@ -185,6 +190,8 @@ export function useTimelineEditCallbacks({
|
|||||||
onResizeElement: handleTimelineElementResize,
|
onResizeElement: handleTimelineElementResize,
|
||||||
onResizeElements: handleTimelineGroupResize,
|
onResizeElements: handleTimelineGroupResize,
|
||||||
onToggleTrackHidden: handleToggleTrackHidden,
|
onToggleTrackHidden: handleToggleTrackHidden,
|
||||||
|
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
|
||||||
|
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
|
||||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||||
onSplitElement: handleTimelineElementSplit,
|
onSplitElement: handleTimelineElementSplit,
|
||||||
onRazorSplit: handleRazorSplit,
|
onRazorSplit: handleRazorSplit,
|
||||||
@@ -378,6 +385,7 @@ export function useTimelineEditCallbacks({
|
|||||||
handleTimelineElementResize,
|
handleTimelineElementResize,
|
||||||
handleTimelineGroupResize,
|
handleTimelineGroupResize,
|
||||||
handleToggleTrackHidden,
|
handleToggleTrackHidden,
|
||||||
|
setAudioGroupAttribute,
|
||||||
handleBlockedTimelineEdit,
|
handleBlockedTimelineEdit,
|
||||||
handleTimelineElementSplit,
|
handleTimelineElementSplit,
|
||||||
handleRazorSplit,
|
handleRazorSplit,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function TimelineEditProvider({
|
|||||||
value.onMoveElements,
|
value.onMoveElements,
|
||||||
value.onResizeElement,
|
value.onResizeElement,
|
||||||
value.onToggleTrackHidden,
|
value.onToggleTrackHidden,
|
||||||
|
value.onSetAudioGroupAttributeLive,
|
||||||
|
value.onSetAudioGroupAttributeQuiet,
|
||||||
value.onBlockedEditAttempt,
|
value.onBlockedEditAttempt,
|
||||||
value.onSplitElement,
|
value.onSplitElement,
|
||||||
value.onRazorSplit,
|
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,
|
type RecordEditInput,
|
||||||
} from "./timelineEditingHelpers";
|
} from "./timelineEditingHelpers";
|
||||||
|
|
||||||
interface MutableRef<T> {
|
export interface MutableRef<T> {
|
||||||
current: T;
|
current: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ interface UseTimelineTrackVisibilityEditingInput extends Omit<
|
|||||||
forceReloadSdkSession?: () => void;
|
forceReloadSdkSession?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseTimelineElementVisibilityEditingInput extends Omit<
|
export interface UseTimelineElementVisibilityEditingInput extends Omit<
|
||||||
ToggleTimelineElementHiddenInput,
|
ToggleTimelineElementHiddenInput,
|
||||||
"projectId" | "elementKey" | "hidden" | "previewIframe" | "timelineElements"
|
"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";
|
} from "./timelineTimingSync";
|
||||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||||
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||||
|
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
||||||
import {
|
import {
|
||||||
useAudioGroupCarveAssignment,
|
useAudioGroupCarveAssignment,
|
||||||
useTimelineElementVisibilityEditing,
|
useTimelineElementVisibilityEditing,
|
||||||
@@ -401,6 +402,18 @@ export function useTimelineEditing({
|
|||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
||||||
|
projectIdRef,
|
||||||
|
activeCompPath,
|
||||||
|
showToast,
|
||||||
|
writeProjectFile,
|
||||||
|
recordEdit,
|
||||||
|
domEditSaveTimestampRef,
|
||||||
|
previewIframeRef,
|
||||||
|
pendingTimelineEditPathRef,
|
||||||
|
isRecordingRef,
|
||||||
|
});
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
const handleTimelineElementsDelete = useCallback(
|
const handleTimelineElementsDelete = useCallback(
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -572,6 +585,7 @@ export function useTimelineEditing({
|
|||||||
handleToggleTrackHidden,
|
handleToggleTrackHidden,
|
||||||
handleToggleElementHidden,
|
handleToggleElementHidden,
|
||||||
handleAutoGroupCarveSources,
|
handleAutoGroupCarveSources,
|
||||||
|
setAudioGroupAttribute,
|
||||||
handleTimelineElementDelete,
|
handleTimelineElementDelete,
|
||||||
handleTimelineElementsDelete,
|
handleTimelineElementsDelete,
|
||||||
handleTimelineElementSplit: handleRazorSplit,
|
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 type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||||
import { TimelineTrackRow } from "./TimelineTrackRow";
|
import { TimelineTrackRow } from "./TimelineTrackRow";
|
||||||
import { TimelineGroupHeader } from "./TimelineGroupHeader";
|
import { TimelineGroupHeader } from "./TimelineGroupHeader";
|
||||||
|
import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
||||||
import { groupAutomationLanes } from "./automationLaneData";
|
import { groupAutomationLanes } from "./automationLaneData";
|
||||||
import { LABEL_COL_W } from "./timelineLayout";
|
import { LABEL_COL_W } from "./timelineLayout";
|
||||||
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||||
|
|
||||||
interface TimelineGroupRowProps {
|
interface TimelineGroupRowProps {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -46,6 +48,12 @@ export function TimelineGroupRow({
|
|||||||
const memberElements = group.memberTracks.flatMap(
|
const memberElements = group.memberTracks.flatMap(
|
||||||
(track) => tracks.find(([t]) => t === track)?.[1] ?? [],
|
(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 (
|
return (
|
||||||
<TimelineTrackRow
|
<TimelineTrackRow
|
||||||
index={index}
|
index={index}
|
||||||
@@ -67,11 +75,30 @@ export function TimelineGroupRow({
|
|||||||
isExpanded={expandedGroupIds.has(group.id)}
|
isExpanded={expandedGroupIds.has(group.id)}
|
||||||
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
onToggleExpanded={() => toggleGroupExpanded(group.id)}
|
||||||
laneCount={groupAutomationLanes(memberElements).length}
|
laneCount={groupAutomationLanes(memberElements).length}
|
||||||
isLaneOpen={expandedLaneOwnerIds.has(group.id)}
|
isLaneOpen={isLaneOpen}
|
||||||
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
|
||||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||||
theme={theme}
|
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>
|
</TimelineTrackRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export interface TimelineEditCallbacks {
|
|||||||
options?: { coalesceKey?: string },
|
options?: { coalesceKey?: string },
|
||||||
) => Promise<void> | void;
|
) => Promise<void> | void;
|
||||||
onToggleTrackHidden?: (track: number, hidden: boolean) => 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;
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||||
onRazorSplit?: (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 LABEL_COL_W = 232;
|
||||||
export const TRACK_H = 48;
|
export const TRACK_H = 48;
|
||||||
export const LANE_H = 28;
|
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 RULER_H = 24;
|
||||||
export const CLIP_Y = 3;
|
export const CLIP_Y = 3;
|
||||||
export const CLIP_HANDLE_W = 18;
|
export const CLIP_HANDLE_W = 18;
|
||||||
|
|
||||||
export interface TimelineBeatEntry {
|
export interface TimelineBeatEntry {
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly time: number;
|
readonly time: number;
|
||||||
|
|||||||
@@ -16,31 +16,68 @@ export interface TimelineTrackGroupInfo {
|
|||||||
anchorKey: number;
|
anchorKey: number;
|
||||||
/** Member track numbers, ascending. */
|
/** Member track numbers, ascending. */
|
||||||
memberTracks: number[];
|
memberTracks: number[];
|
||||||
|
/** The group element's `data-volume`, mirrored from a member's parse (B7's slider). */
|
||||||
|
volume: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupMembership {
|
interface GroupMembership {
|
||||||
trackToGroupId: Map<number, string>;
|
trackToGroupId: Map<number, string>;
|
||||||
memberTracksByGroup: Map<string, number[]>;
|
memberTracksByGroup: Map<string, number[]>;
|
||||||
labelByGroup: Map<string, string>;
|
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 {
|
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
|
||||||
const trackToGroupId = new Map<number, string>();
|
const trackToGroupId = new Map<number, string>();
|
||||||
const memberTracksByGroup = new Map<string, number[]>();
|
const memberTracksByGroup = new Map<string, number[]>();
|
||||||
const labelByGroup = new Map<string, string>();
|
const labelByGroup = new Map<string, string>();
|
||||||
|
const volumeByGroup = new Map<string, number>();
|
||||||
for (const [trackNum, elements] of rawTracks) {
|
for (const [trackNum, elements] of rawTracks) {
|
||||||
const owner = elements.find((el) => el.audioGroup);
|
const owner = elements.find((el) => el.audioGroup);
|
||||||
if (!owner?.audioGroup) continue;
|
if (!owner?.audioGroup) continue;
|
||||||
trackToGroupId.set(trackNum, owner.audioGroup);
|
trackToGroupId.set(trackNum, owner.audioGroup);
|
||||||
if (!labelByGroup.has(owner.audioGroup)) {
|
if (!labelByGroup.has(owner.audioGroup)) {
|
||||||
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
||||||
|
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
||||||
}
|
}
|
||||||
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
||||||
members.push(trackNum);
|
members.push(trackNum);
|
||||||
memberTracksByGroup.set(owner.audioGroup, members);
|
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[];
|
groups: TimelineTrackGroupInfo[];
|
||||||
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
trackGroupOf: Map<number, TimelineTrackGroupInfo>;
|
||||||
} {
|
} {
|
||||||
const { trackToGroupId, memberTracksByGroup, labelByGroup } = resolveGroupMembership(rawTracks);
|
const membership = resolveGroupMembership(rawTracks);
|
||||||
const rawByTrack = new Map(rawTracks);
|
const rawByTrack = new Map(rawTracks);
|
||||||
const groups: TimelineTrackGroupInfo[] = [];
|
const groups: TimelineTrackGroupInfo[] = [];
|
||||||
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
|
const trackGroupOf = new Map<number, TimelineTrackGroupInfo>();
|
||||||
@@ -62,26 +99,16 @@ function groupTimelineTracks(rawTracks: [number, TimelineElement[]][]): {
|
|||||||
const tracks: [number, TimelineElement[]][] = [];
|
const tracks: [number, TimelineElement[]][] = [];
|
||||||
|
|
||||||
for (const [trackNum, elements] of rawTracks) {
|
for (const [trackNum, elements] of rawTracks) {
|
||||||
const groupId = trackToGroupId.get(trackNum);
|
const groupId = membership.trackToGroupId.get(trackNum);
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
tracks.push([trackNum, elements]);
|
tracks.push([trackNum, elements]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (emitted.has(groupId)) continue;
|
if (emitted.has(groupId)) continue;
|
||||||
emitted.add(groupId);
|
emitted.add(groupId);
|
||||||
const memberTracks = [...(memberTracksByGroup.get(groupId) ?? [])].sort((a, b) => a - b);
|
const info = buildGroupInfo(groupId, trackNum, membership);
|
||||||
const info: TimelineTrackGroupInfo = {
|
|
||||||
id: groupId,
|
|
||||||
label: labelByGroup.get(groupId) ?? groupId,
|
|
||||||
anchorKey: (memberTracks[0] ?? trackNum) - 0.5,
|
|
||||||
memberTracks,
|
|
||||||
};
|
|
||||||
groups.push(info);
|
groups.push(info);
|
||||||
tracks.push([info.anchorKey, []]);
|
emitGroupRows(info, rawByTrack, trackGroupOf, tracks);
|
||||||
for (const member of memberTracks) {
|
|
||||||
trackGroupOf.set(member, info);
|
|
||||||
tracks.push([member, rawByTrack.get(member) ?? []]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { tracks, groups, trackGroupOf };
|
return { tracks, groups, trackGroupOf };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|||||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||||
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
||||||
import {
|
import {
|
||||||
|
STRIP_H,
|
||||||
TRACK_H,
|
TRACK_H,
|
||||||
createTimelineRowGeometry,
|
createTimelineRowGeometry,
|
||||||
type TimelineRowGeometry,
|
type TimelineRowGeometry,
|
||||||
trackHeights,
|
trackHeights,
|
||||||
type TimelineTrackHeightClip,
|
type TimelineTrackHeightClip,
|
||||||
} from "./timelineLayout";
|
} from "./timelineLayout";
|
||||||
|
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||||
|
|
||||||
export { getTrackStyle } from "./timelineIcons";
|
export { getTrackStyle } from "./timelineIcons";
|
||||||
|
|
||||||
@@ -127,13 +129,35 @@ function computeLaneCounts(
|
|||||||
return laneCounts;
|
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(
|
function useTimelineRowHeights(
|
||||||
tracks: [number, TimelineElement[]][],
|
tracks: [number, TimelineElement[]][],
|
||||||
gsapAnimations: Map<string, GsapAnimation[]>,
|
gsapAnimations: Map<string, GsapAnimation[]>,
|
||||||
selectedElementId: string | null,
|
selectedElementId: string | null,
|
||||||
selectedElementIds: ReadonlySet<string>,
|
selectedElementIds: ReadonlySet<string>,
|
||||||
|
groups: readonly TimelineTrackGroupInfo[],
|
||||||
) {
|
) {
|
||||||
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
|
||||||
|
const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds);
|
||||||
const { laneCounts, rowGeometry } = useMemo(() => {
|
const { laneCounts, rowGeometry } = useMemo(() => {
|
||||||
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
const laneCounts = computeLaneCounts(tracks, gsapAnimations);
|
||||||
// Keyframe lanes follow only the active clip, so a track with several
|
// 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 {
|
return {
|
||||||
laneCounts,
|
laneCounts,
|
||||||
rowGeometry: createTimelineRowGeometry(
|
rowGeometry: createTimelineRowGeometry(
|
||||||
@@ -171,7 +200,15 @@ function useTimelineRowHeights(
|
|||||||
rowHeights,
|
rowHeights,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}, [expandedClipIds, gsapAnimations, tracks, selectedElementId, selectedElementIds]);
|
}, [
|
||||||
|
expandedClipIds,
|
||||||
|
expandedLaneOwnerIds,
|
||||||
|
gsapAnimations,
|
||||||
|
groups,
|
||||||
|
tracks,
|
||||||
|
selectedElementId,
|
||||||
|
selectedElementIds,
|
||||||
|
]);
|
||||||
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
|
const rowGeometryRef = useRef<TimelineRowGeometry>(rowGeometry);
|
||||||
rowGeometryRef.current = rowGeometry;
|
rowGeometryRef.current = rowGeometry;
|
||||||
return {
|
return {
|
||||||
@@ -197,6 +234,7 @@ export function useTimelineTrackLayout(
|
|||||||
gsapAnimations,
|
gsapAnimations,
|
||||||
selectedElementId,
|
selectedElementId,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
|
groups,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/
|
|||||||
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
||||||
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
||||||
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
||||||
|
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
|
||||||
import { timelineElementsChanged } from "./timelinePlayerSync";
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
||||||
|
|
||||||
export function useTimelinePlayer() {
|
export function useTimelinePlayer() {
|
||||||
@@ -500,6 +501,11 @@ export function useTimelinePlayer() {
|
|||||||
if (data?.source === "hf-preview") {
|
if (data?.source === "hf-preview") {
|
||||||
if (!acceptStudioRuntimeMessage(data)) return;
|
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") {
|
if (data?.source === "hf-preview" && data?.type === "state") {
|
||||||
try {
|
try {
|
||||||
if (usePlayerStore.getState().elements.length === 0) {
|
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
|
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||||
// walks the whole tree, and a parse touches every clip in it.
|
// 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 {
|
function groupInfoFor(
|
||||||
if (!doc) return groupId;
|
doc: Document | null | undefined,
|
||||||
let labels = groupLabelCache.get(doc);
|
groupId: string,
|
||||||
if (!labels) {
|
): { label: string; volume: number } {
|
||||||
labels = new Map(resolveAudioGroups(doc).map((group) => [group.id, group.label]));
|
if (!doc) return { label: groupId, volume: 1 };
|
||||||
groupLabelCache.set(doc, labels);
|
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
|
// fallow-ignore-next-line complexity
|
||||||
@@ -156,7 +164,9 @@ export function createTimelineElementFromManifestClip(params: {
|
|||||||
const audioGroup = hostEl.getAttribute("data-audio-group");
|
const audioGroup = hostEl.getAttribute("data-audio-group");
|
||||||
if (audioGroup) {
|
if (audioGroup) {
|
||||||
entry.audioGroup = 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");
|
const fxChain = hostEl.getAttribute("data-fx-chain");
|
||||||
if (fxChain) entry.fxChain = fxChain;
|
if (fxChain) entry.fxChain = fxChain;
|
||||||
@@ -379,7 +389,9 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|||||||
const domAudioGroup = el.getAttribute("data-audio-group");
|
const domAudioGroup = el.getAttribute("data-audio-group");
|
||||||
if (domAudioGroup) {
|
if (domAudioGroup) {
|
||||||
entry.audioGroup = domAudioGroup;
|
entry.audioGroup = domAudioGroup;
|
||||||
entry.audioGroupLabel = groupLabelFor(doc, domAudioGroup);
|
const domGroupInfo = groupInfoFor(doc, domAudioGroup);
|
||||||
|
entry.audioGroupLabel = domGroupInfo.label;
|
||||||
|
entry.audioGroupVolume = domGroupInfo.volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub-compositions
|
// 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;
|
audioGroup?: string;
|
||||||
/** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
|
/** The owning group's `data-label` (falls back to its id) — resolved once per parse. */
|
||||||
audioGroupLabel?: string;
|
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
|
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||||
* child: the absolute master-timeline start of the sub-comp host the child
|
* child: the absolute master-timeline start of the sub-comp host the child
|
||||||
|
|||||||
Reference in New Issue
Block a user