@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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
acceptStudioRuntimeMessage,
|
|
4
|
+
acceptedRuntimeMessageFps,
|
|
5
|
+
createRuntimeControlMessage,
|
|
6
|
+
inspectStudioRuntimeMessage,
|
|
7
|
+
postRuntimeControlMessage,
|
|
8
|
+
} from "./runtimeProtocol";
|
|
9
|
+
|
|
10
|
+
describe("Studio runtime protocol", () => {
|
|
11
|
+
it("versions every control message and declares rational fps", () => {
|
|
12
|
+
expect(createRuntimeControlMessage("seek", { timeSeconds: 1.25 }, 60)).toEqual({
|
|
13
|
+
source: "hf-parent",
|
|
14
|
+
type: "control",
|
|
15
|
+
action: "seek",
|
|
16
|
+
protocolVersion: 1,
|
|
17
|
+
capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"],
|
|
18
|
+
fps: { numerator: 60, denominator: 1 },
|
|
19
|
+
timeSeconds: 1.25,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("posts the typed message to the target window", () => {
|
|
24
|
+
const target = { postMessage: vi.fn() };
|
|
25
|
+
postRuntimeControlMessage(target as unknown as Window, "pause");
|
|
26
|
+
expect(target.postMessage).toHaveBeenCalledWith(
|
|
27
|
+
expect.objectContaining({ action: "pause", protocolVersion: 1 }),
|
|
28
|
+
"*",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("preserves legacy 30fps messages and rejects unknown majors", () => {
|
|
33
|
+
expect(inspectStudioRuntimeMessage({ source: "hf-preview" })).toEqual({
|
|
34
|
+
status: "legacy",
|
|
35
|
+
fps: 30,
|
|
36
|
+
});
|
|
37
|
+
expect(inspectStudioRuntimeMessage({ protocolVersion: 2 })).toMatchObject({
|
|
38
|
+
status: "unsupported",
|
|
39
|
+
code: "unsupported_protocol_version",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("reads explicit fps for accepted timeline messages", () => {
|
|
44
|
+
const message = createRuntimeControlMessage("pause", {}, 60);
|
|
45
|
+
expect(acceptedRuntimeMessageFps(message)).toBe(60);
|
|
46
|
+
expect(acceptStudioRuntimeMessage(message)).toMatchObject({ status: "supported", fps: 60 });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
inspectRuntimeProtocol,
|
|
3
|
+
runtimeProtocolMetadata,
|
|
4
|
+
type RuntimeProtocolInspection,
|
|
5
|
+
} from "@hyperframes/core/runtime/protocol";
|
|
6
|
+
|
|
7
|
+
export type RuntimeControlMessage = {
|
|
8
|
+
source: "hf-parent";
|
|
9
|
+
type: "control";
|
|
10
|
+
action: string;
|
|
11
|
+
} & ReturnType<typeof runtimeProtocolMetadata> &
|
|
12
|
+
Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export function createRuntimeControlMessage(
|
|
15
|
+
action: string,
|
|
16
|
+
payload: Record<string, unknown> = {},
|
|
17
|
+
fps = 30,
|
|
18
|
+
): RuntimeControlMessage {
|
|
19
|
+
return {
|
|
20
|
+
...payload,
|
|
21
|
+
source: "hf-parent",
|
|
22
|
+
type: "control",
|
|
23
|
+
action,
|
|
24
|
+
...runtimeProtocolMetadata(fps),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function postRuntimeControlMessage(
|
|
29
|
+
target: Pick<Window, "postMessage"> | null | undefined,
|
|
30
|
+
action: string,
|
|
31
|
+
payload: Record<string, unknown> = {},
|
|
32
|
+
fps = 30,
|
|
33
|
+
): void {
|
|
34
|
+
target?.postMessage(createRuntimeControlMessage(action, payload, fps), "*");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function inspectStudioRuntimeMessage(value: unknown): RuntimeProtocolInspection {
|
|
38
|
+
return inspectRuntimeProtocol(value, 30);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dispatchRuntimeProtocolError(inspection: RuntimeProtocolInspection): void {
|
|
42
|
+
if (inspection.status !== "unsupported") return;
|
|
43
|
+
window.dispatchEvent(
|
|
44
|
+
new CustomEvent("runtimeprotocolerror", {
|
|
45
|
+
detail: {
|
|
46
|
+
code: inspection.code,
|
|
47
|
+
receivedVersion: inspection.receivedVersion,
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function acceptStudioRuntimeMessage(
|
|
54
|
+
value: unknown,
|
|
55
|
+
): Exclude<RuntimeProtocolInspection, { status: "unsupported" }> | null {
|
|
56
|
+
const inspection = inspectStudioRuntimeMessage(value);
|
|
57
|
+
if (inspection.status !== "unsupported") return inspection;
|
|
58
|
+
dispatchRuntimeProtocolError(inspection);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function acceptedRuntimeMessageFps(value: unknown): number {
|
|
63
|
+
const inspection = inspectStudioRuntimeMessage(value);
|
|
64
|
+
return inspection.status === "supported" ? inspection.fps : 30;
|
|
65
|
+
}
|
|
@@ -113,6 +113,15 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
113
113
|
start: clip.start,
|
|
114
114
|
duration: clip.duration,
|
|
115
115
|
track: clip.track,
|
|
116
|
+
// clip.track IS the authored data-track-index verbatim (the runtime honors
|
|
117
|
+
// it; see parseAuthoredTrack in core/runtime/timeline.ts). Record it at this
|
|
118
|
+
// translation boundary so later display-lane remaps (normalizeToZones,
|
|
119
|
+
// expanded-child rows) can persist in AUTHORED space instead of
|
|
120
|
+
// reconstructing it from lane occupants.
|
|
121
|
+
authoredTrack: clip.track,
|
|
122
|
+
// Runtime-computed stacking context — authoritative; helpers read it, never
|
|
123
|
+
// re-derive it.
|
|
124
|
+
stackingContextId: clip.stackingContextId ?? null,
|
|
116
125
|
domId,
|
|
117
126
|
hfId,
|
|
118
127
|
selector,
|
|
@@ -298,6 +298,25 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
|
|
|
298
298
|
return element.key ?? element.id;
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Timeline store key for a z-reorder entry built OUTSIDE the timeline
|
|
303
|
+
* expansion (canvas context menu / LayersPanel), so the reorder commit can
|
|
304
|
+
* update the store's zIndex synchronously. Matches buildTimelineElementKey's
|
|
305
|
+
* stable branches (`sourceFile#domId`, else the selector-based key). Undefined
|
|
306
|
+
* when the element has neither a DOM id nor a selector — the timeline's
|
|
307
|
+
* fallback branch needs its own fallbackIndex, which these callers don't have,
|
|
308
|
+
* so such an entry simply skips the synchronous store update.
|
|
309
|
+
*/
|
|
310
|
+
export function deriveTimelineStoreKey(params: {
|
|
311
|
+
domId?: string;
|
|
312
|
+
selector?: string;
|
|
313
|
+
selectorIndex?: number;
|
|
314
|
+
sourceFile?: string;
|
|
315
|
+
}): string | undefined {
|
|
316
|
+
if (!params.domId && !params.selector) return undefined;
|
|
317
|
+
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
|
|
318
|
+
}
|
|
319
|
+
|
|
301
320
|
// ---------------------------------------------------------------------------
|
|
302
321
|
// DOM node querying
|
|
303
322
|
// ---------------------------------------------------------------------------
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
buildTimelineElementIdentity,
|
|
21
21
|
readTimelineElementZIndex,
|
|
22
22
|
} from "./timelineElementHelpers";
|
|
23
|
+
import { postRuntimeControlMessage } from "./runtimeProtocol";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Viewport / DOM normalisation
|
|
@@ -103,10 +104,7 @@ function postPreviewControl(
|
|
|
103
104
|
action: string,
|
|
104
105
|
payload: Record<string, unknown>,
|
|
105
106
|
): void {
|
|
106
|
-
iframe.contentWindow
|
|
107
|
-
{ source: "hf-parent", type: "control", action, ...payload },
|
|
108
|
-
"*",
|
|
109
|
-
);
|
|
107
|
+
postRuntimeControlMessage(iframe.contentWindow, action, payload);
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
2
|
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
|
|
3
3
|
|
|
4
|
+
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
|
|
5
|
+
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
|
|
6
|
+
expect(state.isPlaying).toBe(false);
|
|
7
|
+
expect(state.currentTime).toBe(0);
|
|
8
|
+
expect(state.duration).toBe(0);
|
|
9
|
+
expect(state.timelineReady).toBe(false);
|
|
10
|
+
expect(state.elements).toEqual([]);
|
|
11
|
+
expect(state.selectedElementId).toBeNull();
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
describe("usePlayerStore", () => {
|
|
5
15
|
beforeEach(() => {
|
|
6
16
|
usePlayerStore.getState().reset();
|
|
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
|
|
|
9
19
|
describe("initial state", () => {
|
|
10
20
|
it("has correct defaults", () => {
|
|
11
21
|
const state = usePlayerStore.getState();
|
|
12
|
-
|
|
13
|
-
expect(state.currentTime).toBe(0);
|
|
14
|
-
expect(state.duration).toBe(0);
|
|
15
|
-
expect(state.timelineReady).toBe(false);
|
|
16
|
-
expect(state.elements).toEqual([]);
|
|
17
|
-
expect(state.selectedElementId).toBeNull();
|
|
22
|
+
expectResettableDefaults(state);
|
|
18
23
|
expect(state.playbackRate).toBe(1);
|
|
19
24
|
expect(state.audioMuted).toBe(false);
|
|
20
25
|
expect(state.loopEnabled).toBe(false);
|
|
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
|
|
|
384
389
|
});
|
|
385
390
|
});
|
|
386
391
|
|
|
392
|
+
describe("clipRevealRequest", () => {
|
|
393
|
+
it("starts null and carries the requested element id", () => {
|
|
394
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
395
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
396
|
+
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("bumps the nonce on repeat requests for the same clip", () => {
|
|
400
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
401
|
+
const first = usePlayerStore.getState().clipRevealRequest;
|
|
402
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
403
|
+
const second = usePlayerStore.getState().clipRevealRequest;
|
|
404
|
+
expect(second?.nonce).not.toBe(first?.nonce);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("clears via clearClipRevealRequest and on reset", () => {
|
|
408
|
+
usePlayerStore.getState().requestClipReveal("el-1");
|
|
409
|
+
usePlayerStore.getState().clearClipRevealRequest();
|
|
410
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
411
|
+
|
|
412
|
+
usePlayerStore.getState().requestClipReveal("el-2");
|
|
413
|
+
usePlayerStore.getState().reset();
|
|
414
|
+
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
387
418
|
describe("reset", () => {
|
|
388
419
|
it("resets all state to defaults", () => {
|
|
389
420
|
// Mutate everything
|
|
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
|
|
|
398
429
|
// Reset
|
|
399
430
|
usePlayerStore.getState().reset();
|
|
400
431
|
|
|
401
|
-
|
|
402
|
-
expect(state.isPlaying).toBe(false);
|
|
403
|
-
expect(state.currentTime).toBe(0);
|
|
404
|
-
expect(state.duration).toBe(0);
|
|
405
|
-
expect(state.timelineReady).toBe(false);
|
|
406
|
-
expect(state.elements).toEqual([]);
|
|
407
|
-
expect(state.selectedElementId).toBeNull();
|
|
432
|
+
expectResettableDefaults(usePlayerStore.getState());
|
|
408
433
|
});
|
|
409
434
|
|
|
410
435
|
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
@@ -29,6 +29,17 @@ export interface TimelineElement {
|
|
|
29
29
|
start: number;
|
|
30
30
|
duration: number;
|
|
31
31
|
track: number;
|
|
32
|
+
/**
|
|
33
|
+
* The data-track-index as written in the source file. Set at the manifest
|
|
34
|
+
* translation boundary (createTimelineElementFromManifestClip) from the
|
|
35
|
+
* runtime clip's verbatim track, and preserved through display-lane remaps
|
|
36
|
+
* (normalizeToZones packs sparse authored tracks onto contiguous display
|
|
37
|
+
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
|
|
38
|
+
* must persist THIS space — writing a display-lane number into a sparse file
|
|
39
|
+
* re-targets the wrong track. For an expanded child the value is in its OWN
|
|
40
|
+
* source file's coordinate space, not the host timeline's.
|
|
41
|
+
*/
|
|
42
|
+
authoredTrack?: number;
|
|
32
43
|
/** Resolved z-index for stacking-aware timeline ordering. */
|
|
33
44
|
zIndex?: number;
|
|
34
45
|
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
|
@@ -153,6 +164,9 @@ interface PlayerState {
|
|
|
153
164
|
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
|
154
165
|
timelineSnapEnabled: boolean;
|
|
155
166
|
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
167
|
+
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
|
168
|
+
timeDisplayMode: "time" | "frame";
|
|
169
|
+
setTimeDisplayMode: (mode: "time" | "frame") => void;
|
|
156
170
|
/**
|
|
157
171
|
* Pin the timeline zoom to its current visual scale before a duration-changing
|
|
158
172
|
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
@@ -210,6 +224,16 @@ interface PlayerState {
|
|
|
210
224
|
requestSeek: (time: number) => void;
|
|
211
225
|
clearSeekRequest: () => void;
|
|
212
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Request the timeline to scroll a clip into view (e.g. clicking an
|
|
229
|
+
* already-added asset card in the sidebar). Consumed and cleared by
|
|
230
|
+
* useTimelineRevealClip. The nonce makes repeat requests for the same
|
|
231
|
+
* clip observable so a second click re-reveals after the user scrolls away.
|
|
232
|
+
*/
|
|
233
|
+
clipRevealRequest: { elementId: string; nonce: number } | null;
|
|
234
|
+
requestClipReveal: (elementId: string) => void;
|
|
235
|
+
clearClipRevealRequest: () => void;
|
|
236
|
+
|
|
213
237
|
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
|
214
238
|
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
|
215
239
|
|
|
@@ -341,6 +365,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
341
365
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
342
366
|
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
|
343
367
|
|
|
368
|
+
clipRevealRequest: null,
|
|
369
|
+
requestClipReveal: (elementId) =>
|
|
370
|
+
set((s) => ({
|
|
371
|
+
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
|
|
372
|
+
})),
|
|
373
|
+
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
|
|
374
|
+
|
|
344
375
|
lintFindingsByElement: new Map(),
|
|
345
376
|
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
|
346
377
|
|
|
@@ -416,6 +447,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
416
447
|
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
|
417
448
|
set({ timelineSnapEnabled: enabled });
|
|
418
449
|
},
|
|
450
|
+
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
|
|
451
|
+
setTimeDisplayMode: (mode) => {
|
|
452
|
+
writeStudioUiPreferences({ timeDisplayMode: mode });
|
|
453
|
+
set({ timeDisplayMode: mode });
|
|
454
|
+
},
|
|
419
455
|
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
|
|
420
456
|
set((s) => {
|
|
421
457
|
// Already pinned (or the user manually zoomed) — never clobber that.
|
|
@@ -522,6 +558,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
522
558
|
activeTool: "select",
|
|
523
559
|
selectedKeyframes: new Set(),
|
|
524
560
|
selectedElementIds: new Set(),
|
|
561
|
+
clipRevealRequest: null,
|
|
525
562
|
keyframeCache: new Map(),
|
|
526
563
|
beatAnalysis: null,
|
|
527
564
|
beatEdits: null,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
|
|
3
|
+
|
|
4
|
+
describe("shouldDismissAssetPreview", () => {
|
|
5
|
+
const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
|
|
6
|
+
|
|
7
|
+
it("keeps the preview open while nothing moves", () => {
|
|
8
|
+
expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("tolerates sub-epsilon float noise in currentTime echoes", () => {
|
|
12
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("dismisses when playback starts", () => {
|
|
16
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
|
|
20
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
|
|
21
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("dismisses on a pending out-of-loop seek request", () => {
|
|
25
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
|
|
3
|
+
* the preview is a transient "look at this asset" state, so any playhead
|
|
4
|
+
* activity — starting playback, or seeking/scrubbing away from where the
|
|
5
|
+
* playhead sat when the preview opened — hands focus back to the canvas and
|
|
6
|
+
* closes it.
|
|
7
|
+
*
|
|
8
|
+
* Pure — unit-tested. The overlay captures `openedTime` when the preview
|
|
9
|
+
* opens and feeds every subsequent player-store snapshot through this.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AssetPreviewDismissSnapshot {
|
|
13
|
+
isPlaying: boolean;
|
|
14
|
+
currentTime: number;
|
|
15
|
+
/** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
|
|
16
|
+
requestedSeekTime: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Tolerance for float noise in currentTime echoes (well under one frame). */
|
|
20
|
+
const TIME_EPSILON_S = 1e-6;
|
|
21
|
+
|
|
22
|
+
export function shouldDismissAssetPreview(
|
|
23
|
+
openedTime: number,
|
|
24
|
+
snapshot: AssetPreviewDismissSnapshot,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (snapshot.isPlaying) return true;
|
|
27
|
+
if (snapshot.requestedSeekTime !== null) return true;
|
|
28
|
+
return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
|
|
29
|
+
}
|
|
@@ -1,13 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
1
2
|
import { describe, expect, it } from "vitest";
|
|
2
3
|
import {
|
|
3
4
|
buildTimelineFileDropPlacements,
|
|
4
5
|
buildTimelineAssetInsertHtml,
|
|
6
|
+
extendCompositionDurationIfNeeded,
|
|
7
|
+
fitTimelineAssetGeometry,
|
|
5
8
|
getTimelineAssetKind,
|
|
6
9
|
insertTimelineAssetIntoSource,
|
|
7
|
-
|
|
10
|
+
resolveTimelineAssetCompositionSize,
|
|
8
11
|
resolveTimelineAssetSrc,
|
|
12
|
+
setCompositionDurationToContent,
|
|
9
13
|
} from "./timelineAssetDrop";
|
|
10
14
|
|
|
15
|
+
describe("setCompositionDurationToContent", () => {
|
|
16
|
+
const src = (dur: number) =>
|
|
17
|
+
`<div id="root" data-composition-id="c" data-duration="${dur}">x</div>`;
|
|
18
|
+
|
|
19
|
+
it("shrinks the root duration to the content end", () => {
|
|
20
|
+
expect(setCompositionDurationToContent(src(20), 8)).toContain('data-duration="8"');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("grows the root duration to the content end", () => {
|
|
24
|
+
expect(setCompositionDurationToContent(src(5), 12)).toContain('data-duration="12"');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is a no-op when content end is 0 (empty timeline keeps its declared length)", () => {
|
|
28
|
+
expect(setCompositionDurationToContent(src(12), 0)).toBe(src(12));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is a no-op when already equal", () => {
|
|
32
|
+
expect(setCompositionDurationToContent(src(9), 9)).toBe(src(9));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Reviewer round-2 finding #3: attribute-order and single-quote variants that
|
|
36
|
+
// the old order-dependent, double-quotes-only regex silently ignored.
|
|
37
|
+
it("patches when data-duration precedes data-composition-id", () => {
|
|
38
|
+
const source = `<div data-duration="20" data-composition-id="c">x</div>`;
|
|
39
|
+
expect(setCompositionDurationToContent(source, 8)).toBe(
|
|
40
|
+
`<div data-duration="8" data-composition-id="c">x</div>`,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("patches single-quoted attributes and keeps the quote style", () => {
|
|
45
|
+
const source = `<div data-composition-id='c' data-duration='20'>x</div>`;
|
|
46
|
+
expect(setCompositionDurationToContent(source, 8)).toBe(
|
|
47
|
+
`<div data-composition-id='c' data-duration='8'>x</div>`,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("extendCompositionDurationIfNeeded", () => {
|
|
53
|
+
it("grows the root duration when a clip lands past the end", () => {
|
|
54
|
+
const source = `<div data-composition-id="c" data-duration="5">x</div>`;
|
|
55
|
+
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
|
|
56
|
+
`<div data-composition-id="c" data-duration="8">x</div>`,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("is a no-op when the required end fits within the current duration", () => {
|
|
61
|
+
const source = `<div data-composition-id="c" data-duration="10">x</div>`;
|
|
62
|
+
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("grows even when the attribute order is swapped and quotes are single", () => {
|
|
66
|
+
const source = `<div data-duration='5' data-composition-id='c'>x</div>`;
|
|
67
|
+
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(
|
|
68
|
+
`<div data-duration='8' data-composition-id='c'>x</div>`,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is a no-op when there is no composition root", () => {
|
|
73
|
+
const source = `<div data-duration="5">x</div>`;
|
|
74
|
+
expect(extendCompositionDurationIfNeeded(source, 8)).toBe(source);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
11
78
|
describe("getTimelineAssetKind", () => {
|
|
12
79
|
it("detects image, video, and audio assets", () => {
|
|
13
80
|
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
|
@@ -16,12 +83,28 @@ describe("getTimelineAssetKind", () => {
|
|
|
16
83
|
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
|
|
17
84
|
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
|
18
85
|
});
|
|
86
|
+
|
|
87
|
+
it("classifies svg as image", () => {
|
|
88
|
+
expect(getTimelineAssetKind("assets/logo.svg")).toBe("image");
|
|
89
|
+
expect(getTimelineAssetKind("assets/ICON.SVG")).toBe("image");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("classifies avif and webp as image", () => {
|
|
93
|
+
expect(getTimelineAssetKind("assets/photo.avif")).toBe("image");
|
|
94
|
+
expect(getTimelineAssetKind("assets/photo.webp")).toBe("image");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns null for unknown extensions", () => {
|
|
98
|
+
expect(getTimelineAssetKind("assets/data.json")).toBeNull();
|
|
99
|
+
expect(getTimelineAssetKind("assets/font.woff2")).toBeNull();
|
|
100
|
+
});
|
|
19
101
|
});
|
|
20
102
|
|
|
21
103
|
describe("buildTimelineAssetInsertHtml", () => {
|
|
22
104
|
it("builds an image clip with explicit timing and track", () => {
|
|
23
105
|
const html = buildTimelineAssetInsertHtml({
|
|
24
106
|
id: "photo_asset",
|
|
107
|
+
hfId: "hf-abc123",
|
|
25
108
|
assetPath: "assets/photo.png",
|
|
26
109
|
kind: "image",
|
|
27
110
|
start: 1.25,
|
|
@@ -40,6 +123,7 @@ describe("buildTimelineAssetInsertHtml", () => {
|
|
|
40
123
|
it("builds an audio clip without visual layout styles", () => {
|
|
41
124
|
const html = buildTimelineAssetInsertHtml({
|
|
42
125
|
id: "music_asset",
|
|
126
|
+
hfId: "hf-xyz789",
|
|
43
127
|
assetPath: "assets/music.wav",
|
|
44
128
|
kind: "audio",
|
|
45
129
|
start: 0.5,
|
|
@@ -52,15 +136,13 @@ describe("buildTimelineAssetInsertHtml", () => {
|
|
|
52
136
|
});
|
|
53
137
|
});
|
|
54
138
|
|
|
55
|
-
describe("
|
|
139
|
+
describe("resolveTimelineAssetCompositionSize", () => {
|
|
56
140
|
it("uses the target composition dimensions for visual media", () => {
|
|
57
141
|
expect(
|
|
58
|
-
|
|
142
|
+
resolveTimelineAssetCompositionSize(
|
|
59
143
|
`<div data-composition-id="main" data-width="330" data-height="228"></div>`,
|
|
60
144
|
),
|
|
61
145
|
).toEqual({
|
|
62
|
-
left: 0,
|
|
63
|
-
top: 0,
|
|
64
146
|
width: 330,
|
|
65
147
|
height: 228,
|
|
66
148
|
});
|
|
@@ -84,7 +166,9 @@ describe("buildTimelineFileDropPlacements", () => {
|
|
|
84
166
|
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
|
85
167
|
});
|
|
86
168
|
|
|
87
|
-
it("
|
|
169
|
+
it("spaces multiple files by duration and keeps every one on the dropped track", () => {
|
|
170
|
+
// A clip placed onto an occupied track stays there (overlap is allowed); it is
|
|
171
|
+
// NOT bumped to a new track — that produced surprise empty tracks for users.
|
|
88
172
|
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
|
|
89
173
|
{ start: 1.5, track: 2 },
|
|
90
174
|
{ start: 2.7, track: 2 },
|
|
@@ -99,52 +183,6 @@ describe("buildTimelineFileDropPlacements", () => {
|
|
|
99
183
|
{ start: 7.7, track: 2 },
|
|
100
184
|
]);
|
|
101
185
|
});
|
|
102
|
-
|
|
103
|
-
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
|
|
104
|
-
expect(
|
|
105
|
-
buildTimelineFileDropPlacements(
|
|
106
|
-
{ start: 1.5, track: 2 },
|
|
107
|
-
[1.2, 1.6, 1.1],
|
|
108
|
-
[
|
|
109
|
-
{ start: 0, duration: 8, track: 2 },
|
|
110
|
-
{ start: 0, duration: 4, track: 5 },
|
|
111
|
-
],
|
|
112
|
-
),
|
|
113
|
-
).toEqual([
|
|
114
|
-
{ start: 1.5, track: 6 },
|
|
115
|
-
{ start: 2.7, track: 6 },
|
|
116
|
-
{ start: 4.3, track: 6 },
|
|
117
|
-
]);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("keeps a requested track above occupied rows when that track is clear", () => {
|
|
121
|
-
expect(
|
|
122
|
-
buildTimelineFileDropPlacements(
|
|
123
|
-
{ start: 1.5, track: 8 },
|
|
124
|
-
[1.2, 1.6],
|
|
125
|
-
[
|
|
126
|
-
{ start: 0, duration: 8, track: 2 },
|
|
127
|
-
{ start: 0, duration: 4, track: 5 },
|
|
128
|
-
],
|
|
129
|
-
),
|
|
130
|
-
).toEqual([
|
|
131
|
-
{ start: 1.5, track: 8 },
|
|
132
|
-
{ start: 2.7, track: 8 },
|
|
133
|
-
]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
|
|
137
|
-
expect(
|
|
138
|
-
buildTimelineFileDropPlacements(
|
|
139
|
-
{ start: 0, track: 0 },
|
|
140
|
-
[1.2, 1.6],
|
|
141
|
-
[{ start: 0, duration: 8, track: 0 }],
|
|
142
|
-
),
|
|
143
|
-
).toEqual([
|
|
144
|
-
{ start: 0, track: 1 },
|
|
145
|
-
{ start: 1.2, track: 1 },
|
|
146
|
-
]);
|
|
147
|
-
});
|
|
148
186
|
});
|
|
149
187
|
|
|
150
188
|
describe("insertTimelineAssetIntoSource", () => {
|
|
@@ -159,3 +197,57 @@ describe("insertTimelineAssetIntoSource", () => {
|
|
|
159
197
|
expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
|
|
160
198
|
});
|
|
161
199
|
});
|
|
200
|
+
|
|
201
|
+
describe("buildTimelineAssetInsertHtml markup quality", () => {
|
|
202
|
+
const base = {
|
|
203
|
+
id: "clip_1",
|
|
204
|
+
hfId: "hf-test-1",
|
|
205
|
+
assetPath: "assets/a.mp4",
|
|
206
|
+
start: 1,
|
|
207
|
+
duration: 4,
|
|
208
|
+
track: 2,
|
|
209
|
+
zIndex: 3,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
it("stamps data-hf-id on all kinds", () => {
|
|
213
|
+
for (const kind of ["image", "video", "audio"] as const) {
|
|
214
|
+
expect(buildTimelineAssetInsertHtml({ ...base, kind })).toContain('data-hf-id="hf-test-1"');
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("audio gets an explicit data-volume", () => {
|
|
219
|
+
expect(buildTimelineAssetInsertHtml({ ...base, kind: "audio" })).toContain('data-volume="1"');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("fitTimelineAssetGeometry", () => {
|
|
224
|
+
const comp = { width: 1920, height: 1080 };
|
|
225
|
+
|
|
226
|
+
it("centers a smaller-than-comp asset at natural size", () => {
|
|
227
|
+
expect(fitTimelineAssetGeometry({ width: 640, height: 360 }, comp)).toEqual({
|
|
228
|
+
left: 640,
|
|
229
|
+
top: 360,
|
|
230
|
+
width: 640,
|
|
231
|
+
height: 360,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("scales an oversized asset down to fit, preserving aspect, centered", () => {
|
|
236
|
+
// 4000x1000 → capped to 1920 wide → 1920x480, centered vertically
|
|
237
|
+
expect(fitTimelineAssetGeometry({ width: 4000, height: 1000 }, comp)).toEqual({
|
|
238
|
+
left: 0,
|
|
239
|
+
top: 300,
|
|
240
|
+
width: 1920,
|
|
241
|
+
height: 480,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("falls back to full-frame when natural size is unknown", () => {
|
|
246
|
+
expect(fitTimelineAssetGeometry(null, comp)).toEqual({
|
|
247
|
+
left: 0,
|
|
248
|
+
top: 0,
|
|
249
|
+
width: 1920,
|
|
250
|
+
height: 1080,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|