@hyperframes/studio 0.6.0-alpha.1 → 0.6.0-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -4,14 +4,15 @@ import {
4
4
  useRef,
5
5
  useEffect,
6
6
  useMemo,
7
+ type CSSProperties,
7
8
  type MouseEvent,
8
9
  type ReactNode,
9
10
  } from "react";
10
11
  import { useMountEffect } from "./hooks/useMountEffect";
11
12
  import { NLELayout } from "./components/nle/NLELayout";
12
13
  import { SourceEditor } from "./components/editor/SourceEditor";
13
- import { LeftSidebar } from "./components/sidebar/LeftSidebar";
14
- import { RenderQueue } from "./components/renders/RenderQueue";
14
+ import { LeftSidebar, type LeftSidebarHandle } from "./components/sidebar/LeftSidebar";
15
+ import { RenderQueue, type CompositionDimensions } from "./components/renders/RenderQueue";
15
16
  import { useRenderQueue } from "./components/renders/useRenderQueue";
16
17
  import { CompositionThumbnail, VideoThumbnail, liveTime, usePlayerStore } from "./player";
17
18
  import { AudioWaveform } from "./player/components/AudioWaveform";
@@ -55,6 +56,7 @@ import {
55
56
  } from "./player/components/timelineZoom";
56
57
  import {
57
58
  getTimelineToggleTitle,
59
+ isEditableTarget,
58
60
  shouldHandleTimelineToggleHotkey,
59
61
  } from "./utils/timelineDiscovery";
60
62
  import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./utils/frameCapture";
@@ -78,10 +80,10 @@ import {
78
80
  STUDIO_MANUAL_EDITING_DISABLED_TITLE,
79
81
  STUDIO_MOTION_PANEL_ENABLED,
80
82
  STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
83
+ STUDIO_PREVIEW_SELECTION_ENABLED,
81
84
  STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED,
82
85
  } from "./components/editor/manualEditingAvailability";
83
86
  import {
84
- buildDefaultDomEditTextField,
85
87
  buildDomEditStylePatchOperation,
86
88
  buildDomEditTextPatchOperation,
87
89
  buildElementAgentPrompt,
@@ -91,12 +93,16 @@ import {
91
93
  findElementForTimelineElement,
92
94
  getDomEditLayerKey,
93
95
  getDomEditTargetKey,
96
+ isLargeRasterDomEditSelection,
94
97
  isTextEditableSelection,
98
+ resolveVisualDomEditSelectionTarget,
95
99
  serializeDomEditTextFields,
96
100
  resolveDomEditSelection,
101
+ type DomEditViewport,
97
102
  type DomEditLayerItem,
98
103
  type DomEditTextField,
99
104
  type DomEditSelection,
105
+ buildDefaultDomEditTextField,
100
106
  } from "./components/editor/domEditing";
101
107
  import {
102
108
  STUDIO_MANUAL_EDITS_PATH,
@@ -358,10 +364,64 @@ function isManualGeometryStyleProperty(property: string): boolean {
358
364
  return property === "left" || property === "top" || property === "width" || property === "height";
359
365
  }
360
366
 
367
+ interface PreviewLocalPointer {
368
+ x: number;
369
+ y: number;
370
+ viewport: DomEditViewport;
371
+ }
372
+
373
+ interface AgentModalAnchorPoint {
374
+ x: number;
375
+ y: number;
376
+ }
377
+
378
+ function resolvePreviewLocalPointer(
379
+ iframe: HTMLIFrameElement,
380
+ doc: Document,
381
+ win: Window,
382
+ clientX: number,
383
+ clientY: number,
384
+ ): PreviewLocalPointer | null {
385
+ const iframeRect = iframe.getBoundingClientRect();
386
+ const root =
387
+ doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
388
+ const rootRect = root?.getBoundingClientRect();
389
+ const rootWidth = rootRect?.width || win.innerWidth;
390
+ const rootHeight = rootRect?.height || win.innerHeight;
391
+ if (!rootWidth || !rootHeight) return null;
392
+
393
+ const scaleX = iframeRect.width / rootWidth;
394
+ const scaleY = iframeRect.height / rootHeight;
395
+ return {
396
+ x: (clientX - iframeRect.left) / scaleX,
397
+ y: (clientY - iframeRect.top) / scaleY,
398
+ viewport: { width: rootWidth, height: rootHeight },
399
+ };
400
+ }
401
+
402
+ function getPreviewLocalPointer(
403
+ iframe: HTMLIFrameElement,
404
+ clientX: number,
405
+ clientY: number,
406
+ ): PreviewLocalPointer | null {
407
+ let doc: Document | null = null;
408
+ let win: Window | null = null;
409
+ try {
410
+ doc = iframe.contentDocument;
411
+ win = iframe.contentWindow;
412
+ } catch {
413
+ return null;
414
+ }
415
+ if (!doc || !win) return null;
416
+
417
+ return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
418
+ }
419
+
361
420
  function getPreviewTargetFromPointer(
362
421
  iframe: HTMLIFrameElement,
363
422
  clientX: number,
364
423
  clientY: number,
424
+ activeCompositionPath: string | null,
365
425
  ): HTMLElement | null {
366
426
  let doc: Document | null = null;
367
427
  let win: Window | null = null;
@@ -373,20 +433,35 @@ function getPreviewTargetFromPointer(
373
433
  }
374
434
  if (!doc || !win) return null;
375
435
 
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;
436
+ const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
437
+ if (!localPointer) return null;
383
438
 
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;
439
+ if (typeof doc.elementsFromPoint === "function") {
440
+ const visualTarget = resolveVisualDomEditSelectionTarget(
441
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
442
+ {
443
+ activeCompositionPath,
444
+ },
445
+ );
446
+ if (visualTarget) return visualTarget;
447
+ }
388
448
 
389
- return getEventTargetElement(doc.elementFromPoint(localX, localY));
449
+ return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
450
+ }
451
+
452
+ function buildRasterClickSelectionContext(
453
+ selection: DomEditSelection,
454
+ localPointer: PreviewLocalPointer,
455
+ ): string {
456
+ return [
457
+ "The user clicked a large raster/background element in the Studio preview.",
458
+ `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
459
+ localPointer.viewport.width,
460
+ )}x${Math.round(localPointer.viewport.height)} composition.`,
461
+ `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
462
+ "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
463
+ "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
464
+ ].join("\n");
390
465
  }
391
466
 
392
467
  function domEditSelectionsTargetSame(
@@ -592,17 +667,47 @@ function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number |
592
667
 
593
668
  // ── Ask Agent Modal ──
594
669
 
670
+ function clampNumber(value: number, min: number, max: number): number {
671
+ if (max < min) return min;
672
+ return Math.min(Math.max(value, min), max);
673
+ }
674
+
675
+ function getAgentModalPositionStyle(
676
+ anchorPoint: AgentModalAnchorPoint | null,
677
+ ): CSSProperties | undefined {
678
+ if (!anchorPoint || typeof window === "undefined") return undefined;
679
+
680
+ const modalWidth = 480;
681
+ const estimatedModalHeight = 270;
682
+ const margin = 16;
683
+ const left = clampNumber(
684
+ anchorPoint.x,
685
+ margin + modalWidth / 2,
686
+ window.innerWidth - margin - modalWidth / 2,
687
+ );
688
+ const top = clampNumber(
689
+ anchorPoint.y + 12,
690
+ margin,
691
+ window.innerHeight - margin - estimatedModalHeight,
692
+ );
693
+
694
+ return { left, top, transform: "translateX(-50%)" };
695
+ }
696
+
595
697
  function AskAgentModal({
596
698
  selectionLabel,
699
+ anchorPoint = null,
597
700
  onSubmit,
598
701
  onClose,
599
702
  }: {
600
703
  selectionLabel: string;
704
+ anchorPoint?: AgentModalAnchorPoint | null;
601
705
  onSubmit: (instruction: string) => void;
602
706
  onClose: () => void;
603
707
  }) {
604
708
  const [value, setValue] = useState("");
605
709
  const inputRef = useRef<HTMLTextAreaElement>(null);
710
+ const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
606
711
 
607
712
  useMountEffect(() => {
608
713
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -615,11 +720,18 @@ function AskAgentModal({
615
720
 
616
721
  return (
617
722
  <div
618
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
723
+ className={
724
+ anchorPoint
725
+ ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
726
+ : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
727
+ }
619
728
  onClick={onClose}
620
729
  >
621
730
  <div
622
- className="w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl"
731
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
732
+ anchorPoint ? "fixed" : ""
733
+ }`}
734
+ style={modalPositionStyle}
623
735
  onClick={(e) => e.stopPropagation()}
624
736
  >
625
737
  <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
@@ -774,13 +886,17 @@ export function StudioApp() {
774
886
  const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
775
887
  const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
776
888
  const [agentPromptTagSnippet, setAgentPromptTagSnippet] = useState<string | undefined>();
889
+ const [agentPromptSelectionContext, setAgentPromptSelectionContext] = useState<
890
+ string | undefined
891
+ >();
892
+ const [agentModalAnchorPoint, setAgentModalAnchorPoint] = useState<AgentModalAnchorPoint | null>(
893
+ null,
894
+ );
777
895
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
778
896
  const [agentModalOpen, setAgentModalOpen] = useState(false);
779
897
  const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
780
898
  const [inspectedTimelineElementId, setInspectedTimelineElementId] = useState<string | null>(null);
781
- const [thumbnailedTimelineElementIds, setThumbnailedTimelineElementIds] = useState<
782
- ReadonlySet<string>
783
- >(() => new Set());
899
+ const [compositionLoading, setCompositionLoading] = useState(true);
784
900
  const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
785
901
  const refreshPreviewDocumentVersion = useCallback(() => {
786
902
  setPreviewDocumentVersion((version) => version + 1);
@@ -906,6 +1022,28 @@ export function StudioApp() {
906
1022
  setRightCollapsed(!captionHasSelection);
907
1023
  }
908
1024
  }, [captionHasSelection, captionEditMode]);
1025
+
1026
+ // Track the active composition's authored dimensions so the render
1027
+ // dropdown can derive landscape vs portrait. The runtime emits
1028
+ // `stage-size` after `applyCompositionSizing` resolves the authoritative
1029
+ // dims, so we use that instead of re-parsing the iframe DOM.
1030
+ const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
1031
+ null,
1032
+ );
1033
+ useMountEffect(() => {
1034
+ const handleMessage = (e: MessageEvent) => {
1035
+ const data = e.data;
1036
+ if (data?.source !== "hf-preview" || data?.type !== "stage-size") return;
1037
+ const { width, height } = data as { width: number; height: number };
1038
+ if (!(width > 0) || !(height > 0)) return;
1039
+ setCompositionDimensions((prev) =>
1040
+ prev && prev.width === width && prev.height === height ? prev : { width, height },
1041
+ );
1042
+ };
1043
+ window.addEventListener("message", handleMessage);
1044
+ return () => window.removeEventListener("message", handleMessage);
1045
+ });
1046
+
909
1047
  const [globalDragOver, setGlobalDragOver] = useState(false);
910
1048
  const [appToast, setAppToast] = useState<AppToast | null>(null);
911
1049
  const [timelineVisible, setTimelineVisible] = useState(true);
@@ -916,6 +1054,8 @@ export function StudioApp() {
916
1054
  const lastBlockedDomMoveToastAtRef = useRef(0);
917
1055
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
918
1056
  const previewHotkeyWindowRef = useRef<Window | null>(null);
1057
+ const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
1058
+ const leftSidebarRef = useRef<LeftSidebarHandle>(null);
919
1059
  const previewHistoryHotkeyCleanupRef = useRef<(() => void) | null>(null);
920
1060
  const panelDragRef = useRef<{
921
1061
  side: "left" | "right";
@@ -983,34 +1123,31 @@ export function StudioApp() {
983
1123
  [toggleTimelineVisibility],
984
1124
  );
985
1125
 
986
- useMountEffect(() => {
987
- window.addEventListener("keydown", handleTimelineToggleHotkey);
988
- return () => {
989
- window.removeEventListener("keydown", handleTimelineToggleHotkey);
990
- };
991
- });
1126
+ const previewAppKeyDownHandler = useCallback((event: KeyboardEvent) => {
1127
+ handleAppKeyDownRef.current?.(event);
1128
+ }, []);
992
1129
 
993
1130
  const syncPreviewTimelineHotkey = useCallback(
994
1131
  (iframe: HTMLIFrameElement | null) => {
995
1132
  const nextWindow = iframe?.contentWindow ?? null;
996
1133
  if (previewHotkeyWindowRef.current === nextWindow) return;
997
1134
  if (previewHotkeyWindowRef.current) {
998
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1135
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
999
1136
  }
1000
1137
  previewHotkeyWindowRef.current = nextWindow;
1001
- nextWindow?.addEventListener("keydown", handleTimelineToggleHotkey);
1138
+ nextWindow?.addEventListener("keydown", previewAppKeyDownHandler, true);
1002
1139
  },
1003
- [handleTimelineToggleHotkey],
1140
+ [previewAppKeyDownHandler],
1004
1141
  );
1005
1142
 
1006
1143
  useEffect(
1007
1144
  () => () => {
1008
1145
  if (previewHotkeyWindowRef.current) {
1009
- previewHotkeyWindowRef.current.removeEventListener("keydown", handleTimelineToggleHotkey);
1146
+ previewHotkeyWindowRef.current.removeEventListener("keydown", previewAppKeyDownHandler);
1010
1147
  previewHotkeyWindowRef.current = null;
1011
1148
  }
1012
1149
  },
1013
- [handleTimelineToggleHotkey],
1150
+ [previewAppKeyDownHandler],
1014
1151
  );
1015
1152
 
1016
1153
  const renderClipContent = useCallback(
@@ -1393,6 +1530,10 @@ export function StudioApp() {
1393
1530
  // Debounce the server write (600ms)
1394
1531
  if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
1395
1532
  saveTimerRef.current = setTimeout(() => {
1533
+ // Suppress the file-change watcher echo — the save callback triggers
1534
+ // its own refresh, so a second one from the watcher causes a double-reload
1535
+ // race that can leave the player in a non-playable state.
1536
+ domEditSaveTimestampRef.current = Date.now();
1396
1537
  saveProjectFilesWithHistory({
1397
1538
  projectId: pid,
1398
1539
  label: "Edit source",
@@ -1491,6 +1632,7 @@ export function StudioApp() {
1491
1632
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
1492
1633
  }
1493
1634
 
1635
+ domEditSaveTimestampRef.current = Date.now();
1494
1636
  await saveProjectFilesWithHistory({
1495
1637
  projectId: pid,
1496
1638
  label: "Move timeline clip",
@@ -1575,6 +1717,7 @@ export function StudioApp() {
1575
1717
  throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
1576
1718
  }
1577
1719
 
1720
+ domEditSaveTimestampRef.current = Date.now();
1578
1721
  await saveProjectFilesWithHistory({
1579
1722
  projectId: pid,
1580
1723
  label: "Resize timeline clip",
@@ -1713,6 +1856,7 @@ export function StudioApp() {
1713
1856
  });
1714
1857
  }
1715
1858
 
1859
+ domEditSaveTimestampRef.current = Date.now();
1716
1860
  await saveProjectFilesWithHistory({
1717
1861
  projectId: pid,
1718
1862
  label: "Delete timeline clip",
@@ -1741,6 +1885,155 @@ export function StudioApp() {
1741
1885
  [activeCompPath, editHistory.recordEdit, showToast, timelineElements, writeProjectFile],
1742
1886
  );
1743
1887
 
1888
+ const handleDomEditElementDelete = useCallback(
1889
+ async (selection: DomEditSelection) => {
1890
+ const pid = projectIdRef.current;
1891
+ if (!pid) return;
1892
+
1893
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
1894
+ try {
1895
+ const response = await fetch(
1896
+ `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
1897
+ );
1898
+ if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
1899
+
1900
+ const data = (await response.json()) as { content?: string };
1901
+ const originalContent = data.content;
1902
+ if (typeof originalContent !== "string")
1903
+ throw new Error(`Missing file contents for ${targetPath}`);
1904
+
1905
+ const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
1906
+ ? {
1907
+ id: selection.id,
1908
+ selector: selection.selector,
1909
+ selectorIndex: selection.selectorIndex,
1910
+ }
1911
+ : selection.selector
1912
+ ? { selector: selection.selector, selectorIndex: selection.selectorIndex }
1913
+ : ({} as never);
1914
+ if (!patchTarget.id && !patchTarget.selector) {
1915
+ throw new Error("Selected element has no patchable target");
1916
+ }
1917
+
1918
+ const removeResponse = await fetch(
1919
+ `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
1920
+ {
1921
+ method: "POST",
1922
+ headers: { "Content-Type": "application/json" },
1923
+ body: JSON.stringify({ target: patchTarget }),
1924
+ },
1925
+ );
1926
+ if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
1927
+
1928
+ const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
1929
+ const patchedContent =
1930
+ typeof removeData.content === "string" ? removeData.content : originalContent;
1931
+
1932
+ domEditSaveTimestampRef.current = Date.now();
1933
+ await saveProjectFilesWithHistory({
1934
+ projectId: pid,
1935
+ label: "Delete element",
1936
+ kind: "timeline",
1937
+ files: { [targetPath]: patchedContent },
1938
+ readFile: async () => originalContent,
1939
+ writeFile: writeProjectFile,
1940
+ recordEdit: editHistory.recordEdit,
1941
+ });
1942
+
1943
+ domEditSelectionRef.current = null;
1944
+ domEditGroupSelectionsRef.current = [];
1945
+ setDomEditSelection(null);
1946
+ setDomEditGroupSelections([]);
1947
+ usePlayerStore.getState().setSelectedElementId(null);
1948
+ setRefreshKey((k) => k + 1);
1949
+ } catch (error) {
1950
+ const message = error instanceof Error ? error.message : "Failed to delete element";
1951
+ showToast(message);
1952
+ }
1953
+ },
1954
+ [activeCompPath, editHistory.recordEdit, showToast, writeProjectFile],
1955
+ );
1956
+
1957
+ // ── Consolidated keyboard shortcuts ────────────────────────────────
1958
+ // All app-level window keydown handlers live here.
1959
+ // Component-scoped shortcuts (playback J/K/L/Space, caption nudge)
1960
+ // stay in their respective hooks.
1961
+ const handleToggleRef = useRef(handleTimelineToggleHotkey);
1962
+ handleToggleRef.current = handleTimelineToggleHotkey;
1963
+ const handleDeleteRef = useRef(handleTimelineElementDelete);
1964
+ handleDeleteRef.current = handleTimelineElementDelete;
1965
+ const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
1966
+ handleDomEditDeleteRef.current = handleDomEditElementDelete;
1967
+
1968
+ handleAppKeyDownRef.current = (event: KeyboardEvent) => {
1969
+ // Shift+T — toggle timeline
1970
+ handleToggleRef.current(event);
1971
+
1972
+ // Cmd/Ctrl+Z — undo, Cmd/Ctrl+Shift+Z or Ctrl+Y — redo
1973
+ if (event.metaKey || event.ctrlKey) {
1974
+ if (!shouldIgnoreHistoryShortcut(event.target)) {
1975
+ const key = event.key.toLowerCase();
1976
+ if (key === "z" && !event.shiftKey) {
1977
+ event.preventDefault();
1978
+ void handleUndoRef.current();
1979
+ return;
1980
+ }
1981
+ if ((key === "z" && event.shiftKey) || (event.ctrlKey && !event.metaKey && key === "y")) {
1982
+ event.preventDefault();
1983
+ void handleRedoRef.current();
1984
+ return;
1985
+ }
1986
+ }
1987
+
1988
+ // Cmd/Ctrl+1 — sidebar: Compositions tab
1989
+ if (event.key === "1") {
1990
+ event.preventDefault();
1991
+ leftSidebarRef.current?.selectTab("compositions");
1992
+ return;
1993
+ }
1994
+
1995
+ // Cmd/Ctrl+2 — sidebar: Assets tab
1996
+ if (event.key === "2") {
1997
+ event.preventDefault();
1998
+ leftSidebarRef.current?.selectTab("assets");
1999
+ return;
2000
+ }
2001
+ }
2002
+
2003
+ // Delete / Backspace — remove selected element (timeline clip or preview selection)
2004
+ if (
2005
+ (event.key === "Delete" || event.key === "Backspace") &&
2006
+ !event.metaKey &&
2007
+ !event.ctrlKey &&
2008
+ !event.altKey &&
2009
+ !isEditableTarget(event.target)
2010
+ ) {
2011
+ const { selectedElementId, elements } = usePlayerStore.getState();
2012
+ if (selectedElementId) {
2013
+ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
2014
+ if (element) {
2015
+ event.preventDefault();
2016
+ void handleDeleteRef.current(element);
2017
+ return;
2018
+ }
2019
+ }
2020
+ const domSelection = domEditSelectionRef.current;
2021
+ if (domSelection) {
2022
+ event.preventDefault();
2023
+ void handleDomEditDeleteRef.current(domSelection);
2024
+ }
2025
+ }
2026
+ };
2027
+
2028
+ // eslint-disable-next-line no-restricted-syntax
2029
+ useEffect(() => {
2030
+ function handleAppKeyDown(event: KeyboardEvent) {
2031
+ handleAppKeyDownRef.current?.(event);
2032
+ }
2033
+ window.addEventListener("keydown", handleAppKeyDown, true);
2034
+ return () => window.removeEventListener("keydown", handleAppKeyDown, true);
2035
+ }, []);
2036
+
1744
2037
  const handleBlockedTimelineEdit = useCallback(
1745
2038
  (_element: TimelineElement) => {
1746
2039
  const now = Date.now();
@@ -1771,6 +2064,8 @@ export function StudioApp() {
1771
2064
  options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
1772
2065
  ) => {
1773
2066
  setAgentPromptTagSnippet(undefined);
2067
+ setAgentPromptSelectionContext(undefined);
2068
+ setAgentModalAnchorPoint(null);
1774
2069
  setCopiedAgentPrompt(false);
1775
2070
  if (!selection) {
1776
2071
  domEditSelectionRef.current = null;
@@ -2228,6 +2523,8 @@ export function StudioApp() {
2228
2523
  handleUndoRef.current = handleUndo;
2229
2524
  handleRedoRef.current = handleRedo;
2230
2525
 
2526
+ // History hotkey — no longer has its own window listener (consolidated
2527
+ // handler covers it), but kept as a named callback for iframe forwarding.
2231
2528
  const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
2232
2529
  if (!(event.metaKey || event.ctrlKey)) return;
2233
2530
  if (shouldIgnoreHistoryShortcut(event.target)) return;
@@ -2243,12 +2540,6 @@ export function StudioApp() {
2243
2540
  }
2244
2541
  }, []);
2245
2542
 
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
2543
  const syncPreviewHistoryHotkey = useCallback(
2253
2544
  (iframe: HTMLIFrameElement | null) => {
2254
2545
  previewHistoryHotkeyCleanupRef.current?.();
@@ -2296,13 +2587,13 @@ export function StudioApp() {
2296
2587
  (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
2297
2588
  const iframe = previewIframeRef.current;
2298
2589
  if (!iframe || captionEditMode) return null;
2299
- const target = getPreviewTargetFromPointer(iframe, clientX, clientY);
2590
+ const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
2300
2591
  if (!target) return null;
2301
2592
  return buildDomSelectionFromTarget(target, {
2302
2593
  preferClipAncestor: options?.preferClipAncestor,
2303
2594
  });
2304
2595
  },
2305
- [buildDomSelectionFromTarget, captionEditMode],
2596
+ [activeCompPath, buildDomSelectionFromTarget, captionEditMode],
2306
2597
  );
2307
2598
 
2308
2599
  const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
@@ -2398,8 +2689,21 @@ export function StudioApp() {
2398
2689
 
2399
2690
  const selection = buildDomSelectionForTimelineElement(element);
2400
2691
  if (selection) applyDomSelection(selection);
2692
+
2693
+ const key = getTimelineElementKey(element);
2694
+ if (STUDIO_TIMELINE_LAYER_INSPECTOR_ENABLED && key && canInspectTimelineElement(element)) {
2695
+ setInspectedTimelineElementId(key);
2696
+ setLeftCollapsed(false);
2697
+
2698
+ const iframe = previewIframeRef.current;
2699
+ if (!shouldShowTimelineInspectorBounds(currentTime, element)) {
2700
+ seekStudioPreview(iframe, element.start);
2701
+ }
2702
+ } else {
2703
+ setInspectedTimelineElementId(null);
2704
+ }
2401
2705
  },
2402
- [applyDomSelection, buildDomSelectionForTimelineElement],
2706
+ [applyDomSelection, buildDomSelectionForTimelineElement, currentTime],
2403
2707
  );
2404
2708
 
2405
2709
  const handleTimelineElementInspect = useCallback(
@@ -2426,17 +2730,6 @@ export function StudioApp() {
2426
2730
  [applyDomSelection, buildDomSelectionForTimelineElement, currentTime, showToast],
2427
2731
  );
2428
2732
 
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
2733
  const handleTimelineLayerSelect = useCallback(
2441
2734
  (layer: DomEditLayerItem) => {
2442
2735
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
@@ -2816,15 +3109,26 @@ export function StudioApp() {
2816
3109
  buildDomEditStylePatchOperation("background-size", "contain"),
2817
3110
  );
2818
3111
  }
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
- });
3112
+ try {
3113
+ await persistDomEditOperations(domEditSelection, operations, {
3114
+ label: "Edit layer style",
3115
+ skipRefresh: true,
3116
+ prepareContent: importedFont
3117
+ ? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
3118
+ : undefined,
3119
+ });
3120
+ } catch (err) {
3121
+ console.warn("[Studio] Style persist failed:", err instanceof Error ? err.message : err);
3122
+ }
3123
+ refreshDomEditSelectionFromPreview(domEditSelection);
2826
3124
  },
2827
- [activeCompPath, domEditSelection, persistDomEditOperations, resolveImportedFontAsset],
3125
+ [
3126
+ activeCompPath,
3127
+ domEditSelection,
3128
+ persistDomEditOperations,
3129
+ refreshDomEditSelectionFromPreview,
3130
+ resolveImportedFontAsset,
3131
+ ],
2828
3132
  );
2829
3133
 
2830
3134
  const handleDomTextCommit = useCallback(
@@ -3023,6 +3327,8 @@ export function StudioApp() {
3023
3327
  const handleAskAgent = useCallback(() => {
3024
3328
  if (!domEditSelection) return;
3025
3329
  setAgentPromptTagSnippet(undefined);
3330
+ setAgentPromptSelectionContext(undefined);
3331
+ setAgentModalAnchorPoint(null);
3026
3332
  void preloadAgentPromptSnippet(domEditSelection);
3027
3333
  setAgentModalOpen(true);
3028
3334
  }, [domEditSelection, preloadAgentPromptSnippet]);
@@ -3037,6 +3343,7 @@ export function StudioApp() {
3037
3343
  selection: domEditSelection,
3038
3344
  currentTime,
3039
3345
  tagSnippet,
3346
+ selectionContext: agentPromptSelectionContext,
3040
3347
  userInstruction,
3041
3348
  sourceFilePath: toProjectAbsolutePath(projectDir, targetPath),
3042
3349
  });
@@ -3048,11 +3355,21 @@ export function StudioApp() {
3048
3355
  }
3049
3356
 
3050
3357
  setAgentModalOpen(false);
3358
+ setAgentPromptSelectionContext(undefined);
3359
+ setAgentModalAnchorPoint(null);
3051
3360
  if (copiedAgentTimerRef.current) clearTimeout(copiedAgentTimerRef.current);
3052
3361
  setCopiedAgentPrompt(true);
3053
3362
  copiedAgentTimerRef.current = setTimeout(() => setCopiedAgentPrompt(false), 1600);
3054
3363
  },
3055
- [activeCompPath, agentPromptTagSnippet, currentTime, domEditSelection, projectDir, showToast],
3364
+ [
3365
+ activeCompPath,
3366
+ agentPromptSelectionContext,
3367
+ agentPromptTagSnippet,
3368
+ currentTime,
3369
+ domEditSelection,
3370
+ projectDir,
3371
+ showToast,
3372
+ ],
3056
3373
  );
3057
3374
 
3058
3375
  const handlePreviewIframeRef = useCallback(
@@ -3070,9 +3387,9 @@ export function StudioApp() {
3070
3387
 
3071
3388
  const handlePreviewCanvasMouseDown = useCallback(
3072
3389
  (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3073
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) return;
3390
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
3074
3391
  const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
3075
- preferClipAncestor: options?.preferClipAncestor ?? true,
3392
+ preferClipAncestor: options?.preferClipAncestor ?? false,
3076
3393
  });
3077
3394
  if (!nextSelection) {
3078
3395
  if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
@@ -3080,14 +3397,35 @@ export function StudioApp() {
3080
3397
  }
3081
3398
  e.preventDefault();
3082
3399
  e.stopPropagation();
3400
+ const localPointer = previewIframeRef.current
3401
+ ? getPreviewLocalPointer(previewIframeRef.current, e.clientX, e.clientY)
3402
+ : null;
3083
3403
  applyDomSelection(nextSelection, { additive: e.shiftKey });
3404
+ if (
3405
+ !e.shiftKey &&
3406
+ localPointer &&
3407
+ isLargeRasterDomEditSelection(nextSelection, localPointer.viewport)
3408
+ ) {
3409
+ setAgentPromptSelectionContext(
3410
+ buildRasterClickSelectionContext(nextSelection, localPointer),
3411
+ );
3412
+ setAgentModalAnchorPoint({ x: e.clientX, y: e.clientY });
3413
+ void preloadAgentPromptSnippet(nextSelection);
3414
+ setAgentModalOpen(true);
3415
+ }
3084
3416
  },
3085
- [applyDomSelection, captionEditMode, resolveDomSelectionFromPreviewPoint],
3417
+ [
3418
+ applyDomSelection,
3419
+ captionEditMode,
3420
+ compositionLoading,
3421
+ preloadAgentPromptSnippet,
3422
+ resolveDomSelectionFromPreviewPoint,
3423
+ ],
3086
3424
  );
3087
3425
 
3088
3426
  const handlePreviewCanvasPointerMove = useCallback(
3089
3427
  (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
3090
- if (!STUDIO_PREVIEW_MANUAL_EDITING_ENABLED || captionEditMode) {
3428
+ if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
3091
3429
  updateDomEditHoverSelection(null);
3092
3430
  return null;
3093
3431
  }
@@ -3098,7 +3436,12 @@ export function StudioApp() {
3098
3436
  updateDomEditHoverSelection(nextSelection);
3099
3437
  return nextSelection;
3100
3438
  },
3101
- [captionEditMode, resolveDomSelectionFromPreviewPoint, updateDomEditHoverSelection],
3439
+ [
3440
+ captionEditMode,
3441
+ compositionLoading,
3442
+ resolveDomSelectionFromPreviewPoint,
3443
+ updateDomEditHoverSelection,
3444
+ ],
3102
3445
  );
3103
3446
 
3104
3447
  const handlePreviewCanvasPointerLeave = useCallback(() => {
@@ -3397,6 +3740,7 @@ export function StudioApp() {
3397
3740
  }),
3398
3741
  );
3399
3742
 
3743
+ domEditSaveTimestampRef.current = Date.now();
3400
3744
  await saveProjectFilesWithHistory({
3401
3745
  projectId: pid,
3402
3746
  label: "Add timeline asset",
@@ -3884,6 +4228,7 @@ export function StudioApp() {
3884
4228
  </div>
3885
4229
  ) : (
3886
4230
  <LeftSidebar
4231
+ ref={leftSidebarRef}
3887
4232
  width={leftWidth}
3888
4233
  projectId={projectId}
3889
4234
  compositions={compositions}
@@ -3965,9 +4310,8 @@ export function StudioApp() {
3965
4310
  onInspectTimelineElement={handleTimelineElementInspect}
3966
4311
  inspectedTimelineElementId={inspectedTimelineElementId}
3967
4312
  timelineLayerChildCounts={timelineLayerChildCounts}
3968
- thumbnailedTimelineElementIds={thumbnailedTimelineElementIds}
3969
- onToggleTimelineElementThumbnail={handleToggleTimelineElementThumbnail}
3970
4313
  onCompIdToSrcChange={setCompIdToSrc}
4314
+ onCompositionLoadingChange={setCompositionLoading}
3971
4315
  onCompositionChange={(compPath) => {
3972
4316
  // Sync activeCompPath when user drills down via timeline double-click
3973
4317
  // or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
@@ -3984,7 +4328,7 @@ export function StudioApp() {
3984
4328
  iframeRef={previewIframeRef}
3985
4329
  activeCompositionPath={activeCompPath}
3986
4330
  hoverSelection={
3987
- STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !captionEditMode
4331
+ STUDIO_PREVIEW_SELECTION_ENABLED && !captionEditMode
3988
4332
  ? domEditHoverSelection
3989
4333
  : null
3990
4334
  }
@@ -4093,6 +4437,7 @@ export function StudioApp() {
4093
4437
  projectId={projectId}
4094
4438
  assets={assets}
4095
4439
  element={domEditGroupSelections.length > 1 ? null : domEditSelection}
4440
+ multiSelectCount={domEditGroupSelections.length}
4096
4441
  copiedAgentPrompt={copiedAgentPrompt}
4097
4442
  onClearSelection={clearDomSelection}
4098
4443
  onSetStyle={handleDomStyleCommit}
@@ -4122,10 +4467,11 @@ export function StudioApp() {
4122
4467
  projectId={projectId}
4123
4468
  onDelete={renderQueue.deleteRender}
4124
4469
  onClearCompleted={renderQueue.clearCompleted}
4125
- onStartRender={async (format, quality) => {
4470
+ onStartRender={async (format, quality, resolution, fps) => {
4126
4471
  await waitForPendingDomEditSaves();
4127
- await renderQueue.startRender(30, quality, format);
4472
+ await renderQueue.startRender({ fps, quality, format, resolution });
4128
4473
  }}
4474
+ compositionDimensions={compositionDimensions}
4129
4475
  isRendering={renderQueue.isRendering}
4130
4476
  />
4131
4477
  )}
@@ -4155,8 +4501,13 @@ export function StudioApp() {
4155
4501
  {agentModalOpen && domEditSelection && (
4156
4502
  <AskAgentModal
4157
4503
  selectionLabel={domEditSelection.label}
4504
+ anchorPoint={agentModalAnchorPoint}
4158
4505
  onSubmit={handleAgentModalSubmit}
4159
- onClose={() => setAgentModalOpen(false)}
4506
+ onClose={() => {
4507
+ setAgentModalOpen(false);
4508
+ setAgentPromptSelectionContext(undefined);
4509
+ setAgentModalAnchorPoint(null);
4510
+ }}
4160
4511
  />
4161
4512
  )}
4162
4513