@hyperframes/studio 0.5.5 → 0.6.0-alpha.2

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.
Files changed (74) hide show
  1. package/dist/assets/hyperframes-player-Cd8vYWxP.js +198 -0
  2. package/dist/assets/index-UWFaHilT.css +1 -0
  3. package/dist/assets/index-cPJbxeAk.js +107 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2621 -170
  7. package/src/components/LintModal.tsx +3 -4
  8. package/src/components/editor/DomEditOverlay.test.ts +241 -0
  9. package/src/components/editor/DomEditOverlay.tsx +1300 -0
  10. package/src/components/editor/MotionPanel.tsx +651 -0
  11. package/src/components/editor/PropertyPanel.test.ts +67 -0
  12. package/src/components/editor/PropertyPanel.tsx +2891 -207
  13. package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
  14. package/src/components/editor/TimelineLayerPanel.tsx +113 -0
  15. package/src/components/editor/colorValue.test.ts +82 -0
  16. package/src/components/editor/colorValue.ts +175 -0
  17. package/src/components/editor/domEditing.test.ts +872 -0
  18. package/src/components/editor/domEditing.ts +993 -0
  19. package/src/components/editor/floatingPanel.test.ts +34 -0
  20. package/src/components/editor/floatingPanel.ts +54 -0
  21. package/src/components/editor/fontAssets.ts +32 -0
  22. package/src/components/editor/fontCatalog.ts +126 -0
  23. package/src/components/editor/gradientValue.test.ts +89 -0
  24. package/src/components/editor/gradientValue.ts +445 -0
  25. package/src/components/editor/manualEditingAvailability.test.ts +129 -0
  26. package/src/components/editor/manualEditingAvailability.ts +60 -0
  27. package/src/components/editor/manualEdits.test.ts +945 -0
  28. package/src/components/editor/manualEdits.ts +1397 -0
  29. package/src/components/editor/manualOffsetDrag.test.ts +140 -0
  30. package/src/components/editor/manualOffsetDrag.ts +307 -0
  31. package/src/components/editor/studioMotion.test.ts +355 -0
  32. package/src/components/editor/studioMotion.ts +632 -0
  33. package/src/components/nle/NLELayout.tsx +27 -4
  34. package/src/components/nle/NLEPreview.tsx +50 -5
  35. package/src/components/renders/RenderQueue.tsx +13 -62
  36. package/src/components/renders/useRenderQueue.ts +6 -30
  37. package/src/components/sidebar/AssetsTab.tsx +3 -4
  38. package/src/components/sidebar/CompositionsTab.test.ts +16 -1
  39. package/src/components/sidebar/CompositionsTab.tsx +117 -45
  40. package/src/components/sidebar/LeftSidebar.tsx +140 -125
  41. package/src/hooks/usePersistentEditHistory.test.ts +256 -0
  42. package/src/hooks/usePersistentEditHistory.ts +337 -0
  43. package/src/icons/SystemIcons.tsx +2 -0
  44. package/src/player/components/CompositionThumbnail.test.ts +19 -0
  45. package/src/player/components/CompositionThumbnail.tsx +50 -13
  46. package/src/player/components/EditModal.tsx +5 -20
  47. package/src/player/components/Player.tsx +18 -2
  48. package/src/player/components/Timeline.test.ts +20 -0
  49. package/src/player/components/Timeline.tsx +103 -21
  50. package/src/player/components/TimelineClip.test.ts +92 -0
  51. package/src/player/components/TimelineClip.tsx +241 -7
  52. package/src/player/components/timelineEditing.test.ts +16 -3
  53. package/src/player/components/timelineEditing.ts +10 -3
  54. package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
  55. package/src/player/hooks/useTimelinePlayer.ts +287 -16
  56. package/src/player/store/playerStore.ts +2 -0
  57. package/src/utils/clipboard.test.ts +89 -0
  58. package/src/utils/clipboard.ts +57 -0
  59. package/src/utils/editHistory.test.ts +244 -0
  60. package/src/utils/editHistory.ts +218 -0
  61. package/src/utils/editHistoryStorage.test.ts +37 -0
  62. package/src/utils/editHistoryStorage.ts +99 -0
  63. package/src/utils/mediaTypes.ts +1 -1
  64. package/src/utils/sourcePatcher.test.ts +128 -1
  65. package/src/utils/sourcePatcher.ts +130 -18
  66. package/src/utils/studioFileHistory.test.ts +156 -0
  67. package/src/utils/studioFileHistory.ts +61 -0
  68. package/src/utils/timelineAssetDrop.test.ts +31 -11
  69. package/src/utils/timelineAssetDrop.ts +22 -2
  70. package/src/utils/timelineInspector.test.ts +79 -0
  71. package/src/utils/timelineInspector.ts +116 -0
  72. package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
  73. package/dist/assets/index-04Mp2wOn.css +0 -1
  74. package/dist/assets/index-960mgQMI.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 = "v2";
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 thumbnailBase = previewUrl
52
- .replace("/preview/comp/", "/thumbnail/")
53
- .replace(/\/preview$/, "/thumbnail/index.html");
54
- const midTime = seekTime + duration / 2;
55
- const thumbnailUrl = new URL(thumbnailBase, window.location.origin);
56
- thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
57
- thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
58
- if (selector) thumbnailUrl.searchParams.set("selector", selector);
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="lazy"
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 className="absolute left-2 top-2 z-10">
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 z-10 px-1.5 pb-0.5 pt-3"
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
- try {
66
- await navigator.clipboard.writeText(buildClipboardText());
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
- try {
86
- await navigator.clipboard.writeText(promptText);
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);
@@ -11,6 +11,7 @@ interface PlayerProps {
11
11
  directUrl?: string;
12
12
  onLoad: () => void;
13
13
  portrait?: boolean;
14
+ style?: React.CSSProperties;
14
15
  }
15
16
 
16
17
  interface HyperframesPlayerElement extends HTMLElement {
@@ -30,6 +31,17 @@ function getShaderTransitionLoading(event: Event): boolean | null {
30
31
  return state.loading === true && state.ready !== true;
31
32
  }
32
33
 
34
+ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
35
+ const root = player.shadowRoot;
36
+ if (!root) return;
37
+
38
+ const container = root.querySelector<HTMLElement>(".hfp-container");
39
+ const iframe = root.querySelector<HTMLIFrameElement>(".hfp-iframe");
40
+
41
+ container?.style.setProperty("pointer-events", "auto");
42
+ iframe?.style.setProperty("pointer-events", "auto");
43
+ }
44
+
33
45
  // Assets are considered ready when every `<video>`/`<audio>` has enough data
34
46
  // to play through without buffering, and every registered Lottie animation has
35
47
  // finished loading.
@@ -72,7 +84,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
72
84
  * timeline probing, and DOM inspection.
73
85
  */
74
86
  export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
75
- ({ projectId, directUrl, onLoad, portrait }, ref) => {
87
+ ({ projectId, directUrl, onLoad, portrait, style }, ref) => {
76
88
  const containerRef = useRef<HTMLDivElement>(null);
77
89
  const loadCountRef = useRef(0);
78
90
  const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -105,6 +117,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
105
117
  player.style.height = "100%";
106
118
  player.style.display = "block";
107
119
  container.appendChild(player);
120
+ enableInteractiveIframe(player);
108
121
 
109
122
  // Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
110
123
  const iframe = player.iframeElement;
@@ -227,7 +240,10 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
227
240
  const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
228
241
 
229
242
  return (
230
- <div className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center">
243
+ <div
244
+ className="relative w-full h-full max-w-full max-h-full overflow-hidden bg-black flex items-center justify-center"
245
+ style={style}
246
+ >
231
247
  <div ref={containerRef} className="w-full h-full" />
232
248
  {showAssetOverlay && (
233
249
  <div
@@ -8,9 +8,12 @@ import {
8
8
  getTimelinePlayheadLeft,
9
9
  getTimelineScrollLeftForZoomAnchor,
10
10
  getTimelineScrollLeftForZoomTransition,
11
+ shouldShowTimelineShortcutHint,
11
12
  shouldHandleTimelineDeleteKey,
12
13
  shouldAutoScrollTimeline,
13
14
  } from "./Timeline";
15
+ import { TIMELINE_CLIP_CONTROL_Z_INDEX } from "./TimelineClip";
16
+ import { COMPOSITION_THUMBNAIL_LABEL_Z_INDEX } from "./CompositionThumbnail";
14
17
  import { formatTime } from "../lib/time";
15
18
 
16
19
  describe("generateTicks", () => {
@@ -163,6 +166,12 @@ describe("shouldAutoScrollTimeline", () => {
163
166
  });
164
167
  });
165
168
 
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
+
166
175
  describe("getTimelineScrollLeftForZoomTransition", () => {
167
176
  it("resets horizontal scroll when switching from manual zoom back to fit", () => {
168
177
  expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
@@ -237,6 +246,17 @@ describe("getTimelineCanvasHeight", () => {
237
246
  });
238
247
  });
239
248
 
249
+ describe("shouldShowTimelineShortcutHint", () => {
250
+ it("shows the hint when the timeline does not vertically overflow", () => {
251
+ expect(shouldShowTimelineShortcutHint(220, 220)).toBe(true);
252
+ expect(shouldShowTimelineShortcutHint(220.5, 220)).toBe(true);
253
+ });
254
+
255
+ it("hides the hint when timeline tracks need vertical scrolling", () => {
256
+ expect(shouldShowTimelineShortcutHint(221.5, 220)).toBe(false);
257
+ });
258
+ });
259
+
240
260
  describe("shouldHandleTimelineDeleteKey", () => {
241
261
  it("handles Delete and Backspace when focus is not in an editor", () => {
242
262
  expect(shouldHandleTimelineDeleteKey({ key: "Delete" })).toBe(true);