@hyperframes/studio 0.7.94 → 0.7.95

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 (59) hide show
  1. package/dist/assets/{hyperframes-player-D4mryRja.js → hyperframes-player-C1o3TZ1w.js} +1 -1
  2. package/dist/assets/{index-CH_dyqrx.js → index-4KcC7fDo.js} +195 -195
  3. package/dist/assets/{index-BWh5m2-P.js → index-Bl6x228Z.js} +1 -1
  4. package/dist/assets/{index-B0NWErIq.js → index-DR4Dsw8D.js} +1 -1
  5. package/dist/assets/index-tBPidglp.css +1 -0
  6. package/dist/index.d.ts +27 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +2644 -1837
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/EditorShell.selectionSync.test.tsx +109 -0
  12. package/src/components/EditorShell.tsx +31 -5
  13. package/src/components/TimelineToolbar.test.tsx +20 -1
  14. package/src/components/TimelineToolbar.tsx +29 -1
  15. package/src/hooks/useRenderClipContent.test.ts +77 -2
  16. package/src/hooks/useRenderClipContent.ts +78 -14
  17. package/src/hooks/useThumbnailLease.test.tsx +150 -0
  18. package/src/hooks/useThumbnailLease.ts +44 -0
  19. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
  20. package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
  21. package/src/player/components/AudioWaveform.test.tsx +53 -0
  22. package/src/player/components/AudioWaveform.tsx +137 -140
  23. package/src/player/components/CompositionThumbnail.test.ts +33 -10
  24. package/src/player/components/CompositionThumbnail.tsx +78 -61
  25. package/src/player/components/ImageThumbnail.test.tsx +26 -14
  26. package/src/player/components/ImageThumbnail.tsx +58 -101
  27. package/src/player/components/Timeline.tsx +20 -19
  28. package/src/player/components/TimelineGestureOverlay.tsx +3 -0
  29. package/src/player/components/TimelineLanes.test.tsx +24 -2
  30. package/src/player/components/TimelineLanes.tsx +12 -12
  31. package/src/player/components/TimelineTypes.ts +6 -0
  32. package/src/player/components/VideoThumbnail.test.tsx +46 -100
  33. package/src/player/components/VideoThumbnail.tsx +74 -156
  34. package/src/player/components/thumbnailUtils.test.ts +35 -0
  35. package/src/player/components/thumbnailUtils.ts +86 -8
  36. package/src/player/components/timelineClipChildren.test.ts +31 -0
  37. package/src/player/components/timelineClipChildren.tsx +21 -2
  38. package/src/player/components/timelineLaneProps.ts +3 -0
  39. package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
  40. package/src/player/hooks/useTimelinePlayer.ts +14 -9
  41. package/src/player/lib/mediaProbe.test.ts +143 -0
  42. package/src/player/lib/mediaProbe.ts +117 -25
  43. package/src/player/lib/thumbnailPolicy.test.ts +14 -0
  44. package/src/player/lib/thumbnailPolicy.ts +24 -0
  45. package/src/player/lib/thumbnailScheduler.test.ts +388 -0
  46. package/src/player/lib/thumbnailScheduler.ts +483 -0
  47. package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
  48. package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
  49. package/src/player/lib/timelineViewportBudgets.ts +2 -0
  50. package/src/player/store/playerStore.ts +3 -1
  51. package/src/player/store/thumbnailSlice.ts +18 -0
  52. package/src/utils/frameCapture.test.ts +1 -1
  53. package/src/utils/frameCapture.ts +1 -0
  54. package/src/utils/projectRouting.test.ts +1 -1
  55. package/src/utils/studioSelectionSnapshot.test.ts +1 -1
  56. package/src/utils/studioSelectionSnapshot.ts +1 -0
  57. package/src/utils/studioUiPreferences.test.ts +14 -0
  58. package/src/utils/studioUiPreferences.ts +6 -0
  59. package/dist/assets/index-D78KEjgB.css +0 -1
@@ -2,65 +2,28 @@
2
2
  import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { thumbnailScheduler } from "../lib/thumbnailScheduler";
6
+ import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
5
7
  import { VideoThumbnail } from "./VideoThumbnail";
6
8
 
9
+ vi.mock("../lib/thumbnailVideoDecoder", () => ({ decodeVideoThumbnail: vi.fn() }));
10
+
7
11
  Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
8
12
  configurable: true,
9
13
  value: true,
10
14
  });
11
15
 
12
- // Fire "intersecting" immediately on observe so the extraction effect runs.
13
- class MockIntersectionObserver {
14
- private cb: IntersectionObserverCallback;
15
- constructor(cb: IntersectionObserverCallback) {
16
- this.cb = cb;
17
- }
18
- observe() {
19
- this.cb(
20
- [{ isIntersecting: true } as IntersectionObserverEntry],
21
- this as unknown as IntersectionObserver,
22
- );
23
- }
24
- disconnect() {}
25
- unobserve() {}
26
- takeRecords(): IntersectionObserverEntry[] {
27
- return [];
28
- }
29
- }
30
-
31
16
  class MockResizeObserver {
32
17
  observe() {}
33
18
  disconnect() {}
34
19
  unobserve() {}
35
20
  }
36
21
 
37
- const originalIO = globalThis.IntersectionObserver;
38
- const originalRO = globalThis.ResizeObserver;
39
-
40
22
  let host: HTMLDivElement;
41
23
  let root: Root | null = null;
42
- let createdVideos: HTMLVideoElement[];
43
24
 
44
25
  beforeEach(() => {
45
- globalThis.IntersectionObserver =
46
- MockIntersectionObserver as unknown as typeof IntersectionObserver;
47
26
  globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
48
-
49
- createdVideos = [];
50
- const origCreate = document.createElement.bind(document);
51
- vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
52
- const el = origCreate(tag);
53
- if (tag === "video") createdVideos.push(el as HTMLVideoElement);
54
- return el;
55
- });
56
-
57
- // happy-dom's <video>/<canvas> don't decode media; stub the seam the
58
- // extractor depends on so the effect can run deterministically.
59
- vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
60
- vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
61
- drawImage: () => {},
62
- } as unknown as CanvasRenderingContext2D);
63
-
64
27
  host = document.createElement("div");
65
28
  document.body.append(host);
66
29
  });
@@ -68,85 +31,68 @@ beforeEach(() => {
68
31
  afterEach(() => {
69
32
  act(() => root?.unmount());
70
33
  root = null;
71
- vi.restoreAllMocks();
72
- globalThis.IntersectionObserver = originalIO;
73
- globalThis.ResizeObserver = originalRO;
34
+ thumbnailScheduler.invalidateProject("p");
35
+ vi.clearAllMocks();
74
36
  document.body.innerHTML = "";
75
37
  });
76
38
 
77
- function render(videoSrc: string) {
39
+ async function render(rich = false) {
78
40
  root = createRoot(host);
79
- act(() => {
80
- root!.render(React.createElement(VideoThumbnail, { videoSrc, label: "", labelColor: "#fff" }));
41
+ await act(async () => {
42
+ root!.render(
43
+ <VideoThumbnail
44
+ videoSrc="/api/projects/p/preview/assets/clip.mp4"
45
+ label=""
46
+ labelColor="#fff"
47
+ projectId="p"
48
+ sessionEpoch={1}
49
+ priority="visible"
50
+ rich={rich}
51
+ />,
52
+ );
53
+ await Promise.resolve();
81
54
  });
82
55
  }
83
56
 
84
- function lastVideo(): HTMLVideoElement {
85
- const v = createdVideos.at(-1);
86
- expect(v).toBeDefined();
87
- return v!;
88
- }
89
-
90
- describe("VideoThumbnail — tainted-canvas fallback", () => {
91
- it("stops the extractor and drops the shimmer when toDataURL throws a SecurityError", () => {
92
- vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(() => {
93
- throw new DOMException("Tainted canvases may not be exported.", "SecurityError");
57
+ describe("VideoThumbnail", () => {
58
+ it("renders a scheduler-provided sparse poster", async () => {
59
+ vi.mocked(decodeVideoThumbnail).mockResolvedValue({
60
+ value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
61
+ weight: 128,
94
62
  });
95
63
 
96
- render("https://cdn.example.com/no-cors.mp4");
97
-
98
- // The effect ran once visible → a hidden <video> was created.
99
- const video = lastVideo();
100
-
101
- act(() => {
102
- video.dispatchEvent(new Event("loadedmetadata"));
103
- });
104
- act(() => {
105
- video.dispatchEvent(new Event("seeked"));
106
- });
64
+ await render();
107
65
 
108
- // No frame captured, and crucially the shimmer is gone (not spinning
109
- // forever) the clip falls back to its plain background.
110
- expect(host.querySelectorAll("img").length).toBe(0);
66
+ expect(decodeVideoThumbnail).toHaveBeenCalledWith(
67
+ expect.objectContaining({ frameCount: 1 }),
68
+ expect.any(AbortSignal),
69
+ );
70
+ expect(host.querySelector('img[src="blob:poster"]')).not.toBeNull();
111
71
  expect(host.querySelector(".animate-pulse")).toBeNull();
112
72
  });
113
73
 
114
- it("drops the shimmer when a no-CORS load fires the video error event (0 frames) (#2214)", () => {
115
- render("https://cdn.example.com/no-cors.mp4");
116
- // Before the load resolves, the shimmer placeholder is up.
117
- expect(host.querySelector(".animate-pulse")).not.toBeNull();
118
-
119
- const video = lastVideo();
120
- // crossOrigin="anonymous" against a CORS-less server fails the load outright —
121
- // the error listener fires instead of loadedmetadata/seeked, so no frame is
122
- // ever captured. The shimmer must stop rather than spin forever.
123
- act(() => {
124
- video.dispatchEvent(new Event("error"));
74
+ it("requests a rich filmstrip only for interaction actors", async () => {
75
+ vi.mocked(decodeVideoThumbnail).mockResolvedValue({
76
+ value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
77
+ weight: 256,
125
78
  });
126
79
 
127
- expect(host.querySelectorAll("img").length).toBe(0);
128
- expect(host.querySelector(".animate-pulse")).toBeNull();
129
- });
80
+ await render(true);
130
81
 
131
- it("keeps streaming frames while the shimmer is up until a frame arrives", () => {
132
- vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
133
- "data:image/jpeg;base64,AAAA",
82
+ expect(decodeVideoThumbnail).toHaveBeenCalledWith(
83
+ expect.objectContaining({ frameCount: 6 }),
84
+ expect.any(AbortSignal),
134
85
  );
86
+ expect(host.querySelectorAll("img").length).toBeGreaterThan(0);
87
+ });
135
88
 
136
- render("/api/projects/p/preview/assets/clip.mp4");
137
- // Before any seek resolves, the shimmer placeholder is shown.
138
- expect(host.querySelector(".animate-pulse")).not.toBeNull();
89
+ it("clears the loading shimmer when the scheduled decode fails", async () => {
90
+ vi.mocked(decodeVideoThumbnail).mockRejectedValue(new Error("decode failed"));
139
91
 
140
- const video = lastVideo();
141
- act(() => {
142
- video.dispatchEvent(new Event("loadedmetadata"));
143
- });
144
- act(() => {
145
- video.dispatchEvent(new Event("seeked"));
146
- });
92
+ await render();
93
+ await vi.waitFor(() => expect(thumbnailScheduler.getDiagnostics().active).toBe(0));
147
94
 
148
- // A frame was captured, so tiles render and the shimmer clears.
149
- expect(host.querySelectorAll("img").length).toBeGreaterThanOrEqual(1);
150
95
  expect(host.querySelector(".animate-pulse")).toBeNull();
96
+ expect(host.querySelector("img")).toBeNull();
151
97
  });
152
98
  });
@@ -1,5 +1,8 @@
1
- import { memo, useRef, useState, useCallback, useEffect } from "react";
1
+ import { memo, useCallback, useMemo, useRef, useState } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
+ import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
+ import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
3
6
  import { computeThumbnailStrip, THUMBNAIL_CLIP_HEIGHT } from "./thumbnailUtils";
4
7
 
5
8
  interface VideoThumbnailProps {
@@ -7,189 +10,105 @@ interface VideoThumbnailProps {
7
10
  label: string;
8
11
  labelColor: string;
9
12
  duration?: number;
13
+ sourceStart?: number;
14
+ sourceRangeDuration?: number;
15
+ projectId?: string;
16
+ sessionEpoch?: number;
17
+ priority?: ThumbnailPriority;
18
+ rich?: boolean;
10
19
  }
11
20
 
12
- const CLIP_HEIGHT = THUMBNAIL_CLIP_HEIGHT;
13
- const MAX_UNIQUE_FRAMES: number = 6;
14
-
15
- /**
16
- * Renders a film-strip of video frames extracted client-side via a hidden
17
- * <video> + <canvas>. Each frame is a fixed-width tile; frames repeat to
18
- * fill the clip width — matching ClipThumbnail's visual pattern.
19
- */
21
+ /** Sparse, bounded video frames supplied by the shared thumbnail scheduler. */
20
22
  export const VideoThumbnail = memo(function VideoThumbnail({
21
23
  videoSrc,
22
24
  label,
23
25
  labelColor,
24
26
  duration = 5,
27
+ sourceStart,
28
+ sourceRangeDuration,
29
+ projectId = videoSrc,
30
+ sessionEpoch = 0,
31
+ priority = "visible",
32
+ rich = false,
25
33
  }: VideoThumbnailProps) {
26
34
  const [containerWidth, setContainerWidth] = useState(0);
27
- const [visible, setVisible] = useState(false);
28
- const [frames, setFrames] = useState<string[]>([]);
29
- const [failed, setFailed] = useState(false);
30
- const [aspect, setAspect] = useState(16 / 9);
31
- const ioRef = useRef<IntersectionObserver | null>(null);
32
- const roRef = useRef<ResizeObserver | null>(null);
33
- const extractingRef = useRef(false);
34
-
35
- const setContainerRef = useCallback((el: HTMLDivElement | null) => {
36
- ioRef.current?.disconnect();
37
- roRef.current?.disconnect();
38
- if (!el) return;
39
-
40
- const measured = el.parentElement?.clientWidth || el.clientWidth;
41
- setContainerWidth(measured);
35
+ const observerRef = useRef<ResizeObserver | null>(null);
36
+ const request = useMemo(
37
+ () => ({
38
+ key: createThumbnailKey({
39
+ kind: "video",
40
+ source: videoSrc,
41
+ start: sourceStart,
42
+ duration: sourceRangeDuration ?? duration,
43
+ frames: rich ? 6 : 1,
44
+ }),
45
+ projectId,
46
+ sessionEpoch,
47
+ kind: "video" as const,
48
+ priority,
49
+ rich,
50
+ load: (signal: AbortSignal) =>
51
+ decodeVideoThumbnail(
52
+ {
53
+ source: videoSrc,
54
+ sourceStart,
55
+ sourceRangeDuration: sourceRangeDuration ?? duration,
56
+ frameCount: rich ? 6 : 1,
57
+ fit: "cover",
58
+ },
59
+ signal,
60
+ ),
61
+ }),
62
+ [duration, priority, projectId, rich, sessionEpoch, sourceRangeDuration, sourceStart, videoSrc],
63
+ );
64
+ const snapshot = useThumbnailLease(request);
65
+ const value = snapshot.status === "ready" ? snapshot.value : null;
66
+ const urls =
67
+ value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
68
+ const aspect = value?.kind === "image" || value?.kind === "filmstrip" ? value.aspect : 16 / 9;
69
+ const { frameW, frameCount } = computeThumbnailStrip(
70
+ containerWidth,
71
+ aspect,
72
+ THUMBNAIL_CLIP_HEIGHT,
73
+ );
42
74
 
43
- ioRef.current = new IntersectionObserver(
44
- ([entry]) => {
45
- if (entry.isIntersecting) {
46
- setVisible(true);
47
- ioRef.current?.disconnect();
48
- }
49
- },
50
- { rootMargin: "200px" },
75
+ const setContainerRef = useCallback((element: HTMLDivElement | null) => {
76
+ observerRef.current?.disconnect();
77
+ if (!element) return;
78
+ const target = element.parentElement ?? element;
79
+ setContainerWidth(target.clientWidth);
80
+ observerRef.current = new ResizeObserver(([entry]) =>
81
+ setContainerWidth(entry.contentRect.width),
51
82
  );
52
- // fallow-ignore-next-line code-duplication
53
- ioRef.current.observe(el);
54
-
55
- const target = el.parentElement || el;
56
- roRef.current = new ResizeObserver(([entry]) => {
57
- setContainerWidth(entry.contentRect.width);
58
- });
59
- roRef.current.observe(target);
83
+ observerRef.current.observe(target);
60
84
  }, []);
61
85
 
62
- useMountEffect(() => () => {
63
- ioRef.current?.disconnect();
64
- roRef.current?.disconnect();
65
- });
66
-
67
- // Extract frames progressively — each frame appears as soon as it's ready.
68
- // Note: useEffect with deps is acceptable — syncs with external video element API,
69
- // requires cleanup (cancel extraction, revoke URLs) when inputs change.
70
- // eslint-disable-next-line no-restricted-syntax
71
- useEffect(() => {
72
- if (!visible || extractingRef.current) return;
73
- extractingRef.current = true;
74
-
75
- const video = document.createElement("video");
76
- video.crossOrigin = "anonymous";
77
- video.muted = true;
78
- video.preload = "auto";
79
-
80
- const canvas = document.createElement("canvas");
81
- const ctx = canvas.getContext("2d");
82
- if (!ctx) {
83
- extractingRef.current = false;
84
- return;
85
- }
86
-
87
- const timestamps: number[] = [];
88
- const minSeek = Math.min(0.4, duration * 0.05);
89
- for (let i = 0; i < MAX_UNIQUE_FRAMES; i++) {
90
- const raw =
91
- MAX_UNIQUE_FRAMES === 1 ? duration * 0.15 : (i / (MAX_UNIQUE_FRAMES - 1)) * duration;
92
- timestamps.push(Math.max(raw, minSeek));
93
- }
94
-
95
- let idx = 0;
96
- let cancelled = false;
97
-
98
- const extractNext = () => {
99
- if (cancelled || idx >= timestamps.length) {
100
- if (!cancelled) {
101
- video.src = "";
102
- video.load();
103
- }
104
- return;
105
- }
106
- video.currentTime = timestamps[idx];
107
- };
108
-
109
- video.addEventListener("loadedmetadata", () => {
110
- if (video.videoWidth > 0 && video.videoHeight > 0) {
111
- setAspect(video.videoWidth / video.videoHeight);
112
- const h = CLIP_HEIGHT * 2;
113
- const w = Math.round(h * (video.videoWidth / video.videoHeight));
114
- canvas.width = w;
115
- canvas.height = h;
116
- }
117
- extractNext();
118
- });
119
-
120
- video.addEventListener("seeked", () => {
121
- if (cancelled) return;
122
- let dataUrl: string;
123
- try {
124
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
125
- dataUrl = canvas.toDataURL("image/jpeg", 0.6);
126
- } catch {
127
- // An external http(s) video served without CORS headers taints the
128
- // canvas, so toDataURL throws a SecurityError. Stop the extractor
129
- // cleanly and fall back to the no-thumbnail rendering (plain clip
130
- // background), matching ImageThumbnail's error path — otherwise the
131
- // shimmer placeholder would spin forever.
132
- cancelled = true;
133
- setFailed(true);
134
- video.src = "";
135
- video.load();
136
- return;
137
- }
138
- // Stream each frame immediately
139
- setFrames((prev) => [...prev, dataUrl]);
140
- idx++;
141
- extractNext();
142
- });
143
-
144
- video.addEventListener("error", () => {
145
- // A no-CORS load fails outright (crossOrigin="anonymous" rejects a video
146
- // served without CORS headers), firing this instead of the taint path in
147
- // "seeked" — so 0 frames are ever extracted. Keep whatever frames we have,
148
- // but mark failed so the shimmer placeholder stops spinning forever and we
149
- // fall back to the plain clip background (#2214).
150
- setFailed(true);
151
- });
152
-
153
- video.src = videoSrc;
154
- video.load();
155
-
156
- return () => {
157
- cancelled = true;
158
- extractingRef.current = false;
159
- setFrames([]);
160
- setFailed(false);
161
- video.src = "";
162
- video.load();
163
- };
164
- }, [visible, videoSrc, duration]);
165
-
166
- const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect, CLIP_HEIGHT);
86
+ useMountEffect(() => () => observerRef.current?.disconnect());
167
87
 
168
88
  return (
169
89
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
170
- {visible && frames.length > 0 && (
90
+ {urls.length > 0 && (
171
91
  <div className="absolute inset-0 flex">
172
- {Array.from({ length: frameCount }).map((_, i) => {
173
- const src = frames[i % frames.length];
92
+ {Array.from({ length: frameCount }, (_, index) => {
93
+ const src = urls[index % urls.length];
174
94
  return (
175
95
  <div
176
- key={i}
177
- className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
96
+ key={index}
97
+ className="relative h-full flex-shrink-0 overflow-hidden bg-neutral-900"
178
98
  style={{ width: frameW }}
179
99
  >
180
100
  <img
181
101
  src={src}
182
102
  alt=""
183
103
  draggable={false}
184
- className="absolute inset-0 w-full h-full object-cover"
104
+ className="absolute inset-0 h-full w-full object-cover"
185
105
  />
186
106
  </div>
187
107
  );
188
108
  })}
189
109
  </div>
190
110
  )}
191
-
192
- {visible && frames.length === 0 && !failed && (
111
+ {snapshot.status === "loading" && urls.length === 0 && (
193
112
  <div
194
113
  className="absolute inset-0 animate-pulse"
195
114
  style={{
@@ -198,17 +117,16 @@ export const VideoThumbnail = memo(function VideoThumbnail({
198
117
  }}
199
118
  />
200
119
  )}
201
-
202
120
  {label && (
203
121
  <div
204
- className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
122
+ className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
205
123
  style={{
206
124
  background:
207
125
  "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
208
126
  }}
209
127
  >
210
128
  <span
211
- className="text-[9px] font-semibold truncate block leading-tight"
129
+ className="block truncate text-[9px] font-semibold leading-tight"
212
130
  style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
213
131
  >
214
132
  {label}
@@ -41,9 +41,44 @@ describe("computeThumbnailStrip", () => {
41
41
  it("honors a custom clip height", () => {
42
42
  expect(computeThumbnailStrip(300, 2, 40).frameW).toBe(80);
43
43
  });
44
+
45
+ it("keeps narrow tiles above a caller-owned minimum", () => {
46
+ expect(computeThumbnailStrip(300, 0.25, 40, 48)).toEqual({
47
+ frameW: 48,
48
+ frameCount: 7,
49
+ });
50
+ });
44
51
  });
45
52
 
46
53
  describe("resolveMediaPreviewUrl", () => {
54
+ it("reroutes same-origin root media resolved by the preview iframe", () => {
55
+ expect(
56
+ resolveMediaPreviewUrl(
57
+ "http://localhost:5190/assets/clip.mp4",
58
+ "proj-1",
59
+ "http://localhost:5190",
60
+ ),
61
+ ).toBe("/api/projects/proj-1/preview/assets/clip.mp4");
62
+ });
63
+
64
+ it("preserves empty, canonical preview, and same-origin API sources", () => {
65
+ expect(resolveMediaPreviewUrl("", "proj-1", "http://localhost:5190")).toBe("");
66
+ expect(
67
+ resolveMediaPreviewUrl(
68
+ "http://localhost:5190/api/projects/proj-1/preview/assets/clip.mp4",
69
+ "proj-1",
70
+ "http://localhost:5190",
71
+ ),
72
+ ).toBe("http://localhost:5190/api/projects/proj-1/preview/assets/clip.mp4");
73
+ expect(
74
+ resolveMediaPreviewUrl(
75
+ "http://localhost:5190/api/media/clip.mp4",
76
+ "proj-1",
77
+ "http://localhost:5190",
78
+ ),
79
+ ).toBe("http://localhost:5190/api/media/clip.mp4");
80
+ });
81
+
47
82
  it("routes composition-relative paths through the project preview endpoint", () => {
48
83
  expect(resolveMediaPreviewUrl("assets/image.png", "proj-1")).toBe(
49
84
  "/api/projects/proj-1/preview/assets/image.png",
@@ -8,6 +8,52 @@ export interface ThumbnailStripLayout {
8
8
  frameCount: number;
9
9
  }
10
10
 
11
+ /**
12
+ * Measure an image without mounting it in React's DOM. The scheduler owns the
13
+ * abort signal, so an unmounted clip cannot leave Blink retaining a pending
14
+ * image request and its former React tree.
15
+ */
16
+ export function probeImageAspect(
17
+ imageSrc: string,
18
+ signal: AbortSignal,
19
+ tolerateSvgError = false,
20
+ ): Promise<number> {
21
+ return new Promise((resolve, reject) => {
22
+ const image = new Image();
23
+ const cleanup = () => {
24
+ image.onload = null;
25
+ image.onerror = null;
26
+ signal.removeEventListener("abort", onAbort);
27
+ };
28
+ const onAbort = () => {
29
+ cleanup();
30
+ image.src = "";
31
+ reject(new DOMException("Aborted", "AbortError"));
32
+ };
33
+
34
+ if (signal.aborted) {
35
+ onAbort();
36
+ return;
37
+ }
38
+
39
+ image.onload = () => {
40
+ cleanup();
41
+ resolve(
42
+ image.naturalWidth > 0 && image.naturalHeight > 0
43
+ ? image.naturalWidth / image.naturalHeight
44
+ : 16 / 9,
45
+ );
46
+ };
47
+ image.onerror = () => {
48
+ cleanup();
49
+ if (tolerateSvgError && /\.svg($|\?)/i.test(imageSrc)) resolve(16 / 9);
50
+ else reject(new Error("Image thumbnail failed to load"));
51
+ };
52
+ signal.addEventListener("abort", onAbort, { once: true });
53
+ image.src = imageSrc;
54
+ });
55
+ }
56
+
11
57
  /**
12
58
  * Compute the film-strip tile layout for a clip thumbnail: fixed-height tiles
13
59
  * sized by the media's aspect ratio, repeated to fill the clip width.
@@ -17,9 +63,10 @@ export function computeThumbnailStrip(
17
63
  containerWidth: number,
18
64
  aspect: number,
19
65
  clipHeight: number = THUMBNAIL_CLIP_HEIGHT,
66
+ minFrameWidth = 1,
20
67
  ): ThumbnailStripLayout {
21
68
  const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
22
- const frameW = Math.max(1, Math.round(clipHeight * safeAspect));
69
+ const frameW = Math.max(minFrameWidth, Math.round(clipHeight * safeAspect));
23
70
  const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
24
71
  return { frameW, frameCount };
25
72
  }
@@ -43,12 +90,43 @@ export function encodePreviewPath(relativePath: string): string {
43
90
  * (parent) document. Composition-relative paths (e.g. "assets/image.png") are
44
91
  * routed through the project preview endpoint with each segment encoded.
45
92
  *
46
- * Already-loadable URLs pass through untouched: absolute http(s) URLs, plus
47
- * `data:` and `blob:` URLs. Routing a `data:`/`blob:` URL through the preview
48
- * endpoint would percent-encode the whole thing into a multi-KB path segment
49
- * that the server rejects with HTTP 431 (Request Header Fields Too Large).
93
+ * External http(s), `data:`, and `blob:` URLs pass through untouched. A
94
+ * same-origin absolute URL outside the project preview endpoint is the browser's
95
+ * resolved form of a root-relative authored path, so route it back through the
96
+ * active project instead of accidentally fetching the Studio shell.
50
97
  */
51
- export function resolveMediaPreviewUrl(src: string, projectId: string): string {
52
- if (/^(?:https?:|data:|blob:)/i.test(src)) return src;
53
- return `/api/projects/${projectId}/preview/${encodePreviewPath(src)}`;
98
+ export function resolveMediaPreviewUrl(
99
+ src: string,
100
+ projectId: string,
101
+ studioOrigin?: string,
102
+ ): string {
103
+ if (!src) return src;
104
+ if (/^(?:data:|blob:)/i.test(src)) return src;
105
+
106
+ let relativePath = src;
107
+ let suffix = "";
108
+ if (/^https?:/i.test(src)) {
109
+ let parsed: URL;
110
+ try {
111
+ parsed = new URL(src);
112
+ } catch {
113
+ return src;
114
+ }
115
+ if (!studioOrigin || parsed.origin !== studioOrigin) return src;
116
+ const previewPath = new URL(`/api/projects/${projectId}/preview/`, studioOrigin).pathname;
117
+ if (parsed.pathname.startsWith(previewPath)) return src;
118
+ if (parsed.pathname.startsWith("/api/")) return src;
119
+ try {
120
+ relativePath = parsed.pathname
121
+ .replace(/^\/+/, "")
122
+ .split("/")
123
+ .map(decodeURIComponent)
124
+ .join("/");
125
+ } catch {
126
+ return src;
127
+ }
128
+ suffix = `${parsed.search}${parsed.hash}`;
129
+ }
130
+
131
+ return `/api/projects/${projectId}/preview/${encodePreviewPath(relativePath.replace(/^\/+/, ""))}${suffix}`;
54
132
  }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveClipRenderContext } from "./timelineClipChildren";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+
5
+ const clip: TimelineElement = {
6
+ id: "clip",
7
+ tag: "video",
8
+ start: 10,
9
+ duration: 5,
10
+ track: 0,
11
+ };
12
+
13
+ describe("resolveClipRenderContext", () => {
14
+ it("prioritizes interactive clips and enables rich thumbnails", () => {
15
+ expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
16
+ priority: "interaction",
17
+ rich: true,
18
+ });
19
+ });
20
+
21
+ it("distinguishes visible clips from overscan clips", () => {
22
+ expect(resolveClipRenderContext(clip, { start: 14, end: 16 }, false)).toEqual({
23
+ priority: "visible",
24
+ rich: false,
25
+ });
26
+ expect(resolveClipRenderContext(clip, { start: 15, end: 20 }, false)).toEqual({
27
+ priority: "overscan",
28
+ rich: false,
29
+ });
30
+ });
31
+ });