@hyperframes/studio 0.8.10 → 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-DoTrWV8q.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-DV45YLqR.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-BQs1fahB.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 +6440 -5514
- 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 +184 -7
- package/src/player/components/TimelineFxButton.tsx +171 -23
- 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-C6m2nHiX.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
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
+
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
4
|
+
import { usePlayerStore } from "../player";
|
|
5
|
+
import type { TimelineElementPatch } from "../player/store/timelineElement";
|
|
6
|
+
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
|
|
2
7
|
import {
|
|
3
8
|
buildPatchTarget,
|
|
4
9
|
persistElementAttribute,
|
|
@@ -21,6 +26,111 @@ function patchLiveGroupAttribute(
|
|
|
21
26
|
if (!target) return;
|
|
22
27
|
if (value === null) target.removeAttribute(attr);
|
|
23
28
|
else target.setAttribute(attr, value);
|
|
29
|
+
invalidateGroupInfoCache(iframe?.contentDocument);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
|
|
34
|
+
* empty attribute is UNITY, not zero.
|
|
35
|
+
*
|
|
36
|
+
* `Number(null)` and `Number("")` are both 0 and both finite, so the obvious
|
|
37
|
+
* `Number.isFinite(Number(value))` mirrored "silent" into the store for a
|
|
38
|
+
* removed attribute while the DOM, the preview bus and the render all read 1 —
|
|
39
|
+
* exactly the parse divergence this mirror exists to eliminate.
|
|
40
|
+
*/
|
|
41
|
+
function mirroredGroupVolume(value: string | null): number {
|
|
42
|
+
if (!value) return 1;
|
|
43
|
+
const parsed = parseFloat(value);
|
|
44
|
+
return Number.isFinite(parsed) ? parsed : 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Which store field each writable group attribute mirrors into. */
|
|
48
|
+
const GROUP_ATTR_TO_MIRROR: Record<
|
|
49
|
+
string,
|
|
50
|
+
(value: string | null, groupId: string) => TimelineElementPatch
|
|
51
|
+
> = {
|
|
52
|
+
"data-hidden": (value) => ({ audioGroupHidden: value !== null }),
|
|
53
|
+
"data-volume": (value) => ({ audioGroupVolume: mirroredGroupVolume(value) }),
|
|
54
|
+
"data-label": (value, groupId) => ({ audioGroupLabel: value ?? groupId }),
|
|
55
|
+
[HF_AUDIO_FX_ATTR]: (value) => ({ audioGroupFxChain: value ?? undefined }),
|
|
56
|
+
[HF_AUDIO_AUTOMATION_ATTR]: (value) => ({ audioGroupAutomation: value ?? undefined }),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mirror a group attribute onto the store copy every member carries.
|
|
61
|
+
*
|
|
62
|
+
* The timeline derives a group's label / volume / mute / chain from these
|
|
63
|
+
* mirrored `audioGroup*` fields on its MEMBERS, not from the group element —
|
|
64
|
+
* and a group write only ever touched the file and the live preview DOM.
|
|
65
|
+
* Nothing re-parsed, so the header went on reading the old value: the observed
|
|
66
|
+
* symptom was a muted group whose button stayed "Mute group", re-writing
|
|
67
|
+
* `data-hidden` on every click and never offering to unmute.
|
|
68
|
+
*
|
|
69
|
+
* Invalidating the parse cache is necessary but not sufficient — it only
|
|
70
|
+
* ensures the NEXT parse is honest, and a live attribute patch does not cause
|
|
71
|
+
* one. Same reason `commitDataAttribute` carries `syncStoredAutomationFromPreview`.
|
|
72
|
+
*/
|
|
73
|
+
function syncStoredGroupAttribute(groupId: string, attr: string, value: string | null): void {
|
|
74
|
+
const toPatch = GROUP_ATTR_TO_MIRROR[attr];
|
|
75
|
+
if (!toPatch) return;
|
|
76
|
+
const patch = toPatch(value, groupId);
|
|
77
|
+
// ONE pass and one notification, rather than `updateElement` per member.
|
|
78
|
+
// That helper maps the whole `elements` array per call, so a 3-member group on
|
|
79
|
+
// a 500-clip composition was 1500 object spreads and 3 store notifications per
|
|
80
|
+
// drag frame — at ~60/s, with every `elements`-keyed memo downstream
|
|
81
|
+
// recomputing each time.
|
|
82
|
+
// BOTH stores, because a group declared inside a sub-composition has no flat
|
|
83
|
+
// member to mirror onto: `childGroupState` keeps those members out of
|
|
84
|
+
// `elements` entirely, so their `audioGroup*` fields come from the
|
|
85
|
+
// `DomClipChild` record instead. Mirroring only `elements` made this whole
|
|
86
|
+
// function a no-op for such a group — the header kept the pre-write chain, its
|
|
87
|
+
// FX button showed the old count, and `laneCount` stayed 0 so the lane
|
|
88
|
+
// disclosure never appeared for automation that now existed. Verbatim the
|
|
89
|
+
// symptom this docblock claims to have fixed, fixed only for flat members.
|
|
90
|
+
//
|
|
91
|
+
// `setDomClipChildren` has one other writer, inside `processTimelineMessage`,
|
|
92
|
+
// which a live attribute patch does not trigger.
|
|
93
|
+
usePlayerStore.setState((state) => {
|
|
94
|
+
const next: Partial<typeof state> = {
|
|
95
|
+
elements: state.elements.map((el) => (el.audioGroup === groupId ? { ...el, ...patch } : el)),
|
|
96
|
+
};
|
|
97
|
+
if (state.domClipChildren.some((child) => child.audioGroup === groupId)) {
|
|
98
|
+
next.domClipChildren = state.domClipChildren.map((child) =>
|
|
99
|
+
child.audioGroup === groupId ? { ...child, ...patch } : child,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return next;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The composition FILE that contains a group element, walking up through
|
|
108
|
+
* composition ancestors until one names a file.
|
|
109
|
+
*
|
|
110
|
+
* `getTimelineElementSourceFile` stops at the nearest `[data-composition-id]`,
|
|
111
|
+
* which for an inlined sub-composition is its own ROOT element — that carries
|
|
112
|
+
* the composition id but not the file. The file is on the HOST one level above
|
|
113
|
+
* it. Measured on a live preview:
|
|
114
|
+
*
|
|
115
|
+
* hf-audio-group#voiceover (no composition attrs)
|
|
116
|
+
* section#voices-root data-composition-id="voices" <- stops here
|
|
117
|
+
* div#voices-host data-composition-file="…/voices.html" <- file is here
|
|
118
|
+
* body data-composition-id="<root>"
|
|
119
|
+
*
|
|
120
|
+
* Returns undefined for a group in the root composition (body names an id but
|
|
121
|
+
* no file), which is exactly when the caller should fall back to activeCompPath.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveGroupSourceFile(groupEl: Element | null): string | undefined {
|
|
124
|
+
let node: Element | null = groupEl?.parentElement ?? null;
|
|
125
|
+
while (node) {
|
|
126
|
+
const owner: Element | null = node.closest("[data-composition-id]");
|
|
127
|
+
if (!owner) return undefined;
|
|
128
|
+
const file =
|
|
129
|
+
owner.getAttribute("data-composition-file") ?? owner.getAttribute("data-composition-src");
|
|
130
|
+
if (file) return file;
|
|
131
|
+
node = owner.parentElement;
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
24
134
|
}
|
|
25
135
|
|
|
26
136
|
interface SetAudioGroupAttributeInput {
|
|
@@ -56,7 +166,16 @@ async function setAudioGroupAttribute({
|
|
|
56
166
|
domEditSaveTimestampRef,
|
|
57
167
|
pendingTimelineEditPathRef,
|
|
58
168
|
}: SetAudioGroupAttributeInput): Promise<string[]> {
|
|
59
|
-
|
|
169
|
+
// The file that actually CONTAINS the group element, not just the active
|
|
170
|
+
// composition. A hand-authored sub-composition can declare both the members
|
|
171
|
+
// and their `<hf-audio-group>`, and until sub-comp children inherited
|
|
172
|
+
// `audioGroup*` no group row existed for that case so nothing could reach
|
|
173
|
+
// here. Now the row appears, and routing its writes at `activeCompPath`
|
|
174
|
+
// means `readTagSnippetByTarget` finds nothing and every mute, fader move and
|
|
175
|
+
// FX preset throws "Unable to patch element in index.html". Every sibling
|
|
176
|
+
// timeline writer already routes `element.sourceFile || activeCompPath`.
|
|
177
|
+
const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
|
|
178
|
+
const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
|
|
60
179
|
const patchTarget = buildPatchTarget({ domId: groupId });
|
|
61
180
|
if (!patchTarget) return [];
|
|
62
181
|
|
|
@@ -72,8 +191,6 @@ async function setAudioGroupAttribute({
|
|
|
72
191
|
domEditSaveTimestampRef,
|
|
73
192
|
pendingTimelineEditPathRef,
|
|
74
193
|
patchLive: (v) => patchLiveGroupAttribute(previewIframe, groupId, attr, v),
|
|
75
|
-
readLive: () =>
|
|
76
|
-
previewIframe?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ?? null,
|
|
77
194
|
});
|
|
78
195
|
}
|
|
79
196
|
|
|
@@ -101,6 +218,10 @@ export function useSetAudioGroupAttribute({
|
|
|
101
218
|
const setLive = useCallback(
|
|
102
219
|
(groupId: string, attr: string, value: string | null) => {
|
|
103
220
|
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
|
221
|
+
// Live too, not just on commit: a fader drag is `setLive` per frame and
|
|
222
|
+
// `setQuiet` once on release, so without this the strip's own readout
|
|
223
|
+
// fights the drag.
|
|
224
|
+
syncStoredGroupAttribute(groupId, attr, value);
|
|
104
225
|
},
|
|
105
226
|
[previewIframeRef],
|
|
106
227
|
);
|
|
@@ -126,7 +247,18 @@ export function useSetAudioGroupAttribute({
|
|
|
126
247
|
domEditSaveTimestampRef,
|
|
127
248
|
pendingTimelineEditPathRef,
|
|
128
249
|
});
|
|
250
|
+
syncStoredGroupAttribute(groupId, attr, value);
|
|
129
251
|
} catch (error) {
|
|
252
|
+
// `persistElementAttribute` leaves the live DOM at the previous value
|
|
253
|
+
// however it failed — it unwinds a failed save, and an unresolvable
|
|
254
|
+
// target now throws before patching at all. But `setLive` mirrored the
|
|
255
|
+
// in-progress value into the store on every drag frame — so without
|
|
256
|
+
// this the fader reads 0.4 while
|
|
257
|
+
// the preview and the file are both back at 1.0, and nothing re-parses
|
|
258
|
+
// to correct it (a live patch causing no parse is this mirror's whole
|
|
259
|
+
// premise). Re-mirror from the DOM, which is now authoritative again.
|
|
260
|
+
const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
|
261
|
+
syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
|
|
130
262
|
console.error("[Timeline] Failed to set group attribute", error);
|
|
131
263
|
const message = error instanceof Error ? error.message : "Failed to update group";
|
|
132
264
|
showToast(message);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
deleteSelectedKeyframes,
|
|
7
7
|
extendRootDurationIfNeeded,
|
|
8
8
|
patchIframeDomTiming,
|
|
9
|
+
persistElementAttribute,
|
|
9
10
|
persistTimelineBatchEdit,
|
|
10
11
|
type PersistTimelineBatchChange,
|
|
11
12
|
} from "./timelineEditingHelpers";
|
|
@@ -423,3 +424,88 @@ describe("deleteSelectedKeyframes", () => {
|
|
|
423
424
|
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
|
|
424
425
|
});
|
|
425
426
|
});
|
|
427
|
+
|
|
428
|
+
describe("persistElementAttribute", () => {
|
|
429
|
+
/**
|
|
430
|
+
* The optimistic live patch used to run BEFORE the target was resolved, and
|
|
431
|
+
* only the save was wrapped in the unwind. So an unresolvable target threw
|
|
432
|
+
* with the preview holding a value that never reached disk — and the group
|
|
433
|
+
* writer's catch mirrors the live DOM into the store, so the UI reported the
|
|
434
|
+
* write as applied until a reload dropped it.
|
|
435
|
+
*/
|
|
436
|
+
it("does not patch the live DOM when the target resolves to nothing", async () => {
|
|
437
|
+
const fetchSpy = vi
|
|
438
|
+
.spyOn(globalThis, "fetch")
|
|
439
|
+
.mockResolvedValue(
|
|
440
|
+
new Response(JSON.stringify({ content: '<body><audio id="other"></audio></body>' })),
|
|
441
|
+
);
|
|
442
|
+
const patchLive = vi.fn();
|
|
443
|
+
const writeProjectFile = vi.fn();
|
|
444
|
+
|
|
445
|
+
await expect(
|
|
446
|
+
persistElementAttribute({
|
|
447
|
+
projectId: "p",
|
|
448
|
+
targetPath: "index.html",
|
|
449
|
+
patchTarget: { id: "missing" },
|
|
450
|
+
attr: "data-volume",
|
|
451
|
+
value: "0.4",
|
|
452
|
+
label: "Set volume",
|
|
453
|
+
writeProjectFile,
|
|
454
|
+
recordEdit: vi.fn(),
|
|
455
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
456
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
457
|
+
patchLive,
|
|
458
|
+
}),
|
|
459
|
+
).rejects.toThrow("Unable to patch element in index.html");
|
|
460
|
+
|
|
461
|
+
expect(patchLive).not.toHaveBeenCalled();
|
|
462
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
463
|
+
fetchSpy.mockRestore();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("persistElementAttribute — unwind value", () => {
|
|
468
|
+
/**
|
|
469
|
+
* The unwind has to restore the value on DISK, not the one in the preview.
|
|
470
|
+
*
|
|
471
|
+
* Every live-write caller patches the DOM before committing (a fader drag is
|
|
472
|
+
* `setLive` per frame; hovering a preset auditions the whole chain), so by
|
|
473
|
+
* commit time the live DOM already holds the in-progress value. Reading it as
|
|
474
|
+
* `previousValue` made the unwind a no-op, and the group writer's catch —
|
|
475
|
+
* which deliberately re-mirrors the store off the live DOM — then mirrored the
|
|
476
|
+
* never-saved value: the panel agreed with the preview, and a reload dropped it.
|
|
477
|
+
*/
|
|
478
|
+
it("restores the file's value, not the audition already in the live DOM", async () => {
|
|
479
|
+
const fetchSpy = vi
|
|
480
|
+
.spyOn(globalThis, "fetch")
|
|
481
|
+
.mockResolvedValue(
|
|
482
|
+
new Response(
|
|
483
|
+
JSON.stringify({ content: `<body><audio id="bgm" data-volume="0.25"></audio></body>` }),
|
|
484
|
+
),
|
|
485
|
+
);
|
|
486
|
+
const patched: Array<string | null> = [];
|
|
487
|
+
const writeProjectFile = vi.fn(() => Promise.reject(new Error("save failed")));
|
|
488
|
+
|
|
489
|
+
await expect(
|
|
490
|
+
persistElementAttribute({
|
|
491
|
+
projectId: "p",
|
|
492
|
+
targetPath: "index.html",
|
|
493
|
+
patchTarget: { id: "bgm" },
|
|
494
|
+
attr: "data-volume",
|
|
495
|
+
value: "0.9",
|
|
496
|
+
label: "Set volume",
|
|
497
|
+
writeProjectFile,
|
|
498
|
+
recordEdit: vi.fn(),
|
|
499
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
500
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
501
|
+
// The live DOM is ALREADY at the new value when the commit runs — that
|
|
502
|
+
// is what `setLive` does on every drag frame.
|
|
503
|
+
patchLive: (v) => patched.push(v),
|
|
504
|
+
}),
|
|
505
|
+
).rejects.toThrow("save failed");
|
|
506
|
+
|
|
507
|
+
// First the optimistic write, then the unwind — back to what the FILE said.
|
|
508
|
+
expect(patched).toEqual(["0.9", "0.25"]);
|
|
509
|
+
fetchSpy.mockRestore();
|
|
510
|
+
});
|
|
511
|
+
});
|
|
@@ -410,8 +410,6 @@ export interface PersistElementAttributeInput {
|
|
|
410
410
|
pendingTimelineEditPathRef: { current: Set<string> };
|
|
411
411
|
/** Write the attribute directly on the live preview DOM node. */
|
|
412
412
|
patchLive: (value: string | null) => void;
|
|
413
|
-
/** Read the attribute's current value off the live preview DOM node. */
|
|
414
|
-
readLive: () => string | null;
|
|
415
413
|
}
|
|
416
414
|
|
|
417
415
|
/**
|
|
@@ -420,7 +418,7 @@ export interface PersistElementAttributeInput {
|
|
|
420
418
|
* `setAudioGroupAttribute` (a group id addressed by its own DOM id) and
|
|
421
419
|
* `useSetElementAttribute` (an arbitrary timeline clip) — same shape, only
|
|
422
420
|
* how the live node is found and where the patch target resolves to differs,
|
|
423
|
-
* which is exactly what `patchLive`/`
|
|
421
|
+
* which is exactly what `patchLive`/`patchTarget` parameterize.
|
|
424
422
|
*/
|
|
425
423
|
export async function persistElementAttribute({
|
|
426
424
|
projectId,
|
|
@@ -434,15 +432,29 @@ export async function persistElementAttribute({
|
|
|
434
432
|
domEditSaveTimestampRef,
|
|
435
433
|
pendingTimelineEditPathRef,
|
|
436
434
|
patchLive,
|
|
437
|
-
readLive,
|
|
438
435
|
}: PersistElementAttributeInput): Promise<string[]> {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
436
|
+
// Resolve the target BEFORE patching the live DOM. The optimistic patch used
|
|
437
|
+
// to run first, and only the save was wrapped in the unwind — so an
|
|
438
|
+
// unresolvable target threw with the live preview (and, through the callers'
|
|
439
|
+
// catch, the store mirrored off it) holding a value that never reached disk.
|
|
440
|
+
// The write then read as successful until a reload dropped it.
|
|
442
441
|
const before = await readFileContent(projectId, targetPath);
|
|
443
442
|
if (readTagSnippetByTarget(before, patchTarget) === undefined) {
|
|
444
443
|
throw new Error(`Unable to patch element in ${targetPath}`);
|
|
445
444
|
}
|
|
445
|
+
// The unwind value comes from the FILE, not from `readLive()`.
|
|
446
|
+
//
|
|
447
|
+
// Every live-write caller patches the DOM before committing — a fader drag is
|
|
448
|
+
// `setLive` per frame, hovering a preset auditions the whole chain — so by the
|
|
449
|
+
// time this runs the live DOM already holds the in-progress value. Reading it
|
|
450
|
+
// here made `previousValue === value`, so the unwind below was a no-op, and
|
|
451
|
+
// `setQuiet`'s catch (which deliberately re-mirrors the store from the live
|
|
452
|
+
// DOM) then mirrored that same never-saved value. The group audibly had the
|
|
453
|
+
// preset, the panel agreed, and a reload dropped it — the failure class the
|
|
454
|
+
// target check above was added to close, still open on the live-write path.
|
|
455
|
+
const previousValue = readAttributeByTarget(before, patchTarget, attr) ?? null;
|
|
456
|
+
patchLive(value);
|
|
457
|
+
|
|
446
458
|
const operation: PatchOperation = { type: "attribute", property: attr, value };
|
|
447
459
|
const patched = applyPatchByTarget(before, patchTarget, operation);
|
|
448
460
|
|
|
@@ -75,9 +75,6 @@ async function setElementAttribute({
|
|
|
75
75
|
domEditSaveTimestampRef,
|
|
76
76
|
pendingTimelineEditPathRef,
|
|
77
77
|
patchLive: (v) => patchLiveElementAttribute(previewIframe, element, attr, v, activeCompPath),
|
|
78
|
-
readLive: () =>
|
|
79
|
-
findTimelineElementInIframe(previewIframe, element, activeCompPath)?.getAttribute(attr) ??
|
|
80
|
-
null,
|
|
81
78
|
});
|
|
82
79
|
}
|
|
83
80
|
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "./timelineTrackVisibility";
|
|
5
|
+
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
6
|
+
import { readTagSnippetByTarget } from "../utils/sourcePatcher";
|
|
7
|
+
import { createAudioGroupAndAssignMembers } from "./timelineAudioGroupCreate";
|
|
8
|
+
import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
|
|
10
9
|
|
|
11
10
|
afterEach(() => {
|
|
12
11
|
document.body.innerHTML = "";
|
|
@@ -513,23 +512,106 @@ describe("createAudioGroupAndAssignMembers", () => {
|
|
|
513
512
|
).toBe("voiceover");
|
|
514
513
|
});
|
|
515
514
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
515
|
+
// The group element is what every LATER group write addresses — mute, the bus
|
|
516
|
+
// fader's data-volume, an FX preset all go through
|
|
517
|
+
// `buildPatchTarget({ domId: groupId })`. Membership alone parses, but leaves
|
|
518
|
+
// a group nothing can edit.
|
|
519
|
+
it("emits the group's own <hf-audio-group> element, patchable by its DOM id", async () => {
|
|
520
|
+
const iframe = document.createElement("iframe");
|
|
521
|
+
document.body.append(iframe);
|
|
522
|
+
if (iframe.contentDocument) {
|
|
523
|
+
iframe.contentDocument.body.innerHTML = `
|
|
524
|
+
<audio id="narration"></audio>
|
|
525
|
+
<audio id="interview-guest"></audio>
|
|
526
|
+
`;
|
|
527
|
+
}
|
|
528
|
+
const files = new Map([
|
|
529
|
+
[
|
|
530
|
+
"index.html",
|
|
531
|
+
`<html><body>
|
|
532
|
+
<audio id="narration" data-start="0" data-duration="5"></audio>
|
|
533
|
+
<audio id="interview-guest" data-start="10" data-duration="5"></audio>
|
|
534
|
+
</body></html>`,
|
|
535
|
+
],
|
|
536
|
+
]);
|
|
537
|
+
stubProjectFiles(files);
|
|
538
|
+
|
|
539
|
+
const narration = element({ id: "narration", domId: "narration", track: 0 });
|
|
540
|
+
const guest = element({ id: "interview-guest", domId: "interview-guest", track: 1 });
|
|
541
|
+
usePlayerStore.getState().setElements([narration, guest]);
|
|
542
|
+
|
|
543
|
+
const writes = new Map<string, string>();
|
|
544
|
+
await createAudioGroupAndAssignMembers({
|
|
519
545
|
projectId: "project-1",
|
|
520
546
|
activeCompPath: "index.html",
|
|
521
|
-
elements: [
|
|
547
|
+
elements: [narration, guest],
|
|
522
548
|
groupId: "voiceover",
|
|
523
|
-
previewIframe:
|
|
524
|
-
writeProjectFile: async () => {
|
|
525
|
-
|
|
549
|
+
previewIframe: iframe,
|
|
550
|
+
writeProjectFile: async (path, content) => {
|
|
551
|
+
writes.set(path, content);
|
|
552
|
+
},
|
|
553
|
+
recordEdit: vi.fn(),
|
|
526
554
|
domEditSaveTimestampRef: { current: 0 },
|
|
527
555
|
pendingTimelineEditPathRef: { current: new Set() },
|
|
528
556
|
});
|
|
529
|
-
|
|
557
|
+
|
|
558
|
+
const written = writes.get("index.html") ?? "";
|
|
559
|
+
expect(written).toContain('<hf-audio-group id="voiceover"></hf-audio-group>');
|
|
560
|
+
// The actual contract: the group-attribute writer can now find a target.
|
|
561
|
+
// This is the read that threw "Unable to patch element in index.html".
|
|
562
|
+
expect(readTagSnippetByTarget(written, { id: "voiceover" })).toBeDefined();
|
|
563
|
+
// ...and in the live preview, which is what patchLiveGroupAttribute reads
|
|
564
|
+
// before the next reload.
|
|
565
|
+
expect(iframe.contentDocument?.getElementById("voiceover")?.tagName.toLowerCase()).toBe(
|
|
566
|
+
"hf-audio-group",
|
|
567
|
+
);
|
|
568
|
+
// Both members still resolve into it.
|
|
569
|
+
expect(resolveAudioGroups(iframe.contentDocument as Document)[0]).toMatchObject({
|
|
570
|
+
id: "voiceover",
|
|
571
|
+
memberIds: ["narration", "interview-guest"],
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Rejects rather than resolving empty: the carve's auto-group persists
|
|
576
|
+
// `sources: [groupId]` once this resolves, so a silent no-op leaves the carve
|
|
577
|
+
// pointing at a group that was never written — and stops ducking.
|
|
578
|
+
it("rejects for fewer than two elements — grouping is a plural concept", async () => {
|
|
579
|
+
const recordEdit = vi.fn();
|
|
580
|
+
await expect(
|
|
581
|
+
createAudioGroupAndAssignMembers({
|
|
582
|
+
projectId: "project-1",
|
|
583
|
+
activeCompPath: "index.html",
|
|
584
|
+
elements: [element({ id: "narration", domId: "narration" })],
|
|
585
|
+
groupId: "voiceover",
|
|
586
|
+
previewIframe: null,
|
|
587
|
+
writeProjectFile: async () => {},
|
|
588
|
+
recordEdit,
|
|
589
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
590
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
591
|
+
}),
|
|
592
|
+
).rejects.toThrow("a group needs at least two");
|
|
530
593
|
expect(recordEdit).not.toHaveBeenCalled();
|
|
531
594
|
});
|
|
532
595
|
|
|
596
|
+
it("rejects a group id that is not safe to interpolate into markup or a path", async () => {
|
|
597
|
+
await expect(
|
|
598
|
+
createAudioGroupAndAssignMembers({
|
|
599
|
+
projectId: "project-1",
|
|
600
|
+
activeCompPath: "index.html",
|
|
601
|
+
elements: [
|
|
602
|
+
element({ id: "narration", domId: "narration" }),
|
|
603
|
+
element({ id: "guest", domId: "guest" }),
|
|
604
|
+
],
|
|
605
|
+
groupId: '../x"><script>',
|
|
606
|
+
previewIframe: null,
|
|
607
|
+
writeProjectFile: async () => {},
|
|
608
|
+
recordEdit: vi.fn(),
|
|
609
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
610
|
+
pendingTimelineEditPathRef: { current: new Set() },
|
|
611
|
+
}),
|
|
612
|
+
).rejects.toThrow("Invalid audio group id");
|
|
613
|
+
});
|
|
614
|
+
|
|
533
615
|
it("reverts the optimistic live patch when the save fails", async () => {
|
|
534
616
|
const iframe = document.createElement("iframe");
|
|
535
617
|
document.body.append(iframe);
|