@hyperframes/studio 0.8.84 → 0.8.86

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 (34) hide show
  1. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +473 -0
  2. package/dist/assets/{index-D9dmtDap.js → index-8Fgryj7q.js} +1 -1
  3. package/dist/assets/{index-DFcIBFM8.js → index-Cz3E_ZSj.js} +1 -1
  4. package/dist/assets/{index-gjbSm-vt.js → index-d5DKK15Q.js} +175 -175
  5. package/dist/assets/{index-BWJqxeaH.css → index-mzZCha_N.css} +1 -1
  6. package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
  7. package/dist/chunk-7PLTC6SV.js.map +1 -0
  8. package/dist/index.d.ts +25 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +151 -111
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/TimelineToolbar.test.tsx +53 -2
  16. package/src/components/TimelineToolbar.tsx +119 -101
  17. package/src/components/nle/CompositionBreadcrumb.tsx +5 -5
  18. package/src/components/nle/useCompositionStack.test.tsx +126 -0
  19. package/src/components/nle/useCompositionStack.ts +22 -12
  20. package/src/compositionStackExport.test.ts +7 -0
  21. package/src/index.ts +1 -0
  22. package/src/player/components/Timeline.playheadRemount.test.tsx +65 -0
  23. package/src/player/components/Timeline.test.ts +32 -19
  24. package/src/player/components/TimelineLanes.tsx +2 -3
  25. package/src/player/components/timelineKeyboardNavigation.ts +2 -0
  26. package/src/player/components/timelineLayout.ts +16 -23
  27. package/src/player/components/useTimelineGeometry.test.tsx +105 -0
  28. package/src/player/components/useTimelineGeometry.ts +18 -10
  29. package/src/player/components/useTimelineKeyboardActor.test.tsx +24 -1
  30. package/src/player/components/useTimelineKeyboardActor.ts +10 -2
  31. package/src/player/components/useTimelinePlayhead.ts +1 -1
  32. package/src/player/store/liveTime.ts +6 -1
  33. package/dist/assets/hyperframes-player-Dd74fv7B.js +0 -464
  34. package/dist/chunk-7VDQIGOU.js.map +0 -1
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { liveTime, usePlayerStore } from "../store/playerStore";
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ class MockResizeObserver {
12
+ constructor(private readonly callback: ResizeObserverCallback) {}
13
+ observe(target: Element) {
14
+ this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
15
+ }
16
+ unobserve() {}
17
+ disconnect() {}
18
+ }
19
+
20
+ const originalResizeObserver = globalThis.ResizeObserver;
21
+ const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
22
+
23
+ beforeAll(() => {
24
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
25
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", {
26
+ configurable: true,
27
+ get: () => 900,
28
+ });
29
+ });
30
+
31
+ afterAll(() => {
32
+ globalThis.ResizeObserver = originalResizeObserver;
33
+ if (originalClientWidth)
34
+ Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
35
+ document.body.innerHTML = "";
36
+ });
37
+
38
+ const clips = [{ id: "intro", tag: "div", start: 2, duration: 10, track: 0 }];
39
+ const needle = (host: HTMLElement) =>
40
+ host.querySelector<HTMLElement>("[data-timeline-playhead-layer] > div")?.style.transform;
41
+
42
+ describe("Timeline playhead across a composition switch", () => {
43
+ it("places the remounted playhead at the restored time", async () => {
44
+ usePlayerStore.setState({ duration: 20, currentTime: 0, timelineReady: true, elements: clips });
45
+ const host = document.createElement("div");
46
+ document.body.append(host);
47
+ const root = createRoot(host);
48
+ await act(async () => root.render(<Timeline />));
49
+ const atZero = needle(host);
50
+ await act(async () => usePlayerStore.getState().setCurrentTime(4));
51
+ const atFour = needle(host);
52
+ expect(atFour).not.toBe(atZero);
53
+
54
+ await act(async () => usePlayerStore.setState({ elements: [], timelineReady: false }));
55
+ await act(async () => usePlayerStore.getState().setCurrentTime(1.5));
56
+ await act(async () => {
57
+ usePlayerStore.getState().setCurrentTime(4);
58
+ liveTime.notify(4);
59
+ });
60
+ await act(async () => usePlayerStore.setState({ elements: clips, timelineReady: true }));
61
+
62
+ expect(needle(host)).toBe(atFour);
63
+ act(() => root.unmount());
64
+ });
65
+ });
@@ -213,7 +213,9 @@ describe("Timeline provider boundary", () => {
213
213
  100,
214
214
  ).start,
215
215
  ).toBe(10);
216
- expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1);
216
+ expect(getTimelineFitPps(640, 11, GUTTER)).toBeCloseTo(
217
+ (640 - GUTTER - 2) / (11 * FIT_ZOOM_HEADROOM),
218
+ );
217
219
 
218
220
  act(() => root.unmount());
219
221
  });
@@ -313,7 +315,7 @@ describe("Timeline provider boundary", () => {
313
315
  expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000);
314
316
  expect(collapsedHeader.textContent).toContain("Outro");
315
317
  expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo(
316
- (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S,
318
+ (640 - (LABEL_COL_W + GUTTER) - 2) / (20 * FIT_ZOOM_HEADROOM),
317
319
  );
318
320
  expect(
319
321
  resolveTimelineAssetDrop(
@@ -948,15 +950,13 @@ describe("shouldAutoScrollTimeline", () => {
948
950
  });
949
951
  });
950
952
 
951
- describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
953
+ describe("getTimelineFitPps (fit headroom, 60s floor only without a duration)", () => {
952
954
  const viewport = 632; // usable width = 632 - GUTTER - TRACKS_LEFT_PAD - 2
953
955
 
954
- it("computes fit pps against the 60s floor for short compositions", () => {
955
- // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
956
- // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
957
- const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD);
958
- expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S);
959
- expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6);
956
+ it("fits a short film to the width, not to a 60s ruler", () => {
957
+ const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2;
958
+ const pps = getTimelineFitPps(viewport, 17, GUTTER + TRACKS_LEFT_PAD);
959
+ expect(17 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
960
960
  });
961
961
 
962
962
  it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
@@ -1000,11 +1000,10 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
1000
1000
  });
1001
1001
 
1002
1002
  describe("getTimelineDisplayContentWidth", () => {
1003
- it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
1004
- // 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
1003
+ it("spans MIN_TIMELINE_EXTENT_S seconds while the duration is unknown", () => {
1005
1004
  expect(
1006
1005
  getTimelineDisplayContentWidth({
1007
- trackContentWidth: 200,
1006
+ effectiveDuration: 0,
1008
1007
  viewportWidth: 400,
1009
1008
  contentOrigin: GUTTER,
1010
1009
  pps: 20,
@@ -1012,10 +1011,24 @@ describe("getTimelineDisplayContentWidth", () => {
1012
1011
  ).toBe(MIN_TIMELINE_EXTENT_S * 20);
1013
1012
  });
1014
1013
 
1015
- it("still fills the viewport when that is larger than the 60s floor", () => {
1014
+ it("renders a short film at fit exactly as wide as the viewport, so fit never scrolls", () => {
1015
+ const viewport = 632;
1016
+ const origin = GUTTER + TRACKS_LEFT_PAD;
1017
+ const pps = getTimelineFitPps(viewport, 17, origin);
1018
+ expect(
1019
+ getTimelineDisplayContentWidth({
1020
+ effectiveDuration: 17,
1021
+ viewportWidth: viewport,
1022
+ contentOrigin: origin,
1023
+ pps,
1024
+ }),
1025
+ ).toBeCloseTo(viewport - origin - 2);
1026
+ });
1027
+
1028
+ it("still fills the viewport when that is larger than the fit span", () => {
1016
1029
  expect(
1017
1030
  getTimelineDisplayContentWidth({
1018
- trackContentWidth: 200,
1031
+ effectiveDuration: 40,
1019
1032
  viewportWidth: 2000,
1020
1033
  contentOrigin: GUTTER + TRACKS_LEFT_PAD,
1021
1034
  pps: 5,
@@ -1026,7 +1039,7 @@ describe("getTimelineDisplayContentWidth", () => {
1026
1039
  it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
1027
1040
  expect(
1028
1041
  getTimelineDisplayContentWidth({
1029
- trackContentWidth: 500,
1042
+ effectiveDuration: 100,
1030
1043
  viewportWidth: 400,
1031
1044
  contentOrigin: GUTTER,
1032
1045
  pps: 5,
@@ -1038,7 +1051,7 @@ describe("getTimelineDisplayContentWidth", () => {
1038
1051
  it("tracks a resize (trim) ghost past every other bound (trim-to-extend)", () => {
1039
1052
  expect(
1040
1053
  getTimelineDisplayContentWidth({
1041
- trackContentWidth: 500,
1054
+ effectiveDuration: 100,
1042
1055
  viewportWidth: 400,
1043
1056
  contentOrigin: GUTTER,
1044
1057
  pps: 5,
@@ -1047,15 +1060,15 @@ describe("getTimelineDisplayContentWidth", () => {
1047
1060
  ).toBe(4200);
1048
1061
  });
1049
1062
 
1050
- it("keeps long content authoritative", () => {
1063
+ it("keeps the fit headroom past the end when zoomed in", () => {
1051
1064
  expect(
1052
1065
  getTimelineDisplayContentWidth({
1053
- trackContentWidth: 9000,
1066
+ effectiveDuration: 180,
1054
1067
  viewportWidth: 400,
1055
1068
  contentOrigin: GUTTER,
1056
1069
  pps: 50,
1057
1070
  }),
1058
- ).toBe(9000);
1071
+ ).toBeCloseTo(180 * FIT_ZOOM_HEADROOM * 50);
1059
1072
  });
1060
1073
  });
1061
1074
 
@@ -134,9 +134,8 @@ export function TimelineLanes({
134
134
  focusedTargetId,
135
135
  rowGeometry,
136
136
  scrollRef,
137
- onToggleRow: (row) => {
138
- if (row.elementId) toggleClipExpandedTracked(row.elementId);
139
- },
137
+ onToggleRow: (row) => row.elementId && toggleClipExpandedTracked(row.elementId),
138
+ onDrillDown,
140
139
  });
141
140
  return (
142
141
  <div
@@ -49,6 +49,7 @@ export interface TimelineLogicalItem {
49
49
  elementId: string;
50
50
  /** The item's time anchor. Clips use their midpoint; ease controls use the segment midpoint. */
51
51
  time: number;
52
+ element?: TimelineElement;
52
53
  keyframeTarget?: TimelineKeyframeTarget;
53
54
  }
54
55
 
@@ -113,6 +114,7 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
113
114
  rowId,
114
115
  elementId: id,
115
116
  time: element.start + element.duration / 2,
117
+ element,
116
118
  };
117
119
  });
118
120
  }
@@ -281,57 +281,51 @@ export function getTimelineInsertBoundaryBand(rowHeight: number): number {
281
281
  * rendered width (see Timeline.tsx displayContentWidth).
282
282
  */
283
283
  export const DRAG_EXTEND_MARGIN_PX = 160;
284
- /**
285
- * The rendered timeline always spans at least this many seconds of ruler +
286
- * track lanes, even when the composition is shorter — the empty space on the
287
- * right is a real, drag/drop-enabled surface (clips can be moved into it; the
288
- * composition grows on commit, content-driven). In fit mode the fit pps is
289
- * derived against this floor, so a 10s comp renders as ~1/6 of the viewport
290
- * with 60s of ruler after it.
291
- */
284
+ /** The span the timeline maps while the composition's duration is unknown or 0. */
292
285
  export const MIN_TIMELINE_EXTENT_S = 60;
293
286
  /**
294
287
  * Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` — not the bare
295
288
  * duration — onto the viewport, so the composition ends at ~83% of the width
296
289
  * and the trailing ~17% stays empty ruler + droppable lane surface (room to
297
290
  * drag clips past the current end without first zooming out). Applied ONLY
298
- * inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
299
- * lanes, playhead, marquee, and drag math all inherit it consistently. Manual
291
+ * inside {@link getTimelineFitSpan}, which fit pps and the drawn width share, so
292
+ * the ruler, lanes, playhead, marquee, and drag math inherit it. Manual
300
293
  * zoom percentages stay defined relative to this fit basis (100% == fit).
301
294
  */
302
295
  export const FIT_ZOOM_HEADROOM = 1.2;
303
296
 
304
297
  /* ── Tick generation ──────────────────────────────────────────────── */
305
298
  /* ── Width / duration derivation ──────────────────────────────────── */
299
+ /** Seconds the timeline maps: the composition plus FIT_ZOOM_HEADROOM, or the floor while its duration is unknown. */
300
+ function getTimelineFitSpan(effectiveDuration: number): number {
301
+ return Number.isFinite(effectiveDuration) && effectiveDuration > 0
302
+ ? effectiveDuration * FIT_ZOOM_HEADROOM
303
+ : MIN_TIMELINE_EXTENT_S;
304
+ }
305
+
306
306
  /**
307
307
  * Fit-mode pixels-per-second: fill the viewport with the composition plus
308
308
  * FIT_ZOOM_HEADROOM trailing headroom (CapCut-style — the comp never slams
309
- * into the right edge), and never map fewer than MIN_TIMELINE_EXTENT_S
310
- * seconds onto it — a short comp takes a fraction of the width and the
311
- * remaining ruler runs to 1:00.
312
- * Manual zoom multiplies this base, so the floor only anchors the default.
309
+ * into the right edge). Manual zoom multiplies this base.
313
310
  */
314
311
  export function getTimelineFitPps(
315
312
  viewportWidth: number,
316
313
  effectiveDuration: number,
317
314
  contentOrigin: number,
318
315
  ): number {
319
- const safeDuration =
320
- Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
321
- const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
322
316
  if (!Number.isFinite(viewportWidth) || viewportWidth <= contentOrigin) return 100;
323
- return (viewportWidth - contentOrigin - 2) / span;
317
+ return (viewportWidth - contentOrigin - 2) / getTimelineFitSpan(effectiveDuration);
324
318
  }
325
319
 
326
320
  /**
327
321
  * The rendered timeline extent in px. Always covers, whichever is largest:
328
- * the actual clip content, the visible viewport (no dead black after short
322
+ * the visible viewport (no dead black after short
329
323
  * content — CapCut-style), a live drag or resize ghost plus the auto-scroll
330
- * margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
324
+ * margin (drag/trim-to-extend), and the fit span at this zoom. Only the
331
325
  * RENDERED extent grows; clip positions/durations are untouched.
332
326
  */
333
327
  export function getTimelineDisplayContentWidth(input: {
334
- trackContentWidth: number;
328
+ effectiveDuration: number;
335
329
  viewportWidth: number;
336
330
  contentOrigin: number;
337
331
  pps: number;
@@ -340,11 +334,10 @@ export function getTimelineDisplayContentWidth(input: {
340
334
  }): number {
341
335
  const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
342
336
  return Math.max(
343
- input.trackContentWidth,
344
337
  input.viewportWidth - input.contentOrigin - 2,
345
338
  input.dragGhostEndPx ?? 0,
346
339
  input.resizeGhostEndPx ?? 0,
347
- MIN_TIMELINE_EXTENT_S * safePps,
340
+ getTimelineFitSpan(input.effectiveDuration) * safePps,
348
341
  );
349
342
  }
350
343
 
@@ -115,3 +115,108 @@ describe("useTimelineGeometry restore-scroll effect", () => {
115
115
  expect(scroll.scrollLeft).toBe(300);
116
116
  });
117
117
  });
118
+
119
+ function ScaleProbe({
120
+ duration,
121
+ width = 1200,
122
+ clips = duration > 0,
123
+ seen,
124
+ }: {
125
+ duration: number;
126
+ width?: number;
127
+ clips?: boolean;
128
+ seen: { pps: number; fitPps: number };
129
+ }) {
130
+ const ppsRef = useRef(0);
131
+ const fitPpsRef = useRef(0);
132
+ const zoomMode = usePlayerStore((s) => s.zoomMode);
133
+ const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
134
+ const { pps, fitPps } = useTimelineGeometry({
135
+ viewportWidth: width,
136
+ effectiveDuration: duration,
137
+ zoomMode,
138
+ manualZoomPercent,
139
+ ppsRef,
140
+ fitPpsRef,
141
+ draggedClip: null,
142
+ resizingClip: null,
143
+ expandedElements: clips ? [{ ...element, start: 0, duration }] : [],
144
+ isDragging: useRef(false),
145
+ scrollRef: useRef<HTMLDivElement>(null),
146
+ lastScrollLeftRef: useRef(0),
147
+ contentOrigin: 32,
148
+ });
149
+ seen.pps = pps;
150
+ seen.fitPps = fitPps;
151
+ return null;
152
+ }
153
+
154
+ describe("useTimelineGeometry keeps the scale when only the length changes", () => {
155
+ const seen = { pps: 0, fitPps: 0 };
156
+ const renderAt = (duration: number, width?: number, clips?: boolean) =>
157
+ act(() =>
158
+ root.render(<ScaleProbe duration={duration} width={width} clips={clips} seen={seen} />),
159
+ );
160
+ const within = (pps: number) => Math.abs(seen.pps - pps) < seen.fitPps * 0.005;
161
+
162
+ it("keeps a short film's clips the same size when the first edit lengthens it", () => {
163
+ usePlayerStore.setState({ zoomMode: "fit", manualZoomPercent: 100 });
164
+ renderAt(17);
165
+ const before = seen.pps;
166
+ act(() => usePlayerStore.getState().pinTimelineZoom(seen.pps, seen.fitPps));
167
+ renderAt(25);
168
+ expect(seen.fitPps).toBeLessThan(before);
169
+ expect(within(before)).toBe(true);
170
+ });
171
+
172
+ it("keeps the scale in manual zoom when a later edit shortens the film", () => {
173
+ usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
174
+ renderAt(17);
175
+ const before = seen.pps;
176
+ renderAt(12);
177
+ expect(seen.fitPps).toBeGreaterThan(before / 1.5);
178
+ expect(within(before)).toBe(true);
179
+ });
180
+
181
+ it("still scales with the fit width when the timeline is resized, and when the length first loads", () => {
182
+ usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
183
+ renderAt(0);
184
+ renderAt(17);
185
+ expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
186
+ renderAt(17, 900);
187
+ expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
188
+ });
189
+
190
+ it("refits when another composition opens, which empties the clips first", () => {
191
+ usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
192
+ renderAt(120);
193
+ renderAt(120, undefined, false);
194
+ renderAt(5);
195
+ expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
196
+ });
197
+
198
+ it("refits after a project reset, which empties the clips and the length", () => {
199
+ usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 150 });
200
+ renderAt(120);
201
+ renderAt(0);
202
+ renderAt(17);
203
+ expect(seen.pps).toBeCloseTo(seen.fitPps * 1.5);
204
+ });
205
+
206
+ it("keeps a lengthened film's scale near the maximum zoom", () => {
207
+ usePlayerStore.setState({ zoomMode: "manual", manualZoomPercent: 100_000 });
208
+ renderAt(17);
209
+ const before = seen.pps;
210
+ expect(before).toBeLessThan(seen.fitPps * 1000);
211
+ renderAt(25);
212
+ expect(within(before)).toBe(true);
213
+ });
214
+
215
+ it("leaves the manual zoom alone in fit mode", () => {
216
+ usePlayerStore.setState({ zoomMode: "fit", manualZoomPercent: 100 });
217
+ renderAt(17);
218
+ renderAt(25);
219
+ expect(seen.pps).toBe(seen.fitPps);
220
+ expect(usePlayerStore.getState().manualZoomPercent).toBe(100);
221
+ });
222
+ });
@@ -1,6 +1,6 @@
1
- import { useEffect, useRef, type RefObject } from "react";
1
+ import { useEffect, useLayoutEffect, useRef, type RefObject } from "react";
2
2
  import { usePlayerStore, type TimelineElement, type ZoomMode } from "../store/playerStore";
3
- import { getTimelinePixelsPerSecond } from "./timelineZoom";
3
+ import { computePinnedZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
4
4
  import {
5
5
  DRAG_EXTEND_MARGIN_PX,
6
6
  getTimelineDisplayContentWidth,
@@ -43,12 +43,9 @@ export function useTimelineGeometry({
43
43
  lastScrollLeftRef,
44
44
  contentOrigin,
45
45
  }: UseTimelineGeometryInput) {
46
- // Fit pps maps at least MIN_TIMELINE_EXTENT_S onto the viewport, so short
47
- // comps show a 60s ruler with usable empty space (see getTimelineFitPps).
48
46
  const fitPps = getTimelineFitPps(viewportWidth, effectiveDuration, contentOrigin);
49
47
  const pps = getTimelinePixelsPerSecond(fitPps, zoomMode, manualZoomPercent);
50
48
  ppsRef.current = pps;
51
- const trackContentWidth = Math.max(0, effectiveDuration * pps);
52
49
  // Drag-to-extend: while a clip is dragged, keep the rendered extent a margin
53
50
  // past the ghost's end. Holding the pointer in the right edge zone then keeps
54
51
  // auto-scroll stepping (scrollWidth grows with the ghost), so the timeline
@@ -65,12 +62,12 @@ export function useTimelineGeometry({
65
62
  const resizeGhostEndPx = resizingClip?.started
66
63
  ? (resizingClip.previewStart + resizingClip.previewDuration) * pps + DRAG_EXTEND_MARGIN_PX
67
64
  : 0;
68
- // The timeline canvas always fills at least the viewport width AND the
69
- // MIN_TIMELINE_EXTENT_S floor: the ruler + empty track lanes keep going into
65
+ // The timeline canvas always fills at least the viewport width AND the fit
66
+ // span at this zoom: the ruler + empty track lanes keep going into
70
67
  // the space instead of leaving dead black — CapCut-style. Only the RENDERED
71
68
  // extent grows; clip positions/durations are untouched.
72
69
  const displayContentWidth = getTimelineDisplayContentWidth({
73
- trackContentWidth,
70
+ effectiveDuration,
74
71
  viewportWidth,
75
72
  contentOrigin,
76
73
  pps,
@@ -108,8 +105,7 @@ export function useTimelineGeometry({
108
105
  return () => cancelAnimationFrame(raf);
109
106
  // eslint-disable-next-line react-hooks/exhaustive-deps
110
107
  }, [expandedElements, zoomMode]);
111
- // Publish the live scale so edit handlers OUTSIDE <Timeline> (the keyboard-delete
112
- // path) can pin the zoom via pinTimelineZoomToCurrent without threading geometry.
108
+ // Publish the live scale for readers outside <Timeline> (the toolbar's zoom controls).
113
109
  // In a useEffect (not the render body) so React-18 concurrent replay — Suspense
114
110
  // retry, transitions, StrictMode double-invoke — can't double-publish. The write is
115
111
  // idempotent (same pps/fitPps → same fields), so this is behavior-preserving; the
@@ -117,6 +113,18 @@ export function useTimelineGeometry({
117
113
  useEffect(() => {
118
114
  usePlayerStore.getState().setTimelineScale(pps, fitPps);
119
115
  }, [pps, fitPps]);
116
+ // In manual zoom a length change keeps the on-screen scale instead of rescaling every
117
+ // clip, whichever edit caused it. A composition switch empties the clips first, so it refits.
118
+ const hasClips = expandedElements.length > 0;
119
+ const lastScale = useRef({ effectiveDuration, pps, hasClips });
120
+ useLayoutEffect(() => {
121
+ const last = lastScale.current;
122
+ lastScale.current = { effectiveDuration, pps, hasClips };
123
+ if (zoomMode !== "manual" || !last.hasClips || !hasClips) return;
124
+ if (last.effectiveDuration === effectiveDuration) return;
125
+ // Before paint; the percent is clamped against the new fit, not the store's last published one.
126
+ usePlayerStore.setState({ manualZoomPercent: computePinnedZoomPercent(last.pps, fitPps) });
127
+ });
120
128
 
121
129
  return {
122
130
  pps,
@@ -3,7 +3,7 @@
3
3
  import React, { act, useRef } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { usePlayerStore } from "../store/playerStore";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import { createTimelineRowGeometry } from "./timelineLayout";
8
8
  import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
9
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
@@ -57,6 +57,7 @@ interface HarnessProps {
57
57
  logicalRows?: readonly TimelineLogicalRow[];
58
58
  rowHeights?: readonly number[];
59
59
  onToggleRow?: (target: TimelineLogicalRow) => void;
60
+ onDrillDown?: (element: TimelineElement) => void;
60
61
  }
61
62
 
62
63
  function Harness({
@@ -64,6 +65,7 @@ function Harness({
64
65
  logicalRows = rows,
65
66
  rowHeights = logicalRows.map(() => 48),
66
67
  onToggleRow = vi.fn(),
68
+ onDrillDown,
67
69
  }: HarnessProps) {
68
70
  const scrollRef = useRef<HTMLDivElement>(null);
69
71
  const keyboard = useTimelineKeyboardActor({
@@ -75,6 +77,7 @@ function Harness({
75
77
  ),
76
78
  scrollRef,
77
79
  onToggleRow,
80
+ onDrillDown,
78
81
  });
79
82
  return (
80
83
  <div ref={scrollRef} onFocus={keyboard.onFocus} onKeyDown={keyboard.onKeyDown}>
@@ -228,6 +231,26 @@ describe("useTimelineKeyboardActor", () => {
228
231
  act(() => root.unmount());
229
232
  });
230
233
 
234
+ it("drills into a composition clip on Enter and leaves a plain clip alone", () => {
235
+ const scene = { id: "scene", tag: "div", start: 0, duration: 2, track: 1 };
236
+ const withElements = rows.map((row, index) => ({
237
+ ...row,
238
+ items: row.items.map((item) => ({
239
+ ...item,
240
+ element: index === 0 ? { ...scene, compositionSrc: "scene.html" } : scene,
241
+ })),
242
+ }));
243
+ const onDrillDown = vi.fn();
244
+ const { host, root } = renderHarness({ logicalRows: withElements, onDrillDown });
245
+ const composition = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-1"]')!;
246
+ const plain = host.querySelector<HTMLElement>('[data-timeline-focus-id="clip-2"]')!;
247
+ expect(key(plain, "Enter").defaultPrevented).toBe(false);
248
+ expect(onDrillDown).not.toHaveBeenCalled();
249
+ expect(key(composition, "Enter").defaultPrevented).toBe(true);
250
+ expect(onDrillDown).toHaveBeenCalledWith(withElements[0]!.items[0]!.element);
251
+ act(() => root.unmount());
252
+ });
253
+
231
254
  it("collapses expanded rows and leaves native header controls to the browser", () => {
232
255
  const onToggleRow = vi.fn();
233
256
  const expandedRows = [{ ...rows[0]!, expanded: true }, ...rows.slice(1)];
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
2
- import { usePlayerStore } from "../store/playerStore";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { TimelineRowGeometry } from "./timelineLayout";
4
4
  import {
5
5
  isTimelineNavigationKey,
@@ -14,6 +14,7 @@ interface TimelineKeyboardActorInput {
14
14
  rowGeometry: TimelineRowGeometry;
15
15
  scrollRef: RefObject<HTMLDivElement | null>;
16
16
  onToggleRow: (target: TimelineLogicalRow) => void;
17
+ onDrillDown?: (element: TimelineElement) => void;
17
18
  }
18
19
 
19
20
  function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
@@ -70,6 +71,7 @@ export function useTimelineKeyboardActor({
70
71
  rowGeometry,
71
72
  scrollRef,
72
73
  onToggleRow,
74
+ onDrillDown,
73
75
  }: TimelineKeyboardActorInput) {
74
76
  const rovingTargetId =
75
77
  (focusedTargetId && locateTimelineLogicalTarget(logicalRows, focusedTargetId)?.target.id) ??
@@ -130,6 +132,12 @@ export function useTimelineKeyboardActor({
130
132
  openContextMenu(targetElement);
131
133
  return;
132
134
  }
135
+ const composition = located.target.kind === "clip" && located.target.element;
136
+ if (event.key === "Enter" && composition && composition.compositionSrc && onDrillDown) {
137
+ event.preventDefault();
138
+ onDrillDown(composition);
139
+ return;
140
+ }
133
141
  if (
134
142
  (event.key !== "Enter" && event.key !== " ") ||
135
143
  located.target.kind !== "row" ||
@@ -140,7 +148,7 @@ export function useTimelineKeyboardActor({
140
148
  event.preventDefault();
141
149
  onToggleRow(located.target);
142
150
  },
143
- [logicalRowCountByTrack, logicalRows, onToggleRow, rowGeometry, scrollRef],
151
+ [logicalRowCountByTrack, logicalRows, onDrillDown, onToggleRow, rowGeometry, scrollRef],
144
152
  );
145
153
 
146
154
  return { rovingTargetId, onFocus, onKeyDown };
@@ -104,7 +104,7 @@ export function useTimelinePlayhead({
104
104
 
105
105
  useEffect(() => {
106
106
  syncPlayheadPosition(currentTime);
107
- }, [currentTime, pps, syncPlayheadPosition]);
107
+ }, [currentTime, pps, syncPlayheadPosition, timelineReady, elementsLength]);
108
108
 
109
109
  useLayoutEffect(() => {
110
110
  const scroll = scrollRef.current;
@@ -1,9 +1,14 @@
1
1
  // ponytail: Playback RAF updates the playhead/time display without per-frame React renders.
2
2
  type TimeListener = (time: number) => void;
3
3
  const timeListeners = new Set<TimeListener>();
4
+ let latestTime = 0;
4
5
 
5
6
  export const liveTime = {
6
- notify: (time: number) => timeListeners.forEach((listener) => listener(time)),
7
+ notify: (time: number) => {
8
+ latestTime = time;
9
+ timeListeners.forEach((listener) => listener(time));
10
+ },
11
+ latest: () => latestTime,
7
12
  subscribe: (listener: TimeListener) => {
8
13
  timeListeners.add(listener);
9
14
  return () => timeListeners.delete(listener);