@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.
- package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
- package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4345 -3812
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditOverlay.tsx +31 -62
- package/src/components/editor/LayersPanel.tsx +10 -1
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +36 -16
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/timelineEditingHelpers.test.ts +91 -0
- package/src/hooks/timelineEditingHelpers.ts +31 -244
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +5 -3
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +92 -1
- package/src/hooks/useDomEditCommits.ts +63 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
- package/src/hooks/useElementLifecycleOps.ts +20 -7
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +378 -212
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineGroupEditing.ts +153 -132
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +85 -8
- package/src/player/components/Timeline.tsx +9 -9
- package/src/player/components/TimelineCanvas.tsx +11 -2
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/timelineClipDragCommit.test.ts +91 -8
- package/src/player/components/timelineClipDragCommit.ts +106 -9
- package/src/player/components/timelineLayout.ts +99 -26
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
- package/src/player/components/useTimelineStackingSync.ts +13 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.ts +9 -0
- package/src/player/lib/timelineElementHelpers.ts +19 -0
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +37 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- 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
|
|
171
|
-
|
|
172
|
-
change.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
265
|
+
await finishGroupTimingGsapFallback({
|
|
266
|
+
projectId,
|
|
216
267
|
iframe: previewIframeRef.current,
|
|
217
|
-
needsExtension,
|
|
218
|
-
rootDurationSeconds: maxEnd,
|
|
219
268
|
reloadPreview,
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
change
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
359
|
+
await finishGroupTimingGsapFallback({
|
|
360
|
+
projectId,
|
|
332
361
|
iframe: previewIframeRef.current,
|
|
333
|
-
needsExtension,
|
|
334
|
-
rootDurationSeconds: maxEnd,
|
|
335
362
|
reloadPreview,
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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("
|
|
411
|
-
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
|
|
412
|
-
|
|
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("
|
|
533
|
-
|
|
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(
|
|
538
|
-
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
|
|
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,
|
|
160
|
-
//
|
|
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
|
-
//
|
|
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: `${
|
|
275
|
+
left: `${getTimelinePlayheadLeft(0, 0)}px`,
|
|
276
|
+
width: PLAYHEAD_HEAD_W,
|
|
268
277
|
zIndex: 100,
|
|
269
278
|
display: beatDragging ? "none" : undefined,
|
|
270
279
|
}}
|