@hyperframes/studio 0.8.48 → 0.8.50

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 (124) hide show
  1. package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
  2. package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +16 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6209 -5924
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useBlockHandlers.ts +7 -2
  35. package/src/hooks/useClipboard.test.ts +234 -0
  36. package/src/hooks/useClipboard.ts +264 -70
  37. package/src/hooks/useGroupCommits.test.ts +0 -1
  38. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  39. package/src/hooks/useRazorSplit.test.ts +0 -2
  40. package/src/hooks/useRazorSplit.test.tsx +0 -1
  41. package/src/hooks/useStudioContextValue.ts +5 -19
  42. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  43. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  44. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  45. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  46. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  47. package/src/player/components/ClipContextMenu.tsx +87 -7
  48. package/src/player/components/Timeline.tsx +14 -14
  49. package/src/player/components/TimelineCanvas.tsx +30 -0
  50. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  51. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  52. package/src/player/components/TimelineLanes.test.tsx +22 -1
  53. package/src/player/components/TimelineOverlays.tsx +12 -0
  54. package/src/player/components/TimelineTypes.ts +4 -0
  55. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  56. package/src/player/components/timelineClipChildren.test.ts +2 -2
  57. package/src/player/components/timelineClipChildren.tsx +2 -1
  58. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  59. package/src/player/components/timelineClipDragCommit.ts +20 -51
  60. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  61. package/src/player/components/timelineClipDragPreview.ts +8 -1
  62. package/src/player/components/timelineCollision.test.ts +23 -0
  63. package/src/player/components/timelineCollision.ts +27 -0
  64. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  65. package/src/player/components/timelineDragDrop.ts +30 -23
  66. package/src/player/components/timelineDragLanding.ts +108 -0
  67. package/src/player/components/timelineDropReveal.test.ts +34 -0
  68. package/src/player/components/timelineDropReveal.ts +12 -0
  69. package/src/player/components/timelineEditCapabilities.ts +1 -2
  70. package/src/player/components/timelineEditing.test.ts +0 -16
  71. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  72. package/src/player/components/timelineGroupEditing.ts +0 -1
  73. package/src/player/components/timelineTrackDisplay.ts +1 -1
  74. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  75. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  76. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  77. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  78. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  79. package/src/player/components/useTimelineGeometry.ts +7 -2
  80. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  81. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  82. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  83. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  84. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  85. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  86. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  87. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  88. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  89. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  90. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  91. package/src/player/lib/describeClips.test.ts +95 -0
  92. package/src/player/lib/describeClips.ts +103 -0
  93. package/src/player/lib/timelineDOM.test.ts +0 -80
  94. package/src/player/lib/timelineDOM.ts +1 -61
  95. package/src/player/lib/timelineElementHelpers.ts +10 -53
  96. package/src/player/store/playerStore.ts +5 -0
  97. package/src/player/store/readinessSlice.ts +4 -3
  98. package/src/player/store/timelineElement.ts +1 -3
  99. package/src/shims/canvasBrowserStub.js +50 -0
  100. package/src/utils/blockInstaller.test.ts +4 -0
  101. package/src/utils/blockInstaller.ts +9 -3
  102. package/src/utils/clipboardPayload.test.ts +42 -2
  103. package/src/utils/clipboardPayload.ts +57 -32
  104. package/src/utils/previewSafeMargins.test.ts +34 -0
  105. package/src/utils/previewSafeMargins.ts +10 -0
  106. package/src/utils/razorSplitTransaction.test.ts +0 -1
  107. package/src/utils/retryBudget.ts +20 -0
  108. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  109. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  110. package/src/utils/sdkCutover.ts +7 -4
  111. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  112. package/src/utils/sdkCutoverEligibility.ts +34 -11
  113. package/src/utils/studioUiPreferences.test.ts +43 -0
  114. package/src/utils/studioUiPreferences.ts +27 -6
  115. package/src/utils/timelineCompositionInsert.ts +2 -1
  116. package/src/utils/timelineElementSplit.test.ts +1 -2
  117. package/src/utils/timelineElementSplit.ts +0 -1
  118. package/src/webmcp/tools/lookTools.test.ts +40 -0
  119. package/src/webmcp/tools/lookTools.ts +32 -1
  120. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
  121. package/dist/assets/index-B9LfWNyM.css +0 -1
  122. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  123. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  124. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -37,6 +37,21 @@ describe("studio UI preferences", () => {
37
37
  });
38
38
  });
39
39
 
40
+ it("remembers the ruler and safe-margin toggles and drops non-boolean values", () => {
41
+ const storage = createStorage();
42
+ writeStudioUiPreferences({ rulerVisible: true, safeMarginsVisible: false }, storage);
43
+ expect(readStudioUiPreferences(storage)).toEqual({
44
+ rulerVisible: true,
45
+ safeMarginsVisible: false,
46
+ });
47
+
48
+ storage.setItem(
49
+ "hf-studio-ui-preferences",
50
+ JSON.stringify({ rulerVisible: "yes", safeMarginsVisible: 1 }),
51
+ );
52
+ expect(readStudioUiPreferences(storage)).toEqual({});
53
+ });
54
+
40
55
  it("ignores malformed stored values", () => {
41
56
  const storage = createStorage();
42
57
  storage.setItem(
@@ -101,6 +116,34 @@ describe("thumbnailMode preference", () => {
101
116
  });
102
117
  });
103
118
 
119
+ describe("per-project scoping", () => {
120
+ it("keeps two projects' preferences independent", () => {
121
+ const storage = createStorage();
122
+ writeStudioUiPreferences({ leftWidth: 300 }, storage, "alpha");
123
+ writeStudioUiPreferences({ leftWidth: 500 }, storage, "beta");
124
+
125
+ expect(readStudioUiPreferences(storage, "alpha").leftWidth).toBe(300);
126
+ expect(readStudioUiPreferences(storage, "beta").leftWidth).toBe(500);
127
+ });
128
+
129
+ it("a project with nothing saved yet inherits the pre-scoping global entry once", () => {
130
+ const storage = createStorage();
131
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
132
+
133
+ expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(600);
134
+ });
135
+
136
+ it("stops inheriting the global entry once the project has its own write", () => {
137
+ const storage = createStorage();
138
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ leftWidth: 600 }));
139
+
140
+ writeStudioUiPreferences({ leftWidth: 250 }, storage, "gamma");
141
+
142
+ expect(readStudioUiPreferences(storage, "gamma").leftWidth).toBe(250);
143
+ expect(readStudioUiPreferences(storage, null).leftWidth).toBe(600);
144
+ });
145
+ });
146
+
104
147
  describe("timeline zoom pin persistence", () => {
105
148
  it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
106
149
  const storage = createStorage();
@@ -20,6 +20,8 @@ export interface StudioUiPreferences {
20
20
  recentBlocks?: string[];
21
21
  snapEnabled?: boolean;
22
22
  gridVisible?: boolean;
23
+ rulerVisible?: boolean;
24
+ safeMarginsVisible?: boolean;
23
25
  gridSpacing?: number;
24
26
  snapToGrid?: boolean;
25
27
  /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
@@ -62,11 +64,15 @@ function getBrowserStorage(): Storage | null {
62
64
  }
63
65
  }
64
66
 
67
+ function storageKeyFor(projectId: string | null): string {
68
+ return projectId ? `${STUDIO_UI_PREFERENCES_KEY}:${projectId}` : STUDIO_UI_PREFERENCES_KEY;
69
+ }
70
+
65
71
  // fallow-ignore-next-line complexity
66
- function readStorage(storage: Storage | null): StudioUiPreferences {
72
+ function readStorage(storage: Storage | null, key: string): StudioUiPreferences {
67
73
  if (!storage) return {};
68
74
  try {
69
- const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
75
+ const raw = storage.getItem(key);
70
76
  if (!raw) return {};
71
77
  const parsed: unknown = JSON.parse(raw);
72
78
  if (!isRecord(parsed)) return {};
@@ -130,6 +136,12 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
130
136
  if (typeof parsed.gridVisible === "boolean") {
131
137
  preferences.gridVisible = parsed.gridVisible;
132
138
  }
139
+ if (typeof parsed.rulerVisible === "boolean") {
140
+ preferences.rulerVisible = parsed.rulerVisible;
141
+ }
142
+ if (typeof parsed.safeMarginsVisible === "boolean") {
143
+ preferences.safeMarginsVisible = parsed.safeMarginsVisible;
144
+ }
133
145
  if (typeof parsed.gridSpacing === "number" && Number.isFinite(parsed.gridSpacing)) {
134
146
  preferences.gridSpacing = parsed.gridSpacing;
135
147
  }
@@ -163,21 +175,30 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
163
175
  }
164
176
  }
165
177
 
166
- export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
167
- return readStorage(storage);
178
+ /** `projectId` opts a caller into a per-project entry (falls back once to the
179
+ * shared entry so a project's first read isn't blank). Defaults to `null`:
180
+ * most callers read once at mount, never on a live project switch. */
181
+ export function readStudioUiPreferences(
182
+ storage: Storage | null = getBrowserStorage(),
183
+ projectId: string | null = null,
184
+ ): StudioUiPreferences {
185
+ const scoped = readStorage(storage, storageKeyFor(projectId));
186
+ if (!projectId || Object.keys(scoped).length > 0) return scoped;
187
+ return readStorage(storage, STUDIO_UI_PREFERENCES_KEY);
168
188
  }
169
189
 
170
190
  export function writeStudioUiPreferences(
171
191
  patch: StudioUiPreferences,
172
192
  storage: Storage | null = getBrowserStorage(),
193
+ projectId: string | null = null,
173
194
  ) {
174
195
  if (!storage) return;
175
196
  try {
176
197
  const next = {
177
- ...readStorage(storage),
198
+ ...readStudioUiPreferences(storage, projectId),
178
199
  ...patch,
179
200
  };
180
- storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
201
+ storage.setItem(storageKeyFor(projectId), JSON.stringify(next));
181
202
  } catch {
182
203
  /* localStorage may be unavailable or full */
183
204
  }
@@ -3,6 +3,7 @@ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
3
3
  import type { RecordEditInput } from "./studioFileHistory";
4
4
  import { buildProjectApiPath } from "./projectRouting";
5
5
  import { studioWriteHeaders } from "./studioFileVersion";
6
+ import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
6
7
 
7
8
  interface TimelineCompositionInsertionResult {
8
9
  path: string;
@@ -76,7 +77,7 @@ export async function commitTimelineCompositionInsertion(input: {
76
77
  await input.writeFile(input.targetPath, result.before, result.after);
77
78
  throw error;
78
79
  }
79
- input.selectHost(`${input.targetPath}#${result.hostId}`);
80
+ input.selectHost(deriveTimelineStoreKeyForDomId(result.hostId, input.targetPath));
80
81
  try {
81
82
  input.resync?.();
82
83
  } catch (error) {
@@ -78,9 +78,8 @@ describe("canSplitElementAt", () => {
78
78
  ).toBe(false);
79
79
  });
80
80
 
81
- it("rejects locked and implicit elements while allowing identified compositions", () => {
81
+ it("rejects locked elements while allowing identified compositions", () => {
82
82
  expect(canSplitElementAt(element({ timelineLocked: true }), 3)).toBe(false);
83
- expect(canSplitElementAt(element({ timingSource: "implicit" }), 3)).toBe(false);
84
83
  expect(
85
84
  canSplitElementAt(
86
85
  element({ kind: "composition", compositionSrc: "child.html", playbackRate: 2 }),
@@ -27,7 +27,6 @@ export function canSplitElement(el: TimelineElement): boolean {
27
27
  el.playbackRate == null || (Number.isFinite(el.playbackRate) && el.playbackRate > 0);
28
28
  return (
29
29
  !el.timelineLocked &&
30
- el.timingSource !== "implicit" &&
31
30
  hasStableIdentity &&
32
31
  hasValidRate &&
33
32
  !!el.duration &&
@@ -370,3 +370,43 @@ function sceneDoc(): Document {
370
370
  </main>`;
371
371
  return doc;
372
372
  }
373
+
374
+ describe("buildStudioLook clips", () => {
375
+ const clip = (overrides: Partial<TimelineElement>) =>
376
+ element({ tag: "audio", kind: "audio", domId: "sfx", start: 2, duration: 0.5, ...overrides });
377
+
378
+ it("returns timeline clips that are not scene layers, audio included", () => {
379
+ const result = buildStudioLook(
380
+ snapshot({
381
+ elements: [clip({ domId: "late", start: 9 }), clip({ domId: "early", volume: 0.85 })],
382
+ }),
383
+ );
384
+ if (!result.ok) throw new Error("expected ok");
385
+ expect(result.clipCount).toBe(2);
386
+ expect(result.clips.map((c) => c.id)).toEqual(["early", "late"]);
387
+ expect(result.clips[0]).toMatchObject({ kind: "audio", start: 2, volume: 0.85 });
388
+ expect(result.elements).toEqual([]);
389
+ });
390
+
391
+ it("filters clips by id, kind or src", () => {
392
+ const result = buildStudioLook(
393
+ snapshot({
394
+ elements: [
395
+ clip({ domId: "click", src: "click.mp3" }),
396
+ clip({ domId: "vo", src: "voice.mp3" }),
397
+ ],
398
+ }),
399
+ { filter: "voice" },
400
+ );
401
+ if (!result.ok) throw new Error("expected ok");
402
+ expect(result.clips.map((c) => c.id)).toEqual(["vo"]);
403
+ });
404
+
405
+ it("caps clips at the limit and reports the full count and truncation", () => {
406
+ const elements = Array.from({ length: 5 }, (_, i) => clip({ domId: `c${i}`, start: i }));
407
+ const result = buildStudioLook(snapshot({ elements }), { limit: 2 });
408
+ if (!result.ok) throw new Error("expected ok");
409
+ expect(result.clips.map((c) => c.id)).toEqual(["c0", "c1"]);
410
+ expect([result.clipCount, result.clipsTruncated]).toEqual([5, true]);
411
+ });
412
+ });
@@ -11,6 +11,7 @@
11
11
  import { collectDomEditLayerItems } from "../../components/editor/domEditingLayers";
12
12
  import type { DomEditLayerItem, DomEditSelection } from "../../components/editor/domEditingTypes";
13
13
  import type { TimelineElement } from "../../player/store/timelineElement";
14
+ import { byStart, describeClip, type ClipFact } from "../../player/lib/describeClips";
14
15
  import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
15
16
  import { toolOk, type ToolResult } from "../toolResult";
16
17
 
@@ -107,8 +108,15 @@ export interface StudioLook {
107
108
  elementCount: number;
108
109
  truncated: boolean;
109
110
  elements: LookElement[];
111
+ /** Every timeline clip, audio included, whether or not it is a visual layer. */
112
+ clipCount: number;
113
+ clipsTruncated: boolean;
114
+ clips: LookClip[];
110
115
  }
111
116
 
117
+ /** A timeline clip; `handle` is null when the clip has no addressable source element. */
118
+ type LookClip = ClipFact & { handle: string | null };
119
+
112
120
  export interface StudioLookInput {
113
121
  /** Case-insensitive substring match against label, tag, and handle. */
114
122
  filter?: string;
@@ -154,6 +162,22 @@ function describeSelection(
154
162
  };
155
163
  }
156
164
 
165
+ function matchesClipFilter(clip: LookClip, needle: string): boolean {
166
+ return [clip.id, clip.label, clip.kind, clip.src].some((field) =>
167
+ field?.toLowerCase().includes(needle),
168
+ );
169
+ }
170
+
171
+ function describeClipsWithHandles(snapshot: StudioLookSnapshot): LookClip[] {
172
+ const activeCompositionPath = snapshot.compositionPath ?? "index.html";
173
+ return snapshot.elements.map((element) => ({
174
+ ...describeClip(element),
175
+ handle: mintElementHandle(
176
+ timelineElementAddress(element, activeCompositionPath, snapshot.projectId),
177
+ ),
178
+ }));
179
+ }
180
+
157
181
  function matchesFilter(element: LookElement, needle: string): boolean {
158
182
  return (
159
183
  element.label.toLowerCase().includes(needle) ||
@@ -258,6 +282,8 @@ export function buildStudioLook(
258
282
  const requested =
259
283
  Number.isInteger(input.limit) && input.limit! > 0 ? input.limit! : DEFAULT_LIMIT;
260
284
  const limit = Math.min(requested, DEFAULT_LIMIT);
285
+ const clips = describeClipsWithHandles(snapshot).sort(byStart);
286
+ const matchedClips = needle ? clips.filter((clip) => matchesClipFilter(clip, needle)) : clips;
261
287
 
262
288
  return toolOk<StudioLook>({
263
289
  projectId: snapshot.projectId,
@@ -281,6 +307,9 @@ export function buildStudioLook(
281
307
  elementCount: matched.length,
282
308
  truncated: matched.length > limit,
283
309
  elements: matched.slice(0, limit),
310
+ clipCount: matchedClips.length,
311
+ clipsTruncated: matchedClips.length > limit,
312
+ clips: matchedClips.slice(0, limit),
284
313
  });
285
314
  }
286
315
 
@@ -296,7 +325,7 @@ export const STUDIO_LOOK_INPUT_SCHEMA = {
296
325
  type: "integer",
297
326
  minimum: 1,
298
327
  maximum: DEFAULT_LIMIT,
299
- description: `Cap the returned elements (default and max ${DEFAULT_LIMIT}). elementCount always reports the full match count.`,
328
+ description: `Cap the returned elements and clips (default and max ${DEFAULT_LIMIT} each). elementCount and clipCount always report the full match count.`,
300
329
  },
301
330
  },
302
331
  additionalProperties: false,
@@ -307,6 +336,8 @@ export const STUDIO_LOOK_DESCRIPTION = [
307
336
  "the playhead and duration, what the human currently has selected (including what that",
308
337
  "element will and will not accept), and the live nested scene in DOM preorder.",
309
338
  "Each scene element includes source ownership, hierarchy, and optional timeline timing.",
339
+ "`clips` lists every timeline clip, audio included: kind, start, duration, track, volume,",
340
+ "volume/effect automation points, playback rate, audio group and role.",
310
341
  "Pass a handle back to any tool that edits an element.",
311
342
  "Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
312
343
  "`history.undoLabel` is worth checkpointing before a batch: if it later names something",