@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
package/src/App.tsx CHANGED
@@ -19,13 +19,15 @@ import type { TimelineElement } from "./player";
19
19
  import { LintModal } from "./components/LintModal";
20
20
  import type { LintFinding } from "./components/LintModal";
21
21
  import { MediaPreview } from "./components/MediaPreview";
22
- import { isMediaFile } from "./utils/mediaTypes";
22
+ import { RotateCcw, RotateCw } from "./icons/SystemIcons";
23
+ import { FONT_EXT, isMediaFile } from "./utils/mediaTypes";
23
24
  import {
24
25
  buildTimelineAssetId,
25
26
  buildTimelineAssetInsertHtml,
26
27
  buildTimelineFileDropPlacements,
27
28
  getTimelineAssetKind,
28
29
  insertTimelineAssetIntoSource,
30
+ resolveTimelineAssetInitialGeometry,
29
31
  resolveTimelineAssetSrc,
30
32
  type TimelineAssetKind,
31
33
  } from "./utils/timelineAssetDrop";
@@ -35,7 +37,14 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
35
37
  import { useCaptionStore } from "./captions/store";
36
38
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
37
39
  import { parseCaptionComposition } from "./captions/parser";
38
- import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
40
+ import { copyTextToClipboard } from "./utils/clipboard";
41
+ import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
42
+ import {
43
+ applyPatchByTarget,
44
+ readAttributeByTarget,
45
+ readTagSnippetByTarget,
46
+ type PatchOperation,
47
+ } from "./utils/sourcePatcher";
39
48
  import {
40
49
  buildTrackZIndexMap,
41
50
  formatTimelineAttributeNumber,
@@ -51,6 +60,82 @@ import {
51
60
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
52
61
  import { buildProjectHash, parseProjectIdFromHash } from "./utils/projectRouting";
53
62
  import { Camera } from "./icons/SystemIcons";
63
+ import { PropertyPanel } from "./components/editor/PropertyPanel";
64
+ import { MotionPanel } from "./components/editor/MotionPanel";
65
+ import { googleFontStylesheetUrl } from "./components/editor/fontCatalog";
66
+ import {
67
+ fontFamilyFromAssetPath,
68
+ importedFontFaceCss,
69
+ type ImportedFontAsset,
70
+ } from "./components/editor/fontAssets";
71
+ import {
72
+ DomEditOverlay,
73
+ type DomEditGroupPathOffsetCommit,
74
+ } from "./components/editor/DomEditOverlay";
75
+ import { TimelineLayerPanel } from "./components/editor/TimelineLayerPanel";
76
+ import {
77
+ STUDIO_INSPECTOR_PANELS_ENABLED,
78
+ STUDIO_MANUAL_EDITING_DISABLED_TITLE,
79
+ STUDIO_MOTION_PANEL_ENABLED,
80
+ STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
81
+ STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
82
+ } from "./components/editor/manualEditingAvailability";
83
+ import {
84
+ buildDefaultDomEditTextField,
85
+ buildDomEditStylePatchOperation,
86
+ buildDomEditTextPatchOperation,
87
+ buildElementAgentPrompt,
88
+ collectDomEditLayerItems,
89
+ countDomEditChildLayers,
90
+ findElementForSelection,
91
+ findElementForTimelineElement,
92
+ getDomEditLayerKey,
93
+ getDomEditTargetKey,
94
+ isTextEditableSelection,
95
+ serializeDomEditTextFields,
96
+ resolveDomEditSelection,
97
+ type DomEditLayerItem,
98
+ type DomEditTextField,
99
+ type DomEditSelection,
100
+ } from "./components/editor/domEditing";
101
+ import {
102
+ STUDIO_MANUAL_EDITS_PATH,
103
+ applyStudioManualEditManifest,
104
+ emptyStudioManualEditManifest,
105
+ installStudioManualEditSeekReapply,
106
+ isStudioManualEditManifestPath,
107
+ parseStudioManualEditManifest,
108
+ readStudioFileChangePath,
109
+ removeStudioManualEditsForSelection,
110
+ serializeStudioManualEditManifest,
111
+ type StudioManualEditManifest,
112
+ upsertStudioBoxSizeEdit,
113
+ upsertStudioPathOffsetEdit,
114
+ upsertStudioRotationEdit,
115
+ } from "./components/editor/manualEdits";
116
+ import {
117
+ STUDIO_MOTION_PATH,
118
+ applyStudioMotionManifest,
119
+ emptyStudioMotionManifest,
120
+ getStudioMotionForSelection,
121
+ installStudioMotionSeekReapply,
122
+ isStudioMotionManifestPath,
123
+ parseStudioMotionManifest,
124
+ removeStudioMotionForSelection,
125
+ serializeStudioMotionManifest,
126
+ type StudioGsapMotion,
127
+ type StudioMotionManifest,
128
+ upsertStudioGsapMotion,
129
+ } from "./components/editor/studioMotion";
130
+ import { saveProjectFilesWithHistory } from "./utils/studioFileHistory";
131
+ import {
132
+ canInspectTimelineElement,
133
+ getTimelineElementKey,
134
+ getTimelineLayerVisibilityInPreview,
135
+ isTimelineElementActiveAtTime,
136
+ isTimelineLayerVisibleInPreview,
137
+ shouldShowTimelineInspectorBounds,
138
+ } from "./utils/timelineInspector";
54
139
 
55
140
  interface EditingFile {
56
141
  path: string;
@@ -66,6 +151,532 @@ function getTimelineElementLabel(element: TimelineElement): string {
66
151
  return element.label || element.id || element.tag;
67
152
  }
68
153
 
154
+ type RightPanelTab = "design" | "motion" | "renders";
155
+
156
+ const GENERIC_FONT_FAMILIES = new Set([
157
+ "inherit",
158
+ "initial",
159
+ "revert",
160
+ "revert-layer",
161
+ "serif",
162
+ "sans-serif",
163
+ "monospace",
164
+ "cursive",
165
+ "fantasy",
166
+ "system-ui",
167
+ "ui-sans-serif",
168
+ "ui-serif",
169
+ "ui-monospace",
170
+ "ui-rounded",
171
+ "emoji",
172
+ "math",
173
+ "fangsong",
174
+ ]);
175
+
176
+ function primaryFontFamilyFromCss(value: string): string {
177
+ const first = value.split(",")[0] ?? "";
178
+ return first.trim().replace(/^["']|["']$/g, "");
179
+ }
180
+
181
+ function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
182
+ const family = primaryFontFamilyFromCss(fontFamilyValue);
183
+ if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
184
+
185
+ const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
186
+ if (doc.getElementById(id)) return;
187
+
188
+ const link = doc.createElement("link");
189
+ link.id = id;
190
+ link.rel = "stylesheet";
191
+ link.href = googleFontStylesheetUrl(family);
192
+ doc.head.appendChild(link);
193
+ }
194
+
195
+ function primaryFontFamilyValue(value: string): string {
196
+ return (
197
+ value
198
+ .split(",")[0]
199
+ ?.trim()
200
+ .replace(/^["']|["']$/g, "")
201
+ .trim() ?? ""
202
+ );
203
+ }
204
+
205
+ function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
206
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
207
+ if (doc.getElementById(id)) return;
208
+ const style = doc.createElement("style");
209
+ style.id = id;
210
+ style.textContent = importedFontFaceCss(asset);
211
+ doc.head.appendChild(style);
212
+ }
213
+
214
+ function normalizeProjectAssetPath(value: string): string {
215
+ const trimmed = value.trim();
216
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
217
+ return decodeURIComponent(maybeUrl)
218
+ .replace(/\\/g, "/")
219
+ .replace(/^\.?\//, "");
220
+ }
221
+
222
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
223
+ const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
224
+ const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
225
+
226
+ fromParts.pop();
227
+
228
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
229
+ fromParts.shift();
230
+ targetParts.shift();
231
+ }
232
+
233
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
234
+ }
235
+
236
+ function isAbsoluteFilePath(value: string): boolean {
237
+ return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
238
+ }
239
+
240
+ function toProjectAbsolutePath(projectDir: string | null, sourceFile: string): string | undefined {
241
+ const trimmedSource = sourceFile.trim();
242
+ if (!trimmedSource) return undefined;
243
+
244
+ const normalizedSource = trimmedSource.replace(/\\/g, "/");
245
+ if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
246
+
247
+ const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
248
+ if (!normalizedRoot) return undefined;
249
+
250
+ return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
251
+ }
252
+
253
+ function ensureImportedFontFace(
254
+ html: string,
255
+ asset: ImportedFontAsset,
256
+ sourceFile: string,
257
+ ): string {
258
+ const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
259
+ if (html.includes(css)) return html;
260
+
261
+ const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
262
+ const styleMatch = styleRe.exec(html);
263
+ if (styleMatch) {
264
+ const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
265
+ return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
266
+ }
267
+
268
+ const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
269
+ if (/<\/head>/i.test(html)) {
270
+ return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
271
+ }
272
+ return `${styleTag}\n${html}`;
273
+ }
274
+ function normalizeDomEditStyleValue(property: string, value: string): string {
275
+ const trimmed = value.trim();
276
+ if (!trimmed) return trimmed;
277
+
278
+ if (
279
+ ["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
280
+ /^-?\d+(\.\d+)?$/.test(trimmed)
281
+ ) {
282
+ return `${trimmed}px`;
283
+ }
284
+
285
+ return trimmed;
286
+ }
287
+
288
+ function isImageBackgroundValue(value: string): boolean {
289
+ return /^url\(/i.test(value.trim());
290
+ }
291
+
292
+ function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
293
+ if (!target || typeof target !== "object") return null;
294
+ const maybeNode = target as {
295
+ nodeType?: number;
296
+ parentElement?: Element | null;
297
+ };
298
+ if (maybeNode.nodeType === 1) return target as HTMLElement;
299
+ if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
300
+ return maybeNode.parentElement as HTMLElement;
301
+ }
302
+ return null;
303
+ }
304
+
305
+ function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
306
+ const el = getEventTargetElement(target);
307
+ if (!el) return false;
308
+ return Boolean(
309
+ el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
310
+ );
311
+ }
312
+
313
+ function getHistoryShortcutLabel(action: "undo" | "redo"): string {
314
+ const isMac =
315
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
316
+ const modifier = isMac ? "Cmd" : "Ctrl";
317
+ return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
318
+ }
319
+
320
+ function findMatchingTimelineElementId(
321
+ selection: Pick<
322
+ DomEditSelection,
323
+ "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
324
+ >,
325
+ elements: TimelineElement[],
326
+ ): string | null {
327
+ const selectionSourceFile = selection.sourceFile || "index.html";
328
+ for (const element of elements) {
329
+ const elementSourceFile = element.sourceFile || "index.html";
330
+ if (
331
+ selection.id &&
332
+ element.domId === selection.id &&
333
+ elementSourceFile === selectionSourceFile
334
+ ) {
335
+ return element.key ?? element.id;
336
+ }
337
+ if (
338
+ selection.isCompositionHost &&
339
+ selection.compositionSrc &&
340
+ element.compositionSrc === selection.compositionSrc
341
+ ) {
342
+ return element.key ?? element.id;
343
+ }
344
+ if (
345
+ selection.selector &&
346
+ element.selector === selection.selector &&
347
+ (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
348
+ (element.sourceFile ?? "index.html") === selection.sourceFile
349
+ ) {
350
+ return element.key ?? element.id;
351
+ }
352
+ }
353
+
354
+ return null;
355
+ }
356
+
357
+ function isManualGeometryStyleProperty(property: string): boolean {
358
+ return property === "left" || property === "top" || property === "width" || property === "height";
359
+ }
360
+
361
+ function getPreviewTargetFromPointer(
362
+ iframe: HTMLIFrameElement,
363
+ clientX: number,
364
+ clientY: number,
365
+ ): HTMLElement | null {
366
+ let doc: Document | null = null;
367
+ let win: Window | null = null;
368
+ try {
369
+ doc = iframe.contentDocument;
370
+ win = iframe.contentWindow;
371
+ } catch {
372
+ return null;
373
+ }
374
+ if (!doc || !win) return null;
375
+
376
+ const iframeRect = iframe.getBoundingClientRect();
377
+ const root =
378
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
379
+ const rootRect = root?.getBoundingClientRect();
380
+ const rootWidth = rootRect?.width || win.innerWidth;
381
+ const rootHeight = rootRect?.height || win.innerHeight;
382
+ if (!rootWidth || !rootHeight) return null;
383
+
384
+ const scaleX = iframeRect.width / rootWidth;
385
+ const scaleY = iframeRect.height / rootHeight;
386
+ const localX = (clientX - iframeRect.left) / scaleX;
387
+ const localY = (clientY - iframeRect.top) / scaleY;
388
+
389
+ return getEventTargetElement(doc.elementFromPoint(localX, localY));
390
+ }
391
+
392
+ function domEditSelectionsTargetSame(
393
+ a: DomEditSelection | null,
394
+ b: DomEditSelection | null,
395
+ ): boolean {
396
+ if (a === b) return true;
397
+ if (!a || !b) return false;
398
+ return getDomEditTargetKey(a) === getDomEditTargetKey(b);
399
+ }
400
+
401
+ function domEditSelectionInGroup(
402
+ group: DomEditSelection[],
403
+ selection: DomEditSelection | null,
404
+ ): boolean {
405
+ if (!selection) return false;
406
+ return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
407
+ }
408
+
409
+ function toggleDomEditGroupSelection(
410
+ group: DomEditSelection[],
411
+ selection: DomEditSelection,
412
+ ): DomEditSelection[] {
413
+ if (domEditSelectionInGroup(group, selection)) {
414
+ return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
415
+ }
416
+ return [...group, selection];
417
+ }
418
+
419
+ function replaceDomEditGroupSelection(
420
+ group: DomEditSelection[],
421
+ selection: DomEditSelection,
422
+ ): DomEditSelection[] {
423
+ let replaced = false;
424
+ const nextGroup = group.map((entry) => {
425
+ if (!domEditSelectionsTargetSame(entry, selection)) return entry;
426
+ replaced = true;
427
+ return selection;
428
+ });
429
+ return replaced ? nextGroup : [...group, selection];
430
+ }
431
+
432
+ function seedDomEditGroupWithSelection(
433
+ group: DomEditSelection[],
434
+ selection: DomEditSelection | null,
435
+ ): DomEditSelection[] {
436
+ if (!selection || domEditSelectionInGroup(group, selection)) return group;
437
+ return [selection, ...group];
438
+ }
439
+
440
+ function objectLike(value: unknown): object | null {
441
+ return value && (typeof value === "object" || typeof value === "function") ? value : null;
442
+ }
443
+
444
+ function callPlaybackMethod(target: object | null, key: string): void {
445
+ const method = target ? Reflect.get(target, key) : null;
446
+ if (typeof method !== "function") return;
447
+ try {
448
+ method.call(target);
449
+ } catch {
450
+ // Best-effort playback freeze; drag should still work if playback control is unavailable.
451
+ }
452
+ }
453
+
454
+ function readPlaybackTime(target: object | null, key: string): number | null {
455
+ const method = target ? Reflect.get(target, key) : null;
456
+ if (typeof method !== "function") return null;
457
+ try {
458
+ const value = method.call(target);
459
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ interface PreviewPlayerCompat {
466
+ getTime: () => number;
467
+ renderSeek: (timeSeconds: number) => void;
468
+ }
469
+
470
+ function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
471
+ const player = objectLike(win ? Reflect.get(win, "__player") : null);
472
+ if (!player) return null;
473
+ const getTime = Reflect.get(player, "getTime");
474
+ const renderSeek = Reflect.get(player, "renderSeek");
475
+ if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
476
+ return {
477
+ getTime: () => {
478
+ const value = getTime.call(player);
479
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
480
+ },
481
+ renderSeek: (timeSeconds: number) => {
482
+ renderSeek.call(player, timeSeconds);
483
+ },
484
+ };
485
+ }
486
+
487
+ function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
488
+ const player = getPreviewPlayer(iframe?.contentWindow);
489
+ if (!player) return false;
490
+ const nextTime = Math.max(0, timeSeconds);
491
+ player.renderSeek(nextTime);
492
+ usePlayerStore.getState().setCurrentTime(nextTime);
493
+ liveTime.notify(nextTime);
494
+ return true;
495
+ }
496
+
497
+ function parseFiniteSeconds(value: string | null): number | null {
498
+ if (value == null || value.trim() === "") return null;
499
+ const parsed = Number.parseFloat(value);
500
+ return Number.isFinite(parsed) ? parsed : null;
501
+ }
502
+
503
+ function resolveLayerVisibleSeekTime(
504
+ layerElement: HTMLElement,
505
+ timelineElement: TimelineElement | null,
506
+ player: PreviewPlayerCompat | null,
507
+ ): number | null {
508
+ if (!timelineElement || !player) return null;
509
+ const originalTime = player.getTime();
510
+
511
+ const clipStart = Math.max(0, timelineElement.start);
512
+ const clipEnd = Math.max(clipStart, clipStart + Math.max(0, timelineElement.duration));
513
+ const authoredStart = parseFiniteSeconds(
514
+ layerElement.getAttribute("data-start") ??
515
+ layerElement.closest<HTMLElement>("[data-start]")?.getAttribute("data-start") ??
516
+ null,
517
+ );
518
+ const preferredTime =
519
+ authoredStart == null
520
+ ? clipStart
521
+ : Math.min(clipEnd, Math.max(clipStart, clipStart + authoredStart));
522
+ const candidates = [preferredTime, clipStart];
523
+ const duration = clipEnd - clipStart;
524
+ if (duration > 0) {
525
+ const maxSamples = 24;
526
+ const frameStep = 1 / 24;
527
+ const step = Math.max(frameStep, duration / maxSamples);
528
+ for (let time = clipStart; time <= clipEnd + 0.0001; time += step) {
529
+ candidates.push(Math.min(clipEnd, time));
530
+ }
531
+ }
532
+ candidates.push(clipEnd);
533
+
534
+ let lastTried = preferredTime;
535
+ let clearestVisibleTime: number | null = null;
536
+ let clearestVisibleOpacity = 0;
537
+ let resolvedTime: number | null = null;
538
+ const seen = new Set<string>();
539
+ try {
540
+ for (const candidate of candidates) {
541
+ const time = Math.min(clipEnd, Math.max(clipStart, candidate));
542
+ const key = time.toFixed(4);
543
+ if (seen.has(key)) continue;
544
+ seen.add(key);
545
+ lastTried = time;
546
+ player.renderSeek(time);
547
+ const visibility = getTimelineLayerVisibilityInPreview(layerElement);
548
+ if (visibility.visible && visibility.compositeOpacity > clearestVisibleOpacity) {
549
+ clearestVisibleTime = time;
550
+ clearestVisibleOpacity = visibility.compositeOpacity;
551
+ }
552
+ if (isTimelineLayerVisibleInPreview(layerElement, { minCompositeOpacity: 0.9 })) {
553
+ resolvedTime = time;
554
+ break;
555
+ }
556
+ }
557
+ } finally {
558
+ player.renderSeek(originalTime);
559
+ }
560
+
561
+ return resolvedTime ?? clearestVisibleTime ?? lastTried;
562
+ }
563
+
564
+ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
565
+ const win = iframe?.contentWindow;
566
+ if (!win) return null;
567
+
568
+ try {
569
+ let pausedTime: number | null = null;
570
+ const player = objectLike(Reflect.get(win, "__player"));
571
+ pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
572
+ callPlaybackMethod(player, "pause");
573
+
574
+ const timeline = objectLike(Reflect.get(win, "__timeline"));
575
+ pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
576
+ callPlaybackMethod(timeline, "pause");
577
+
578
+ const timelines = objectLike(Reflect.get(win, "__timelines"));
579
+ if (timelines) {
580
+ for (const value of Object.values(timelines)) {
581
+ const timelineRecord = objectLike(value);
582
+ pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
583
+ callPlaybackMethod(timelineRecord, "pause");
584
+ }
585
+ }
586
+
587
+ return pausedTime;
588
+ } catch {
589
+ return null;
590
+ }
591
+ }
592
+
593
+ // ── Ask Agent Modal ──
594
+
595
+ function AskAgentModal({
596
+ selectionLabel,
597
+ onSubmit,
598
+ onClose,
599
+ }: {
600
+ selectionLabel: string;
601
+ onSubmit: (instruction: string) => void;
602
+ onClose: () => void;
603
+ }) {
604
+ const [value, setValue] = useState("");
605
+ const inputRef = useRef<HTMLTextAreaElement>(null);
606
+
607
+ useMountEffect(() => {
608
+ requestAnimationFrame(() => inputRef.current?.focus());
609
+ });
610
+
611
+ const handleSubmit = () => {
612
+ if (!value.trim()) return;
613
+ onSubmit(value.trim());
614
+ };
615
+
616
+ return (
617
+ <div
618
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
619
+ onClick={onClose}
620
+ >
621
+ <div
622
+ className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
623
+ onClick={(e) => e.stopPropagation()}
624
+ >
625
+ <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
626
+ <div>
627
+ <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
628
+ <p className="text-xs text-neutral-500 mt-0.5">
629
+ {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
630
+ </p>
631
+ </div>
632
+ <button
633
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
634
+ onClick={onClose}
635
+ >
636
+ <svg
637
+ width="14"
638
+ height="14"
639
+ viewBox="0 0 24 24"
640
+ fill="none"
641
+ stroke="currentColor"
642
+ strokeWidth="2"
643
+ strokeLinecap="round"
644
+ >
645
+ <line x1="18" y1="6" x2="6" y2="18" />
646
+ <line x1="6" y1="6" x2="18" y2="18" />
647
+ </svg>
648
+ </button>
649
+ </div>
650
+ <div className="px-5 py-4">
651
+ <textarea
652
+ ref={inputRef}
653
+ className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
654
+ placeholder="Describe what you want to change…"
655
+ value={value}
656
+ onChange={(e) => setValue(e.target.value)}
657
+ onKeyDown={(e) => {
658
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
659
+ if (e.key === "Escape") onClose();
660
+ }}
661
+ />
662
+ </div>
663
+ <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
664
+ <span className="text-[11px] text-neutral-600">
665
+ {navigator.platform.includes("Mac") ? "⌘" : "Ctrl"}+Enter to copy
666
+ </span>
667
+ <button
668
+ className="px-4 py-1.5 rounded-lg bg-studio-accent/90 text-xs font-medium text-neutral-950 hover:bg-studio-accent disabled:opacity-40 disabled:cursor-not-allowed"
669
+ disabled={!value.trim()}
670
+ onClick={handleSubmit}
671
+ >
672
+ Copy prompt
673
+ </button>
674
+ </div>
675
+ </div>
676
+ </div>
677
+ );
678
+ }
679
+
69
680
  const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
70
681
  image: 3,
71
682
  video: 5,
@@ -144,6 +755,7 @@ export function StudioApp() {
144
755
  });
145
756
 
146
757
  const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
758
+ const [projectDir, setProjectDir] = useState<string | null>(null);
147
759
  const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
148
760
  const [fileTree, setFileTree] = useState<string[]>([]);
149
761
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
@@ -157,6 +769,24 @@ export function StudioApp() {
157
769
  const [rightWidth, setRightWidth] = useState(400);
158
770
  const [leftCollapsed, setLeftCollapsed] = useState(false);
159
771
  const [rightCollapsed, setRightCollapsed] = useState(true);
772
+ const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("renders");
773
+ const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
774
+ const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
775
+ const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
776
+ const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
777
+ const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
778
+ const [agentModalOpen, setAgentModalOpen] = useState(false);
779
+ const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
780
+ const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
781
+ const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
782
+ ReadonlySet<string>
783
+ >(() => new Set());
784
+ const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
785
+ const refreshPreviewDocumentVersion = useCallback(() => {
786
+ setPreviewDocumentVersion((version) => version + 1);
787
+ window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 80);
788
+ window.setTimeout(() => setPreviewDocumentVersion((version) => version + 1), 300);
789
+ }, []);
160
790
  // Auto-enter caption edit mode when the iframe contains .caption-group elements.
161
791
  // This is a subscription to external events (postMessage from runtime) — useEffect
162
792
  // is appropriate here. The runtime fires "state"/"timeline" messages after all
@@ -283,7 +913,10 @@ export function StudioApp() {
283
913
  const dragCounterRef = useRef(0);
284
914
  const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
285
915
  const lastBlockedTimelineToastAtRef = useRef(0);
916
+ const lastBlockedDomMoveToastAtRef = useRef(0);
917
+ const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
286
918
  const previewHotkeyWindowRef = useRef<Window | null>(null);
919
+ const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
287
920
  const panelDragRef = useRef<{
288
921
  side: "left" | "right";
289
922
  startX: number;
@@ -294,11 +927,15 @@ export function StudioApp() {
294
927
  const activePreviewUrl = activeCompPath
295
928
  ? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
296
929
  : null;
930
+ const isMasterView = !activeCompPath || activeCompPath === "index.html";
297
931
  const zoomMode = usePlayerStore((s) => s.zoomMode);
298
932
  const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
299
933
  const setZoomMode = usePlayerStore((s) => s.setZoomMode);
300
934
  const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
935
+ const currentTime = usePlayerStore((s) => s.currentTime);
301
936
  const timelineElements = usePlayerStore((s) => s.elements);
937
+ const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
938
+ const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
302
939
  const timelineDuration = usePlayerStore((s) => s.duration);
303
940
  const effectiveTimelineDuration = useMemo(() => {
304
941
  const maxEnd =
@@ -400,7 +1037,6 @@ export function StudioApp() {
400
1037
  label={getTimelineElementLabel(el)}
401
1038
  labelColor={style.label}
402
1039
  accentColor={style.clip}
403
- selector={el.selector}
404
1040
  seekTime={0}
405
1041
  duration={el.duration}
406
1042
  />
@@ -417,6 +1053,7 @@ export function StudioApp() {
417
1053
  labelColor={style.label}
418
1054
  accentColor={style.clip}
419
1055
  selector={el.selector}
1056
+ selectorIndex={el.selectorIndex}
420
1057
  seekTime={el.start}
421
1058
  duration={el.duration}
422
1059
  />
@@ -478,6 +1115,7 @@ export function StudioApp() {
478
1115
  labelColor={style.label}
479
1116
  accentColor={style.clip}
480
1117
  selector={el.selector}
1118
+ selectorIndex={el.selectorIndex}
481
1119
  seekTime={el.start}
482
1120
  duration={el.duration}
483
1121
  />
@@ -562,16 +1200,80 @@ export function StudioApp() {
562
1200
  const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
563
1201
  const [linting, setLinting] = useState(false);
564
1202
  const [refreshKey, setRefreshKey] = useState(0);
1203
+ const [, setStudioMotionRevision] = useState(0);
565
1204
  const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
566
1205
  const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
567
1206
  const projectIdRef = useRef(projectId);
568
1207
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
569
1208
  const consoleErrorsRef = useRef<LintFinding[]>([]);
1209
+ const copiedAgentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1210
+ const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
1211
+ const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
1212
+ const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
1213
+ const domEditSaveTimestampRef = useRef(0);
1214
+ const domTextCommitVersionRef = useRef(0);
1215
+ const domEditSaveQueueRef = useRef(Promise.resolve());
1216
+ const studioManualEditManifestRef = useRef<StudioManualEditManifest>(
1217
+ emptyStudioManualEditManifest(),
1218
+ );
1219
+ const studioManualEditRevisionRef = useRef(0);
1220
+ const studioMotionManifestRef = useRef<StudioMotionManifest>(emptyStudioMotionManifest());
1221
+ const studioMotionRevisionRef = useRef(0);
1222
+ const applyStudioManualEditsToPreviewRef = useRef<
1223
+ (
1224
+ iframe?: HTMLIFrameElement | null,
1225
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1226
+ ) => Promise<void>
1227
+ >(async () => {});
1228
+ const applyStudioMotionToPreviewRef = useRef<
1229
+ (
1230
+ iframe?: HTMLIFrameElement | null,
1231
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1232
+ ) => Promise<void>
1233
+ >(async () => {});
1234
+ const studioManualEditProjectRef = useRef<string | null>(projectId);
1235
+ const activeCompPathRef = useRef(activeCompPath);
1236
+ activeCompPathRef.current = activeCompPath;
1237
+
1238
+ const queueDomEditSave = useCallback((save: () => Promise<void>) => {
1239
+ const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save);
1240
+ domEditSaveQueueRef.current = queuedSave.then(
1241
+ () => undefined,
1242
+ () => undefined,
1243
+ );
1244
+ return queuedSave;
1245
+ }, []);
1246
+
1247
+ const waitForPendingDomEditSaves = useCallback(async () => {
1248
+ await domEditSaveQueueRef.current.catch(() => undefined);
1249
+ }, []);
570
1250
 
571
1251
  // Listen for external file changes (user editing HTML outside the editor).
572
1252
  // In dev: use Vite HMR. In embedded/production: use SSE from /api/events.
1253
+ // Suppress file-change events that echo back from a recent DOM edit save —
1254
+ // those changes are already applied to the iframe DOM and a full reload
1255
+ // would flash the preview.
573
1256
  useMountEffect(() => {
574
- const handler = () => {
1257
+ const handler = (payload?: unknown) => {
1258
+ const changedPath = readStudioFileChangePath(payload);
1259
+ const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200;
1260
+ if (isStudioManualEditManifestPath(changedPath)) {
1261
+ if (!recentDomEditSave) {
1262
+ void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, {
1263
+ forceFromDisk: true,
1264
+ });
1265
+ }
1266
+ return;
1267
+ }
1268
+ if (isStudioMotionManifestPath(changedPath)) {
1269
+ if (!recentDomEditSave) {
1270
+ void applyStudioMotionToPreviewRef.current(previewIframeRef.current, {
1271
+ forceFromDisk: true,
1272
+ });
1273
+ }
1274
+ return;
1275
+ }
1276
+ if (recentDomEditSave) return;
575
1277
  if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
576
1278
  refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400);
577
1279
  };
@@ -585,6 +1287,21 @@ export function StudioApp() {
585
1287
  return () => es.close();
586
1288
  });
587
1289
  projectIdRef.current = projectId;
1290
+ domEditSelectionRef.current = domEditSelection;
1291
+ domEditGroupSelectionsRef.current = domEditGroupSelections;
1292
+ domEditHoverSelectionRef.current = domEditHoverSelection;
1293
+
1294
+ // eslint-disable-next-line no-restricted-syntax
1295
+ useEffect(() => {
1296
+ const previousProjectId = studioManualEditProjectRef.current;
1297
+ studioManualEditProjectRef.current = projectId;
1298
+ if (!previousProjectId || previousProjectId === projectId) return;
1299
+ studioManualEditManifestRef.current = emptyStudioManualEditManifest();
1300
+ studioManualEditRevisionRef.current += 1;
1301
+ studioMotionManifestRef.current = emptyStudioMotionManifest();
1302
+ studioMotionRevisionRef.current += 1;
1303
+ setStudioMotionRevision((revision) => revision + 1);
1304
+ }, [projectId]);
588
1305
 
589
1306
  // Load file tree when projectId changes.
590
1307
  // Note: This is one of the few places where useEffect with deps is acceptable —
@@ -596,10 +1313,13 @@ export function StudioApp() {
596
1313
  let cancelled = false;
597
1314
  fetch(`/api/projects/${projectId}`)
598
1315
  .then((r) => r.json())
599
- .then((data: { files?: string[] }) => {
1316
+ .then((data: { files?: string[]; dir?: string }) => {
600
1317
  if (!cancelled && data.files) setFileTree(data.files);
1318
+ if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
601
1319
  })
602
- .catch(() => {});
1320
+ .catch(() => {
1321
+ if (!cancelled) setProjectDir(null);
1322
+ });
603
1323
  return () => {
604
1324
  cancelled = true;
605
1325
  };
@@ -627,29 +1347,72 @@ export function StudioApp() {
627
1347
 
628
1348
  const editingPathRef = useRef(editingFile?.path);
629
1349
  editingPathRef.current = editingFile?.path;
1350
+ const editHistory = usePersistentEditHistory({ projectId });
630
1351
 
631
- const handleContentChange = useCallback((content: string) => {
1352
+ const readProjectFile = useCallback(async (path: string): Promise<string> => {
632
1353
  const pid = projectIdRef.current;
633
- if (!pid) return;
634
- const path = editingPathRef.current;
635
- if (!path) return;
636
-
637
- // Debounce the server write (600ms)
638
- if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
639
- saveTimerRef.current = setTimeout(() => {
640
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
641
- method: "PUT",
642
- headers: { "Content-Type": "text/plain" },
643
- body: content,
644
- })
645
- .then(() => {
646
- if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
647
- refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
648
- })
649
- .catch(() => {});
650
- }, 600);
1354
+ if (!pid) throw new Error("No active project");
1355
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
1356
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
1357
+ const data = (await response.json()) as { content?: string };
1358
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
1359
+ return data.content;
1360
+ }, []);
1361
+
1362
+ const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
1363
+ const pid = projectIdRef.current;
1364
+ if (!pid) throw new Error("No active project");
1365
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
1366
+ method: "PUT",
1367
+ headers: { "Content-Type": "text/plain" },
1368
+ body: content,
1369
+ });
1370
+ if (!response.ok) throw new Error(`Failed to save ${path}`);
1371
+ if (editingPathRef.current === path) {
1372
+ setEditingFile({ path, content });
1373
+ }
1374
+ }, []);
1375
+
1376
+ const readOptionalProjectFile = useCallback(async (path: string): Promise<string> => {
1377
+ const pid = projectIdRef.current;
1378
+ if (!pid) throw new Error("No active project");
1379
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
1380
+ if (response.status === 404) return "";
1381
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
1382
+ const data = (await response.json()) as { content?: string };
1383
+ return typeof data.content === "string" ? data.content : "";
651
1384
  }, []);
652
1385
 
1386
+ const handleContentChange = useCallback(
1387
+ (content: string) => {
1388
+ const pid = projectIdRef.current;
1389
+ if (!pid) return;
1390
+ const path = editingPathRef.current;
1391
+ if (!path) return;
1392
+
1393
+ // Debounce the server write (600ms)
1394
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1395
+ saveTimerRef.current = setTimeout(() => {
1396
+ saveProjectFilesWithHistory({
1397
+ projectId: pid,
1398
+ label: "Edit source",
1399
+ kind: "source",
1400
+ coalesceKey: `source:${path}`,
1401
+ files: { [path]: content },
1402
+ readFile: readProjectFile,
1403
+ writeFile: writeProjectFile,
1404
+ recordEdit: editHistory.recordEdit,
1405
+ })
1406
+ .then(() => {
1407
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
1408
+ refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600);
1409
+ })
1410
+ .catch(() => {});
1411
+ }, 600);
1412
+ },
1413
+ [editHistory.recordEdit, readProjectFile, writeProjectFile],
1414
+ );
1415
+
653
1416
  const handleTimelineElementMove = useCallback(
654
1417
  async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
655
1418
  const pid = projectIdRef.current;
@@ -728,25 +1491,19 @@ export function StudioApp() {
728
1491
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
729
1492
  }
730
1493
 
731
- const saveResponse = await fetch(
732
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
733
- {
734
- method: "PUT",
735
- headers: { "Content-Type": "text/plain" },
736
- body: patchedContent,
737
- },
738
- );
739
- if (!saveResponse.ok) {
740
- throw new Error(`Failed to save ${targetPath}`);
741
- }
742
-
743
- if (editingPathRef.current === targetPath) {
744
- setEditingFile({ path: targetPath, content: patchedContent });
745
- }
1494
+ await saveProjectFilesWithHistory({
1495
+ projectId: pid,
1496
+ label: "Move timeline clip",
1497
+ kind: "timeline",
1498
+ files: { [targetPath]: patchedContent },
1499
+ readFile: async () => originalContent,
1500
+ writeFile: writeProjectFile,
1501
+ recordEdit: editHistory.recordEdit,
1502
+ });
746
1503
 
747
1504
  setRefreshKey((k) => k + 1);
748
1505
  },
749
- [activeCompPath, timelineElements],
1506
+ [activeCompPath, editHistory.recordEdit, timelineElements, writeProjectFile],
750
1507
  );
751
1508
 
752
1509
  const handleTimelineElementResize = useCallback(
@@ -818,25 +1575,19 @@ export function StudioApp() {
818
1575
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
819
1576
  }
820
1577
 
821
- const saveResponse = await fetch(
822
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
823
- {
824
- method: "PUT",
825
- headers: { "Content-Type": "text/plain" },
826
- body: patchedContent,
827
- },
828
- );
829
- if (!saveResponse.ok) {
830
- throw new Error(`Failed to save ${targetPath}`);
831
- }
832
-
833
- if (editingPathRef.current === targetPath) {
834
- setEditingFile({ path: targetPath, content: patchedContent });
835
- }
1578
+ await saveProjectFilesWithHistory({
1579
+ projectId: pid,
1580
+ label: "Resize timeline clip",
1581
+ kind: "timeline",
1582
+ files: { [targetPath]: patchedContent },
1583
+ readFile: async () => originalContent,
1584
+ writeFile: writeProjectFile,
1585
+ recordEdit: editHistory.recordEdit,
1586
+ });
836
1587
 
837
1588
  setRefreshKey((k) => k + 1);
838
1589
  },
839
- [activeCompPath],
1590
+ [activeCompPath, editHistory.recordEdit, writeProjectFile],
840
1591
  );
841
1592
 
842
1593
  const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
@@ -852,6 +1603,7 @@ export function StudioApp() {
852
1603
 
853
1604
  const currentTime = usePlayerStore.getState().currentTime;
854
1605
  setCaptureFrameTime(currentTime);
1606
+ await waitForPendingDomEditSaves();
855
1607
  const href = buildFrameCaptureUrl({
856
1608
  projectId,
857
1609
  compositionPath: activeCompPath,
@@ -878,7 +1630,7 @@ export function StudioApp() {
878
1630
  showToast(message);
879
1631
  }
880
1632
  },
881
- [activeCompPath, projectId, showToast],
1633
+ [activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
882
1634
  );
883
1635
 
884
1636
  const handleTimelineElementDelete = useCallback(
@@ -961,21 +1713,15 @@ export function StudioApp() {
961
1713
  });
962
1714
  }
963
1715
 
964
- const saveResponse = await fetch(
965
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
966
- {
967
- method: "PUT",
968
- headers: { "Content-Type": "text/plain" },
969
- body: patchedContent,
970
- },
971
- );
972
- if (!saveResponse.ok) {
973
- throw new Error(`Failed to save ${targetPath}`);
974
- }
975
-
976
- if (editingPathRef.current === targetPath) {
977
- setEditingFile({ path: targetPath, content: patchedContent });
978
- }
1716
+ await saveProjectFilesWithHistory({
1717
+ projectId: pid,
1718
+ label: "Delete timeline clip",
1719
+ kind: "timeline",
1720
+ files: { [targetPath]: patchedContent },
1721
+ readFile: async () => originalContent,
1722
+ writeFile: writeProjectFile,
1723
+ recordEdit: editHistory.recordEdit,
1724
+ });
979
1725
 
980
1726
  usePlayerStore
981
1727
  .getState()
@@ -992,7 +1738,7 @@ export function StudioApp() {
992
1738
  showToast(message);
993
1739
  }
994
1740
  },
995
- [activeCompPath, showToast, timelineElements],
1741
+ [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
996
1742
  );
997
1743
 
998
1744
  const handleBlockedTimelineEdit = useCallback(
@@ -1005,23 +1751,1532 @@ export function StudioApp() {
1005
1751
  [showToast],
1006
1752
  );
1007
1753
 
1008
- const refreshFileTree = useCallback(async () => {
1009
- const pid = projectIdRef.current;
1010
- if (!pid) return;
1011
- const res = await fetch(`/api/projects/${pid}`);
1012
- const data = await res.json();
1013
- if (data.files) setFileTree(data.files);
1014
- }, []);
1754
+ const handleBlockedDomMove = useCallback(
1755
+ (selection: DomEditSelection) => {
1756
+ const now = Date.now();
1757
+ if (now - lastBlockedDomMoveToastAtRef.current < 1500) return;
1758
+ lastBlockedDomMoveToastAtRef.current = now;
1759
+ showToast(
1760
+ selection.capabilities.reasonIfDisabled ??
1761
+ "This element can’t be adjusted directly from the preview.",
1762
+ "info",
1763
+ );
1764
+ },
1765
+ [showToast],
1766
+ );
1015
1767
 
1016
- const uploadProjectFiles = useCallback(
1017
- async (files: Iterable<File>, dir?: string): Promise<string[]> => {
1018
- const pid = projectIdRef.current;
1019
- const fileList = Array.from(files);
1020
- if (!pid || fileList.length === 0) return [];
1768
+ const applyDomSelection = useCallback(
1769
+ (
1770
+ selection: DomEditSelection | null,
1771
+ options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1772
+ ) => {
1773
+ setAgentPromptTagSnippet(undefined);
1774
+ setCopiedAgentPrompt(false);
1775
+ if (!selection) {
1776
+ domEditSelectionRef.current = null;
1777
+ domEditGroupSelectionsRef.current = [];
1778
+ setDomEditSelection(null);
1779
+ setDomEditGroupSelections([]);
1780
+ setSelectedTimelineElementId(null);
1781
+ return;
1782
+ }
1783
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
1784
+ domEditSelectionRef.current = null;
1785
+ domEditGroupSelectionsRef.current = [];
1786
+ setDomEditSelection(null);
1787
+ setDomEditGroupSelections([]);
1788
+ setSelectedTimelineElementId(null);
1789
+ return;
1790
+ }
1021
1791
 
1022
- const formData = new FormData();
1023
- for (const file of fileList) {
1024
- formData.append("file", file);
1792
+ const isAdditiveSelection = Boolean(options?.additive);
1793
+ const currentSelection = domEditSelectionRef.current;
1794
+ const previousGroup = domEditGroupSelectionsRef.current;
1795
+ const currentGroup = isAdditiveSelection
1796
+ ? seedDomEditGroupWithSelection(previousGroup, currentSelection)
1797
+ : previousGroup;
1798
+ const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
1799
+ const nextGroup = options?.preserveGroup
1800
+ ? replaceDomEditGroupSelection(currentGroup, selection)
1801
+ : isAdditiveSelection
1802
+ ? toggleDomEditGroupSelection(currentGroup, selection)
1803
+ : [selection];
1804
+ const nextSelection = options?.preserveGroup
1805
+ ? selection
1806
+ : isAdditiveSelection && wasInGroup
1807
+ ? domEditSelectionsTargetSame(currentSelection, selection)
1808
+ ? (nextGroup[0] ?? null)
1809
+ : domEditSelectionInGroup(nextGroup, currentSelection)
1810
+ ? currentSelection
1811
+ : (nextGroup[0] ?? null)
1812
+ : selection;
1813
+
1814
+ domEditSelectionRef.current = nextSelection;
1815
+ domEditGroupSelectionsRef.current = nextGroup;
1816
+ setDomEditSelection(nextSelection);
1817
+ setDomEditGroupSelections(nextGroup);
1818
+
1819
+ if (nextSelection) {
1820
+ if (options?.revealPanel !== false) {
1821
+ setRightCollapsed(false);
1822
+ setRightPanelTab("design");
1823
+ }
1824
+ const nextSelectedTimelineId = findMatchingTimelineElementId(
1825
+ nextSelection,
1826
+ timelineElements,
1827
+ );
1828
+ setSelectedTimelineElementId(nextSelectedTimelineId);
1829
+ return;
1830
+ }
1831
+
1832
+ setSelectedTimelineElementId(null);
1833
+ },
1834
+ [setSelectedTimelineElementId, timelineElements],
1835
+ );
1836
+
1837
+ const clearDomSelection = useCallback(() => {
1838
+ applyDomSelection(null, { revealPanel: false });
1839
+ }, [applyDomSelection]);
1840
+
1841
+ const readHistoryProjectFile = useCallback(
1842
+ async (path: string): Promise<string> => {
1843
+ return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH
1844
+ ? readOptionalProjectFile(path)
1845
+ : readProjectFile(path);
1846
+ },
1847
+ [readOptionalProjectFile, readProjectFile],
1848
+ );
1849
+
1850
+ const writeHistoryProjectFile = useCallback(
1851
+ async (path: string, content: string): Promise<void> => {
1852
+ await writeProjectFile(path, content);
1853
+ if (path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH) {
1854
+ domEditSaveTimestampRef.current = Date.now();
1855
+ }
1856
+ },
1857
+ [writeProjectFile],
1858
+ );
1859
+
1860
+ const applyCurrentStudioManualEditsToPreview = useCallback(
1861
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
1862
+ if (!iframe) return;
1863
+ let doc: Document | null = null;
1864
+ try {
1865
+ doc = iframe.contentDocument;
1866
+ } catch {
1867
+ return;
1868
+ }
1869
+ if (!doc) return;
1870
+ const previewDoc = doc;
1871
+
1872
+ const applyManifest = () => {
1873
+ applyStudioManualEditManifest(
1874
+ previewDoc,
1875
+ studioManualEditManifestRef.current,
1876
+ activeCompPathRef.current,
1877
+ );
1878
+ };
1879
+ const applyAndInstallSeekHooks = () => {
1880
+ applyManifest();
1881
+ if (iframe.contentWindow) {
1882
+ installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest);
1883
+ }
1884
+ };
1885
+
1886
+ const win = iframe.contentWindow;
1887
+ applyAndInstallSeekHooks();
1888
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
1889
+ win?.setTimeout?.(applyAndInstallSeekHooks, 80);
1890
+ win?.setTimeout?.(applyAndInstallSeekHooks, 250);
1891
+ win?.setTimeout?.(applyAndInstallSeekHooks, 500);
1892
+ win?.setTimeout?.(applyAndInstallSeekHooks, 1000);
1893
+ win?.setTimeout?.(applyAndInstallSeekHooks, 2000);
1894
+ },
1895
+ [],
1896
+ );
1897
+
1898
+ const applyStudioManualEditsToPreview = useCallback(
1899
+ async (
1900
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
1901
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1902
+ ) => {
1903
+ const readRevision = studioManualEditRevisionRef.current;
1904
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
1905
+ if (!readFromDiskFirst) {
1906
+ applyCurrentStudioManualEditsToPreview(iframe);
1907
+ }
1908
+ let content: string;
1909
+ try {
1910
+ content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
1911
+ } catch (error) {
1912
+ const message =
1913
+ error instanceof Error ? error.message : "Failed to read manual edit manifest";
1914
+ showToast(message);
1915
+ if (readFromDiskFirst) {
1916
+ applyCurrentStudioManualEditsToPreview(iframe);
1917
+ }
1918
+ return;
1919
+ }
1920
+ if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) {
1921
+ studioManualEditManifestRef.current = parseStudioManualEditManifest(content);
1922
+ if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1;
1923
+ applyCurrentStudioManualEditsToPreview(iframe);
1924
+ return;
1925
+ }
1926
+ if (readFromDiskFirst) {
1927
+ applyCurrentStudioManualEditsToPreview(iframe);
1928
+ }
1929
+ },
1930
+ [applyCurrentStudioManualEditsToPreview, readOptionalProjectFile, showToast],
1931
+ );
1932
+ applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview;
1933
+
1934
+ const applyStudioManualEditsToPreviewAfterRefresh = useCallback(
1935
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
1936
+ applyStudioManualEditsToPreview(iframe, { readFromDiskFirst: true }),
1937
+ [applyStudioManualEditsToPreview],
1938
+ );
1939
+
1940
+ const applyCurrentStudioMotionToPreview = useCallback(
1941
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) => {
1942
+ if (!iframe) return;
1943
+ let doc: Document | null = null;
1944
+ try {
1945
+ doc = iframe.contentDocument;
1946
+ } catch {
1947
+ return;
1948
+ }
1949
+ if (!doc) return;
1950
+ const previewDoc = doc;
1951
+
1952
+ const applyManifest = () => {
1953
+ applyStudioMotionManifest(
1954
+ previewDoc,
1955
+ studioMotionManifestRef.current,
1956
+ activeCompPathRef.current,
1957
+ );
1958
+ };
1959
+ const applyAndInstallSeekHooks = () => {
1960
+ applyManifest();
1961
+ if (iframe.contentWindow) {
1962
+ installStudioMotionSeekReapply(iframe.contentWindow, applyManifest);
1963
+ }
1964
+ };
1965
+
1966
+ const win = iframe.contentWindow;
1967
+ win?.requestAnimationFrame?.(applyAndInstallSeekHooks);
1968
+ win?.setTimeout?.(applyAndInstallSeekHooks, 120);
1969
+ },
1970
+ [],
1971
+ );
1972
+
1973
+ const applyStudioMotionToPreview = useCallback(
1974
+ async (
1975
+ iframe: HTMLIFrameElement | null = previewIframeRef.current,
1976
+ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean },
1977
+ ) => {
1978
+ const readRevision = studioMotionRevisionRef.current;
1979
+ const readFromDiskFirst = Boolean(options?.forceFromDisk || options?.readFromDiskFirst);
1980
+ if (!readFromDiskFirst) {
1981
+ applyCurrentStudioMotionToPreview(iframe);
1982
+ }
1983
+ let content: string;
1984
+ try {
1985
+ content = await readOptionalProjectFile(STUDIO_MOTION_PATH);
1986
+ } catch (error) {
1987
+ const message = error instanceof Error ? error.message : "Failed to read motion manifest";
1988
+ showToast(message);
1989
+ if (readFromDiskFirst) {
1990
+ applyCurrentStudioMotionToPreview(iframe);
1991
+ }
1992
+ return;
1993
+ }
1994
+ if (options?.forceFromDisk || readRevision === studioMotionRevisionRef.current) {
1995
+ studioMotionManifestRef.current = parseStudioMotionManifest(content);
1996
+ if (options?.forceFromDisk) studioMotionRevisionRef.current += 1;
1997
+ setStudioMotionRevision((revision) => revision + 1);
1998
+ applyCurrentStudioMotionToPreview(iframe);
1999
+ return;
2000
+ }
2001
+ if (readFromDiskFirst) {
2002
+ applyCurrentStudioMotionToPreview(iframe);
2003
+ }
2004
+ },
2005
+ [applyCurrentStudioMotionToPreview, readOptionalProjectFile, showToast],
2006
+ );
2007
+ applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview;
2008
+
2009
+ const applyStudioMotionToPreviewAfterRefresh = useCallback(
2010
+ (iframe: HTMLIFrameElement | null = previewIframeRef.current) =>
2011
+ applyStudioMotionToPreview(iframe, { readFromDiskFirst: true }),
2012
+ [applyStudioMotionToPreview],
2013
+ );
2014
+
2015
+ const commitStudioManualEditManifestOptimistically = useCallback(
2016
+ (
2017
+ updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
2018
+ options: { label: string; coalesceKey: string },
2019
+ ) => {
2020
+ const previousManifest = studioManualEditManifestRef.current;
2021
+ const nextManifest = updateManifest(previousManifest);
2022
+ const previousContent = serializeStudioManualEditManifest(previousManifest);
2023
+ const nextContent = serializeStudioManualEditManifest(nextManifest);
2024
+ if (nextContent === previousContent) {
2025
+ return;
2026
+ }
2027
+
2028
+ const revision = studioManualEditRevisionRef.current + 1;
2029
+ studioManualEditRevisionRef.current = revision;
2030
+ studioManualEditManifestRef.current = nextManifest;
2031
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2032
+
2033
+ const save = async () => {
2034
+ const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH);
2035
+ const diskManifest = parseStudioManualEditManifest(originalContent);
2036
+ const nextDiskManifest = updateManifest(diskManifest);
2037
+ const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest);
2038
+ if (nextDiskContent === originalContent) {
2039
+ return;
2040
+ }
2041
+
2042
+ const pid = projectIdRef.current;
2043
+ if (!pid) throw new Error("No active project");
2044
+ domEditSaveTimestampRef.current = Date.now();
2045
+ await saveProjectFilesWithHistory({
2046
+ projectId: pid,
2047
+ label: options.label,
2048
+ kind: "manual",
2049
+ coalesceKey: options.coalesceKey,
2050
+ files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent },
2051
+ readFile: async () => originalContent,
2052
+ writeFile: writeProjectFile,
2053
+ recordEdit: editHistory.recordEdit,
2054
+ });
2055
+ domEditSaveTimestampRef.current = Date.now();
2056
+
2057
+ if (studioManualEditRevisionRef.current === revision) {
2058
+ studioManualEditManifestRef.current = nextDiskManifest;
2059
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2060
+ }
2061
+ };
2062
+
2063
+ void queueDomEditSave(save).catch((error) => {
2064
+ if (studioManualEditRevisionRef.current === revision) {
2065
+ studioManualEditRevisionRef.current += 1;
2066
+ studioManualEditManifestRef.current = previousManifest;
2067
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2068
+ }
2069
+ const message = error instanceof Error ? error.message : "Failed to save manual edit";
2070
+ showToast(message);
2071
+ });
2072
+ },
2073
+ [
2074
+ applyCurrentStudioManualEditsToPreview,
2075
+ editHistory.recordEdit,
2076
+ queueDomEditSave,
2077
+ readOptionalProjectFile,
2078
+ showToast,
2079
+ writeProjectFile,
2080
+ ],
2081
+ );
2082
+
2083
+ const commitStudioMotionManifestOptimistically = useCallback(
2084
+ (
2085
+ updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
2086
+ options: { label: string; coalesceKey: string },
2087
+ ) => {
2088
+ const previousManifest = studioMotionManifestRef.current;
2089
+ const nextManifest = updateManifest(previousManifest);
2090
+ const previousContent = serializeStudioMotionManifest(previousManifest);
2091
+ const nextContent = serializeStudioMotionManifest(nextManifest);
2092
+ if (nextContent === previousContent) {
2093
+ return;
2094
+ }
2095
+
2096
+ const revision = studioMotionRevisionRef.current + 1;
2097
+ studioMotionRevisionRef.current = revision;
2098
+ studioMotionManifestRef.current = nextManifest;
2099
+ setStudioMotionRevision((current) => current + 1);
2100
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2101
+
2102
+ const save = async () => {
2103
+ const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH);
2104
+ const diskManifest = parseStudioMotionManifest(originalContent);
2105
+ const nextDiskManifest = updateManifest(diskManifest);
2106
+ const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest);
2107
+ if (nextDiskContent === originalContent) {
2108
+ return;
2109
+ }
2110
+
2111
+ const pid = projectIdRef.current;
2112
+ if (!pid) throw new Error("No active project");
2113
+ domEditSaveTimestampRef.current = Date.now();
2114
+ await saveProjectFilesWithHistory({
2115
+ projectId: pid,
2116
+ label: options.label,
2117
+ kind: "motion",
2118
+ coalesceKey: options.coalesceKey,
2119
+ files: { [STUDIO_MOTION_PATH]: nextDiskContent },
2120
+ readFile: async () => originalContent,
2121
+ writeFile: writeProjectFile,
2122
+ recordEdit: editHistory.recordEdit,
2123
+ });
2124
+ domEditSaveTimestampRef.current = Date.now();
2125
+
2126
+ if (studioMotionRevisionRef.current === revision) {
2127
+ studioMotionManifestRef.current = nextDiskManifest;
2128
+ setStudioMotionRevision((current) => current + 1);
2129
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2130
+ }
2131
+ };
2132
+
2133
+ void queueDomEditSave(save).catch((error) => {
2134
+ if (studioMotionRevisionRef.current === revision) {
2135
+ studioMotionRevisionRef.current += 1;
2136
+ studioMotionManifestRef.current = previousManifest;
2137
+ setStudioMotionRevision((current) => current + 1);
2138
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2139
+ }
2140
+ const message = error instanceof Error ? error.message : "Failed to save motion edit";
2141
+ showToast(message);
2142
+ });
2143
+ },
2144
+ [
2145
+ applyCurrentStudioMotionToPreview,
2146
+ editHistory.recordEdit,
2147
+ queueDomEditSave,
2148
+ readOptionalProjectFile,
2149
+ showToast,
2150
+ writeProjectFile,
2151
+ ],
2152
+ );
2153
+
2154
+ const syncHistoryPreviewAfterApply = useCallback(
2155
+ async (paths: string[] | undefined) => {
2156
+ const changedPaths = paths ?? [];
2157
+ const manualManifestOnly =
2158
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH);
2159
+ const motionManifestOnly =
2160
+ changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH);
2161
+
2162
+ if (manualManifestOnly) {
2163
+ await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true });
2164
+ return;
2165
+ }
2166
+ if (motionManifestOnly) {
2167
+ await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true });
2168
+ return;
2169
+ }
2170
+
2171
+ setRefreshKey((key) => key + 1);
2172
+ },
2173
+ [applyStudioManualEditsToPreview, applyStudioMotionToPreview],
2174
+ );
2175
+
2176
+ const handleUndo = useCallback(async () => {
2177
+ await waitForPendingDomEditSaves();
2178
+ const result = await editHistory.undo({
2179
+ readFile: readHistoryProjectFile,
2180
+ writeFile: writeHistoryProjectFile,
2181
+ });
2182
+ if (!result.ok && result.reason === "content-mismatch") {
2183
+ showToast("File changed outside Studio. Undo history was not applied.", "info");
2184
+ return;
2185
+ }
2186
+ if (result.ok && result.label) {
2187
+ clearDomSelection();
2188
+ await syncHistoryPreviewAfterApply(result.paths);
2189
+ showToast(`Undid ${result.label}`, "info");
2190
+ }
2191
+ }, [
2192
+ clearDomSelection,
2193
+ editHistory,
2194
+ readHistoryProjectFile,
2195
+ showToast,
2196
+ syncHistoryPreviewAfterApply,
2197
+ waitForPendingDomEditSaves,
2198
+ writeHistoryProjectFile,
2199
+ ]);
2200
+
2201
+ const handleRedo = useCallback(async () => {
2202
+ await waitForPendingDomEditSaves();
2203
+ const result = await editHistory.redo({
2204
+ readFile: readHistoryProjectFile,
2205
+ writeFile: writeHistoryProjectFile,
2206
+ });
2207
+ if (!result.ok && result.reason === "content-mismatch") {
2208
+ showToast("File changed outside Studio. Redo history was not applied.", "info");
2209
+ return;
2210
+ }
2211
+ if (result.ok && result.label) {
2212
+ clearDomSelection();
2213
+ await syncHistoryPreviewAfterApply(result.paths);
2214
+ showToast(`Redid ${result.label}`, "info");
2215
+ }
2216
+ }, [
2217
+ clearDomSelection,
2218
+ editHistory,
2219
+ readHistoryProjectFile,
2220
+ showToast,
2221
+ syncHistoryPreviewAfterApply,
2222
+ waitForPendingDomEditSaves,
2223
+ writeHistoryProjectFile,
2224
+ ]);
2225
+
2226
+ const handleUndoRef = useRef(handleUndo);
2227
+ const handleRedoRef = useRef(handleRedo);
2228
+ handleUndoRef.current = handleUndo;
2229
+ handleRedoRef.current = handleRedo;
2230
+
2231
+ const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
2232
+ if (!(event.metaKey || event.ctrlKey)) return;
2233
+ if (shouldIgnoreHistoryShortcut(event.target)) return;
2234
+ const key = event.key.toLowerCase();
2235
+ if (key === "z" && !event.shiftKey) {
2236
+ event.preventDefault();
2237
+ void handleUndoRef.current();
2238
+ return;
2239
+ }
2240
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
2241
+ event.preventDefault();
2242
+ void handleRedoRef.current();
2243
+ }
2244
+ }, []);
2245
+
2246
+ // eslint-disable-next-line no-restricted-syntax
2247
+ useEffect(() => {
2248
+ window.addEventListener("keydown", handleHistoryHotkey, true);
2249
+ return () => window.removeEventListener("keydown", handleHistoryHotkey, true);
2250
+ }, [handleHistoryHotkey]);
2251
+
2252
+ const syncPreviewHistoryHotkey = useCallback(
2253
+ (iframe: HTMLIFrameElement | null) => {
2254
+ previewHistoryHotkeyCleanupRef.current?.();
2255
+ previewHistoryHotkeyCleanupRef.current = null;
2256
+
2257
+ const win = iframe?.contentWindow ?? null;
2258
+ let doc: Document | null = null;
2259
+ try {
2260
+ doc = iframe?.contentDocument ?? null;
2261
+ } catch {
2262
+ doc = null;
2263
+ }
2264
+ if (!win && !doc) return;
2265
+
2266
+ win?.addEventListener("keydown", handleHistoryHotkey, true);
2267
+ doc?.addEventListener("keydown", handleHistoryHotkey, true);
2268
+ previewHistoryHotkeyCleanupRef.current = () => {
2269
+ win?.removeEventListener("keydown", handleHistoryHotkey, true);
2270
+ doc?.removeEventListener("keydown", handleHistoryHotkey, true);
2271
+ };
2272
+ },
2273
+ [handleHistoryHotkey],
2274
+ );
2275
+
2276
+ useEffect(
2277
+ () => () => {
2278
+ previewHistoryHotkeyCleanupRef.current?.();
2279
+ previewHistoryHotkeyCleanupRef.current = null;
2280
+ },
2281
+ [],
2282
+ );
2283
+
2284
+ const buildDomSelectionFromTarget = useCallback(
2285
+ (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
2286
+ return resolveDomEditSelection(target, {
2287
+ activeCompositionPath: activeCompPath,
2288
+ isMasterView,
2289
+ preferClipAncestor: options?.preferClipAncestor,
2290
+ });
2291
+ },
2292
+ [activeCompPath, isMasterView],
2293
+ );
2294
+
2295
+ const resolveDomSelectionFromPreviewPoint = useCallback(
2296
+ (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2297
+ const iframe = previewIframeRef.current;
2298
+ if (!iframe || captionEditMode) return null;
2299
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
2300
+ if (!target) return null;
2301
+ return buildDomSelectionFromTarget(target, {
2302
+ preferClipAncestor: options?.preferClipAncestor,
2303
+ });
2304
+ },
2305
+ [buildDomSelectionFromTarget, captionEditMode],
2306
+ );
2307
+
2308
+ const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
2309
+ if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
2310
+ domEditHoverSelectionRef.current = selection;
2311
+ setDomEditHoverSelection(selection);
2312
+ }, []);
2313
+
2314
+ const buildDomSelectionForTimelineElement = useCallback(
2315
+ (element: TimelineElement): DomEditSelection | null => {
2316
+ const iframe = previewIframeRef.current;
2317
+ let doc: Document | null = null;
2318
+ try {
2319
+ doc = iframe?.contentDocument ?? null;
2320
+ } catch {
2321
+ return null;
2322
+ }
2323
+ if (!doc) return null;
2324
+
2325
+ const targetElement = findElementForTimelineElement(doc, element, {
2326
+ activeCompositionPath: activeCompPath,
2327
+ compIdToSrc,
2328
+ isMasterView,
2329
+ });
2330
+ return targetElement
2331
+ ? buildDomSelectionFromTarget(targetElement, { preferClipAncestor: false })
2332
+ : null;
2333
+ },
2334
+ [activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView],
2335
+ );
2336
+
2337
+ const inspectedTimelineElement = useMemo(
2338
+ () =>
2339
+ timelineElements.find(
2340
+ (element) => getTimelineElementKey(element) === inspectedTimelineElementId,
2341
+ ) ?? null,
2342
+ [inspectedTimelineElementId, timelineElements],
2343
+ );
2344
+
2345
+ const timelineLayerChildCounts = useMemo(() => {
2346
+ void previewDocumentVersion;
2347
+ const counts = new Map<string, number>();
2348
+ if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return counts;
2349
+
2350
+ const key = getTimelineElementKey(inspectedTimelineElement);
2351
+ if (key) {
2352
+ const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
2353
+ const count = countDomEditChildLayers(selection?.element, {
2354
+ activeCompositionPath: activeCompPath,
2355
+ isMasterView,
2356
+ });
2357
+ if (count > 0) counts.set(key, count);
2358
+ }
2359
+
2360
+ return counts;
2361
+ }, [
2362
+ activeCompPath,
2363
+ buildDomSelectionForTimelineElement,
2364
+ inspectedTimelineElement,
2365
+ isMasterView,
2366
+ previewDocumentVersion,
2367
+ ]);
2368
+
2369
+ const inspectedTimelineLayers = useMemo(() => {
2370
+ void previewDocumentVersion;
2371
+ if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !inspectedTimelineElement) return [];
2372
+ const selection = buildDomSelectionForTimelineElement(inspectedTimelineElement);
2373
+ return collectDomEditLayerItems(selection?.element, {
2374
+ activeCompositionPath: activeCompPath,
2375
+ isMasterView,
2376
+ });
2377
+ }, [
2378
+ activeCompPath,
2379
+ buildDomSelectionForTimelineElement,
2380
+ inspectedTimelineElement,
2381
+ isMasterView,
2382
+ previewDocumentVersion,
2383
+ ]);
2384
+
2385
+ const selectedTimelineLayerKey = useMemo(
2386
+ () => (domEditSelection ? getDomEditLayerKey(domEditSelection) : null),
2387
+ [domEditSelection],
2388
+ );
2389
+
2390
+ const handleTimelineElementSelect = useCallback(
2391
+ (element: TimelineElement | null) => {
2392
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
2393
+ if (!element) {
2394
+ applyDomSelection(null, { revealPanel: false });
2395
+ setInspectedTimelineElementId(null);
2396
+ return;
2397
+ }
2398
+
2399
+ const selection = buildDomSelectionForTimelineElement(element);
2400
+ if (selection) applyDomSelection(selection);
2401
+ },
2402
+ [applyDomSelection, buildDomSelectionForTimelineElement],
2403
+ );
2404
+
2405
+ const handleTimelineElementInspect = useCallback(
2406
+ (element: TimelineElement) => {
2407
+ if (!STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED || !STUDIO_INSPECTOR_PANELS_ENABLED) return;
2408
+ if (!canInspectTimelineElement(element)) {
2409
+ showToast("Audio clips do not have visual layers.", "info");
2410
+ return;
2411
+ }
2412
+
2413
+ const key = getTimelineElementKey(element);
2414
+ if (!key) return;
2415
+ setInspectedTimelineElementId((current) => (current === key ? null : key));
2416
+ setLeftCollapsed(false);
2417
+
2418
+ const iframe = previewIframeRef.current;
2419
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2420
+ seekStudioPreview(iframe, element.start);
2421
+ }
2422
+
2423
+ const selection = buildDomSelectionForTimelineElement(element);
2424
+ if (selection) applyDomSelection(selection);
2425
+ },
2426
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2427
+ );
2428
+
2429
+ const handleToggleTimelineElementThumbnail = useCallback((element: TimelineElement) => {
2430
+ const key = getTimelineElementKey(element);
2431
+ if (!key) return;
2432
+ setThumbnailedTimelineElementIds((current) => {
2433
+ const next = new Set(current);
2434
+ if (next.has(key)) next.delete(key);
2435
+ else next.add(key);
2436
+ return next;
2437
+ });
2438
+ }, []);
2439
+
2440
+ const handleTimelineLayerSelect = useCallback(
2441
+ (layer: DomEditLayerItem) => {
2442
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
2443
+
2444
+ const iframe = previewIframeRef.current;
2445
+ const player = getPreviewPlayer(iframe?.contentWindow);
2446
+ const visibleTime = resolveLayerVisibleSeekTime(
2447
+ layer.element,
2448
+ inspectedTimelineElement,
2449
+ player,
2450
+ );
2451
+ if (visibleTime != null) {
2452
+ seekStudioPreview(iframe, visibleTime);
2453
+ }
2454
+
2455
+ const selection = buildDomSelectionFromTarget(layer.element, { preferClipAncestor: false });
2456
+ if (!selection) {
2457
+ showToast("Studio could not resolve this nested layer.", "error");
2458
+ return;
2459
+ }
2460
+
2461
+ applyDomSelection(selection);
2462
+ requestAnimationFrame(refreshPreviewDocumentVersion);
2463
+ },
2464
+ [
2465
+ applyDomSelection,
2466
+ buildDomSelectionFromTarget,
2467
+ inspectedTimelineElement,
2468
+ refreshPreviewDocumentVersion,
2469
+ showToast,
2470
+ ],
2471
+ );
2472
+
2473
+ const handleTimelineLayerPanelClose = useCallback(() => {
2474
+ setInspectedTimelineElementId(null);
2475
+ }, []);
2476
+
2477
+ const preloadAgentPromptSnippet = useCallback(
2478
+ async (selection: DomEditSelection) => {
2479
+ const pid = projectIdRef.current;
2480
+ if (!pid) return;
2481
+
2482
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
2483
+ try {
2484
+ const response = await fetch(
2485
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
2486
+ );
2487
+ if (!response.ok) return;
2488
+
2489
+ const data = (await response.json()) as { content?: string };
2490
+ const html = data.content;
2491
+ const tagSnippet =
2492
+ typeof html === "string" ? readTagSnippetByTarget(html, selection) : undefined;
2493
+
2494
+ setAgentPromptTagSnippet((current) => {
2495
+ if (domEditSelectionRef.current !== selection) return current;
2496
+ return tagSnippet;
2497
+ });
2498
+ } catch {
2499
+ // Runtime outerHTML is still available as a synchronous copy fallback.
2500
+ }
2501
+ },
2502
+ [activeCompPath],
2503
+ );
2504
+
2505
+ const resolveImportedFontAsset = useCallback(
2506
+ (fontFamilyValue: string): ImportedFontAsset | null => {
2507
+ const family = primaryFontFamilyValue(fontFamilyValue);
2508
+ if (!family) return null;
2509
+ const imported = importedFontAssetsRef.current.find(
2510
+ (font) => font.family.toLowerCase() === family.toLowerCase(),
2511
+ );
2512
+ if (imported) return imported;
2513
+ const asset = fileTree.find(
2514
+ (path) =>
2515
+ FONT_EXT.test(path) &&
2516
+ fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
2517
+ );
2518
+ if (!asset) return null;
2519
+ return {
2520
+ family: fontFamilyFromAssetPath(asset),
2521
+ path: asset,
2522
+ url: `/api/projects/${projectId}/preview/${asset}`,
2523
+ };
2524
+ },
2525
+ [fileTree, projectId],
2526
+ );
2527
+
2528
+ const persistDomEditOperations = useCallback(
2529
+ async (
2530
+ selection: DomEditSelection,
2531
+ operations: Parameters<typeof applyPatchByTarget>[2][],
2532
+ options?: {
2533
+ label?: string;
2534
+ coalesceKey?: string;
2535
+ skipRefresh?: boolean;
2536
+ prepareContent?: (html: string, sourceFile: string) => string;
2537
+ shouldSave?: () => boolean;
2538
+ },
2539
+ ) => {
2540
+ const pid = projectIdRef.current;
2541
+ if (!pid) throw new Error("No active project");
2542
+ if (options?.shouldSave && !options.shouldSave()) return;
2543
+
2544
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
2545
+ const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
2546
+ if (!response.ok) {
2547
+ throw new Error(`Failed to read ${targetPath}`);
2548
+ }
2549
+
2550
+ const data = (await response.json()) as { content?: string };
2551
+ const originalContent = data.content;
2552
+ if (typeof originalContent !== "string") {
2553
+ throw new Error(`Missing file contents for ${targetPath}`);
2554
+ }
2555
+
2556
+ let patchedContent = originalContent;
2557
+ for (const operation of operations) {
2558
+ patchedContent = applyPatchByTarget(patchedContent, selection, operation);
2559
+ }
2560
+ if (options?.prepareContent) {
2561
+ patchedContent = options.prepareContent(patchedContent, targetPath);
2562
+ }
2563
+ if (options?.shouldSave && !options.shouldSave()) return;
2564
+
2565
+ if (patchedContent === originalContent) {
2566
+ throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
2567
+ }
2568
+
2569
+ await saveProjectFilesWithHistory({
2570
+ projectId: pid,
2571
+ label: options?.label ?? "Edit layer",
2572
+ kind: "manual",
2573
+ coalesceKey: options?.coalesceKey,
2574
+ files: { [targetPath]: patchedContent },
2575
+ readFile: async () => originalContent,
2576
+ writeFile: writeProjectFile,
2577
+ recordEdit: editHistory.recordEdit,
2578
+ });
2579
+
2580
+ if (options?.skipRefresh) {
2581
+ domEditSaveTimestampRef.current = Date.now();
2582
+ } else {
2583
+ setRefreshKey((k) => k + 1);
2584
+ }
2585
+ },
2586
+ [activeCompPath, editHistory.recordEdit, writeProjectFile],
2587
+ );
2588
+
2589
+ const refreshDomEditSelectionFromPreview = useCallback(
2590
+ (selection: DomEditSelection) => {
2591
+ const iframe = previewIframeRef.current;
2592
+ let doc: Document | null = null;
2593
+ try {
2594
+ doc = iframe?.contentDocument ?? null;
2595
+ } catch {
2596
+ return;
2597
+ }
2598
+ if (!doc) return;
2599
+
2600
+ const element = findElementForSelection(doc, selection, activeCompPath);
2601
+ if (!element) return;
2602
+
2603
+ const nextSelection = buildDomSelectionFromTarget(element);
2604
+ if (nextSelection) {
2605
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2606
+ }
2607
+ },
2608
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget],
2609
+ );
2610
+
2611
+ const refreshDomEditGroupSelectionsFromPreview = useCallback(
2612
+ (selections: DomEditSelection[]) => {
2613
+ const iframe = previewIframeRef.current;
2614
+ let doc: Document | null = null;
2615
+ try {
2616
+ doc = iframe?.contentDocument ?? null;
2617
+ } catch {
2618
+ return;
2619
+ }
2620
+ if (!doc) return;
2621
+
2622
+ const nextGroup: DomEditSelection[] = [];
2623
+ for (const selection of selections) {
2624
+ const element = findElementForSelection(doc, selection, activeCompPath);
2625
+ if (!element) continue;
2626
+ const nextSelection = buildDomSelectionFromTarget(element);
2627
+ if (nextSelection) nextGroup.push(nextSelection);
2628
+ }
2629
+ if (nextGroup.length === 0) return;
2630
+
2631
+ const currentSelection = domEditSelectionRef.current;
2632
+ const nextSelection =
2633
+ nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
2634
+ nextGroup[0] ??
2635
+ null;
2636
+
2637
+ setAgentPromptTagSnippet(undefined);
2638
+ setCopiedAgentPrompt(false);
2639
+ domEditSelectionRef.current = nextSelection;
2640
+ domEditGroupSelectionsRef.current = nextGroup;
2641
+ setDomEditSelection(nextSelection);
2642
+ setDomEditGroupSelections(nextGroup);
2643
+
2644
+ if (nextSelection) {
2645
+ setSelectedTimelineElementId(
2646
+ findMatchingTimelineElementId(nextSelection, timelineElements),
2647
+ );
2648
+ } else {
2649
+ setSelectedTimelineElementId(null);
2650
+ }
2651
+ },
2652
+ [activeCompPath, buildDomSelectionFromTarget, setSelectedTimelineElementId, timelineElements],
2653
+ );
2654
+
2655
+ const handleDomManualDragStart = useCallback(() => {
2656
+ const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
2657
+ const playerStore = usePlayerStore.getState();
2658
+ playerStore.setIsPlaying(false);
2659
+ if (pausedTime != null) {
2660
+ playerStore.setCurrentTime(pausedTime);
2661
+ liveTime.notify(pausedTime);
2662
+ }
2663
+ }, []);
2664
+
2665
+ const handleDomPathOffsetCommit = useCallback(
2666
+ (selection: DomEditSelection, next: { x: number; y: number }) => {
2667
+ commitStudioManualEditManifestOptimistically(
2668
+ (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
2669
+ {
2670
+ label: "Move layer",
2671
+ coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
2672
+ },
2673
+ );
2674
+ refreshDomEditSelectionFromPreview(selection);
2675
+ },
2676
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2677
+ );
2678
+
2679
+ const handleDomGroupPathOffsetCommit = useCallback(
2680
+ (updates: DomEditGroupPathOffsetCommit[]) => {
2681
+ if (updates.length === 0) return;
2682
+ const coalesceKey = updates
2683
+ .map((update) => getDomEditTargetKey(update.selection))
2684
+ .sort()
2685
+ .join(":");
2686
+ commitStudioManualEditManifestOptimistically(
2687
+ (manifest) =>
2688
+ updates.reduce(
2689
+ (nextManifest, update) =>
2690
+ upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
2691
+ manifest,
2692
+ ),
2693
+ {
2694
+ label: `Move ${updates.length} layers`,
2695
+ coalesceKey: `group-path-offset:${coalesceKey}`,
2696
+ },
2697
+ );
2698
+ refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
2699
+ },
2700
+ [commitStudioManualEditManifestOptimistically, refreshDomEditGroupSelectionsFromPreview],
2701
+ );
2702
+
2703
+ const handleDomBoxSizeCommit = useCallback(
2704
+ (selection: DomEditSelection, next: { width: number; height: number }) => {
2705
+ commitStudioManualEditManifestOptimistically(
2706
+ (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
2707
+ {
2708
+ label: "Resize layer box",
2709
+ coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
2710
+ },
2711
+ );
2712
+ refreshDomEditSelectionFromPreview(selection);
2713
+ },
2714
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2715
+ );
2716
+
2717
+ const handleDomRotationCommit = useCallback(
2718
+ (selection: DomEditSelection, next: { angle: number }) => {
2719
+ commitStudioManualEditManifestOptimistically(
2720
+ (manifest) => upsertStudioRotationEdit(manifest, selection, next),
2721
+ {
2722
+ label: "Rotate layer",
2723
+ coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
2724
+ },
2725
+ );
2726
+ refreshDomEditSelectionFromPreview(selection);
2727
+ },
2728
+ [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
2729
+ );
2730
+
2731
+ const handleDomManualEditsReset = useCallback(
2732
+ (selection: DomEditSelection) => {
2733
+ commitStudioManualEditManifestOptimistically(
2734
+ (manifest) => removeStudioManualEditsForSelection(manifest, selection),
2735
+ {
2736
+ label: "Reset layer edits",
2737
+ coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
2738
+ },
2739
+ );
2740
+ applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
2741
+ refreshDomEditSelectionFromPreview(selection);
2742
+ },
2743
+ [
2744
+ applyCurrentStudioManualEditsToPreview,
2745
+ commitStudioManualEditManifestOptimistically,
2746
+ refreshDomEditSelectionFromPreview,
2747
+ ],
2748
+ );
2749
+
2750
+ const handleDomMotionCommit = useCallback(
2751
+ (
2752
+ selection: DomEditSelection,
2753
+ motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
2754
+ ) => {
2755
+ commitStudioMotionManifestOptimistically(
2756
+ (manifest) => upsertStudioGsapMotion(manifest, selection, motion),
2757
+ {
2758
+ label: "Set GSAP motion",
2759
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
2760
+ },
2761
+ );
2762
+ refreshDomEditSelectionFromPreview(selection);
2763
+ },
2764
+ [commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
2765
+ );
2766
+
2767
+ const handleDomMotionClear = useCallback(
2768
+ (selection: DomEditSelection) => {
2769
+ commitStudioMotionManifestOptimistically(
2770
+ (manifest) => removeStudioMotionForSelection(manifest, selection),
2771
+ {
2772
+ label: "Clear GSAP motion",
2773
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
2774
+ },
2775
+ );
2776
+ applyCurrentStudioMotionToPreview(previewIframeRef.current);
2777
+ refreshDomEditSelectionFromPreview(selection);
2778
+ },
2779
+ [
2780
+ applyCurrentStudioMotionToPreview,
2781
+ commitStudioMotionManifestOptimistically,
2782
+ refreshDomEditSelectionFromPreview,
2783
+ ],
2784
+ );
2785
+
2786
+ const handleDomStyleCommit = useCallback(
2787
+ async (property: string, value: string) => {
2788
+ if (!domEditSelection) return;
2789
+ if (isManualGeometryStyleProperty(property)) return;
2790
+ if (!domEditSelection.capabilities.canEditStyles) return;
2791
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
2792
+ const iframe = previewIframeRef.current;
2793
+ const doc = iframe?.contentDocument;
2794
+ if (doc) {
2795
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
2796
+ if (el) {
2797
+ el.style.setProperty(property, normalizeDomEditStyleValue(property, value));
2798
+ if (property === "font-family") {
2799
+ injectPreviewGoogleFont(doc, value);
2800
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
2801
+ }
2802
+ if (property === "background-image" && isImageBackgroundValue(value)) {
2803
+ el.style.setProperty("background-position", "center");
2804
+ el.style.setProperty("background-repeat", "no-repeat");
2805
+ el.style.setProperty("background-size", "contain");
2806
+ }
2807
+ }
2808
+ }
2809
+ const operations: PatchOperation[] = [
2810
+ buildDomEditStylePatchOperation(property, normalizeDomEditStyleValue(property, value)),
2811
+ ];
2812
+ if (property === "background-image" && isImageBackgroundValue(value)) {
2813
+ operations.push(
2814
+ buildDomEditStylePatchOperation("background-position", "center"),
2815
+ buildDomEditStylePatchOperation("background-repeat", "no-repeat"),
2816
+ buildDomEditStylePatchOperation("background-size", "contain"),
2817
+ );
2818
+ }
2819
+ await persistDomEditOperations(domEditSelection, operations, {
2820
+ label: "Edit layer style",
2821
+ skipRefresh: true,
2822
+ prepareContent: importedFont
2823
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2824
+ : undefined,
2825
+ });
2826
+ },
2827
+ [activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
2828
+ );
2829
+
2830
+ const handleDomTextCommit = useCallback(
2831
+ async (value: string, fieldKey?: string) => {
2832
+ if (!domEditSelection) return;
2833
+ if (!isTextEditableSelection(domEditSelection)) return;
2834
+ const commitVersion = domTextCommitVersionRef.current + 1;
2835
+ domTextCommitVersionRef.current = commitVersion;
2836
+ const nextTextFields =
2837
+ domEditSelection.textFields.length > 0
2838
+ ? domEditSelection.textFields.map((field) =>
2839
+ field.key === fieldKey ? { ...field, value } : field,
2840
+ )
2841
+ : [];
2842
+ const nextContent =
2843
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
2844
+ ? serializeDomEditTextFields(nextTextFields)
2845
+ : value;
2846
+ const iframe = previewIframeRef.current;
2847
+ const doc = iframe?.contentDocument;
2848
+ if (doc) {
2849
+ const el = findElementForSelection(doc, domEditSelection, activeCompPath);
2850
+ if (el) {
2851
+ if (
2852
+ nextTextFields.length > 1 ||
2853
+ nextTextFields.some((field) => field.source === "child")
2854
+ ) {
2855
+ el.innerHTML = nextContent;
2856
+ } else {
2857
+ el.textContent = value;
2858
+ }
2859
+ }
2860
+ }
2861
+ await persistDomEditOperations(
2862
+ domEditSelection,
2863
+ [buildDomEditTextPatchOperation(nextContent)],
2864
+ {
2865
+ label: "Edit text",
2866
+ skipRefresh: true,
2867
+ shouldSave: () => domTextCommitVersionRef.current === commitVersion,
2868
+ },
2869
+ );
2870
+ if (domTextCommitVersionRef.current !== commitVersion) return;
2871
+
2872
+ if (doc) {
2873
+ const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
2874
+ if (refreshed) {
2875
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
2876
+ if (nextSelection) {
2877
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2878
+ }
2879
+ }
2880
+ }
2881
+ },
2882
+ [
2883
+ activeCompPath,
2884
+ applyDomSelection,
2885
+ buildDomSelectionFromTarget,
2886
+ domEditSelection,
2887
+ persistDomEditOperations,
2888
+ ],
2889
+ );
2890
+
2891
+ const commitDomTextFields = useCallback(
2892
+ async (
2893
+ selection: DomEditSelection,
2894
+ nextTextFields: DomEditTextField[],
2895
+ options?: { importedFont?: ImportedFontAsset | null },
2896
+ ) => {
2897
+ const nextContent =
2898
+ nextTextFields.length > 1 || nextTextFields.some((field) => field.source === "child")
2899
+ ? serializeDomEditTextFields(nextTextFields)
2900
+ : (nextTextFields[0]?.value ?? "");
2901
+
2902
+ const iframe = previewIframeRef.current;
2903
+ const doc = iframe?.contentDocument;
2904
+ if (doc) {
2905
+ const el = findElementForSelection(doc, selection, activeCompPath);
2906
+ if (el) {
2907
+ if (
2908
+ nextTextFields.length > 1 ||
2909
+ nextTextFields.some((field) => field.source === "child")
2910
+ ) {
2911
+ el.innerHTML = nextContent;
2912
+ } else {
2913
+ el.textContent = nextContent;
2914
+ }
2915
+ }
2916
+ }
2917
+
2918
+ const importedFont = options?.importedFont ?? null;
2919
+ await persistDomEditOperations(selection, [buildDomEditTextPatchOperation(nextContent)], {
2920
+ label: "Edit text",
2921
+ skipRefresh: true,
2922
+ prepareContent: importedFont
2923
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
2924
+ : undefined,
2925
+ });
2926
+
2927
+ if (doc) {
2928
+ const refreshed = findElementForSelection(doc, selection, activeCompPath);
2929
+ if (refreshed) {
2930
+ const nextSelection = buildDomSelectionFromTarget(refreshed);
2931
+ if (nextSelection) {
2932
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
2933
+ }
2934
+ }
2935
+ }
2936
+ },
2937
+ [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, persistDomEditOperations],
2938
+ );
2939
+
2940
+ const handleDomTextFieldStyleCommit = useCallback(
2941
+ async (fieldKey: string, property: string, value: string) => {
2942
+ if (!domEditSelection) return;
2943
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
2944
+ if (!field) return;
2945
+
2946
+ if (field.source === "self") {
2947
+ await handleDomStyleCommit(property, value);
2948
+ return;
2949
+ }
2950
+
2951
+ const normalizedValue = normalizeDomEditStyleValue(property, value);
2952
+ const importedFont = property === "font-family" ? resolveImportedFontAsset(value) : null;
2953
+ if (property === "font-family") {
2954
+ const doc = previewIframeRef.current?.contentDocument;
2955
+ if (doc) {
2956
+ injectPreviewGoogleFont(doc, normalizedValue);
2957
+ if (importedFont) injectPreviewImportedFont(doc, importedFont);
2958
+ }
2959
+ }
2960
+ const nextTextFields = domEditSelection.textFields.map((entry) =>
2961
+ entry.key === fieldKey
2962
+ ? {
2963
+ ...entry,
2964
+ inlineStyles: {
2965
+ ...entry.inlineStyles,
2966
+ [property]: normalizedValue,
2967
+ },
2968
+ computedStyles: {
2969
+ ...entry.computedStyles,
2970
+ [property]: normalizedValue,
2971
+ },
2972
+ }
2973
+ : entry,
2974
+ );
2975
+
2976
+ await commitDomTextFields(domEditSelection, nextTextFields, { importedFont });
2977
+ },
2978
+ [commitDomTextFields, domEditSelection, handleDomStyleCommit, resolveImportedFontAsset],
2979
+ );
2980
+
2981
+ const handleDomAddTextField = useCallback(
2982
+ async (afterFieldKey?: string) => {
2983
+ if (!domEditSelection) return null;
2984
+ if (!domEditSelection.textFields.some((field) => field.source === "child")) return null;
2985
+
2986
+ const insertionIndex = domEditSelection.textFields.findIndex(
2987
+ (field) => field.key === afterFieldKey,
2988
+ );
2989
+ const baseField =
2990
+ domEditSelection.textFields[insertionIndex >= 0 ? insertionIndex : 0] ??
2991
+ domEditSelection.textFields[0];
2992
+ const nextField = buildDefaultDomEditTextField(baseField);
2993
+ const nextTextFields = [...domEditSelection.textFields];
2994
+ nextTextFields.splice(
2995
+ insertionIndex >= 0 ? insertionIndex + 1 : nextTextFields.length,
2996
+ 0,
2997
+ nextField,
2998
+ );
2999
+
3000
+ await commitDomTextFields(domEditSelection, nextTextFields);
3001
+ return nextField.key;
3002
+ },
3003
+ [commitDomTextFields, domEditSelection],
3004
+ );
3005
+
3006
+ const handleDomRemoveTextField = useCallback(
3007
+ async (fieldKey: string) => {
3008
+ if (!domEditSelection) return;
3009
+ const field = domEditSelection.textFields.find((entry) => entry.key === fieldKey);
3010
+ if (!field) return;
3011
+
3012
+ if (field.source === "self") {
3013
+ await handleDomTextCommit("", fieldKey);
3014
+ return;
3015
+ }
3016
+
3017
+ const nextTextFields = domEditSelection.textFields.filter((entry) => entry.key !== fieldKey);
3018
+ await commitDomTextFields(domEditSelection, nextTextFields);
3019
+ },
3020
+ [commitDomTextFields, domEditSelection, handleDomTextCommit],
3021
+ );
3022
+
3023
+ const handleAskAgent = useCallback(() => {
3024
+ if (!domEditSelection) return;
3025
+ setAgentPromptTagSnippet(undefined);
3026
+ void preloadAgentPromptSnippet(domEditSelection);
3027
+ setAgentModalOpen(true);
3028
+ }, [domEditSelection, preloadAgentPromptSnippet]);
3029
+
3030
+ const handleAgentModalSubmit = useCallback(
3031
+ async (userInstruction: string) => {
3032
+ if (!domEditSelection) return;
3033
+
3034
+ const targetPath = domEditSelection.sourceFile || activeCompPath || "index.html";
3035
+ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML;
3036
+ const prompt = buildElementAgentPrompt({
3037
+ selection: domEditSelection,
3038
+ currentTime,
3039
+ tagSnippet,
3040
+ userInstruction,
3041
+ sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3042
+ });
3043
+
3044
+ const copied = await copyTextToClipboard(prompt);
3045
+ if (!copied) {
3046
+ showToast("Could not copy prompt to clipboard.", "error");
3047
+ return;
3048
+ }
3049
+
3050
+ setAgentModalOpen(false);
3051
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3052
+ setCopiedAgentPrompt(true);
3053
+ copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3054
+ },
3055
+ [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
3056
+ );
3057
+
3058
+ const handlePreviewIframeRef = useCallback(
3059
+ (iframe: HTMLIFrameElement | null) => {
3060
+ previewIframeRef.current = iframe;
3061
+ setPreviewIframe(iframe);
3062
+ syncPreviewTimelineHotkey(iframe);
3063
+ syncPreviewHistoryHotkey(iframe);
3064
+ consoleErrorsRef.current = [];
3065
+ setConsoleErrors(null);
3066
+ refreshPreviewDocumentVersion();
3067
+ },
3068
+ [refreshPreviewDocumentVersion, syncPreviewHistoryHotkey, syncPreviewTimelineHotkey],
3069
+ );
3070
+
3071
+ const handlePreviewCanvasMouseDown = useCallback(
3072
+ (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3073
+ if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
3074
+ const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3075
+ preferClipAncestor: options?.preferClipAncestor ?? true,
3076
+ });
3077
+ if (!nextSelection) {
3078
+ if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
3079
+ return;
3080
+ }
3081
+ e.preventDefault();
3082
+ e.stopPropagation();
3083
+ applyDomSelection(nextSelection, { additive: e.shiftKey });
3084
+ },
3085
+ [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
3086
+ );
3087
+
3088
+ const handlePreviewCanvasPointerMove = useCallback(
3089
+ (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3090
+ if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
3091
+ updateDomEditHoverSelection(null);
3092
+ return null;
3093
+ }
3094
+
3095
+ const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3096
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3097
+ });
3098
+ updateDomEditHoverSelection(nextSelection);
3099
+ return nextSelection;
3100
+ },
3101
+ [captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
3102
+ );
3103
+
3104
+ const handlePreviewCanvasPointerLeave = useCallback(() => {
3105
+ updateDomEditHoverSelection(null);
3106
+ }, [updateDomEditHoverSelection]);
3107
+
3108
+ // eslint-disable-next-line no-restricted-syntax
3109
+ useEffect(() => {
3110
+ if (captionEditMode) updateDomEditHoverSelection(null);
3111
+ }, [captionEditMode, updateDomEditHoverSelection]);
3112
+
3113
+ // eslint-disable-next-line no-restricted-syntax
3114
+ useEffect(() => {
3115
+ updateDomEditHoverSelection(null);
3116
+ }, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
3117
+
3118
+ // eslint-disable-next-line no-restricted-syntax
3119
+ useEffect(() => {
3120
+ if (!domEditHoverSelection) return;
3121
+ const hoverMatchesSelection = domEditSelectionsTargetSame(
3122
+ domEditHoverSelection,
3123
+ domEditSelection,
3124
+ );
3125
+ const hoverMatchesGroup = domEditSelectionInGroup(
3126
+ domEditGroupSelections,
3127
+ domEditHoverSelection,
3128
+ );
3129
+ if (!hoverMatchesSelection && !hoverMatchesGroup) return;
3130
+ updateDomEditHoverSelection(null);
3131
+ }, [
3132
+ domEditGroupSelections,
3133
+ domEditHoverSelection,
3134
+ domEditSelection,
3135
+ updateDomEditHoverSelection,
3136
+ ]);
3137
+
3138
+ // eslint-disable-next-line no-restricted-syntax
3139
+ useEffect(() => {
3140
+ if (!domEditHoverSelection) return;
3141
+ if (domEditHoverSelection.element.isConnected) return;
3142
+ updateDomEditHoverSelection(null);
3143
+ }, [domEditHoverSelection, updateDomEditHoverSelection]);
3144
+
3145
+ // eslint-disable-next-line no-restricted-syntax
3146
+ useEffect(() => {
3147
+ if (!previewIframe) return;
3148
+
3149
+ const syncSelectionFromDocument = () => {
3150
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
3151
+ const currentSelection = domEditSelectionRef.current;
3152
+ if (!currentSelection) return;
3153
+ let doc: Document | null = null;
3154
+ try {
3155
+ doc = previewIframe.contentDocument;
3156
+ } catch {
3157
+ return;
3158
+ }
3159
+ if (!doc) return;
3160
+
3161
+ const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
3162
+ if (!nextElement) {
3163
+ applyDomSelection(null, { revealPanel: false });
3164
+ return;
3165
+ }
3166
+
3167
+ const nextSelection = buildDomSelectionFromTarget(nextElement);
3168
+ if (nextSelection) {
3169
+ applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
3170
+ }
3171
+ };
3172
+
3173
+ const attachErrorCapture = () => {
3174
+ try {
3175
+ const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
3176
+ if (!win) return;
3177
+ if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
3178
+ (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
3179
+ const origError = win.console.error.bind(win.console);
3180
+ win.console.error = function (...args: unknown[]) {
3181
+ origError(...args);
3182
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
3183
+ if (text.includes("favicon")) return;
3184
+ consoleErrorsRef.current = [
3185
+ ...consoleErrorsRef.current,
3186
+ { severity: "error", message: text },
3187
+ ];
3188
+ setConsoleErrors([...consoleErrorsRef.current]);
3189
+ };
3190
+ win.addEventListener("error", (e: ErrorEvent) => {
3191
+ const text = e.message || String(e);
3192
+ consoleErrorsRef.current = [
3193
+ ...consoleErrorsRef.current,
3194
+ { severity: "error", message: text },
3195
+ ];
3196
+ setConsoleErrors([...consoleErrorsRef.current]);
3197
+ });
3198
+ } catch {
3199
+ // same-origin only
3200
+ }
3201
+ };
3202
+
3203
+ attachErrorCapture();
3204
+ syncPreviewHistoryHotkey(previewIframe);
3205
+ void (async () => {
3206
+ await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
3207
+ await applyStudioMotionToPreviewAfterRefresh(previewIframe);
3208
+ })();
3209
+ syncSelectionFromDocument();
3210
+ refreshPreviewDocumentVersion();
3211
+
3212
+ const handleLoad = () => {
3213
+ consoleErrorsRef.current = [];
3214
+ setConsoleErrors(null);
3215
+ attachErrorCapture();
3216
+ syncPreviewHistoryHotkey(previewIframe);
3217
+ void (async () => {
3218
+ await applyStudioManualEditsToPreviewAfterRefresh(previewIframe);
3219
+ await applyStudioMotionToPreviewAfterRefresh(previewIframe);
3220
+ })();
3221
+ syncSelectionFromDocument();
3222
+ refreshPreviewDocumentVersion();
3223
+ };
3224
+
3225
+ previewIframe.addEventListener("load", handleLoad);
3226
+ return () => {
3227
+ previewIframe.removeEventListener("load", handleLoad);
3228
+ };
3229
+ }, [
3230
+ activeCompPath,
3231
+ applyDomSelection,
3232
+ applyStudioManualEditsToPreviewAfterRefresh,
3233
+ applyStudioMotionToPreviewAfterRefresh,
3234
+ buildDomSelectionFromTarget,
3235
+ captionEditMode,
3236
+ previewIframe,
3237
+ refreshPreviewDocumentVersion,
3238
+ syncPreviewHistoryHotkey,
3239
+ ]);
3240
+
3241
+ // eslint-disable-next-line no-restricted-syntax
3242
+ useEffect(() => {
3243
+ if (!captionEditMode) return;
3244
+ applyDomSelection(null, { revealPanel: false });
3245
+ }, [applyDomSelection, captionEditMode]);
3246
+
3247
+ // eslint-disable-next-line no-restricted-syntax
3248
+ useEffect(() => {
3249
+ if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
3250
+ updateDomEditHoverSelection(null);
3251
+ applyDomSelection(null, { revealPanel: false });
3252
+ if (rightPanelTab !== "renders") setRightPanelTab("renders");
3253
+ }, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection]);
3254
+
3255
+ // eslint-disable-next-line no-restricted-syntax
3256
+ useEffect(
3257
+ () => () => {
3258
+ if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3259
+ },
3260
+ [],
3261
+ );
3262
+
3263
+ const refreshFileTree = useCallback(async () => {
3264
+ const pid = projectIdRef.current;
3265
+ if (!pid) return;
3266
+ const res = await fetch(`/api/projects/${pid}`);
3267
+ const data = await res.json();
3268
+ if (data.files) setFileTree(data.files);
3269
+ }, []);
3270
+
3271
+ const uploadProjectFiles = useCallback(
3272
+ async (files: Iterable<File>, dir?: string): Promise<string[]> => {
3273
+ const pid = projectIdRef.current;
3274
+ const fileList = Array.from(files);
3275
+ if (!pid || fileList.length === 0) return [];
3276
+
3277
+ const formData = new FormData();
3278
+ for (const file of fileList) {
3279
+ formData.append("file", file);
1025
3280
  }
1026
3281
 
1027
3282
  const qs = dir ? `?dir=${encodeURIComponent(dir)}` : "";
@@ -1138,24 +3393,19 @@ export function StudioApp() {
1138
3393
  duration: normalizedDuration,
1139
3394
  track: placement.track,
1140
3395
  zIndex: trackZIndices.get(placement.track) ?? 1,
3396
+ geometry: resolveTimelineAssetInitialGeometry(originalContent),
1141
3397
  }),
1142
3398
  );
1143
3399
 
1144
- const saveResponse = await fetch(
1145
- `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1146
- {
1147
- method: "PUT",
1148
- headers: { "Content-Type": "text/plain" },
1149
- body: patchedContent,
1150
- },
1151
- );
1152
- if (!saveResponse.ok) {
1153
- throw new Error(`Failed to save ${targetPath}`);
1154
- }
1155
-
1156
- if (editingPathRef.current === targetPath) {
1157
- setEditingFile({ path: targetPath, content: patchedContent });
1158
- }
3400
+ await saveProjectFilesWithHistory({
3401
+ projectId: pid,
3402
+ label: "Add timeline asset",
3403
+ kind: "timeline",
3404
+ files: { [targetPath]: patchedContent },
3405
+ readFile: async () => originalContent,
3406
+ writeFile: writeProjectFile,
3407
+ recordEdit: editHistory.recordEdit,
3408
+ });
1159
3409
 
1160
3410
  setRefreshKey((k) => k + 1);
1161
3411
  } catch (error) {
@@ -1164,7 +3414,7 @@ export function StudioApp() {
1164
3414
  showToast(message);
1165
3415
  }
1166
3416
  },
1167
- [activeCompPath, showToast, timelineElements],
3417
+ [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
1168
3418
  );
1169
3419
 
1170
3420
  const handleTimelineFileDrop = useCallback(
@@ -1322,7 +3572,33 @@ export function StudioApp() {
1322
3572
 
1323
3573
  const handleImportFiles = useCallback(
1324
3574
  async (files: FileList | File[], dir?: string) => {
1325
- void uploadProjectFiles(Array.from(files), dir);
3575
+ return uploadProjectFiles(Array.from(files), dir);
3576
+ },
3577
+ [uploadProjectFiles],
3578
+ );
3579
+
3580
+ const handleImportFonts = useCallback(
3581
+ async (files: FileList | File[]) => {
3582
+ const uploaded = await uploadProjectFiles(
3583
+ Array.from(files).filter((file) => FONT_EXT.test(file.name)),
3584
+ "assets/fonts",
3585
+ );
3586
+ const pid = projectIdRef.current;
3587
+ const imported = uploaded
3588
+ .filter((asset) => FONT_EXT.test(asset))
3589
+ .map((asset) => ({
3590
+ family: fontFamilyFromAssetPath(asset),
3591
+ path: asset,
3592
+ url: `/api/projects/${pid}/preview/${asset}`,
3593
+ }));
3594
+ importedFontAssetsRef.current = [
3595
+ ...imported,
3596
+ ...importedFontAssetsRef.current.filter(
3597
+ (existing) =>
3598
+ !imported.some((font) => font.family.toLowerCase() === existing.family.toLowerCase()),
3599
+ ),
3600
+ ];
3601
+ return imported;
1326
3602
  },
1327
3603
  [uploadProjectFiles],
1328
3604
  );
@@ -1394,6 +3670,53 @@ export function StudioApp() {
1394
3670
  fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")),
1395
3671
  [fileTree],
1396
3672
  );
3673
+ const fontAssets = useMemo<ImportedFontAsset[]>(
3674
+ () =>
3675
+ assets
3676
+ .filter((asset) => FONT_EXT.test(asset))
3677
+ .map((asset) => ({
3678
+ family: fontFamilyFromAssetPath(asset),
3679
+ path: asset,
3680
+ url: `/api/projects/${projectId}/preview/${asset}`,
3681
+ })),
3682
+ [assets, projectId],
3683
+ );
3684
+ const selectedStudioMotion =
3685
+ STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
3686
+ ? getStudioMotionForSelection(studioMotionManifestRef.current, domEditSelection)
3687
+ : null;
3688
+ const selectedTimelineElement = useMemo(
3689
+ () =>
3690
+ selectedTimelineElementId
3691
+ ? (timelineElements.find(
3692
+ (element) => getTimelineElementKey(element) === selectedTimelineElementId,
3693
+ ) ?? null)
3694
+ : null,
3695
+ [selectedTimelineElementId, timelineElements],
3696
+ );
3697
+ const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
3698
+ const motionPanelActive =
3699
+ STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
3700
+ const inspectorPanelActive = designPanelActive || motionPanelActive;
3701
+ const shouldShowSelectedDomBounds =
3702
+ inspectorPanelActive &&
3703
+ !rightCollapsed &&
3704
+ (!selectedTimelineElement ||
3705
+ isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
3706
+ const inspectorButtonActive =
3707
+ STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive;
3708
+ const timelineLayerPanel =
3709
+ STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED &&
3710
+ inspectedTimelineElement &&
3711
+ inspectedTimelineLayers.length > 0 ? (
3712
+ <TimelineLayerPanel
3713
+ clipLabel={getTimelineElementLabel(inspectedTimelineElement)}
3714
+ layers={inspectedTimelineLayers}
3715
+ selectedLayerKey={selectedTimelineLayerKey}
3716
+ onSelectLayer={handleTimelineLayerSelect}
3717
+ onClose={handleTimelineLayerPanelClose}
3718
+ />
3719
+ ) : null;
1397
3720
 
1398
3721
  if (resolving || !projectId) {
1399
3722
  return (
@@ -1439,6 +3762,42 @@ export function StudioApp() {
1439
3762
  </div>
1440
3763
  {/* Right: toolbar buttons */}
1441
3764
  <div className="flex items-center gap-1.5">
3765
+ <button
3766
+ type="button"
3767
+ onClick={() => void handleUndo()}
3768
+ disabled={!editHistory.canUndo}
3769
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
3770
+ editHistory.canUndo
3771
+ ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
3772
+ : "border-neutral-900 text-neutral-700"
3773
+ }`}
3774
+ title={
3775
+ editHistory.undoLabel
3776
+ ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
3777
+ : `Undo (${getHistoryShortcutLabel("undo")})`
3778
+ }
3779
+ aria-label="Undo"
3780
+ >
3781
+ <RotateCcw size={14} />
3782
+ </button>
3783
+ <button
3784
+ type="button"
3785
+ onClick={() => void handleRedo()}
3786
+ disabled={!editHistory.canRedo}
3787
+ className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
3788
+ editHistory.canRedo
3789
+ ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
3790
+ : "border-neutral-900 text-neutral-700"
3791
+ }`}
3792
+ title={
3793
+ editHistory.redoLabel
3794
+ ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
3795
+ : `Redo (${getHistoryShortcutLabel("redo")})`
3796
+ }
3797
+ aria-label="Redo"
3798
+ >
3799
+ <RotateCw size={14} />
3800
+ </button>
1442
3801
  <a
1443
3802
  href={captureFrameHref}
1444
3803
  download={captureFrameFilename}
@@ -1453,12 +3812,31 @@ export function StudioApp() {
1453
3812
  <span>Capture</span>
1454
3813
  </a>
1455
3814
  <button
1456
- onClick={() => setRightCollapsed((v) => !v)}
3815
+ type="button"
3816
+ onClick={() => {
3817
+ if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
3818
+ if (rightCollapsed || !inspectorPanelActive) {
3819
+ setRightPanelTab("design");
3820
+ setRightCollapsed(false);
3821
+ return;
3822
+ }
3823
+ clearDomSelection();
3824
+ setRightCollapsed(true);
3825
+ }}
3826
+ disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
1457
3827
  className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
1458
- !rightCollapsed
3828
+ inspectorButtonActive
1459
3829
  ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
1460
- : "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
3830
+ : STUDIO_INSPECTOR_PANELS_ENABLED
3831
+ ? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
3832
+ : "cursor-not-allowed border-transparent text-neutral-700"
1461
3833
  }`}
3834
+ title={
3835
+ STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
3836
+ }
3837
+ aria-label={
3838
+ STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
3839
+ }
1462
3840
  >
1463
3841
  <svg
1464
3842
  width="12"
@@ -1471,8 +3849,7 @@ export function StudioApp() {
1471
3849
  <circle cx="12" cy="12" r="10" />
1472
3850
  <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
1473
3851
  </svg>
1474
- Renders
1475
- {renderQueue.jobs.length > 0 ? ` (${renderQueue.jobs.length})` : ""}
3852
+ Inspector
1476
3853
  </button>
1477
3854
  </div>
1478
3855
  </div>
@@ -1553,6 +3930,7 @@ export function StudioApp() {
1553
3930
  onLint={handleLint}
1554
3931
  linting={linting}
1555
3932
  onToggleCollapse={toggleLeftSidebar}
3933
+ takeoverContent={timelineLayerPanel}
1556
3934
  />
1557
3935
  )}
1558
3936
 
@@ -1583,62 +3961,48 @@ export function StudioApp() {
1583
3961
  onMoveElement={handleTimelineElementMove}
1584
3962
  onResizeElement={handleTimelineElementResize}
1585
3963
  onBlockedEditAttempt={handleBlockedTimelineEdit}
3964
+ onSelectTimelineElement={handleTimelineElementSelect}
3965
+ onInspectTimelineElement={handleTimelineElementInspect}
3966
+ inspectedTimelineElementId={inspectedTimelineElementId}
3967
+ timelineLayerChildCounts={timelineLayerChildCounts}
3968
+ thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
3969
+ onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
1586
3970
  onCompIdToSrcChange={setCompIdToSrc}
1587
3971
  onCompositionChange={(compPath) => {
1588
3972
  // Sync activeCompPath when user drills down via timeline double-click
1589
3973
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
1590
3974
  setActiveCompPath(compPath);
3975
+ setInspectedTimelineElementId(null);
3976
+ refreshPreviewDocumentVersion();
1591
3977
  }}
1592
- onIframeRef={(iframe) => {
1593
- previewIframeRef.current = iframe;
1594
- syncPreviewTimelineHotkey(iframe);
1595
- consoleErrorsRef.current = [];
1596
- setConsoleErrors(null);
1597
- if (!iframe) return;
1598
-
1599
- // Attach error capture after each iframe load (content resets on navigation)
1600
- const attachErrorCapture = () => {
1601
- try {
1602
- const win = iframe.contentWindow as (Window & typeof globalThis) | null;
1603
- if (!win) return;
1604
- // Guard against double-patching
1605
- if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
1606
- (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
1607
- const origError = win.console.error.bind(win.console);
1608
- win.console.error = function (...args: unknown[]) {
1609
- origError(...args);
1610
- const text = args
1611
- .map((a) => (a instanceof Error ? a.message : String(a)))
1612
- .join(" ");
1613
- if (text.includes("favicon")) return;
1614
- consoleErrorsRef.current = [
1615
- ...consoleErrorsRef.current,
1616
- { severity: "error", message: text },
1617
- ];
1618
- setConsoleErrors([...consoleErrorsRef.current]);
1619
- };
1620
- win.addEventListener("error", (e: ErrorEvent) => {
1621
- const text = e.message || String(e);
1622
- consoleErrorsRef.current = [
1623
- ...consoleErrorsRef.current,
1624
- { severity: "error", message: text },
1625
- ];
1626
- setConsoleErrors([...consoleErrorsRef.current]);
1627
- });
1628
- } catch {
1629
- // cross-origin — can't attach
1630
- }
1631
- };
1632
- // Attach now (iframe may already be loaded) and on future loads
1633
- attachErrorCapture();
1634
- iframe.addEventListener("load", () => {
1635
- consoleErrorsRef.current = [];
1636
- setConsoleErrors(null);
1637
- attachErrorCapture();
1638
- });
1639
- }}
3978
+ onIframeRef={handlePreviewIframeRef}
1640
3979
  previewOverlay={
1641
- captionEditMode ? <CaptionOverlay iframeRef={previewIframeRef} /> : undefined
3980
+ captionEditMode ? (
3981
+ <CaptionOverlay iframeRef={previewIframeRef} />
3982
+ ) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
3983
+ <DomEditOverlay
3984
+ iframeRef={previewIframeRef}
3985
+ activeCompositionPath={activeCompPath}
3986
+ hoverSelection={
3987
+ STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
3988
+ ? domEditHoverSelection
3989
+ : null
3990
+ }
3991
+ selection={shouldShowSelectedDomBounds ? domEditSelection : null}
3992
+ groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
3993
+ allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
3994
+ onCanvasMouseDown={handlePreviewCanvasMouseDown}
3995
+ onCanvasPointerMove={handlePreviewCanvasPointerMove}
3996
+ onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
3997
+ onSelectionChange={applyDomSelection}
3998
+ onBlockedMove={handleBlockedDomMove}
3999
+ onManualDragStart={handleDomManualDragStart}
4000
+ onPathOffsetCommit={handleDomPathOffsetCommit}
4001
+ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
4002
+ onBoxSizeCommit={handleDomBoxSizeCommit}
4003
+ onRotationCommit={handleDomRotationCommit}
4004
+ />
4005
+ ) : null
1642
4006
  }
1643
4007
  timelineFooter={
1644
4008
  captionEditMode ? (
@@ -1679,16 +4043,94 @@ export function StudioApp() {
1679
4043
  {captionEditMode ? (
1680
4044
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
1681
4045
  ) : (
1682
- <RenderQueue
1683
- jobs={renderQueue.jobs}
1684
- projectId={projectId}
1685
- onDelete={renderQueue.deleteRender}
1686
- onClearCompleted={renderQueue.clearCompleted}
1687
- onStartRender={(format, quality, resolution) =>
1688
- renderQueue.startRender({ format, quality, resolution })
1689
- }
1690
- isRendering={renderQueue.isRendering}
1691
- />
4046
+ <>
4047
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
4048
+ {STUDIO_INSPECTOR_PANELS_ENABLED && (
4049
+ <>
4050
+ <button
4051
+ type="button"
4052
+ onClick={() => setRightPanelTab("design")}
4053
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4054
+ rightPanelTab === "design"
4055
+ ? "bg-neutral-800 text-white"
4056
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4057
+ }`}
4058
+ >
4059
+ Design
4060
+ </button>
4061
+ {STUDIO_MOTION_PANEL_ENABLED && (
4062
+ <button
4063
+ type="button"
4064
+ onClick={() => setRightPanelTab("motion")}
4065
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4066
+ rightPanelTab === "motion"
4067
+ ? "bg-neutral-800 text-white"
4068
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4069
+ }`}
4070
+ >
4071
+ Motion
4072
+ </button>
4073
+ )}
4074
+ </>
4075
+ )}
4076
+ <button
4077
+ type="button"
4078
+ onClick={() => setRightPanelTab("renders")}
4079
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
4080
+ rightPanelTab === "renders"
4081
+ ? "bg-neutral-800 text-white"
4082
+ : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
4083
+ }`}
4084
+ >
4085
+ {renderQueue.jobs.length > 0
4086
+ ? `Renders (${renderQueue.jobs.length})`
4087
+ : "Renders"}
4088
+ </button>
4089
+ </div>
4090
+ <div className="min-h-0 flex-1">
4091
+ {designPanelActive ? (
4092
+ <PropertyPanel
4093
+ projectId={projectId}
4094
+ assets={assets}
4095
+ element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4096
+ copiedAgentPrompt={copiedAgentPrompt}
4097
+ onClearSelection={clearDomSelection}
4098
+ onSetStyle={handleDomStyleCommit}
4099
+ onSetManualOffset={handleDomPathOffsetCommit}
4100
+ onSetManualSize={handleDomBoxSizeCommit}
4101
+ onSetText={handleDomTextCommit}
4102
+ onSetTextFieldStyle={handleDomTextFieldStyleCommit}
4103
+ onAddTextField={handleDomAddTextField}
4104
+ onRemoveTextField={handleDomRemoveTextField}
4105
+ onResetManualEdits={handleDomManualEditsReset}
4106
+ onAskAgent={handleAskAgent}
4107
+ onImportAssets={handleImportFiles}
4108
+ fontAssets={fontAssets}
4109
+ onImportFonts={handleImportFonts}
4110
+ />
4111
+ ) : motionPanelActive ? (
4112
+ <MotionPanel
4113
+ element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4114
+ motion={selectedStudioMotion}
4115
+ onClearSelection={clearDomSelection}
4116
+ onSetMotion={handleDomMotionCommit}
4117
+ onClearMotion={handleDomMotionClear}
4118
+ />
4119
+ ) : (
4120
+ <RenderQueue
4121
+ jobs={renderQueue.jobs}
4122
+ projectId={projectId}
4123
+ onDelete={renderQueue.deleteRender}
4124
+ onClearCompleted={renderQueue.clearCompleted}
4125
+ onStartRender={async (format, quality) => {
4126
+ await waitForPendingDomEditSaves();
4127
+ await renderQueue.startRender(30, quality, format);
4128
+ }}
4129
+ isRendering={renderQueue.isRendering}
4130
+ />
4131
+ )}
4132
+ </div>
4133
+ </>
1692
4134
  )}
1693
4135
  </div>
1694
4136
  </>
@@ -1709,6 +4151,15 @@ export function StudioApp() {
1709
4151
  />
1710
4152
  )}
1711
4153
 
4154
+ {/* Ask agent modal */}
4155
+ {agentModalOpen && domEditSelection && (
4156
+ <AskAgentModal
4157
+ selectionLabel={domEditSelection.label}
4158
+ onSubmit={handleAgentModalSubmit}
4159
+ onClose={() => setAgentModalOpen(false)}
4160
+ />
4161
+ )}
4162
+
1712
4163
  {/* Global drag-drop overlay */}
1713
4164
  {globalDragOver && (
1714
4165
  <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">