mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio,core): reach presets and the rack from the timeline (#3292)
C1: the FX button in the track/group header, and its popover — the
"reach FX from the timeline" entry point, last on purpose because it
targets a group or a single clip, never "a track" (N clips = N chains
is the ill-defined thing the design doc refuses to build).
The button (TimelineFxButton.tsx): renders on group rows and on track
rows holding exactly one audio clip, reading "FX" (or "FX n" once the
target's data-fx-chain has n enabled nodes). A multi-clip ungrouped
audio track gets a pointer instead ("Group these clips to add effects
to all of them" + a Group action) rather than silently hiding the
entry point — reuses B6's exact auto-grouping write
(useAudioGroupCarveAssignment, exposed as onGroupClips) with a minted
group id (mintGroupId, exported from useFxCarveGrouping.ts).
The popover (TimelineFxPopover.tsx, components/editor/): a thin
positioner around FxPresetMenu exactly as the property panel renders
it — same audition contract (useFxAudition), same preset-apply
computation (extracted into useApplyAudioFxPreset.ts's
applyPresetToChain, now shared with propertyPanelFxSection.tsx's own
applyPreset rather than duplicated). Escape closes without
deselecting whatever is behind it; an outside pointerdown dismisses.
Footer's "+ effect"/"Open rack ›" both select the target and hand off
to the property panel (a simplification from the step doc's two
distinct behaviors — remotely toggling the rack's own internal
"adding" state isn't plumbed anywhere, and building that plumbing
would be new UI-state wiring beyond what "reuse existing selection
dispatch" asks for).
Writes, one path per target kind, neither a new persistence mechanism:
- Group: B7/B5's existing onSetAudioGroupAttributeLive/Quiet
(data-fx-chain, same as data-volume/data-hidden already do).
- Clip: a NEW onSetElementAttributeLive/Quiet pair
(timelineElementFxAttribute.ts), addressed by the TimelineElement
itself rather than the current selection. This is the one real
architectural gap the step doc's assumption didn't survive: the
property panel's onSetAttributeQuiet closes over domEditSelection,
so writing a clip that isn't already selected has no synchronous
path through it. Extracted the shared live-patch-then-persist core
(persistElementAttribute, timelineEditingHelpers.ts) out of both
this new path and the existing setAudioGroupAttribute, which the
fallow duplication gate flagged as a 66-line clone on first pass —
now a single ~50-line core parameterized by patchLive/readLive, with
each caller a ~15-line wrapper resolving its own patch target
(buildPatchTarget({domId}) for a group, buildPatchTarget(element)
for an arbitrary clip) and live-DOM lookup.
Data plumbing: HfAudioGroup.fxChain (already on the B1 model) mirrored
onto TimelineElement.audioGroupFxChain (timelineDOM.ts's groupInfoFor
cache) and TimelineTrackGroupInfo.fxChain (useTimelineTrackDerivations.ts),
alongside the existing volume/hidden mirrors.
Deferred: the property panel's own rack doesn't (yet) expose a way to
remotely force its add-menu open, so "+ effect" and "Open rack ›"
converge on the same navigation rather than the step doc's two
distinct ones. A grouped multi-clip track (some clips already carry
data-audio-group) gets neither the chain button nor the pointer —
its members' own per-clip FX buttons still work individually, and the
group's own FX button on TimelineGroupHeader covers the group level.
Gates: bun run build clean; packages/studio full suite 4286/4304 (18
pre-existing todo, up from 4276/4294 — 10 new tests, 0 regressions);
new TimelineFxPopover.test.tsx (6) + TimelineFxButton.test.tsx (4)
cover exactly-one-write-per-apply, hover-audition-reverts-on-leave,
Escape-without-deselecting, outside/inside pointerdown dismissal, and
the group-pointer's Group action; oxfmt/oxlint clean on all 22 touched
files; fallow clean (0 new dead-code/unused-export/duplication
findings — the pointer test caught during the first commit attempt).
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9ec75a485f
commit
6a92d21401
@@ -546,6 +546,8 @@ export function StudioApp() {
|
||||
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
|
||||
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
||||
setAudioGroupAttribute={timelineEditing.setAudioGroupAttribute}
|
||||
handleGroupClips={timelineEditing.handleAutoGroupCarveSources}
|
||||
setElementFxAttribute={timelineEditing.setElementFxAttribute}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||
handleRazorSplit={timelineEditing.handleRazorSplit}
|
||||
|
||||
@@ -86,6 +86,8 @@ export function EditorShell({
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleGroupClips,
|
||||
setElementFxAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
@@ -137,6 +139,8 @@ export function EditorShell({
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleGroupClips,
|
||||
setElementFxAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { TimelineFxPopover } from "./TimelineFxPopover.js";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
|
||||
const RECT = { left: 0, top: 0, right: 0, bottom: 0 } as DOMRect;
|
||||
|
||||
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
||||
}
|
||||
|
||||
function mount(overrides: Partial<Parameters<typeof TimelineFxPopover>[0]> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onChainChange = vi.fn();
|
||||
const onChainPreview = vi.fn();
|
||||
const onOpenRack = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
act(() => {
|
||||
createRoot(host).render(
|
||||
<TimelineFxPopover
|
||||
anchorRect={RECT}
|
||||
chain={EMPTY_CHAIN}
|
||||
onClose={onClose}
|
||||
onChainChange={onChainChange}
|
||||
onChainPreview={onChainPreview}
|
||||
onOpenRack={onOpenRack}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, onClose, onChainChange, onChainPreview, onOpenRack };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("TimelineFxPopover", () => {
|
||||
it("applies a preset with exactly one onChainChange write, and closes", () => {
|
||||
const { host, onChainChange, onClose } = mount();
|
||||
const button = byTextButton(host, "Chipmunk");
|
||||
expect(button).toBeDefined();
|
||||
act(() => button?.click());
|
||||
expect(onChainChange).toHaveBeenCalledTimes(1);
|
||||
const written = onChainChange.mock.calls[0]?.[0] as HfAudioFxChain;
|
||||
expect(written.nodes.length).toBeGreaterThan(0);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auditions on hover (focus is the keyboard's hover) and reverts on leave", () => {
|
||||
const { host, onChainPreview } = mount();
|
||||
const button = byTextButton(host, "Chipmunk");
|
||||
expect(button).toBeDefined();
|
||||
act(() => (button as HTMLButtonElement).focus());
|
||||
expect(onChainPreview).toHaveBeenCalled();
|
||||
const previewed = onChainPreview.mock.calls.at(-1)?.[0] as HfAudioFxChain;
|
||||
expect(previewed.nodes.length).toBeGreaterThan(0);
|
||||
onChainPreview.mockClear();
|
||||
act(() => {
|
||||
button?.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
||||
});
|
||||
expect(onChainPreview).toHaveBeenCalledWith(EMPTY_CHAIN);
|
||||
});
|
||||
|
||||
it("Escape closes without letting the keystroke propagate past the popover", () => {
|
||||
const { host, onClose } = mount();
|
||||
const outer = vi.fn();
|
||||
document.body.addEventListener("keydown", outer);
|
||||
const dialog = host.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
act(() => {
|
||||
dialog?.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(outer).not.toHaveBeenCalled();
|
||||
document.body.removeEventListener("keydown", outer);
|
||||
});
|
||||
|
||||
it("dismisses on an outside pointerdown", () => {
|
||||
const { onClose } = mount();
|
||||
act(() => {
|
||||
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dismiss on a pointerdown inside the popover", () => {
|
||||
const { host, onClose } = mount();
|
||||
const dialog = host.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
act(() => {
|
||||
dialog?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the footer opens the rack and closes the popover", () => {
|
||||
const { host, onOpenRack, onClose } = mount();
|
||||
const openRack = byTextButton(host, "Open rack");
|
||||
expect(openRack).toBeDefined();
|
||||
act(() => openRack?.click());
|
||||
expect(onOpenRack).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The FX popover the timeline's per-track/per-group FX button opens (C1).
|
||||
*
|
||||
* A THIN positioner around existing pieces, not a second rack: the body is
|
||||
* `FxPresetMenu` exactly as the property panel's FX section renders it, and
|
||||
* applying or auditioning a preset goes through the caller's own write path
|
||||
* (a group's own attribute for a group target, the selected element's for a
|
||||
* clip) — nothing here serializes a chain of its own.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, type CSSProperties, type KeyboardEvent } from "react";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
||||
import { FxPresetMenu } from "./propertyPanelFxPresetMenu.js";
|
||||
import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
|
||||
import { useFxAudition } from "./useFxAudition.js";
|
||||
|
||||
const POPOVER_WIDTH = 260;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
|
||||
function clampedStyle(anchorRect: DOMRect): CSSProperties {
|
||||
const left = Math.min(
|
||||
Math.max(anchorRect.left, VIEWPORT_MARGIN),
|
||||
Math.max(VIEWPORT_MARGIN, window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN),
|
||||
);
|
||||
const spaceBelow = window.innerHeight - anchorRect.bottom;
|
||||
const openUpward = spaceBelow < 260 && anchorRect.top > spaceBelow;
|
||||
return {
|
||||
position: "fixed",
|
||||
left,
|
||||
width: POPOVER_WIDTH,
|
||||
...(openUpward
|
||||
? { bottom: window.innerHeight - anchorRect.top + 4 }
|
||||
: { top: anchorRect.bottom + 4 }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelineFxPopoverProps {
|
||||
anchorRect: DOMRect;
|
||||
chain: HfAudioFxChain;
|
||||
trackKind?: HfAudioNameKind;
|
||||
onClose: () => void;
|
||||
/** Persist the applied preset onto the target (group attribute, or the
|
||||
* selected clip's attribute — the caller resolves which). */
|
||||
onChainChange: (next: HfAudioFxChain) => void;
|
||||
/** Preview a hypothetical chain on the running graph without persisting. */
|
||||
onChainPreview?: (next: HfAudioFxChain) => void;
|
||||
onAuditionTransport?: (on: boolean) => void;
|
||||
/** Select the target the way clicking it in the timeline does, and ensure
|
||||
* the property panel's Audio FX group is expanded. */
|
||||
onOpenRack: () => void;
|
||||
}
|
||||
|
||||
export function TimelineFxPopover({
|
||||
anchorRect,
|
||||
chain,
|
||||
trackKind,
|
||||
onClose,
|
||||
onChainChange,
|
||||
onChainPreview,
|
||||
onAuditionTransport,
|
||||
onOpenRack,
|
||||
}: TimelineFxPopoverProps) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { audition, clearAudition } = useFxAudition(chain, onChainPreview, onAuditionTransport);
|
||||
|
||||
// Outside click dismisses like any other popover; the button itself is
|
||||
// excluded by pointerdown timing (the button's own click hasn't happened yet).
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () => document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
const applyPreset = (id: string) => {
|
||||
const next = applyPresetToChain(chain, id, trackKind);
|
||||
if (!next) return;
|
||||
clearAudition();
|
||||
onChainChange(next);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Escape closes without deselecting whatever is behind this — a keystroke
|
||||
// aimed at the popover is not aimed at the clip under it.
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.stopPropagation();
|
||||
audition(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
role="dialog"
|
||||
aria-label="Effects"
|
||||
className="z-50 rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
|
||||
style={clampedStyle(anchorRect)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<FxPresetMenu
|
||||
trackKind={trackKind}
|
||||
onPick={applyPreset}
|
||||
onAudition={
|
||||
onChainPreview
|
||||
? (id) =>
|
||||
audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-white"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenRack();
|
||||
}}
|
||||
>
|
||||
+ effect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-white"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenRack();
|
||||
}}
|
||||
>
|
||||
Open rack ›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type HfAudioFxParamValues,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
||||
import { applyPresetToChain } from "./useApplyAudioFxPreset.js";
|
||||
import {
|
||||
addAudioEq,
|
||||
audioEqIds,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
trackNodeAdded,
|
||||
trackNodeMoved,
|
||||
trackNodeRemoved,
|
||||
trackPresetApplied,
|
||||
trackPresetAuditioned,
|
||||
trackPresetAutomated,
|
||||
trackPresetRemoved,
|
||||
@@ -138,23 +138,8 @@ export function FxSection({
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(id: string) => {
|
||||
const preset = getAudioFxPreset(id);
|
||||
if (!preset) return;
|
||||
// Appends. Stacking a character preset onto an already-cleaned voice is a
|
||||
// real thing to want, and replacing silently would throw work away — so
|
||||
// the destructive option is a separate gesture, not the default one.
|
||||
const next = applyAudioFxPreset(chain, preset);
|
||||
// Re-applying replaces this preset's own nodes in place rather than
|
||||
// appending a second copy, and the two are different decisions — worth
|
||||
// telling apart in the numbers.
|
||||
const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
|
||||
trackPresetApplied(
|
||||
preset.id,
|
||||
preset.family,
|
||||
preset.nodes.length,
|
||||
reapply ? "reapply" : "append",
|
||||
{ trackKind },
|
||||
);
|
||||
const next = applyPresetToChain(chain, id, trackKind);
|
||||
if (!next) return;
|
||||
// The audition WAS this, so there is nothing to put back — and putting the
|
||||
// old chain back over the write that just landed is a race the author
|
||||
// hears as the preset arriving and then leaving again.
|
||||
@@ -162,7 +147,7 @@ export function FxSection({
|
||||
mutate(next.nodes);
|
||||
// Land on the first node the preset wrote, so the author can hear what
|
||||
// arrived and immediately see what it is made of.
|
||||
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === preset.id));
|
||||
setOpenNode(next.nodes.findIndex((n) => n.fromPreset === id));
|
||||
setPicking(false);
|
||||
},
|
||||
[chain, mutate, clearAudition, trackKind],
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Append (or, on reapply, replace in place) a preset's nodes onto a chain,
|
||||
* with the telemetry that decision needs — the write path the property
|
||||
* panel's FX section and the timeline FX popover (C1) both use, so a preset
|
||||
* applied from either surface counts the same way.
|
||||
*/
|
||||
|
||||
import { applyAudioFxPreset, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
||||
import { trackPresetApplied } from "./audioFxTelemetry.js";
|
||||
|
||||
export function applyPresetToChain(
|
||||
chain: HfAudioFxChain,
|
||||
presetId: string,
|
||||
trackKind: HfAudioNameKind | undefined,
|
||||
): HfAudioFxChain | null {
|
||||
const preset = getAudioFxPreset(presetId);
|
||||
if (!preset) return null;
|
||||
// Appends. Stacking a character preset onto an already-cleaned voice is a
|
||||
// real thing to want, and replacing silently would throw work away — so
|
||||
// the destructive option is a separate gesture, not the default one.
|
||||
const next = applyAudioFxPreset(chain, preset);
|
||||
// Re-applying replaces this preset's own nodes in place rather than
|
||||
// appending a second copy, and the two are different decisions — worth
|
||||
// telling apart in the numbers.
|
||||
const reapply = chain.nodes.some((n) => n.fromPreset === preset.id);
|
||||
trackPresetApplied(
|
||||
preset.id,
|
||||
preset.family,
|
||||
preset.nodes.length,
|
||||
reapply ? "reapply" : "append",
|
||||
{ trackKind },
|
||||
);
|
||||
return next;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
||||
* document — group ids and plain element ids share one namespace, so both
|
||||
* have to be checked.
|
||||
*/
|
||||
function mintGroupId(doc: Document): string {
|
||||
export function mintGroupId(doc: Document): string {
|
||||
const taken = new Set([
|
||||
...resolveAudioGroups(doc).map((g) => g.id),
|
||||
...Array.from(doc.querySelectorAll("[id]")).map((el) => el.id),
|
||||
|
||||
@@ -46,6 +46,18 @@ export interface TimelineEditCallbackDeps {
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
|
||||
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
|
||||
handleGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
||||
/** C1's single-clip FX write, addressed by the clip itself. */
|
||||
setElementFxAttribute?: {
|
||||
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
|
||||
setQuiet: (
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
interface TimelineKeyframeTargetAnimation {
|
||||
@@ -108,6 +120,8 @@ export function useTimelineEditCallbacks({
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
handleRazorSplitAll,
|
||||
handleGroupClips,
|
||||
setElementFxAttribute,
|
||||
}: TimelineEditCallbackDeps): TimelineEditCallbacks {
|
||||
const { projectId, activeCompPath } = useStudioShellContext();
|
||||
const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext();
|
||||
@@ -192,6 +206,9 @@ export function useTimelineEditCallbacks({
|
||||
onToggleTrackHidden: handleToggleTrackHidden,
|
||||
onSetAudioGroupAttributeLive: setAudioGroupAttribute.setLive,
|
||||
onSetAudioGroupAttributeQuiet: setAudioGroupAttribute.setQuiet,
|
||||
onGroupClips: handleGroupClips,
|
||||
onSetElementAttributeLive: setElementFxAttribute?.setLive,
|
||||
onSetElementAttributeQuiet: setElementFxAttribute?.setQuiet,
|
||||
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
||||
onSplitElement: handleTimelineElementSplit,
|
||||
onRazorSplit: handleRazorSplit,
|
||||
@@ -386,6 +403,8 @@ export function useTimelineEditCallbacks({
|
||||
handleTimelineGroupResize,
|
||||
handleToggleTrackHidden,
|
||||
setAudioGroupAttribute,
|
||||
handleGroupClips,
|
||||
setElementFxAttribute,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
handleRazorSplit,
|
||||
|
||||
@@ -36,6 +36,9 @@ export function TimelineEditProvider({
|
||||
value.onToggleTrackHidden,
|
||||
value.onSetAudioGroupAttributeLive,
|
||||
value.onSetAudioGroupAttributeQuiet,
|
||||
value.onGroupClips,
|
||||
value.onSetElementAttributeLive,
|
||||
value.onSetElementAttributeQuiet,
|
||||
value.onBlockedEditAttempt,
|
||||
value.onSplitElement,
|
||||
value.onRazorSplit,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
buildPatchTarget,
|
||||
readFileContent,
|
||||
persistElementAttribute,
|
||||
type RecordEditInput,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type {
|
||||
@@ -63,37 +60,21 @@ async function setAudioGroupAttribute({
|
||||
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;
|
||||
}
|
||||
return persistElementAttribute({
|
||||
projectId,
|
||||
targetPath,
|
||||
patchTarget,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
|
||||
readLive: () =>
|
||||
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
||||
import { applyPatchByTarget, findTagByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
||||
import {
|
||||
applyPatchByTarget,
|
||||
findTagByTarget,
|
||||
readAttributeByTarget,
|
||||
readTagSnippetByTarget,
|
||||
type PatchOperation,
|
||||
} from "../utils/sourcePatcher";
|
||||
import {
|
||||
formatTimelineAttributeNumber,
|
||||
type TimelineStackingReorderIntent,
|
||||
@@ -390,3 +396,74 @@ export async function persistTimelineBatchEdit(
|
||||
export { applyPatchByTarget, formatTimelineAttributeNumber };
|
||||
|
||||
export { patchDocumentRootDuration } from "./timelineEditingGsap";
|
||||
|
||||
export interface PersistElementAttributeInput {
|
||||
projectId: string;
|
||||
targetPath: string;
|
||||
patchTarget: PatchTarget;
|
||||
attr: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: { current: number };
|
||||
pendingTimelineEditPathRef: { current: Set<string> };
|
||||
/** Write the attribute directly on the live preview DOM node. */
|
||||
patchLive: (value: string | null) => void;
|
||||
/** Read the attribute's current value off the live preview DOM node. */
|
||||
readLive: () => string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attribute, persisted to source and optimistically patched onto the
|
||||
* live preview, with a revert on save failure. The shared core behind
|
||||
* `setAudioGroupAttribute` (a group id addressed by its own DOM id) and
|
||||
* `useSetElementAttribute` (an arbitrary timeline clip) — same shape, only
|
||||
* how the live node is found and where the patch target resolves to differs,
|
||||
* which is exactly what `patchLive`/`readLive`/`patchTarget` parameterize.
|
||||
*/
|
||||
export async function persistElementAttribute({
|
||||
projectId,
|
||||
targetPath,
|
||||
patchTarget,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive,
|
||||
readLive,
|
||||
}: PersistElementAttributeInput): Promise<string[]> {
|
||||
const previousValue = readLive();
|
||||
patchLive(value);
|
||||
|
||||
const before = await readFileContent(projectId, targetPath);
|
||||
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
||||
throw new Error(`Unable to patch element 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.
|
||||
patchLive(previousValue);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* C1's clip-level FX write: persist one attribute directly on a specific
|
||||
* timeline clip, addressed by the clip itself rather than the current
|
||||
* selection — so applying a preset from the timeline FX popover doesn't
|
||||
* depend on that clip already being selected in the property panel.
|
||||
* Built on `persistElementAttribute` (`timelineEditingHelpers.ts`), the
|
||||
* shared core `setAudioGroupAttribute` also uses.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import {
|
||||
buildPatchTarget,
|
||||
findTimelineElementInIframe,
|
||||
persistElementAttribute,
|
||||
} from "./timelineEditingHelpers";
|
||||
import type {
|
||||
MutableRef,
|
||||
UseTimelineElementVisibilityEditingInput,
|
||||
} from "./timelineTrackVisibility";
|
||||
|
||||
function patchLiveElementAttribute(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
activeCompPath: string | null,
|
||||
): void {
|
||||
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
||||
if (!target) return;
|
||||
if (value === null) target.removeAttribute(attr);
|
||||
else target.setAttribute(attr, value);
|
||||
}
|
||||
|
||||
interface SetElementAttributeInput {
|
||||
projectId: string;
|
||||
activeCompPath: string | null;
|
||||
element: TimelineElement;
|
||||
attr: string;
|
||||
value: string | null;
|
||||
label: string;
|
||||
previewIframe: HTMLIFrameElement | null;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
recordEdit: Parameters<typeof persistElementAttribute>[0]["recordEdit"];
|
||||
domEditSaveTimestampRef: MutableRef<number>;
|
||||
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
||||
}
|
||||
|
||||
async function setElementAttribute({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
element,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: SetElementAttributeInput): Promise<string[]> {
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const patchTarget = buildPatchTarget(element);
|
||||
if (!patchTarget) return [];
|
||||
|
||||
return persistElementAttribute({
|
||||
projectId,
|
||||
targetPath,
|
||||
patchTarget,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
|
||||
readLive: () =>
|
||||
findTimelineElementInIframe(previewIframe, element, activeCompPath)?.getAttribute(attr) ??
|
||||
null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetElementAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
}: UseTimelineElementVisibilityEditingInput): {
|
||||
setLive: (element: TimelineElement, attr: string, value: string | null) => void;
|
||||
setQuiet: (
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
} {
|
||||
const setLive = useCallback(
|
||||
(element: TimelineElement, attr: string, value: string | null) => {
|
||||
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
||||
},
|
||||
[previewIframeRef, activeCompPath],
|
||||
);
|
||||
const setQuiet = useCallback(
|
||||
async (element: TimelineElement, 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 setElementAttribute({
|
||||
projectId: pid,
|
||||
activeCompPath,
|
||||
element,
|
||||
attr,
|
||||
value,
|
||||
label,
|
||||
previewIframe: previewIframeRef.current,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Timeline] Failed to set element attribute", error);
|
||||
const message = error instanceof Error ? error.message : "Failed to update effect";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
showToast,
|
||||
projectIdRef,
|
||||
],
|
||||
);
|
||||
return { setLive, setQuiet };
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Timeline clip deletion: the marquee/multi path and the single-clip wrapper
|
||||
// the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
|
||||
// it under the studio 600-line cap, following useTimelineAssetDropOps.
|
||||
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
import { buildPatchTarget } from "./timelineEditingHelpers";
|
||||
import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
||||
|
||||
interface UseTimelineDeleteOpsOptions {
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
isRecordingRef?: MutableRefObject<boolean>;
|
||||
forceReloadSdkSession?: () => void;
|
||||
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
}
|
||||
|
||||
export function useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
}: UseTimelineDeleteOpsOptions) {
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: TimelineElement[]) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const [element] = selection;
|
||||
if (!element) return;
|
||||
const label =
|
||||
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
||||
|
||||
// One file per delete pass. Every element in a marquee selection lives in
|
||||
// the composition being edited, so they share a target; anything that
|
||||
// does not is dropped rather than written to the wrong file.
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const sameFile = selection.filter(
|
||||
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
||||
);
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
// Remove every selected element before saving once. The server rewrites
|
||||
// the file per call, so `removedContent` after the last one holds them
|
||||
// all — which is what makes this a single history entry, and a single
|
||||
// undo, rather than one per clip.
|
||||
let removedContent = originalContent;
|
||||
for (const target of sameFile) {
|
||||
const patchTarget = buildPatchTarget(target);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
||||
}
|
||||
|
||||
const removeData = (await removeResponse.json()) as {
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
if (typeof removeData.content === "string") removedContent = removeData.content;
|
||||
}
|
||||
// Content-driven duration: shrink the composition to the furthest
|
||||
// remaining clip end, read from the post-removal SOURCE (raw
|
||||
// data-duration), so deleting the last/longest clip removes trailing
|
||||
// empty space. Measured from the source, not the store, whose
|
||||
// durations are runtime-truncated.
|
||||
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
||||
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
||||
// Optimistically reflect the shrunk length in the readout/seek bar,
|
||||
// rolling it back if the persist below fails (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(deleteContentEnd);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
// remove-element already wrote the removal, so disk holds THAT — not the
|
||||
// content read at the top. Undo still goes back to the original.
|
||||
diskContent: { [targetPath]: removedContent },
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
usePlayerStore.getState().setSelectedElementIds(new Set());
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
showToast(
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
projectIdRef,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
/** Single-clip delete — the context menu and clip chrome path. */
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
await handleTimelineElementsDelete([element]);
|
||||
},
|
||||
[handleTimelineElementsDelete],
|
||||
);
|
||||
|
||||
return { handleTimelineElementsDelete, handleTimelineElementDelete };
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
||||
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
||||
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
||||
import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
|
||||
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
||||
import {
|
||||
applyTimelineStackingReorder,
|
||||
buildPatchTarget,
|
||||
patchIframeDomTiming,
|
||||
playbackStartAttributeForElement,
|
||||
persistTimelineEdit,
|
||||
@@ -28,6 +22,8 @@ import {
|
||||
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
||||
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
|
||||
import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
||||
import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
|
||||
import { useSetElementAttribute } from "./timelineElementFxAttribute";
|
||||
import {
|
||||
useAudioGroupCarveAssignment,
|
||||
useTimelineElementVisibilityEditing,
|
||||
@@ -38,7 +34,6 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
||||
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
||||
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
||||
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
||||
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
||||
|
||||
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
||||
stackingReorder?: TimelineStackingReorderIntent | null;
|
||||
@@ -402,6 +397,18 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const setElementFxAttribute = useSetElementAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
previewIframeRef,
|
||||
pendingTimelineEditPathRef,
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
const setAudioGroupAttribute = useSetAudioGroupAttribute({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
@@ -414,131 +421,19 @@ export function useTimelineEditing({
|
||||
isRecordingRef,
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleTimelineElementsDelete = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (selection: TimelineElement[]) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
return;
|
||||
}
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) throw new Error("No active project");
|
||||
const [element] = selection;
|
||||
if (!element) return;
|
||||
const label =
|
||||
selection.length === 1 ? getTimelineElementLabel(element) : `${selection.length} clips`;
|
||||
|
||||
// One file per delete pass. Every element in a marquee selection lives in
|
||||
// the composition being edited, so they share a target; anything that
|
||||
// does not is dropped rather than written to the wrong file.
|
||||
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||
const sameFile = selection.filter(
|
||||
(candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
|
||||
);
|
||||
try {
|
||||
const originalContent = await readFileContent(pid, targetPath);
|
||||
|
||||
// Remove every selected element before saving once. The server rewrites
|
||||
// the file per call, so `removedContent` after the last one holds them
|
||||
// all — which is what makes this a single history entry, and a single
|
||||
// undo, rather than one per clip.
|
||||
let removedContent = originalContent;
|
||||
for (const target of sameFile) {
|
||||
const patchTarget = buildPatchTarget(target);
|
||||
if (!patchTarget) {
|
||||
throw new Error(`Timeline element ${target.id} is missing a patchable target`);
|
||||
}
|
||||
|
||||
const removeResponse = await fetch(
|
||||
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
||||
body: JSON.stringify({ target: patchTarget }),
|
||||
},
|
||||
);
|
||||
if (!removeResponse.ok) {
|
||||
throw new Error(`Failed to delete ${target.id} from ${targetPath}`);
|
||||
}
|
||||
|
||||
const removeData = (await removeResponse.json()) as {
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
};
|
||||
if (typeof removeData.content === "string") removedContent = removeData.content;
|
||||
}
|
||||
// Content-driven duration: shrink the composition to the furthest
|
||||
// remaining clip end, read from the post-removal SOURCE (raw
|
||||
// data-duration), so deleting the last/longest clip removes trailing
|
||||
// empty space. Measured from the source, not the store, whose
|
||||
// durations are runtime-truncated.
|
||||
const deleteContentEnd = furthestClipEndFromSource(removedContent);
|
||||
const patchedContent = setCompositionDurationToContent(removedContent, deleteContentEnd);
|
||||
// Optimistically reflect the shrunk length in the readout/seek bar,
|
||||
// rolling it back if the persist below fails (see captureDurationRollback).
|
||||
const rollbackDuration = captureDurationRollback(previewIframeRef.current);
|
||||
if (deleteContentEnd > 0 && targetPath === (activeCompPath || "index.html")) {
|
||||
usePlayerStore.getState().setDuration(deleteContentEnd);
|
||||
}
|
||||
|
||||
domEditSaveTimestampRef.current = Date.now();
|
||||
try {
|
||||
await saveProjectFilesWithHistory({
|
||||
projectId: pid,
|
||||
label: "Delete timeline clip",
|
||||
kind: "timeline",
|
||||
files: { [targetPath]: patchedContent },
|
||||
readFile: async () => originalContent,
|
||||
// remove-element already wrote the removal, so disk holds THAT — not the
|
||||
// content read at the top. Undo still goes back to the original.
|
||||
diskContent: { [targetPath]: removedContent },
|
||||
writeFile: writeProjectFile,
|
||||
recordEdit,
|
||||
});
|
||||
} catch (error) {
|
||||
rollbackDuration();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
|
||||
usePlayerStore
|
||||
.getState()
|
||||
.setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
|
||||
usePlayerStore.getState().setSelectedElementId(null);
|
||||
usePlayerStore.getState().setSelectedElementIds(new Set());
|
||||
forceReloadSdkSession?.();
|
||||
reloadPreview();
|
||||
showToast(
|
||||
`Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
|
||||
"info",
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
||||
showToast(message);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeCompPath,
|
||||
recordEdit,
|
||||
showToast,
|
||||
timelineElements,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
],
|
||||
);
|
||||
|
||||
/** Single-clip delete — the context menu and clip chrome path. */
|
||||
const handleTimelineElementDelete = useCallback(
|
||||
async (element: TimelineElement) => {
|
||||
await handleTimelineElementsDelete([element]);
|
||||
},
|
||||
[handleTimelineElementsDelete],
|
||||
);
|
||||
const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
|
||||
projectIdRef,
|
||||
activeCompPath,
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
previewIframeRef,
|
||||
});
|
||||
|
||||
const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
|
||||
useTimelineAssetDropOps({
|
||||
@@ -586,6 +481,7 @@ export function useTimelineEditing({
|
||||
handleToggleElementHidden,
|
||||
handleAutoGroupCarveSources,
|
||||
setAudioGroupAttribute,
|
||||
setElementFxAttribute,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementsDelete,
|
||||
handleTimelineElementSplit: handleRazorSplit,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { serializeAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { TimelineFxButton } from "./TimelineFxButton.js";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
||||
}
|
||||
|
||||
function mount(node: React.ReactElement) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
act(() => createRoot(host).render(node));
|
||||
return host;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("TimelineFxButton", () => {
|
||||
it("reads FX with no count when the chain is empty", () => {
|
||||
const host = mount(
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={undefined}
|
||||
onChainChange={vi.fn()}
|
||||
onOpenRack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(byTextButton(host, "FX")?.textContent).toBe("FX");
|
||||
});
|
||||
|
||||
it("counts only enabled nodes", () => {
|
||||
const chain = {
|
||||
version: 1 as const,
|
||||
nodes: [
|
||||
{ type: "peaking", params: {}, enabled: true },
|
||||
{ type: "gain", params: {}, enabled: false },
|
||||
],
|
||||
};
|
||||
const host = mount(
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={serializeAudioFxChain(chain)}
|
||||
onChainChange={vi.fn()}
|
||||
onOpenRack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(byTextButton(host, "FX 1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("opens the popover on click, anchored off the button", () => {
|
||||
const host = mount(
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={undefined}
|
||||
onChainChange={vi.fn()}
|
||||
onOpenRack={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(host.querySelector('[role="dialog"]')).toBeNull();
|
||||
act(() => byTextButton(host, "FX")?.click());
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("group-pointer variant offers Group instead of a popover", () => {
|
||||
const onGroupClips = vi.fn();
|
||||
const host = mount(<TimelineFxButton variant="group-pointer" onGroupClips={onGroupClips} />);
|
||||
act(() => byTextButton(host, "FX")?.click());
|
||||
const groupButton = document.body.querySelectorAll("button");
|
||||
const group = Array.from(groupButton).find((b) => b.textContent === "Group");
|
||||
expect(group).toBeDefined();
|
||||
act(() => group?.click());
|
||||
expect(onGroupClips).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The FX entry point from the timeline (C1) — a track or group header button
|
||||
* that opens the same preset shelf the property panel's rack uses, without
|
||||
* requiring a trip through the panel first.
|
||||
*
|
||||
* Rendered on group rows and on track rows holding exactly one audio clip
|
||||
* (see `plans/audio-mixer-groups.md` §1.6 and the execution runbook's C1 step
|
||||
* — a track with several ungrouped clips has no single chain to point at, so
|
||||
* it gets the grouping pointer instead of the popover).
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
enabledAudioFxNodes,
|
||||
parseAudioFxChain,
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
|
||||
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
|
||||
|
||||
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
|
||||
if (!raw) return { version: 1, nodes: [] };
|
||||
try {
|
||||
return parseAudioFxChain(raw);
|
||||
} catch {
|
||||
return { version: 1, nodes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
interface TimelineFxButtonChainProps {
|
||||
variant?: "chain";
|
||||
trackKind?: HfAudioNameKind;
|
||||
onOpenRack: () => void;
|
||||
fxChainRaw: string | undefined;
|
||||
onChainChange: (next: HfAudioFxChain) => void;
|
||||
onChainPreview?: (next: HfAudioFxChain) => void;
|
||||
onAuditionTransport?: (on: boolean) => void;
|
||||
}
|
||||
|
||||
interface TimelineFxButtonGroupPointerProps {
|
||||
variant: "group-pointer";
|
||||
onGroupClips: () => void;
|
||||
}
|
||||
|
||||
type TimelineFxButtonProps = TimelineFxButtonChainProps | TimelineFxButtonGroupPointerProps;
|
||||
|
||||
export function TimelineFxButton(props: TimelineFxButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const openAt = () => {
|
||||
setAnchorRect(buttonRef.current?.getBoundingClientRect() ?? null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
if (props.variant === "group-pointer") {
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
ref={buttonRef}
|
||||
aria-label="Effects — group these clips first"
|
||||
title="Group these clips to add effects to all of them"
|
||||
className="flex h-6 items-center justify-center rounded border-0 bg-transparent px-1 text-[10px] font-semibold text-white/35 hover:text-white/75"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openAt();
|
||||
}}
|
||||
>
|
||||
FX
|
||||
</button>
|
||||
{open &&
|
||||
anchorRect &&
|
||||
createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Group these clips to add effects"
|
||||
className="z-50 w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
|
||||
style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p>Group these clips to add effects to all of them.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 w-full rounded border border-white/20 py-1 text-[10px] font-semibold text-white hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
props.onGroupClips();
|
||||
}}
|
||||
>
|
||||
Group
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const chain = parseFxChainOrEmpty(props.fxChainRaw);
|
||||
const nodeCount = enabledAudioFxNodes(chain).length;
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
ref={buttonRef}
|
||||
aria-label={nodeCount > 0 ? `Effects — ${nodeCount} applied` : "Effects"}
|
||||
title="Effects"
|
||||
className={`flex h-6 items-center justify-center gap-0.5 rounded border-0 bg-transparent px-1 text-[10px] font-semibold transition-colors ${
|
||||
open || nodeCount > 0 ? "text-[#3CE6AC]" : "text-white/35 hover:text-white/75"
|
||||
}`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openAt();
|
||||
}}
|
||||
>
|
||||
FX{nodeCount > 0 ? ` ${nodeCount}` : ""}
|
||||
</button>
|
||||
{open &&
|
||||
anchorRect &&
|
||||
createPortal(
|
||||
<TimelineFxPopover
|
||||
anchorRect={anchorRect}
|
||||
chain={chain}
|
||||
trackKind={props.trackKind}
|
||||
onClose={() => setOpen(false)}
|
||||
onChainChange={props.onChainChange}
|
||||
onChainPreview={props.onChainPreview}
|
||||
onAuditionTransport={props.onAuditionTransport}
|
||||
onOpenRack={props.onOpenRack}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
|
||||
import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
|
||||
import { TRACK_H } from "./timelineLayout";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import { TimelineFxButton } from "./TimelineFxButton";
|
||||
|
||||
interface TimelineGroupHeaderProps {
|
||||
label: string;
|
||||
@@ -21,14 +23,19 @@ interface TimelineGroupHeaderProps {
|
||||
isHalfLitSolo: boolean;
|
||||
/** `add: true` (⌘/Ctrl-click) toggles membership; a plain click is exclusive. */
|
||||
onToggleSolo: (options?: { add?: boolean }) => void;
|
||||
/** C1: the group's serialized `data-fx-chain`, when set. */
|
||||
fxChain?: string;
|
||||
onFxChainChange: (next: HfAudioFxChain) => void;
|
||||
onFxChainPreview?: (next: HfAudioFxChain) => void;
|
||||
onFxAuditionTransport?: (on: boolean) => void;
|
||||
onOpenFxRack: () => void;
|
||||
columnWidth: number;
|
||||
theme: TimelineTheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* A group's own row header: caret (member disclosure) + `▤` + label + count +
|
||||
* mute + solo + `∿ n` (lane disclosure). The FX entry point (C1) lands here
|
||||
* as a sibling once that step exists.
|
||||
* mute + solo + FX + `∿ n` (lane disclosure).
|
||||
*/
|
||||
export function TimelineGroupHeader({
|
||||
label,
|
||||
@@ -43,6 +50,11 @@ export function TimelineGroupHeader({
|
||||
isSoloed,
|
||||
isHalfLitSolo,
|
||||
onToggleSolo,
|
||||
fxChain,
|
||||
onFxChainChange,
|
||||
onFxChainPreview,
|
||||
onFxAuditionTransport,
|
||||
onOpenFxRack,
|
||||
columnWidth,
|
||||
theme,
|
||||
}: TimelineGroupHeaderProps) {
|
||||
@@ -132,6 +144,13 @@ export function TimelineGroupHeader({
|
||||
>
|
||||
<span aria-hidden="true">⌗</span>
|
||||
</button>
|
||||
<TimelineFxButton
|
||||
fxChainRaw={fxChain}
|
||||
onChainChange={onFxChainChange}
|
||||
onChainPreview={onFxChainPreview}
|
||||
onAuditionTransport={onFxAuditionTransport}
|
||||
onOpenRack={onOpenFxRack}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { TimelineElement } from "../store/playerStore";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { isGroupHalfLitUnderSolo } from "../store/audioSoloSlice";
|
||||
import {
|
||||
HF_AUDIO_FX_ATTR,
|
||||
serializeAudioFxChain,
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import type { TimelineTheme } from "./timelineTheme";
|
||||
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
||||
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
||||
@@ -10,6 +15,7 @@ import { TimelineGroupBusStrip } from "./TimelineGroupBusStrip";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
import { LABEL_COL_W } from "./timelineLayout";
|
||||
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
||||
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
||||
|
||||
interface TimelineGroupRowProps {
|
||||
index: number;
|
||||
@@ -56,9 +62,24 @@ export function TimelineGroupRow({
|
||||
});
|
||||
const isLaneOpen = expandedLaneOwnerIds.has(group.id);
|
||||
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } = useTimelineEditContext();
|
||||
const domEditActions = useDomEditActionsContextOptional();
|
||||
const soloed = usePlayerStore((s) => s.soloed);
|
||||
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||||
const memberIds = memberElements.map((el) => el.key ?? el.id);
|
||||
const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
|
||||
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
||||
if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
|
||||
else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
};
|
||||
const openGroupFxRack = () => {
|
||||
const target = domEditActions?.previewIframeRef.current?.contentDocument?.getElementById(
|
||||
group.id,
|
||||
);
|
||||
if (!target) return;
|
||||
void domEditActions
|
||||
?.buildDomSelectionFromTarget(target)
|
||||
.then((selection) => selection && domEditActions.applyDomSelection(selection));
|
||||
};
|
||||
return (
|
||||
<TimelineTrackRow
|
||||
index={index}
|
||||
@@ -94,6 +115,10 @@ export function TimelineGroupRow({
|
||||
isSoloed={soloed.has(group.id)}
|
||||
isHalfLitSolo={isGroupHalfLitUnderSolo(soloed, group.id, memberIds)}
|
||||
onToggleSolo={(options) => toggleSolo(group.id, options)}
|
||||
fxChain={group.fxChain}
|
||||
onFxChainChange={(next) => writeGroupFxChain(next, false)}
|
||||
onFxChainPreview={(next) => writeGroupFxChain(next, true)}
|
||||
onOpenFxRack={openGroupFxRack}
|
||||
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
||||
theme={theme}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import {
|
||||
HF_AUDIO_FX_ATTR,
|
||||
serializeAudioFxChain,
|
||||
type HfAudioFxChain,
|
||||
} from "@hyperframes/core/audio-fx";
|
||||
import { classifyAudioName } from "@hyperframes/core/audio-carve";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader";
|
||||
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
||||
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
||||
import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext";
|
||||
import { mintGroupId } from "../../components/editor/useFxCarveGrouping";
|
||||
import { TimelineFxButton } from "./TimelineFxButton";
|
||||
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
||||
import { groupAutomationLanes } from "./automationLaneData";
|
||||
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
||||
@@ -380,6 +390,31 @@ export function TimelineTrackHeader({
|
||||
const soloed = usePlayerStore((s) => s.soloed);
|
||||
const toggleSolo = usePlayerStore((s) => s.toggleSolo);
|
||||
|
||||
// C1: the FX entry point. A single audio clip has one chain to point at; a
|
||||
// track holding several ungrouped ones has no single chain — the design
|
||||
// doc refuses to build "N clips = N chains", so that case gets a pointer
|
||||
// at grouping (B6's normative rule) instead of a popover.
|
||||
const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
|
||||
useTimelineEditContextOptional();
|
||||
const domEditActions = useDomEditActionsContextOptional();
|
||||
const singleAudioClip =
|
||||
isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
|
||||
const isTrackGrouped = trackElements.some((el) => el.audioGroup);
|
||||
const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
|
||||
const value = next.nodes.length ? serializeAudioFxChain(next) : null;
|
||||
if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
|
||||
else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
|
||||
};
|
||||
const openClipFxRack = (clip: TimelineElement) => {
|
||||
void domEditActions?.handleTimelineElementSelect(clip);
|
||||
};
|
||||
const groupUngroupedClips = () => {
|
||||
const doc = domEditActions?.previewIframeRef.current?.contentDocument;
|
||||
if (!doc || !onGroupClips) return;
|
||||
const clipIds = trackElements.map((el) => el.key ?? el.id);
|
||||
void onGroupClips(clipIds, mintGroupId(doc));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="rowheader"
|
||||
@@ -398,19 +433,34 @@ export function TimelineTrackHeader({
|
||||
}}
|
||||
>
|
||||
{!keyframeClip || !disclosable ? (
|
||||
<PlainTrackHeader
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
trackLabel={trackLabel}
|
||||
clipCount={clipCount}
|
||||
showTrackLabel={showTrackLabel}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
||||
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
||||
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
/>
|
||||
<>
|
||||
<PlainTrackHeader
|
||||
trackNumber={trackNumber}
|
||||
trackDisplayNumber={trackDisplayNumber}
|
||||
trackLabel={trackLabel}
|
||||
clipCount={clipCount}
|
||||
showTrackLabel={showTrackLabel}
|
||||
isTrackHidden={isTrackHidden}
|
||||
isAudioTrack={isAudioTrack}
|
||||
isGroupMuted={trackElements.some((el) => el.audioGroupHidden)}
|
||||
isSoloed={soloTargetId !== null && soloed.has(soloTargetId)}
|
||||
onToggleSolo={soloTargetId ? (options) => toggleSolo(soloTargetId, options) : undefined}
|
||||
onToggleTrackHidden={onToggleTrackHidden}
|
||||
/>
|
||||
{singleAudioClip && (
|
||||
<TimelineFxButton
|
||||
variant="chain"
|
||||
fxChainRaw={singleAudioClip.fxChain}
|
||||
trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
|
||||
onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
|
||||
onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
|
||||
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
||||
/>
|
||||
)}
|
||||
{isAudioTrack && clipCount > 1 && !isTrackGrouped && (
|
||||
<TimelineFxButton variant="group-pointer" onGroupClips={groupUngroupedClips} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayerDisclosureRow
|
||||
|
||||
@@ -77,6 +77,23 @@ export interface TimelineEditCallbacks {
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
/** C1's ungrouped-track FX pointer: "Group these clips" — write
|
||||
* `data-audio-group` on every one of them, atomically. Same shape B6's
|
||||
* carve auto-grouping uses. */
|
||||
onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
||||
/** C1's single-clip FX write: addressed by the clip itself rather than the
|
||||
* current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
|
||||
onSetElementAttributeLive?: (
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
) => void;
|
||||
onSetElementAttributeQuiet?: (
|
||||
element: TimelineElement,
|
||||
attr: string,
|
||||
value: string | null,
|
||||
label: string,
|
||||
) => Promise<void>;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface TimelineTrackGroupInfo {
|
||||
volume: number;
|
||||
/** The group element's `data-hidden`, mirrored from a member's parse (B5's group mute). */
|
||||
hidden: boolean;
|
||||
/** The group element's serialized `data-fx-chain`, mirrored from a member's parse (C1's FX entry). */
|
||||
fxChain?: string;
|
||||
}
|
||||
|
||||
interface GroupMembership {
|
||||
@@ -28,15 +30,17 @@ interface GroupMembership {
|
||||
labelByGroup: Map<string, string>;
|
||||
volumeByGroup: Map<string, number>;
|
||||
hiddenByGroup: Map<string, boolean>;
|
||||
fxChainByGroup: Map<string, string | undefined>;
|
||||
}
|
||||
|
||||
/** Which track belongs to which group, and each group's label/volume/hidden — one pass over raw tracks. */
|
||||
/** Which track belongs to which group, and each group's label/volume/hidden/fxChain — one pass over raw tracks. */
|
||||
function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): GroupMembership {
|
||||
const trackToGroupId = new Map<number, string>();
|
||||
const memberTracksByGroup = new Map<string, number[]>();
|
||||
const labelByGroup = new Map<string, string>();
|
||||
const volumeByGroup = new Map<string, number>();
|
||||
const hiddenByGroup = new Map<string, boolean>();
|
||||
const fxChainByGroup = new Map<string, string | undefined>();
|
||||
for (const [trackNum, elements] of rawTracks) {
|
||||
const owner = elements.find((el) => el.audioGroup);
|
||||
if (!owner?.audioGroup) continue;
|
||||
@@ -45,12 +49,20 @@ function resolveGroupMembership(rawTracks: [number, TimelineElement[]][]): Group
|
||||
labelByGroup.set(owner.audioGroup, owner.audioGroupLabel ?? owner.audioGroup);
|
||||
volumeByGroup.set(owner.audioGroup, owner.audioGroupVolume ?? 1);
|
||||
hiddenByGroup.set(owner.audioGroup, owner.audioGroupHidden ?? false);
|
||||
fxChainByGroup.set(owner.audioGroup, owner.audioGroupFxChain);
|
||||
}
|
||||
const members = memberTracksByGroup.get(owner.audioGroup) ?? [];
|
||||
members.push(trackNum);
|
||||
memberTracksByGroup.set(owner.audioGroup, members);
|
||||
}
|
||||
return { trackToGroupId, memberTracksByGroup, labelByGroup, volumeByGroup, hiddenByGroup };
|
||||
return {
|
||||
trackToGroupId,
|
||||
memberTracksByGroup,
|
||||
labelByGroup,
|
||||
volumeByGroup,
|
||||
hiddenByGroup,
|
||||
fxChainByGroup,
|
||||
};
|
||||
}
|
||||
|
||||
/** One group's resolved row info, built once the first time its id is seen. */
|
||||
@@ -62,6 +74,7 @@ function buildGroupInfo(
|
||||
const memberTracks = [...(membership.memberTracksByGroup.get(groupId) ?? [])].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
const fxChain = membership.fxChainByGroup.get(groupId);
|
||||
return {
|
||||
id: groupId,
|
||||
label: membership.labelByGroup.get(groupId) ?? groupId,
|
||||
@@ -69,6 +82,7 @@ function buildGroupInfo(
|
||||
memberTracks,
|
||||
volume: membership.volumeByGroup.get(groupId) ?? 1,
|
||||
hidden: membership.hiddenByGroup.get(groupId) ?? false,
|
||||
...(fxChain ? { fxChain } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -70,22 +70,28 @@ function resolveClipTag(clip: ClipManifestClip): string {
|
||||
|
||||
// One `<hf-audio-group>` scan per document, not per clip — resolveAudioGroups
|
||||
// walks the whole tree, and a parse touches every clip in it.
|
||||
const groupInfoCache = new WeakMap<
|
||||
Document,
|
||||
Map<string, { label: string; volume: number; hidden: boolean }>
|
||||
>();
|
||||
interface GroupInfo {
|
||||
label: string;
|
||||
volume: number;
|
||||
hidden: boolean;
|
||||
fxChain?: string;
|
||||
}
|
||||
|
||||
function groupInfoFor(
|
||||
doc: Document | null | undefined,
|
||||
groupId: string,
|
||||
): { label: string; volume: number; hidden: boolean } {
|
||||
const groupInfoCache = new WeakMap<Document, Map<string, GroupInfo>>();
|
||||
|
||||
function groupInfoFor(doc: Document | null | undefined, groupId: string): GroupInfo {
|
||||
if (!doc) return { label: groupId, volume: 1, hidden: false };
|
||||
let info = groupInfoCache.get(doc);
|
||||
if (!info) {
|
||||
info = new Map(
|
||||
resolveAudioGroups(doc).map((group) => [
|
||||
group.id,
|
||||
{ label: group.label, volume: group.volume, hidden: group.hidden },
|
||||
{
|
||||
label: group.label,
|
||||
volume: group.volume,
|
||||
hidden: group.hidden,
|
||||
...(group.fxChain ? { fxChain: group.fxChain } : {}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
groupInfoCache.set(doc, info);
|
||||
@@ -171,6 +177,7 @@ export function createTimelineElementFromManifestClip(params: {
|
||||
entry.audioGroupLabel = info.label;
|
||||
entry.audioGroupVolume = info.volume;
|
||||
entry.audioGroupHidden = info.hidden;
|
||||
if (info.fxChain) entry.audioGroupFxChain = info.fxChain;
|
||||
}
|
||||
const fxChain = hostEl.getAttribute("data-fx-chain");
|
||||
if (fxChain) entry.fxChain = fxChain;
|
||||
@@ -397,6 +404,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
||||
entry.audioGroupLabel = domGroupInfo.label;
|
||||
entry.audioGroupVolume = domGroupInfo.volume;
|
||||
entry.audioGroupHidden = domGroupInfo.hidden;
|
||||
if (domGroupInfo.fxChain) entry.audioGroupFxChain = domGroupInfo.fxChain;
|
||||
}
|
||||
|
||||
// Sub-compositions
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface TimelineElement {
|
||||
audioGroupVolume?: number;
|
||||
/** The owning group's `data-hidden` (defaults to false) — resolved once per parse. */
|
||||
audioGroupHidden?: boolean;
|
||||
/** The owning group's serialized `data-fx-chain`, when set — resolved once per parse. */
|
||||
audioGroupFxChain?: string;
|
||||
/**
|
||||
* Set by useExpandedTimelineElements on an inline-expanded sub-composition
|
||||
* child: the absolute master-timeline start of the sub-comp host the child
|
||||
|
||||
Reference in New Issue
Block a user