@hyperframes/studio 0.7.56 → 0.7.57

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 (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -6,17 +6,20 @@ import {
6
6
  buildTimelineMoveTimingPatch,
7
7
  buildTimelineResizeTimingPatch,
8
8
  extendRootDurationIfNeeded,
9
- finishTimelineTimingFallback,
10
- foldGsapMutationIntoHistory,
11
9
  formatTimelineAttributeNumber,
12
10
  patchIframeDomTiming,
13
11
  persistTimelineBatchEdit,
14
- readFileContent,
15
- scaleGsapPositions,
16
- shiftGsapPositions,
17
12
  type PersistTimelineBatchChange,
18
13
  type RecordEditInput,
19
14
  } from "./timelineEditingHelpers";
15
+ import {
16
+ captureDurationRollback,
17
+ finishGroupTimingGsapFallback,
18
+ readFileContent,
19
+ scaleGsapPositions,
20
+ shiftGsapPositions,
21
+ syncPreviewContentDuration,
22
+ } from "./timelineTimingSync";
20
23
 
21
24
  export interface TimelineGroupMoveChange {
22
25
  element: TimelineElement;
@@ -75,6 +78,15 @@ function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): strin
75
78
  return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
76
79
  }
77
80
 
81
+ function toSdkTimingChanges<T extends { element: TimelineElement }>(
82
+ changes: readonly T[],
83
+ timingUpdate: (change: T) => { start: number; duration?: number },
84
+ ): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
85
+ return changes.map((change) =>
86
+ change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
87
+ );
88
+ }
89
+
78
90
  function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
79
91
  return (
80
92
  change.playbackStart != null ||
@@ -149,6 +161,55 @@ export function useTimelineGroupEditing({
149
161
  ],
150
162
  );
151
163
 
164
+ // Shared SDK fast path for group move/resize: eligible when nothing needs the
165
+ // server (no root-duration growth, one shared file, every change SDK-addressable
166
+ // and `eligible` per the caller's own gate). Returns whether the SDK handled it;
167
+ // false → caller falls through to the server batch persist.
168
+ const trySdkBatchPersist = useCallback(
169
+ async (input: {
170
+ changes: readonly { element: TimelineElement }[];
171
+ sdkChanges: Array<{
172
+ hfId: string;
173
+ timingUpdate: { start: number; duration?: number };
174
+ } | null>;
175
+ eligible: boolean;
176
+ needsExtension: boolean;
177
+ label: string;
178
+ coalesceKey: string;
179
+ }): Promise<boolean> => {
180
+ const sharedPath = allChangesSharePath(input.changes, activeCompPath);
181
+ const canUseSdk =
182
+ !input.needsExtension &&
183
+ sharedPath !== null &&
184
+ input.eligible &&
185
+ input.sdkChanges.every((change) => change !== null);
186
+ if (!canUseSdk) return false;
187
+ return sdkTimingBatchPersist(
188
+ input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
189
+ sharedPath,
190
+ sdkSession,
191
+ {
192
+ editHistory: { recordEdit },
193
+ writeProjectFile,
194
+ reloadPreview,
195
+ domEditSaveTimestampRef,
196
+ compositionPath: activeCompPath,
197
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
198
+ },
199
+ { label: input.label, coalesceKey: input.coalesceKey },
200
+ );
201
+ },
202
+ [
203
+ activeCompPath,
204
+ domEditSaveTimestampRef,
205
+ projectIdRef,
206
+ recordEdit,
207
+ reloadPreview,
208
+ sdkSession,
209
+ writeProjectFile,
210
+ ],
211
+ );
212
+
152
213
  const handleTimelineGroupMove = useCallback(
153
214
  (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
154
215
  if (changes.length === 0) return Promise.resolve();
@@ -163,38 +224,27 @@ export function useTimelineGroupEditing({
163
224
  }
164
225
 
165
226
  const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
227
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
228
+ // persist can roll the readout + live root back (see captureDurationRollback).
229
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
230
+ // needsExtension gates the SDK path (setTiming can't grow the root duration),
231
+ // so read the store BEFORE the readout sync below optimistically updates it.
166
232
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
233
+ // Optimistic duration readout: content-driven (grow AND shrink), read from
234
+ // the just-patched live DOM. See syncPreviewContentDuration.
235
+ syncPreviewContentDuration(previewIframeRef.current);
167
236
  const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
168
237
  return enqueueGroupOperation("Move timeline clips", async (projectId) => {
169
238
  await options?.beforeTiming;
170
- const sharedPath = allChangesSharePath(changes, activeCompPath);
171
- const sdkChanges = changes.map((change) =>
172
- change.element.hfId
173
- ? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
174
- : null,
175
- );
176
- const canUseSdk =
177
- !needsExtension &&
178
- sharedPath !== null &&
179
- changes.every((change) => change.track == null) &&
180
- sdkChanges.every((change) => change !== null);
181
- if (canUseSdk) {
182
- const handled = await sdkTimingBatchPersist(
183
- sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
184
- sharedPath,
185
- sdkSession,
186
- {
187
- editHistory: { recordEdit },
188
- writeProjectFile,
189
- reloadPreview,
190
- domEditSaveTimestampRef,
191
- compositionPath: activeCompPath,
192
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
193
- },
194
- { label: "Move timeline clips", coalesceKey },
195
- );
196
- if (handled) return;
197
- }
239
+ const handledBySdk = await trySdkBatchPersist({
240
+ changes,
241
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
242
+ eligible: changes.every((change) => change.track == null),
243
+ needsExtension,
244
+ label: "Move timeline clips",
245
+ coalesceKey,
246
+ });
247
+ if (handledBySdk) return;
198
248
 
199
249
  await persistServerBatch(
200
250
  projectId,
@@ -212,50 +262,39 @@ export function useTimelineGroupEditing({
212
262
  })),
213
263
  coalesceKey,
214
264
  );
215
- await finishTimelineTimingFallback({
265
+ await finishGroupTimingGsapFallback({
266
+ projectId,
216
267
  iframe: previewIframeRef.current,
217
- needsExtension,
218
- rootDurationSeconds: maxEnd,
219
268
  reloadPreview,
220
- gsapMutation: () =>
221
- foldGsapMutationIntoHistory({
222
- projectId,
223
- paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
224
- label: "Move timeline clips",
225
- coalesceKey,
226
- recordEdit,
227
- gsapMutation: async () => {
228
- let mutated = false;
229
- for (const change of changes) {
230
- const delta = change.start - change.element.start;
231
- const domId = change.element.domId;
232
- if (delta === 0 || !domId) continue;
233
- const status = await shiftGsapPositions(
234
- projectId,
235
- targetPathFor(change.element, activeCompPath),
236
- domId,
237
- delta,
238
- );
239
- mutated = mutated || status.mutated;
240
- }
241
- return { mutated };
242
- },
243
- }),
244
- onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
269
+ label: "Move timeline clips",
270
+ errorLabel: "Failed to shift GSAP positions",
271
+ coalesceKey,
272
+ recordEdit,
273
+ activeCompPath,
274
+ changes,
275
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
276
+ mutateChange: (change, changePath) => {
277
+ const delta = change.start - change.element.start;
278
+ const domId = change.element.domId;
279
+ if (delta === 0 || !domId) return null;
280
+ return shiftGsapPositions(projectId, changePath, domId, delta);
281
+ },
245
282
  });
283
+ }).catch((error) => {
284
+ // Failed persist: revert the optimistic duration readout + live root
285
+ // alongside the gesture owner's store rollback.
286
+ rollbackDuration();
287
+ throw error;
246
288
  });
247
289
  },
248
290
  [
249
291
  activeCompPath,
250
- domEditSaveTimestampRef,
251
292
  enqueueGroupOperation,
252
293
  persistServerBatch,
253
294
  previewIframeRef,
254
- projectIdRef,
255
295
  recordEdit,
256
296
  reloadPreview,
257
- sdkSession,
258
- writeProjectFile,
297
+ trySdkBatchPersist,
259
298
  ],
260
299
  );
261
300
 
@@ -278,41 +317,30 @@ export function useTimelineGroupEditing({
278
317
  }
279
318
 
280
319
  const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
320
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
321
+ // persist can roll the readout + live root back (see captureDurationRollback).
322
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
323
+ // needsExtension gates the SDK path (setTiming can't grow the root duration),
324
+ // so read the store BEFORE the readout sync below optimistically updates it.
281
325
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
326
+ // Optimistic duration readout: content-driven (grow AND shrink), read from
327
+ // the just-patched live DOM. See syncPreviewContentDuration.
328
+ syncPreviewContentDuration(previewIframeRef.current);
282
329
  const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
283
330
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
284
331
  await options?.beforeTiming;
285
- const sharedPath = allChangesSharePath(changes, activeCompPath);
286
- const sdkChanges = changes.map((change) =>
287
- change.element.hfId
288
- ? {
289
- hfId: change.element.hfId,
290
- timingUpdate: { start: change.start, duration: change.duration },
291
- }
292
- : null,
293
- );
294
- const canUseSdk =
295
- !needsExtension &&
296
- sharedPath !== null &&
297
- changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
298
- sdkChanges.every((change) => change !== null);
299
- if (canUseSdk) {
300
- const handled = await sdkTimingBatchPersist(
301
- sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
302
- sharedPath,
303
- sdkSession,
304
- {
305
- editHistory: { recordEdit },
306
- writeProjectFile,
307
- reloadPreview,
308
- domEditSaveTimestampRef,
309
- compositionPath: activeCompPath,
310
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
311
- },
312
- { label: "Resize timeline clips", coalesceKey },
313
- );
314
- if (handled) return;
315
- }
332
+ const handledBySdk = await trySdkBatchPersist({
333
+ changes,
334
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({
335
+ start: change.start,
336
+ duration: change.duration,
337
+ })),
338
+ eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
339
+ needsExtension,
340
+ label: "Resize timeline clips",
341
+ coalesceKey,
342
+ });
343
+ if (handledBySdk) return;
316
344
 
317
345
  await persistServerBatch(
318
346
  projectId,
@@ -328,55 +356,48 @@ export function useTimelineGroupEditing({
328
356
  })),
329
357
  coalesceKey,
330
358
  );
331
- await finishTimelineTimingFallback({
359
+ await finishGroupTimingGsapFallback({
360
+ projectId,
332
361
  iframe: previewIframeRef.current,
333
- needsExtension,
334
- rootDurationSeconds: maxEnd,
335
362
  reloadPreview,
336
- gsapMutation: () =>
337
- foldGsapMutationIntoHistory({
363
+ label: "Resize timeline clips",
364
+ errorLabel: "Failed to scale GSAP positions",
365
+ coalesceKey,
366
+ recordEdit,
367
+ activeCompPath,
368
+ changes,
369
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
370
+ mutateChange: (change, changePath) => {
371
+ const domId = change.element.domId;
372
+ const timingChanged =
373
+ change.start !== change.element.start || change.duration !== change.element.duration;
374
+ if (!timingChanged || !domId) return null;
375
+ return scaleGsapPositions(
338
376
  projectId,
339
- paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
340
- label: "Resize timeline clips",
341
- coalesceKey,
342
- recordEdit,
343
- gsapMutation: async () => {
344
- let mutated = false;
345
- for (const change of changes) {
346
- const domId = change.element.domId;
347
- const timingChanged =
348
- change.start !== change.element.start ||
349
- change.duration !== change.element.duration;
350
- if (!timingChanged || !domId) continue;
351
- const status = await scaleGsapPositions(
352
- projectId,
353
- targetPathFor(change.element, activeCompPath),
354
- domId,
355
- change.element.start,
356
- change.element.duration,
357
- change.start,
358
- change.duration,
359
- );
360
- mutated = mutated || status.mutated;
361
- }
362
- return { mutated };
363
- },
364
- }),
365
- onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
377
+ changePath,
378
+ domId,
379
+ change.element.start,
380
+ change.element.duration,
381
+ change.start,
382
+ change.duration,
383
+ );
384
+ },
366
385
  });
386
+ }).catch((error) => {
387
+ // Failed persist: revert the optimistic duration readout + live root
388
+ // alongside the gesture owner's store rollback.
389
+ rollbackDuration();
390
+ throw error;
367
391
  });
368
392
  },
369
393
  [
370
394
  activeCompPath,
371
- domEditSaveTimestampRef,
372
395
  enqueueGroupOperation,
373
396
  persistServerBatch,
374
397
  previewIframeRef,
375
- projectIdRef,
376
398
  recordEdit,
377
399
  reloadPreview,
378
- sdkSession,
379
- writeProjectFile,
400
+ trySdkBatchPersist,
380
401
  ],
381
402
  );
382
403
 
@@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
9
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
10
  import { SpeedMenu } from "./SpeedMenu";
11
11
  import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
12
- import { useState } from "react";
13
12
 
14
13
  export { resolveSeekPercent };
15
- type TimeDisplayMode = "time" | "frame";
16
14
 
17
15
  /* ── Icon sub-components ─────────────────────────────────────────── */
18
16
 
@@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
369
367
  const outPoint = usePlayerStore((s) => s.outPoint);
370
368
  const setInPoint = usePlayerStore.getState().setInPoint;
371
369
  const setOutPoint = usePlayerStore.getState().setOutPoint;
372
- const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
370
+ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
371
+ const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
373
372
 
374
373
  const progressFillRef = useRef<HTMLDivElement>(null);
375
374
  const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({
428
427
 
429
428
  return (
430
429
  <div
430
+ // No own background/border: the transport blends into the preview
431
+ // panel's surface — buttons carry their own chrome.
431
432
  className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
432
433
  aria-disabled={disabled || undefined}
433
434
  style={{
434
- borderTop: "1px solid rgba(255,255,255,0.04)",
435
435
  paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
436
436
  }}
437
437
  >
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
456
456
  >
457
457
  <button
458
458
  type="button"
459
- onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
459
+ onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
460
460
  disabled={disabled}
461
461
  className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
462
462
  style={{ color: "#A1A1AA", cursor: "pointer" }}
@@ -9,6 +9,8 @@
9
9
  * no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
10
10
  * FILLED while the playhead is actively held/scrubbed (`scrubbing`).
11
11
  */
12
+ import { PLAYHEAD_HEAD_W } from "./timelineLayout";
13
+
12
14
  interface PlayheadIndicatorProps {
13
15
  /** CSS color, defaults to the HF accent variable */
14
16
  color?: string;
@@ -31,8 +33,11 @@ export function PlayheadIndicator({
31
33
  }: PlayheadIndicatorProps) {
32
34
  // Head chip dimensions — used to compute the centering offset and the
33
35
  // point where the vertical line starts (so it begins at the head's bottom
34
- // edge rather than running through the hollow diamond center).
35
- const HEAD_W = 9;
36
+ // edge rather than running through the hollow diamond center). The width is
37
+ // the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
38
+ // wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
39
+ // wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
40
+ const HEAD_W = PLAYHEAD_HEAD_W;
36
41
  const HEAD_H = 9;
37
42
  // marginTop(1) + HEAD_H = where the line should start.
38
43
  const HEAD_TOTAL_H = 1 + HEAD_H;
@@ -19,8 +19,10 @@ import {
19
19
  shouldAutoScrollTimeline,
20
20
  } from "./Timeline";
21
21
  import {
22
+ FIT_ZOOM_HEADROOM,
22
23
  GUTTER,
23
24
  MIN_TIMELINE_EXTENT_S,
25
+ PLAYHEAD_HEAD_W,
24
26
  RULER_H,
25
27
  TRACK_H,
26
28
  getTimelineDisplayContentWidth,
@@ -327,6 +329,60 @@ describe("generateTicks", () => {
327
329
  const { major } = generateTicks(180, 80);
328
330
  expect(major[1] - major[0]).toBe(2);
329
331
  });
332
+
333
+ it("picks 'nice' NLE steps across zoom levels (no 7s-style intervals)", () => {
334
+ // step = first nice interval whose px spacing >= 88 at that pps.
335
+ const cases: Array<[number, number]> = [
336
+ [2, 60], // 60s * 2pps = 120px
337
+ [10, 10], // 10s * 10pps = 100px
338
+ [20, 5], // 5s * 20pps = 100px
339
+ [50, 2], // 2s * 50pps = 100px
340
+ [100, 1], // 1s * 100pps = 100px
341
+ ];
342
+ for (const [pps, expected] of cases) {
343
+ const { major } = generateTicks(600, pps);
344
+ expect(major[1] - major[0]).toBe(expected);
345
+ }
346
+ });
347
+
348
+ it("uses minute/hour steps when zoomed far out instead of colliding 10m labels", () => {
349
+ // 0.05 pps → 600s step would be 30px apart (labels collide); 1800s = 90px.
350
+ const { major } = generateTicks(7200, 0.05);
351
+ expect(major[1] - major[0]).toBe(1800);
352
+ expect(major).toContain(3600);
353
+ });
354
+
355
+ it("does not drift on long rulers (ticks are exact multiples of the step)", () => {
356
+ const { major } = generateTicks(600, 100); // 1s step, 601 ticks
357
+ expect(major[599]).toBe(599);
358
+ });
359
+
360
+ describe("frame display mode (frameRate provided)", () => {
361
+ it("snaps sub-frame steps up to one whole frame (no duplicate frame labels)", () => {
362
+ // 4400 pps would pick a 0.02s step = 0.6 frames at 30fps → snapped to 1 frame.
363
+ const { major } = generateTicks(2, 4400, 30);
364
+ const frames = major.map((t) => Math.round(t * 30));
365
+ // Frame labels are consecutive integers — no duplicates, no gaps.
366
+ frames.forEach((f, i) => expect(f).toBe(i));
367
+ });
368
+
369
+ it("keeps major AND minor ticks on whole frames", () => {
370
+ // 200 pps → 0.5s step (15 frames); quarters (3.75f) are rejected in
371
+ // frame mode in favour of fifths (3f).
372
+ const { major, minor } = generateTicks(20, 200, 30);
373
+ expect(major[1]).toBeCloseTo(0.5);
374
+ expect(minor).toContain(0.1); // 3 frames
375
+ for (const t of [...major, ...minor]) {
376
+ const frames = t * 30;
377
+ expect(Math.abs(frames - Math.round(frames))).toBeLessThan(1e-3);
378
+ }
379
+ });
380
+
381
+ it("leaves whole-second steps unchanged", () => {
382
+ const { major } = generateTicks(60, 100, 30);
383
+ expect(major[1] - major[0]).toBe(1);
384
+ });
385
+ });
330
386
  });
331
387
 
332
388
  describe("formatTime", () => {
@@ -397,19 +453,33 @@ describe("shouldAutoScrollTimeline", () => {
397
453
  });
398
454
  });
399
455
 
400
- describe("getTimelineFitPps (min 60s extent)", () => {
456
+ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
401
457
  const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
402
458
 
403
459
  it("computes fit pps against the 60s floor for short compositions", () => {
404
460
  // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
461
+ // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
405
462
  const pps = getTimelineFitPps(viewport, 10);
406
463
  expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
407
464
  expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
408
465
  });
409
466
 
410
- it("keeps filling the viewport with the composition when it is 60s or longer", () => {
411
- expect(getTimelineFitPps(viewport, 60)).toBeCloseTo((viewport - GUTTER - 2) / 60);
412
- expect(getTimelineFitPps(viewport, 120)).toBeCloseTo((viewport - GUTTER - 2) / 120);
467
+ it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
468
+ expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
469
+ (viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
470
+ );
471
+ expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
472
+ (viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
473
+ );
474
+ });
475
+
476
+ it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
477
+ const usable = viewport - GUTTER - 2;
478
+ const pps = getTimelineFitPps(viewport, 120);
479
+ // Composition content occupies usable/1.2 px; the remaining ~17% is empty
480
+ // droppable ruler/lane surface past the end.
481
+ expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
482
+ expect(120 * pps).toBeLessThan(usable);
413
483
  });
414
484
 
415
485
  it("falls back to 100 pps before the viewport is measured", () => {
@@ -529,13 +599,20 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
529
599
  });
530
600
 
531
601
  describe("getTimelinePlayheadLeft", () => {
532
- it("converts time to a pixel offset from the gutter", () => {
533
- expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
602
+ it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
603
+ // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
604
+ // equal GUTTER + t*pps at any zoom.
605
+ expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
606
+ expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
607
+ });
608
+
609
+ it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
610
+ expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
534
611
  });
535
612
 
536
613
  it("guards invalid input", () => {
537
- expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
538
- expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
614
+ expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
615
+ expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
539
616
  });
540
617
  });
541
618
 
@@ -26,6 +26,7 @@ import {
26
26
  getTimelineCanvasHeight,
27
27
  shouldShowTimelineShortcutHint,
28
28
  } from "./timelineLayout";
29
+ import { STUDIO_PREVIEW_FPS } from "../lib/time";
29
30
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
30
31
  import type { TimelineProps } from "./TimelineTypes";
31
32
 
@@ -95,6 +96,7 @@ export const Timeline = memo(function Timeline({
95
96
  [beatAnalysis, musicElement, beatEdits],
96
97
  );
97
98
  const duration = usePlayerStore((s) => s.duration);
99
+ const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
98
100
  const timelineReady = usePlayerStore((s) => s.timelineReady);
99
101
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
100
102
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
@@ -156,11 +158,8 @@ export const Timeline = memo(function Timeline({
156
158
  containerRef.current = el;
157
159
  }, []);
158
160
 
159
- // Last horizontal scroll offset, tracked so it can be RESTORED across the
160
- // post-edit iframe reload: an edit re-derives elements (and may shrink the
161
- // content width), which the browser clamps into a scroll jump. Paired with the
162
- // pinned zoom (which keeps pps constant so the pixel offset stays meaningful),
163
- // restoring this keeps the user parked at the same spot after any edit.
161
+ // Last horizontal scroll offset, RESTORED across the post-edit iframe reload (which clamps into
162
+ // a scroll jump); with the pinned zoom this keeps the user parked at the same spot after edits.
164
163
  const lastScrollLeftRef = useRef(0);
165
164
  const setScrollRef = useCallback(
166
165
  (el: HTMLDivElement | null) => {
@@ -395,9 +394,11 @@ export const Timeline = memo(function Timeline({
395
394
  }
396
395
  });
397
396
 
397
+ // Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
398
+ const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
398
399
  const { major, minor } = useMemo(
399
- () => generateTicks(displayDuration, pps),
400
- [displayDuration, pps],
400
+ () => generateTicks(displayDuration, pps, tickFps),
401
+ [displayDuration, pps, tickFps],
401
402
  );
402
403
  const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
403
404
 
@@ -526,8 +527,7 @@ export const Timeline = memo(function Timeline({
526
527
  const elKey = el.key ?? el.id;
527
528
  setSelectedElementId(elKey);
528
529
  onSelectElement?.(el);
529
- // Visually select the clicked diamond (matches shift-click / motion-path
530
- // selection); cleared above so this single-selects it.
530
+ // Select the clicked diamond (matches shift-click); cleared above so this single-selects.
531
531
  toggleSelectedKeyframe(`${elKey}:${pct}`);
532
532
  const absTime = el.start + (pct / 100) * el.duration;
533
533
  onSeek?.(absTime);
@@ -10,6 +10,8 @@ import {
10
10
  CLIP_Y,
11
11
  TRACKS_TOP_PAD,
12
12
  TRACKS_BOTTOM_PAD,
13
+ PLAYHEAD_HEAD_W,
14
+ getTimelinePlayheadLeft,
13
15
  getTimelineRowTop,
14
16
  } from "./timelineLayout";
15
17
  import { usePlayerStore } from "../store/playerStore";
@@ -20,6 +22,7 @@ import type { Rect } from "../../utils/marqueeGeometry";
20
22
  import { TimelineClip } from "./TimelineClip";
21
23
  import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
22
24
  import { renderClipChildren } from "./timelineClipChildren";
25
+ import { useTimelineRevealClip } from "./useTimelineRevealClip";
23
26
 
24
27
  interface TimelineCanvasProps extends TimelineLaneBaseProps {
25
28
  major: number[];
@@ -41,6 +44,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
41
44
  const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
42
45
  useTimelineEditContextOptional();
43
46
  const beatDragging = usePlayerStore((s) => s.beatDragging);
47
+ // Scroll a clip into view when the sidebar (asset card) requests a reveal.
48
+ useTimelineRevealClip(scrollRef);
44
49
  const draggedElement = draggedClip?.element ?? null;
45
50
  const activeDraggedElement =
46
51
  draggedClip?.started === true && draggedElement
@@ -259,12 +264,16 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
259
264
  )}
260
265
 
261
266
  {/* Playhead — hidden while dragging a beat so its guideline doesn't
262
- track the scrub and clutter the beat being moved. */}
267
+ track the scrub and clutter the beat being moved. Explicit width +
268
+ the half-head offset baked into getTimelinePlayheadLeft keep the
269
+ inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
270
+ ticks' center), instead of relying on shrink-wrap sizing. */}
263
271
  <div
264
272
  ref={props.playheadRef}
265
273
  className="absolute top-0 bottom-0 pointer-events-none"
266
274
  style={{
267
- left: `${GUTTER}px`,
275
+ left: `${getTimelinePlayheadLeft(0, 0)}px`,
276
+ width: PLAYHEAD_HEAD_W,
268
277
  zIndex: 100,
269
278
  display: beatDragging ? "none" : undefined,
270
279
  }}