mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio,core): a volume and a living meter on the group row (#3290)
* 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> * fix(studio,core): keep useTimelinePlayer under the size cap and the level buffer non-shared Two CI gates, both from this branch's own additions. `File size check`: `useTimelinePlayer.ts` sat at 599 lines on main and the group-levels branch pushed it to 605 (cap 600). Extracted the `window.message` router — which already carried a `fallow-ignore-next-line complexity` admitting it had outgrown its home — into `previewMessageRouter.ts`, with the fixture lease, sender check and protocol accept-gate collapsed into one `acceptedPreviewMessage` so the listener is a flat dispatch and the suppression is retired rather than moved. Same branches, same refs, no behaviour change; the file lands at 561. `Test: runtime contract`: `levelBuf: Float32Array` resolves to `Float32Array<ArrayBufferLike>` under `tsconfig.runtime.json`, and `getFloatTimeDomainData` will not take a possibly-shared buffer (TS2345). Pinned the field to `Float32Array<ArrayBuffer>`, which is what `new Float32Array(analyser.fftSize)` already produces. Also drops `EditorShell.selectionSync.test.tsx`'s `vi.mock("./StudioFeedbackBar")` — main deleted that component in favour of `feedback/StudioFeedbackCard`, and touching this file for the group prop put the dangling path in fallow's scope. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
99f42be04c
commit
5fd84c395b
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user