@hyperframes/studio 0.7.39 → 0.7.40
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/{index-312a3Ceu.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-B9YvRJz1.js → index-B2Utv-2b.js} +195 -195
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-CQHiecE7.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
- package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +34 -29
- package/dist/index.html +2 -2
- package/dist/index.js +2749 -2075
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/editor/DomEditOverlay.tsx +33 -47
- package/src/components/editor/PropertyPanel.test.ts +25 -0
- package/src/components/editor/PropertyPanel.tsx +33 -7
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +11 -0
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-0P10SwC_.css +0 -1
- /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
|
+
import { useExpandedTimelineElements } from "../player/hooks/useExpandedTimelineElements";
|
|
4
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
5
|
+
import { readTagSnippetByTarget, type PatchOperation } from "../utils/sourcePatcher";
|
|
6
|
+
import {
|
|
7
|
+
applyPatchByTarget,
|
|
8
|
+
buildPatchTarget,
|
|
9
|
+
findTimelineElementInIframe,
|
|
10
|
+
readFileContent,
|
|
11
|
+
type RecordEditInput,
|
|
12
|
+
} from "./timelineEditingHelpers";
|
|
13
|
+
|
|
14
|
+
interface MutableRef<T> {
|
|
15
|
+
current: T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ReadonlyRef<T> {
|
|
19
|
+
readonly current: T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ToggleTimelineTrackHiddenInput {
|
|
23
|
+
projectId: string;
|
|
24
|
+
activeCompPath: string | null;
|
|
25
|
+
timelineElements: readonly TimelineElement[];
|
|
26
|
+
track: number;
|
|
27
|
+
hidden: boolean;
|
|
28
|
+
previewIframe: HTMLIFrameElement | null;
|
|
29
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
31
|
+
domEditSaveTimestampRef: MutableRef<number>;
|
|
32
|
+
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
|
|
36
|
+
elementKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SetElementsHiddenInput {
|
|
40
|
+
projectId: string;
|
|
41
|
+
activeCompPath: string | null;
|
|
42
|
+
elements: readonly TimelineElement[];
|
|
43
|
+
hidden: boolean;
|
|
44
|
+
label: string;
|
|
45
|
+
previewIframe: HTMLIFrameElement | null;
|
|
46
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
47
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
48
|
+
domEditSaveTimestampRef: MutableRef<number>;
|
|
49
|
+
pendingTimelineEditPathRef: MutableRef<Set<string>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface UseTimelineTrackVisibilityEditingInput extends Omit<
|
|
53
|
+
ToggleTimelineTrackHiddenInput,
|
|
54
|
+
"projectId" | "track" | "hidden" | "previewIframe"
|
|
55
|
+
> {
|
|
56
|
+
projectIdRef: ReadonlyRef<string | null>;
|
|
57
|
+
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
|
58
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
59
|
+
isRecordingRef?: ReadonlyRef<boolean>;
|
|
60
|
+
forceReloadSdkSession?: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface UseTimelineElementVisibilityEditingInput extends Omit<
|
|
64
|
+
ToggleTimelineElementHiddenInput,
|
|
65
|
+
"projectId" | "elementKey" | "hidden" | "previewIframe"
|
|
66
|
+
> {
|
|
67
|
+
projectIdRef: ReadonlyRef<string | null>;
|
|
68
|
+
previewIframeRef: ReadonlyRef<HTMLIFrameElement | null>;
|
|
69
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
70
|
+
isRecordingRef?: ReadonlyRef<boolean>;
|
|
71
|
+
forceReloadSdkSession?: () => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getTimelineElementTargetPath(
|
|
75
|
+
element: TimelineElement,
|
|
76
|
+
activeCompPath: string | null,
|
|
77
|
+
): string {
|
|
78
|
+
return element.sourceFile || activeCompPath || "index.html";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function patchLiveHiddenState(
|
|
82
|
+
iframe: HTMLIFrameElement | null,
|
|
83
|
+
elements: readonly TimelineElement[],
|
|
84
|
+
hidden: boolean,
|
|
85
|
+
): void {
|
|
86
|
+
for (const element of elements) {
|
|
87
|
+
const target = findTimelineElementInIframe(iframe, element);
|
|
88
|
+
if (!target) continue;
|
|
89
|
+
if (hidden) {
|
|
90
|
+
target.setAttribute("data-hidden", "");
|
|
91
|
+
} else {
|
|
92
|
+
target.removeAttribute("data-hidden");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reseekPreviewRuntime(iframe: HTMLIFrameElement | null): void {
|
|
98
|
+
try {
|
|
99
|
+
const win: (Window & { __player?: { seek?: (time: number) => void } }) | null =
|
|
100
|
+
iframe?.contentWindow ?? null;
|
|
101
|
+
win?.__player?.seek?.(usePlayerStore.getState().currentTime);
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function groupElementsByTargetPath(
|
|
106
|
+
elements: readonly TimelineElement[],
|
|
107
|
+
activeCompPath: string | null,
|
|
108
|
+
): Map<string, TimelineElement[]> {
|
|
109
|
+
const byPath = new Map<string, TimelineElement[]>();
|
|
110
|
+
for (const element of elements) {
|
|
111
|
+
const targetPath = getTimelineElementTargetPath(element, activeCompPath);
|
|
112
|
+
const existing = byPath.get(targetPath);
|
|
113
|
+
if (existing) {
|
|
114
|
+
existing.push(element);
|
|
115
|
+
} else {
|
|
116
|
+
byPath.set(targetPath, [element]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return byPath;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// fallow-ignore-next-line complexity
|
|
123
|
+
async function setElementsHidden({
|
|
124
|
+
projectId,
|
|
125
|
+
activeCompPath,
|
|
126
|
+
elements,
|
|
127
|
+
hidden,
|
|
128
|
+
label,
|
|
129
|
+
previewIframe,
|
|
130
|
+
writeProjectFile,
|
|
131
|
+
recordEdit,
|
|
132
|
+
domEditSaveTimestampRef,
|
|
133
|
+
pendingTimelineEditPathRef,
|
|
134
|
+
}: SetElementsHiddenInput): Promise<string[]> {
|
|
135
|
+
if (elements.length === 0) return [];
|
|
136
|
+
|
|
137
|
+
patchLiveHiddenState(previewIframe, elements, hidden);
|
|
138
|
+
reseekPreviewRuntime(previewIframe);
|
|
139
|
+
|
|
140
|
+
const hiddenOperation: PatchOperation = {
|
|
141
|
+
type: "attribute",
|
|
142
|
+
property: "hidden",
|
|
143
|
+
value: hidden ? "" : null,
|
|
144
|
+
};
|
|
145
|
+
const originalByPath = new Map<string, string>();
|
|
146
|
+
const files: Record<string, string> = {};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
for (const [targetPath, fileElements] of groupElementsByTargetPath(elements, activeCompPath)) {
|
|
150
|
+
let patchedContent = await readFileContent(projectId, targetPath);
|
|
151
|
+
originalByPath.set(targetPath, patchedContent);
|
|
152
|
+
|
|
153
|
+
for (const element of fileElements) {
|
|
154
|
+
const patchTarget = buildPatchTarget(element);
|
|
155
|
+
if (!patchTarget) {
|
|
156
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
157
|
+
}
|
|
158
|
+
if (readTagSnippetByTarget(patchedContent, patchTarget) === undefined) {
|
|
159
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
160
|
+
}
|
|
161
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, hiddenOperation);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
files[targetPath] = patchedContent;
|
|
165
|
+
pendingTimelineEditPathRef.current.add(targetPath);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
169
|
+
const changedPaths = await saveProjectFilesWithHistory({
|
|
170
|
+
projectId,
|
|
171
|
+
label,
|
|
172
|
+
kind: "timeline",
|
|
173
|
+
files,
|
|
174
|
+
readFile: async (path) => {
|
|
175
|
+
const original = originalByPath.get(path);
|
|
176
|
+
if (original !== undefined) return original;
|
|
177
|
+
return readFileContent(projectId, path);
|
|
178
|
+
},
|
|
179
|
+
writeFile: writeProjectFile,
|
|
180
|
+
recordEdit,
|
|
181
|
+
});
|
|
182
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
183
|
+
for (const element of elements) {
|
|
184
|
+
usePlayerStore.getState().updateElement(element.key ?? element.id, { hidden });
|
|
185
|
+
}
|
|
186
|
+
return changedPaths;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// The optimistic live patch already ran; a patch-target/save failure here would
|
|
189
|
+
// otherwise leave the preview showing the wrong visibility until a reload. Revert
|
|
190
|
+
// the live DOM to the prior state so what's on screen matches what persisted.
|
|
191
|
+
patchLiveHiddenState(previewIframe, elements, !hidden);
|
|
192
|
+
reseekPreviewRuntime(previewIframe);
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function toggleTimelineTrackHidden({
|
|
198
|
+
projectId,
|
|
199
|
+
activeCompPath,
|
|
200
|
+
timelineElements,
|
|
201
|
+
track,
|
|
202
|
+
hidden,
|
|
203
|
+
previewIframe,
|
|
204
|
+
writeProjectFile,
|
|
205
|
+
recordEdit,
|
|
206
|
+
domEditSaveTimestampRef,
|
|
207
|
+
pendingTimelineEditPathRef,
|
|
208
|
+
}: ToggleTimelineTrackHiddenInput): Promise<string[]> {
|
|
209
|
+
return setElementsHidden({
|
|
210
|
+
projectId,
|
|
211
|
+
activeCompPath,
|
|
212
|
+
elements: timelineElements.filter((element) => element.track === track),
|
|
213
|
+
hidden,
|
|
214
|
+
label: hidden ? `Hide track ${track}` : `Show track ${track}`,
|
|
215
|
+
previewIframe,
|
|
216
|
+
writeProjectFile,
|
|
217
|
+
recordEdit,
|
|
218
|
+
domEditSaveTimestampRef,
|
|
219
|
+
pendingTimelineEditPathRef,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function toggleTimelineElementHidden({
|
|
224
|
+
projectId,
|
|
225
|
+
activeCompPath,
|
|
226
|
+
timelineElements,
|
|
227
|
+
elementKey,
|
|
228
|
+
hidden,
|
|
229
|
+
previewIframe,
|
|
230
|
+
writeProjectFile,
|
|
231
|
+
recordEdit,
|
|
232
|
+
domEditSaveTimestampRef,
|
|
233
|
+
pendingTimelineEditPathRef,
|
|
234
|
+
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
|
235
|
+
const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey);
|
|
236
|
+
return setElementsHidden({
|
|
237
|
+
projectId,
|
|
238
|
+
activeCompPath,
|
|
239
|
+
elements: element ? [element] : [],
|
|
240
|
+
hidden,
|
|
241
|
+
label: hidden ? "Hide element" : "Show element",
|
|
242
|
+
previewIframe,
|
|
243
|
+
writeProjectFile,
|
|
244
|
+
recordEdit,
|
|
245
|
+
domEditSaveTimestampRef,
|
|
246
|
+
pendingTimelineEditPathRef,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function useTimelineTrackVisibilityEditing({
|
|
251
|
+
projectIdRef,
|
|
252
|
+
activeCompPath,
|
|
253
|
+
showToast,
|
|
254
|
+
writeProjectFile,
|
|
255
|
+
recordEdit,
|
|
256
|
+
domEditSaveTimestampRef,
|
|
257
|
+
previewIframeRef,
|
|
258
|
+
pendingTimelineEditPathRef,
|
|
259
|
+
isRecordingRef,
|
|
260
|
+
forceReloadSdkSession,
|
|
261
|
+
}: UseTimelineTrackVisibilityEditingInput): (track: number, hidden: boolean) => Promise<void> {
|
|
262
|
+
// Resolve the eye toggle against the EXPANDED rows the canvas actually renders:
|
|
263
|
+
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
|
264
|
+
// so filtering the raw store list by a virtual track number would hide the wrong
|
|
265
|
+
// outer-scene sibling sharing that index.
|
|
266
|
+
const expandedElements = useExpandedTimelineElements();
|
|
267
|
+
return useCallback(
|
|
268
|
+
async (track: number, hidden: boolean) => {
|
|
269
|
+
if (isRecordingRef?.current) {
|
|
270
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const pid = projectIdRef.current;
|
|
274
|
+
if (!pid) return;
|
|
275
|
+
try {
|
|
276
|
+
await toggleTimelineTrackHidden({
|
|
277
|
+
projectId: pid,
|
|
278
|
+
activeCompPath,
|
|
279
|
+
timelineElements: expandedElements,
|
|
280
|
+
track,
|
|
281
|
+
hidden,
|
|
282
|
+
previewIframe: previewIframeRef.current,
|
|
283
|
+
writeProjectFile,
|
|
284
|
+
recordEdit,
|
|
285
|
+
domEditSaveTimestampRef,
|
|
286
|
+
pendingTimelineEditPathRef,
|
|
287
|
+
});
|
|
288
|
+
forceReloadSdkSession?.();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
console.error("[Timeline] Failed to toggle track visibility", error);
|
|
291
|
+
const message =
|
|
292
|
+
error instanceof Error ? error.message : "Failed to toggle track visibility";
|
|
293
|
+
showToast(message);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[
|
|
297
|
+
activeCompPath,
|
|
298
|
+
expandedElements,
|
|
299
|
+
previewIframeRef,
|
|
300
|
+
writeProjectFile,
|
|
301
|
+
recordEdit,
|
|
302
|
+
domEditSaveTimestampRef,
|
|
303
|
+
pendingTimelineEditPathRef,
|
|
304
|
+
isRecordingRef,
|
|
305
|
+
showToast,
|
|
306
|
+
forceReloadSdkSession,
|
|
307
|
+
projectIdRef,
|
|
308
|
+
],
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function useTimelineElementVisibilityEditing({
|
|
313
|
+
projectIdRef,
|
|
314
|
+
activeCompPath,
|
|
315
|
+
timelineElements,
|
|
316
|
+
showToast,
|
|
317
|
+
writeProjectFile,
|
|
318
|
+
recordEdit,
|
|
319
|
+
domEditSaveTimestampRef,
|
|
320
|
+
previewIframeRef,
|
|
321
|
+
pendingTimelineEditPathRef,
|
|
322
|
+
isRecordingRef,
|
|
323
|
+
forceReloadSdkSession,
|
|
324
|
+
}: UseTimelineElementVisibilityEditingInput): (
|
|
325
|
+
elementKey: string,
|
|
326
|
+
hidden: boolean,
|
|
327
|
+
) => Promise<void> {
|
|
328
|
+
return useCallback(
|
|
329
|
+
async (elementKey: string, hidden: boolean) => {
|
|
330
|
+
if (isRecordingRef?.current) {
|
|
331
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const pid = projectIdRef.current;
|
|
335
|
+
if (!pid) return;
|
|
336
|
+
try {
|
|
337
|
+
await toggleTimelineElementHidden({
|
|
338
|
+
projectId: pid,
|
|
339
|
+
activeCompPath,
|
|
340
|
+
timelineElements,
|
|
341
|
+
elementKey,
|
|
342
|
+
hidden,
|
|
343
|
+
previewIframe: previewIframeRef.current,
|
|
344
|
+
writeProjectFile,
|
|
345
|
+
recordEdit,
|
|
346
|
+
domEditSaveTimestampRef,
|
|
347
|
+
pendingTimelineEditPathRef,
|
|
348
|
+
});
|
|
349
|
+
forceReloadSdkSession?.();
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.error("[Timeline] Failed to toggle element visibility", error);
|
|
352
|
+
const message =
|
|
353
|
+
error instanceof Error ? error.message : "Failed to toggle element visibility";
|
|
354
|
+
showToast(message);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
[
|
|
358
|
+
activeCompPath,
|
|
359
|
+
timelineElements,
|
|
360
|
+
previewIframeRef,
|
|
361
|
+
writeProjectFile,
|
|
362
|
+
recordEdit,
|
|
363
|
+
domEditSaveTimestampRef,
|
|
364
|
+
pendingTimelineEditPathRef,
|
|
365
|
+
isRecordingRef,
|
|
366
|
+
showToast,
|
|
367
|
+
forceReloadSdkSession,
|
|
368
|
+
projectIdRef,
|
|
369
|
+
],
|
|
370
|
+
);
|
|
371
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { act, isValidElement, type ReactNode } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
6
7
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
7
8
|
import type { TimelineElement } from "../player/store/playerStore";
|
|
8
9
|
import { normalizeCompositionSrc } from "./useRenderClipContent";
|
|
@@ -63,7 +64,10 @@ describe("normalizeCompositionSrc", () => {
|
|
|
63
64
|
});
|
|
64
65
|
|
|
65
66
|
describe("useRenderClipContent", () => {
|
|
66
|
-
function renderClipContent(
|
|
67
|
+
function renderClipContent(
|
|
68
|
+
el: TimelineElement,
|
|
69
|
+
activePreviewUrl: string | null = "/api/projects/my-project/preview",
|
|
70
|
+
): ReactNode {
|
|
67
71
|
const host = document.createElement("div");
|
|
68
72
|
document.body.append(host);
|
|
69
73
|
const root = createRoot(host);
|
|
@@ -73,7 +77,7 @@ describe("useRenderClipContent", () => {
|
|
|
73
77
|
const render = useRenderClipContent({
|
|
74
78
|
projectIdRef: { current: "my-project" },
|
|
75
79
|
compIdToSrc: new Map(),
|
|
76
|
-
activePreviewUrl
|
|
80
|
+
activePreviewUrl,
|
|
77
81
|
effectiveTimelineDuration: 12,
|
|
78
82
|
});
|
|
79
83
|
content = render(el, { clip: "#222", label: "#fff" });
|
|
@@ -100,4 +104,66 @@ describe("useRenderClipContent", () => {
|
|
|
100
104
|
expect(isValidElement(content)).toBe(true);
|
|
101
105
|
if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
|
|
102
106
|
});
|
|
107
|
+
|
|
108
|
+
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
109
|
+
const cases: Array<{ content: ReactNode; type: unknown }> = [
|
|
110
|
+
{
|
|
111
|
+
content: renderClipContent({
|
|
112
|
+
id: "voiceover",
|
|
113
|
+
tag: "audio",
|
|
114
|
+
start: 1,
|
|
115
|
+
duration: 4,
|
|
116
|
+
track: 1,
|
|
117
|
+
src: "assets/voiceover.mp3",
|
|
118
|
+
}),
|
|
119
|
+
type: AudioWaveform,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
content: renderClipContent({
|
|
123
|
+
id: "nested",
|
|
124
|
+
tag: "div",
|
|
125
|
+
start: 0,
|
|
126
|
+
duration: 4,
|
|
127
|
+
track: 0,
|
|
128
|
+
compositionSrc: "compositions/nested.html",
|
|
129
|
+
}),
|
|
130
|
+
type: CompositionThumbnail,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
content: renderClipContent(
|
|
134
|
+
{
|
|
135
|
+
id: "clip-video",
|
|
136
|
+
tag: "video",
|
|
137
|
+
start: 0,
|
|
138
|
+
duration: 4,
|
|
139
|
+
track: 0,
|
|
140
|
+
src: "assets/clip.mp4",
|
|
141
|
+
},
|
|
142
|
+
null,
|
|
143
|
+
),
|
|
144
|
+
type: VideoThumbnail,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
content: renderClipContent(
|
|
148
|
+
{
|
|
149
|
+
id: "headline",
|
|
150
|
+
tag: "div",
|
|
151
|
+
start: 1,
|
|
152
|
+
duration: 4,
|
|
153
|
+
track: 0,
|
|
154
|
+
},
|
|
155
|
+
null,
|
|
156
|
+
),
|
|
157
|
+
type: CompositionThumbnail,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
for (const item of cases) {
|
|
162
|
+
expect(isValidElement<{ label: string }>(item.content)).toBe(true);
|
|
163
|
+
if (isValidElement<{ label: string }>(item.content)) {
|
|
164
|
+
expect(item.content.type).toBe(item.type);
|
|
165
|
+
expect(item.content.props.label).toBe("");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
103
169
|
});
|
|
@@ -3,7 +3,6 @@ import { createElement } from "react";
|
|
|
3
3
|
import { CompositionThumbnail, VideoThumbnail } from "../player";
|
|
4
4
|
import type { TimelineElement } from "../player";
|
|
5
5
|
import { AudioWaveform } from "../player/components/AudioWaveform";
|
|
6
|
-
import { getTimelineElementLabel } from "../utils/studioHelpers";
|
|
7
6
|
|
|
8
7
|
export function normalizeCompositionSrc(
|
|
9
8
|
compSrc: string,
|
|
@@ -58,7 +57,7 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
|
|
|
58
57
|
return createElement(AudioWaveform, {
|
|
59
58
|
audioUrl,
|
|
60
59
|
waveformUrl,
|
|
61
|
-
label:
|
|
60
|
+
label: "",
|
|
62
61
|
labelColor,
|
|
63
62
|
trimStartFraction: start,
|
|
64
63
|
trimEndFraction: end,
|
|
@@ -102,7 +101,7 @@ export function useRenderClipContent({
|
|
|
102
101
|
if (compSrc) {
|
|
103
102
|
return createElement(CompositionThumbnail, {
|
|
104
103
|
previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
|
|
105
|
-
label:
|
|
104
|
+
label: "",
|
|
106
105
|
labelColor: style.label,
|
|
107
106
|
|
|
108
107
|
seekTime: 0,
|
|
@@ -122,7 +121,7 @@ export function useRenderClipContent({
|
|
|
122
121
|
if (activePreviewUrl && el.duration > 0) {
|
|
123
122
|
return createElement(CompositionThumbnail, {
|
|
124
123
|
previewUrl: activePreviewUrl,
|
|
125
|
-
label:
|
|
124
|
+
label: "",
|
|
126
125
|
labelColor: style.label,
|
|
127
126
|
|
|
128
127
|
selector: el.selector,
|
|
@@ -144,7 +143,7 @@ export function useRenderClipContent({
|
|
|
144
143
|
: `/api/projects/${pid}/preview/${el.src}`;
|
|
145
144
|
return createElement(VideoThumbnail, {
|
|
146
145
|
videoSrc: mediaSrc,
|
|
147
|
-
label:
|
|
146
|
+
label: "",
|
|
148
147
|
labelColor: style.label,
|
|
149
148
|
duration: el.duration,
|
|
150
149
|
});
|
|
@@ -153,7 +152,7 @@ export function useRenderClipContent({
|
|
|
153
152
|
if (htmlPreviewEligible) {
|
|
154
153
|
return createElement(CompositionThumbnail, {
|
|
155
154
|
previewUrl: `/api/projects/${pid}/preview`,
|
|
156
|
-
label:
|
|
155
|
+
label: "",
|
|
157
156
|
labelColor: style.label,
|
|
158
157
|
|
|
159
158
|
selector: el.selector,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
collectHtmlIds,
|
|
21
21
|
resolveDroppedAssetDuration,
|
|
22
22
|
} from "../utils/studioHelpers";
|
|
23
|
-
import type { EditHistoryKind } from "../utils/editHistory";
|
|
24
23
|
import {
|
|
25
24
|
buildPatchTarget,
|
|
26
25
|
patchIframeDomTiming,
|
|
@@ -33,36 +32,12 @@ import {
|
|
|
33
32
|
scaleGsapPositions,
|
|
34
33
|
} from "./timelineEditingHelpers";
|
|
35
34
|
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
|
|
35
|
+
import {
|
|
36
|
+
useTimelineElementVisibilityEditing,
|
|
37
|
+
useTimelineTrackVisibilityEditing,
|
|
38
|
+
} from "./timelineTrackVisibility";
|
|
36
39
|
import { sdkTimingPersist } from "../utils/sdkCutover";
|
|
37
|
-
import type {
|
|
38
|
-
|
|
39
|
-
// ── Types ──
|
|
40
|
-
|
|
41
|
-
interface RecordEditInput {
|
|
42
|
-
label: string;
|
|
43
|
-
kind: EditHistoryKind;
|
|
44
|
-
coalesceKey?: string;
|
|
45
|
-
files: Record<string, { before: string; after: string }>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface UseTimelineEditingOptions {
|
|
49
|
-
projectId: string | null;
|
|
50
|
-
activeCompPath: string | null;
|
|
51
|
-
timelineElements: TimelineElement[];
|
|
52
|
-
showToast: (message: string, tone?: "error" | "info") => void;
|
|
53
|
-
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
54
|
-
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
55
|
-
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
56
|
-
reloadPreview: () => void;
|
|
57
|
-
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
58
|
-
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
59
|
-
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
60
|
-
isRecordingRef?: React.RefObject<boolean>;
|
|
61
|
-
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
|
62
|
-
sdkSession?: Composition | null;
|
|
63
|
-
/** Resync the SDK session after a server-authoritative timeline write. */
|
|
64
|
-
forceReloadSdkSession?: () => void;
|
|
65
|
-
}
|
|
40
|
+
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
66
41
|
|
|
67
42
|
// ── Hook ──
|
|
68
43
|
|
|
@@ -323,6 +298,34 @@ export function useTimelineEditing({
|
|
|
323
298
|
],
|
|
324
299
|
);
|
|
325
300
|
|
|
301
|
+
const handleToggleTrackHidden = useTimelineTrackVisibilityEditing({
|
|
302
|
+
projectIdRef,
|
|
303
|
+
activeCompPath,
|
|
304
|
+
timelineElements,
|
|
305
|
+
showToast,
|
|
306
|
+
writeProjectFile,
|
|
307
|
+
recordEdit,
|
|
308
|
+
domEditSaveTimestampRef,
|
|
309
|
+
previewIframeRef,
|
|
310
|
+
pendingTimelineEditPathRef,
|
|
311
|
+
isRecordingRef,
|
|
312
|
+
forceReloadSdkSession,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const handleToggleElementHidden = useTimelineElementVisibilityEditing({
|
|
316
|
+
projectIdRef,
|
|
317
|
+
activeCompPath,
|
|
318
|
+
timelineElements,
|
|
319
|
+
showToast,
|
|
320
|
+
writeProjectFile,
|
|
321
|
+
recordEdit,
|
|
322
|
+
domEditSaveTimestampRef,
|
|
323
|
+
previewIframeRef,
|
|
324
|
+
pendingTimelineEditPathRef,
|
|
325
|
+
isRecordingRef,
|
|
326
|
+
forceReloadSdkSession,
|
|
327
|
+
});
|
|
328
|
+
|
|
326
329
|
// fallow-ignore-next-line complexity
|
|
327
330
|
const handleTimelineElementDelete = useCallback(
|
|
328
331
|
// fallow-ignore-next-line complexity
|
|
@@ -562,6 +565,8 @@ export function useTimelineEditing({
|
|
|
562
565
|
return {
|
|
563
566
|
handleTimelineElementMove,
|
|
564
567
|
handleTimelineElementResize,
|
|
568
|
+
handleToggleTrackHidden,
|
|
569
|
+
handleToggleElementHidden,
|
|
565
570
|
handleTimelineElementDelete,
|
|
566
571
|
handleTimelineElementSplit: handleRazorSplit,
|
|
567
572
|
handleRazorSplit,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { MutableRefObject, RefObject } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { TimelineElement } from "../player";
|
|
4
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
|
|
6
|
+
interface RecordEditInput {
|
|
7
|
+
label: string;
|
|
8
|
+
kind: EditHistoryKind;
|
|
9
|
+
coalesceKey?: string;
|
|
10
|
+
files: Record<string, { before: string; after: string }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UseTimelineEditingOptions {
|
|
14
|
+
projectId: string | null;
|
|
15
|
+
activeCompPath: string | null;
|
|
16
|
+
timelineElements: TimelineElement[];
|
|
17
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
18
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
19
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
20
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
21
|
+
reloadPreview: () => void;
|
|
22
|
+
previewIframeRef: RefObject<HTMLIFrameElement | null>;
|
|
23
|
+
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
|
|
24
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
25
|
+
isRecordingRef?: RefObject<boolean>;
|
|
26
|
+
/** Stage 7 §3.2: SDK session for routing timing ops through setTiming. */
|
|
27
|
+
sdkSession?: Composition | null;
|
|
28
|
+
/** Resync the SDK session after a server-authoritative timeline write. */
|
|
29
|
+
forceReloadSdkSession?: () => void;
|
|
30
|
+
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
Eye as PhEye,
|
|
5
5
|
FilmStrip,
|
|
6
6
|
Stack,
|
|
7
|
-
ChatCenteredText,
|
|
8
7
|
ArrowsOutCardinal,
|
|
9
8
|
MusicNote,
|
|
10
9
|
Palette as PhPalette,
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
TextT,
|
|
16
15
|
X as PhX,
|
|
17
16
|
Lightning,
|
|
18
|
-
CaretDown,
|
|
19
17
|
CaretRight,
|
|
20
18
|
ClipboardText,
|
|
21
19
|
ArrowCounterClockwise,
|
|
@@ -41,7 +39,6 @@ export const Clock = makeIcon(PhClock);
|
|
|
41
39
|
export const Eye = makeIcon(PhEye);
|
|
42
40
|
export const Film = makeIcon(FilmStrip);
|
|
43
41
|
export const Layers = makeIcon(Stack);
|
|
44
|
-
export const MessageSquare = makeIcon(ChatCenteredText);
|
|
45
42
|
export const Move = makeIcon(ArrowsOutCardinal);
|
|
46
43
|
export const Music = makeIcon(MusicNote);
|
|
47
44
|
export const Palette = makeIcon(PhPalette);
|
|
@@ -53,7 +50,18 @@ export const Type = makeIcon(TextT);
|
|
|
53
50
|
export const X = makeIcon(PhX);
|
|
54
51
|
export const Zap = makeIcon(Lightning);
|
|
55
52
|
// Extra icons used in this project (not in lucide's default mapping above)
|
|
56
|
-
export const ChevronDown =
|
|
53
|
+
export const ChevronDown = ({ title, style, ...props }: IconProps) => {
|
|
54
|
+
const transform = style?.transform ? `${style.transform} rotate(90deg)` : "rotate(90deg)";
|
|
55
|
+
return (
|
|
56
|
+
<CaretRight
|
|
57
|
+
alt={title}
|
|
58
|
+
aria-label={title}
|
|
59
|
+
aria-hidden={title ? undefined : true}
|
|
60
|
+
style={{ ...style, transform }}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
57
65
|
export const ChevronRight = makeIcon(CaretRight);
|
|
58
66
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
59
67
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|