@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.
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-CZkdm5YL.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-CbQKp0Ek.js → index-uvB30_yI.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-CZUbpYhQ.js +0 -416
- 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
|
|
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
|
-
): {
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
178
|
-
duration:
|
|
179
|
-
playbackStart:
|
|
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})
|
|
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)}
|
|
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
|
+
}
|