@hyperframes/studio 0.8.11 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6431 -5515
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating an audio group: the member sweep, the group element, and the
|
|
3
|
+
* carve's auto-group write-back.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `timelineTrackVisibility.ts`, which owns the hidden/mute writes
|
|
6
|
+
* these mirror and had reached the 600-line studio ceiling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback } from "react";
|
|
10
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
11
|
+
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
|
|
12
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
13
|
+
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
14
|
+
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
15
|
+
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
|
16
|
+
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
17
|
+
import {
|
|
18
|
+
applyPatchByTarget,
|
|
19
|
+
buildPatchTarget,
|
|
20
|
+
findTimelineElementInIframe,
|
|
21
|
+
readFileContent,
|
|
22
|
+
type RecordEditInput,
|
|
23
|
+
} from "./timelineEditingHelpers";
|
|
24
|
+
import {
|
|
25
|
+
groupElementsByTargetPath,
|
|
26
|
+
reseekPreviewRuntime,
|
|
27
|
+
type MutableRef,
|
|
28
|
+
type UseTimelineElementVisibilityEditingInput,
|
|
29
|
+
} from "./timelineTrackVisibility";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Assign (or restore) `data-audio-group` across a set of members.
|
|
33
|
+
*
|
|
34
|
+
* `restore` carries each member's PRIOR value so the unwind can put back a
|
|
35
|
+
* membership that already existed, rather than removing the attribute outright.
|
|
36
|
+
* `setElementsHidden`, which this mirrors, gets away with a plain `!hidden`
|
|
37
|
+
* because hidden is boolean; group membership is an arbitrary id, and the carve
|
|
38
|
+
* path does not check whether a clip is already grouped — so a failed save
|
|
39
|
+
* could silently un-group clips that belonged to another group before it.
|
|
40
|
+
*/
|
|
41
|
+
function patchLiveAudioGroupState(
|
|
42
|
+
iframe: HTMLIFrameElement | null,
|
|
43
|
+
elements: readonly TimelineElement[],
|
|
44
|
+
groupId: string | null,
|
|
45
|
+
activeCompPath: string | null,
|
|
46
|
+
restore?: ReadonlyMap<TimelineElement, string | null>,
|
|
47
|
+
): void {
|
|
48
|
+
for (const element of elements) {
|
|
49
|
+
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
50
|
+
if (!target) continue;
|
|
51
|
+
const next = restore ? (restore.get(element) ?? null) : groupId;
|
|
52
|
+
if (next) target.setAttribute(HF_AUDIO_GROUP_ATTR, next);
|
|
53
|
+
else target.removeAttribute(HF_AUDIO_GROUP_ATTR);
|
|
54
|
+
}
|
|
55
|
+
invalidateGroupInfoCache(iframe?.contentDocument);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Each member's `data-audio-group` before this write, for the unwind. */
|
|
59
|
+
function captureAudioGroupState(
|
|
60
|
+
iframe: HTMLIFrameElement | null,
|
|
61
|
+
elements: readonly TimelineElement[],
|
|
62
|
+
activeCompPath: string | null,
|
|
63
|
+
): Map<TimelineElement, string | null> {
|
|
64
|
+
const prior = new Map<TimelineElement, string | null>();
|
|
65
|
+
for (const element of elements) {
|
|
66
|
+
const target = findTimelineElementInIframe(iframe, element, activeCompPath);
|
|
67
|
+
prior.set(element, target?.getAttribute(HF_AUDIO_GROUP_ATTR) ?? null);
|
|
68
|
+
}
|
|
69
|
+
return prior;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Group ids are interpolated into markup and into a render-side filename, so
|
|
73
|
+
* they stay in the character set an HTML id and a path can both carry. */
|
|
74
|
+
const GROUP_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The group's own `<hf-audio-group>` element, appended before `</body>` when it
|
|
78
|
+
* is not already in the file.
|
|
79
|
+
*
|
|
80
|
+
* Membership alone is enough for `resolveAudioGroups` to see the group, but
|
|
81
|
+
* every group-level WRITE — mute, the bus fader's `data-volume`, an FX preset —
|
|
82
|
+
* addresses the group by its DOM id (`setAudioGroupAttribute` →
|
|
83
|
+
* `buildPatchTarget({ domId: groupId })`), so without an element of its own a
|
|
84
|
+
* group is created and then cannot be edited at all.
|
|
85
|
+
*
|
|
86
|
+
* Written to the active composition file rather than beside the members, which
|
|
87
|
+
* can live in a sub-composition: that is the file the group's later writes
|
|
88
|
+
* target, and `resolveAudioGroups` reads the flattened document, so co-location
|
|
89
|
+
* buys nothing.
|
|
90
|
+
*/
|
|
91
|
+
/** Attribute-safe, for a name the author typed. */
|
|
92
|
+
function escapeAttr(value: string): string {
|
|
93
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function insertGroupElement(html: string, groupId: string, label?: string): string {
|
|
97
|
+
const existing = readTagSnippetByTarget(html, { id: groupId });
|
|
98
|
+
if (existing !== undefined) {
|
|
99
|
+
// Only OUR tag counts as "already there". The id was minted against the
|
|
100
|
+
// live preview document, which does not contain markup that is on disk but
|
|
101
|
+
// not rendered (inside a `<template>`, or an unloaded sub-composition) — so
|
|
102
|
+
// an unrelated element can already own it. Writing nothing there would aim
|
|
103
|
+
// every later group write (`buildPatchTarget({ domId })`) at that element,
|
|
104
|
+
// stamping data-volume / data-hidden / data-fx-chain onto it.
|
|
105
|
+
if (new RegExp(`^<\\s*${HF_AUDIO_GROUP_TAG}\\b`, "i").test(existing)) return html;
|
|
106
|
+
throw new Error(`Cannot create audio group: id ${groupId} is already used in this file`);
|
|
107
|
+
}
|
|
108
|
+
// The author's name for the group, from the naming dialog (groups doc §5).
|
|
109
|
+
// Without it the timeline falls back to the minted id, which is the one thing
|
|
110
|
+
// the dialog exists to stop an author having to read.
|
|
111
|
+
const labelAttr = label ? ` data-label="${escapeAttr(label)}"` : "";
|
|
112
|
+
const tag = `<${HF_AUDIO_GROUP_TAG} id="${groupId}"${labelAttr}></${HF_AUDIO_GROUP_TAG}>`;
|
|
113
|
+
const closeBody = html.lastIndexOf("</body>");
|
|
114
|
+
if (closeBody < 0) return `${html}\n${tag}\n`;
|
|
115
|
+
return `${html.slice(0, closeBody)} ${tag}\n ${html.slice(closeBody)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The same element in the live preview, so the group is editable before the
|
|
119
|
+
* next reload. Returns true when it created one (only then may the unwind
|
|
120
|
+
* remove it — a pre-existing group element is not ours to delete). */
|
|
121
|
+
function patchLiveGroupElement(
|
|
122
|
+
iframe: HTMLIFrameElement | null,
|
|
123
|
+
groupId: string,
|
|
124
|
+
label?: string,
|
|
125
|
+
): boolean {
|
|
126
|
+
const doc = iframe?.contentDocument;
|
|
127
|
+
if (!doc?.body || doc.getElementById(groupId)) return false;
|
|
128
|
+
const el = doc.createElement(HF_AUDIO_GROUP_TAG);
|
|
129
|
+
el.id = groupId;
|
|
130
|
+
if (label) el.setAttribute("data-label", label);
|
|
131
|
+
doc.body.appendChild(el);
|
|
132
|
+
invalidateGroupInfoCache(doc);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface CreateAudioGroupAndAssignMembersInput {
|
|
137
|
+
projectId: string;
|
|
138
|
+
activeCompPath: string | null;
|
|
139
|
+
elements: readonly TimelineElement[];
|
|
140
|
+
groupId: string;
|
|
141
|
+
/** The author's name for it, from the naming dialog (groups doc §5). */
|
|
142
|
+
groupLabel?: string;
|
|
143
|
+
previewIframe: HTMLIFrameElement | null;
|
|
144
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
145
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
146
|
+
domEditSaveTimestampRef: MutableRef<number>;
|
|
147
|
+
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Group two or more voice clips: write `data-audio-group="<groupId>"` on
|
|
152
|
+
* every one of them, atomically, one undo entry — the same multi-target shape
|
|
153
|
+
* `setElementsHidden` uses for mute — plus the group's own `<hf-audio-group>`
|
|
154
|
+
* element, which every later group-level write addresses by DOM id. No naming
|
|
155
|
+
* dialog: the id is the default name, the way `resolveAudioGroups` reads it.
|
|
156
|
+
*/
|
|
157
|
+
// fallow-ignore-next-line complexity
|
|
158
|
+
export async function createAudioGroupAndAssignMembers({
|
|
159
|
+
projectId,
|
|
160
|
+
activeCompPath,
|
|
161
|
+
elements,
|
|
162
|
+
groupId,
|
|
163
|
+
groupLabel,
|
|
164
|
+
previewIframe,
|
|
165
|
+
writeProjectFile,
|
|
166
|
+
recordEdit,
|
|
167
|
+
domEditSaveTimestampRef,
|
|
168
|
+
pendingTimelineEditPathRef,
|
|
169
|
+
}: CreateAudioGroupAndAssignMembersInput): Promise<string[]> {
|
|
170
|
+
// Throws rather than returning empty: the carve's auto-group awaits this and
|
|
171
|
+
// then persists `sources: [groupId]` on success, so a quiet no-op leaves the
|
|
172
|
+
// carve aimed at a group that does not exist.
|
|
173
|
+
if (elements.length < 2) {
|
|
174
|
+
throw new Error(`Cannot group ${elements.length} clip(s) — a group needs at least two`);
|
|
175
|
+
}
|
|
176
|
+
if (!GROUP_ID_PATTERN.test(groupId)) {
|
|
177
|
+
throw new Error(`Invalid audio group id ${JSON.stringify(groupId)}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const priorGroups = captureAudioGroupState(previewIframe, elements, activeCompPath);
|
|
181
|
+
patchLiveAudioGroupState(previewIframe, elements, groupId, activeCompPath);
|
|
182
|
+
const createdLiveGroupElement = patchLiveGroupElement(previewIframe, groupId, groupLabel);
|
|
183
|
+
reseekPreviewRuntime(previewIframe);
|
|
184
|
+
|
|
185
|
+
const groupOperation: PatchOperation = {
|
|
186
|
+
type: "attribute",
|
|
187
|
+
property: HF_AUDIO_GROUP_ATTR,
|
|
188
|
+
value: groupId,
|
|
189
|
+
};
|
|
190
|
+
const originalByPath = new Map<string, string>();
|
|
191
|
+
const files: Record<string, string> = {};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
195
|
+
let patchedContent = await readFileContent(projectId, targetPath);
|
|
196
|
+
originalByPath.set(targetPath, patchedContent);
|
|
197
|
+
|
|
198
|
+
for (const element of fileElements) {
|
|
199
|
+
const patchTarget = buildPatchTarget(element);
|
|
200
|
+
if (!patchTarget) {
|
|
201
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
202
|
+
}
|
|
203
|
+
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
204
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
205
|
+
}
|
|
206
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, groupOperation);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
files[targetPath] = patchedContent;
|
|
210
|
+
pendingTimelineEditPathRef.current.add(targetPath);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const groupPath = activeCompPath || "index.html";
|
|
214
|
+
let groupContent = files[groupPath];
|
|
215
|
+
if (groupContent === undefined) {
|
|
216
|
+
groupContent = await readFileContent(projectId, groupPath);
|
|
217
|
+
originalByPath.set(groupPath, groupContent);
|
|
218
|
+
}
|
|
219
|
+
const withGroupElement = insertGroupElement(groupContent, groupId, groupLabel);
|
|
220
|
+
if (withGroupElement !== groupContent) {
|
|
221
|
+
files[groupPath] = withGroupElement;
|
|
222
|
+
pendingTimelineEditPathRef.current.add(groupPath);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
226
|
+
const changedPaths = await saveProjectFilesWithHistory({
|
|
227
|
+
projectId,
|
|
228
|
+
label: groupLabel
|
|
229
|
+
? `Group ${elements.length} clips as ${groupLabel}`
|
|
230
|
+
: `Group ${elements.length} voice clips`,
|
|
231
|
+
kind: "timeline",
|
|
232
|
+
files,
|
|
233
|
+
readFile: async (path) => {
|
|
234
|
+
const original = originalByPath.get(path);
|
|
235
|
+
if (original !== undefined) return original;
|
|
236
|
+
return readFileContent(projectId, path);
|
|
237
|
+
},
|
|
238
|
+
writeFile: writeProjectFile,
|
|
239
|
+
recordEdit,
|
|
240
|
+
});
|
|
241
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
242
|
+
for (const element of elements) {
|
|
243
|
+
usePlayerStore.getState().updateElement(element.key ?? element.id, { audioGroup: groupId });
|
|
244
|
+
}
|
|
245
|
+
return changedPaths;
|
|
246
|
+
} catch (error) {
|
|
247
|
+
// Mirrors setElementsHidden's failure path: the optimistic live patch
|
|
248
|
+
// already ran, so a save failure has to be unwound or the preview shows a
|
|
249
|
+
// grouping that never made it to disk.
|
|
250
|
+
patchLiveAudioGroupState(previewIframe, elements, null, activeCompPath, priorGroups);
|
|
251
|
+
if (createdLiveGroupElement) {
|
|
252
|
+
previewIframe?.contentDocument?.getElementById(groupId)?.remove();
|
|
253
|
+
}
|
|
254
|
+
reseekPreviewRuntime(previewIframe);
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The write behind B6's auto-group: pick two or more voice clips in the carve
|
|
261
|
+
* picker and they land in a group instead of naming each other by id. Same
|
|
262
|
+
* expanded-rows resolution as element-visibility, for the same reason — a
|
|
263
|
+
* nested sub-composition child has no entry in the raw store list.
|
|
264
|
+
*/
|
|
265
|
+
export function useAudioGroupCarveAssignment({
|
|
266
|
+
projectIdRef,
|
|
267
|
+
activeCompPath,
|
|
268
|
+
showToast,
|
|
269
|
+
writeProjectFile,
|
|
270
|
+
recordEdit,
|
|
271
|
+
domEditSaveTimestampRef,
|
|
272
|
+
previewIframeRef,
|
|
273
|
+
pendingTimelineEditPathRef,
|
|
274
|
+
isRecordingRef,
|
|
275
|
+
}: UseTimelineElementVisibilityEditingInput): (
|
|
276
|
+
clipIds: readonly string[],
|
|
277
|
+
groupId: string,
|
|
278
|
+
groupLabel?: string,
|
|
279
|
+
) => Promise<void> {
|
|
280
|
+
const expandedElements = useExpandedTimelineElements();
|
|
281
|
+
return useCallback(
|
|
282
|
+
async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
|
|
283
|
+
if (isRecordingRef?.current) {
|
|
284
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const pid = projectIdRef.current;
|
|
288
|
+
if (!pid) return;
|
|
289
|
+
// DOM ids, not store keys: both callers (the carve picker and the
|
|
290
|
+
// timeline's group-pointer button) name clips the way the document does,
|
|
291
|
+
// because that is the only space `resolveAudioGroups` reads back.
|
|
292
|
+
const wanted = new Set(clipIds);
|
|
293
|
+
const elements = expandedElements.filter((item) => {
|
|
294
|
+
const domId = runtimeAudioId(item);
|
|
295
|
+
return domId !== null && wanted.has(domId);
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
// Loud, not silent: an unresolved id used to leave `elements` short,
|
|
299
|
+
// `createAudioGroupAndAssignMembers` returning early with no write, and
|
|
300
|
+
// the carve still persisting `sources: [groupId]` for a group that was
|
|
301
|
+
// never created — a carve pointing at nothing, silently not ducking.
|
|
302
|
+
if (elements.length !== wanted.size) {
|
|
303
|
+
const missing = [...wanted].filter(
|
|
304
|
+
(id) => !elements.some((item) => runtimeAudioId(item) === id),
|
|
305
|
+
);
|
|
306
|
+
throw new Error(`Cannot group: no timeline clip for ${missing.join(", ")}`);
|
|
307
|
+
}
|
|
308
|
+
await createAudioGroupAndAssignMembers({
|
|
309
|
+
groupLabel,
|
|
310
|
+
projectId: pid,
|
|
311
|
+
activeCompPath,
|
|
312
|
+
elements,
|
|
313
|
+
groupId,
|
|
314
|
+
previewIframe: previewIframeRef.current,
|
|
315
|
+
writeProjectFile,
|
|
316
|
+
recordEdit,
|
|
317
|
+
domEditSaveTimestampRef,
|
|
318
|
+
pendingTimelineEditPathRef,
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error("[Timeline] Failed to group voice clips", error);
|
|
322
|
+
const message = error instanceof Error ? error.message : "Failed to group voice clips";
|
|
323
|
+
showToast(message);
|
|
324
|
+
// Rethrown, not just reported: the carve's auto-group chains
|
|
325
|
+
// `.then(() => ({ ...next, sources: [groupId] }))` off this promise, so
|
|
326
|
+
// swallowing here let it persist a carve pointing at a group that was
|
|
327
|
+
// never written — the exact silent no-op the throw inside
|
|
328
|
+
// `createAudioGroupAndAssignMembers` exists to prevent.
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
[
|
|
333
|
+
activeCompPath,
|
|
334
|
+
expandedElements,
|
|
335
|
+
previewIframeRef,
|
|
336
|
+
writeProjectFile,
|
|
337
|
+
recordEdit,
|
|
338
|
+
domEditSaveTimestampRef,
|
|
339
|
+
pendingTimelineEditPathRef,
|
|
340
|
+
isRecordingRef,
|
|
341
|
+
showToast,
|
|
342
|
+
projectIdRef,
|
|
343
|
+
],
|
|
344
|
+
);
|
|
345
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A group write has to reach the STORE, not just the file and the live DOM.
|
|
5
|
+
*
|
|
6
|
+
* The timeline derives a group row's label, fader, mute and chain from the
|
|
7
|
+
* `audioGroup*` fields mirrored onto its members — so a write that lands
|
|
8
|
+
* everywhere except there leaves the header rendering whatever it parsed at
|
|
9
|
+
* load. Observed in the studio: muting a group wrote `data-hidden` to disk and
|
|
10
|
+
* to the preview, and the button stayed "Mute group Voiceover", re-writing the
|
|
11
|
+
* same attribute on every click with no way to unmute.
|
|
12
|
+
*
|
|
13
|
+
* Invalidating the parse cache is necessary but not sufficient — it only makes
|
|
14
|
+
* the NEXT parse honest, and a live attribute patch never triggers one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
19
|
+
import { resolveGroupSourceFile, useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
usePlayerStore.getState().reset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function member(domId: string, track: number): TimelineElement {
|
|
26
|
+
return {
|
|
27
|
+
id: domId,
|
|
28
|
+
key: `index.html#${domId}`,
|
|
29
|
+
domId,
|
|
30
|
+
tag: "audio",
|
|
31
|
+
start: 0,
|
|
32
|
+
duration: 5,
|
|
33
|
+
track,
|
|
34
|
+
audioGroup: "voiceover",
|
|
35
|
+
audioGroupHidden: false,
|
|
36
|
+
audioGroupVolume: 1,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The hook without React — it only closes over refs and callbacks. */
|
|
41
|
+
function makeSetter() {
|
|
42
|
+
const input = {
|
|
43
|
+
projectIdRef: { current: "project-1" },
|
|
44
|
+
activeCompPath: "index.html",
|
|
45
|
+
showToast: () => {},
|
|
46
|
+
writeProjectFile: async () => {},
|
|
47
|
+
recordEdit: async () => {},
|
|
48
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
49
|
+
pendingTimelineEditPathRef: { current: new Set<string>() },
|
|
50
|
+
previewIframeRef: { current: null },
|
|
51
|
+
};
|
|
52
|
+
// `setLive` takes no async path and touches only the preview DOM + store, so
|
|
53
|
+
// it can be exercised directly; `setQuiet` additionally persists, which this
|
|
54
|
+
// test deliberately does not cover (that is timelineTrackVisibility's job).
|
|
55
|
+
let setter: ReturnType<typeof useSetAudioGroupAttribute> | null = null;
|
|
56
|
+
const Probe = () => {
|
|
57
|
+
setter = useSetAudioGroupAttribute(input as never);
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
// Minimal hook harness: call the component function directly. It uses only
|
|
61
|
+
// useCallback, which React allows outside a renderer when the result is used
|
|
62
|
+
// immediately and never re-rendered.
|
|
63
|
+
return { Probe, get: () => setter };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("group attribute writes reach the store", () => {
|
|
67
|
+
it("mirrors data-hidden onto every member so the header can flip", async () => {
|
|
68
|
+
const react = await import("react");
|
|
69
|
+
const { renderToStaticMarkup } = await import("react-dom/server");
|
|
70
|
+
const harness = makeSetter();
|
|
71
|
+
renderToStaticMarkup(react.createElement(harness.Probe));
|
|
72
|
+
const setter = harness.get();
|
|
73
|
+
expect(setter).not.toBeNull();
|
|
74
|
+
|
|
75
|
+
usePlayerStore.getState().setElements([member("voice-1", 0), member("voice-2", 1)]);
|
|
76
|
+
|
|
77
|
+
setter?.setLive("voiceover", "data-hidden", "");
|
|
78
|
+
expect(usePlayerStore.getState().elements.every((el) => el.audioGroupHidden === true)).toBe(
|
|
79
|
+
true,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
setter?.setLive("voiceover", "data-hidden", null);
|
|
83
|
+
expect(usePlayerStore.getState().elements.every((el) => el.audioGroupHidden === false)).toBe(
|
|
84
|
+
true,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// `Number(null)` and `Number("")` are both 0 AND finite, so the obvious
|
|
89
|
+
// isFinite check mirrored "silent" for a removed attribute while core's
|
|
90
|
+
// `readAudioGroupVolume` reads the same absence as unity — a parse divergence
|
|
91
|
+
// inside the mirror whose entire job is to prevent one.
|
|
92
|
+
it("reads a removed data-volume as unity, the way core does", async () => {
|
|
93
|
+
const react = await import("react");
|
|
94
|
+
const { renderToStaticMarkup } = await import("react-dom/server");
|
|
95
|
+
const harness = makeSetter();
|
|
96
|
+
renderToStaticMarkup(react.createElement(harness.Probe));
|
|
97
|
+
const setter = harness.get();
|
|
98
|
+
|
|
99
|
+
usePlayerStore.getState().setElements([member("voice-1", 0)]);
|
|
100
|
+
|
|
101
|
+
setter?.setLive("voiceover", "data-volume", "0.3");
|
|
102
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBeCloseTo(0.3, 6);
|
|
103
|
+
|
|
104
|
+
setter?.setLive("voiceover", "data-volume", null);
|
|
105
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
|
|
106
|
+
|
|
107
|
+
setter?.setLive("voiceover", "data-volume", "");
|
|
108
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
|
|
109
|
+
|
|
110
|
+
setter?.setLive("voiceover", "data-volume", "nonsense");
|
|
111
|
+
expect(usePlayerStore.getState().elements[0]?.audioGroupVolume).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("mirrors data-volume, and leaves other groups alone", async () => {
|
|
115
|
+
const react = await import("react");
|
|
116
|
+
const { renderToStaticMarkup } = await import("react-dom/server");
|
|
117
|
+
const harness = makeSetter();
|
|
118
|
+
renderToStaticMarkup(react.createElement(harness.Probe));
|
|
119
|
+
const setter = harness.get();
|
|
120
|
+
|
|
121
|
+
const other: TimelineElement = { ...member("sfx", 2), audioGroup: "effects" };
|
|
122
|
+
usePlayerStore.getState().setElements([member("voice-1", 0), other]);
|
|
123
|
+
|
|
124
|
+
setter?.setLive("voiceover", "data-volume", "0.4");
|
|
125
|
+
|
|
126
|
+
const byId = new Map(usePlayerStore.getState().elements.map((el) => [el.id, el]));
|
|
127
|
+
expect(byId.get("voice-1")?.audioGroupVolume).toBeCloseTo(0.4, 6);
|
|
128
|
+
expect(byId.get("sfx")?.audioGroupVolume).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The ancestor shape here is COPIED FROM A LIVE PREVIEW, not imagined.
|
|
134
|
+
*
|
|
135
|
+
* The first attempt at this fix used `getTimelineElementSourceFile` and a
|
|
136
|
+
* fixture in which the sub-composition root carried `data-composition-file`.
|
|
137
|
+
* The test passed; the studio still threw "Unable to patch element in
|
|
138
|
+
* index.html", because the runtime inlines a sub-comp as its own root element
|
|
139
|
+
* that carries only the composition ID — the FILE is on the host above it.
|
|
140
|
+
*/
|
|
141
|
+
describe("the mirror reaches sub-composition members", () => {
|
|
142
|
+
/**
|
|
143
|
+
* A group declared inside a sub-composition has no FLAT member to mirror onto
|
|
144
|
+
* — `childGroupState` keeps those members out of `elements` — so mirroring
|
|
145
|
+
* only `elements` made this whole function a no-op for it: the header kept the
|
|
146
|
+
* pre-write chain and `laneCount` stayed 0, so the lane disclosure never
|
|
147
|
+
* appeared for automation that now existed.
|
|
148
|
+
*/
|
|
149
|
+
it("mirrors a group write onto DomClipChild members as well as flat ones", async () => {
|
|
150
|
+
const react = await import("react");
|
|
151
|
+
const { renderToStaticMarkup } = await import("react-dom/server");
|
|
152
|
+
const harness = makeSetter();
|
|
153
|
+
renderToStaticMarkup(react.createElement(harness.Probe));
|
|
154
|
+
const setter = harness.get();
|
|
155
|
+
usePlayerStore.getState().setElements([member("flat-1", 0)]);
|
|
156
|
+
usePlayerStore.getState().setDomClipChildren([
|
|
157
|
+
{ id: "sub-1", parentId: "host", hostId: "host", label: "Sub 1", audioGroup: "voiceover" },
|
|
158
|
+
{ id: "other", parentId: "host", hostId: "host", label: "Other", audioGroup: "sfx" },
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
setter?.setLive("voiceover", "data-label", "Voices");
|
|
162
|
+
|
|
163
|
+
const children = usePlayerStore.getState().domClipChildren;
|
|
164
|
+
expect(children.find((c) => c.id === "sub-1")?.audioGroupLabel).toBe("Voices");
|
|
165
|
+
// A member of another group is untouched.
|
|
166
|
+
expect(children.find((c) => c.id === "other")?.audioGroupLabel).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("resolveGroupSourceFile", () => {
|
|
171
|
+
function livePreviewShape(): Document {
|
|
172
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
173
|
+
doc.body.setAttribute("data-composition-id", "subcomp-group-qa");
|
|
174
|
+
doc.body.innerHTML = `
|
|
175
|
+
<div id="voices-host" data-composition-id="voices-host" data-composition-file="compositions/voices.html">
|
|
176
|
+
<section id="voices-root" data-composition-id="voices">
|
|
177
|
+
<hf-audio-group id="voiceover"></hf-audio-group>
|
|
178
|
+
</section>
|
|
179
|
+
</div>
|
|
180
|
+
<hf-audio-group id="root-group"></hf-audio-group>
|
|
181
|
+
`;
|
|
182
|
+
return doc;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
it("climbs past the sub-comp root to the host that names the file", () => {
|
|
186
|
+
const doc = livePreviewShape();
|
|
187
|
+
expect(resolveGroupSourceFile(doc.getElementById("voiceover"))).toBe(
|
|
188
|
+
"compositions/voices.html",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// A group in the root composition: body names an id but no file, so the
|
|
193
|
+
// caller falls back to activeCompPath. Returning body's id here would route
|
|
194
|
+
// every root-composition group write at a path that does not exist.
|
|
195
|
+
it("returns undefined for a group in the root composition", () => {
|
|
196
|
+
const doc = livePreviewShape();
|
|
197
|
+
expect(resolveGroupSourceFile(doc.getElementById("root-group"))).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("is safe on a detached or missing element", () => {
|
|
201
|
+
expect(resolveGroupSourceFile(null)).toBeUndefined();
|
|
202
|
+
expect(resolveGroupSourceFile(document.createElement("hf-audio-group"))).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
});
|