@hyperframes/studio 0.7.45 → 0.7.47

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 (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-CZkdm5YL.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-CbQKp0Ek.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-CZUbpYhQ.js +0 -416
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -4,14 +4,19 @@ import {
4
4
  buildPromptCopyText,
5
5
  buildTimelineElementAgentPrompt,
6
6
  buildTimelineAgentPrompt,
7
+ clampTimelineGroupResizeDelta,
7
8
  getTimelineEditCapabilities,
8
9
  hasPatchableTimelineTarget,
9
10
  resolveBlockedTimelineEditIntent,
10
11
  resolveTimelineAutoScroll,
11
12
  resolveTimelineMove,
12
13
  resolveTimelineResize,
14
+ resolveTimelineGroupMove,
15
+ resolveTimelineGroupResize,
16
+ snapKeyframePctToBeat,
13
17
  type TimelinePromptElement,
14
18
  } from "./timelineEditing";
19
+ import { buildStackingTimelineLayers } from "./timelineTrackOrder";
15
20
 
16
21
  describe("resolveTimelineMove", () => {
17
22
  it("moves timing based on horizontal drag and snaps to centiseconds", () => {
@@ -155,6 +160,220 @@ describe("resolveTimelineMove", () => {
155
160
  ),
156
161
  ).toEqual({ start: 2, track: 2 });
157
162
  });
163
+
164
+ it("snaps conflicting vertical stacking movement to a new lane without changing data-track-index", () => {
165
+ const stackingElements = [
166
+ {
167
+ id: "root-front",
168
+ tag: "div",
169
+ start: 0,
170
+ duration: 2,
171
+ track: 0,
172
+ zIndex: 2,
173
+ hasExplicitZIndex: true,
174
+ stackingContextId: "root",
175
+ parentCompositionId: null,
176
+ compositionAncestors: ["root"],
177
+ },
178
+ {
179
+ id: "root-back",
180
+ tag: "div",
181
+ start: 0,
182
+ duration: 2,
183
+ track: 1,
184
+ zIndex: 1,
185
+ hasExplicitZIndex: true,
186
+ stackingContextId: "root",
187
+ parentCompositionId: null,
188
+ compositionAncestors: ["root"],
189
+ },
190
+ ];
191
+ const layers = buildStackingTimelineLayers(stackingElements).rows;
192
+ const result = resolveTimelineMove(
193
+ {
194
+ start: 0,
195
+ track: 1,
196
+ duration: 2,
197
+ originClientX: 0,
198
+ originClientY: 0,
199
+ pixelsPerSecond: 100,
200
+ trackHeight: 72,
201
+ maxStart: 8,
202
+ trackOrder: [0, 1],
203
+ layerOrder: layers.map((layer) => layer.id),
204
+ timelineLayers: layers,
205
+ stackingElement: stackingElements[1],
206
+ stackingElements,
207
+ },
208
+ 0,
209
+ -72,
210
+ );
211
+
212
+ expect(result).toEqual({
213
+ start: 0,
214
+ track: 1,
215
+ previewLayerId: `preview:root-back:above:${layers[0]!.id}`,
216
+ previewLayerIndex: 0,
217
+ stackingReorder: {
218
+ contextKey: "root",
219
+ placement: { type: "above", layerId: layers[0]!.id },
220
+ zIndexChanges: [{ key: "root-back", zIndex: 3 }],
221
+ },
222
+ });
223
+ });
224
+ });
225
+
226
+ describe("resolveTimelineGroupMove", () => {
227
+ it("applies an unclamped delta uniformly", () => {
228
+ const result = resolveTimelineGroupMove(
229
+ [
230
+ { start: 1, duration: 2 },
231
+ { start: 4, duration: 3 },
232
+ ],
233
+ 1.25,
234
+ );
235
+
236
+ expect(result).toEqual({
237
+ delta: 1.25,
238
+ members: [
239
+ { start: 2.25, duration: 2 },
240
+ { start: 5.25, duration: 3 },
241
+ ],
242
+ });
243
+ });
244
+
245
+ it("clamps the whole group when the earliest start reaches zero", () => {
246
+ const result = resolveTimelineGroupMove(
247
+ [
248
+ { start: 1, duration: 2 },
249
+ { start: 5, duration: 3 },
250
+ ],
251
+ -3,
252
+ );
253
+
254
+ expect(result).toEqual({
255
+ delta: -1,
256
+ members: [
257
+ { start: 0, duration: 2 },
258
+ { start: 4, duration: 3 },
259
+ ],
260
+ });
261
+ expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
262
+ });
263
+ });
264
+
265
+ describe("resolveTimelineGroupResize", () => {
266
+ it("returns the shared clamped delta without applying per-member starts", () => {
267
+ expect(
268
+ clampTimelineGroupResizeDelta(
269
+ 1,
270
+ [
271
+ { start: 1, duration: 0.5 },
272
+ { start: 4, duration: 2 },
273
+ ],
274
+ "start",
275
+ ),
276
+ ).toBe(0.4);
277
+ });
278
+
279
+ it("applies an unclamped start-edge delta uniformly", () => {
280
+ const result = resolveTimelineGroupResize(
281
+ [
282
+ { start: 1, duration: 3 },
283
+ { start: 5, duration: 4 },
284
+ ],
285
+ "start",
286
+ 1,
287
+ );
288
+
289
+ expect(result).toEqual({
290
+ delta: 1,
291
+ members: [
292
+ { start: 2, duration: 2, playbackStart: undefined },
293
+ { start: 6, duration: 3, playbackStart: undefined },
294
+ ],
295
+ });
296
+ expect(result.members[1]!.start - result.members[0]!.start).toBe(4);
297
+ });
298
+
299
+ it("clamps a start-edge delta when the earliest member reaches zero", () => {
300
+ const result = resolveTimelineGroupResize(
301
+ [
302
+ { start: 0.5, duration: 3 },
303
+ { start: 4, duration: 4 },
304
+ ],
305
+ "start",
306
+ -2,
307
+ );
308
+
309
+ expect(result).toEqual({
310
+ delta: -0.5,
311
+ members: [
312
+ { start: 0, duration: 3.5, playbackStart: undefined },
313
+ { start: 3.5, duration: 4.5, playbackStart: undefined },
314
+ ],
315
+ });
316
+ expect(result.members[1]!.start - result.members[0]!.start).toBe(3.5);
317
+ });
318
+
319
+ it("clamps a start-edge delta when any member reaches minimum duration", () => {
320
+ const result = resolveTimelineGroupResize(
321
+ [
322
+ { start: 1, duration: 0.5 },
323
+ { start: 4, duration: 2 },
324
+ ],
325
+ "start",
326
+ 1,
327
+ );
328
+
329
+ expect(result).toEqual({
330
+ delta: 0.4,
331
+ members: [
332
+ { start: 1.4, duration: 0.1, playbackStart: undefined },
333
+ { start: 4.4, duration: 1.6, playbackStart: undefined },
334
+ ],
335
+ });
336
+ expect(result.members[1]!.start - result.members[0]!.start).toBeCloseTo(3);
337
+ });
338
+
339
+ it("clamps an end-edge delta when any member reaches minimum duration", () => {
340
+ const result = resolveTimelineGroupResize(
341
+ [
342
+ { start: 1, duration: 0.5 },
343
+ { start: 4, duration: 2 },
344
+ ],
345
+ "end",
346
+ -1,
347
+ );
348
+
349
+ expect(result).toEqual({
350
+ delta: -0.4,
351
+ members: [
352
+ { start: 1, duration: 0.1, playbackStart: undefined },
353
+ { start: 4, duration: 1.6, playbackStart: undefined },
354
+ ],
355
+ });
356
+ expect(result.members[1]!.start - result.members[0]!.start).toBe(3);
357
+ });
358
+
359
+ it("adjusts each start-edge playback start using the shared delta", () => {
360
+ const result = resolveTimelineGroupResize(
361
+ [
362
+ { start: 2, duration: 3, playbackStart: 1, playbackRate: 1 },
363
+ { start: 5, duration: 4, playbackStart: 2, playbackRate: 2 },
364
+ ],
365
+ "start",
366
+ 0.5,
367
+ );
368
+
369
+ expect(result).toEqual({
370
+ delta: 0.5,
371
+ members: [
372
+ { start: 2.5, duration: 2.5, playbackStart: 1.5 },
373
+ { start: 5.5, duration: 3.5, playbackStart: 3 },
374
+ ],
375
+ });
376
+ });
158
377
  });
159
378
 
160
379
  describe("hasPatchableTimelineTarget", () => {
@@ -474,7 +693,7 @@ describe("buildTimelineAgentPrompt", () => {
474
693
  prompt: "Move the title later and lower the music",
475
694
  });
476
695
 
477
- expect(text).toContain("Time range: 0:01 0:04");
696
+ expect(text).toContain("Time range: 0:01 - 0:04");
478
697
  expect(text).toContain("#title (div)");
479
698
  expect(text).toContain("#music (audio)");
480
699
  expect(text).toContain("Move the title later and lower the music");
@@ -613,3 +832,34 @@ describe("buildPromptCopyText", () => {
613
832
  );
614
833
  });
615
834
  });
835
+
836
+ describe("snapKeyframePctToBeat", () => {
837
+ // el spans 0–10s, so clip-% maps to composition time as pct * 0.1s.
838
+ // At pps=100 the snap window is 8 / 100 = 0.08s.
839
+ const el = { start: 0, duration: 10 };
840
+ const beats = [2, 5, 8];
841
+
842
+ it("snaps a keyframe within ~8px of a beat exactly onto it", () => {
843
+ // pct 50.5 → 5.05s, 0.05s from the beat at 5s (inside 0.08s window) → 50%.
844
+ expect(snapKeyframePctToBeat(el, 50.5, beats, 100)).toBe(50);
845
+ });
846
+
847
+ it("leaves a keyframe unchanged when no beat is within the window", () => {
848
+ // pct 55 → 5.5s, 0.5s from the nearest beat → free.
849
+ expect(snapKeyframePctToBeat(el, 55, beats, 100)).toBe(55);
850
+ });
851
+
852
+ it("is a no-op when there are no beats", () => {
853
+ expect(snapKeyframePctToBeat(el, 50.5, [], 100)).toBe(50.5);
854
+ expect(snapKeyframePctToBeat(el, 50.5, undefined, 100)).toBe(50.5);
855
+ });
856
+
857
+ it("is a no-op for a zero-duration clip", () => {
858
+ expect(snapKeyframePctToBeat({ start: 0, duration: 0 }, 50.5, beats, 100)).toBe(50.5);
859
+ });
860
+
861
+ it("widens the snap window as zoom (pps) decreases", () => {
862
+ // pct 53 → 5.3s, 0.3s from the beat at 5s. At pps=20 the window is 0.4s → snaps to 50%.
863
+ expect(snapKeyframePctToBeat(el, 53, beats, 20)).toBe(50);
864
+ });
865
+ });
@@ -1,5 +1,28 @@
1
1
  import { formatTime } from "../lib/time";
2
2
  import { roundToCenti } from "../../utils/rounding";
3
+ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrder";
4
+ import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
5
+ import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
6
+ import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
7
+
8
+ import {
9
+ applyClipStartTrimDelta,
10
+ clipStartTrimDeltaBounds,
11
+ resolveTimelineMinDuration,
12
+ } from "./timelineGroupEditing";
13
+
14
+ export {
15
+ clampTimelineGroupResizeDelta,
16
+ resolveTimelineGroupMove,
17
+ resolveTimelineGroupResize,
18
+ type TimelineGroupResizeEdge,
19
+ type TimelineGroupTimingMember,
20
+ } from "./timelineGroupEditing";
21
+
22
+ export {
23
+ type TimelineStackingElement,
24
+ type TimelineStackingReorderIntent,
25
+ } from "./timelineStacking";
3
26
 
4
27
  const roundToCentiseconds = roundToCenti;
5
28
 
@@ -25,6 +48,12 @@ export interface TimelineMoveInput {
25
48
  trackHeight: number;
26
49
  maxStart: number;
27
50
  trackOrder: number[];
51
+ layerOrder?: TimelineLayerId[];
52
+ timelineLayers?: StackingTimelineLayer[];
53
+ /** When provided, vertical movement is resolved as a z-index stacking reorder
54
+ * within `stackingElement`'s context instead of a raw track change. */
55
+ stackingElement?: TimelineStackingElement;
56
+ stackingElements?: TimelineStackingElement[];
28
57
  }
29
58
 
30
59
  export interface TimelineResizeInput {
@@ -73,7 +102,13 @@ export function resolveTimelineMove(
73
102
  input: TimelineMoveInput,
74
103
  clientX: number,
75
104
  clientY: number,
76
- ): { start: number; track: number } {
105
+ ): {
106
+ start: number;
107
+ track: number;
108
+ previewLayerId?: TimelineLayerId;
109
+ previewLayerIndex?: number;
110
+ stackingReorder?: TimelineStackingReorderIntent | null;
111
+ } {
77
112
  const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
78
113
  const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
79
114
  const deltaTime =
@@ -81,6 +116,36 @@ export function resolveTimelineMove(
81
116
  const trackDeltaRaw =
82
117
  (clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
83
118
  const deltaTrack = Math.round(trackDeltaRaw);
119
+ const nextStart = clamp(
120
+ roundToCentiseconds(input.start + deltaTime),
121
+ 0,
122
+ Math.max(0, input.maxStart),
123
+ );
124
+
125
+ // Stacking mode: the two axes never fight. Horizontal movement writes time
126
+ // (nextStart); vertical movement writes z-index. Lane/overlap resolution
127
+ // uses the clip's authored time span, NOT the dragged start, otherwise a
128
+ // diagonal drag that drifts the clip out of overlap silently flips the
129
+ // placement from "restack" to "join lane" and cancels the reorder.
130
+ if (input.stackingElement) {
131
+ const layerMove =
132
+ input.timelineLayers && input.layerOrder
133
+ ? resolveTimelineLayerStackingMove({
134
+ element: { ...input.stackingElement, duration: input.duration },
135
+ layers: input.timelineLayers,
136
+ layerOrder: input.layerOrder,
137
+ trackDeltaRaw,
138
+ })
139
+ : null;
140
+ return {
141
+ start: nextStart,
142
+ track: input.track,
143
+ previewLayerId: layerMove?.previewLayerId,
144
+ previewLayerIndex: layerMove?.previewLayerIndex,
145
+ stackingReorder: layerMove?.stackingReorder ?? null,
146
+ };
147
+ }
148
+
84
149
  const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
85
150
  const desiredTrackIndex = currentTrackIndex + deltaTrack;
86
151
  const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
@@ -106,7 +171,7 @@ export function resolveTimelineMove(
106
171
  }
107
172
 
108
173
  return {
109
- start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
174
+ start: nextStart,
110
175
  track: nextTrack,
111
176
  };
112
177
  }
@@ -144,7 +209,7 @@ export function resolveTimelineResize(
144
209
  edge: "start" | "end",
145
210
  clientX: number,
146
211
  ): { start: number; duration: number; playbackStart?: number } {
147
- const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
212
+ const minDuration = resolveTimelineMinDuration(input.minDuration);
148
213
  const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
149
214
 
150
215
  if (edge === "end") {
@@ -160,23 +225,15 @@ export function resolveTimelineResize(
160
225
  };
161
226
  }
162
227
 
163
- const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
164
- const maxLeftExtensionFromMedia =
165
- input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
166
- const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
167
- const maxDelta = input.duration - minDuration;
228
+ const { minDelta, maxDelta } = clipStartTrimDeltaBounds(input, input.minStart, minDuration);
168
229
  const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
169
- const nextStart = roundToCentiseconds(input.start + clampedDelta);
170
- const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
171
- const nextPlaybackStart =
172
- input.playbackStart != null
173
- ? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
174
- : undefined;
230
+ const trimmed = applyClipStartTrimDelta(input, clampedDelta);
175
231
 
176
232
  return {
177
- start: nextStart,
178
- duration: nextDuration,
179
- playbackStart: nextPlaybackStart,
233
+ start: roundToCentiseconds(trimmed.start),
234
+ duration: roundToCentiseconds(trimmed.duration),
235
+ playbackStart:
236
+ trimmed.playbackStart != null ? roundToCentiseconds(trimmed.playbackStart) : undefined,
180
237
  };
181
238
  }
182
239
 
@@ -203,6 +260,108 @@ export interface TimelineRangeSelection {
203
260
  anchorY: number;
204
261
  }
205
262
 
263
+ export interface TimelineMarqueeSelectionRect {
264
+ startTime: number;
265
+ endTime: number;
266
+ top: number;
267
+ bottom: number;
268
+ }
269
+
270
+ export interface TimelineMarqueeSelectionInput {
271
+ rect: TimelineMarqueeSelectionRect;
272
+ layers: readonly StackingTimelineLayer[];
273
+ layerOrder: readonly TimelineLayerId[];
274
+ rulerHeight: number;
275
+ trackHeight: number;
276
+ groupHeaderHeight?: number;
277
+ }
278
+
279
+ interface NormalizedTimelineMarqueeRect {
280
+ startTime: number;
281
+ endTime: number;
282
+ top: number;
283
+ bottom: number;
284
+ }
285
+
286
+ function timelineIntervalsOverlap(
287
+ startA: number,
288
+ endA: number,
289
+ startB: number,
290
+ endB: number,
291
+ ): boolean {
292
+ return startA < endB && startB < endA;
293
+ }
294
+
295
+ function normalizeTimelineMarqueeRect(
296
+ rect: TimelineMarqueeSelectionRect,
297
+ ): NormalizedTimelineMarqueeRect | null {
298
+ const normalized = {
299
+ startTime: Math.max(0, Math.min(rect.startTime, rect.endTime)),
300
+ endTime: Math.max(0, Math.max(rect.startTime, rect.endTime)),
301
+ top: Math.min(rect.top, rect.bottom),
302
+ bottom: Math.max(rect.top, rect.bottom),
303
+ };
304
+ if (normalized.endTime <= normalized.startTime || normalized.bottom <= normalized.top) {
305
+ return null;
306
+ }
307
+ return normalized;
308
+ }
309
+
310
+ function buildTimelineLayerMap(layers: readonly StackingTimelineLayer[]) {
311
+ const layerById = new Map<TimelineLayerId, StackingTimelineLayer>();
312
+ for (const layer of layers) layerById.set(layer.id, layer);
313
+ return layerById;
314
+ }
315
+
316
+ function appendMarqueeLayerSelection(
317
+ selected: string[],
318
+ layer: StackingTimelineLayer,
319
+ rect: NormalizedTimelineMarqueeRect,
320
+ ) {
321
+ for (const element of layer.elements) {
322
+ if (
323
+ timelineIntervalsOverlap(
324
+ rect.startTime,
325
+ rect.endTime,
326
+ element.start,
327
+ element.start + element.duration,
328
+ )
329
+ ) {
330
+ selected.push(element.key ?? element.id);
331
+ }
332
+ }
333
+ }
334
+
335
+ export function selectTimelineElementsInMarquee({
336
+ rect,
337
+ layers,
338
+ layerOrder,
339
+ rulerHeight,
340
+ trackHeight,
341
+ groupHeaderHeight = 0,
342
+ }: TimelineMarqueeSelectionInput): string[] {
343
+ const normalized = normalizeTimelineMarqueeRect(rect);
344
+ if (!normalized) return [];
345
+ const layerById = buildTimelineLayerMap(layers);
346
+ const selected: string[] = [];
347
+ let previousContextKey = "";
348
+ let rowTop = rulerHeight;
349
+ for (const layerId of layerOrder) {
350
+ const layer = layerById.get(layerId);
351
+ if (!layer) continue;
352
+ if (shouldShowTimelineLayerGroupHeader(layer.contextKey, previousContextKey)) {
353
+ rowTop += groupHeaderHeight;
354
+ }
355
+ const rowBottom = rowTop + trackHeight;
356
+ if (timelineIntervalsOverlap(normalized.top, normalized.bottom, rowTop, rowBottom)) {
357
+ appendMarqueeLayerSelection(selected, layer, normalized);
358
+ }
359
+ previousContextKey = layer.contextKey;
360
+ rowTop = rowBottom;
361
+ }
362
+ return selected;
363
+ }
364
+
206
365
  function isDeterministicTimelineWindow(input: {
207
366
  tag: string;
208
367
  compositionSrc?: string;
@@ -306,13 +465,13 @@ export function buildTimelineAgentPrompt({
306
465
  const elementLines = elements
307
466
  .map(
308
467
  (el) =>
309
- `- #${el.id} (${el.tag}) ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
468
+ `- #${el.id} (${el.tag}) - ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
310
469
  )
311
470
  .join("\n");
312
471
 
313
472
  return `Edit the following HyperFrames composition:
314
473
 
315
- Time range: ${formatTime(start)} ${formatTime(end)}
474
+ Time range: ${formatTime(start)} - ${formatTime(end)}
316
475
 
317
476
  Elements in range:
318
477
  ${elementLines || "(none)"}
@@ -0,0 +1,159 @@
1
+ import { roundToCenti } from "../../utils/rounding";
2
+
3
+ const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
4
+ const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
5
+
6
+ function clamp(value: number, min: number, max: number): number {
7
+ return Math.min(Math.max(value, min), max);
8
+ }
9
+
10
+ function roundTimelineTime(value: number): number {
11
+ return roundToCenti(value);
12
+ }
13
+
14
+ export function resolveTimelineMinDuration(minDuration?: number): number {
15
+ return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
16
+ }
17
+
18
+ /** Playback rate never drops to zero (would make media-in-point math divide by ~0). */
19
+ function resolveTimelinePlaybackRate(rate?: number): number {
20
+ return Math.max(0.1, rate ?? 1);
21
+ }
22
+
23
+ interface TimelineStartTrimClip {
24
+ start: number;
25
+ duration: number;
26
+ playbackStart?: number;
27
+ playbackRate?: number;
28
+ }
29
+
30
+ /**
31
+ * Delta bounds for trimming a clip's START edge (shared by single-clip and group
32
+ * resize). Left-bounded by how far the start can move toward `minStart` and by the
33
+ * media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`.
34
+ * Returned deltas are unrounded — callers round with their own centisecond helper.
35
+ */
36
+ export function clipStartTrimDeltaBounds(
37
+ clip: TimelineStartTrimClip,
38
+ minStart: number,
39
+ minDuration: number,
40
+ ): { minDelta: number; maxDelta: number } {
41
+ const playbackRate = resolveTimelinePlaybackRate(clip.playbackRate);
42
+ const maxLeftExtensionFromMedia =
43
+ clip.playbackStart != null ? clip.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
44
+ return {
45
+ minDelta: -Math.min(clip.start - minStart, maxLeftExtensionFromMedia),
46
+ maxDelta: clip.duration - minDuration,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Apply a start-edge delta to one clip (unrounded): moves the start, shrinks the
52
+ * duration by the same amount, and shifts the media in-point by the delta scaled to
53
+ * the playback rate (clamped at 0).
54
+ */
55
+ export function applyClipStartTrimDelta(
56
+ clip: TimelineStartTrimClip,
57
+ delta: number,
58
+ ): { start: number; duration: number; playbackStart?: number } {
59
+ const playbackRate = resolveTimelinePlaybackRate(clip.playbackRate);
60
+ return {
61
+ start: clip.start + delta,
62
+ duration: clip.duration - delta,
63
+ playbackStart:
64
+ clip.playbackStart != null
65
+ ? Math.max(0, clip.playbackStart + delta * playbackRate)
66
+ : undefined,
67
+ };
68
+ }
69
+
70
+ export interface TimelineGroupTimingMember {
71
+ start: number;
72
+ duration: number;
73
+ playbackStart?: number;
74
+ playbackRate?: number;
75
+ }
76
+
77
+ export type TimelineGroupResizeEdge = "start" | "end";
78
+
79
+ export interface TimelineGroupMoveResult {
80
+ delta: number;
81
+ members: Array<Pick<TimelineGroupTimingMember, "start" | "duration">>;
82
+ }
83
+
84
+ export interface TimelineGroupResizeResult {
85
+ delta: number;
86
+ members: Array<Pick<TimelineGroupTimingMember, "start" | "duration" | "playbackStart">>;
87
+ }
88
+
89
+ function clampTimelineGroupMoveDelta(
90
+ rawDelta: number,
91
+ members: readonly TimelineGroupTimingMember[],
92
+ ): number {
93
+ if (members.length === 0) return 0;
94
+ const minDelta = Math.max(...members.map((member) => -member.start));
95
+ return roundTimelineTime(Math.max(rawDelta, minDelta));
96
+ }
97
+
98
+ export function resolveTimelineGroupMove(
99
+ members: readonly TimelineGroupTimingMember[],
100
+ rawDelta: number,
101
+ ): TimelineGroupMoveResult {
102
+ const delta = clampTimelineGroupMoveDelta(rawDelta, members);
103
+ return {
104
+ delta,
105
+ members: members.map((member) => ({
106
+ start: roundTimelineTime(member.start + delta),
107
+ duration: member.duration,
108
+ })),
109
+ };
110
+ }
111
+
112
+ export function clampTimelineGroupResizeDelta(
113
+ rawDelta: number,
114
+ members: readonly TimelineGroupTimingMember[],
115
+ edge: TimelineGroupResizeEdge,
116
+ minDuration = resolveTimelineMinDuration(),
117
+ ): number {
118
+ if (members.length === 0) return 0;
119
+
120
+ if (edge === "end") {
121
+ const minDelta = Math.max(...members.map((member) => minDuration - member.duration));
122
+ return roundTimelineTime(Math.max(rawDelta, minDelta));
123
+ }
124
+
125
+ // Rigid group: the applied delta is bounded by the most-constrained member.
126
+ const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
127
+ const minDelta = Math.max(...bounds.map((b) => b.minDelta));
128
+ const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
129
+ return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
130
+ }
131
+
132
+ export function resolveTimelineGroupResize(
133
+ members: readonly TimelineGroupTimingMember[],
134
+ edge: TimelineGroupResizeEdge,
135
+ rawDelta: number,
136
+ minDuration = resolveTimelineMinDuration(),
137
+ ): TimelineGroupResizeResult {
138
+ const delta = clampTimelineGroupResizeDelta(rawDelta, members, edge, minDuration);
139
+ return {
140
+ delta,
141
+ members: members.map((member) => {
142
+ if (edge === "end") {
143
+ return {
144
+ start: member.start,
145
+ duration: roundTimelineTime(member.duration + delta),
146
+ playbackStart: member.playbackStart,
147
+ };
148
+ }
149
+
150
+ const trimmed = applyClipStartTrimDelta(member, delta);
151
+ return {
152
+ start: roundTimelineTime(trimmed.start),
153
+ duration: roundTimelineTime(trimmed.duration),
154
+ playbackStart:
155
+ trimmed.playbackStart != null ? roundTimelineTime(trimmed.playbackStart) : undefined,
156
+ };
157
+ }),
158
+ };
159
+ }