@hyperframes/studio 0.7.56 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -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?.postMessage(
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
- expect(state.isPlaying).toBe(false);
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
- const state = usePlayerStore.getState();
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
- resolveTimelineAssetInitialGeometry,
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("resolveTimelineAssetInitialGeometry", () => {
139
+ describe("resolveTimelineAssetCompositionSize", () => {
56
140
  it("uses the target composition dimensions for visual media", () => {
57
141
  expect(
58
- resolveTimelineAssetInitialGeometry(
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("uses the dropped start and spaces multiple files by duration on the same track", () => {
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
+ });