@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
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-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +1120 -0
- package/src/components/editor/domEditing.ts +1117 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +194 -179
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildEditHistoryEntry,
|
|
4
|
+
createEmptyEditHistory,
|
|
5
|
+
hashEditHistoryContent,
|
|
6
|
+
pushEditHistoryEntry,
|
|
7
|
+
redoEditHistory,
|
|
8
|
+
undoEditHistory,
|
|
9
|
+
type BuildEditHistoryEntryInput,
|
|
10
|
+
type EditHistoryKind,
|
|
11
|
+
type EditHistoryState,
|
|
12
|
+
} from "../utils/editHistory";
|
|
13
|
+
import {
|
|
14
|
+
createIndexedDbEditHistoryStorage,
|
|
15
|
+
loadEditHistoryState,
|
|
16
|
+
saveEditHistoryState,
|
|
17
|
+
type EditHistoryStorageAdapter,
|
|
18
|
+
} from "../utils/editHistoryStorage";
|
|
19
|
+
|
|
20
|
+
interface RecordEditInput {
|
|
21
|
+
label: string;
|
|
22
|
+
kind: EditHistoryKind;
|
|
23
|
+
coalesceKey?: string;
|
|
24
|
+
files: BuildEditHistoryEntryInput["files"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ApplyCallbacks {
|
|
28
|
+
readFile: (path: string) => Promise<string>;
|
|
29
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface UsePersistentEditHistoryOptions {
|
|
33
|
+
projectId: string | null;
|
|
34
|
+
storage?: EditHistoryStorageAdapter;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ApplyResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
reason?: "empty" | "content-mismatch";
|
|
41
|
+
label?: string;
|
|
42
|
+
paths?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PersistentEditHistoryStoreOptions {
|
|
46
|
+
projectId: string;
|
|
47
|
+
storage: EditHistoryStorageAdapter;
|
|
48
|
+
initialState: EditHistoryState;
|
|
49
|
+
now?: () => number;
|
|
50
|
+
onChange: (state: EditHistoryState) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
|
|
54
|
+
state: EditHistoryState;
|
|
55
|
+
result: T;
|
|
56
|
+
}>;
|
|
57
|
+
|
|
58
|
+
function createEntryId(now: number): string {
|
|
59
|
+
return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function snapshotEditHistoryState(state: EditHistoryState) {
|
|
63
|
+
const undoEntry = state.undo[state.undo.length - 1] ?? null;
|
|
64
|
+
const redoEntry = state.redo[state.redo.length - 1] ?? null;
|
|
65
|
+
return {
|
|
66
|
+
canUndo: Boolean(undoEntry),
|
|
67
|
+
canRedo: Boolean(redoEntry),
|
|
68
|
+
undoLabel: undoEntry?.label ?? null,
|
|
69
|
+
redoLabel: redoEntry?.label ?? null,
|
|
70
|
+
undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
|
|
71
|
+
redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
|
|
72
|
+
state,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readCurrentFileHashes(
|
|
77
|
+
paths: string[],
|
|
78
|
+
readFile: (path: string) => Promise<string>,
|
|
79
|
+
): Promise<{
|
|
80
|
+
currentFiles: Record<string, string>;
|
|
81
|
+
currentHashes: Record<string, string>;
|
|
82
|
+
}> {
|
|
83
|
+
const currentFiles: Record<string, string> = {};
|
|
84
|
+
const currentHashes: Record<string, string> = {};
|
|
85
|
+
for (const path of paths) {
|
|
86
|
+
const content = await readFile(path);
|
|
87
|
+
currentFiles[path] = content;
|
|
88
|
+
currentHashes[path] = hashEditHistoryContent(content);
|
|
89
|
+
}
|
|
90
|
+
return { currentFiles, currentHashes };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeFilesWithRollback({
|
|
94
|
+
files,
|
|
95
|
+
rollbackFiles,
|
|
96
|
+
writeFile,
|
|
97
|
+
}: {
|
|
98
|
+
files: Record<string, string>;
|
|
99
|
+
rollbackFiles: Record<string, string>;
|
|
100
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
101
|
+
}): Promise<void> {
|
|
102
|
+
const writtenPaths: string[] = [];
|
|
103
|
+
try {
|
|
104
|
+
for (const [path, content] of Object.entries(files)) {
|
|
105
|
+
await writeFile(path, content);
|
|
106
|
+
writtenPaths.push(path);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
try {
|
|
110
|
+
for (const path of writtenPaths.reverse()) {
|
|
111
|
+
await writeFile(path, rollbackFiles[path]);
|
|
112
|
+
}
|
|
113
|
+
} catch (rollbackError) {
|
|
114
|
+
throw new AggregateError(
|
|
115
|
+
[error, rollbackError],
|
|
116
|
+
"Failed to apply edit history and rollback did not complete",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createPersistentEditHistoryStore({
|
|
124
|
+
projectId,
|
|
125
|
+
storage,
|
|
126
|
+
initialState,
|
|
127
|
+
now = Date.now,
|
|
128
|
+
onChange,
|
|
129
|
+
}: PersistentEditHistoryStoreOptions) {
|
|
130
|
+
let state = initialState;
|
|
131
|
+
let queue = Promise.resolve();
|
|
132
|
+
|
|
133
|
+
const save = async (nextState: EditHistoryState) => {
|
|
134
|
+
state = nextState;
|
|
135
|
+
onChange(nextState);
|
|
136
|
+
try {
|
|
137
|
+
await saveEditHistoryState(storage, projectId, nextState);
|
|
138
|
+
} catch {
|
|
139
|
+
// Keep in-memory history usable when IndexedDB is unavailable.
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
|
|
144
|
+
const run = queue.then(async () => {
|
|
145
|
+
const { state: nextState, result } = await mutation(state);
|
|
146
|
+
if (nextState !== state) await save(nextState);
|
|
147
|
+
return result;
|
|
148
|
+
});
|
|
149
|
+
queue = run.then(
|
|
150
|
+
() => undefined,
|
|
151
|
+
() => undefined,
|
|
152
|
+
);
|
|
153
|
+
return run;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
snapshot: () => snapshotEditHistoryState(state),
|
|
158
|
+
async recordEdit(input: RecordEditInput) {
|
|
159
|
+
await mutate<void>(async (currentState) => {
|
|
160
|
+
const timestamp = now();
|
|
161
|
+
const entry = buildEditHistoryEntry({
|
|
162
|
+
...input,
|
|
163
|
+
id: createEntryId(timestamp),
|
|
164
|
+
projectId,
|
|
165
|
+
now: timestamp,
|
|
166
|
+
});
|
|
167
|
+
return {
|
|
168
|
+
state: pushEditHistoryEntry(currentState, entry),
|
|
169
|
+
result: undefined,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
174
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
175
|
+
const entry = currentState.undo[currentState.undo.length - 1];
|
|
176
|
+
if (!entry) {
|
|
177
|
+
return {
|
|
178
|
+
state: currentState,
|
|
179
|
+
result: { ok: false, reason: "empty" },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
183
|
+
Object.keys(entry.files),
|
|
184
|
+
callbacks.readFile,
|
|
185
|
+
);
|
|
186
|
+
const result = undoEditHistory(currentState, currentHashes, now());
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
return {
|
|
189
|
+
state: currentState,
|
|
190
|
+
result: { ok: false, reason: result.reason },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
await writeFilesWithRollback({
|
|
194
|
+
files: result.filesToWrite,
|
|
195
|
+
rollbackFiles: currentFiles,
|
|
196
|
+
writeFile: callbacks.writeFile,
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
state: result.state,
|
|
200
|
+
result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
205
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
206
|
+
const entry = currentState.redo[currentState.redo.length - 1];
|
|
207
|
+
if (!entry) {
|
|
208
|
+
return {
|
|
209
|
+
state: currentState,
|
|
210
|
+
result: { ok: false, reason: "empty" },
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
214
|
+
Object.keys(entry.files),
|
|
215
|
+
callbacks.readFile,
|
|
216
|
+
);
|
|
217
|
+
const result = redoEditHistory(currentState, currentHashes, now());
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
return {
|
|
220
|
+
state: currentState,
|
|
221
|
+
result: { ok: false, reason: result.reason },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
await writeFilesWithRollback({
|
|
225
|
+
files: result.filesToWrite,
|
|
226
|
+
rollbackFiles: currentFiles,
|
|
227
|
+
writeFile: callbacks.writeFile,
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
state: result.state,
|
|
231
|
+
result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function createPersistentEditHistoryController({
|
|
239
|
+
projectId,
|
|
240
|
+
storage,
|
|
241
|
+
now = Date.now,
|
|
242
|
+
onChange,
|
|
243
|
+
}: {
|
|
244
|
+
projectId: string;
|
|
245
|
+
storage: EditHistoryStorageAdapter;
|
|
246
|
+
now?: () => number;
|
|
247
|
+
onChange: (state: EditHistoryState) => void;
|
|
248
|
+
}) {
|
|
249
|
+
let state = await loadEditHistoryState(storage, projectId);
|
|
250
|
+
const store = createPersistentEditHistoryStore({
|
|
251
|
+
projectId,
|
|
252
|
+
storage,
|
|
253
|
+
initialState: state,
|
|
254
|
+
now,
|
|
255
|
+
onChange: (nextState) => {
|
|
256
|
+
state = nextState;
|
|
257
|
+
onChange(nextState);
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return store;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
|
|
265
|
+
const storage = useMemo(
|
|
266
|
+
() => options.storage ?? createIndexedDbEditHistoryStorage(),
|
|
267
|
+
[options.storage],
|
|
268
|
+
);
|
|
269
|
+
const now = options.now ?? Date.now;
|
|
270
|
+
const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
|
|
271
|
+
const [loaded, setLoaded] = useState(false);
|
|
272
|
+
const projectId = options.projectId;
|
|
273
|
+
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
|
274
|
+
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
let cancelled = false;
|
|
277
|
+
const emptyState = createEmptyEditHistory();
|
|
278
|
+
storeRef.current = null;
|
|
279
|
+
setState(emptyState);
|
|
280
|
+
setLoaded(false);
|
|
281
|
+
if (!projectId) {
|
|
282
|
+
setLoaded(true);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
loadEditHistoryState(storage, projectId)
|
|
287
|
+
.then((loadedState) => {
|
|
288
|
+
if (cancelled) return;
|
|
289
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
290
|
+
projectId,
|
|
291
|
+
storage,
|
|
292
|
+
initialState: loadedState,
|
|
293
|
+
now,
|
|
294
|
+
onChange: setState,
|
|
295
|
+
});
|
|
296
|
+
setState(loadedState);
|
|
297
|
+
})
|
|
298
|
+
.catch(() => {
|
|
299
|
+
if (cancelled) return;
|
|
300
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
301
|
+
projectId,
|
|
302
|
+
storage,
|
|
303
|
+
initialState: emptyState,
|
|
304
|
+
now,
|
|
305
|
+
onChange: setState,
|
|
306
|
+
});
|
|
307
|
+
setState(emptyState);
|
|
308
|
+
})
|
|
309
|
+
.finally(() => {
|
|
310
|
+
if (!cancelled) setLoaded(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
return () => {
|
|
314
|
+
cancelled = true;
|
|
315
|
+
};
|
|
316
|
+
}, [now, projectId, storage]);
|
|
317
|
+
|
|
318
|
+
const recordEdit = useCallback(async (input: RecordEditInput) => {
|
|
319
|
+
await storeRef.current?.recordEdit(input);
|
|
320
|
+
}, []);
|
|
321
|
+
|
|
322
|
+
const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
323
|
+
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
|
324
|
+
}, []);
|
|
325
|
+
|
|
326
|
+
const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
327
|
+
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
|
328
|
+
}, []);
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
loaded,
|
|
332
|
+
...snapshotEditHistoryState(state),
|
|
333
|
+
recordEdit,
|
|
334
|
+
undo,
|
|
335
|
+
redo,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
ClipboardText,
|
|
55
55
|
ArrowCounterClockwise,
|
|
56
56
|
Camera as PhCamera,
|
|
57
|
+
ArrowClockwise,
|
|
57
58
|
Gear,
|
|
58
59
|
} from "@phosphor-icons/react";
|
|
59
60
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
@@ -129,4 +130,5 @@ export const ChevronRight = makeIcon(CaretRight);
|
|
|
129
130
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
130
131
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
131
132
|
export const Camera = makeIcon(PhCamera);
|
|
133
|
+
export const RotateCw = makeIcon(ArrowClockwise);
|
|
132
134
|
export const Settings = makeIcon(Gear);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
|
|
3
|
+
|
|
4
|
+
describe("buildCompositionThumbnailUrl", () => {
|
|
5
|
+
it("includes selector and occurrence index for precise element thumbnails", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCompositionThumbnailUrl({
|
|
8
|
+
previewUrl: "/api/projects/demo/preview",
|
|
9
|
+
seekTime: 1,
|
|
10
|
+
duration: 2,
|
|
11
|
+
selector: ".card",
|
|
12
|
+
selectorIndex: 2,
|
|
13
|
+
origin: "http://localhost:3000",
|
|
14
|
+
}),
|
|
15
|
+
).toBe(
|
|
16
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
|
|
|
7
7
|
labelColor: string;
|
|
8
8
|
accentColor?: string;
|
|
9
9
|
selector?: string;
|
|
10
|
+
selectorIndex?: number;
|
|
10
11
|
seekTime?: number;
|
|
11
12
|
duration?: number;
|
|
12
13
|
width?: number;
|
|
@@ -14,7 +15,39 @@ interface CompositionThumbnailProps {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
const CLIP_HEIGHT = 66;
|
|
17
|
-
const THUMBNAIL_URL_VERSION = "
|
|
18
|
+
const THUMBNAIL_URL_VERSION = "v3";
|
|
19
|
+
export const COMPOSITION_THUMBNAIL_LABEL_Z_INDEX = 10;
|
|
20
|
+
|
|
21
|
+
export function buildCompositionThumbnailUrl({
|
|
22
|
+
previewUrl,
|
|
23
|
+
seekTime = 2,
|
|
24
|
+
duration = 5,
|
|
25
|
+
selector,
|
|
26
|
+
selectorIndex,
|
|
27
|
+
origin,
|
|
28
|
+
}: {
|
|
29
|
+
previewUrl: string;
|
|
30
|
+
seekTime?: number;
|
|
31
|
+
duration?: number;
|
|
32
|
+
selector?: string;
|
|
33
|
+
selectorIndex?: number;
|
|
34
|
+
origin: string;
|
|
35
|
+
}): string {
|
|
36
|
+
const thumbnailBase = previewUrl
|
|
37
|
+
.replace("/preview/comp/", "/thumbnail/")
|
|
38
|
+
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
39
|
+
const midTime = seekTime + duration / 2;
|
|
40
|
+
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
41
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
42
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
43
|
+
if (selector) {
|
|
44
|
+
thumbnailUrl.searchParams.set("selector", selector);
|
|
45
|
+
if (selectorIndex != null && selectorIndex > 0) {
|
|
46
|
+
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return thumbnailUrl.toString();
|
|
50
|
+
}
|
|
18
51
|
|
|
19
52
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
20
53
|
previewUrl,
|
|
@@ -22,6 +55,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
22
55
|
labelColor,
|
|
23
56
|
accentColor = "#6B7280",
|
|
24
57
|
selector,
|
|
58
|
+
selectorIndex,
|
|
25
59
|
seekTime = 2,
|
|
26
60
|
duration = 5,
|
|
27
61
|
}: CompositionThumbnailProps) {
|
|
@@ -48,15 +82,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
48
82
|
roRef.current?.disconnect();
|
|
49
83
|
});
|
|
50
84
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const url = thumbnailUrl.toString();
|
|
85
|
+
const url = buildCompositionThumbnailUrl({
|
|
86
|
+
previewUrl,
|
|
87
|
+
seekTime,
|
|
88
|
+
duration,
|
|
89
|
+
selector,
|
|
90
|
+
selectorIndex,
|
|
91
|
+
origin: window.location.origin,
|
|
92
|
+
});
|
|
60
93
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
61
94
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
62
95
|
|
|
@@ -66,7 +99,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
66
99
|
src={url}
|
|
67
100
|
alt=""
|
|
68
101
|
draggable={false}
|
|
69
|
-
loading="
|
|
102
|
+
loading="eager"
|
|
70
103
|
onLoad={(e) => {
|
|
71
104
|
const img = e.currentTarget;
|
|
72
105
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
@@ -111,7 +144,10 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
111
144
|
}}
|
|
112
145
|
/>
|
|
113
146
|
|
|
114
|
-
<div
|
|
147
|
+
<div
|
|
148
|
+
className="absolute left-2 top-2"
|
|
149
|
+
style={{ zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX }}
|
|
150
|
+
>
|
|
115
151
|
<span
|
|
116
152
|
className="block max-w-full truncate rounded-md px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none"
|
|
117
153
|
style={{
|
|
@@ -125,8 +161,9 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
125
161
|
</div>
|
|
126
162
|
|
|
127
163
|
<div
|
|
128
|
-
className="absolute bottom-0 left-0 right-0
|
|
164
|
+
className="absolute bottom-0 left-0 right-0 px-1.5 pb-0.5 pt-3"
|
|
129
165
|
style={{
|
|
166
|
+
zIndex: COMPOSITION_THUMBNAIL_LABEL_Z_INDEX,
|
|
130
167
|
background:
|
|
131
168
|
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
132
169
|
}}
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
7
|
|
|
7
8
|
interface EditPopoverProps {
|
|
8
9
|
rangeStart: number;
|
|
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
62
63
|
}, [start, end, elementsInRange, prompt]);
|
|
63
64
|
|
|
64
65
|
const handleCopy = useCallback(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {
|
|
68
|
-
const ta = document.createElement("textarea");
|
|
69
|
-
ta.value = buildClipboardText();
|
|
70
|
-
document.body.appendChild(ta);
|
|
71
|
-
ta.select();
|
|
72
|
-
document.execCommand("copy");
|
|
73
|
-
document.body.removeChild(ta);
|
|
74
|
-
}
|
|
66
|
+
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
+
if (!copied) return;
|
|
75
68
|
setCopiedAgentPrompt(true);
|
|
76
69
|
setTimeout(() => {
|
|
77
70
|
setCopiedAgentPrompt(false);
|
|
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
82
75
|
const handleCopyPrompt = useCallback(async () => {
|
|
83
76
|
const promptText = buildPromptCopyText(prompt);
|
|
84
77
|
if (!promptText) return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
const ta = document.createElement("textarea");
|
|
89
|
-
ta.value = promptText;
|
|
90
|
-
document.body.appendChild(ta);
|
|
91
|
-
ta.select();
|
|
92
|
-
document.execCommand("copy");
|
|
93
|
-
document.body.removeChild(ta);
|
|
94
|
-
}
|
|
78
|
+
const copied = await copyTextToClipboard(promptText);
|
|
79
|
+
if (!copied) return;
|
|
95
80
|
setCopiedPromptOnly(true);
|
|
96
81
|
setTimeout(() => {
|
|
97
82
|
setCopiedPromptOnly(false);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { hasUnloadedAssets, shouldShowCompositionLoadingOverlay } from "./Player";
|
|
5
|
+
|
|
6
|
+
describe("composition loading overlay", () => {
|
|
7
|
+
it("shows while the composition is loading", () => {
|
|
8
|
+
expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("hides after the composition is ready", () => {
|
|
12
|
+
expect(shouldShowCompositionLoadingOverlay(false)).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("keeps the asset overlay up while media is still buffering", () => {
|
|
16
|
+
const iframe = document.createElement("iframe");
|
|
17
|
+
document.body.appendChild(iframe);
|
|
18
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
19
|
+
expect(audio).toBeDefined();
|
|
20
|
+
Object.defineProperty(audio, "readyState", {
|
|
21
|
+
value: 0,
|
|
22
|
+
configurable: true,
|
|
23
|
+
});
|
|
24
|
+
Object.defineProperty(audio, "networkState", {
|
|
25
|
+
value: 2,
|
|
26
|
+
configurable: true,
|
|
27
|
+
});
|
|
28
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
29
|
+
|
|
30
|
+
expect(hasUnloadedAssets(iframe, false)).toBe(true);
|
|
31
|
+
|
|
32
|
+
iframe.remove();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("does not keep the asset overlay stuck on failed media sources", () => {
|
|
36
|
+
const iframe = document.createElement("iframe");
|
|
37
|
+
document.body.appendChild(iframe);
|
|
38
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
39
|
+
expect(audio).toBeDefined();
|
|
40
|
+
Object.defineProperty(audio, "error", {
|
|
41
|
+
value: { code: 4, message: "format error" },
|
|
42
|
+
configurable: true,
|
|
43
|
+
});
|
|
44
|
+
Object.defineProperty(audio, "readyState", {
|
|
45
|
+
value: 0,
|
|
46
|
+
configurable: true,
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(audio, "networkState", {
|
|
49
|
+
value: 3,
|
|
50
|
+
configurable: true,
|
|
51
|
+
});
|
|
52
|
+
iframe.contentDocument?.body.appendChild(audio!);
|
|
53
|
+
|
|
54
|
+
expect(hasUnloadedAssets(iframe, false)).toBe(false);
|
|
55
|
+
|
|
56
|
+
iframe.remove();
|
|
57
|
+
});
|
|
58
|
+
});
|