@hyperframes/studio 0.7.93 → 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-NSdcoOcK.js → hyperframes-player-C1o3TZ1w.js} +1 -1
  2. package/dist/assets/{index-Dp1zVb9X.js → index-4KcC7fDo.js} +195 -195
  3. package/dist/assets/{index-wS6f7F5q.js → index-Bl6x228Z.js} +1 -1
  4. package/dist/assets/{index-CCGnc_0M.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.93",
3
+ "version": "0.7.95",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.93",
51
- "@hyperframes/parsers": "0.7.93",
52
- "@hyperframes/studio-server": "0.7.93",
53
- "@hyperframes/sdk": "0.7.93",
54
- "@hyperframes/player": "0.7.93"
50
+ "@hyperframes/parsers": "0.7.95",
51
+ "@hyperframes/core": "0.7.95",
52
+ "@hyperframes/player": "0.7.95",
53
+ "@hyperframes/sdk": "0.7.95",
54
+ "@hyperframes/studio-server": "0.7.95"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -67,7 +67,7 @@
67
67
  "vite": "^6.4.2",
68
68
  "vitest": "^3.2.4",
69
69
  "zustand": "^5.0.0",
70
- "@hyperframes/producer": "0.7.93"
70
+ "@hyperframes/producer": "0.7.95"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "19",
@@ -0,0 +1,109 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { EditorShell } from "./EditorShell";
7
+
8
+ const hookMocks = vi.hoisted(() => ({
9
+ useTimelineSelectionPreviewSync: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks);
13
+ vi.mock("../contexts/StudioContext", () => ({
14
+ useStudioPlaybackContext: () => ({
15
+ captionEditMode: false,
16
+ refreshKey: 0,
17
+ refreshPreviewDocumentVersion: vi.fn(),
18
+ timelineElements: [],
19
+ }),
20
+ useStudioShellContext: () => ({
21
+ projectId: "project-1",
22
+ activeCompPath: "index.html",
23
+ setActiveCompPath: vi.fn(),
24
+ handlePreviewIframeRef: vi.fn(),
25
+ showToast: vi.fn(),
26
+ }),
27
+ }));
28
+ vi.mock("../contexts/DomEditContext", () => ({
29
+ useDomEditActionsContext: () => ({
30
+ handleTimelineElementSelect: vi.fn(),
31
+ buildDomSelectionForTimelineElement: vi.fn(),
32
+ applyDomSelection: vi.fn(),
33
+ applyMarqueeSelection: vi.fn(),
34
+ }),
35
+ useDomEditSelectionContext: () => ({
36
+ domEditSelection: null,
37
+ domEditGroupSelections: [],
38
+ }),
39
+ }));
40
+ vi.mock("./nle/NLEContext", () => ({
41
+ NLEProvider: ({ children }: { children: React.ReactNode }) => children,
42
+ useNLEContext: () => ({
43
+ compositionStack: [],
44
+ updateCompositionStack: vi.fn(),
45
+ containerRef: { current: null },
46
+ }),
47
+ }));
48
+ vi.mock("./nle/useTimelineEditCallbacks", () => ({
49
+ useTimelineEditCallbacks: () => ({}),
50
+ }));
51
+ vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
52
+ vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
53
+ vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
54
+ vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
55
+ vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null }));
56
+
57
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
58
+
59
+ afterEach(() => {
60
+ document.body.innerHTML = "";
61
+ hookMocks.useTimelineSelectionPreviewSync.mockClear();
62
+ });
63
+
64
+ describe("EditorShell timeline selection sync", () => {
65
+ it("keeps the timeline store mirrored into the preview selection", () => {
66
+ const host = document.createElement("div");
67
+ document.body.append(host);
68
+ const root = createRoot(host);
69
+
70
+ act(() => {
71
+ root.render(
72
+ <EditorShell
73
+ left={null}
74
+ right={null}
75
+ timelineToolbar={null}
76
+ renderClipContent={() => null}
77
+ handleTimelineElementDelete={vi.fn()}
78
+ handleTimelineAssetDrop={vi.fn()}
79
+ handleTimelineFileDrop={vi.fn()}
80
+ handleTimelineElementMove={vi.fn()}
81
+ handleTimelineElementsMove={vi.fn()}
82
+ handleTimelineElementResize={vi.fn()}
83
+ handleTimelineGroupResize={vi.fn()}
84
+ handleToggleTrackHidden={vi.fn()}
85
+ handleBlockedTimelineEdit={vi.fn()}
86
+ handleTimelineElementSplit={vi.fn()}
87
+ handleRazorSplit={vi.fn()}
88
+ handleRazorSplitAll={vi.fn()}
89
+ setCompIdToSrc={vi.fn()}
90
+ setCompositionLoading={vi.fn()}
91
+ shouldShowMotionPath={false}
92
+ shouldShowSelectedDomBounds={false}
93
+ />,
94
+ );
95
+ });
96
+
97
+ expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce();
98
+ expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledWith(
99
+ expect.objectContaining({
100
+ activeCompPath: "index.html",
101
+ timelineElements: [],
102
+ domEditSelection: null,
103
+ domEditGroupSelections: [],
104
+ }),
105
+ );
106
+
107
+ act(() => root.unmount());
108
+ });
109
+ });
@@ -10,11 +10,12 @@ import { NLEProvider, useNLEContext } from "./nle/NLEContext";
10
10
  import { CaptionTimeline } from "../captions/components/CaptionTimeline";
11
11
  import { StudioFeedbackBar } from "./StudioFeedbackBar";
12
12
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
13
- import { useDomEditActionsContext } from "../contexts/DomEditContext";
13
+ import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext";
14
14
  import { TimelineEditProvider } from "../contexts/TimelineEditContext";
15
- import type { TimelineElement } from "../player";
15
+ import { usePlayerStore, type TimelineElement } from "../player";
16
16
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
17
17
  import type { GestureRecordingState } from "./editor/GestureRecordControl";
18
+ import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
18
19
 
19
20
  type RenderClipContent = (
20
21
  element: TimelineElement,
@@ -99,10 +100,35 @@ export function EditorShell({
99
100
  blockPreview,
100
101
  gestureOverlay,
101
102
  }: EditorShellProps) {
102
- const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } =
103
+ const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } =
103
104
  useStudioShellContext();
104
- const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext();
105
- const { handleTimelineElementSelect } = useDomEditActionsContext();
105
+ const { refreshKey, captionEditMode, refreshPreviewDocumentVersion, timelineElements } =
106
+ useStudioPlaybackContext();
107
+ const {
108
+ handleTimelineElementSelect,
109
+ buildDomSelectionForTimelineElement,
110
+ applyDomSelection,
111
+ applyMarqueeSelection,
112
+ } = useDomEditActionsContext();
113
+ const { domEditSelection, domEditGroupSelections } = useDomEditSelectionContext();
114
+ const selectedElementId = usePlayerStore((state) => state.selectedElementId);
115
+ const selectedElementIds = usePlayerStore((state) => state.selectedElementIds);
116
+ const reportTimelineSelectionNotFound = useCallback(() => {
117
+ showToast("The selected clip is not available in the preview yet.", "info");
118
+ }, [showToast]);
119
+
120
+ useTimelineSelectionPreviewSync({
121
+ selectedElementId,
122
+ selectedElementIds,
123
+ timelineElements,
124
+ domEditSelection,
125
+ domEditGroupSelections,
126
+ activeCompPath,
127
+ buildDomSelectionForTimelineElement,
128
+ applyDomSelection,
129
+ applyMarqueeSelection,
130
+ onSelectionNotFound: reportTimelineSelectionNotFound,
131
+ });
106
132
 
107
133
  const timelineEditCallbacks = useTimelineEditCallbacks({
108
134
  handleTimelineElementMove,
@@ -12,7 +12,7 @@ import { TimelineToolbar } from "./TimelineToolbar";
12
12
 
13
13
  afterEach(() => {
14
14
  document.body.innerHTML = "";
15
- usePlayerStore.setState({ autoKeyframeEnabled: true });
15
+ usePlayerStore.setState({ autoKeyframeEnabled: true, thumbnailMode: "adaptive" });
16
16
  });
17
17
 
18
18
  function renderToolbar(
@@ -58,6 +58,25 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
58
58
  act(() => root.unmount());
59
59
  });
60
60
  });
61
+
62
+ describe("TimelineToolbar — adaptive thumbnails", () => {
63
+ it("keeps a user-controlled hidden mode as the rollback path", () => {
64
+ const { host, root } = renderToolbar();
65
+ const button = host.querySelector<HTMLButtonElement>(
66
+ 'button[aria-label="Hide thumbnails — labels only"]',
67
+ );
68
+ if (!button) throw new Error("thumbnail toggle not rendered");
69
+
70
+ act(() => button.click());
71
+
72
+ expect(usePlayerStore.getState().thumbnailMode).toBe("hidden");
73
+ expect(button.getAttribute("aria-label")).toBe(
74
+ "Show thumbnails — posters stay visible; richer previews appear on interaction",
75
+ );
76
+ act(() => root.unmount());
77
+ });
78
+ });
79
+
61
80
  describe("TimelineToolbar — motion path endpoints", () => {
62
81
  it("does not advertise a destructive keyframe toggle for a required endpoint", () => {
63
82
  usePlayerStore.setState({ currentTime: 10 });
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef } from "react";
2
- import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
2
+ import { Image, Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
3
3
  import {
4
4
  useEnableKeyframes,
5
5
  isPlayheadWithinTween,
@@ -111,6 +111,9 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
111
111
  const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
112
112
  const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
113
113
  const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
114
+ const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
115
+ const setThumbnailMode = usePlayerStore((s) => s.setThumbnailMode);
116
+ const thumbnailsVisible = thumbnailMode === "adaptive";
114
117
  // Subscribe so the add-beat button reacts to playhead movement and analysis load.
115
118
  const currentTime = usePlayerStore((s) => s.currentTime);
116
119
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
@@ -403,6 +406,31 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
403
406
  })()}
404
407
  </div>
405
408
  <div className="flex items-center gap-0.5">
409
+ <Tooltip
410
+ label={
411
+ thumbnailsVisible
412
+ ? "Hide thumbnails — labels only"
413
+ : "Show thumbnails — posters stay visible; richer previews appear on interaction"
414
+ }
415
+ >
416
+ <button
417
+ type="button"
418
+ aria-label={
419
+ thumbnailsVisible
420
+ ? "Hide thumbnails — labels only"
421
+ : "Show thumbnails — posters stay visible; richer previews appear on interaction"
422
+ }
423
+ aria-pressed={thumbnailsVisible}
424
+ onClick={() => setThumbnailMode(thumbnailsVisible ? "hidden" : "adaptive")}
425
+ className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
426
+ thumbnailsVisible
427
+ ? "bg-studio-accent/10 text-studio-accent"
428
+ : "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
429
+ }`}
430
+ >
431
+ <Image size={16} aria-hidden="true" />
432
+ </button>
433
+ </Tooltip>
406
434
  <Tooltip label="Fit timeline to width">
407
435
  <button
408
436
  type="button"
@@ -5,13 +5,15 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { CompositionThumbnail, VideoThumbnail } from "../player";
7
7
  import { AudioWaveform } from "../player/components/AudioWaveform";
8
- import type { TimelineElement } from "../player/store/playerStore";
8
+ import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
9
+ import { usePlayerStore, type TimelineElement } from "../player/store/playerStore";
9
10
  import { normalizeCompositionSrc } from "./useRenderClipContent";
10
11
  import { useRenderClipContent } from "./useRenderClipContent";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
14
15
  afterEach(() => {
16
+ usePlayerStore.setState({ thumbnailMode: "hidden" });
15
17
  document.body.innerHTML = "";
16
18
  });
17
19
 
@@ -67,6 +69,7 @@ describe("useRenderClipContent", () => {
67
69
  function renderClipContent(
68
70
  el: TimelineElement,
69
71
  activePreviewUrl: string | null = "/api/projects/my-project/preview",
72
+ context?: TimelineClipRenderContext,
70
73
  ): ReactNode {
71
74
  const host = document.createElement("div");
72
75
  document.body.append(host);
@@ -80,7 +83,7 @@ describe("useRenderClipContent", () => {
80
83
  activePreviewUrl,
81
84
  effectiveTimelineDuration: 12,
82
85
  });
83
- content = render(el, { clip: "#222", label: "#fff" });
86
+ content = render(el, { clip: "#222", label: "#fff" }, context);
84
87
  return null;
85
88
  }
86
89
 
@@ -105,7 +108,45 @@ describe("useRenderClipContent", () => {
105
108
  if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
106
109
  });
107
110
 
111
+ it("routes root-relative iframe media back through the active project", () => {
112
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
113
+ const resolvedRootMedia = `${window.location.origin}/assets/clip.mp4`;
114
+ const video = renderClipContent(
115
+ {
116
+ id: "video",
117
+ tag: "video",
118
+ start: 0,
119
+ duration: 4,
120
+ track: 0,
121
+ src: resolvedRootMedia,
122
+ },
123
+ null,
124
+ );
125
+ const audio = renderClipContent({
126
+ id: "audio",
127
+ tag: "audio",
128
+ start: 0,
129
+ duration: 4,
130
+ track: 1,
131
+ src: resolvedRootMedia,
132
+ });
133
+
134
+ expect(isValidElement<{ videoSrc: string }>(video)).toBe(true);
135
+ expect(isValidElement<{ audioUrl: string; waveformUrl: string }>(audio)).toBe(true);
136
+ if (isValidElement<{ videoSrc: string }>(video)) {
137
+ expect(video.props.videoSrc).toBe("/api/projects/my-project/preview/assets/clip.mp4");
138
+ }
139
+ if (isValidElement<{ audioUrl: string; waveformUrl: string }>(audio)) {
140
+ expect(audio.props).toMatchObject({
141
+ audioUrl: "/api/projects/my-project/preview/assets/clip.mp4",
142
+ waveformUrl: "/api/projects/my-project/waveform/assets/clip.mp4",
143
+ });
144
+ }
145
+ });
146
+
108
147
  it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
148
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
149
+
109
150
  const cases: Array<{ content: ReactNode; type: unknown }> = [
110
151
  {
111
152
  content: renderClipContent({
@@ -166,4 +207,38 @@ describe("useRenderClipContent", () => {
166
207
  }
167
208
  }
168
209
  });
210
+
211
+ it("forwards the viewport priority and interaction detail to media work", () => {
212
+ usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 });
213
+
214
+ const content = renderClipContent(
215
+ {
216
+ id: "clip-video",
217
+ tag: "video",
218
+ start: 0,
219
+ duration: 4,
220
+ track: 0,
221
+ src: "assets/clip.mp4",
222
+ },
223
+ null,
224
+ { priority: "interaction", rich: true },
225
+ );
226
+
227
+ expect(
228
+ isValidElement<{
229
+ projectId: string;
230
+ sessionEpoch: number;
231
+ priority: string;
232
+ rich: boolean;
233
+ }>(content),
234
+ ).toBe(true);
235
+ if (isValidElement(content)) {
236
+ expect(content.props).toMatchObject({
237
+ projectId: "my-project",
238
+ sessionEpoch: 7,
239
+ priority: "interaction",
240
+ rich: true,
241
+ });
242
+ }
243
+ });
169
244
  });
@@ -2,9 +2,12 @@ import { useCallback, type ReactNode } from "react";
2
2
  import { createElement } from "react";
3
3
  import { CompositionThumbnail, VideoThumbnail } from "../player";
4
4
  import type { TimelineElement } from "../player";
5
+ import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
5
6
  import { AudioWaveform } from "../player/components/AudioWaveform";
6
7
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
7
8
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
9
+ import { usePlayerStore } from "../player/store/playerStore";
10
+ import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
8
11
 
9
12
  export function normalizeCompositionSrc(
10
13
  compSrc: string,
@@ -24,12 +27,21 @@ export function normalizeCompositionSrc(
24
27
  }
25
28
 
26
29
  /** Resolve a media src to its project-relative preview path, or null. */
27
- function resolvePreviewRelative(src: string | undefined, pid: string): string | null {
30
+ function resolvePreviewRelative(
31
+ src: string | undefined,
32
+ pid: string,
33
+ origin: string,
34
+ ): string | null {
28
35
  if (!src) return null;
29
- if (!src.startsWith("http")) return src;
30
- const base = `/api/projects/${pid}/preview/`;
31
- const idx = src.indexOf(base);
32
- return idx !== -1 ? decodeURIComponent(src.slice(idx + base.length)) : null;
36
+ try {
37
+ const parsed = new URL(src, origin);
38
+ const base = new URL(`/api/projects/${pid}/preview/`, origin).pathname;
39
+ return parsed.pathname.startsWith(base)
40
+ ? decodeURIComponent(parsed.pathname.slice(base.length))
41
+ : null;
42
+ } catch {
43
+ return null;
44
+ }
33
45
  }
34
46
 
35
47
  /**
@@ -51,15 +63,19 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
51
63
  * Build the waveform element for an audio clip, windowing the rendered peaks to
52
64
  * the trimmed source slice so the bars track the clip edges.
53
65
  */
54
- function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
55
- const srcRelative = resolvePreviewRelative(el.src, pid);
66
+ function renderAudioClip(
67
+ el: TimelineElement,
68
+ pid: string,
69
+ sessionEpoch: number,
70
+ labelColor: string,
71
+ context: TimelineClipRenderContext,
72
+ ): ReactNode {
73
+ const audioUrl = resolveMediaPreviewUrl(el.src ?? "", pid, window.location.origin);
74
+ const srcRelative = resolvePreviewRelative(audioUrl, pid, window.location.origin);
56
75
  // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
57
76
  // what the assets panel loads — a raw segment 404s. resolvePreviewRelative
58
77
  // returns the DECODED path, so it must be re-encoded here.
59
78
  const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
60
- const audioUrl = encodedRelative
61
- ? `/api/projects/${pid}/preview/${encodedRelative}`
62
- : (el.src ?? "");
63
79
  const waveformUrl = encodedRelative
64
80
  ? `/api/projects/${pid}/waveform/${encodedRelative}`
65
81
  : undefined;
@@ -71,6 +87,9 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string):
71
87
  labelColor,
72
88
  trimStartFraction: start,
73
89
  trimEndFraction: end,
90
+ projectId: pid,
91
+ sessionEpoch,
92
+ priority: context.priority,
74
93
  });
75
94
  }
76
95
 
@@ -87,13 +106,29 @@ export function useRenderClipContent({
87
106
  activePreviewUrl,
88
107
  effectiveTimelineDuration,
89
108
  }: UseRenderClipContentOptions) {
109
+ // Self-sourced so the adaptive policy gates thumbnail generation without App plumbing.
110
+ const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
111
+ const effectiveMode = effectiveThumbnailMode(thumbnailMode);
112
+ const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
90
113
  return useCallback(
91
114
  // Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
92
115
  // fallow-ignore-next-line complexity
93
- (el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
116
+ (
117
+ el: TimelineElement,
118
+ style: { clip: string; label: string },
119
+ context: TimelineClipRenderContext = { priority: "visible", rich: false },
120
+ ): ReactNode => {
94
121
  const pid = projectIdRef.current;
95
122
  if (!pid) return null;
96
123
 
124
+ // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows
125
+ // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar.
126
+ if (effectiveMode === "hidden") {
127
+ return el.tag === "audio"
128
+ ? renderAudioClip(el, pid, sessionEpoch, style.label, context)
129
+ : null;
130
+ }
131
+
97
132
  let compSrc = el.compositionSrc;
98
133
  if (compSrc) {
99
134
  compSrc = normalizeCompositionSrc(compSrc, pid, window.location.origin);
@@ -116,6 +151,10 @@ export function useRenderClipContent({
116
151
 
117
152
  seekTime: 0,
118
153
  duration: el.duration,
154
+ projectId: pid,
155
+ sessionEpoch,
156
+ priority: context.priority,
157
+ rich: context.rich,
119
158
  });
120
159
  }
121
160
 
@@ -123,7 +162,7 @@ export function useRenderClipContent({
123
162
  // activePreviewUrl thumbnail branch; audio rows need waveform data, not a
124
163
  // captured frame from the currently drilled composition preview.
125
164
  if (el.tag === "audio") {
126
- return renderAudioClip(el, pid, style.label);
165
+ return renderAudioClip(el, pid, sessionEpoch, style.label, context);
127
166
  }
128
167
 
129
168
  // When drilled into a composition, render all inner elements via
@@ -138,6 +177,10 @@ export function useRenderClipContent({
138
177
  selectorIndex: el.selectorIndex,
139
178
  seekTime: el.start,
140
179
  duration: el.duration,
180
+ projectId: pid,
181
+ sessionEpoch,
182
+ priority: context.priority,
183
+ rich: context.rich,
141
184
  });
142
185
  }
143
186
 
@@ -148,7 +191,7 @@ export function useRenderClipContent({
148
191
  !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
149
192
 
150
193
  if ((el.tag === "video" || el.tag === "img") && el.src) {
151
- const mediaSrc = resolveMediaPreviewUrl(el.src, pid);
194
+ const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);
152
195
  // Still images can't be decoded by VideoThumbnail's <video> extractor
153
196
  // (the error event fires and the shimmer never resolves) — render the
154
197
  // image itself as the strip.
@@ -157,6 +200,10 @@ export function useRenderClipContent({
157
200
  imageSrc: mediaSrc,
158
201
  label: "",
159
202
  labelColor: style.label,
203
+ projectId: pid,
204
+ sessionEpoch,
205
+ priority: context.priority,
206
+ rich: context.rich,
160
207
  });
161
208
  }
162
209
  return createElement(VideoThumbnail, {
@@ -164,6 +211,12 @@ export function useRenderClipContent({
164
211
  label: "",
165
212
  labelColor: style.label,
166
213
  duration: el.duration,
214
+ sourceStart: el.playbackStart,
215
+ sourceRangeDuration: el.duration * (el.playbackRate ?? 1),
216
+ projectId: pid,
217
+ sessionEpoch,
218
+ priority: context.priority,
219
+ rich: context.rich,
167
220
  });
168
221
  }
169
222
 
@@ -177,11 +230,22 @@ export function useRenderClipContent({
177
230
  selectorIndex: el.selectorIndex,
178
231
  seekTime: el.start,
179
232
  duration: el.duration,
233
+ projectId: pid,
234
+ sessionEpoch,
235
+ priority: context.priority,
236
+ rich: context.rich,
180
237
  });
181
238
  }
182
239
 
183
240
  return null;
184
241
  },
185
- [projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
242
+ [
243
+ projectIdRef,
244
+ compIdToSrc,
245
+ activePreviewUrl,
246
+ effectiveTimelineDuration,
247
+ effectiveMode,
248
+ sessionEpoch,
249
+ ],
186
250
  );
187
251
  }