@hyperframes/studio 0.7.94 → 0.7.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-D4mryRja.js → hyperframes-player-CogOkBTX.js} +1 -1
- package/dist/assets/{index-B0NWErIq.js → index-DfZlc9RM.js} +1 -1
- package/dist/assets/{index-BWh5m2-P.js → index-OMTw4iIl.js} +1 -1
- package/dist/assets/{index-CH_dyqrx.js → index-YO-rhwSW.js} +195 -195
- package/dist/assets/index-tBPidglp.css +1 -0
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +2648 -1838
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.selectionSync.test.tsx +109 -0
- package/src/components/EditorShell.tsx +31 -5
- package/src/components/TimelineToolbar.test.tsx +20 -1
- package/src/components/TimelineToolbar.tsx +29 -1
- package/src/hooks/useRenderClipContent.test.ts +77 -2
- package/src/hooks/useRenderClipContent.ts +78 -14
- package/src/hooks/useThumbnailLease.test.tsx +150 -0
- package/src/hooks/useThumbnailLease.ts +44 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
- package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
- package/src/player/components/AudioWaveform.test.tsx +53 -0
- package/src/player/components/AudioWaveform.tsx +137 -140
- package/src/player/components/CompositionThumbnail.test.ts +33 -10
- package/src/player/components/CompositionThumbnail.tsx +78 -61
- package/src/player/components/ImageThumbnail.test.tsx +26 -14
- package/src/player/components/ImageThumbnail.tsx +58 -101
- package/src/player/components/Timeline.tsx +20 -19
- package/src/player/components/TimelineGestureOverlay.tsx +3 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -2
- package/src/player/components/TimelineLanes.tsx +12 -12
- package/src/player/components/TimelineTypes.ts +6 -0
- package/src/player/components/VideoThumbnail.test.tsx +46 -100
- package/src/player/components/VideoThumbnail.tsx +74 -156
- package/src/player/components/thumbnailUtils.test.ts +35 -0
- package/src/player/components/thumbnailUtils.ts +86 -8
- package/src/player/components/timelineClipChildren.test.ts +31 -0
- package/src/player/components/timelineClipChildren.tsx +21 -2
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
- package/src/player/hooks/useTimelinePlayer.ts +14 -9
- package/src/player/lib/mediaProbe.test.ts +143 -0
- package/src/player/lib/mediaProbe.ts +117 -25
- package/src/player/lib/thumbnailPolicy.test.ts +14 -0
- package/src/player/lib/thumbnailPolicy.ts +24 -0
- package/src/player/lib/thumbnailScheduler.test.ts +388 -0
- package/src/player/lib/thumbnailScheduler.ts +483 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
- package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -0
- package/src/player/store/playerStore.ts +3 -1
- package/src/player/store/thumbnailSlice.ts +18 -0
- package/src/telemetry/canary.test.ts +25 -0
- package/src/telemetry/canary.ts +14 -4
- package/src/utils/frameCapture.test.ts +1 -1
- package/src/utils/frameCapture.ts +1 -0
- package/src/utils/projectRouting.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.ts +1 -0
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- 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
|
-
|
|
72
|
-
|
|
73
|
-
globalThis.ResizeObserver = originalRO;
|
|
34
|
+
thumbnailScheduler.invalidateProject("p");
|
|
35
|
+
vi.clearAllMocks();
|
|
74
36
|
document.body.innerHTML = "";
|
|
75
37
|
});
|
|
76
38
|
|
|
77
|
-
function render(
|
|
39
|
+
async function render(rich = false) {
|
|
78
40
|
root = createRoot(host);
|
|
79
|
-
act(() => {
|
|
80
|
-
root!.render(
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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("
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
129
|
-
});
|
|
80
|
+
await render(true);
|
|
130
81
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
90
|
+
{urls.length > 0 && (
|
|
171
91
|
<div className="absolute inset-0 flex">
|
|
172
|
-
{Array.from({ length: frameCount }
|
|
173
|
-
const src =
|
|
92
|
+
{Array.from({ length: frameCount }, (_, index) => {
|
|
93
|
+
const src = urls[index % urls.length];
|
|
174
94
|
return (
|
|
175
95
|
<div
|
|
176
|
-
key={
|
|
177
|
-
className="flex-shrink-0
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
});
|