Files
hyperframes/packages/studio/src/player/components/timelineViewModel.ts
T
Vance IngallsandClaude Opus 5 6056ec7463 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>
2026-08-20 16:39:42 -07:00

70 lines
2.7 KiB
TypeScript

import type { TimelineElement } from "../store/playerStore";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { ResizingClipState } from "./timelineClipDragTypes";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { animationContributesLane } from "./TimelinePropertyLanes";
function hasKeyframedTimelineClips(
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
): boolean {
return Array.from(animationsByElement.values()).some((animations) =>
animations.some(animationContributesLane),
);
}
/**
* 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[],
): number {
const safeDuration = Number.isFinite(duration) ? duration : 0;
if (elements.length === 0) return safeDuration;
const result = Math.max(
safeDuration,
...elements.map((element) => element.start + element.duration),
);
return Number.isFinite(result) ? result : safeDuration;
}
export function getTimelinePreviewElement(
element: TimelineElement,
resizingClip: ResizingClipState | null,
): TimelineElement {
const elementIdentity = getTimelineElementIdentity(element);
const groupPreview = resizingClip?.groupPreview?.find((change) => change.key === elementIdentity);
if (groupPreview) return { ...element, ...groupPreview };
if (resizingClip && getTimelineElementIdentity(resizingClip.element) === elementIdentity) {
return {
...element,
start: resizingClip.previewStart,
duration: resizingClip.previewDuration,
playbackStart: resizingClip.previewPlaybackStart,
};
}
return element;
}