@hyperframes/studio 0.7.54 → 0.7.55

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 (35) hide show
  1. package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. package/src/utils/timelineInspector.ts +32 -1
@@ -0,0 +1,31 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { __resetForTests, acquireCanvasNudgeKeys, canvasNudgeKeysClaimed } from "./canvasNudgeGate";
3
+
4
+ describe("canvasNudgeGate", () => {
5
+ // The claim counter is module-level state; reset it so an unbalanced claim in one
6
+ // test can't leak into the next.
7
+ beforeEach(() => {
8
+ __resetForTests();
9
+ });
10
+
11
+ it("reports claimed while at least one claim is held", () => {
12
+ expect(canvasNudgeKeysClaimed()).toBe(false);
13
+ const releaseA = acquireCanvasNudgeKeys();
14
+ const releaseB = acquireCanvasNudgeKeys();
15
+ expect(canvasNudgeKeysClaimed()).toBe(true);
16
+ releaseA();
17
+ expect(canvasNudgeKeysClaimed()).toBe(true);
18
+ releaseB();
19
+ expect(canvasNudgeKeysClaimed()).toBe(false);
20
+ });
21
+
22
+ it("makes release idempotent so an effect re-run cannot underflow", () => {
23
+ const release = acquireCanvasNudgeKeys();
24
+ release();
25
+ release();
26
+ expect(canvasNudgeKeysClaimed()).toBe(false);
27
+ const releaseNext = acquireCanvasNudgeKeys();
28
+ expect(canvasNudgeKeysClaimed()).toBe(true);
29
+ releaseNext();
30
+ });
31
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Arrow-key ownership gate between the canvas nudge (DomEditOverlay) and the
3
+ * playback frame-step shortcuts (usePlaybackKeyboard). Both listen for arrow
4
+ * keys with window capture listeners, and their relative order depends on
5
+ * mount order (DomEditOverlay remounts when caption edit mode toggles), so
6
+ * `event.defaultPrevented` alone can't arbitrate. While a nudgeable canvas
7
+ * selection holds a claim, the playback handler skips ArrowLeft/ArrowRight.
8
+ */
9
+
10
+ let claims = 0;
11
+
12
+ /** Claim the arrow keys for canvas nudging. Returns an idempotent release. */
13
+ export function acquireCanvasNudgeKeys(): () => void {
14
+ claims += 1;
15
+ let released = false;
16
+ return () => {
17
+ if (released) return;
18
+ released = true;
19
+ claims -= 1;
20
+ };
21
+ }
22
+
23
+ /** True while a nudgeable canvas selection owns the arrow keys. */
24
+ export function canvasNudgeKeysClaimed(): boolean {
25
+ return claims > 0;
26
+ }
27
+
28
+ /** Test-only: reset the module-level claim counter so leaked claims from one test
29
+ * can't bleed into the next (call in a `beforeEach`). */
30
+ export function __resetForTests(): void {
31
+ claims = 0;
32
+ }
@@ -50,3 +50,41 @@ describe("studio UI preferences", () => {
50
50
  });
51
51
  });
52
52
  });
53
+
54
+ describe("timelineSnapEnabled preference", () => {
55
+ it("round-trips through storage", () => {
56
+ const storage = createStorage();
57
+ writeStudioUiPreferences({ timelineSnapEnabled: false }, storage);
58
+ expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBe(false);
59
+ });
60
+
61
+ it("ignores non-boolean values", () => {
62
+ const storage = createStorage();
63
+ storage.setItem("hf-studio-ui-preferences", JSON.stringify({ timelineSnapEnabled: "yes" }));
64
+ expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe("timeline zoom pin persistence", () => {
69
+ it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
70
+ const storage = createStorage();
71
+ writeStudioUiPreferences(
72
+ { timelineZoomMode: "manual", timelineManualZoomPercent: 250 },
73
+ storage,
74
+ );
75
+ const prefs = readStudioUiPreferences(storage);
76
+ expect(prefs.timelineZoomMode).toBe("manual");
77
+ expect(prefs.timelineManualZoomPercent).toBe(250);
78
+ });
79
+
80
+ it("ignores an invalid zoom mode and a non-finite percent", () => {
81
+ const storage = createStorage();
82
+ storage.setItem(
83
+ "hf-studio-ui-preferences",
84
+ JSON.stringify({ timelineZoomMode: "zoomy", timelineManualZoomPercent: "big" }),
85
+ );
86
+ const prefs = readStudioUiPreferences(storage);
87
+ expect(prefs.timelineZoomMode).toBeUndefined();
88
+ expect(prefs.timelineManualZoomPercent).toBeUndefined();
89
+ });
90
+ });
@@ -16,6 +16,18 @@ export interface StudioUiPreferences {
16
16
  gridVisible?: boolean;
17
17
  gridSpacing?: number;
18
18
  snapToGrid?: boolean;
19
+ /** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
20
+ timelineSnapEnabled?: boolean;
21
+ /** Transport + ruler readout mode: timecode or frame number. */
22
+ timeDisplayMode?: "time" | "frame";
23
+ /**
24
+ * Timeline zoom mode. Persisted so a zoom PINNED on the first edit survives the
25
+ * post-edit iframe reload — otherwise the store reset to "fit" and the duration
26
+ * change rescaled every clip (the blink-fix's rescale symptom).
27
+ */
28
+ timelineZoomMode?: "fit" | "manual";
29
+ /** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
30
+ timelineManualZoomPercent?: number;
19
31
  }
20
32
 
21
33
  const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -88,6 +100,21 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
88
100
  if (typeof parsed.snapToGrid === "boolean") {
89
101
  preferences.snapToGrid = parsed.snapToGrid;
90
102
  }
103
+ if (typeof parsed.timelineSnapEnabled === "boolean") {
104
+ preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
105
+ }
106
+ if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
107
+ preferences.timeDisplayMode = parsed.timeDisplayMode;
108
+ }
109
+ if (parsed.timelineZoomMode === "fit" || parsed.timelineZoomMode === "manual") {
110
+ preferences.timelineZoomMode = parsed.timelineZoomMode;
111
+ }
112
+ if (
113
+ typeof parsed.timelineManualZoomPercent === "number" &&
114
+ Number.isFinite(parsed.timelineManualZoomPercent)
115
+ ) {
116
+ preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
117
+ }
91
118
  return preferences;
92
119
  } catch {
93
120
  return {};
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isAudioTimelineElement, isMusicTrack, resolveBeatSourceTrack } from "./timelineInspector";
3
+ import type { TimelineElement } from "../player";
4
+
5
+ // Minimal element factory for tests
6
+ function el(
7
+ overrides: Partial<
8
+ Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration">
9
+ >,
10
+ ): Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"> {
11
+ return {
12
+ tag: "audio",
13
+ src: "assets/track.mp3",
14
+ id: "el-1",
15
+ domId: "el-1",
16
+ timelineRole: undefined,
17
+ duration: 10,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ describe("isAudioTimelineElement", () => {
23
+ it("is true for audio tag", () => {
24
+ expect(isAudioTimelineElement(el({ tag: "audio" }))).toBe(true);
25
+ });
26
+
27
+ it("is true for music/sfx/narration semantic tags", () => {
28
+ expect(isAudioTimelineElement(el({ tag: "music" }))).toBe(true);
29
+ expect(isAudioTimelineElement(el({ tag: "sfx" }))).toBe(true);
30
+ expect(isAudioTimelineElement(el({ tag: "narration" }))).toBe(true);
31
+ });
32
+
33
+ it("is true for img/div with an audio src extension", () => {
34
+ expect(isAudioTimelineElement(el({ tag: "div", src: "assets/bg.mp3" }))).toBe(true);
35
+ expect(isAudioTimelineElement(el({ tag: "div", src: "assets/fx.wav" }))).toBe(true);
36
+ });
37
+
38
+ it("is false for video and image elements", () => {
39
+ expect(isAudioTimelineElement(el({ tag: "video", src: "assets/clip.mp4" }))).toBe(false);
40
+ expect(isAudioTimelineElement(el({ tag: "img", src: "assets/logo.svg" }))).toBe(false);
41
+ });
42
+
43
+ it("returns false for null/undefined", () => {
44
+ expect(isAudioTimelineElement(null)).toBe(false);
45
+ expect(isAudioTimelineElement(undefined)).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("isMusicTrack", () => {
50
+ it("is true when timelineRole is 'music'", () => {
51
+ expect(isMusicTrack(el({ timelineRole: "music" }))).toBe(true);
52
+ });
53
+
54
+ it("is false for explicit non-music roles", () => {
55
+ expect(isMusicTrack(el({ timelineRole: "sfx" }))).toBe(false);
56
+ expect(isMusicTrack(el({ timelineRole: "voiceover" }))).toBe(false);
57
+ });
58
+
59
+ it("matches music-like ids when no role is set", () => {
60
+ expect(isMusicTrack(el({ domId: "bgm", timelineRole: undefined }))).toBe(true);
61
+ expect(isMusicTrack(el({ domId: "background_music", timelineRole: undefined }))).toBe(true);
62
+ expect(isMusicTrack(el({ domId: "soundtrack", timelineRole: undefined }))).toBe(true);
63
+ });
64
+
65
+ it("is false for generic ids with no role", () => {
66
+ expect(isMusicTrack(el({ domId: "my_audio_file", timelineRole: undefined }))).toBe(false);
67
+ expect(isMusicTrack(el({ domId: "drop_1", timelineRole: undefined }))).toBe(false);
68
+ });
69
+
70
+ it("is false for non-audio elements even with a music-like id", () => {
71
+ expect(isMusicTrack(el({ tag: "img", src: "assets/logo.svg", domId: "bgm" }))).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("resolveBeatSourceTrack", () => {
76
+ it("returns null when there are no elements", () => {
77
+ expect(resolveBeatSourceTrack([])).toBeNull();
78
+ });
79
+
80
+ it("returns null when there are no audio elements", () => {
81
+ const elements = [el({ tag: "img", src: "assets/logo.png" })];
82
+ expect(resolveBeatSourceTrack(elements)).toBeNull();
83
+ });
84
+
85
+ it("returns isFallback=false for an explicitly tagged music track", () => {
86
+ const music = el({ timelineRole: "music" });
87
+ const result = resolveBeatSourceTrack([music]);
88
+ expect(result).not.toBeNull();
89
+ expect(result!.isFallback).toBe(false);
90
+ expect(result!.element).toBe(music);
91
+ });
92
+
93
+ it("prefers the explicit music track over a longer untagged clip", () => {
94
+ const music = el({ timelineRole: "music", duration: 30 });
95
+ const other = el({ id: "drop_1", domId: "drop_1", timelineRole: undefined, duration: 120 });
96
+ const result = resolveBeatSourceTrack([music, other]);
97
+ expect(result!.element).toBe(music);
98
+ expect(result!.isFallback).toBe(false);
99
+ });
100
+
101
+ it("falls back to the longest untagged audio clip when no music track exists", () => {
102
+ const short = el({ id: "drop_1", domId: "drop_1", duration: 5, timelineRole: undefined });
103
+ const long = el({ id: "drop_2", domId: "drop_2", duration: 60, timelineRole: undefined });
104
+ const result = resolveBeatSourceTrack([short, long]);
105
+ expect(result).not.toBeNull();
106
+ expect(result!.isFallback).toBe(true);
107
+ expect(result!.element).toBe(long);
108
+ });
109
+
110
+ it("excludes explicitly non-music roles (sfx, voiceover) from the fallback", () => {
111
+ const sfx = el({ id: "sfx_1", domId: "sfx_1", timelineRole: "sfx", duration: 30 });
112
+ const vo = el({ id: "vo_1", domId: "vo_1", timelineRole: "voiceover", duration: 20 });
113
+ expect(resolveBeatSourceTrack([sfx, vo])).toBeNull();
114
+ });
115
+
116
+ it("returns isFallback=true for an untagged clip with a non-music id", () => {
117
+ const clip = el({ id: "my_audio_file", domId: "my_audio_file", timelineRole: undefined });
118
+ const result = resolveBeatSourceTrack([clip]);
119
+ expect(result!.isFallback).toBe(true);
120
+ });
121
+ });
@@ -4,7 +4,7 @@ const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narratio
4
4
  const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i;
5
5
  const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
6
6
 
7
- function isAudioTimelineElement(
7
+ export function isAudioTimelineElement(
8
8
  element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
9
9
  ): boolean {
10
10
  if (!element) return false;
@@ -29,3 +29,34 @@ export function isMusicTrack(
29
29
  const id = element.domId ?? element.id ?? "";
30
30
  return MUSIC_ID_RE.test(id);
31
31
  }
32
+
33
+ /**
34
+ * Resolve the best audio source for beat analysis. An explicitly tagged or
35
+ * named music track wins; when none is present (e.g. an audio file dropped
36
+ * from Finder with a generic id), the LONGEST untagged audio clip is used as a
37
+ * fallback. Ties on duration resolve to the FIRST such clip encountered (the loop
38
+ * keeps the current best on `>` only), i.e. discovery/DOM order wins.
39
+ * Returns the element and whether it was found via the fallback path.
40
+ *
41
+ * The `isMusicTrack` predicate is unchanged so beat-snap and drag-exclusion
42
+ * logic remain unaffected by this fallback.
43
+ */
44
+ export function resolveBeatSourceTrack(
45
+ elements: readonly Pick<
46
+ TimelineElement,
47
+ "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"
48
+ >[],
49
+ ): { element: (typeof elements)[number]; isFallback: boolean } | null {
50
+ const explicit = elements.find(isMusicTrack);
51
+ if (explicit) return { element: explicit, isFallback: false };
52
+
53
+ // Fallback: pick the longest audio clip (skipping explicitly non-music roles
54
+ // like "sfx" or "voiceover" to avoid triggering beat analysis on those).
55
+ let best: (typeof elements)[number] | null = null;
56
+ for (const el of elements) {
57
+ if (!isAudioTimelineElement(el)) continue;
58
+ if (el.timelineRole && el.timelineRole !== "music") continue;
59
+ if (!best || el.duration > best.duration) best = el;
60
+ }
61
+ return best ? { element: best, isFallback: true } : null;
62
+ }