@hyperframes/studio 0.6.0-alpha.9 → 0.6.1

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 (111) hide show
  1. package/dist/assets/hyperframes-player-CzwFysqv.js +418 -0
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-hYc4aP7M.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 +421 -4303
  8. package/src/captions/components/CaptionOverlay.tsx +13 -246
  9. package/src/captions/components/CaptionOverlayUtils.ts +221 -0
  10. package/src/components/AskAgentModal.tsx +120 -0
  11. package/src/components/StudioHeader.tsx +133 -0
  12. package/src/components/StudioLeftSidebar.tsx +125 -0
  13. package/src/components/StudioPreviewArea.tsx +167 -0
  14. package/src/components/StudioRightPanel.tsx +198 -0
  15. package/src/components/TimelineToolbar.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +88 -993
  17. package/src/components/editor/EaseCurveEditor.tsx +221 -0
  18. package/src/components/editor/FileTree.tsx +13 -621
  19. package/src/components/editor/FileTreeIcons.tsx +128 -0
  20. package/src/components/editor/FileTreeNodes.tsx +496 -0
  21. package/src/components/editor/MotionPanel.tsx +16 -390
  22. package/src/components/editor/MotionPanelFields.tsx +185 -0
  23. package/src/components/editor/PropertyPanel.test.ts +0 -49
  24. package/src/components/editor/PropertyPanel.tsx +132 -2763
  25. package/src/components/editor/domEditOverlayGeometry.ts +211 -0
  26. package/src/components/editor/domEditOverlayGestures.ts +138 -0
  27. package/src/components/editor/domEditOverlayStartGesture.ts +155 -0
  28. package/src/components/editor/domEditing.ts +44 -1117
  29. package/src/components/editor/domEditingAgentPrompt.ts +97 -0
  30. package/src/components/editor/domEditingDom.ts +266 -0
  31. package/src/components/editor/domEditingElement.ts +329 -0
  32. package/src/components/editor/domEditingLayers.ts +460 -0
  33. package/src/components/editor/domEditingTypes.ts +125 -0
  34. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  35. package/src/components/editor/manualEditingAvailability.ts +1 -1
  36. package/src/components/editor/manualEdits.ts +84 -1049
  37. package/src/components/editor/manualEditsDom.ts +436 -0
  38. package/src/components/editor/manualEditsParsing.ts +280 -0
  39. package/src/components/editor/manualEditsSnapshot.ts +333 -0
  40. package/src/components/editor/manualEditsTypes.ts +141 -0
  41. package/src/components/editor/propertyPanelColor.tsx +371 -0
  42. package/src/components/editor/propertyPanelFill.tsx +421 -0
  43. package/src/components/editor/propertyPanelFont.tsx +455 -0
  44. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  45. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  46. package/src/components/editor/propertyPanelSections.tsx +453 -0
  47. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  48. package/src/components/editor/studioMotion.ts +47 -434
  49. package/src/components/editor/studioMotionOps.ts +299 -0
  50. package/src/components/editor/studioMotionTypes.ts +168 -0
  51. package/src/components/editor/useDomEditOverlayGestures.ts +393 -0
  52. package/src/components/editor/useDomEditOverlayRects.ts +207 -0
  53. package/src/components/nle/NLELayout.tsx +68 -155
  54. package/src/components/nle/NLEPreview.tsx +3 -0
  55. package/src/components/nle/useCompositionStack.ts +126 -0
  56. package/src/components/renders/RenderQueue.tsx +102 -31
  57. package/src/components/renders/useRenderQueue.ts +8 -2
  58. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  59. package/src/contexts/DomEditContext.tsx +137 -0
  60. package/src/contexts/FileManagerContext.tsx +110 -0
  61. package/src/contexts/PanelLayoutContext.tsx +68 -0
  62. package/src/contexts/StudioContext.tsx +135 -0
  63. package/src/hooks/useAppHotkeys.ts +326 -0
  64. package/src/hooks/useAskAgentModal.ts +162 -0
  65. package/src/hooks/useCaptionDetection.ts +132 -0
  66. package/src/hooks/useCompositionDimensions.ts +25 -0
  67. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  68. package/src/hooks/useDomEditCommits.ts +437 -0
  69. package/src/hooks/useDomEditSession.ts +342 -0
  70. package/src/hooks/useDomEditTextCommits.ts +330 -0
  71. package/src/hooks/useDomSelection.ts +398 -0
  72. package/src/hooks/useFileManager.ts +431 -0
  73. package/src/hooks/useFrameCapture.ts +77 -0
  74. package/src/hooks/useLintModal.ts +35 -0
  75. package/src/hooks/useManifestPersistence.ts +492 -0
  76. package/src/hooks/usePanelLayout.ts +68 -0
  77. package/src/hooks/usePreviewInteraction.ts +153 -0
  78. package/src/hooks/useRenderClipContent.ts +124 -0
  79. package/src/hooks/useTimelineEditing.ts +472 -0
  80. package/src/hooks/useToast.ts +20 -0
  81. package/src/player/components/Player.tsx +33 -2
  82. package/src/player/components/Timeline.test.ts +0 -8
  83. package/src/player/components/Timeline.tsx +196 -1518
  84. package/src/player/components/TimelineCanvas.tsx +434 -0
  85. package/src/player/components/TimelineClip.tsx +9 -244
  86. package/src/player/components/TimelineEmptyState.tsx +102 -0
  87. package/src/player/components/TimelineRuler.tsx +90 -0
  88. package/src/player/components/timelineIcons.tsx +49 -0
  89. package/src/player/components/timelineLayout.ts +215 -0
  90. package/src/player/components/timelineUtils.ts +211 -0
  91. package/src/player/components/useTimelineClipDrag.ts +388 -0
  92. package/src/player/components/useTimelinePlayhead.ts +200 -0
  93. package/src/player/components/useTimelineRangeSelection.ts +135 -0
  94. package/src/player/hooks/usePlaybackKeyboard.ts +171 -0
  95. package/src/player/hooks/useTimelinePlayer.ts +105 -1371
  96. package/src/player/hooks/useTimelineSyncCallbacks.ts +288 -0
  97. package/src/player/lib/playbackAdapter.ts +145 -0
  98. package/src/player/lib/playbackShortcuts.ts +68 -0
  99. package/src/player/lib/playbackTypes.ts +60 -0
  100. package/src/player/lib/timelineDOM.ts +373 -0
  101. package/src/player/lib/timelineElementHelpers.ts +303 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +269 -0
  103. package/src/utils/domEditHelpers.ts +50 -0
  104. package/src/utils/studioFontHelpers.ts +83 -0
  105. package/src/utils/studioHelpers.ts +214 -0
  106. package/src/utils/studioPreviewHelpers.ts +185 -0
  107. package/src/utils/timelineDiscovery.ts +1 -1
  108. package/dist/assets/hyperframes-player-DjsVzYFP.js +0 -418
  109. package/dist/assets/index-14zH9lqh.css +0 -1
  110. package/dist/assets/index-DYCiFGWQ.js +0 -108
  111. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Runtime iframe integration utilities.
3
+ *
4
+ * Handles the boundary between the studio host page and the preview iframe:
5
+ * - Viewport normalisation on load
6
+ * - Auto-healing missing data-composition-id attributes
7
+ * - Unmuting media via postMessage
8
+ * - Resolving the underlying <iframe> from any wrapper element
9
+ * - Scanning the DOM for composition hosts the manifest missed
10
+ * (element-reference starts that the CDN runtime fails to resolve)
11
+ */
12
+
13
+ import type { TimelineElement } from "../store/playerStore";
14
+ import type { IframeWindow } from "./playbackTypes";
15
+ import {
16
+ getTimelineElementSelector,
17
+ getTimelineElementSourceFile,
18
+ getTimelineElementSelectorIndex,
19
+ getTimelineElementDisplayLabel,
20
+ buildTimelineElementIdentity,
21
+ } from "./timelineElementHelpers";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Viewport / DOM normalisation
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export function normalizePreviewViewport(doc: Document, win: Window): void {
28
+ if (doc.documentElement) {
29
+ doc.documentElement.style.overflow = "hidden";
30
+ doc.documentElement.style.margin = "0";
31
+ }
32
+ if (doc.body) {
33
+ doc.body.style.overflow = "hidden";
34
+ doc.body.style.margin = "0";
35
+ }
36
+ win.scrollTo({ top: 0, left: 0, behavior: "auto" });
37
+ }
38
+
39
+ export function autoHealMissingCompositionIds(doc: Document): void {
40
+ const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
41
+ const referencedIds = new Set<string>();
42
+ const scopedNodes = Array.from(doc.querySelectorAll("style, script"));
43
+ for (const node of scopedNodes) {
44
+ const text = node.textContent || "";
45
+ if (!text) continue;
46
+ let match: RegExpExecArray | null;
47
+ while ((match = compositionIdRe.exec(text)) !== null) {
48
+ const id = (match[1] || "").trim();
49
+ if (id) referencedIds.add(id);
50
+ }
51
+ }
52
+
53
+ if (referencedIds.size === 0) return;
54
+
55
+ const existingIds = new Set<string>();
56
+ const existingNodes = Array.from(doc.querySelectorAll<HTMLElement>("[data-composition-id]"));
57
+ for (const node of existingNodes) {
58
+ const id = node.getAttribute("data-composition-id");
59
+ if (id) existingIds.add(id);
60
+ }
61
+
62
+ for (const compId of referencedIds) {
63
+ if (compId === "root" || existingIds.has(compId)) continue;
64
+ const host =
65
+ doc.getElementById(`${compId}-layer`) ||
66
+ doc.getElementById(`${compId}-comp`) ||
67
+ doc.getElementById(compId);
68
+ if (!host) continue;
69
+ if (!host.getAttribute("data-composition-id")) {
70
+ host.setAttribute("data-composition-id", compId);
71
+ }
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Muting / iframe resolution
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
80
+ if (!iframe) return;
81
+ try {
82
+ iframe.contentWindow?.postMessage(
83
+ { source: "hf-parent", type: "control", action: "set-muted", muted: false },
84
+ "*",
85
+ );
86
+ } catch (err) {
87
+ console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve the underlying iframe from any host element. Supports:
93
+ * - Direct `<iframe>` element (most common — studio's own `Player.tsx`)
94
+ * - Custom elements (e.g. `<hyperframes-player>`) whose shadow DOM contains an iframe
95
+ * - Wrapper elements whose light DOM contains a descendant iframe
96
+ *
97
+ * Exported so web-component consumers can pre-resolve the iframe before
98
+ * assigning it to `iframeRef` returned by `useTimelinePlayer`. Returns `null`
99
+ * when the element has no associated iframe yet.
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * const { iframeRef } = useTimelinePlayer();
104
+ * const playerElRef = useRef<HyperframesPlayer>(null);
105
+ *
106
+ * useEffect(() => {
107
+ * iframeRef.current = resolveIframe(playerElRef.current);
108
+ * }, [iframeRef]);
109
+ * ```
110
+ */
111
+ export function resolveIframe(el: Element | null): HTMLIFrameElement | null {
112
+ if (!el) return null;
113
+ if (el instanceof HTMLIFrameElement) return el;
114
+ return el.shadowRoot?.querySelector("iframe") ?? el.querySelector("iframe") ?? null;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Enrich missing compositions from DOM
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Scan the iframe DOM for composition hosts missing from the current
123
+ * timeline elements and add them. The CDN runtime often fails to resolve
124
+ * element-reference starts (`data-start="intro"`) so composition hosts
125
+ * are silently dropped from `__clipManifest`. This pass reads the DOM +
126
+ * GSAP timeline registry directly to fill the gaps.
127
+ */
128
+ export function buildMissingCompositionElements(
129
+ doc: Document,
130
+ iframeWin: IframeWindow,
131
+ currentEls: readonly TimelineElement[],
132
+ rootDuration: number,
133
+ ): { missing: TimelineElement[]; updatedEls: TimelineElement[]; patched: boolean } {
134
+ const existingIds = new Set(currentEls.map((e) => e.id));
135
+ const rootComp = doc.querySelector("[data-composition-id]");
136
+ const rootCompId = rootComp?.getAttribute("data-composition-id");
137
+ // Use [data-composition-id][data-start] — the composition loader strips
138
+ // data-composition-src after loading, so we can't rely on it.
139
+ const hosts = doc.querySelectorAll("[data-composition-id][data-start]");
140
+ const missing: TimelineElement[] = [];
141
+
142
+ hosts.forEach((host) => {
143
+ const el = host as HTMLElement;
144
+ const compId = el.getAttribute("data-composition-id");
145
+ if (!compId || compId === rootCompId) return;
146
+ if (existingIds.has(el.id) || existingIds.has(compId)) return;
147
+
148
+ // Resolve start: numeric or element-reference
149
+ const startAttr = el.getAttribute("data-start") ?? "0";
150
+ let start = parseFloat(startAttr);
151
+ if (isNaN(start)) {
152
+ const ref =
153
+ doc.getElementById(startAttr) || doc.querySelector(`[data-composition-id="${startAttr}"]`);
154
+ if (ref) {
155
+ const refStartAttr = ref.getAttribute("data-start") ?? "0";
156
+ let refStart = parseFloat(refStartAttr);
157
+ // Recursively resolve one level of reference for the ref's own start
158
+ if (isNaN(refStart)) {
159
+ const refRef =
160
+ doc.getElementById(refStartAttr) ||
161
+ doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
162
+ const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
163
+ const rrCompId = refRef?.getAttribute("data-composition-id");
164
+ const rrDur =
165
+ parseFloat(refRef?.getAttribute("data-duration") ?? "") ||
166
+ (rrCompId
167
+ ? ((
168
+ iframeWin.__timelines?.[rrCompId] as { duration?: () => number } | undefined
169
+ )?.duration?.() ?? 0)
170
+ : 0);
171
+ refStart = rrStart + rrDur;
172
+ }
173
+ const refCompId = ref.getAttribute("data-composition-id");
174
+ const refDur =
175
+ parseFloat(ref.getAttribute("data-duration") ?? "") ||
176
+ (refCompId
177
+ ? ((
178
+ iframeWin.__timelines?.[refCompId] as { duration?: () => number } | undefined
179
+ )?.duration?.() ?? 0)
180
+ : 0);
181
+ start = refStart + refDur;
182
+ } else {
183
+ start = 0;
184
+ }
185
+ }
186
+
187
+ // Resolve duration from data-duration or GSAP timeline
188
+ let dur = parseFloat(el.getAttribute("data-duration") ?? "");
189
+ if (isNaN(dur) || dur <= 0) {
190
+ dur =
191
+ (
192
+ iframeWin.__timelines?.[compId] as { duration?: () => number } | undefined
193
+ )?.duration?.() ?? 0;
194
+ }
195
+ if (!Number.isFinite(dur) || dur <= 0) return;
196
+ if (!Number.isFinite(start)) start = 0;
197
+ if (Number.isFinite(rootDuration) && rootDuration > 0) {
198
+ if (start >= rootDuration) return;
199
+ dur = Math.min(dur, Math.max(0, rootDuration - start));
200
+ if (dur <= 0) return;
201
+ }
202
+
203
+ const trackStr = el.getAttribute("data-track-index");
204
+ const track = trackStr != null ? parseInt(trackStr, 10) : 0;
205
+ const compSrc =
206
+ el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
207
+ const selector = getTimelineElementSelector(el);
208
+ const sourceFile = getTimelineElementSourceFile(el);
209
+ const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
210
+ const label = getTimelineElementDisplayLabel({
211
+ id: el.id || compId || null,
212
+ label: el.getAttribute("data-timeline-label") ?? el.getAttribute("data-label"),
213
+ tag: el.tagName,
214
+ });
215
+ const identity = buildTimelineElementIdentity({
216
+ preferredId: el.id || compId || null,
217
+ label,
218
+ fallbackIndex: missing.length,
219
+ domId: el.id || undefined,
220
+ selector,
221
+ selectorIndex,
222
+ sourceFile,
223
+ });
224
+ const entry: TimelineElement = {
225
+ id: identity.id,
226
+ label,
227
+ key: identity.key,
228
+ tag: el.tagName.toLowerCase(),
229
+ start,
230
+ duration: dur,
231
+ track: isNaN(track) ? 0 : track,
232
+ domId: el.id || undefined,
233
+ selector,
234
+ selectorIndex,
235
+ sourceFile,
236
+ };
237
+ if (compSrc) {
238
+ entry.compositionSrc = compSrc;
239
+ } else {
240
+ // Inline composition — expose inner video for thumbnails
241
+ const innerVideo = el.querySelector("video[src]");
242
+ if (innerVideo) {
243
+ entry.src = innerVideo.getAttribute("src") || undefined;
244
+ entry.tag = "video";
245
+ }
246
+ }
247
+ missing.push(entry);
248
+ });
249
+
250
+ // Patch existing elements that are missing compositionSrc
251
+ let patched = false;
252
+ const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
253
+ if (existing.compositionSrc) return existing;
254
+ // Find the matching DOM host by element id or composition id
255
+ const host =
256
+ doc.getElementById(existing.id) ??
257
+ doc.querySelector(`[data-composition-id="${existing.id}"]`);
258
+ if (!host) return existing;
259
+ const compSrc =
260
+ host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
261
+ if (compSrc) {
262
+ patched = true;
263
+ return { ...existing, compositionSrc: compSrc };
264
+ }
265
+ return existing;
266
+ });
267
+
268
+ return { missing, updatedEls, patched };
269
+ }
@@ -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
+ }