@hyperframes/studio 0.8.79 → 0.8.81

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 (86) hide show
  1. package/dist/assets/hyperframes-player-C68UawR3.js +464 -0
  2. package/dist/assets/{index-ORZXYB0k.js → index-CMzPGv9P.js} +1 -1
  3. package/dist/assets/{index-CJP92F4-.js → index-CS5SImRT.js} +1 -1
  4. package/dist/assets/{index-DBKySIo7.js → index-hNVM9Pd2.js} +200 -197
  5. package/dist/index.d.ts +18 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1673 -1348
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/App.tsx +13 -10
  11. package/src/components/StudioRightPanels.tsx +4 -48
  12. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  13. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  14. package/src/components/editor/manualEdits.ts +1 -0
  15. package/src/components/editor/manualEditsParsing.ts +4 -0
  16. package/src/components/editor/useFxAudition.ts +3 -3
  17. package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
  18. package/src/components/panels/VariablesOtherCompositions.tsx +4 -4
  19. package/src/components/panels/VariablesPanel.test.tsx +86 -0
  20. package/src/components/panels/VariablesPanel.tsx +3 -3
  21. package/src/components/sidebar/SidebarLintButton.test.tsx +29 -0
  22. package/src/components/sidebar/SidebarLintButton.tsx +4 -0
  23. package/src/components/studioColorGradingScope.ts +5 -7
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/contexts/TimelineEditContext.tsx +2 -0
  26. package/src/hooks/liveLanes.ts +147 -0
  27. package/src/hooks/timelineAudioGroupVolume.ts +81 -47
  28. package/src/hooks/timelineEditPermission.ts +9 -1
  29. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  30. package/src/hooks/timelineEditingHelpers.ts +69 -43
  31. package/src/hooks/timelineElementFxAttribute.ts +82 -36
  32. package/src/hooks/useAppHotkeys.ts +1 -1
  33. package/src/hooks/useApplyColorGradingScope.test.tsx +53 -0
  34. package/src/hooks/useApplyColorGradingScope.ts +54 -0
  35. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  36. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  37. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -4
  39. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  40. package/src/hooks/useDomEditSession.ts +1 -0
  41. package/src/hooks/useEditHistoryActions.ts +4 -3
  42. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  43. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  44. package/src/hooks/useLintModal.test.tsx +15 -0
  45. package/src/hooks/useLintModal.ts +5 -2
  46. package/src/hooks/useProjectCompositionVariables.ts +3 -3
  47. package/src/hooks/useTimelineEditing.ts +19 -16
  48. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  49. package/src/hooks/useToast.ts +2 -1
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +23 -0
  52. package/src/player/components/Player.tsx +4 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/groupAutomationElement.ts +9 -1
  64. package/src/player/components/timelineCallbacks.ts +2 -0
  65. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  66. package/src/player/components/timelineClipDragPreview.ts +15 -24
  67. package/src/player/components/timelineEditing.test.ts +99 -1
  68. package/src/player/components/timelineEditing.ts +5 -8
  69. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  70. package/src/player/components/timelineGroupEditing.ts +18 -15
  71. package/src/player/components/timelineStackingSync.ts +5 -5
  72. package/src/player/components/timelineZones.ts +2 -3
  73. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  74. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  75. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  76. package/src/player/components/useAutomationLanes.ts +58 -74
  77. package/src/player/lib/automationStoreSync.ts +30 -3
  78. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  79. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  80. package/src/player/lib/timelineDOM.test.ts +28 -8
  81. package/src/player/lib/timelineElementHelpers.ts +3 -4
  82. package/src/player/lib/timelineGroupInfo.ts +23 -23
  83. package/src/utils/studioFileHistory.ts +36 -30
  84. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +0 -464
  85. package/src/player/components/automationGestureKeys.test.ts +0 -49
  86. package/src/player/components/automationGestureKeys.ts +0 -49
@@ -27,6 +27,7 @@ import {
27
27
  type HfAutomationLane,
28
28
  type HfAutomationPoint,
29
29
  } from "@hyperframes/core/audio-automation";
30
+ import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
30
31
  import { envelopePath, fromUnit, laneFor, PAD_X, toUnit, withLane } from "./automationLaneGeometry";
31
32
  import { useAutomationLaneGestures } from "./useAutomationLaneGestures";
32
33
  import { AutomationValueInput } from "./AutomationValueInput";
@@ -180,9 +181,9 @@ export interface TimelineAutomationLaneProps {
180
181
  /** Clip-local seconds of the playhead, or null when it is outside the clip. */
181
182
  playheadSec: number | null;
182
183
  /** Continuous write while dragging; does not persist. */
183
- onPreview(automation: HfAutomation): void;
184
+ onPreview(automation: HfAutomation, ended?: boolean): void;
184
185
  /** Gesture-end write; this is the one that persists and lands in undo. */
185
- onCommit(automation: HfAutomation): void;
186
+ onCommit(automation: HfAutomation): Promise<TimelineEditOutcome | void> | void;
186
187
  /**
187
188
  * Clip-local times a dragged point snaps to — the beat grid, shifted into this
188
189
  * clip's frame. Its own neighbouring points are added on top.
@@ -241,14 +242,6 @@ export function TimelineAutomationLane({
241
242
  [draft, target, stored],
242
243
  );
243
244
 
244
- // The draft is released when the automation it was drawn over actually
245
- // changes — the persisted edit landing, or an edit from elsewhere. Releasing
246
- // it merely because the drag ended would snap the point back to where it
247
- // started for as long as the write takes to come around.
248
- useEffect(() => {
249
- if (draft && draft.basedOn !== automation) setDraft(null);
250
- }, [automation, draft]);
251
-
252
245
  // A different parameter is a different envelope; the draft does not carry over.
253
246
  useEffect(() => {
254
247
  setDraft(null);
@@ -289,12 +282,16 @@ export function TimelineAutomationLane({
289
282
  );
290
283
 
291
284
  const commitPoints = useCallback(
292
- (points: HfAutomationLane["points"], persist: boolean): void => {
285
+ (points: HfAutomationLane["points"], persist: boolean, ended?: boolean): void => {
293
286
  // Draw from the draft immediately; the write is what eventually agrees.
294
287
  setDraft({ points, basedOn: automation });
295
288
  const next = withLane(automation, { target, points });
296
- if (persist) onCommit(next);
297
- else onPreview(next);
289
+ if (!persist) return onPreview(next, ended);
290
+ void Promise.resolve(onCommit(next)).then((outcome) => {
291
+ if (outcome && outcome.status !== "saved") {
292
+ setDraft((current) => (current?.points === points ? null : current));
293
+ }
294
+ });
298
295
  },
299
296
  [automation, target, onCommit, onPreview],
300
297
  );
@@ -319,6 +316,7 @@ export function TimelineAutomationLane({
319
316
  duration,
320
317
  rangeSelection,
321
318
  });
319
+
322
320
  const {
323
321
  dragIndex,
324
322
  curveIndex,
@@ -329,6 +327,13 @@ export function TimelineAutomationLane({
329
327
  hint,
330
328
  editing,
331
329
  } = gestures;
330
+ // Released when the automation it was drawn over changes, not on drag end (the point would
331
+ // snap back until the write lands) and not under a live gesture (its release would commit
332
+ // an older save's points).
333
+ const gestureLive = [dragIndex, curveIndex, segmentDragIndex, edgeDrag].some((g) => g !== null);
334
+ useEffect(() => {
335
+ if (draft && draft.basedOn !== automation && !gestureLive) setDraft(null);
336
+ }, [automation, draft, gestureLive]);
332
337
 
333
338
  const removeAt = useCallback(
334
339
  (index: number): void => {
@@ -32,7 +32,6 @@ function mountSlot(binding: Partial<AutomationLaneBinding>) {
32
32
  onCommit: vi.fn(),
33
33
  onSelect: vi.fn(),
34
34
  readOnly: false,
35
- commitTargetKey: "bgm",
36
35
  selection: null,
37
36
  onRangeSelect: vi.fn(),
38
37
  onRangeClear,
@@ -96,7 +95,6 @@ const readingBind = (element: TimelineElement, isSelected: boolean): AutomationL
96
95
  onCommit: vi.fn(),
97
96
  onSelect: vi.fn(),
98
97
  readOnly: !isSelected,
99
- commitTargetKey: null,
100
98
  selection: null,
101
99
  onRangeSelect: vi.fn(),
102
100
  onRangeClear: vi.fn(),
@@ -33,6 +33,7 @@ function render(
33
33
  const root = createRoot(host);
34
34
  const onSetElementAttributeLive = vi.fn();
35
35
  const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
36
+ const onRevertElementAttributeLive = vi.fn();
36
37
  // Stands in for the TimelineClip button the handles live inside.
37
38
  const node = (
38
39
  <div data-testid="clip" onPointerDown={options.onClipPointerDown}>
@@ -49,13 +50,25 @@ function render(
49
50
  options.provide === false ? (
50
51
  node
51
52
  ) : (
52
- <TimelineEditProvider value={{ onSetElementAttributeLive, onSetElementAttributeQuiet }}>
53
+ <TimelineEditProvider
54
+ value={{
55
+ onSetElementAttributeLive,
56
+ onSetElementAttributeQuiet,
57
+ onRevertElementAttributeLive,
58
+ }}
59
+ >
53
60
  {node}
54
61
  </TimelineEditProvider>
55
62
  ),
56
63
  );
57
64
  });
58
- return { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet };
65
+ return {
66
+ host,
67
+ root,
68
+ onSetElementAttributeLive,
69
+ onSetElementAttributeQuiet,
70
+ onRevertElementAttributeLive,
71
+ };
59
72
  }
60
73
 
61
74
  function pointer(type: string, clientX: number, pointerId = 1) {
@@ -146,14 +159,22 @@ describe("TimelineClipFades", () => {
146
159
  });
147
160
 
148
161
  it("puts the live value back and writes nothing when the gesture is cancelled", () => {
149
- const { host, root, onSetElementAttributeLive, onSetElementAttributeQuiet } = render(clip);
162
+ const {
163
+ host,
164
+ root,
165
+ onSetElementAttributeLive,
166
+ onRevertElementAttributeLive,
167
+ onSetElementAttributeQuiet,
168
+ } = render(clip);
150
169
  const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
151
170
  if (!handle) throw new Error("expected a fade-in handle");
152
171
  armCapture(handle);
153
172
  act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
154
173
  act(() => handle.dispatchEvent(pointer("pointermove", 300)));
155
174
  act(() => handle.dispatchEvent(pointer("pointercancel", 300)));
175
+ // Written back live for a host without the revert, then ended through the lanes' revert.
156
176
  expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "1");
177
+ expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
157
178
  expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
158
179
  act(() => root.unmount());
159
180
  });
@@ -32,7 +32,7 @@ interface TimelineClipFadesProps {
32
32
 
33
33
  /** Corner dots that drag `data-fade-in` / `data-fade-out`; the wedge is the faded region. */
34
34
  export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineClipFadesProps) {
35
- const { onSetElementAttributeLive, onSetElementAttributeQuiet } =
35
+ const { onSetElementAttributeLive, onSetElementAttributeQuiet, onRevertElementAttributeLive } =
36
36
  useTimelineEditContextOptional();
37
37
  const canEdit = Boolean(onSetElementAttributeLive && onSetElementAttributeQuiet);
38
38
 
@@ -126,7 +126,10 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
126
126
 
127
127
  /** Puts the live document back where the file has it and drops the draft. */
128
128
  const revertGesture = (g: Gesture) => {
129
- if (g.moved) onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
129
+ if (g.moved) {
130
+ onSetElementAttributeLive?.(el, attrFor(g.edge), attrText(g.originSeconds));
131
+ onRevertElementAttributeLive?.(el, attrFor(g.edge));
132
+ }
130
133
  setDraft(null);
131
134
  };
132
135
 
@@ -157,7 +157,7 @@ interface TimelineFxButtonChainProps {
157
157
  onOpenRack: () => void;
158
158
  fxChainRaw: string | undefined;
159
159
  onChainChange: (next: HfAudioFxChain) => void;
160
- onChainPreview?: (next: HfAudioFxChain) => void;
160
+ onChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
161
161
  /** The clips this chain is heard through, so an audition starts where they
162
162
  * actually sound rather than from a playhead parked before the first. */
163
163
  auditionSpans?: readonly AuditionSpan[];
@@ -18,7 +18,7 @@ interface TimelineGroupHeaderProps {
18
18
  /** C1: the group's serialized `data-fx-chain`, when set. */
19
19
  fxChain?: string;
20
20
  onFxChainChange: (next: HfAudioFxChain) => void;
21
- onFxChainPreview?: (next: HfAudioFxChain) => void;
21
+ onFxChainPreview?: (next: HfAudioFxChain, ended?: boolean) => void;
22
22
  /** Member clips, so hovering a preset auditions where the group sounds. */
23
23
  auditionSpans?: readonly AuditionSpan[];
24
24
  onOpenFxRack: () => void;
@@ -84,7 +84,6 @@ function renderRow(
84
84
  onCommit: vi.fn(),
85
85
  onSelect: vi.fn(),
86
86
  readOnly: true,
87
- commitTargetKey: null,
88
87
  selection: null,
89
88
  onRangeSelect: vi.fn(),
90
89
  onRangeClear: vi.fn(),
@@ -80,9 +80,8 @@ export function TimelineGroupRow({
80
80
  // The group wearing a clip's shape so the lane machinery can render it — see
81
81
  // `groupAutomationElement` for why that beats a second, parallel lane path.
82
82
  const groupElement = groupAutomationElement(group, compositionDuration);
83
- // The binder writes through the dom-edit selection, so a group lane is
84
- // editable exactly when the group is the selected element — which clicking
85
- // its name in the header does.
83
+ // A group lane is editable exactly when the group is the selected element,
84
+ // which clicking its name in the header does.
86
85
  const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
87
86
  const isGroupSelected = domSelection?.id === group.id;
88
87
  const isLaneOpen = expandedLaneOwnerIds.has(group.id);
@@ -90,14 +89,21 @@ export function TimelineGroupRow({
90
89
  // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing
91
90
  // hook took the whole timeline down with it the moment a group existed —
92
91
  // not just this row.
93
- const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
94
- useTimelineEditContextOptional();
92
+ const {
93
+ onSetAudioGroupAttributeLive,
94
+ onSetAudioGroupAttributeQuiet,
95
+ onRevertAudioGroupAttributeLive,
96
+ } = useTimelineEditContextOptional();
95
97
  const domEditActions = useDomEditActionsContextOptional();
96
98
  const revealAudioFx = usePlayerStore((state) => state.setRevealedAudioFxTarget);
97
- const writeGroupFxChain = (next: HfAudioFxChain, live: boolean) => {
99
+ const writeGroupFxChain = (next: HfAudioFxChain, live: boolean, ended = false) => {
98
100
  const value = next.nodes.length ? serializeAudioFxChain(next) : null;
99
- if (live) onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
100
- else void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
101
+ if (!live) {
102
+ void onSetAudioGroupAttributeQuiet?.(group.id, HF_AUDIO_FX_ATTR, value, "Apply preset");
103
+ return;
104
+ }
105
+ onSetAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR, value);
106
+ if (ended) onRevertAudioGroupAttributeLive?.(group.id, HF_AUDIO_FX_ATTR);
101
107
  };
102
108
  const openGroupFxRack = (automationTarget?: string) => {
103
109
  // Use the guarded timeline-selection path even though the bus is synthetic:
@@ -157,7 +163,7 @@ export function TimelineGroupRow({
157
163
  onToggleLanes={() => toggleLaneOwnerExpanded(group.id)}
158
164
  fxChain={group.fxChain}
159
165
  onFxChainChange={(next) => writeGroupFxChain(next, false)}
160
- onFxChainPreview={(next) => writeGroupFxChain(next, true)}
166
+ onFxChainPreview={(next, ended) => writeGroupFxChain(next, true, ended)}
161
167
  auditionSpans={memberElements}
162
168
  onOpenFxRack={() => openGroupFxRack()}
163
169
  // Same width as every other row's header. The group row needs a real
@@ -169,8 +175,8 @@ export function TimelineGroupRow({
169
175
  theme={theme}
170
176
  />
171
177
  {/* The group's OWN curves, under the strip. Selected-gated exactly like a
172
- clip's: the binder writes through the dom-edit selection, so a lane is
173
- editable once the group is selected — which clicking its name does. */}
178
+ clip's: a lane is editable once the group is selected, which clicking
179
+ its name does. */}
174
180
  {/* The label column for those lanes, on the accent rail — inside the
175
181
  sticky column above, so they pin with the header. */}
176
182
  {isLaneOpen && (
@@ -214,8 +214,12 @@ export function TimelineTrackHeader({
214
214
  // track holding several ungrouped ones has no single chain — the design
215
215
  // doc refuses to build "N clips = N chains", so that case gets a pointer
216
216
  // at grouping (B6's normative rule) instead of a popover.
217
- const { onGroupClips, onSetElementAttributeLive, onSetElementAttributeQuiet } =
218
- useTimelineEditContextOptional();
217
+ const {
218
+ onGroupClips,
219
+ onSetElementAttributeLive,
220
+ onSetElementAttributeQuiet,
221
+ onRevertElementAttributeLive,
222
+ } = useTimelineEditContextOptional();
219
223
  const domEditActions = useDomEditActionsContextOptional();
220
224
  const singleAudioClip =
221
225
  isAudioTrack && clipCount === 1 && trackElements.length > 0 ? trackElements[0] : null;
@@ -225,10 +229,19 @@ export function TimelineTrackHeader({
225
229
  // needs to be TOLD that, so it earns the button and a refusal.
226
230
  const isVideoWithAudioTrack =
227
231
  !isAudioTrack && trackElements.some((el) => el.tag.toLowerCase() === "video");
228
- const writeClipFxChain = (clip: TimelineElement, next: HfAudioFxChain, live: boolean) => {
232
+ const writeClipFxChain = (
233
+ clip: TimelineElement,
234
+ next: HfAudioFxChain,
235
+ live: boolean,
236
+ ended = false,
237
+ ) => {
229
238
  const value = next.nodes.length ? serializeAudioFxChain(next) : null;
230
- if (live) onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
231
- else void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
239
+ if (!live) {
240
+ void onSetElementAttributeQuiet?.(clip, HF_AUDIO_FX_ATTR, value, "Apply preset");
241
+ return;
242
+ }
243
+ onSetElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR, value);
244
+ if (ended) onRevertElementAttributeLive?.(clip, HF_AUDIO_FX_ATTR);
232
245
  };
233
246
  const openClipFxRack = (clip: TimelineElement) => {
234
247
  void domEditActions?.handleTimelineElementSelect(clip);
@@ -309,7 +322,9 @@ export function TimelineTrackHeader({
309
322
  fxChainRaw={singleAudioClip.fxChain}
310
323
  trackKind={classifyAudioName(singleAudioClip.id, singleAudioClip.src)}
311
324
  onChainChange={(next) => writeClipFxChain(singleAudioClip, next, false)}
312
- onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)}
325
+ onChainPreview={(next, ended) =>
326
+ writeClipFxChain(singleAudioClip, next, true, ended)
327
+ }
313
328
  // Muted, an audition is silent — so the hover lifts the mute on
314
329
  // the running graph and puts it back on the way out, the same
315
330
  // borrow-and-return it already does with the playhead.
@@ -16,12 +16,18 @@
16
16
  import type { TimelineElement } from "../store/playerStore";
17
17
  import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
18
18
 
19
+ const groupElements = new WeakSet<TimelineElement>();
20
+
21
+ export function isGroupAutomationElement(element: TimelineElement): boolean {
22
+ return groupElements.has(element);
23
+ }
24
+
19
25
  /** The synthetic element's track number — the group row's own anchor. */
20
26
  export function groupAutomationElement(
21
27
  group: Pick<TimelineTrackGroupInfo, "id" | "label" | "automation" | "fxChain" | "anchorKey">,
22
28
  compositionDuration: number,
23
29
  ): TimelineElement {
24
- return {
30
+ const element: TimelineElement = {
25
31
  id: group.id,
26
32
  // The DOM id, so a write lands on the `<hf-audio-group>` and not on a clip.
27
33
  domId: group.id,
@@ -34,4 +40,6 @@ export function groupAutomationElement(
34
40
  ...(group.automation ? { automation: group.automation } : {}),
35
41
  ...(group.fxChain ? { fxChain: group.fxChain } : {}),
36
42
  };
43
+ groupElements.add(element);
44
+ return element;
37
45
  }
@@ -84,6 +84,7 @@ export interface TimelineEditCallbacks {
84
84
  ) => Promise<void> | void;
85
85
  /** B7's bus strip: live-write the group's own attribute while dragging. */
86
86
  onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
87
+ onRevertAudioGroupAttributeLive?: (groupId: string, attr: string) => void;
87
88
  /** ...and persist one undo entry on release. */
88
89
  onSetAudioGroupAttributeQuiet?: (
89
90
  groupId: string,
@@ -106,6 +107,7 @@ export interface TimelineEditCallbacks {
106
107
  attr: string,
107
108
  value: string | null,
108
109
  ) => void;
110
+ onRevertElementAttributeLive?: (element: TimelineElement, attr: string) => void;
109
111
  onSetElementAttributeQuiet?: (
110
112
  element: TimelineElement,
111
113
  attr: string,
@@ -291,6 +291,47 @@ describe("computeResizePreview — composition source continuity", () => {
291
291
  previewPlaybackStart: 2,
292
292
  });
293
293
  });
294
+
295
+ it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
296
+ const result = computeResizePreview(
297
+ {
298
+ element: clip("vo", 0, 2, 1, 0),
299
+ edge: "end",
300
+ originClientX: 0,
301
+ previewStart: 2,
302
+ previewDuration: 1,
303
+ started: true,
304
+ },
305
+ -95,
306
+ { scroll: fakeScroll(), pps: 100, buildSnapTargets: () => [{ time: 2.07, type: "beat" }] },
307
+ );
308
+
309
+ expect(result).toMatchObject({ previewDuration: 0.1, snapTime: null });
310
+ });
311
+
312
+ it("keeps a slowed clip's media clock when its head snaps to a beat", () => {
313
+ const element = { ...clip("vo", 0, 2, 4, 0), playbackStart: 3, playbackRate: 0.8 };
314
+ const result = computeResizePreview(
315
+ {
316
+ element,
317
+ edge: "start",
318
+ originClientX: 0,
319
+ previewStart: 2,
320
+ previewDuration: 4,
321
+ started: true,
322
+ },
323
+ 30,
324
+ {
325
+ scroll: fakeScroll(),
326
+ pps: 100,
327
+ buildSnapTargets: () => [{ time: 2.3456, type: "beat" }],
328
+ },
329
+ );
330
+
331
+ expect(result.snapTime).toBe(2.3456);
332
+ const clock = result.previewStart - result.previewPlaybackStart! / 0.8;
333
+ expect(clock).toBeCloseTo(2 - 3 / 0.8, 9);
334
+ });
294
335
  });
295
336
 
296
337
  describe("getTimelineDragOverlayPosition", () => {
@@ -1,4 +1,9 @@
1
1
  import { resolveTimelineMove, resolveTimelineResize } from "./timelineEditing";
2
+ import {
3
+ applyClipStartTrimDelta,
4
+ clipStartTrimDeltaBounds,
5
+ resolveTimelineMinDuration,
6
+ } from "./timelineGroupEditing";
2
7
  import type { TimelineElement } from "../store/playerStore";
3
8
  import {
4
9
  getTimelineInsertBoundaryBand,
@@ -275,7 +280,6 @@ export function computeResizePreview(
275
280
  const normalizedTag = resize.element.tag.toLowerCase();
276
281
  const canSeedPlaybackStart =
277
282
  resize.element.kind === "composition" || normalizedTag === "audio" || normalizedTag === "video";
278
- const playbackRate = Math.max(resize.element.playbackRate ?? 1, 0.1);
279
283
  // Trim limit = available source media only — NOT the composition length.
280
284
  // Duration is content-driven (the comp grows/shrinks to fit on commit), so
281
285
  // capping a trim at the current comp end both blocked extending the last clip
@@ -319,35 +323,22 @@ export function computeResizePreview(
319
323
  // Stay within [start+minDuration, maxEnd] so the snap can't create a
320
324
  // degenerate clip or run past the source/composition limit.
321
325
  const snappedDuration = Math.round((snapped - nextResize.start) * 1000) / 1000;
322
- if (target && snapped <= maxEnd + 1e-6 && snappedDuration >= 0.05) {
326
+ if (
327
+ target &&
328
+ snapped <= maxEnd + 1e-6 &&
329
+ snappedDuration >= resolveTimelineMinDuration() - 1e-6
330
+ ) {
323
331
  // An edge already on the target still owns the guide; only move it when off.
324
332
  if (snapped !== edgeTime) nextResize = { ...nextResize, duration: snappedDuration };
325
333
  snap = target;
326
334
  }
327
335
  } else {
328
336
  const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
329
- const delta = nextResize.start - snapped; // >0 when snapping left
330
- // Leftward snap reveals more source; cap so playbackStart can't go < 0.
331
- const maxLeftDelta =
332
- nextResize.playbackStart != null
333
- ? nextResize.playbackStart / playbackRate
334
- : Number.POSITIVE_INFINITY;
335
- // Also require the resulting duration to stay >= minDuration so a rightward
336
- // snap (delta < 0) can't collapse the clip to zero/negative.
337
- const snappedDuration = Math.round((nextResize.duration + delta) * 1000) / 1000;
338
- if (target && snapped >= 0 && delta <= maxLeftDelta + 1e-6 && snappedDuration >= 0.05) {
339
- if (snapped !== nextResize.start) {
340
- nextResize = {
341
- ...nextResize,
342
- start: snapped,
343
- duration: snappedDuration,
344
- playbackStart:
345
- nextResize.playbackStart != null
346
- ? Math.round(Math.max(0, nextResize.playbackStart - delta * playbackRate) * 1000) /
347
- 1000
348
- : undefined,
349
- };
350
- }
337
+ const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
338
+ const delta = snapped - nextResize.start;
339
+ const bounds = clipStartTrimDeltaBounds(clip, 0, resolveTimelineMinDuration());
340
+ if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
341
+ if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
351
342
  snap = target;
352
343
  }
353
344
  }
@@ -5,6 +5,8 @@ import {
5
5
  buildTimelineElementAgentPrompt,
6
6
  buildTimelineAgentPrompt,
7
7
  clampTimelineGroupResizeDelta,
8
+ formatTimelineAttributeNumber,
9
+ formatTimelineMediaOffset,
8
10
  getTimelineEditCapabilities,
9
11
  hasPatchableTimelineTarget,
10
12
  resolveBlockedTimelineEditIntent,
@@ -366,6 +368,15 @@ describe("resolveTimelineGroupResize", () => {
366
368
  ],
367
369
  });
368
370
  });
371
+
372
+ it("derives a slowed member's playback start from its rounded start", () => {
373
+ const [member] = resolveTimelineGroupResize(
374
+ [{ start: 2, duration: 3, playbackStart: 1, playbackRate: 0.8 }],
375
+ "start",
376
+ 0.373,
377
+ ).members;
378
+ expect(member!.start - member!.playbackStart! / 0.8).toBeCloseTo(2 - 1 / 0.8, 9);
379
+ });
369
380
  });
370
381
 
371
382
  describe("hasPatchableTimelineTarget", () => {
@@ -795,7 +806,7 @@ describe("resolveTimelineResize", () => {
795
806
  "start",
796
807
  0,
797
808
  ),
798
- ).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
809
+ ).toEqual({ start: 0.8, duration: 3.2, playbackStart: expect.closeTo(0, 9) });
799
810
  });
800
811
 
801
812
  it("trims generic element start without media offset", () => {
@@ -831,6 +842,93 @@ describe("resolveTimelineResize", () => {
831
842
  ),
832
843
  ).toEqual({ start: 0, duration: 4, playbackStart: undefined });
833
844
  });
845
+
846
+ it("rounds a fully-left head trim inward so the in-point is never clamped off the media clock", () => {
847
+ const next = resolveTimelineResize(
848
+ {
849
+ start: 2,
850
+ duration: 3,
851
+ originClientX: 0,
852
+ pixelsPerSecond: 100,
853
+ minStart: 0,
854
+ maxEnd: 10,
855
+ playbackStart: 1,
856
+ playbackRate: 1.5,
857
+ },
858
+ "start",
859
+ -1000,
860
+ );
861
+ expect(next.start).toBe(1.34);
862
+ expect(next.start - next.playbackStart! / 1.5).toBeCloseTo(2 - 1 / 1.5, 9);
863
+ });
864
+
865
+ it("lets a fully-left head trim reach a reloaded in-point's media start at a 5-decimal speed", () => {
866
+ const next = resolveTimelineResize(
867
+ {
868
+ start: 2.37,
869
+ duration: 2.63,
870
+ originClientX: 0,
871
+ pixelsPerSecond: 100,
872
+ minStart: 0,
873
+ maxEnd: 10,
874
+ playbackStart: Number(formatTimelineMediaOffset(0.37 * 0.33333)),
875
+ playbackRate: 0.33333,
876
+ },
877
+ "start",
878
+ -1000,
879
+ );
880
+ expect(next.start).toBe(2);
881
+ });
882
+
883
+ it("moves every group member by the same amount when one reaches its media start", () => {
884
+ const members = [
885
+ { start: 2, duration: 3, playbackStart: 1, playbackRate: 1.5 },
886
+ { start: 5, duration: 3, playbackStart: 4, playbackRate: 1 },
887
+ ];
888
+ const result = resolveTimelineGroupResize(members, "start", -10);
889
+ for (const [i, member] of members.entries()) {
890
+ const next = result.members[i]!;
891
+ expect(next.start - member.start).toBeCloseTo(result.delta, 9);
892
+ expect(next.start - next.playbackStart! / member.playbackRate).toBeCloseTo(
893
+ member.start - member.playbackStart / member.playbackRate,
894
+ 9,
895
+ );
896
+ }
897
+ });
898
+
899
+ it("keeps a clip's media clock and end fixed across repeated head trims at any speed", () => {
900
+ const dragsPx = [37.3, -12.9, 81.7, -5.3];
901
+ for (const playbackRate of [0.25, 0.5, 0.8, 1, 1.25, 2]) {
902
+ let clip = { start: 2, duration: 6, playbackStart: 3 };
903
+ const clock = clip.start - clip.playbackStart / playbackRate;
904
+ const end = clip.start + clip.duration;
905
+ for (const clientX of dragsPx) {
906
+ const next = resolveTimelineResize(
907
+ {
908
+ ...clip,
909
+ originClientX: 0,
910
+ pixelsPerSecond: 100,
911
+ minStart: 0,
912
+ maxEnd: 20,
913
+ playbackRate,
914
+ },
915
+ "start",
916
+ clientX,
917
+ );
918
+ // Round-trip through the saved attributes, as a reload would.
919
+ const savedInPoint = formatTimelineMediaOffset(next.playbackStart!);
920
+ if (playbackRate === 1)
921
+ expect(savedInPoint).toBe(formatTimelineAttributeNumber(next.playbackStart!));
922
+ clip = {
923
+ start: Number(formatTimelineAttributeNumber(next.start)),
924
+ duration: Number(formatTimelineAttributeNumber(next.duration)),
925
+ playbackStart: Number(savedInPoint),
926
+ };
927
+ expect(clip.start - clip.playbackStart / playbackRate).toBeCloseTo(clock, 9);
928
+ expect(clip.start + clip.duration).toBeCloseTo(end, 9);
929
+ }
930
+ }
931
+ });
834
932
  });
835
933
 
836
934
  describe("buildPromptCopyText", () => {
@@ -236,14 +236,7 @@ export function resolveTimelineResize(
236
236
 
237
237
  const { minDelta, maxDelta } = clipStartTrimDeltaBounds(input, input.minStart, minDuration);
238
238
  const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
239
- const trimmed = applyClipStartTrimDelta(input, clampedDelta);
240
-
241
- return {
242
- start: roundToCentiseconds(trimmed.start),
243
- duration: roundToCentiseconds(trimmed.duration),
244
- playbackStart:
245
- trimmed.playbackStart != null ? roundToCentiseconds(trimmed.playbackStart) : undefined,
246
- };
239
+ return applyClipStartTrimDelta(input, clampedDelta);
247
240
  }
248
241
 
249
242
  export interface TimelinePromptElement {
@@ -476,6 +469,10 @@ export function formatTimelineAttributeNumber(value: number): string {
476
469
  return Number(roundToCentiseconds(value).toFixed(2)).toString();
477
470
  }
478
471
 
472
+ export function formatTimelineMediaOffset(value: number): string {
473
+ return Number(value.toFixed(6)).toString();
474
+ }
475
+
479
476
  /**
480
477
  * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
481
478
  * pointer is in, clamped to the scrollable range. Returns true when the
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import {
4
+ applyTimelineGroupResizePreview,
4
5
  buildTimelineGroupResizeMembers,
5
6
  resolveTimelineGroupResize,
6
7
  resolveTimelineGroupResizeChanges,
@@ -132,3 +133,29 @@ describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
132
133
  ).toEqual(raw.members);
133
134
  });
134
135
  });
136
+
137
+ describe("applyTimelineGroupResizePreview", () => {
138
+ it("keeps each slowed member's media clock through the group preview", () => {
139
+ const a = el("a", { start: 2, duration: 3, playbackStart: 1, playbackRate: 0.8 });
140
+ const b = el("b", { start: 6, duration: 3, playbackStart: 2, playbackRate: 1.25 });
141
+ const members = buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "a", "start")!;
142
+ const session = {
143
+ grabbedKey: "a",
144
+ edge: "start" as const,
145
+ members,
146
+ changes: [],
147
+ hasChanged: false,
148
+ };
149
+
150
+ applyTimelineGroupResizePreview(session, { previewStart: 2.373, previewDuration: 2.627 });
151
+
152
+ for (const [i, member] of [a, b].entries()) {
153
+ const change = session.changes[i]!;
154
+ const rate = member.playbackRate!;
155
+ expect(change.start - change.playbackStart! / rate).toBeCloseTo(
156
+ member.start - member.playbackStart! / rate,
157
+ 9,
158
+ );
159
+ }
160
+ });
161
+ });