@hyperframes/studio 0.6.0-alpha.9 → 0.6.1
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-CzwFysqv.js +418 -0
- package/dist/assets/index-D1JDq7Gg.css +1 -0
- package/dist/assets/index-hYc4aP7M.js +117 -0
- package/dist/favicon.svg +14 -0
- package/dist/index.html +3 -2
- package/package.json +9 -9
- package/src/App.tsx +421 -4303
- package/src/captions/components/CaptionOverlay.tsx +13 -246
- package/src/captions/components/CaptionOverlayUtils.ts +221 -0
- 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 +167 -0
- package/src/components/StudioRightPanel.tsx +198 -0
- package/src/components/TimelineToolbar.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +88 -993
- package/src/components/editor/EaseCurveEditor.tsx +221 -0
- package/src/components/editor/FileTree.tsx +13 -621
- package/src/components/editor/FileTreeIcons.tsx +128 -0
- package/src/components/editor/FileTreeNodes.tsx +496 -0
- package/src/components/editor/MotionPanel.tsx +16 -390
- package/src/components/editor/MotionPanelFields.tsx +185 -0
- package/src/components/editor/PropertyPanel.test.ts +0 -49
- package/src/components/editor/PropertyPanel.tsx +132 -2763
- package/src/components/editor/domEditOverlayGeometry.ts +211 -0
- package/src/components/editor/domEditOverlayGestures.ts +138 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
- package/src/components/editor/domEditing.ts +44 -1117
- package/src/components/editor/domEditingAgentPrompt.ts +97 -0
- package/src/components/editor/domEditingDom.ts +266 -0
- package/src/components/editor/domEditingElement.ts +329 -0
- package/src/components/editor/domEditingLayers.ts +460 -0
- package/src/components/editor/domEditingTypes.ts +125 -0
- package/src/components/editor/manualEditingAvailability.test.ts +2 -2
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +84 -1049
- package/src/components/editor/manualEditsDom.ts +436 -0
- package/src/components/editor/manualEditsParsing.ts +280 -0
- package/src/components/editor/manualEditsSnapshot.ts +333 -0
- package/src/components/editor/manualEditsTypes.ts +141 -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/editor/studioMotion.ts +47 -434
- package/src/components/editor/studioMotionOps.ts +299 -0
- package/src/components/editor/studioMotionTypes.ts +168 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
- package/src/components/editor/useDomEditOverlayRects.ts +207 -0
- package/src/components/nle/NLELayout.tsx +68 -155
- package/src/components/nle/NLEPreview.tsx +3 -0
- package/src/components/nle/useCompositionStack.ts +126 -0
- package/src/components/renders/RenderQueue.tsx +102 -31
- package/src/components/renders/useRenderQueue.ts +8 -2
- package/src/components/sidebar/LeftSidebar.tsx +186 -186
- 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/hooks/useToast.ts +20 -0
- package/src/player/components/Player.tsx +33 -2
- package/src/player/components/Timeline.test.ts +0 -8
- package/src/player/components/Timeline.tsx +196 -1518
- package/src/player/components/TimelineCanvas.tsx +434 -0
- package/src/player/components/TimelineClip.tsx +9 -244
- package/src/player/components/TimelineEmptyState.tsx +102 -0
- package/src/player/components/TimelineRuler.tsx +90 -0
- package/src/player/components/timelineIcons.tsx +49 -0
- package/src/player/components/timelineLayout.ts +215 -0
- package/src/player/components/timelineUtils.ts +211 -0
- package/src/player/components/useTimelineClipDrag.ts +388 -0
- package/src/player/components/useTimelinePlayhead.ts +200 -0
- package/src/player/components/useTimelineRangeSelection.ts +135 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
- package/src/player/hooks/useTimelinePlayer.ts +105 -1371
- package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
- package/src/player/lib/playbackAdapter.ts +145 -0
- package/src/player/lib/playbackShortcuts.ts +68 -0
- package/src/player/lib/playbackTypes.ts +60 -0
- package/src/player/lib/timelineDOM.ts +373 -0
- package/src/player/lib/timelineElementHelpers.ts +303 -0
- package/src/player/lib/timelineIframeHelpers.ts +269 -0
- 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/src/utils/timelineDiscovery.ts +1 -1
- package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
- package/dist/assets/index-14zH9lqh.css +0 -1
- package/dist/assets/index-DYCiFGWQ.js +0 -108
- 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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { useMountEffect } from "./useMountEffect";
|
|
3
|
+
import type { AppToast } from "../utils/studioHelpers";
|
|
4
|
+
|
|
5
|
+
export function useToast() {
|
|
6
|
+
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
|
7
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
8
|
+
|
|
9
|
+
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
|
10
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
11
|
+
setAppToast({ message, tone });
|
|
12
|
+
timerRef.current = setTimeout(() => setAppToast(null), 4000);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
useMountEffect(() => () => {
|
|
16
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return { appToast, showToast };
|
|
20
|
+
}
|
|
@@ -13,6 +13,7 @@ interface PlayerProps {
|
|
|
13
13
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
14
14
|
portrait?: boolean;
|
|
15
15
|
style?: React.CSSProperties;
|
|
16
|
+
suppressLoadingOverlay?: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
interface HyperframesPlayerElement extends HTMLElement {
|
|
@@ -35,6 +36,8 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
|
|
35
36
|
return state.loading === true && state.ready !== true;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
const COMPOSITION_LOADING_OVERLAY_DELAY_MS = 400;
|
|
40
|
+
|
|
38
41
|
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
|
39
42
|
return compositionLoading;
|
|
40
43
|
}
|
|
@@ -102,7 +105,18 @@ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean
|
|
|
102
105
|
* timeline probing, and DOM inspection.
|
|
103
106
|
*/
|
|
104
107
|
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
105
|
-
(
|
|
108
|
+
(
|
|
109
|
+
{
|
|
110
|
+
projectId,
|
|
111
|
+
directUrl,
|
|
112
|
+
onLoad,
|
|
113
|
+
onCompositionLoadingChange,
|
|
114
|
+
portrait,
|
|
115
|
+
style,
|
|
116
|
+
suppressLoadingOverlay,
|
|
117
|
+
},
|
|
118
|
+
ref,
|
|
119
|
+
) => {
|
|
106
120
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
121
|
const loadCountRef = useRef(0);
|
|
108
122
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -112,6 +126,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
112
126
|
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
|
113
127
|
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
|
114
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]);
|
|
115
143
|
|
|
116
144
|
useMountEffect(() => {
|
|
117
145
|
const container = containerRef.current;
|
|
@@ -268,7 +296,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
268
296
|
};
|
|
269
297
|
}, [assetsLoading]);
|
|
270
298
|
|
|
271
|
-
const showCompositionOverlay =
|
|
299
|
+
const showCompositionOverlay =
|
|
300
|
+
!suppressLoadingOverlay &&
|
|
301
|
+
!compositionOverlayDeferred &&
|
|
302
|
+
shouldShowCompositionLoadingOverlay(compositionLoading);
|
|
272
303
|
const showAssetOverlay =
|
|
273
304
|
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
|
|
274
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);
|