@hyperframes/studio 0.6.7 → 0.6.8

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 (30) hide show
  1. package/dist/assets/index-BSe0Kibk.js +115 -0
  2. package/dist/index.html +1 -1
  3. package/package.json +4 -4
  4. package/src/App.tsx +5 -10
  5. package/src/components/StudioLeftSidebar.tsx +16 -2
  6. package/src/components/StudioRightPanel.tsx +15 -2
  7. package/src/components/editor/MotionPanel.tsx +8 -8
  8. package/src/components/editor/SourceEditor.tsx +14 -0
  9. package/src/components/editor/manualEdits.ts +2 -0
  10. package/src/components/editor/manualEditsDom.ts +56 -0
  11. package/src/components/editor/studioMotion.ts +96 -0
  12. package/src/components/editor/studioMotionOps.test.ts +445 -0
  13. package/src/components/editor/studioMotionOps.ts +78 -4
  14. package/src/components/renders/RenderQueue.tsx +20 -6
  15. package/src/components/renders/renderSettings.ts +38 -0
  16. package/src/components/renders/useRenderQueue.ts +11 -1
  17. package/src/components/sidebar/CompositionsTab.tsx +43 -1
  18. package/src/components/sidebar/LeftSidebar.tsx +6 -0
  19. package/src/contexts/FileManagerContext.tsx +6 -0
  20. package/src/hooks/useDomEditCommits.ts +45 -33
  21. package/src/hooks/useDomEditSession.ts +26 -25
  22. package/src/hooks/useFileManager.ts +42 -0
  23. package/src/hooks/useManifestPersistence.ts +40 -218
  24. package/src/hooks/usePreviewInteraction.ts +7 -0
  25. package/src/player/components/Player.tsx +12 -3
  26. package/src/player/components/PlayerControls.tsx +29 -2
  27. package/src/player/components/useTimelineRangeSelection.ts +30 -3
  28. package/src/utils/sourcePatcher.test.ts +285 -0
  29. package/src/utils/sourcePatcher.ts +26 -6
  30. package/dist/assets/index-Yvtxngdi.js +0 -116
@@ -5,6 +5,8 @@ interface CompositionsTabProps {
5
5
  compositions: string[];
6
6
  activeComposition: string | null;
7
7
  onSelect: (comp: string) => void;
8
+ onRenderComposition?: (comp: string) => void;
9
+ isRendering?: boolean;
8
10
  }
9
11
 
10
12
  const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
@@ -94,11 +96,15 @@ function CompCard({
94
96
  comp,
95
97
  isActive,
96
98
  onSelect,
99
+ onRender,
100
+ isRendering,
97
101
  }: {
98
102
  projectId: string;
99
103
  comp: string;
100
104
  isActive: boolean;
101
105
  onSelect: () => void;
106
+ onRender?: () => void;
107
+ isRendering?: boolean;
102
108
  }) {
103
109
  const [hovered, setHovered] = useState(false);
104
110
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
@@ -158,7 +164,7 @@ function CompCard({
158
164
  onClick={onSelect}
159
165
  onPointerEnter={handleEnter}
160
166
  onPointerLeave={handleLeave}
161
- className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
167
+ className={`group/card w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
162
168
  isActive
163
169
  ? "bg-studio-accent/10 border-l-2 border-studio-accent"
164
170
  : "border-l-2 border-transparent hover:bg-neutral-800/50"
@@ -200,6 +206,38 @@ function CompCard({
200
206
  <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
201
207
  <span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
202
208
  </div>
209
+ {onRender && (
210
+ <button
211
+ type="button"
212
+ title={isRendering ? "Rendering..." : `Render ${name}`}
213
+ aria-label={isRendering ? "Rendering..." : `Render ${name}`}
214
+ disabled={isRendering}
215
+ onClick={(e) => {
216
+ e.stopPropagation();
217
+ onRender();
218
+ }}
219
+ className={`flex-shrink-0 p-1 rounded transition-colors ${
220
+ isRendering
221
+ ? "text-neutral-600 cursor-not-allowed"
222
+ : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
223
+ }`}
224
+ >
225
+ <svg
226
+ width="14"
227
+ height="14"
228
+ viewBox="0 0 24 24"
229
+ fill="none"
230
+ stroke="currentColor"
231
+ strokeWidth="2"
232
+ strokeLinecap="round"
233
+ strokeLinejoin="round"
234
+ >
235
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
236
+ <polyline points="7 10 12 15 17 10" />
237
+ <line x1="12" y1="15" x2="12" y2="3" />
238
+ </svg>
239
+ </button>
240
+ )}
203
241
  </div>
204
242
  );
205
243
  }
@@ -209,6 +247,8 @@ export const CompositionsTab = memo(function CompositionsTab({
209
247
  compositions,
210
248
  activeComposition,
211
249
  onSelect,
250
+ onRenderComposition,
251
+ isRendering,
212
252
  }: CompositionsTabProps) {
213
253
  if (compositions.length === 0) {
214
254
  return (
@@ -227,6 +267,8 @@ export const CompositionsTab = memo(function CompositionsTab({
227
267
  comp={comp}
228
268
  isActive={activeComposition === comp}
229
269
  onSelect={() => onSelect(comp)}
270
+ onRender={onRenderComposition ? () => onRenderComposition(comp) : undefined}
271
+ isRendering={isRendering}
230
272
  />
231
273
  ))}
232
274
  </div>
@@ -43,6 +43,8 @@ interface LeftSidebarProps {
43
43
  onDuplicateFile?: (path: string) => void;
44
44
  onMoveFile?: (oldPath: string, newPath: string) => void;
45
45
  codeChildren?: ReactNode;
46
+ onRenderComposition?: (comp: string) => void;
47
+ isRendering?: boolean;
46
48
  onLint?: () => void;
47
49
  linting?: boolean;
48
50
  onToggleCollapse?: () => void;
@@ -69,6 +71,8 @@ export const LeftSidebar = memo(
69
71
  onDuplicateFile,
70
72
  onMoveFile,
71
73
  codeChildren,
74
+ onRenderComposition,
75
+ isRendering,
72
76
  onLint,
73
77
  linting,
74
78
  onToggleCollapse,
@@ -169,6 +173,8 @@ export const LeftSidebar = memo(
169
173
  compositions={compositions}
170
174
  activeComposition={activeComposition}
171
175
  onSelect={onSelectComposition}
176
+ onRenderComposition={onRenderComposition}
177
+ isRendering={isRendering}
172
178
  />
173
179
  )}
174
180
  {tab === "assets" && (
@@ -26,6 +26,8 @@ export function FileManagerProvider({
26
26
  readProjectFile,
27
27
  writeProjectFile,
28
28
  readOptionalProjectFile,
29
+ revealSourceOffset,
30
+ openSourceForSelection,
29
31
  handleFileSelect,
30
32
  handleContentChange,
31
33
  refreshFileTree,
@@ -62,6 +64,8 @@ export function FileManagerProvider({
62
64
  readProjectFile,
63
65
  writeProjectFile,
64
66
  readOptionalProjectFile,
67
+ revealSourceOffset,
68
+ openSourceForSelection,
65
69
  handleFileSelect,
66
70
  handleContentChange,
67
71
  refreshFileTree,
@@ -92,6 +96,8 @@ export function FileManagerProvider({
92
96
  readProjectFile,
93
97
  writeProjectFile,
94
98
  readOptionalProjectFile,
99
+ revealSourceOffset,
100
+ openSourceForSelection,
95
101
  handleFileSelect,
96
102
  handleContentChange,
97
103
  refreshFileTree,
@@ -20,12 +20,14 @@ import {
20
20
  buildClearPathOffsetPatches,
21
21
  buildClearBoxSizePatches,
22
22
  buildClearRotationPatches,
23
+ buildMotionPatches,
24
+ buildClearMotionPatches,
23
25
  } from "../components/editor/manualEditsDom";
24
26
  import {
25
- removeStudioMotionForSelection,
27
+ writeStudioMotionToElement,
28
+ clearStudioMotionFromElement,
29
+ applyStudioMotionFromDom,
26
30
  type StudioGsapMotion,
27
- type StudioMotionManifest,
28
- upsertStudioGsapMotion,
29
31
  } from "../components/editor/studioMotion";
30
32
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
31
33
  import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
@@ -58,11 +60,6 @@ export interface UseDomEditCommitsParams {
58
60
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
59
61
  showToast: (message: string, tone?: "error" | "info") => void;
60
62
  queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
61
- commitStudioMotionManifestOptimistically: (
62
- updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
63
- options: { label: string; coalesceKey: string },
64
- ) => void;
65
- applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
66
63
  writeProjectFile: (path: string, content: string) => Promise<void>;
67
64
  domEditSaveTimestampRef: React.MutableRefObject<number>;
68
65
  editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
@@ -93,8 +90,6 @@ export function useDomEditCommits({
93
90
  previewIframeRef,
94
91
  showToast,
95
92
  queueDomEditSave,
96
- commitStudioMotionManifestOptimistically,
97
- applyCurrentStudioMotionToPreview,
98
93
  writeProjectFile,
99
94
  domEditSaveTimestampRef,
100
95
  editHistory,
@@ -306,43 +301,60 @@ export function useDomEditCommits({
306
301
  [commitPositionPatchToHtml],
307
302
  );
308
303
 
309
- // ── Motion commits ──
304
+ // ── Motion commits (HTML-attribute–backed) ──
310
305
 
311
306
  const handleDomMotionCommit = useCallback(
312
307
  (
313
308
  selection: DomEditSelection,
314
309
  motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
315
310
  ) => {
316
- commitStudioMotionManifestOptimistically(
317
- (manifest) => upsertStudioGsapMotion(manifest, selection, motion),
318
- {
319
- label: "Set GSAP motion",
320
- coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
321
- },
322
- );
311
+ // 1. Write motion data as JSON attribute on the element
312
+ writeStudioMotionToElement(selection.element, motion);
313
+ // 2. Apply the GSAP timeline from DOM attributes
314
+ let doc: Document | null = null;
315
+ try {
316
+ doc = previewIframeRef.current?.contentDocument ?? null;
317
+ } catch {
318
+ // cross-origin guard
319
+ }
320
+ if (doc) applyStudioMotionFromDom(doc);
321
+ // 3. Build patches and persist to HTML
322
+ const patches = buildMotionPatches(selection.element);
323
+ commitPositionPatchToHtml(selection, patches, {
324
+ label: "Set GSAP motion",
325
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
326
+ });
323
327
  refreshDomEditSelectionFromPreview(selection);
324
328
  },
325
- [commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
329
+ [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
326
330
  );
327
331
 
328
332
  const handleDomMotionClear = useCallback(
329
333
  (selection: DomEditSelection) => {
330
- commitStudioMotionManifestOptimistically(
331
- (manifest) => removeStudioMotionForSelection(manifest, selection),
332
- {
333
- label: "Clear GSAP motion",
334
- coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
335
- },
336
- );
337
- applyCurrentStudioMotionToPreview(previewIframeRef.current);
334
+ const clearPatches = buildClearMotionPatches(selection.element);
335
+ // Get gsap from the preview window for proper cleanup
336
+ let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
337
+ try {
338
+ gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
339
+ } catch {
340
+ // cross-origin guard
341
+ }
342
+ clearStudioMotionFromElement(selection.element, gsap);
343
+ let doc: Document | null = null;
344
+ try {
345
+ doc = previewIframeRef.current?.contentDocument ?? null;
346
+ } catch {
347
+ // cross-origin guard
348
+ }
349
+ if (doc) applyStudioMotionFromDom(doc);
350
+ commitPositionPatchToHtml(selection, clearPatches, {
351
+ label: "Clear GSAP motion",
352
+ coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
353
+ skipRefresh: false,
354
+ });
338
355
  refreshDomEditSelectionFromPreview(selection);
339
356
  },
340
- [
341
- applyCurrentStudioMotionToPreview,
342
- commitStudioMotionManifestOptimistically,
343
- refreshDomEditSelectionFromPreview,
344
- previewIframeRef,
345
- ],
357
+ [commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
346
358
  );
347
359
 
348
360
  const handleDomEditElementDelete = useCallback(
@@ -1,11 +1,12 @@
1
- import { useEffect } from "react";
1
+ import { useCallback, useEffect } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
4
- import { findElementForSelection } from "../components/editor/domEditing";
5
- import type { StudioMotionManifest } from "../components/editor/studioMotion";
4
+ import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
6
5
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
7
6
  import type { EditHistoryKind } from "../utils/editHistory";
8
7
  import type { RightPanelTab } from "../utils/studioHelpers";
8
+ import type { PatchTarget } from "../utils/sourcePatcher";
9
+ import type { SidebarTab } from "../components/sidebar/LeftSidebar";
9
10
  import { useAskAgentModal } from "./useAskAgentModal";
10
11
  import { useDomSelection } from "./useDomSelection";
11
12
  import { usePreviewInteraction } from "./usePreviewInteraction";
@@ -36,11 +37,6 @@ export interface UseDomEditSessionParams {
36
37
  showToast: (message: string, tone?: "error" | "info") => void;
37
38
  refreshPreviewDocumentVersion: () => void;
38
39
  queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
39
- commitStudioMotionManifestOptimistically: (
40
- updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
41
- options: { label: string; coalesceKey: string },
42
- ) => void;
43
- applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
44
40
  readProjectFile: (path: string) => Promise<string>;
45
41
  writeProjectFile: (path: string, content: string) => Promise<void>;
46
42
  domEditSaveTimestampRef: React.MutableRefObject<number>;
@@ -55,12 +51,11 @@ export interface UseDomEditSessionParams {
55
51
  applyStudioManualEditsToPreviewRef: React.MutableRefObject<
56
52
  (iframe: HTMLIFrameElement) => Promise<void>
57
53
  >;
58
- applyStudioMotionToPreviewRef: React.MutableRefObject<
59
- (iframe: HTMLIFrameElement) => Promise<void>
60
- >;
61
54
  syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
62
55
  reloadPreview: () => void;
63
56
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
57
+ openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
58
+ selectSidebarTab?: (tab: SidebarTab) => void;
64
59
  }
65
60
 
66
61
  // ── Hook ──
@@ -81,8 +76,6 @@ export function useDomEditSession({
81
76
  showToast,
82
77
  refreshPreviewDocumentVersion,
83
78
  queueDomEditSave,
84
- commitStudioMotionManifestOptimistically,
85
- applyCurrentStudioMotionToPreview,
86
79
  readProjectFile: _readProjectFile,
87
80
  writeProjectFile,
88
81
  domEditSaveTimestampRef,
@@ -95,12 +88,28 @@ export function useDomEditSession({
95
88
  refreshKey,
96
89
  rightPanelTab,
97
90
  applyStudioManualEditsToPreviewRef,
98
- applyStudioMotionToPreviewRef,
99
91
  syncPreviewHistoryHotkey,
100
92
  reloadPreview,
101
93
  setRefreshKey: _setRefreshKey,
94
+ openSourceForSelection,
95
+ selectSidebarTab,
102
96
  }: UseDomEditSessionParams) {
103
97
  void _setRefreshKey;
98
+
99
+ const onClickToSource = useCallback(
100
+ (selection: DomEditSelection) => {
101
+ if (!openSourceForSelection || !selectSidebarTab) return;
102
+ if (!selection.sourceFile) return;
103
+ selectSidebarTab("code");
104
+ openSourceForSelection(selection.sourceFile, {
105
+ id: selection.id,
106
+ selector: selection.selector,
107
+ selectorIndex: selection.selectorIndex,
108
+ });
109
+ },
110
+ [openSourceForSelection, selectSidebarTab],
111
+ );
112
+
104
113
  // ── Selection (delegated to useDomSelection) ──
105
114
 
106
115
  const {
@@ -176,6 +185,7 @@ export function useDomEditSession({
176
185
  setAgentPromptSelectionContext,
177
186
  setAgentModalAnchorPoint,
178
187
  setAgentModalOpen,
188
+ onClickToSource,
179
189
  });
180
190
 
181
191
  // ── Commit handlers (delegated to useDomEditCommits) ──
@@ -200,8 +210,6 @@ export function useDomEditSession({
200
210
  previewIframeRef,
201
211
  showToast,
202
212
  queueDomEditSave,
203
- commitStudioMotionManifestOptimistically,
204
- applyCurrentStudioMotionToPreview,
205
213
  writeProjectFile,
206
214
  domEditSaveTimestampRef,
207
215
  editHistory,
@@ -249,19 +257,13 @@ export function useDomEditSession({
249
257
  };
250
258
 
251
259
  syncPreviewHistoryHotkey(previewIframe);
252
- void (async () => {
253
- await applyStudioManualEditsToPreviewRef.current(previewIframe);
254
- await applyStudioMotionToPreviewRef.current(previewIframe);
255
- })();
260
+ void applyStudioManualEditsToPreviewRef.current(previewIframe);
256
261
  syncSelectionFromDocument();
257
262
  refreshPreviewDocumentVersion();
258
263
 
259
264
  const handleLoad = () => {
260
265
  syncPreviewHistoryHotkey(previewIframe);
261
- void (async () => {
262
- await applyStudioManualEditsToPreviewRef.current(previewIframe);
263
- await applyStudioMotionToPreviewRef.current(previewIframe);
264
- })();
266
+ void applyStudioManualEditsToPreviewRef.current(previewIframe);
265
267
  syncSelectionFromDocument();
266
268
  refreshPreviewDocumentVersion();
267
269
  };
@@ -280,7 +282,6 @@ export function useDomEditSession({
280
282
  refreshPreviewDocumentVersion,
281
283
  syncPreviewHistoryHotkey,
282
284
  applyStudioManualEditsToPreviewRef,
283
- applyStudioMotionToPreviewRef,
284
285
  ]);
285
286
 
286
287
  return {
@@ -4,6 +4,7 @@ import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
6
6
  import type { EditHistoryKind } from "../utils/editHistory";
7
+ import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
8
 
8
9
  // ── Types ──
9
10
 
@@ -37,6 +38,7 @@ export function useFileManager({
37
38
  const [projectDir, setProjectDir] = useState<string | null>(null);
38
39
  const [fileTree, setFileTree] = useState<string[]>([]);
39
40
  const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
41
+ const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);
40
42
 
41
43
  // ── Refs ──
42
44
 
@@ -169,6 +171,42 @@ export function useFileManager({
169
171
  [domEditSaveTimestampRef, readProjectFile, recordEdit, setRefreshKey, writeProjectFile],
170
172
  );
171
173
 
174
+ // ── Open source for selection (click-to-source) ──
175
+
176
+ const revealRequestIdRef = useRef(0);
177
+ const revealAbortRef = useRef<AbortController | null>(null);
178
+
179
+ const openSourceForSelection = useCallback(
180
+ (sourceFile: string, target: PatchTarget) => {
181
+ const pid = projectIdRef.current;
182
+ if (!pid || !sourceFile) return;
183
+ revealAbortRef.current?.abort();
184
+ revealAbortRef.current = null;
185
+ if (editingPathRef.current === sourceFile && editingFile?.content != null) {
186
+ const match = findTagByTarget(editingFile.content, target);
187
+ setRevealSourceOffset(match ? match.start : null);
188
+ return;
189
+ }
190
+ const requestId = ++revealRequestIdRef.current;
191
+ const controller = new AbortController();
192
+ revealAbortRef.current = controller;
193
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(sourceFile)}`, {
194
+ signal: controller.signal,
195
+ })
196
+ .then((r) => r.json())
197
+ .then((data: { content?: string }) => {
198
+ if (requestId !== revealRequestIdRef.current) return;
199
+ if (data.content != null) {
200
+ setEditingFile({ path: sourceFile, content: data.content });
201
+ const match = findTagByTarget(data.content, target);
202
+ setRevealSourceOffset(match ? match.start : null);
203
+ }
204
+ })
205
+ .catch(() => {});
206
+ },
207
+ [editingFile?.content],
208
+ );
209
+
172
210
  // ── File tree refresh ──
173
211
 
174
212
  const refreshFileTree = useCallback(async () => {
@@ -418,6 +456,10 @@ export function useFileManager({
418
456
  writeProjectFile,
419
457
  readOptionalProjectFile,
420
458
 
459
+ // Click-to-source
460
+ revealSourceOffset,
461
+ openSourceForSelection,
462
+
421
463
  // Callbacks
422
464
  handleFileSelect,
423
465
  handleContentChange,