@hyperframes/studio 0.8.122 → 0.8.124

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 (29) hide show
  1. package/dist/assets/{hyperframes-player-DMzBHhbH.js → hyperframes-player-ColOZ_78.js} +1 -1
  2. package/dist/assets/{index-B-YDp8hN.js → index-BuSTrK-Z.js} +146 -146
  3. package/dist/assets/{index-CBRX2eZX.js → index-CVuUfURE.js} +1 -1
  4. package/dist/assets/{index-t1LH6H5H.css → index-D11atkBt.css} +1 -1
  5. package/dist/assets/{index-lyjhSEzi.js → index-jQEqFKMk.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1250 -1043
  8. package/dist/index.js.map +1 -1
  9. package/dist/styles.css +1 -1
  10. package/package.json +8 -8
  11. package/src/components/FrameyGlyph.tsx +52 -0
  12. package/src/components/OpenInDesktopButton.dom.test.tsx +130 -0
  13. package/src/components/OpenInDesktopButton.tsx +160 -0
  14. package/src/components/StudioHeader.dom.test.tsx +2 -0
  15. package/src/components/StudioHeader.tsx +2 -0
  16. package/src/player/components/AudibleVideoClipContent.test.tsx +67 -0
  17. package/src/player/components/AudibleVideoClipContent.tsx +5 -4
  18. package/src/player/components/AudioWaveform.tsx +2 -1
  19. package/src/player/components/ClipPeakMarks.tsx +5 -2
  20. package/src/player/components/OutOfSyncBadge.tsx +1 -1
  21. package/src/player/components/Timeline.trackPadding.test.tsx +1 -3
  22. package/src/player/components/TimelineCanvas.tsx +1 -0
  23. package/src/player/components/TimelineClip.filmstrip.test.tsx +27 -0
  24. package/src/player/components/TimelineClipFades.test.tsx +2 -1
  25. package/src/player/components/timelineClipDragPreview.test.ts +31 -0
  26. package/src/player/components/timelineClipDragPreview.ts +42 -37
  27. package/src/player/hooks/useTimelinePlayer.seek.test.ts +11 -0
  28. package/src/styles/components.css +82 -1
  29. package/src/styles/theme.css +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.122",
3
+ "version": "0.8.124",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.122",
64
- "@hyperframes/sdk": "0.8.122",
65
- "@hyperframes/core": "0.8.122",
66
- "@hyperframes/shader-transitions": "0.8.122",
67
- "@hyperframes/studio-server": "0.8.122",
68
- "@hyperframes/player": "0.8.122"
63
+ "@hyperframes/core": "0.8.124",
64
+ "@hyperframes/player": "0.8.124",
65
+ "@hyperframes/sdk": "0.8.124",
66
+ "@hyperframes/parsers": "0.8.124",
67
+ "@hyperframes/shader-transitions": "0.8.124",
68
+ "@hyperframes/studio-server": "0.8.124"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.122"
84
+ "@hyperframes/producer": "0.8.124"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -0,0 +1,52 @@
1
+ import type { Ref } from "react";
2
+
3
+ // Framey, the desktop app's cursor mascot, as hyperframes-internal packages/nib draws it: a rounded triangle with a
4
+ // white rim and one eye. The viewBox frames the body with its rim; the tip points up and left like a cursor.
5
+ const BODY = "M77.55 48.44 L122.77 65.85 L99.3 89.41 Z";
6
+ const EYE = { cx: 111.6, cy: 68.2, r: 11.5 };
7
+
8
+ /** `eyeRef` moves the pupils (a translate of a few px) without re-rendering. */
9
+ export function FrameyGlyph({
10
+ size,
11
+ className,
12
+ eyeRef,
13
+ }: {
14
+ size: number;
15
+ className?: string;
16
+ eyeRef?: Ref<SVGGElement>;
17
+ }) {
18
+ return (
19
+ <svg
20
+ width={size}
21
+ height={size}
22
+ viewBox="58 30 82 76"
23
+ aria-hidden="true"
24
+ className={className}
25
+ overflow="visible"
26
+ >
27
+ <path
28
+ d={BODY}
29
+ fill="var(--color-white)"
30
+ stroke="var(--color-white)"
31
+ strokeWidth={24}
32
+ strokeLinejoin="round"
33
+ />
34
+ <path
35
+ d={BODY}
36
+ fill="var(--color-framey)"
37
+ stroke="var(--color-framey)"
38
+ strokeWidth={19}
39
+ strokeLinejoin="round"
40
+ />
41
+ <circle cx={EYE.cx} cy={EYE.cy} r={EYE.r} fill="var(--color-white)" />
42
+ <g ref={eyeRef} className="hf-framey-eye">
43
+ <path
44
+ d={`M${EYE.cx - 2.6} ${EYE.cy - 2} V${EYE.cy + 2} M${EYE.cx + 2.6} ${EYE.cy - 2} V${EYE.cy + 2}`}
45
+ stroke="var(--color-framey-ink)"
46
+ strokeWidth={2.4}
47
+ strokeLinecap="round"
48
+ />
49
+ </g>
50
+ </svg>
51
+ );
52
+ }
@@ -0,0 +1,130 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * "Edit with Framey": shown where the preview server has the app or its download to offer; a press opens the project in the app,
5
+ * or introduces Framey with the download while the app cannot take it or is missing.
6
+ */
7
+ import React, { act } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ const showToast = vi.fn();
14
+ vi.mock("../contexts/StudioContext", () => ({ useStudioShellContext: () => ({ showToast }) }));
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
+
17
+ const { OpenInDesktopButton } = await import("./OpenInDesktopButton");
18
+
19
+ const DOWNLOAD = "https://hyperframes.dev/studio/download";
20
+ let mounted: { root: Root; host: HTMLElement } | null = null;
21
+ let posts: number;
22
+
23
+ function serve(get: Response | null, post?: unknown) {
24
+ posts = 0;
25
+ vi.stubGlobal(
26
+ "fetch",
27
+ vi.fn(async (_url: string, init?: RequestInit) => {
28
+ if (init?.method === "POST") {
29
+ posts += 1;
30
+ return Response.json(post);
31
+ }
32
+ return get ?? new Response("Not found", { status: 404 });
33
+ }),
34
+ );
35
+ }
36
+
37
+ async function mount(): Promise<HTMLElement> {
38
+ const host = document.createElement("div");
39
+ document.body.append(host);
40
+ const root = createRoot(host);
41
+ mounted = { root, host };
42
+ await act(async () => root.render(<OpenInDesktopButton />));
43
+ return host;
44
+ }
45
+
46
+ const downloadHref = () =>
47
+ [...document.querySelectorAll("a")]
48
+ .find((a) => a.textContent?.includes("Get the desktop app"))
49
+ ?.getAttribute("href");
50
+
51
+ const button = () =>
52
+ document.querySelector<HTMLButtonElement>('[data-testid="header-open-in-desktop"]');
53
+
54
+ beforeEach(() => showToast.mockReset());
55
+
56
+ afterEach(() => {
57
+ vi.unstubAllGlobals();
58
+ if (!mounted) return;
59
+ const { root, host } = mounted;
60
+ mounted = null;
61
+ act(() => root.unmount());
62
+ host.remove();
63
+ });
64
+
65
+ it("stays hidden where the server has no hand-off (the desktop's own Studio)", async () => {
66
+ serve(null);
67
+ await mount();
68
+ expect(button()).toBeNull();
69
+ });
70
+
71
+ it("stays hidden when the app has no build for this machine (Windows)", async () => {
72
+ serve(Response.json({ available: false, handoff: true, downloadUrl: null }));
73
+ await mount();
74
+ expect(button()).toBeNull();
75
+ });
76
+
77
+ it("with the app installed but no download (Windows), shows only once the app can take the project", async () => {
78
+ serve(Response.json({ available: true, handoff: false, downloadUrl: null }));
79
+ await mount();
80
+ expect(button()).toBeNull();
81
+ });
82
+
83
+ it("opens the project in an installed app that has no download (Windows)", async () => {
84
+ serve(Response.json({ available: true, handoff: true, downloadUrl: null }), {
85
+ opened: true,
86
+ app: "the HyperFrames desktop app",
87
+ });
88
+ await mount();
89
+ await act(async () => button()!.click());
90
+ expect(posts).toBe(1);
91
+ });
92
+
93
+ it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
94
+ const linux = `${DOWNLOAD}?os=linux`;
95
+ serve(Response.json({ available: true, handoff: false, downloadUrl: linux }), { opened: true });
96
+ await mount();
97
+ await act(async () => button()!.click());
98
+ expect(posts).toBe(0);
99
+ expect(document.body.textContent).toContain("Meet Framey");
100
+ expect(downloadHref()).toBe(linux);
101
+ });
102
+
103
+ it("opens the project in the app and says so", async () => {
104
+ serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
105
+ opened: true,
106
+ app: "the HyperFrames desktop app",
107
+ });
108
+ await mount();
109
+ await act(async () => button()!.click());
110
+ expect(posts).toBe(1);
111
+ expect(showToast).toHaveBeenCalledWith(
112
+ "Framey is opening this project in the HyperFrames desktop app",
113
+ "info",
114
+ );
115
+ expect(button()!.dataset.opening).toBe("true");
116
+ expect(document.body.textContent).not.toContain("Meet Framey");
117
+ });
118
+
119
+ it("introduces Framey and offers the download when the app is missing", async () => {
120
+ serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
121
+ opened: false,
122
+ reason: "not-installed",
123
+ downloadUrl: DOWNLOAD,
124
+ });
125
+ await mount();
126
+ await act(async () => button()!.click());
127
+ expect(showToast).not.toHaveBeenCalled();
128
+ expect(document.body.textContent).toContain("Meet Framey");
129
+ expect(downloadHref()).toBe(DOWNLOAD);
130
+ });
@@ -0,0 +1,160 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useStudioShellContext } from "../contexts/StudioContext";
3
+ import { studioApiFetch } from "../utils/studioApiFetch";
4
+ import { trackStudioEvent } from "../utils/studioTelemetry";
5
+ import { FrameyGlyph } from "./FrameyGlyph";
6
+ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./ui";
7
+
8
+ // "Edit with Framey": the preview server (`/api/open-in-desktop`) says whether to show it and whether the app can
9
+ // take the project yet (its gate is the CLI's HANDOFF_READY); until then a press introduces Framey with the download.
10
+ const ROUTE = "/api/open-in-desktop";
11
+ /** How far the pupils travel towards the pointer, in the glyph's own units. */
12
+ const GAZE = 2.2;
13
+ /** Long enough for the flight to finish before the label comes back. */
14
+ const OPENING_MS = 1600;
15
+
16
+ type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
17
+
18
+ type DesktopRoute = { handoff: boolean; downloadUrl: string | null };
19
+
20
+ /** Null hides the button: no route (the app's own Studio), or nothing here to open or download. */
21
+ function useDesktopRoute(): DesktopRoute | null {
22
+ const [route, setRoute] = useState<DesktopRoute | null>(null);
23
+ useEffect(() => {
24
+ let live = true;
25
+ studioApiFetch(ROUTE)
26
+ .then((res) => (res.ok ? res.json() : null))
27
+ .then((body: { available?: unknown; handoff?: unknown; downloadUrl?: unknown } | null) => {
28
+ if (!live || body?.available !== true) return;
29
+ const handoff = body.handoff === true;
30
+ const downloadUrl = typeof body.downloadUrl === "string" ? body.downloadUrl : null;
31
+ if (handoff || downloadUrl) setRoute({ handoff, downloadUrl });
32
+ })
33
+ .catch(() => {});
34
+ return () => {
35
+ live = false;
36
+ };
37
+ }, []);
38
+ return route;
39
+ }
40
+
41
+ /** Framey looks at the pointer: one listener, one write per frame, no re-render. */
42
+ function useGaze(
43
+ anchor: React.RefObject<HTMLElement | null>,
44
+ eye: React.RefObject<SVGGElement | null>,
45
+ ) {
46
+ useEffect(() => {
47
+ let frame = 0;
48
+ const look = (event: PointerEvent) => {
49
+ cancelAnimationFrame(frame);
50
+ frame = requestAnimationFrame(() => {
51
+ const box = anchor.current?.getBoundingClientRect();
52
+ if (!box || !eye.current) return;
53
+ const dx = event.clientX - (box.left + box.height / 2);
54
+ const dy = event.clientY - (box.top + box.height / 2);
55
+ const distance = Math.hypot(dx, dy) || 1;
56
+ eye.current.style.transform = `translate(${(dx / distance) * GAZE}px, ${(dy / distance) * GAZE}px)`;
57
+ });
58
+ };
59
+ window.addEventListener("pointermove", look);
60
+ return () => {
61
+ cancelAnimationFrame(frame);
62
+ window.removeEventListener("pointermove", look);
63
+ };
64
+ }, [anchor, eye]);
65
+ }
66
+
67
+ export function OpenInDesktopButton() {
68
+ const { showToast } = useStudioShellContext();
69
+ const route = useDesktopRoute();
70
+ const [opening, setOpening] = useState(false);
71
+ // The card under the button, introducing Framey with the app's download.
72
+ const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
73
+ const trigger = useRef<HTMLButtonElement>(null);
74
+ const eye = useRef<SVGGElement>(null);
75
+ useGaze(trigger, eye);
76
+ if (!route) return null;
77
+
78
+ const open = async () => {
79
+ if (!route.handoff) {
80
+ trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
81
+ setDownloadUrl(route.downloadUrl);
82
+ return;
83
+ }
84
+ if (opening) return;
85
+ setOpening(true);
86
+ trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
87
+ try {
88
+ const res = await studioApiFetch(ROUTE, { method: "POST" });
89
+ const result = (await res.json()) as OpenResult;
90
+ if (result.opened) {
91
+ showToast("Framey is opening this project in the HyperFrames desktop app", "info");
92
+ await new Promise((done) => setTimeout(done, OPENING_MS));
93
+ } else setDownloadUrl(result.downloadUrl);
94
+ } catch {
95
+ showToast("Couldn't reach the preview server to open the app", "error");
96
+ } finally {
97
+ setOpening(false);
98
+ }
99
+ };
100
+
101
+ return (
102
+ <Popover
103
+ open={downloadUrl !== null}
104
+ // The popover owns the trigger's press: a press runs the hand-off, and the card shows only when there is
105
+ // something to say (the download). A second handler on the button raced this one.
106
+ onOpenChange={(next) => {
107
+ if (next) void open();
108
+ else setDownloadUrl(null);
109
+ }}
110
+ side="bottom"
111
+ align="end"
112
+ arrow
113
+ aria-label="Meet Framey"
114
+ className="w-72"
115
+ trigger={
116
+ <Button
117
+ ref={trigger}
118
+ variant="ghost"
119
+ data-testid="header-open-in-desktop"
120
+ data-opening={opening || undefined}
121
+ title="Open this project in the HyperFrames desktop app and edit it with Framey"
122
+ className="hf-framey-trigger gap-1.5 border border-accent/40 bg-accent/10 pl-1 font-semibold text-text-0 enabled:hover:bg-accent/20"
123
+ icon={<FrameyGlyph size={24} className="hf-framey" eyeRef={eye} />}
124
+ >
125
+ {opening ? "Opening in the app…" : "Edit with Framey"}
126
+ {!opening && (
127
+ <span className="rounded-full bg-accent px-1.5 text-[10px] leading-4 font-bold text-on-accent">
128
+ New
129
+ </span>
130
+ )}
131
+ </Button>
132
+ }
133
+ >
134
+ <div className="flex items-center gap-2.5">
135
+ <FrameyGlyph size={36} />
136
+ <p className="text-step-12 font-medium text-text-0">Meet Framey</p>
137
+ </div>
138
+ <p className="mt-2 text-balance text-text-3">I live in the free HyperFrames desktop app:</p>
139
+ <ul className="mt-1.5 grid gap-1 text-text-1">
140
+ <li>Chat to change anything in the video</li>
141
+ <li>Draw on a frame to point at it</li>
142
+ <li>Watch me act out each edit</li>
143
+ </ul>
144
+ <div className="mt-3 flex items-center justify-end gap-2 whitespace-nowrap">
145
+ <Button variant="ghost" onClick={() => setDownloadUrl(null)}>
146
+ Not now
147
+ </Button>
148
+ <a
149
+ href={downloadUrl ?? undefined}
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
153
+ onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
154
+ >
155
+ Get the desktop app
156
+ </a>
157
+ </div>
158
+ </Popover>
159
+ );
160
+ }
@@ -40,6 +40,8 @@ vi.mock("../contexts/PanelLayoutContext", () => ({
40
40
  }));
41
41
 
42
42
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
43
+ // Probes the preview server on mount; covered in OpenInDesktopButton.dom.test.tsx.
44
+ vi.mock("./OpenInDesktopButton", () => ({ OpenInDesktopButton: () => null }));
43
45
 
44
46
  const { StudioHeader } = await import("./StudioHeader");
45
47
  const { ShowThemeToggle } = await import("./ThemeToggle");
@@ -6,6 +6,7 @@ import { trackStudioEvent } from "../utils/studioTelemetry";
6
6
  import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
7
7
  import { Dock } from "./dock/Dock";
8
8
  import { InspectorIcon } from "./icons/InspectorIcon";
9
+ import { OpenInDesktopButton } from "./OpenInDesktopButton";
9
10
  import { HyperframesLogo } from "./ui/HyperframesLogo";
10
11
  import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle";
11
12
 
@@ -151,6 +152,7 @@ export const StudioHeader = memo(function StudioHeader({
151
152
  </div>
152
153
  {showThemeToggle && <ThemeToggle />}
153
154
  <Dock.WindowMenu />
155
+ <OpenInDesktopButton />
154
156
  <Tooltip
155
157
  label={
156
158
  ffmpegMissing
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+ import { createRequire } from "node:module";
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { act, type ReactNode } from "react";
6
+ import { compile } from "tailwindcss";
7
+ import { describe, expect, it } from "vitest";
8
+ import { createHappyDomRootHarness } from "./testRootHarness";
9
+ import { AudibleVideoClipContent } from "./AudibleVideoClipContent";
10
+
11
+ const harness = createHappyDomRootHarness();
12
+ const STYLES_DIR = path.resolve(__dirname, "../../styles");
13
+ const TAILWIND_DIR = path.dirname(
14
+ createRequire(import.meta.url).resolve("tailwindcss/package.json"),
15
+ );
16
+
17
+ async function studioCss() {
18
+ const load = async (id: string, base: string) => {
19
+ const file =
20
+ id === "tailwindcss" ? path.join(TAILWIND_DIR, "index.css") : path.resolve(base, id);
21
+ return { path: file, base: path.dirname(file), content: readFileSync(file, "utf8") };
22
+ };
23
+ const compiled = await compile(readFileSync(path.join(STYLES_DIR, "studio.css"), "utf8"), {
24
+ base: STYLES_DIR,
25
+ loadStylesheet: load,
26
+ });
27
+ return compiled.build([]);
28
+ }
29
+
30
+ async function renderStrip(wave: ReactNode) {
31
+ const host = document.createElement("div");
32
+ document.body.appendChild(host);
33
+ await act(async () => {
34
+ harness
35
+ .mount(host)
36
+ .render(<AudibleVideoClipContent thumbnail={<span>frames</span>} waveform={wave} />);
37
+ });
38
+ return host;
39
+ }
40
+
41
+ describe("AudibleVideoClipContent", () => {
42
+ it("draws the sound on the audio clip's own surface, under half the clip", async () => {
43
+ const host = await renderStrip(<span>wave</span>);
44
+ const strip = host.querySelector<HTMLElement>('[data-testid="audible-video-wave"]');
45
+ expect(strip?.textContent).toBe("wave");
46
+ expect(strip?.style.backgroundColor).toBe("var(--timeline-clip-audio-bg)");
47
+ expect(strip?.style.height).toBe("50%");
48
+ expect(strip?.previousElementSibling?.getAttribute("style")).toContain("bottom: 50%");
49
+ });
50
+
51
+ it("keeps only the peak mark in the strip, above the waveform canvas layer", async () => {
52
+ const style = document.createElement("style");
53
+ style.textContent = await studioCss();
54
+ document.head.appendChild(style);
55
+ const host = await renderStrip(
56
+ <span data-peak-badge>
57
+ ▲<span data-peak-text> peaks +0.7 dBFS</span>
58
+ </span>,
59
+ );
60
+ const badge = host.querySelector<HTMLElement>("[data-peak-badge]")!;
61
+ const text = host.querySelector<HTMLElement>("[data-peak-text]")!;
62
+ expect(getComputedStyle(text).display).toBe("none");
63
+ // The waveform canvas sits in a z-index 10 layer (AudioWaveform).
64
+ expect(Number(getComputedStyle(badge).zIndex)).toBeGreaterThan(10);
65
+ style.remove();
66
+ });
67
+ });
@@ -1,7 +1,8 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
- /** Share of the clip height the sound strip takes under a video's thumbnails. */
4
- const AUDIBLE_VIDEO_WAVE_SHARE = 0.38;
3
+ /** Share of the clip height the sound strip takes under a video's thumbnails: tall enough for the bars to read. */
4
+ const AUDIBLE_VIDEO_WAVE_SHARE = 0.5;
5
+ const AUDIO_CLIP_SURFACE = "var(--timeline-clip-audio-bg)";
5
6
 
6
7
  /** A video that carries sound: thumbnails on top, its waveform strip along the bottom. */
7
8
  export function AudibleVideoClipContent({
@@ -18,9 +19,9 @@ export function AudibleVideoClipContent({
18
19
  {thumbnail}
19
20
  </div>
20
21
  <div
21
- className="absolute inset-x-0 bottom-0 bg-black/30"
22
+ className="audible-video-wave absolute inset-x-0 bottom-0"
22
23
  data-testid="audible-video-wave"
23
- style={{ height: waveHeight }}
24
+ style={{ height: waveHeight, backgroundColor: AUDIO_CLIP_SURFACE }}
24
25
  >
25
26
  {waveform}
26
27
  </div>
@@ -26,6 +26,7 @@ const BAR_STEP = 3;
26
26
 
27
27
  export const rendersWaveform = (el: { tag: string }) => el.tag === "audio";
28
28
  const FADE_GHOST_OPACITY = 0.27;
29
+ export const WAVEFORM_LAYER_Z = 10;
29
30
 
30
31
  type BarGeometry = { x: number; width: number; height: number; gain: number };
31
32
 
@@ -236,7 +237,7 @@ export const AudioWaveform = memo(function AudioWaveform({
236
237
 
237
238
  return (
238
239
  <div ref={rootRef} className="absolute inset-0">
239
- <div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
240
+ <div className="absolute inset-0 overflow-hidden" style={{ zIndex: WAVEFORM_LAYER_Z }}>
240
241
  <canvas
241
242
  ref={setCanvasRef}
242
243
  className="absolute inset-x-0 bottom-0 w-full"
@@ -49,8 +49,11 @@ export function ClipPeakMarks({
49
49
  }}
50
50
  />
51
51
  ))}
52
- <span className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400">
53
- ▲ peaks {dbText(peakDbfs)}
52
+ <span
53
+ className="absolute top-0 right-1 font-mono text-[9px] leading-none text-red-400"
54
+ data-peak-badge
55
+ >
56
+ ▲<span data-peak-text> peaks {dbText(peakDbfs)}</span>
54
57
  </span>
55
58
  </div>
56
59
  )}
@@ -105,7 +105,7 @@ export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: Timelin
105
105
  aria-label={`Out of sync with ${partnerName} by ${label} frames`}
106
106
  title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
107
107
  data-testid="out-of-sync-badge"
108
- className="absolute bottom-0.5 left-1 z-[7] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
108
+ className="absolute bottom-0.5 left-1 z-[12] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
109
109
  onPointerDown={stop}
110
110
  onDoubleClick={stop}
111
111
  onClick={open}
@@ -99,9 +99,7 @@ describe("Timeline trackPadding", () => {
99
99
  const clip = host.querySelector('[data-clip][data-el-id="c0"]')!;
100
100
  pointer(clip, "pointerdown", RULER_H + TRACK_H / 2);
101
101
  pointer(window, "pointermove", ROW1_MID);
102
- const ghost = Array.from(
103
- host.querySelectorAll<HTMLElement>(".absolute.pointer-events-none"),
104
- ).find((el) => el.style.zIndex === "30" && !el.dataset.testid);
102
+ const ghost = host.querySelector<HTMLElement>('[data-testid="timeline-drag-landing"]');
105
103
  expect(top(ghost)).toBe(`${RULER_H + TRACK_H + CLIP_Y}px`);
106
104
  pointer(window, "pointermove", RULER_H + 3 * TRACK_H + TRACK_H / 2);
107
105
  const lane = host.querySelector<HTMLElement>("[data-timeline-new-track-lane]");
@@ -134,6 +134,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
134
134
  lane + snapped start), parallel to the ghost. Hidden in insert mode. */}
135
135
  {draggedClip?.started && draggedClip.insertRow == null && draggedRowIndex >= 0 && (
136
136
  <div
137
+ data-testid="timeline-drag-landing"
137
138
  className="absolute pointer-events-none"
138
139
  style={{
139
140
  top: props.rowGeometry.getRowTop(draggedRowIndex) + CLIP_Y,
@@ -6,6 +6,7 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
6
6
  import { loadStylesheet, STYLES_DIR } from "../../styles/styleSources";
7
7
  import { cleanupMounted, trackedRoot } from "../../components/ui/mountHost.testHelpers";
8
8
  import { TimelineClip } from "./TimelineClip";
9
+ import { WAVEFORM_LAYER_Z } from "./AudioWaveform";
9
10
  import { renderClipChildren } from "./timelineClipChildren";
10
11
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
11
12
 
@@ -23,6 +24,7 @@ beforeAll(async () => {
23
24
  afterEach(() => {
24
25
  cleanupMounted();
25
26
  usePlayerStore.getState().setLintFindingsByElement(new Map());
27
+ usePlayerStore.getState().setElements([]);
26
28
  document.body.innerHTML = "";
27
29
  });
28
30
 
@@ -153,4 +155,29 @@ describe("Filmstrip clips", () => {
153
155
  );
154
156
  expect(css).toMatch(/\.timeline-clip:not\(\.is-audio\)\s*\{\s*border-width:\s*0;/);
155
157
  });
158
+
159
+ const audioPillZ = () =>
160
+ Number(
161
+ css.match(/\.timeline-clip\.is-audio \.timeline-clip__label\s*\{[^}]*z-index:\s*(\d+)/)?.[1],
162
+ );
163
+
164
+ it("draws an audio clip's name pill over its waveform", () => {
165
+ expect(audioPillZ()).toBeGreaterThan(WAVEFORM_LAYER_Z);
166
+ });
167
+
168
+ it("keeps the out-of-sync badge over the audio name pill", () => {
169
+ const audio = {
170
+ id: "clip",
171
+ domId: "clip",
172
+ label: "City",
173
+ tag: "audio",
174
+ start: 1.7,
175
+ duration: 4,
176
+ };
177
+ const synced = { ...audio, track: 1, playbackStart: 0, syncOrigin: "lk-1" };
178
+ const video = { ...synced, id: "talk", domId: "talk", tag: "video", start: 1, track: 0 };
179
+ usePlayerStore.getState().setElements([video, synced]);
180
+ const badge = render("audio", synced, false).querySelector('[data-testid="out-of-sync-badge"]');
181
+ expect(Number(badge?.className.match(/\bz-\[(\d+)\]/)?.[1])).toBeGreaterThan(audioPillZ());
182
+ });
156
183
  });
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { WAVEFORM_LAYER_Z } from "./AudioWaveform";
6
7
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
8
  import { TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
8
9
  import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
@@ -298,7 +299,7 @@ describe("TimelineClipFades", () => {
298
299
  it("paints the handles above the clip's waveform and thumbnail layers", () => {
299
300
  const { host } = render(clip);
300
301
  const layer = host.querySelector<HTMLElement>('[data-testid="clip"]')?.firstElementChild;
301
- expect(Number((layer as HTMLElement).style.zIndex)).toBeGreaterThan(10);
302
+ expect(Number((layer as HTMLElement).style.zIndex)).toBeGreaterThan(WAVEFORM_LAYER_Z);
302
303
  });
303
304
 
304
305
  it("splits a clip narrower than two targets between the handles", () => {
@@ -321,6 +321,20 @@ describe("guideIfSaved — a guide only where the clip saves", () => {
321
321
  expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull();
322
322
  });
323
323
 
324
+ it("keeps the pointer's start when a move's snap would save off the target", () => {
325
+ // 1440 px/s: a 31/30 s clip whose end snaps to 3.25s would save at 2.22s and jump 4px on release.
326
+ const el = clip("a", 0, 1, 31 / 30, 0, "div");
327
+ const { drag } = horizontalDrag(el, 0.5, 0);
328
+ const context = {
329
+ ...ctx(undefined, [el]),
330
+ pps: 1440,
331
+ buildSnapTargets: () => [{ time: 3.25, type: "clip-edge" as const }],
332
+ };
333
+ const x = drag.originClientX + 1.22 * 1440;
334
+ const next = computeDragPreview(drag, x, drag.originClientY, context);
335
+ expect(next).toMatchObject({ previewStart: 2.22, snapTime: null, snapType: null });
336
+ });
337
+
324
338
  it("draws no guide for a move whose saved start misses the target", () => {
325
339
  const nested = { ...moodboard, parentCompositionStart: 1 / 30 };
326
340
  const { drag } = horizontalDrag(nested, 0.5, 0);
@@ -388,6 +402,23 @@ describe("computeResizePreview — composition source continuity", () => {
388
402
  expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null });
389
403
  });
390
404
 
405
+ it("keeps the pointer's end when a tail snap would save off the target", () => {
406
+ // 1440 px/s: snapping 1.125s + 2.12s to 3.25s needs a 2.125s duration, which saves 7px off.
407
+ const result = computeResizePreview(
408
+ {
409
+ element: clip("a", 0, 1.125, 2, 0, "div"),
410
+ edge: "end",
411
+ originClientX: 0,
412
+ previewStart: 1.125,
413
+ previewDuration: 2,
414
+ started: true,
415
+ },
416
+ 173,
417
+ { scroll: fakeScroll(), pps: 1440, buildSnapTargets: () => [{ time: 3.25, type: "grid" }] },
418
+ );
419
+ expect(result).toMatchObject({ previewDuration: 2.12, snapTime: null, snapType: null });
420
+ });
421
+
391
422
  it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
392
423
  const result = computeResizePreview(
393
424
  {