@hyperframes/studio 0.6.0-alpha.8 → 0.6.0

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 (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +35 -4
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-ClYcrksa.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -906,6 +906,7 @@ export function useTimelinePlayer() {
906
906
  const iframeShortcutCleanupRef = useRef<(() => void) | null>(null);
907
907
  const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
908
908
  const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
909
+ const lastTimelineMessageRef = useRef<number>(0);
909
910
  const staticSeekAdapterRef = useRef<{
910
911
  player: RuntimePlaybackAdapter;
911
912
  duration: number;
@@ -920,17 +921,40 @@ export function useTimelinePlayer() {
920
921
  const syncTimelineElements = useCallback(
921
922
  (elements: TimelineElement[], nextDuration?: number) => {
922
923
  const state = usePlayerStore.getState();
924
+ const resolvedDuration = nextDuration ?? state.duration;
923
925
  const mergedElements = mergeTimelineElementsPreservingDowngrades(
924
926
  state.elements,
925
927
  elements,
926
928
  state.duration,
927
- nextDuration ?? state.duration,
929
+ resolvedDuration,
928
930
  );
929
- setElements(mergedElements);
930
- if (Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0) {
931
+
932
+ const elementsChanged =
933
+ mergedElements.length !== state.elements.length ||
934
+ mergedElements.some((el, i) => {
935
+ const prev = state.elements[i];
936
+ return (
937
+ !prev ||
938
+ el.id !== prev.id ||
939
+ el.start !== prev.start ||
940
+ el.duration !== prev.duration ||
941
+ el.track !== prev.track
942
+ );
943
+ });
944
+
945
+ if (elementsChanged) {
946
+ setElements(mergedElements);
947
+ }
948
+ if (
949
+ Number.isFinite(nextDuration) &&
950
+ (nextDuration ?? 0) > 0 &&
951
+ nextDuration !== state.duration
952
+ ) {
931
953
  setDuration(nextDuration ?? 0);
932
954
  }
933
- setTimelineReady(true);
955
+ if (!state.timelineReady) {
956
+ setTimelineReady(true);
957
+ }
934
958
  },
935
959
  [setElements, setTimelineReady, setDuration],
936
960
  );
@@ -1501,102 +1525,70 @@ export function useTimelinePlayer() {
1501
1525
  }
1502
1526
  }, [syncTimelineElements]);
1503
1527
 
1504
- const onIframeLoad = useCallback(() => {
1505
- unmutePreviewMedia(iframeRef.current);
1506
-
1507
- let attempts = 0;
1508
- const maxAttempts = 25;
1509
-
1510
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1511
-
1512
- probeIntervalRef.current = setInterval(() => {
1513
- attempts++;
1514
- const adapter = getAdapter();
1515
- if (adapter && adapter.getDuration() > 0) {
1516
- clearInterval(probeIntervalRef.current);
1517
- adapter.pause();
1518
-
1519
- const seekTo = pendingSeekRef.current;
1520
- pendingSeekRef.current = null;
1521
- const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
1522
-
1523
- adapter.seek(startTime);
1524
- const adapterDur = adapter.getDuration();
1525
- // Cap at 7200s (2h) to guard against loop-inflated GSAP timelines
1526
- if (Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200)
1527
- setDuration(adapterDur);
1528
- setCurrentTime(startTime);
1529
- if (!isRefreshingRef.current) {
1530
- setTimelineReady(true);
1531
- }
1532
- isRefreshingRef.current = false;
1533
- setIsPlaying(false);
1528
+ const initializeAdapter = useCallback(() => {
1529
+ const adapter = getAdapter();
1530
+ if (!adapter || adapter.getDuration() <= 0) return false;
1534
1531
 
1535
- try {
1536
- const iframe = iframeRef.current;
1537
- const doc = iframe?.contentDocument;
1538
- const iframeWin = iframe?.contentWindow as IframeWindow | null;
1539
- if (doc && iframeWin) {
1540
- normalizePreviewViewport(doc, iframeWin);
1541
- autoHealMissingCompositionIds(doc);
1542
- attachIframeShortcutListeners();
1543
- }
1532
+ adapter.pause();
1533
+ const seekTo = pendingSeekRef.current;
1534
+ pendingSeekRef.current = null;
1535
+ const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
1536
+
1537
+ adapter.seek(startTime);
1538
+ const adapterDur = adapter.getDuration();
1539
+ if (
1540
+ Number.isFinite(adapterDur) &&
1541
+ adapterDur > 0 &&
1542
+ adapterDur < 7200 &&
1543
+ adapterDur !== usePlayerStore.getState().duration
1544
+ ) {
1545
+ setDuration(adapterDur);
1546
+ }
1547
+ setCurrentTime(startTime);
1548
+ if (!isRefreshingRef.current) {
1549
+ setTimelineReady(true);
1550
+ }
1551
+ isRefreshingRef.current = false;
1552
+ setIsPlaying(false);
1544
1553
 
1545
- // Try reading __clipManifest if already available (fast path)
1546
- const manifest = iframeWin?.__clipManifest;
1547
- if (manifest && manifest.clips.length > 0) {
1548
- processTimelineMessage(manifest);
1549
- }
1550
- // Enrich: fill in composition hosts the manifest missed
1551
- enrichMissingCompositions();
1552
-
1553
- // Run DOM fallback if still no elements were populated
1554
- // (manifest may exist but all clips filtered out by parentCompositionId logic)
1555
- if (usePlayerStore.getState().elements.length === 0 && doc) {
1556
- // Fallback: parse data-start elements directly from DOM (raw HTML without runtime)
1557
- const els = parseTimelineFromDOM(doc, adapter.getDuration());
1558
- if (els.length > 0) {
1559
- syncTimelineElements(els);
1560
- }
1561
- }
1554
+ try {
1555
+ const iframe = iframeRef.current;
1556
+ const doc = iframe?.contentDocument;
1557
+ const iframeWin = iframe?.contentWindow as IframeWindow | null;
1558
+ if (doc && iframeWin) {
1559
+ normalizePreviewViewport(doc, iframeWin);
1560
+ autoHealMissingCompositionIds(doc);
1561
+ attachIframeShortcutListeners();
1562
+ }
1562
1563
 
1563
- // Final fallback for standalone composition previews: if still no
1564
- // elements, build timeline entries from the DOM inside the root
1565
- // composition. This ensures the timeline always shows content when
1566
- // viewing a single composition (where elements lack data-start).
1567
- if (usePlayerStore.getState().elements.length === 0 && doc) {
1568
- const rootComp = doc.querySelector("[data-composition-id]");
1569
- const rootDuration = adapter.getDuration();
1570
- if (rootComp && rootDuration > 0) {
1571
- const fallbackElement = buildStandaloneRootTimelineElement({
1572
- compositionId: rootComp.getAttribute("data-composition-id") || "composition",
1573
- tagName: (rootComp as HTMLElement).tagName || "div",
1574
- rootDuration,
1575
- iframeSrc: iframe?.src || "",
1576
- selector: getTimelineElementSelector(rootComp),
1577
- });
1578
- if (fallbackElement) {
1579
- // Always show the root composition as a single clip — guarantees
1580
- // the timeline is never empty when a valid composition is loaded.
1581
- syncTimelineElements([fallbackElement]);
1582
- }
1583
- }
1584
- }
1585
- // The runtime will also postMessage the full timeline after all compositions load.
1586
- // That message is handled by the window listener below, which will update elements
1587
- // with the complete data (including async-loaded compositions).
1588
- } catch (err) {
1589
- console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
1590
- }
1564
+ const manifest = iframeWin?.__clipManifest;
1565
+ if (manifest && manifest.clips.length > 0) {
1566
+ processTimelineMessage(manifest);
1567
+ }
1568
+ enrichMissingCompositions();
1591
1569
 
1592
- return;
1570
+ if (usePlayerStore.getState().elements.length === 0 && doc) {
1571
+ const els = parseTimelineFromDOM(doc, adapter.getDuration());
1572
+ if (els.length > 0) syncTimelineElements(els);
1593
1573
  }
1594
- if (attempts >= maxAttempts) {
1595
- clearInterval(probeIntervalRef.current);
1596
- console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
1574
+ if (usePlayerStore.getState().elements.length === 0 && doc) {
1575
+ const rootComp = doc.querySelector("[data-composition-id]");
1576
+ const rootDuration = adapter.getDuration();
1577
+ if (rootComp && rootDuration > 0) {
1578
+ const fallbackElement = buildStandaloneRootTimelineElement({
1579
+ compositionId: rootComp.getAttribute("data-composition-id") || "composition",
1580
+ tagName: (rootComp as HTMLElement).tagName || "div",
1581
+ rootDuration,
1582
+ iframeSrc: iframe?.src || "",
1583
+ selector: getTimelineElementSelector(rootComp),
1584
+ });
1585
+ if (fallbackElement) syncTimelineElements([fallbackElement]);
1586
+ }
1597
1587
  }
1598
- }, 200);
1599
- // eslint-disable-next-line react-hooks/exhaustive-deps
1588
+ } catch (err) {
1589
+ console.warn("[useTimelinePlayer] Could not read timeline elements from iframe", err);
1590
+ }
1591
+ return true;
1600
1592
  }, [
1601
1593
  getAdapter,
1602
1594
  setDuration,
@@ -1609,6 +1601,50 @@ export function useTimelinePlayer() {
1609
1601
  attachIframeShortcutListeners,
1610
1602
  ]);
1611
1603
 
1604
+ const onIframeLoad = useCallback(() => {
1605
+ unmutePreviewMedia(iframeRef.current);
1606
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1607
+
1608
+ // Fast path: adapter already available (in-place reloads, cached compositions)
1609
+ if (initializeAdapter()) return;
1610
+
1611
+ // The runtime posts "state" or "timeline" messages once ready.
1612
+ // Listen for those instead of polling. Use a short-lived message
1613
+ // listener that fires initializeAdapter on the first signal.
1614
+ const iframe = iframeRef.current;
1615
+ let settled = false;
1616
+
1617
+ const trySettle = () => {
1618
+ if (settled) return;
1619
+ if (initializeAdapter()) {
1620
+ settled = true;
1621
+ window.removeEventListener("message", onMessage);
1622
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
1623
+ }
1624
+ };
1625
+
1626
+ const onMessage = (e: MessageEvent) => {
1627
+ if (e.source && iframe && e.source !== iframe.contentWindow) return;
1628
+ const data = e.data;
1629
+ if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
1630
+ trySettle();
1631
+ }
1632
+ };
1633
+ window.addEventListener("message", onMessage);
1634
+
1635
+ // Safety net: if no message arrives within 5s, try one last time then give up.
1636
+ // This replaces the old 25×200ms polling loop with a single delayed check.
1637
+ probeIntervalRef.current = setTimeout(() => {
1638
+ if (!settled) {
1639
+ trySettle();
1640
+ if (!settled) {
1641
+ console.warn("[useTimelinePlayer] Runtime did not signal readiness within 5s");
1642
+ }
1643
+ }
1644
+ window.removeEventListener("message", onMessage);
1645
+ }, 5000) as unknown as ReturnType<typeof setInterval>;
1646
+ }, [initializeAdapter]);
1647
+
1612
1648
  /** Save the current playback time so the next onIframeLoad restores it. */
1613
1649
  const saveSeekPosition = useCallback(() => {
1614
1650
  const adapter = getAdapter();
@@ -1668,23 +1704,24 @@ export function useTimelinePlayer() {
1668
1704
  processTimelineMessageRef.current(manifest);
1669
1705
  }
1670
1706
  }
1671
- // Always try to enrich timelines may have registered since the last check
1672
- enrichMissingCompositionsRef.current();
1707
+ // Enrich only when the timeline has settled skip during the window
1708
+ // right after a "timeline" message to avoid the enrichment adding
1709
+ // elements that fight with the manifest's authoritative element list,
1710
+ // causing duration oscillation (the merge function alternates between
1711
+ // REPLACE and PRESERVE when element counts fluctuate).
1712
+ const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
1713
+ if (msSinceTimeline > 500) {
1714
+ enrichMissingCompositionsRef.current();
1715
+ }
1673
1716
  } catch (err) {
1674
1717
  console.warn("[useTimelinePlayer] Could not read clip manifest from iframe", err);
1675
1718
  }
1676
1719
  }
1677
1720
  if (data?.source === "hf-preview" && data?.type === "timeline" && Array.isArray(data.clips)) {
1721
+ lastTimelineMessageRef.current = Date.now();
1678
1722
  processTimelineMessageRef.current(data);
1679
1723
  // Fill in composition hosts the manifest missed (element-reference starts)
1680
1724
  enrichMissingCompositionsRef.current();
1681
- if (data.durationInFrames > 0 && Number.isFinite(data.durationInFrames)) {
1682
- const fps = 30;
1683
- const dur = data.durationInFrames / fps;
1684
- if (dur > 0 && dur < 7200) {
1685
- usePlayerStore.getState().setDuration(dur);
1686
- }
1687
- }
1688
1725
  // If manifest produced 0 elements after filtering, try DOM fallback
1689
1726
  if (usePlayerStore.getState().elements.length === 0) {
1690
1727
  try {
@@ -0,0 +1,50 @@
1
+ import type { DomEditSelection } from "../components/editor/domEditing";
2
+ import { getDomEditTargetKey } from "../components/editor/domEditing";
3
+
4
+ export function domEditSelectionsTargetSame(
5
+ a: DomEditSelection | null,
6
+ b: DomEditSelection | null,
7
+ ): boolean {
8
+ if (a === b) return true;
9
+ if (!a || !b) return false;
10
+ return getDomEditTargetKey(a) === getDomEditTargetKey(b);
11
+ }
12
+
13
+ export function domEditSelectionInGroup(
14
+ group: DomEditSelection[],
15
+ selection: DomEditSelection | null,
16
+ ): boolean {
17
+ if (!selection) return false;
18
+ return group.some((entry) => domEditSelectionsTargetSame(entry, selection));
19
+ }
20
+
21
+ export function toggleDomEditGroupSelection(
22
+ group: DomEditSelection[],
23
+ selection: DomEditSelection,
24
+ ): DomEditSelection[] {
25
+ if (domEditSelectionInGroup(group, selection)) {
26
+ return group.filter((entry) => !domEditSelectionsTargetSame(entry, selection));
27
+ }
28
+ return [...group, selection];
29
+ }
30
+
31
+ export function replaceDomEditGroupSelection(
32
+ group: DomEditSelection[],
33
+ selection: DomEditSelection,
34
+ ): DomEditSelection[] {
35
+ let replaced = false;
36
+ const nextGroup = group.map((entry) => {
37
+ if (!domEditSelectionsTargetSame(entry, selection)) return entry;
38
+ replaced = true;
39
+ return selection;
40
+ });
41
+ return replaced ? nextGroup : [...group, selection];
42
+ }
43
+
44
+ export function seedDomEditGroupWithSelection(
45
+ group: DomEditSelection[],
46
+ selection: DomEditSelection | null,
47
+ ): DomEditSelection[] {
48
+ if (!selection || domEditSelectionInGroup(group, selection)) return group;
49
+ return [selection, ...group];
50
+ }
@@ -0,0 +1,83 @@
1
+ import { googleFontStylesheetUrl } from "../components/editor/fontCatalog";
2
+ import { importedFontFaceCss, type ImportedFontAsset } from "../components/editor/fontAssets";
3
+ import { toRelativeProjectAssetPath } from "./studioHelpers";
4
+
5
+ const GENERIC_FONT_FAMILIES = new Set([
6
+ "inherit",
7
+ "initial",
8
+ "revert",
9
+ "revert-layer",
10
+ "serif",
11
+ "sans-serif",
12
+ "monospace",
13
+ "cursive",
14
+ "fantasy",
15
+ "system-ui",
16
+ "ui-sans-serif",
17
+ "ui-serif",
18
+ "ui-monospace",
19
+ "ui-rounded",
20
+ "emoji",
21
+ "math",
22
+ "fangsong",
23
+ ]);
24
+
25
+ export function primaryFontFamilyFromCss(value: string): string {
26
+ const first = value.split(",")[0] ?? "";
27
+ return first.trim().replace(/^["']|["']$/g, "");
28
+ }
29
+
30
+ export function primaryFontFamilyValue(value: string): string {
31
+ return (
32
+ value
33
+ .split(",")[0]
34
+ ?.trim()
35
+ .replace(/^["']|["']$/g, "")
36
+ .trim() ?? ""
37
+ );
38
+ }
39
+
40
+ export function injectPreviewGoogleFont(doc: Document, fontFamilyValue: string): void {
41
+ const family = primaryFontFamilyFromCss(fontFamilyValue);
42
+ if (!family || GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return;
43
+
44
+ const id = `studio-preview-google-font-${family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
45
+ if (doc.getElementById(id)) return;
46
+
47
+ const link = doc.createElement("link");
48
+ link.id = id;
49
+ link.rel = "stylesheet";
50
+ link.href = googleFontStylesheetUrl(family);
51
+ doc.head.appendChild(link);
52
+ }
53
+
54
+ export function injectPreviewImportedFont(doc: Document, asset: ImportedFontAsset): void {
55
+ const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
56
+ if (doc.getElementById(id)) return;
57
+ const style = doc.createElement("style");
58
+ style.id = id;
59
+ style.textContent = importedFontFaceCss(asset);
60
+ doc.head.appendChild(style);
61
+ }
62
+
63
+ export function ensureImportedFontFace(
64
+ html: string,
65
+ asset: ImportedFontAsset,
66
+ sourceFile: string,
67
+ ): string {
68
+ const css = importedFontFaceCss(asset, toRelativeProjectAssetPath(sourceFile, asset.path));
69
+ if (html.includes(css)) return html;
70
+
71
+ const styleRe = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
72
+ const styleMatch = styleRe.exec(html);
73
+ if (styleMatch) {
74
+ const nextCss = `${styleMatch[2].trim()}\n${css}`.trim();
75
+ return html.replace(styleMatch[0], `<style data-hf-studio-fonts="true">\n${nextCss}\n</style>`);
76
+ }
77
+
78
+ const styleTag = `<style data-hf-studio-fonts="true">\n${css}\n</style>`;
79
+ if (/<\/head>/i.test(html)) {
80
+ return html.replace(/<\/head>/i, ` ${styleTag}\n </head>`);
81
+ }
82
+ return `${styleTag}\n${html}`;
83
+ }
@@ -0,0 +1,214 @@
1
+ import type { TimelineElement } from "../player";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+ import type { TimelineAssetKind } from "./timelineAssetDrop";
4
+
5
+ export interface EditingFile {
6
+ path: string;
7
+ content: string | null;
8
+ }
9
+
10
+ export interface AppToast {
11
+ message: string;
12
+ tone: "error" | "info";
13
+ }
14
+
15
+ export type RightPanelTab = "design" | "motion" | "renders";
16
+
17
+ export interface AgentModalAnchorPoint {
18
+ x: number;
19
+ y: number;
20
+ }
21
+
22
+ export function getTimelineElementLabel(element: TimelineElement): string {
23
+ return element.label || element.id || element.tag;
24
+ }
25
+
26
+ export function confirmElementDelete(label: string, kind: "timeline clip" | "element"): boolean {
27
+ return window.confirm(
28
+ `Delete ${kind} "${label}"?\n\nThis removes it from the project source. You can use Undo to restore it.`,
29
+ );
30
+ }
31
+
32
+ export function normalizeProjectAssetPath(value: string): string {
33
+ const trimmed = value.trim();
34
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
35
+ return decodeURIComponent(maybeUrl)
36
+ .replace(/\\/g, "/")
37
+ .replace(/^\.?\//, "");
38
+ }
39
+
40
+ export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
41
+ const fromParts = normalizeProjectAssetPath(sourceFile).split("/").filter(Boolean);
42
+ const targetParts = normalizeProjectAssetPath(assetPath).split("/").filter(Boolean);
43
+
44
+ fromParts.pop();
45
+
46
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
47
+ fromParts.shift();
48
+ targetParts.shift();
49
+ }
50
+
51
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
52
+ }
53
+
54
+ export function isAbsoluteFilePath(value: string): boolean {
55
+ return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
56
+ }
57
+
58
+ export function toProjectAbsolutePath(
59
+ projectDir: string | null,
60
+ sourceFile: string,
61
+ ): string | undefined {
62
+ const trimmedSource = sourceFile.trim();
63
+ if (!trimmedSource) return undefined;
64
+
65
+ const normalizedSource = trimmedSource.replace(/\\/g, "/");
66
+ if (isAbsoluteFilePath(normalizedSource)) return normalizedSource;
67
+
68
+ const normalizedRoot = projectDir?.trim().replace(/\\/g, "/").replace(/\/+$/, "");
69
+ if (!normalizedRoot) return undefined;
70
+
71
+ return `${normalizedRoot}/${normalizedSource.replace(/^\.?\//, "")}`;
72
+ }
73
+
74
+ export function normalizeDomEditStyleValue(property: string, value: string): string {
75
+ const trimmed = value.trim();
76
+ if (!trimmed) return trimmed;
77
+
78
+ if (
79
+ ["border-radius", "border-width", "font-size", "letter-spacing"].includes(property) &&
80
+ /^-?\d+(\.\d+)?$/.test(trimmed)
81
+ ) {
82
+ return `${trimmed}px`;
83
+ }
84
+
85
+ return trimmed;
86
+ }
87
+
88
+ export function isImageBackgroundValue(value: string): boolean {
89
+ return /^url\(/i.test(value.trim());
90
+ }
91
+
92
+ export function isManualGeometryStyleProperty(property: string): boolean {
93
+ return property === "left" || property === "top" || property === "width" || property === "height";
94
+ }
95
+
96
+ export function getEventTargetElement(target: EventTarget | null): HTMLElement | null {
97
+ if (!target || typeof target !== "object") return null;
98
+ const maybeNode = target as {
99
+ nodeType?: number;
100
+ parentElement?: Element | null;
101
+ };
102
+ if (maybeNode.nodeType === 1) return target as HTMLElement;
103
+ if (maybeNode.nodeType === 3 && maybeNode.parentElement) {
104
+ return maybeNode.parentElement as HTMLElement;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ export function shouldIgnoreHistoryShortcut(target: EventTarget | null): boolean {
110
+ const el = getEventTargetElement(target);
111
+ if (!el) return false;
112
+ return Boolean(
113
+ el.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], .cm-editor"),
114
+ );
115
+ }
116
+
117
+ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
118
+ const isMac =
119
+ typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
120
+ const modifier = isMac ? "Cmd" : "Ctrl";
121
+ return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
122
+ }
123
+
124
+ export function findMatchingTimelineElementId(
125
+ selection: Pick<
126
+ DomEditSelection,
127
+ "id" | "selector" | "selectorIndex" | "sourceFile" | "compositionSrc" | "isCompositionHost"
128
+ >,
129
+ elements: TimelineElement[],
130
+ ): string | null {
131
+ const selectionSourceFile = selection.sourceFile || "index.html";
132
+ for (const element of elements) {
133
+ const elementSourceFile = element.sourceFile || "index.html";
134
+ if (
135
+ selection.id &&
136
+ element.domId === selection.id &&
137
+ elementSourceFile === selectionSourceFile
138
+ ) {
139
+ return element.key ?? element.id;
140
+ }
141
+ if (
142
+ selection.isCompositionHost &&
143
+ selection.compositionSrc &&
144
+ element.compositionSrc === selection.compositionSrc
145
+ ) {
146
+ return element.key ?? element.id;
147
+ }
148
+ if (
149
+ selection.selector &&
150
+ element.selector === selection.selector &&
151
+ (element.selectorIndex ?? 0) === (selection.selectorIndex ?? 0) &&
152
+ (element.sourceFile ?? "index.html") === selection.sourceFile
153
+ ) {
154
+ return element.key ?? element.id;
155
+ }
156
+ }
157
+
158
+ return null;
159
+ }
160
+
161
+ export function clampNumber(value: number, min: number, max: number): number {
162
+ if (max < min) return min;
163
+ return Math.min(Math.max(value, min), max);
164
+ }
165
+
166
+ export function collectHtmlIds(source: string): string[] {
167
+ return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
168
+ }
169
+
170
+ export const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
171
+ image: 3,
172
+ video: 5,
173
+ audio: 5,
174
+ };
175
+
176
+ export async function resolveDroppedAssetDuration(
177
+ projectId: string,
178
+ assetPath: string,
179
+ kind: TimelineAssetKind,
180
+ ): Promise<number> {
181
+ if (kind === "image") return DEFAULT_TIMELINE_ASSET_DURATION.image;
182
+
183
+ const media = document.createElement(kind === "video" ? "video" : "audio");
184
+ media.preload = "metadata";
185
+ media.src = `/api/projects/${projectId}/preview/${assetPath}`;
186
+
187
+ const duration = await new Promise<number>((resolve) => {
188
+ const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000);
189
+ const finalize = (value: number) => {
190
+ window.clearTimeout(timeout);
191
+ resolve(value);
192
+ };
193
+
194
+ media.addEventListener(
195
+ "loadedmetadata",
196
+ () => {
197
+ const raw = Number(media.duration);
198
+ finalize(
199
+ Number.isFinite(raw) && raw > 0
200
+ ? Math.round(raw * 100) / 100
201
+ : DEFAULT_TIMELINE_ASSET_DURATION[kind],
202
+ );
203
+ },
204
+ { once: true },
205
+ );
206
+ media.addEventListener("error", () => finalize(DEFAULT_TIMELINE_ASSET_DURATION[kind]), {
207
+ once: true,
208
+ });
209
+ });
210
+
211
+ media.src = "";
212
+ media.load();
213
+ return duration;
214
+ }