@hyperframes/studio 0.6.0-alpha.12 → 0.6.0-alpha.14
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-BI1oj9hu.js +418 -0
- package/dist/assets/index-CBj2NLRG.js +117 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +427 -4487
- package/src/components/AskAgentModal.tsx +120 -0
- package/src/components/StudioHeader.tsx +133 -0
- package/src/components/StudioLeftSidebar.tsx +125 -0
- package/src/components/StudioPreviewArea.tsx +163 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +15 -1
- package/src/components/editor/PropertyPanel.tsx +129 -2681
- package/src/components/editor/domEditing.ts +38 -5
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +32 -0
- package/src/components/editor/propertyPanelColor.tsx +371 -0
- package/src/components/editor/propertyPanelFill.tsx +421 -0
- package/src/components/editor/propertyPanelFont.tsx +455 -0
- package/src/components/editor/propertyPanelHelpers.ts +401 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
- package/src/components/editor/propertyPanelSections.tsx +453 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
- package/src/components/nle/NLELayout.tsx +0 -10
- package/src/contexts/DomEditContext.tsx +137 -0
- package/src/contexts/FileManagerContext.tsx +110 -0
- package/src/contexts/PanelLayoutContext.tsx +68 -0
- package/src/contexts/StudioContext.tsx +135 -0
- package/src/hooks/useAppHotkeys.ts +326 -0
- package/src/hooks/useAskAgentModal.ts +162 -0
- package/src/hooks/useCaptionDetection.ts +132 -0
- package/src/hooks/useCompositionDimensions.ts +25 -0
- package/src/hooks/useConsoleErrorCapture.ts +60 -0
- package/src/hooks/useDomEditCommits.ts +437 -0
- package/src/hooks/useDomEditSession.ts +342 -0
- package/src/hooks/useDomEditTextCommits.ts +330 -0
- package/src/hooks/useDomSelection.ts +398 -0
- package/src/hooks/useFileManager.ts +431 -0
- package/src/hooks/useFrameCapture.ts +77 -0
- package/src/hooks/useLintModal.ts +35 -0
- package/src/hooks/useManifestPersistence.ts +492 -0
- package/src/hooks/usePanelLayout.ts +68 -0
- package/src/hooks/usePreviewInteraction.ts +153 -0
- package/src/hooks/useRenderClipContent.ts +124 -0
- package/src/hooks/useTimelineEditing.ts +472 -0
- package/src/player/components/Player.tsx +19 -1
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +2 -83
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/hooks/useTimelinePlayer.ts +140 -103
- package/src/utils/domEditHelpers.ts +50 -0
- package/src/utils/studioFontHelpers.ts +83 -0
- package/src/utils/studioHelpers.ts +214 -0
- package/src/utils/studioPreviewHelpers.ts +185 -0
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-FWg79aJz.css +0 -1
- package/dist/assets/index-xdyn_qRZ.js +0 -110
- package/src/player/components/TimelineClip.test.ts +0 -92
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
3
|
+
import { usePlayerStore } from "../player";
|
|
4
|
+
import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher";
|
|
5
|
+
import {
|
|
6
|
+
buildTrackZIndexMap,
|
|
7
|
+
formatTimelineAttributeNumber,
|
|
8
|
+
} from "../player/components/timelineEditing";
|
|
9
|
+
import {
|
|
10
|
+
buildTimelineAssetId,
|
|
11
|
+
buildTimelineAssetInsertHtml,
|
|
12
|
+
buildTimelineFileDropPlacements,
|
|
13
|
+
getTimelineAssetKind,
|
|
14
|
+
insertTimelineAssetIntoSource,
|
|
15
|
+
resolveTimelineAssetInitialGeometry,
|
|
16
|
+
resolveTimelineAssetSrc,
|
|
17
|
+
} from "../utils/timelineAssetDrop";
|
|
18
|
+
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
19
|
+
import {
|
|
20
|
+
getTimelineElementLabel,
|
|
21
|
+
collectHtmlIds,
|
|
22
|
+
resolveDroppedAssetDuration,
|
|
23
|
+
} from "../utils/studioHelpers";
|
|
24
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
25
|
+
|
|
26
|
+
// ── Types ──
|
|
27
|
+
|
|
28
|
+
interface RecordEditInput {
|
|
29
|
+
label: string;
|
|
30
|
+
kind: EditHistoryKind;
|
|
31
|
+
coalesceKey?: string;
|
|
32
|
+
files: Record<string, { before: string; after: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface UseTimelineEditingOptions {
|
|
36
|
+
projectId: string | null;
|
|
37
|
+
activeCompPath: string | null;
|
|
38
|
+
timelineElements: TimelineElement[];
|
|
39
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
40
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
41
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
42
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
43
|
+
reloadPreview: () => void;
|
|
44
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Helpers ──
|
|
48
|
+
|
|
49
|
+
function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) {
|
|
50
|
+
if (element.domId) {
|
|
51
|
+
return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex };
|
|
52
|
+
}
|
|
53
|
+
if (element.selector) {
|
|
54
|
+
return { selector: element.selector, selectorIndex: element.selectorIndex };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
60
|
+
const response = await fetch(
|
|
61
|
+
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
|
62
|
+
);
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
65
|
+
}
|
|
66
|
+
const data = (await response.json()) as { content?: string };
|
|
67
|
+
if (typeof data.content !== "string") {
|
|
68
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
69
|
+
}
|
|
70
|
+
return data.content;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Hook ──
|
|
74
|
+
|
|
75
|
+
export function useTimelineEditing({
|
|
76
|
+
projectId,
|
|
77
|
+
activeCompPath,
|
|
78
|
+
timelineElements,
|
|
79
|
+
showToast,
|
|
80
|
+
writeProjectFile,
|
|
81
|
+
recordEdit,
|
|
82
|
+
domEditSaveTimestampRef,
|
|
83
|
+
reloadPreview,
|
|
84
|
+
uploadProjectFiles,
|
|
85
|
+
}: UseTimelineEditingOptions) {
|
|
86
|
+
const projectIdRef = useRef(projectId);
|
|
87
|
+
projectIdRef.current = projectId;
|
|
88
|
+
|
|
89
|
+
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
90
|
+
|
|
91
|
+
const handleTimelineElementMove = useCallback(
|
|
92
|
+
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
93
|
+
const pid = projectIdRef.current;
|
|
94
|
+
if (!pid) throw new Error("No active project");
|
|
95
|
+
|
|
96
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
97
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
98
|
+
|
|
99
|
+
const patchTarget = buildPatchTarget(element);
|
|
100
|
+
if (!patchTarget) {
|
|
101
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
105
|
+
const relevantElements = timelineElements
|
|
106
|
+
.map((te) =>
|
|
107
|
+
(te.key ?? te.id) === (element.key ?? element.id)
|
|
108
|
+
? { ...te, start: updates.start, track: updates.track }
|
|
109
|
+
: te,
|
|
110
|
+
)
|
|
111
|
+
.filter((te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath);
|
|
112
|
+
const trackZIndices = buildTrackZIndexMap(relevantElements.map((te) => te.track));
|
|
113
|
+
|
|
114
|
+
let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
|
|
115
|
+
type: "attribute",
|
|
116
|
+
property: "start",
|
|
117
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
118
|
+
});
|
|
119
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
120
|
+
type: "attribute",
|
|
121
|
+
property: "track-index",
|
|
122
|
+
value: String(updates.track),
|
|
123
|
+
});
|
|
124
|
+
for (const te of relevantElements) {
|
|
125
|
+
const elementTarget = buildPatchTarget(te);
|
|
126
|
+
if (!elementTarget) continue;
|
|
127
|
+
const nextZIndex = trackZIndices.get(te.track);
|
|
128
|
+
if (nextZIndex == null) continue;
|
|
129
|
+
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
130
|
+
type: "inline-style",
|
|
131
|
+
property: "z-index",
|
|
132
|
+
value: String(nextZIndex),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (patchedContent === originalContent) {
|
|
137
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
141
|
+
await saveProjectFilesWithHistory({
|
|
142
|
+
projectId: pid,
|
|
143
|
+
label: "Move timeline clip",
|
|
144
|
+
kind: "timeline",
|
|
145
|
+
files: { [targetPath]: patchedContent },
|
|
146
|
+
readFile: async () => originalContent,
|
|
147
|
+
writeFile: writeProjectFile,
|
|
148
|
+
recordEdit,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
reloadPreview();
|
|
152
|
+
},
|
|
153
|
+
[
|
|
154
|
+
activeCompPath,
|
|
155
|
+
recordEdit,
|
|
156
|
+
timelineElements,
|
|
157
|
+
writeProjectFile,
|
|
158
|
+
domEditSaveTimestampRef,
|
|
159
|
+
reloadPreview,
|
|
160
|
+
],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const handleTimelineElementResize = useCallback(
|
|
164
|
+
async (
|
|
165
|
+
element: TimelineElement,
|
|
166
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
167
|
+
) => {
|
|
168
|
+
const pid = projectIdRef.current;
|
|
169
|
+
if (!pid) throw new Error("No active project");
|
|
170
|
+
|
|
171
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
172
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
173
|
+
|
|
174
|
+
const patchTarget = buildPatchTarget(element);
|
|
175
|
+
if (!patchTarget) {
|
|
176
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const playbackStartAttrName =
|
|
180
|
+
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
181
|
+
const currentPlaybackStartValue =
|
|
182
|
+
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
|
183
|
+
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
|
184
|
+
const currentPlaybackStart =
|
|
185
|
+
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
|
186
|
+
const trimDelta = updates.start - element.start;
|
|
187
|
+
const fallbackPlaybackStart =
|
|
188
|
+
updates.playbackStart == null &&
|
|
189
|
+
trimDelta !== 0 &&
|
|
190
|
+
Number.isFinite(currentPlaybackStart) &&
|
|
191
|
+
currentPlaybackStart != null
|
|
192
|
+
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
|
193
|
+
: undefined;
|
|
194
|
+
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
|
195
|
+
|
|
196
|
+
let patchedContent = originalContent;
|
|
197
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
198
|
+
type: "attribute",
|
|
199
|
+
property: "start",
|
|
200
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
201
|
+
});
|
|
202
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
203
|
+
type: "attribute",
|
|
204
|
+
property: "duration",
|
|
205
|
+
value: formatTimelineAttributeNumber(updates.duration),
|
|
206
|
+
});
|
|
207
|
+
if (nextPlaybackStart != null) {
|
|
208
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
209
|
+
type: "attribute",
|
|
210
|
+
property: playbackStartAttrName,
|
|
211
|
+
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (patchedContent === originalContent) {
|
|
216
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
220
|
+
await saveProjectFilesWithHistory({
|
|
221
|
+
projectId: pid,
|
|
222
|
+
label: "Resize timeline clip",
|
|
223
|
+
kind: "timeline",
|
|
224
|
+
files: { [targetPath]: patchedContent },
|
|
225
|
+
readFile: async () => originalContent,
|
|
226
|
+
writeFile: writeProjectFile,
|
|
227
|
+
recordEdit,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
reloadPreview();
|
|
231
|
+
},
|
|
232
|
+
[activeCompPath, recordEdit, writeProjectFile, domEditSaveTimestampRef, reloadPreview],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const handleTimelineElementDelete = useCallback(
|
|
236
|
+
async (element: TimelineElement) => {
|
|
237
|
+
const pid = projectIdRef.current;
|
|
238
|
+
if (!pid) throw new Error("No active project");
|
|
239
|
+
const label = getTimelineElementLabel(element);
|
|
240
|
+
|
|
241
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
242
|
+
try {
|
|
243
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
244
|
+
|
|
245
|
+
const patchTarget = buildPatchTarget(element);
|
|
246
|
+
if (!patchTarget) {
|
|
247
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
251
|
+
const remainingElements = timelineElements.filter(
|
|
252
|
+
(te) =>
|
|
253
|
+
(te.key ?? te.id) !== (element.key ?? element.id) &&
|
|
254
|
+
(te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
255
|
+
);
|
|
256
|
+
const trackZIndices = buildTrackZIndexMap(remainingElements.map((te) => te.track));
|
|
257
|
+
|
|
258
|
+
const removeResponse = await fetch(
|
|
259
|
+
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
260
|
+
{
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: { "Content-Type": "application/json" },
|
|
263
|
+
body: JSON.stringify({ target: patchTarget }),
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
if (!removeResponse.ok) {
|
|
267
|
+
throw new Error(`Failed to delete ${element.id} from ${targetPath}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const removeData = (await removeResponse.json()) as {
|
|
271
|
+
changed?: boolean;
|
|
272
|
+
content?: string;
|
|
273
|
+
};
|
|
274
|
+
let patchedContent =
|
|
275
|
+
typeof removeData.content === "string" ? removeData.content : originalContent;
|
|
276
|
+
for (const te of remainingElements) {
|
|
277
|
+
const elementTarget = buildPatchTarget(te);
|
|
278
|
+
if (!elementTarget) continue;
|
|
279
|
+
const nextZIndex = trackZIndices.get(te.track);
|
|
280
|
+
if (nextZIndex == null) continue;
|
|
281
|
+
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
282
|
+
type: "inline-style",
|
|
283
|
+
property: "z-index",
|
|
284
|
+
value: String(nextZIndex),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
289
|
+
await saveProjectFilesWithHistory({
|
|
290
|
+
projectId: pid,
|
|
291
|
+
label: "Delete timeline clip",
|
|
292
|
+
kind: "timeline",
|
|
293
|
+
files: { [targetPath]: patchedContent },
|
|
294
|
+
readFile: async () => originalContent,
|
|
295
|
+
writeFile: writeProjectFile,
|
|
296
|
+
recordEdit,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
usePlayerStore
|
|
300
|
+
.getState()
|
|
301
|
+
.setElements(
|
|
302
|
+
timelineElements.filter((te) => (te.key ?? te.id) !== (element.key ?? element.id)),
|
|
303
|
+
);
|
|
304
|
+
usePlayerStore.getState().setSelectedElementId(null);
|
|
305
|
+
reloadPreview();
|
|
306
|
+
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
|
|
309
|
+
showToast(message);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
[
|
|
313
|
+
activeCompPath,
|
|
314
|
+
recordEdit,
|
|
315
|
+
showToast,
|
|
316
|
+
timelineElements,
|
|
317
|
+
writeProjectFile,
|
|
318
|
+
domEditSaveTimestampRef,
|
|
319
|
+
reloadPreview,
|
|
320
|
+
],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const handleTimelineAssetDrop = useCallback(
|
|
324
|
+
async (
|
|
325
|
+
assetPath: string,
|
|
326
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
327
|
+
durationOverride?: number,
|
|
328
|
+
) => {
|
|
329
|
+
const pid = projectIdRef.current;
|
|
330
|
+
if (!pid) throw new Error("No active project");
|
|
331
|
+
|
|
332
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
333
|
+
if (!kind) {
|
|
334
|
+
showToast("Only image, video, and audio assets can be dropped onto the timeline.");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const targetPath = activeCompPath || "index.html";
|
|
339
|
+
try {
|
|
340
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
341
|
+
|
|
342
|
+
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
343
|
+
const duration =
|
|
344
|
+
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
345
|
+
? durationOverride
|
|
346
|
+
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
347
|
+
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
348
|
+
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
349
|
+
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
350
|
+
|
|
351
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
352
|
+
const relevantElements = timelineElements.filter(
|
|
353
|
+
(te) => (te.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
354
|
+
);
|
|
355
|
+
const trackZIndices = buildTrackZIndexMap([
|
|
356
|
+
...relevantElements.map((te) => te.track),
|
|
357
|
+
placement.track,
|
|
358
|
+
]);
|
|
359
|
+
|
|
360
|
+
let patchedContent = originalContent;
|
|
361
|
+
for (const te of relevantElements) {
|
|
362
|
+
const elementTarget = buildPatchTarget(te);
|
|
363
|
+
if (!elementTarget) continue;
|
|
364
|
+
const nextZIndex = trackZIndices.get(te.track);
|
|
365
|
+
if (nextZIndex == null) continue;
|
|
366
|
+
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
367
|
+
type: "inline-style",
|
|
368
|
+
property: "z-index",
|
|
369
|
+
value: String(nextZIndex),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
patchedContent = insertTimelineAssetIntoSource(
|
|
374
|
+
patchedContent,
|
|
375
|
+
buildTimelineAssetInsertHtml({
|
|
376
|
+
id: newId,
|
|
377
|
+
assetPath: resolvedAssetSrc,
|
|
378
|
+
kind,
|
|
379
|
+
start: normalizedStart,
|
|
380
|
+
duration: normalizedDuration,
|
|
381
|
+
track: placement.track,
|
|
382
|
+
zIndex: trackZIndices.get(placement.track) ?? 1,
|
|
383
|
+
geometry: resolveTimelineAssetInitialGeometry(originalContent),
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
388
|
+
await saveProjectFilesWithHistory({
|
|
389
|
+
projectId: pid,
|
|
390
|
+
label: "Add timeline asset",
|
|
391
|
+
kind: "timeline",
|
|
392
|
+
files: { [targetPath]: patchedContent },
|
|
393
|
+
readFile: async () => originalContent,
|
|
394
|
+
writeFile: writeProjectFile,
|
|
395
|
+
recordEdit,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
reloadPreview();
|
|
399
|
+
} catch (error) {
|
|
400
|
+
const message =
|
|
401
|
+
error instanceof Error ? error.message : "Failed to drop asset onto timeline";
|
|
402
|
+
showToast(message);
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
[
|
|
406
|
+
activeCompPath,
|
|
407
|
+
recordEdit,
|
|
408
|
+
showToast,
|
|
409
|
+
timelineElements,
|
|
410
|
+
writeProjectFile,
|
|
411
|
+
domEditSaveTimestampRef,
|
|
412
|
+
reloadPreview,
|
|
413
|
+
],
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const handleTimelineFileDrop = useCallback(
|
|
417
|
+
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
418
|
+
const pid = projectIdRef.current;
|
|
419
|
+
if (!pid) return;
|
|
420
|
+
const uploaded = await uploadProjectFiles(files);
|
|
421
|
+
if (uploaded.length === 0) return;
|
|
422
|
+
const durations: number[] = [];
|
|
423
|
+
for (const assetPath of uploaded) {
|
|
424
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
425
|
+
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
|
426
|
+
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
|
427
|
+
}
|
|
428
|
+
const placements = buildTimelineFileDropPlacements(
|
|
429
|
+
placement ?? { start: 0, track: 0 },
|
|
430
|
+
durations,
|
|
431
|
+
timelineElements
|
|
432
|
+
.filter(
|
|
433
|
+
(te) =>
|
|
434
|
+
(te.sourceFile || activeCompPath || "index.html") ===
|
|
435
|
+
(activeCompPath || "index.html"),
|
|
436
|
+
)
|
|
437
|
+
.map((te) => ({
|
|
438
|
+
start: te.start,
|
|
439
|
+
duration: te.duration,
|
|
440
|
+
track: te.track,
|
|
441
|
+
})),
|
|
442
|
+
);
|
|
443
|
+
for (const [index, assetPath] of uploaded.entries()) {
|
|
444
|
+
await handleTimelineAssetDrop(
|
|
445
|
+
assetPath,
|
|
446
|
+
placements[index] ?? placements[0],
|
|
447
|
+
durations[index],
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const handleBlockedTimelineEdit = useCallback(
|
|
455
|
+
(_element: TimelineElement) => {
|
|
456
|
+
const now = Date.now();
|
|
457
|
+
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
|
|
458
|
+
lastBlockedTimelineToastAtRef.current = now;
|
|
459
|
+
showToast("This clip can't be moved or resized from the timeline yet.", "info");
|
|
460
|
+
},
|
|
461
|
+
[showToast],
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
handleTimelineElementMove,
|
|
466
|
+
handleTimelineElementResize,
|
|
467
|
+
handleTimelineElementDelete,
|
|
468
|
+
handleTimelineAssetDrop,
|
|
469
|
+
handleTimelineFileDrop,
|
|
470
|
+
handleBlockedTimelineEdit,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
@@ -36,6 +36,8 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
36
36
|
return state.loading === true && state.ready !== true;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
40
|
+
|
|
39
41
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
40
42
|
return compositionLoading;
|
|
41
43
|
}
|
|
@@ -124,6 +126,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
124
126
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
125
127
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
126
128
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
129
|
+
const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
|
|
130
|
+
|
|
131
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!compositionLoading) {
|
|
134
|
+
setCompositionOverlayDeferred(true);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const timer = setTimeout(
|
|
138
|
+
() => setCompositionOverlayDeferred(false),
|
|
139
|
+
COMPOSITION_LOADING_OVERLAY_DELAY_MS,
|
|
140
|
+
);
|
|
141
|
+
return () => clearTimeout(timer);
|
|
142
|
+
}, [compositionLoading]);
|
|
127
143
|
|
|
128
144
|
useMountEffect(() => {
|
|
129
145
|
const container = containerRef.current;
|
|
@@ -281,7 +297,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
281
297
|
}, [assetsLoading]);
|
|
282
298
|
|
|
283
299
|
const showCompositionOverlay =
|
|
284
|
-
!suppressLoadingOverlay &&
|
|
300
|
+
!suppressLoadingOverlay &&
|
|
301
|
+
!compositionOverlayDeferred &&
|
|
302
|
+
shouldShowCompositionLoadingOverlay(compositionLoading);
|
|
285
303
|
const showAssetOverlay =
|
|
286
304
|
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
|
|
287
305
|
|
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
shouldHandleTimelineDeleteKey,
|
|
13
13
|
shouldAutoScrollTimeline,
|
|
14
14
|
} from "./Timeline";
|
|
15
|
-
import { TIMELINE_CLIP_CONTROL_Z_INDEX } from "./TimelineClip";
|
|
16
|
-
import { COMPOSITION_THUMBNAIL_LABEL_Z_INDEX } from "./CompositionThumbnail";
|
|
17
15
|
import { formatTime } from "../lib/time";
|
|
18
16
|
|
|
19
17
|
describe("generateTicks", () => {
|
|
@@ -166,12 +164,6 @@ describe("shouldAutoScrollTimeline", () => {
|
|
|
166
164
|
});
|
|
167
165
|
});
|
|
168
166
|
|
|
169
|
-
describe("timeline clip controls", () => {
|
|
170
|
-
it("renders layer controls above composition thumbnail chrome", () => {
|
|
171
|
-
expect(TIMELINE_CLIP_CONTROL_Z_INDEX).toBeGreaterThan(COMPOSITION_THUMBNAIL_LABEL_Z_INDEX);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
|
|
175
167
|
describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
176
168
|
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
|
|
177
169
|
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|