mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio): close seven defects a review found in the previous two commits
Sixth review pass. Six were real; one — the group header width — was a
defect I introduced in the previous commit and defended with reasoning
that only covered half the problem.
The header overhang was wrong. `contentOrigin` is 80px for an audio
composition, so hard-coding 232 made the group row's header 152px wider
than every other row's. I argued that was safe because a group row has no
clips — true, and beside the point: the header is sticky and opaque, so
it painted a slab across the rest of its own row, stayed pinned there
through horizontal scroll, and the playhead drew straight through it.
Groups now turn `labelMode` on instead, which is what that flag is for.
Every row gets the same 232px header, verified in the browser.
Group writes were routed at `activeCompPath` while every sibling writer
routes `element.sourceFile || activeCompPath`. Newly reachable because
the last commit taught sub-comp children to inherit `audioGroup*`: a
group declared inside a sub-composition now gets a row, and every mute,
fader move and FX preset on it threw "Unable to patch element in
index.html".
`Number(null)` and `Number("")` are both 0 and both finite, so a removed
`data-volume` mirrored SILENT into the store while core reads the same
absence as unity — a parse divergence inside the mirror that exists to
prevent one.
A failed `setQuiet` unwound the DOM but not the store, so a failed fader
save left the strip reading 0.4 while the preview played 1.0, with
nothing to re-parse and correct it.
`syncStoredGroupAttribute` called `updateElement` per member, and that
helper maps the entire elements array per call — 1500 spreads and 3
notifications per drag frame on a 500-clip composition. One pass now.
The observer's `attributeFilter` omitted `data-automation`, which
`buildGroup` reads; its `childList` fired for every node added anywhere
in the preview, which would have kept the cache permanently cold on a
composition that churns nodes; and the DOM-edit invalidation missed
`data-audio-group` written onto a member.
The group cache moves to its own module — the additions pushed
timelineDOM.ts past the 600-line ceiling.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
25d7af8a5c
commit
6056ec7463
@@ -12,7 +12,7 @@ import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimeline
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
|
||||
@@ -85,6 +85,32 @@ describe("group attribute writes reach the store", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// `Number(null)` and `Number("")` are both 0 AND finite, so the obvious
|
||||
// isFinite check mirrored "silent" for a removed attribute while core's
|
||||
// `readAudioGroupVolume` reads the same absence as unity — a parse divergence
|
||||
// inside the mirror whose entire job is to prevent one.
|
||||
it("reads a removed data-volume as unity, the way core does", async () => {
|
||||
const react = await import("react");
|
||||
const { renderToStaticMarkup } = await import("react-dom/server");
|
||||
const harness = makeSetter();
|
||||
renderToStaticMarkup(react.createElement(harness.Probe));
|
||||
const setter = harness.get();
|
||||
|
||||
usePlayerStore.getState().setElements([member("voice-1", 0)]);
|
||||
|
||||
setter?.setLive("voiceover", "data-volume", "0.3");
|
||||
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBeCloseTo(0.3, 6);
|
||||
|
||||
setter?.setLive("voiceover", "data-volume", null);
|
||||
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
|
||||
|
||||
setter?.setLive("voiceover", "data-volume", "");
|
||||
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
|
||||
|
||||
setter?.setLive("voiceover", "data-volume", "nonsense");
|
||||
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
|
||||
});
|
||||
|
||||
it("mirrors data-volume, and leaves other groups alone", async () => {
|
||||
const react = await import("react");
|
||||
const { renderToStaticMarkup } = await import("react-dom/server");
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useCallback } from "react";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
import { usePlayerStore } from "../player";
|
||||
import type { TimelineElementPatch } from "../player/store/timelineElement";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
||||
import { getTimelineElementSourceFile } from "../player/lib/timelineElementHelpers";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
persistElementAttribute,
|
||||
@@ -28,15 +29,28 @@ function patchLiveGroupAttribute(
|
||||
invalidateGroupInfoCache(iframe?.contentDocument);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
|
||||
* empty attribute is UNITY, not zero.
|
||||
*
|
||||
* `Number(null)` and `Number("")` are both 0 and both finite, so the obvious
|
||||
* `Number.isFinite(Number(value))` mirrored "silent" into the store for a
|
||||
* removed attribute while the DOM, the preview bus and the render all read 1 —
|
||||
* exactly the parse divergence this mirror exists to eliminate.
|
||||
*/
|
||||
function mirroredGroupVolume(value: string | null): number {
|
||||
if (!value) return 1;
|
||||
const parsed = parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 1;
|
||||
}
|
||||
|
||||
/** Which store field each writable group attribute mirrors into. */
|
||||
const GROUP_ATTR_TO_MIRROR: Record<
|
||||
string,
|
||||
(value: string | null, groupId: string) => TimelineElementPatch
|
||||
> = {
|
||||
"data-hidden": (value) => ({ audioGroupHidden: value !== null }),
|
||||
"data-volume": (value) => ({
|
||||
audioGroupVolume: Number.isFinite(Number(value)) ? Number(value) : 1,
|
||||
}),
|
||||
"data-volume": (value) => ({ audioGroupVolume: mirroredGroupVolume(value) }),
|
||||
"data-label": (value, groupId) => ({ audioGroupLabel: value ?? groupId }),
|
||||
[HF_AUDIO_FX_ATTR]: (value) => ({ audioGroupFxChain: value ?? undefined }),
|
||||
};
|
||||
@@ -59,10 +73,14 @@ function syncStoredGroupAttribute(groupId: string, attr: string, value: string |
|
||||
const toPatch = GROUP_ATTR_TO_MIRROR[attr];
|
||||
if (!toPatch) return;
|
||||
const patch = toPatch(value, groupId);
|
||||
const store = usePlayerStore.getState();
|
||||
for (const element of store.elements) {
|
||||
if (element.audioGroup === groupId) store.updateElement(element.key ?? element.id, patch);
|
||||
}
|
||||
// ONE pass and one notification, rather than `updateElement` per member.
|
||||
// That helper maps the whole `elements` array per call, so a 3-member group on
|
||||
// a 500-clip composition was 1500 object spreads and 3 store notifications per
|
||||
// drag frame — at ~60/s, with every `elements`-keyed memo downstream
|
||||
// recomputing each time.
|
||||
usePlayerStore.setState((state) => ({
|
||||
elements: state.elements.map((el) => (el.audioGroup === groupId ? { ...el, ...patch } : el)),
|
||||
}));
|
||||
}
|
||||
|
||||
interface SetAudioGroupAttributeInput {
|
||||
@@ -98,7 +116,17 @@ async function setAudioGroupAttribute({
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: SetAudioGroupAttributeInput): Promise<string[]> {
|
||||
const targetPath = activeCompPath || "index.html";
|
||||
// The file that actually CONTAINS the group element, not just the active
|
||||
// composition. A hand-authored sub-composition can declare both the members
|
||||
// and their `<hf-audio-group>`, and until sub-comp children inherited
|
||||
// `audioGroup*` no group row existed for that case so nothing could reach
|
||||
// here. Now the row appears, and routing its writes at `activeCompPath`
|
||||
// means `readTagSnippetByTarget` finds nothing and every mute, fader move and
|
||||
// FX preset throws "Unable to patch element in index.html". Every sibling
|
||||
// timeline writer already routes `element.sourceFile || activeCompPath`.
|
||||
const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
|
||||
const targetPath =
|
||||
(groupEl ? getTimelineElementSourceFile(groupEl) : undefined) || activeCompPath || "index.html";
|
||||
const patchTarget = buildPatchTarget({ domId: groupId });
|
||||
if (!patchTarget) return [];
|
||||
|
||||
@@ -174,6 +202,14 @@ export function useSetAudioGroupAttribute({
|
||||
});
|
||||
syncStoredGroupAttribute(groupId, attr, value);
|
||||
} catch (error) {
|
||||
// `persistElementAttribute` has already unwound the live DOM to the
|
||||
// previous value, but `setLive` mirrored the in-progress value into the
|
||||
// store on every drag frame — so without this the fader reads 0.4 while
|
||||
// the preview and the file are both back at 1.0, and nothing re-parses
|
||||
// to correct it (a live patch causing no parse is this mirror's whole
|
||||
// premise). Re-mirror from the DOM, which is now authoritative again.
|
||||
const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
||||
syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
|
||||
console.error("[Timeline] Failed to set group attribute", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to update group";
|
||||
showToast(message);
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
||||
import { reportDomEditPersistFailure } from "./domEditPersistFailure";
|
||||
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
|
||||
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
|
||||
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineDOM";
|
||||
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
||||
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -68,12 +68,12 @@ function setOrRemovePreviewAttribute(
|
||||
// Every DOM-edit attribute write funnels through here, which is the only
|
||||
// place that can catch a group edit made from the rack rather than from the
|
||||
// group header — `openGroupFxRack` hands the `<hf-audio-group>` to the DOM
|
||||
// editor, and that path never went near the timeline's own writers. The group
|
||||
// scan is cached against the preview Document, and group edits are live
|
||||
// patches so that document is never replaced; a stale entry is re-read on
|
||||
// every manifest tick, so the header's preset button then builds on the old
|
||||
// chain and discards the rack's edit.
|
||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
|
||||
// editor, and that path never went near the timeline's own writers.
|
||||
//
|
||||
// The group element itself OR a member's membership attribute: writing
|
||||
// `data-audio-group` onto an `<audio>` moves it between groups, which changes
|
||||
// the answer just as much as editing the bus does.
|
||||
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG || fullAttr === HF_AUDIO_GROUP_ATTR) {
|
||||
invalidateGroupInfoCache(el.ownerDocument);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemet
|
||||
import {
|
||||
getEffectiveTimelineDuration,
|
||||
getTimelinePreviewElement,
|
||||
hasKeyframedTimelineClips,
|
||||
timelineNeedsLabelColumn,
|
||||
} from "./timelineViewModel";
|
||||
import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
|
||||
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
||||
@@ -115,7 +115,10 @@ export const Timeline = memo(function Timeline({
|
||||
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
||||
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
||||
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
||||
const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
|
||||
const labelMode = useMemo(
|
||||
() => timelineNeedsLabelColumn(gsapAnimations, expandedElements),
|
||||
[gsapAnimations, expandedElements],
|
||||
);
|
||||
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
||||
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
||||
const contentGutter = labelMode ? GUTTER : 0;
|
||||
|
||||
@@ -25,6 +25,7 @@ interface TimelineGroupRowProps {
|
||||
top: number;
|
||||
height: number;
|
||||
virtualized: boolean;
|
||||
contentOrigin: number;
|
||||
theme: TimelineTheme;
|
||||
rovingTargetId?: string | null;
|
||||
collapsedGroupIds: ReadonlySet<string>;
|
||||
@@ -42,6 +43,7 @@ export function TimelineGroupRow({
|
||||
top,
|
||||
height,
|
||||
virtualized,
|
||||
contentOrigin,
|
||||
theme,
|
||||
rovingTargetId = null,
|
||||
collapsedGroupIds,
|
||||
@@ -125,14 +127,12 @@ export function TimelineGroupRow({
|
||||
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
||||
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
||||
onOpenFxRack={openGroupFxRack}
|
||||
// Always the full label column, never squeezed down to `contentOrigin`.
|
||||
// A track row can afford a narrow gutter because its CLIPS carry the
|
||||
// name on the bar; a group row has no clips at all, so the gutter is
|
||||
// the only place its name exists — and at the default fit the gutter is
|
||||
// ~80px, which rendered the label at zero width and clipped the solo,
|
||||
// FX and lane buttons off the side. Overhanging into the lane area is
|
||||
// safe precisely because this row is empty (see `propertyRows={[]}`).
|
||||
columnWidth={LABEL_COL_W}
|
||||
// Same width as every other row's header. The group row needs a real
|
||||
// label column, but it gets one by turning `labelMode` on for the whole
|
||||
// timeline (see Timeline.tsx) rather than by overhanging alone — an
|
||||
// overhanging header paints opaquely across the rest of its row and
|
||||
// stays pinned there through horizontal scroll.
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
theme={theme}
|
||||
/>
|
||||
{isLaneOpen && (
|
||||
|
||||
@@ -166,6 +166,7 @@ export function TimelineLanes({
|
||||
top={rowGeometry.getRowTop(row)}
|
||||
height={rowGeometry.getRowHeight(row)}
|
||||
virtualized={rowsVirtualized}
|
||||
contentOrigin={contentOrigin}
|
||||
theme={theme}
|
||||
rovingTargetId={keyboard.rovingTargetId}
|
||||
collapsedGroupIds={collapsedGroupIds}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ResizingClipState } from "./timelineClipDragTypes";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { animationContributesLane } from "./TimelinePropertyLanes";
|
||||
|
||||
export function hasKeyframedTimelineClips(
|
||||
function hasKeyframedTimelineClips(
|
||||
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
||||
): boolean {
|
||||
return Array.from(animationsByElement.values()).some((animations) =>
|
||||
@@ -12,6 +12,31 @@ export function hasKeyframedTimelineClips(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the timeline need the wide label column?
|
||||
*
|
||||
* Keyframed clips need it for their property-lane names. Audio GROUPS need it
|
||||
* for the same reason a keyframed clip does — a row whose name has nowhere else
|
||||
* to go. A track row survives a narrow gutter because its CLIPS carry the name
|
||||
* on the bar; a group row has no clips at all, so in the 80px gutter its label
|
||||
* rendered at zero width and its solo, FX and lane buttons were clipped off the
|
||||
* side.
|
||||
*
|
||||
* Widening the column for the whole timeline, rather than letting just the
|
||||
* group row overhang: the header is sticky and opaque, so an oversized one
|
||||
* painted a slab across the rest of its own row, stayed pinned there through
|
||||
* horizontal scroll, and had the playhead drawn straight through it.
|
||||
*/
|
||||
export function timelineNeedsLabelColumn(
|
||||
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
||||
elements: readonly TimelineElement[],
|
||||
): boolean {
|
||||
return (
|
||||
hasKeyframedTimelineClips(animationsByElement) ||
|
||||
elements.some((element) => Boolean(element.audioGroup))
|
||||
);
|
||||
}
|
||||
|
||||
export function getEffectiveTimelineDuration(
|
||||
duration: number,
|
||||
elements: readonly TimelineElement[],
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
createTimelineElementFromManifestClip,
|
||||
parseTimelineFromDOM,
|
||||
createImplicitTimelineLayersFromDOM,
|
||||
invalidateGroupInfoCache,
|
||||
mergeTimelineElementsPreservingDowngrades,
|
||||
} from "./timelineDOM";
|
||||
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
||||
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
|
||||
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
||||
|
||||
@@ -12,8 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
import type { ClipManifestClip } from "./playbackTypes";
|
||||
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
||||
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
||||
import { HF_AUDIO_GROUP_ATTR, resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
import { groupInfoFor } from "./timelineGroupInfo";
|
||||
import {
|
||||
resolveMediaElement,
|
||||
applyMediaMetadataFromElement,
|
||||
@@ -69,99 +68,6 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
||||
return clip.tagName || clip.kind || "div";
|
||||
}
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
interface GroupInfo {
|
||||
label: string;
|
||||
volume: number;
|
||||
hidden: boolean;
|
||||
fxChain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached scan, plus the DOM revision it was taken at.
|
||||
*
|
||||
* Keeping the revision beside the entry is what makes staleness *detectable*
|
||||
* rather than a documented obligation. The cache is keyed on the document, and
|
||||
* group edits are applied as live patches precisely so the iframe never
|
||||
* reloads, so the key alone never changes: an explicit "remember to invalidate"
|
||||
* contract silently rots the first time a new writer forgets — which is exactly
|
||||
* what happened with the FX rack, whose group writes go through the DOM editor
|
||||
* rather than the timeline's own writers.
|
||||
*/
|
||||
const groupInfoCache = new WeakMap<
|
||||
Document,
|
||||
{ revision: number; entries: Map<string, GroupInfo> }
|
||||
>();
|
||||
|
||||
/** Bumped by every observed mutation to group state in a document. */
|
||||
const groupRevisions = new WeakMap<Document, number>();
|
||||
const groupObservers = new WeakSet<Document>();
|
||||
|
||||
/**
|
||||
* Watch a document for any change to group state, so the cache expires itself.
|
||||
*
|
||||
* One observer per document, attached the first time a group is read from it.
|
||||
* It watches the attributes a group's identity is made of, anywhere in the
|
||||
* tree, plus added/removed nodes — which covers a group element appearing, a
|
||||
* member joining or leaving, and any group attribute being edited, by any
|
||||
* writer, without that writer having to know this cache exists.
|
||||
*/
|
||||
function observeGroupState(doc: Document): void {
|
||||
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
groupObservers.add(doc);
|
||||
const observer = new MutationObserver(() => {
|
||||
groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
||||
});
|
||||
observer.observe(doc.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: [
|
||||
HF_AUDIO_GROUP_ATTR,
|
||||
HF_AUDIO_FX_ATTR,
|
||||
"data-label",
|
||||
"data-volume",
|
||||
"data-hidden",
|
||||
"id",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached group scan for a document.
|
||||
*
|
||||
* Belt to the observer's braces: a caller that has just written and wants the
|
||||
* very next read to be honest cannot wait for the observer's microtask. Callers
|
||||
* that forget are no longer punished — the revision check catches them.
|
||||
*/
|
||||
export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
|
||||
if (doc) groupInfoCache.delete(doc);
|
||||
}
|
||||
|
||||
function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
||||
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||
observeGroupState(doc);
|
||||
const revision = groupRevisions.get(doc) ?? 0;
|
||||
const cached = groupInfoCache.get(doc);
|
||||
let info = cached && cached.revision === revision ? cached.entries : undefined;
|
||||
if (!info) {
|
||||
info = new Map(
|
||||
resolveAudioGroups(doc).map((group) => [
|
||||
group.id,
|
||||
{
|
||||
label: group.label,
|
||||
volume: group.volume,
|
||||
hidden: group.hidden,
|
||||
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
groupInfoCache.set(doc, { revision, entries: info });
|
||||
}
|
||||
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function createTimelineElementFromManifestClip(params: {
|
||||
clip: ClipManifestClip;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* The audio-group scan cached per preview document, and the mechanism that
|
||||
* keeps it honest.
|
||||
*
|
||||
* Split out of `timelineDOM.ts` (600-line studio ceiling). Self-contained: the
|
||||
* cache, its revision counter, the observer that bumps it, and the one reader.
|
||||
*/
|
||||
|
||||
import {
|
||||
HF_AUDIO_GROUP_ATTR,
|
||||
HF_AUDIO_GROUP_TAG,
|
||||
resolveAudioGroups,
|
||||
} from "@hyperframes/core/audio-groups";
|
||||
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
||||
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
interface GroupInfo {
|
||||
label: string;
|
||||
volume: number;
|
||||
hidden: boolean;
|
||||
fxChain?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached scan, plus the DOM revision it was taken at.
|
||||
*
|
||||
* Keeping the revision beside the entry is what makes staleness *detectable*
|
||||
* rather than a documented obligation. The cache is keyed on the document, and
|
||||
* group edits are applied as live patches precisely so the iframe never
|
||||
* reloads, so the key alone never changes: an explicit "remember to invalidate"
|
||||
* contract silently rots the first time a new writer forgets — which is exactly
|
||||
* what happened with the FX rack, whose group writes go through the DOM editor
|
||||
* rather than the timeline's own writers.
|
||||
*/
|
||||
const groupInfoCache = new WeakMap<
|
||||
Document,
|
||||
{ revision: number; entries: Map<string, GroupInfo> }
|
||||
>();
|
||||
|
||||
/** Bumped by every observed mutation to group state in a document. */
|
||||
const groupRevisions = new WeakMap<Document, number>();
|
||||
const groupObservers = new WeakSet<Document>();
|
||||
|
||||
/**
|
||||
* Watch a document for any change to group state, so the cache expires itself.
|
||||
*
|
||||
* One observer per document, attached the first time a group is read from it.
|
||||
* It watches the attributes a group's identity is made of, anywhere in the
|
||||
* tree, plus added/removed nodes — which covers a group element appearing, a
|
||||
* member joining or leaving, and any group attribute being edited, by any
|
||||
* writer, without that writer having to know this cache exists.
|
||||
*/
|
||||
function observeGroupState(doc: Document): void {
|
||||
if (groupObservers.has(doc) || typeof MutationObserver === "undefined" || !doc.body) return;
|
||||
groupObservers.add(doc);
|
||||
const observer = new MutationObserver((records) => {
|
||||
// `childList` fires for EVERY node added or removed anywhere in the live
|
||||
// preview, which on a composition that churns nodes during playback
|
||||
// (SplitText, a typewriter, anything runtime-inserted) would expire this
|
||||
// cache permanently and put it back to one whole-tree scan per parse. Only
|
||||
// a group ELEMENT appearing or leaving actually changes the answer, so
|
||||
// childList records are filtered rather than trusted; attribute records
|
||||
// always count, because the filter below already narrowed them.
|
||||
const relevant = records.some(
|
||||
(record) =>
|
||||
record.type !== "childList" ||
|
||||
[...record.addedNodes, ...record.removedNodes].some(
|
||||
(node) =>
|
||||
node instanceof Element &&
|
||||
(node.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG ||
|
||||
node.hasAttribute(HF_AUDIO_GROUP_ATTR) ||
|
||||
node.querySelector?.(`${HF_AUDIO_GROUP_TAG},[${HF_AUDIO_GROUP_ATTR}]`) != null),
|
||||
),
|
||||
);
|
||||
if (relevant) groupRevisions.set(doc, (groupRevisions.get(doc) ?? 0) + 1);
|
||||
});
|
||||
observer.observe(doc.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
// Every attribute `resolveAudioGroups`/`buildGroup` reads, including
|
||||
// `data-automation` — which `GroupInfo` does not cache TODAY, so omitting it
|
||||
// was inert, but group automation lanes already exist and the first person
|
||||
// to cache one would have got a silently never-firing observer. That is the
|
||||
// precise rot this observer replaced.
|
||||
attributeFilter: [
|
||||
HF_AUDIO_GROUP_ATTR,
|
||||
HF_AUDIO_FX_ATTR,
|
||||
HF_AUDIO_AUTOMATION_ATTR,
|
||||
"data-label",
|
||||
"data-volume",
|
||||
"data-hidden",
|
||||
"id",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached group scan for a document.
|
||||
*
|
||||
* Belt to the observer's braces: a caller that has just written and wants the
|
||||
* very next read to be honest cannot wait for the observer's microtask. Callers
|
||||
* that forget are no longer punished — the revision check catches them.
|
||||
*/
|
||||
export function invalidateGroupInfoCache(doc: Document | null | undefined): void {
|
||||
if (doc) groupInfoCache.delete(doc);
|
||||
}
|
||||
|
||||
export function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
||||
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||
observeGroupState(doc);
|
||||
const revision = groupRevisions.get(doc) ?? 0;
|
||||
const cached = groupInfoCache.get(doc);
|
||||
let info = cached && cached.revision === revision ? cached.entries : undefined;
|
||||
if (!info) {
|
||||
info = new Map(
|
||||
resolveAudioGroups(doc).map((group) => [
|
||||
group.id,
|
||||
{
|
||||
label: group.label,
|
||||
volume: group.volume,
|
||||
hidden: group.hidden,
|
||||
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
groupInfoCache.set(doc, { revision, entries: info });
|
||||
}
|
||||
return info.get(groupId) ?? { label: groupId, volume: 1, hidden: false };
|
||||
}
|
||||
Reference in New Issue
Block a user