@hyperframes/studio 0.8.6 → 0.8.8
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-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
package/src/captions/store.ts
CHANGED
|
@@ -9,17 +9,36 @@ import {
|
|
|
9
9
|
|
|
10
10
|
let nextSplitId = 0;
|
|
11
11
|
|
|
12
|
+
const HISTORY_CAP = 50;
|
|
13
|
+
/** Coalesce rapid same-target edits (typing, nudging) into one history entry. */
|
|
14
|
+
const HISTORY_COALESCE_MS = 800;
|
|
15
|
+
|
|
12
16
|
interface CaptionState {
|
|
13
17
|
isEditMode: boolean;
|
|
18
|
+
/** User explicitly exited caption editing — suppresses auto re-activation. */
|
|
19
|
+
dismissed: boolean;
|
|
14
20
|
model: CaptionModel | null;
|
|
15
21
|
selectedSegmentIds: Set<string>;
|
|
16
22
|
selectedGroupId: string | null;
|
|
17
23
|
sourceFilePath: string | null;
|
|
24
|
+
/** Load/save failure surfaced to the user (null = healthy). */
|
|
25
|
+
syncError: string | null;
|
|
26
|
+
/** Registered by useCaptionSync so error banners can retry the save. */
|
|
27
|
+
retrySave: (() => void) | null;
|
|
28
|
+
|
|
29
|
+
// Undo/redo (in-memory model snapshots)
|
|
30
|
+
past: CaptionModel[];
|
|
31
|
+
future: CaptionModel[];
|
|
32
|
+
undo: () => CaptionModel | null;
|
|
33
|
+
redo: () => CaptionModel | null;
|
|
18
34
|
|
|
19
35
|
// Basic
|
|
20
36
|
setEditMode: (active: boolean) => void;
|
|
37
|
+
setDismissed: (dismissed: boolean) => void;
|
|
21
38
|
setModel: (model: CaptionModel | null) => void;
|
|
22
39
|
setSourceFilePath: (path: string | null) => void;
|
|
40
|
+
setSyncError: (error: string | null) => void;
|
|
41
|
+
setRetrySave: (fn: (() => void) | null) => void;
|
|
23
42
|
|
|
24
43
|
// Selection
|
|
25
44
|
selectSegment: (id: string, additive?: boolean) => void;
|
|
@@ -53,19 +72,70 @@ interface CaptionState {
|
|
|
53
72
|
|
|
54
73
|
const initialState = {
|
|
55
74
|
isEditMode: false,
|
|
75
|
+
dismissed: false,
|
|
56
76
|
model: null,
|
|
57
77
|
selectedSegmentIds: new Set<string>(),
|
|
58
78
|
selectedGroupId: null,
|
|
59
79
|
sourceFilePath: null,
|
|
80
|
+
syncError: null,
|
|
81
|
+
retrySave: null,
|
|
82
|
+
past: [] as CaptionModel[],
|
|
83
|
+
future: [] as CaptionModel[],
|
|
60
84
|
};
|
|
61
85
|
|
|
86
|
+
// Coalescing bookkeeping lives outside the store — it is not renderable state.
|
|
87
|
+
let lastHistoryKey: string | null = null;
|
|
88
|
+
let lastHistoryAt = 0;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Snapshot the current model onto the undo stack before a mutation.
|
|
92
|
+
* `key` identifies the edit target so rapid repeats (typing a value, arrow-key
|
|
93
|
+
* nudging) coalesce into a single entry instead of one per keystroke.
|
|
94
|
+
*/
|
|
95
|
+
function pushHistory(
|
|
96
|
+
state: Pick<CaptionState, "model" | "past">,
|
|
97
|
+
key: string,
|
|
98
|
+
): Partial<Pick<CaptionState, "past" | "future">> {
|
|
99
|
+
if (!state.model) return {};
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
if (lastHistoryKey === key && now - lastHistoryAt < HISTORY_COALESCE_MS) {
|
|
102
|
+
lastHistoryAt = now;
|
|
103
|
+
return { future: [] };
|
|
104
|
+
}
|
|
105
|
+
lastHistoryKey = key;
|
|
106
|
+
lastHistoryAt = now;
|
|
107
|
+
const past = [...state.past, state.model].slice(-HISTORY_CAP);
|
|
108
|
+
return { past, future: [] };
|
|
109
|
+
}
|
|
110
|
+
|
|
62
111
|
export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
63
112
|
...initialState,
|
|
64
113
|
|
|
65
114
|
// Basic
|
|
66
115
|
setEditMode: (active) => set({ isEditMode: active }),
|
|
116
|
+
setDismissed: (dismissed) => set({ dismissed }),
|
|
67
117
|
setModel: (model) => set({ model }),
|
|
68
118
|
setSourceFilePath: (path) => set({ sourceFilePath: path }),
|
|
119
|
+
setSyncError: (error) => set({ syncError: error }),
|
|
120
|
+
setRetrySave: (fn) => set({ retrySave: fn }),
|
|
121
|
+
|
|
122
|
+
// Undo/redo
|
|
123
|
+
undo: () => {
|
|
124
|
+
const { model, past, future } = get();
|
|
125
|
+
const prev = past[past.length - 1];
|
|
126
|
+
if (!prev || !model) return null;
|
|
127
|
+
lastHistoryKey = null;
|
|
128
|
+
set({ model: prev, past: past.slice(0, -1), future: [...future, model] });
|
|
129
|
+
return prev;
|
|
130
|
+
},
|
|
131
|
+
redo: () => {
|
|
132
|
+
const { model, past, future } = get();
|
|
133
|
+
const next = future[future.length - 1];
|
|
134
|
+
if (!next || !model) return null;
|
|
135
|
+
lastHistoryKey = null;
|
|
136
|
+
set({ model: next, past: [...past, model].slice(-HISTORY_CAP), future: future.slice(0, -1) });
|
|
137
|
+
return next;
|
|
138
|
+
},
|
|
69
139
|
|
|
70
140
|
// Selection
|
|
71
141
|
selectSegment: (id, additive = false) =>
|
|
@@ -111,7 +181,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
111
181
|
if (!segment) return {};
|
|
112
182
|
const segments = new Map(state.model.segments);
|
|
113
183
|
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
|
|
114
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
...pushHistory(state, `seg-style:${segmentId}`),
|
|
186
|
+
model: { ...state.model, segments },
|
|
187
|
+
};
|
|
115
188
|
}),
|
|
116
189
|
|
|
117
190
|
updateSegmentText: (segmentId, text) =>
|
|
@@ -121,7 +194,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
121
194
|
if (!segment) return {};
|
|
122
195
|
const segments = new Map(state.model.segments);
|
|
123
196
|
segments.set(segmentId, { ...segment, text });
|
|
124
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
...pushHistory(state, `seg-text:${segmentId}`),
|
|
199
|
+
model: { ...state.model, segments },
|
|
200
|
+
};
|
|
125
201
|
}),
|
|
126
202
|
|
|
127
203
|
updateSegmentTiming: (segmentId, start, end) =>
|
|
@@ -131,7 +207,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
131
207
|
if (!segment) return {};
|
|
132
208
|
const segments = new Map(state.model.segments);
|
|
133
209
|
segments.set(segmentId, { ...segment, start, end });
|
|
134
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
...pushHistory(state, `seg-timing:${segmentId}`),
|
|
212
|
+
model: { ...state.model, segments },
|
|
213
|
+
};
|
|
135
214
|
}),
|
|
136
215
|
|
|
137
216
|
// Group mutations
|
|
@@ -142,7 +221,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
142
221
|
if (!group) return {};
|
|
143
222
|
const groups = new Map(state.model.groups);
|
|
144
223
|
groups.set(groupId, { ...group, style: { ...group.style, ...style } });
|
|
145
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
...pushHistory(state, `group-style:${groupId}`),
|
|
226
|
+
model: { ...state.model, groups },
|
|
227
|
+
};
|
|
146
228
|
}),
|
|
147
229
|
|
|
148
230
|
updateGroupContainer: (groupId, container) =>
|
|
@@ -155,7 +237,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
155
237
|
...group,
|
|
156
238
|
containerStyle: { ...group.containerStyle, ...container },
|
|
157
239
|
});
|
|
158
|
-
return {
|
|
240
|
+
return {
|
|
241
|
+
...pushHistory(state, `group-container:${groupId}`),
|
|
242
|
+
model: { ...state.model, groups },
|
|
243
|
+
};
|
|
159
244
|
}),
|
|
160
245
|
|
|
161
246
|
updateGroupAnimation: (groupId, phase, animation) =>
|
|
@@ -173,7 +258,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
173
258
|
...group,
|
|
174
259
|
animation: { ...group.animation, [phase]: mergedPhase },
|
|
175
260
|
});
|
|
176
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
...pushHistory(state, `group-anim:${groupId}:${phase}`),
|
|
263
|
+
model: { ...state.model, groups },
|
|
264
|
+
};
|
|
177
265
|
}),
|
|
178
266
|
|
|
179
267
|
splitGroup: (groupId, atSegmentId) =>
|
|
@@ -206,7 +294,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
206
294
|
}
|
|
207
295
|
});
|
|
208
296
|
|
|
209
|
-
return {
|
|
297
|
+
return {
|
|
298
|
+
...pushHistory(state, `split:${groupId}:${atSegmentId}:${nextSplitId}`),
|
|
299
|
+
model: { ...state.model, groups, segments, groupOrder },
|
|
300
|
+
};
|
|
210
301
|
}),
|
|
211
302
|
|
|
212
303
|
mergeGroups: (groupId1, groupId2) =>
|
|
@@ -236,7 +327,11 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
236
327
|
// Clear selection if it referenced group2
|
|
237
328
|
const selectedGroupId = state.selectedGroupId === groupId2 ? null : state.selectedGroupId;
|
|
238
329
|
|
|
239
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
...pushHistory(state, `merge:${groupId1}:${groupId2}`),
|
|
332
|
+
model: { ...state.model, groups, segments, groupOrder },
|
|
333
|
+
selectedGroupId,
|
|
334
|
+
};
|
|
240
335
|
}),
|
|
241
336
|
|
|
242
337
|
// Bulk
|
|
@@ -250,7 +345,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
250
345
|
segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
|
|
251
346
|
}
|
|
252
347
|
}
|
|
253
|
-
return {
|
|
348
|
+
return {
|
|
349
|
+
...pushHistory(state, `sel-style:${[...state.selectedSegmentIds].join(",")}`),
|
|
350
|
+
model: { ...state.model, segments },
|
|
351
|
+
};
|
|
254
352
|
}),
|
|
255
353
|
|
|
256
354
|
applyAnimationToAll: (animation) =>
|
|
@@ -260,13 +358,21 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
|
|
|
260
358
|
for (const [id, group] of groups) {
|
|
261
359
|
groups.set(id, { ...group, animation });
|
|
262
360
|
}
|
|
263
|
-
return {
|
|
361
|
+
return {
|
|
362
|
+
...pushHistory(state, "apply-anim-all"),
|
|
363
|
+
model: { ...state.model, groups },
|
|
364
|
+
};
|
|
264
365
|
}),
|
|
265
366
|
|
|
266
|
-
// Reset
|
|
267
|
-
reset: () =>
|
|
268
|
-
|
|
367
|
+
// Reset — keeps retrySave (registered once by useCaptionSync for the app's lifetime)
|
|
368
|
+
reset: () => {
|
|
369
|
+
lastHistoryKey = null;
|
|
370
|
+
set((state) => ({
|
|
269
371
|
...initialState,
|
|
372
|
+
retrySave: state.retrySave,
|
|
270
373
|
selectedSegmentIds: new Set<string>(),
|
|
271
|
-
|
|
374
|
+
past: [],
|
|
375
|
+
future: [],
|
|
376
|
+
}));
|
|
377
|
+
},
|
|
272
378
|
}));
|
|
@@ -52,7 +52,6 @@ vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
|
|
|
52
52
|
vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
|
|
53
53
|
vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
|
|
54
54
|
vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
|
|
55
|
-
vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null }));
|
|
56
55
|
|
|
57
56
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
58
57
|
|
|
@@ -82,6 +81,7 @@ describe("EditorShell timeline selection sync", () => {
|
|
|
82
81
|
handleTimelineElementResize={vi.fn()}
|
|
83
82
|
handleTimelineGroupResize={vi.fn()}
|
|
84
83
|
handleToggleTrackHidden={vi.fn()}
|
|
84
|
+
setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
|
|
85
85
|
handleBlockedTimelineEdit={vi.fn()}
|
|
86
86
|
handleTimelineElementSplit={vi.fn()}
|
|
87
87
|
handleRazorSplit={vi.fn()}
|
|
@@ -85,6 +85,9 @@ export function EditorShell({
|
|
|
85
85
|
handleTimelineElementResize,
|
|
86
86
|
handleTimelineGroupResize,
|
|
87
87
|
handleToggleTrackHidden,
|
|
88
|
+
setAudioGroupAttribute,
|
|
89
|
+
handleGroupClips,
|
|
90
|
+
setElementFxAttribute,
|
|
88
91
|
handleBlockedTimelineEdit,
|
|
89
92
|
handleTimelineElementSplit,
|
|
90
93
|
handleRazorSplit,
|
|
@@ -135,6 +138,9 @@ export function EditorShell({
|
|
|
135
138
|
handleTimelineElementResize,
|
|
136
139
|
handleTimelineGroupResize,
|
|
137
140
|
handleToggleTrackHidden,
|
|
141
|
+
setAudioGroupAttribute,
|
|
142
|
+
handleGroupClips,
|
|
143
|
+
setElementFxAttribute,
|
|
138
144
|
handleBlockedTimelineEdit,
|
|
139
145
|
handleTimelineElementSplit,
|
|
140
146
|
handleRazorSplit,
|
|
@@ -231,6 +237,12 @@ function EditorShellBody({
|
|
|
231
237
|
}: EditorShellBodyProps) {
|
|
232
238
|
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
233
239
|
|
|
240
|
+
// The caption track's blocks are seek targets; CaptionTimeline took an onSeek
|
|
241
|
+
// prop that nothing ever passed, so clicking a block did nothing.
|
|
242
|
+
const seekCaptionTime = useCallback((time: number) => {
|
|
243
|
+
usePlayerStore.getState().requestSeek(time);
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
234
246
|
// Keyboard: Escape to pop composition level
|
|
235
247
|
const handleKeyDown = useCallback(
|
|
236
248
|
(e: React.KeyboardEvent) => {
|
|
@@ -282,7 +294,7 @@ function EditorShellBody({
|
|
|
282
294
|
Captions
|
|
283
295
|
</span>
|
|
284
296
|
</div>
|
|
285
|
-
<CaptionTimeline pixelsPerSecond={100} />
|
|
297
|
+
<CaptionTimeline pixelsPerSecond={100} onSeek={seekCaptionTime} />
|
|
286
298
|
</div>
|
|
287
299
|
) : undefined
|
|
288
300
|
}
|
|
@@ -45,6 +45,7 @@ export function StudioRightPanel({
|
|
|
45
45
|
domEditSaveTimestampRef,
|
|
46
46
|
recordEdit,
|
|
47
47
|
onToggleElementHidden,
|
|
48
|
+
onAutoGroupCarveSources,
|
|
48
49
|
onAddMediaOverlay,
|
|
49
50
|
}: StudioRightPanelProps) {
|
|
50
51
|
const {
|
|
@@ -328,6 +329,7 @@ export function StudioRightPanel({
|
|
|
328
329
|
copiedAgentPrompt={copiedAgentPrompt}
|
|
329
330
|
onClearSelection={clearDomSelection}
|
|
330
331
|
onToggleElementHidden={onToggleElementHidden}
|
|
332
|
+
onAutoGroupCarveSources={onAutoGroupCarveSources}
|
|
331
333
|
onUngroup={handleUngroupSelection}
|
|
332
334
|
onSetStyle={handleDomStyleCommit}
|
|
333
335
|
onSetAttribute={handleDomAttributeCommit}
|
|
@@ -51,5 +51,6 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
|
|
|
51
51
|
files: Record<string, { before: string; after: string }>;
|
|
52
52
|
}) => Promise<void>;
|
|
53
53
|
onToggleElementHidden?: ToggleHiddenHandler;
|
|
54
|
+
onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
|
|
54
55
|
onAddMediaOverlay?: AddMediaOverlayHandler;
|
|
55
56
|
}
|
|
@@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({
|
|
|
197
197
|
<button
|
|
198
198
|
type="button"
|
|
199
199
|
onClick={() => setExpanded((v) => !v)}
|
|
200
|
-
|
|
200
|
+
aria-expanded={expanded}
|
|
201
|
+
className="flex w-full items-center gap-2 py-1.5 active:scale-[0.99]"
|
|
201
202
|
>
|
|
202
203
|
<span
|
|
203
204
|
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
|
|
@@ -46,8 +46,9 @@ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }
|
|
|
46
46
|
<button
|
|
47
47
|
type="button"
|
|
48
48
|
onClick={onClick}
|
|
49
|
-
className="flex-shrink-0 rounded p-
|
|
49
|
+
className="relative flex-shrink-0 rounded p-1.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400 active:scale-[0.95]"
|
|
50
50
|
title={title}
|
|
51
|
+
aria-label={title}
|
|
51
52
|
>
|
|
52
53
|
<svg
|
|
53
54
|
width="12"
|
|
@@ -87,13 +88,15 @@ export function PropertyRow({
|
|
|
87
88
|
</span>
|
|
88
89
|
<button
|
|
89
90
|
type="button"
|
|
91
|
+
role="switch"
|
|
92
|
+
aria-checked={isVisible}
|
|
90
93
|
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
|
|
91
|
-
className="flex-shrink-0 rounded-full transition-
|
|
94
|
+
className="flex-shrink-0 rounded-full transition-colors duration-200 relative"
|
|
92
95
|
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
|
|
93
96
|
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
|
|
94
97
|
>
|
|
95
98
|
<span
|
|
96
|
-
className="absolute top-[2px] left-0 rounded-full transition-transform duration-
|
|
99
|
+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
|
|
97
100
|
style={{
|
|
98
101
|
width: 12,
|
|
99
102
|
height: 12,
|
|
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
|
|
|
45
45
|
<span className={LABEL}>Arc Motion</span>
|
|
46
46
|
<button
|
|
47
47
|
type="button"
|
|
48
|
+
role="switch"
|
|
49
|
+
aria-checked={Boolean(arcPath.enabled)}
|
|
50
|
+
aria-label="Arc motion"
|
|
48
51
|
onClick={handleToggle}
|
|
49
52
|
disabled={disabled}
|
|
50
|
-
className="relative rounded-full transition-
|
|
53
|
+
className="relative rounded-full transition-colors duration-200"
|
|
51
54
|
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
|
|
52
55
|
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
|
|
53
56
|
>
|
|
54
57
|
<span
|
|
55
|
-
className="absolute top-[2px] left-0 rounded-full transition-transform duration-
|
|
58
|
+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
|
|
56
59
|
style={{
|
|
57
60
|
width: 12,
|
|
58
61
|
height: 12,
|
|
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
|
|
|
69
72
|
<span className={LABEL}>Auto-Rotate</span>
|
|
70
73
|
<button
|
|
71
74
|
type="button"
|
|
75
|
+
role="switch"
|
|
76
|
+
aria-checked={Boolean(arcPath.autoRotate)}
|
|
77
|
+
aria-label="Auto-rotate along path"
|
|
72
78
|
onClick={handleAutoRotate}
|
|
73
79
|
disabled={disabled}
|
|
74
|
-
className="relative rounded-full transition-
|
|
80
|
+
className="relative rounded-full transition-colors duration-200"
|
|
75
81
|
style={{
|
|
76
82
|
width: 28,
|
|
77
83
|
height: 16,
|
|
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
|
|
|
84
90
|
}
|
|
85
91
|
>
|
|
86
92
|
<span
|
|
87
|
-
className="absolute top-[2px] left-0 rounded-full transition-transform duration-
|
|
93
|
+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
|
|
88
94
|
style={{
|
|
89
95
|
width: 12,
|
|
90
96
|
height: 12,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { BlockParam } from "@hyperframes/core/registry";
|
|
7
|
+
import { FileManagerProvider } from "../../contexts/FileManagerContext";
|
|
8
|
+
import type { useFileManager } from "../../hooks/useFileManager";
|
|
9
|
+
import { StudioPlaybackProvider, type StudioPlaybackValue } from "../../contexts/StudioContext";
|
|
10
|
+
import { BlockParamsPanel } from "./BlockParamsPanel";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
|
|
16
|
+
const PARAMS: BlockParam[] = [
|
|
17
|
+
{ key: "--bg-color", label: "Background", type: "color", default: "#0c0c0c" },
|
|
18
|
+
{ key: "--text-color", label: "Text color", type: "color", default: "#fafafa" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const playbackValue: StudioPlaybackValue = {
|
|
22
|
+
captionEditMode: false,
|
|
23
|
+
compositionLoading: false,
|
|
24
|
+
refreshKey: 0,
|
|
25
|
+
setRefreshKey: vi.fn(),
|
|
26
|
+
timelineElements: [],
|
|
27
|
+
isPlaying: false,
|
|
28
|
+
refreshPreviewDocumentVersion: vi.fn(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function makeFileManager(content: { value: string }) {
|
|
32
|
+
const readProjectFile = vi.fn(async () => content.value);
|
|
33
|
+
const writeProjectFile = vi.fn(async (_path: string, next: string) => {
|
|
34
|
+
content.value = next;
|
|
35
|
+
});
|
|
36
|
+
// The panel only touches read/writeProjectFile; the rest of the context
|
|
37
|
+
// surface is irrelevant to these tests.
|
|
38
|
+
const value = { readProjectFile, writeProjectFile } as unknown as ReturnType<
|
|
39
|
+
typeof useFileManager
|
|
40
|
+
>;
|
|
41
|
+
return { value, readProjectFile, writeProjectFile };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let root: Root | null = null;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
act(() => root?.unmount());
|
|
48
|
+
root = null;
|
|
49
|
+
document.body.innerHTML = "";
|
|
50
|
+
vi.clearAllTimers();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function renderPanel(fileManager: ReturnType<typeof makeFileManager>["value"]) {
|
|
54
|
+
const host = document.createElement("div");
|
|
55
|
+
document.body.append(host);
|
|
56
|
+
root = createRoot(host);
|
|
57
|
+
act(() => {
|
|
58
|
+
root?.render(
|
|
59
|
+
<StudioPlaybackProvider value={playbackValue}>
|
|
60
|
+
<FileManagerProvider value={fileManager}>
|
|
61
|
+
<BlockParamsPanel
|
|
62
|
+
blockName="vfx-demo"
|
|
63
|
+
blockTitle="VFX Demo"
|
|
64
|
+
params={PARAMS}
|
|
65
|
+
compositionPath="compositions/vfx-demo.html"
|
|
66
|
+
onClose={vi.fn()}
|
|
67
|
+
/>
|
|
68
|
+
</FileManagerProvider>
|
|
69
|
+
</StudioPlaybackProvider>,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function changeParam(label: string, next: string) {
|
|
75
|
+
const input = document.querySelector<HTMLInputElement>(`input[aria-label="${label} value"]`);
|
|
76
|
+
if (!input) throw new Error(`no input for ${label}`);
|
|
77
|
+
act(() => {
|
|
78
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
79
|
+
setter?.call(input, next);
|
|
80
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function flushCommit() {
|
|
85
|
+
await act(async () => {
|
|
86
|
+
vi.advanceTimersByTime(350);
|
|
87
|
+
// Drain the read → guard → write promise chain.
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
await Promise.resolve();
|
|
91
|
+
await Promise.resolve();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe("BlockParamsPanel commit safety", () => {
|
|
96
|
+
it("refuses a multi-occurrence value and never mutates the unrelated sibling", async () => {
|
|
97
|
+
// The param's default (#0c0c0c) also appears on an UNRELATED element. The
|
|
98
|
+
// panel has no way to tell which occurrence belongs to the param, so it
|
|
99
|
+
// must refuse rather than risk rewriting `.unrelated`. Nothing is written.
|
|
100
|
+
const content = {
|
|
101
|
+
value: ".block { background: #0c0c0c; } .unrelated { border-color: #0c0c0c; }",
|
|
102
|
+
};
|
|
103
|
+
const before = content.value;
|
|
104
|
+
const fm = makeFileManager(content);
|
|
105
|
+
renderPanel(fm.value);
|
|
106
|
+
|
|
107
|
+
changeParam("Background", "#123456");
|
|
108
|
+
await flushCommit();
|
|
109
|
+
|
|
110
|
+
expect(fm.writeProjectFile).not.toHaveBeenCalled();
|
|
111
|
+
expect(content.value).toBe(before); // both occurrences untouched
|
|
112
|
+
expect(content.value).toContain(".unrelated { border-color: #0c0c0c; }");
|
|
113
|
+
expect(document.body.textContent).toContain("appears 2×");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("writes a unique occurrence with token boundaries (no substring corruption)", async () => {
|
|
117
|
+
// #0c0c0c is unique here — the 8-digit #0c0c0cff must NOT match (token
|
|
118
|
+
// boundary), so exactly one occurrence rewrites and the hex8 is preserved.
|
|
119
|
+
const content = {
|
|
120
|
+
value: ".a { background: #0c0c0c; } .c { color: #0c0c0cff; }",
|
|
121
|
+
};
|
|
122
|
+
const fm = makeFileManager(content);
|
|
123
|
+
renderPanel(fm.value);
|
|
124
|
+
|
|
125
|
+
changeParam("Background", "#123456");
|
|
126
|
+
await flushCommit();
|
|
127
|
+
|
|
128
|
+
expect(fm.writeProjectFile).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(content.value).toBe(".a { background: #123456; } .c { color: #0c0c0cff; }");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("refuses once a previously-unique value later collides with unrelated content", async () => {
|
|
133
|
+
const content = {
|
|
134
|
+
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
|
|
135
|
+
};
|
|
136
|
+
const fm = makeFileManager(content);
|
|
137
|
+
renderPanel(fm.value);
|
|
138
|
+
|
|
139
|
+
// #0c0c0c is unique → first commit writes; now #fafafa appears 2×.
|
|
140
|
+
changeParam("Background", "#fafafa");
|
|
141
|
+
await flushCommit();
|
|
142
|
+
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
|
|
143
|
+
|
|
144
|
+
// Next edit's current value (#fafafa) now matches 2× → refuse.
|
|
145
|
+
changeParam("Background", "#ff0000");
|
|
146
|
+
await flushCommit();
|
|
147
|
+
|
|
148
|
+
expect(fm.writeProjectFile).toHaveBeenCalledTimes(1); // no second write
|
|
149
|
+
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
|
|
150
|
+
expect(document.body.textContent).toContain("appears 2×");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps a pending commit for one param when another param is edited", async () => {
|
|
154
|
+
const content = {
|
|
155
|
+
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
|
|
156
|
+
};
|
|
157
|
+
const fm = makeFileManager(content);
|
|
158
|
+
renderPanel(fm.value);
|
|
159
|
+
|
|
160
|
+
// Edit both params back-to-back within the 300ms debounce window: the
|
|
161
|
+
// second edit must not cancel the first param's pending commit.
|
|
162
|
+
changeParam("Background", "#111111");
|
|
163
|
+
changeParam("Text color", "#222222");
|
|
164
|
+
await flushCommit();
|
|
165
|
+
await flushCommit();
|
|
166
|
+
|
|
167
|
+
expect(content.value).toContain("#111111");
|
|
168
|
+
expect(content.value).toContain("#222222");
|
|
169
|
+
});
|
|
170
|
+
});
|