@hyperframes/studio 0.8.85 → 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.
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +473 -0
- package/dist/assets/{index-CZxhjQpw.js → index-8Fgryj7q.js} +1 -1
- package/dist/assets/{index-Bus1BKtk.js → index-Cz3E_ZSj.js} +1 -1
- package/dist/assets/{index-ClLdmgua.js → index-d5DKK15Q.js} +169 -169
- package/dist/{chunk-7VDQIGOU.js → chunk-7PLTC6SV.js} +8 -2
- package/dist/chunk-7PLTC6SV.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +1 -1
- package/dist/index.js +132 -100
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +53 -2
- package/src/components/TimelineToolbar.tsx +119 -101
- package/src/components/nle/useCompositionStack.test.tsx +126 -0
- package/src/components/nle/useCompositionStack.ts +22 -12
- package/src/player/components/Timeline.playheadRemount.test.tsx +65 -0
- package/src/player/components/Timeline.test.ts +32 -19
- package/src/player/components/timelineLayout.ts +16 -23
- package/src/player/components/useTimelineGeometry.test.tsx +105 -0
- package/src/player/components/useTimelineGeometry.ts +18 -10
- package/src/player/components/useTimelinePlayhead.ts +1 -1
- package/src/player/store/liveTime.ts +6 -1
- package/dist/assets/hyperframes-player-BK18SggX.js +0 -464
- package/dist/chunk-7VDQIGOU.js.map +0 -1
|
@@ -213,7 +213,9 @@ describe("Timeline provider boundary", () => {
|
|
|
213
213
|
100,
|
|
214
214
|
).start,
|
|
215
215
|
).toBe(10);
|
|
216
|
-
expect(getTimelineFitPps(640, 11, GUTTER)).
|
|
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) /
|
|
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 (
|
|
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("
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1063
|
+
it("keeps the fit headroom past the end when zoomed in", () => {
|
|
1051
1064
|
expect(
|
|
1052
1065
|
getTimelineDisplayContentWidth({
|
|
1053
|
-
|
|
1066
|
+
effectiveDuration: 180,
|
|
1054
1067
|
viewportWidth: 400,
|
|
1055
1068
|
contentOrigin: GUTTER,
|
|
1056
1069
|
pps: 50,
|
|
1057
1070
|
}),
|
|
1058
|
-
).
|
|
1071
|
+
).toBeCloseTo(180 * FIT_ZOOM_HEADROOM * 50);
|
|
1059
1072
|
});
|
|
1060
1073
|
});
|
|
1061
1074
|
|
|
@@ -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
|
|
299
|
-
* lanes, playhead, marquee, and drag math
|
|
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)
|
|
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) /
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -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) =>
|
|
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);
|