@hyperframes/studio 0.8.104 → 0.8.106

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 (225) hide show
  1. package/dist/assets/{hyperframes-player-DAoaajuM.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/index-CMiYVGv5.js +399 -0
  3. package/dist/assets/{index-WV9LroAD.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-BDK4rTMb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-HIGHVWYD.js → chunk-ICTZJPQQ.js} +3 -2
  9. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  10. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  11. package/dist/chunk-NCUBAVGK.js.map +1 -0
  12. package/dist/index.css +6 -1
  13. package/dist/index.css.map +1 -1
  14. package/dist/index.d.ts +15 -4
  15. package/dist/index.html +11 -2
  16. package/dist/index.js +10391 -9836
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +2 -2
  19. package/dist/styles/tailwind-preset.js.map +1 -1
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +2 -2
  22. package/package.json +9 -9
  23. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  24. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  25. package/src/captions/components/CaptionTimeline.tsx +3 -3
  26. package/src/captions/keyboard.test.ts +6 -0
  27. package/src/captions/keyboard.ts +2 -1
  28. package/src/components/AskAgentModal.tsx +1 -1
  29. package/src/components/LintModal.tsx +35 -35
  30. package/src/components/StudioErrorBoundary.tsx +1 -1
  31. package/src/components/StudioHeader.dom.test.tsx +35 -13
  32. package/src/components/StudioHeader.tsx +6 -2
  33. package/src/components/StudioToast.tsx +7 -16
  34. package/src/components/ThemeToggle.tsx +99 -0
  35. package/src/components/TimelineToolPicker.tsx +1 -1
  36. package/src/components/TimelineToolbar.tsx +11 -11
  37. package/src/components/dock/Dock.tsx +1 -1
  38. package/src/components/dock/dock.css +9 -1
  39. package/src/components/editor/AnimationCard.tsx +5 -5
  40. package/src/components/editor/AnimationCardParts.tsx +1 -1
  41. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  42. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  43. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  44. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  45. package/src/components/editor/DomEditOverlay.tsx +37 -5
  46. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  47. package/src/components/editor/EaseCurveSection.tsx +14 -19
  48. package/src/components/editor/EaseModeControls.tsx +2 -2
  49. package/src/components/editor/EaseParamFields.tsx +8 -8
  50. package/src/components/editor/FileTree.tsx +1 -1
  51. package/src/components/editor/FileTreeIcons.tsx +47 -26
  52. package/src/components/editor/FileTreeNodes.tsx +11 -8
  53. package/src/components/editor/GestureRecordControl.tsx +3 -3
  54. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  55. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  56. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  57. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  58. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  59. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  60. package/src/components/editor/LayersPanel.tsx +3 -3
  61. package/src/components/editor/PromotableControl.tsx +2 -2
  62. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  63. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  64. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  65. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  66. package/src/components/editor/SnapToolbar.tsx +5 -5
  67. package/src/components/editor/SourceEditor.tsx +2 -6
  68. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  69. package/src/components/editor/Transform3DCube.tsx +1 -1
  70. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  71. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  72. package/src/components/editor/easeCurveSvg.tsx +1 -1
  73. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  74. package/src/components/editor/manualEditsDom.ts +36 -3
  75. package/src/components/editor/manualEditsDomPatches.test.ts +2 -86
  76. package/src/components/editor/manualEditsDomPatches.ts +0 -30
  77. package/src/components/editor/manualEditsSnapshot.ts +1 -0
  78. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  79. package/src/components/editor/marqueeCommit.ts +6 -1
  80. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  81. package/src/components/editor/overlayFrameLoop.ts +21 -0
  82. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  83. package/src/components/editor/plainTranslate.ts +34 -3
  84. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  85. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  86. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  87. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  88. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  89. package/src/components/editor/propertyPanelFill.tsx +6 -6
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  92. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  93. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  94. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  97. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  98. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  99. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  101. package/src/components/editor/propertyPanelFont.tsx +71 -48
  102. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  106. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  107. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  108. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  109. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  110. package/src/components/editor/propertyPanelSections.tsx +2 -2
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  114. package/src/components/editor/studioCodeTheme.ts +99 -0
  115. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  116. package/src/components/editor/useDomEditNudge.ts +2 -2
  117. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  118. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  119. package/src/components/editor/useMotionPathData.ts +5 -1
  120. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  121. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  122. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  123. package/src/components/nle/NLEPreview.test.ts +8 -4
  124. package/src/components/nle/PreviewOverlays.tsx +1 -1
  125. package/src/components/nle/PreviewPane.tsx +3 -3
  126. package/src/components/nle/PreviewPoster.tsx +29 -1
  127. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  128. package/src/components/panels/SlideshowPanel.tsx +4 -4
  129. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  130. package/src/components/panels/VariablesBindElement.tsx +1 -1
  131. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  132. package/src/components/panels/VariablesPanel.tsx +4 -6
  133. package/src/components/panels/VariablesRowAction.tsx +1 -1
  134. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  135. package/src/components/renders/RenderQueue.tsx +2 -2
  136. package/src/components/renders/RenderQueueItem.tsx +3 -3
  137. package/src/components/sidebar/AssetCard.tsx +3 -3
  138. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  139. package/src/components/sidebar/AssetsTab.tsx +6 -6
  140. package/src/components/sidebar/AudioRow.tsx +5 -3
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  143. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  144. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  145. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  148. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  149. package/src/components/timelineToolbarStyles.ts +3 -3
  150. package/src/components/ui/Button.tsx +4 -4
  151. package/src/components/ui/HyperframesLogo.tsx +19 -18
  152. package/src/components/ui/Input.tsx +1 -1
  153. package/src/components/ui/Menu.tsx +4 -2
  154. package/src/hooks/appHotkeysDispatch.ts +2 -1
  155. package/src/hooks/elementOffsetStager.ts +8 -7
  156. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  157. package/src/hooks/useAppHotkeys.test.ts +32 -0
  158. package/src/hooks/useAppHotkeys.ts +1 -2
  159. package/src/hooks/useClipboard.source.test.ts +141 -23
  160. package/src/hooks/useClipboard.ts +154 -123
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.test.tsx +76 -72
  163. package/src/hooks/useDomEditSession.ts +7 -43
  164. package/src/hooks/useDomGeometryCommit.test.tsx +4 -1
  165. package/src/hooks/useDomGeometryCommit.ts +0 -1
  166. package/src/hooks/useDomGeometryCommits.test.tsx +177 -32
  167. package/src/hooks/useDomGeometryCommits.ts +4 -49
  168. package/src/hooks/useGsapTweenCache.ts +1 -2
  169. package/src/hooks/useInlineTextEdit.ts +16 -2
  170. package/src/hooks/useLivePreviewIframe.ts +18 -0
  171. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  172. package/src/hooks/usePreviewPersistence.ts +2 -5
  173. package/src/hooks/useRenderClipContent.ts +3 -3
  174. package/src/index.ts +2 -0
  175. package/src/livePreviewIframeExport.test.tsx +33 -0
  176. package/src/main.tsx +4 -1
  177. package/src/player/components/AudioWaveform.test.tsx +36 -15
  178. package/src/player/components/AudioWaveform.tsx +24 -7
  179. package/src/player/components/ClipContextMenu.tsx +1 -1
  180. package/src/player/components/CompositionThumbnail.tsx +23 -11
  181. package/src/player/components/EditModal.tsx +6 -6
  182. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  183. package/src/player/components/PlayerControls.tsx +3 -3
  184. package/src/player/components/ShortcutsPanel.tsx +8 -20
  185. package/src/player/components/TimelineCanvas.tsx +1 -1
  186. package/src/player/components/TimelineClip.test.tsx +38 -0
  187. package/src/player/components/TimelineClip.tsx +16 -8
  188. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  189. package/src/player/components/TimelineClipFades.tsx +368 -137
  190. package/src/player/components/TimelineEmptyState.tsx +2 -2
  191. package/src/player/components/TimelineLanes.tsx +0 -1
  192. package/src/player/components/TrackClipCount.tsx +1 -1
  193. package/src/player/components/VolumeControl.tsx +1 -1
  194. package/src/player/components/timelineClipChildren.tsx +1 -1
  195. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  196. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  197. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  198. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  199. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  200. package/src/player/lib/automationStoreSync.test.ts +18 -1
  201. package/src/player/lib/automationStoreSync.ts +29 -17
  202. package/src/player/lib/thumbnailScheduler.ts +4 -5
  203. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  204. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  205. package/src/player/sceneSwap.ts +18 -0
  206. package/src/styles/components.css +26 -0
  207. package/src/styles/hex-baseline.json +7 -19
  208. package/src/styles/studio.css +4 -7
  209. package/src/styles/tailwind-preset.shared.js +2 -1
  210. package/src/styles/theme.css +214 -147
  211. package/src/styles/theme.test.ts +13 -9
  212. package/src/utils/blockCategories.ts +41 -9
  213. package/src/utils/clipboardPayload.ts +12 -1
  214. package/src/utils/studioPendingEdits.ts +21 -0
  215. package/src/utils/studioTheme.test.tsx +141 -0
  216. package/src/utils/studioTheme.ts +34 -0
  217. package/src/utils/studioUiPreferences.ts +3 -0
  218. package/src/utils/typingTarget.test.ts +8 -1
  219. package/src/utils/typingTarget.ts +5 -0
  220. package/dist/assets/index-CQN3M56N.css +0 -1
  221. package/dist/assets/index-De2aO4XM.js +0 -399
  222. package/dist/chunk-62DDWOSH.js.map +0 -1
  223. package/dist/chunk-6GAO75IU.js.map +0 -1
  224. package/dist/chunk-HIGHVWYD.js.map +0 -1
  225. package/src/hooks/gsapTargetCache.ts +0 -62
@@ -1,57 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { act } from "react";
4
- import { createRoot, type Root } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { usePlayerStore } from "../../player/store/playerStore";
3
+ import { describe, expect, it } from "vitest";
7
4
  import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
8
- import { CompositionsTab } from "./CompositionsTab";
5
+ import { mountCompositionsTab } from "./compositionsTabTestUtils";
9
6
 
10
- Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
11
- (
12
- window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
13
- ).happyDOM.settings.disableIframePageLoading = true;
14
-
15
- let root: Root | null = null;
16
-
17
- afterEach(() => {
18
- if (root) act(() => root?.unmount());
19
- root = null;
20
- document.body.innerHTML = "";
21
- usePlayerStore.setState({ thumbnailRevisions: {} });
22
- });
23
-
24
- function mount(compositions: string[], masterCompositionPath: string | null) {
25
- const host = document.createElement("div");
26
- document.body.append(host);
27
- root = createRoot(host);
28
- act(() => {
29
- root?.render(
30
- <CompositionsTab
31
- projectId="demo"
32
- compositions={compositions}
33
- activeComposition={null}
34
- masterCompositionPath={masterCompositionPath}
35
- onSelect={vi.fn()}
36
- />,
37
- );
38
- });
39
- return host;
40
- }
41
-
42
- describe("CompositionsTab card thumbnails", () => {
43
- it("waits for the live preview to boot and keeps them through edit reloads", () => {
44
- usePlayerStore.getState().reset();
45
- const host = mount(["index.html"], "index.html");
46
- expect(host.querySelector("img")).toBeNull();
47
-
48
- act(() => usePlayerStore.getState().markPreviewBooted());
49
- expect(host.querySelector("img")?.getAttribute("src")).toContain("/thumbnail/index.html");
50
-
51
- act(() => usePlayerStore.getState().setTimelineReady(false));
52
- expect(host.querySelector("img")).not.toBeNull();
53
- });
54
- });
7
+ const mount = (compositions: string[], masterCompositionPath: string | null) =>
8
+ mountCompositionsTab({ compositions, masterCompositionPath });
55
9
 
56
10
  describe("CompositionsTab root badge", () => {
57
11
  it("marks the composition matching masterCompositionPath as root", () => {
@@ -0,0 +1,256 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { thumbnailScheduler } from "../../player/lib/thumbnailScheduler";
7
+ import { TIMELINE_VIEWPORT_BUDGETS } from "../../player/lib/timelineViewportBudgets";
8
+ import { CompositionThumbnail } from "../../player/components/CompositionThumbnail";
9
+ import { usePlayerStore } from "../../player/store/playerStore";
10
+ import { renderPosterForNextOpen } from "../nle/PreviewPoster";
11
+ import { mountCompositionsTab } from "./compositionsTabTestUtils";
12
+
13
+ class DecodingImage {
14
+ onload: (() => void) | null = null;
15
+ onerror: (() => void) | null = null;
16
+ naturalWidth = 16;
17
+ naturalHeight = 9;
18
+ set src(value: string) {
19
+ if (value) queueMicrotask(() => this.onload?.());
20
+ }
21
+ }
22
+
23
+ let autoIntersect = true;
24
+ const observed: Array<{
25
+ element: Element;
26
+ callback: IntersectionObserverCallback;
27
+ root?: Element | Document | null;
28
+ }> = [];
29
+ class ViewportObserver {
30
+ constructor(
31
+ private readonly callback: IntersectionObserverCallback,
32
+ private readonly options?: IntersectionObserverInit,
33
+ ) {}
34
+ observe(element: Element) {
35
+ observed.push({ element, callback: this.callback, root: this.options?.root });
36
+ if (autoIntersect) this.enter();
37
+ }
38
+ enter() {
39
+ const entry = { isIntersecting: true } as IntersectionObserverEntry;
40
+ this.callback([entry], this as unknown as IntersectionObserver);
41
+ }
42
+ disconnect() {}
43
+ }
44
+
45
+ interface Render {
46
+ url: URL;
47
+ signal: AbortSignal;
48
+ answer: (status?: number) => void;
49
+ }
50
+
51
+ const real = {
52
+ IntersectionObserver: globalThis.IntersectionObserver,
53
+ fetch: globalThis.fetch,
54
+ Image: globalThis.Image,
55
+ create: URL.createObjectURL,
56
+ revoke: URL.revokeObjectURL,
57
+ };
58
+ let renders: Render[] = [];
59
+ let inFlight = 0;
60
+ let peak = 0;
61
+ let frames = 0;
62
+
63
+ beforeEach(() => {
64
+ renders = [];
65
+ [inFlight, peak, frames] = [0, 0, 0];
66
+ globalThis.fetch = vi.fn(
67
+ (input: RequestInfo | URL, init?: RequestInit) =>
68
+ new Promise<Response>((resolve, reject) => {
69
+ const signal = init?.signal ?? new AbortController().signal;
70
+ peak = Math.max(peak, ++inFlight);
71
+ const end = () => (inFlight -= 1);
72
+ signal.addEventListener("abort", () => {
73
+ end();
74
+ reject(new DOMException("Aborted", "AbortError"));
75
+ });
76
+ const answer = (status = 200) => {
77
+ end();
78
+ resolve(new Response(new Blob(["png"]), { status }));
79
+ };
80
+ renders.push({ url: new URL(String(input), window.location.origin), signal, answer });
81
+ }),
82
+ ) as typeof fetch;
83
+ globalThis.Image = DecodingImage as unknown as typeof Image;
84
+ globalThis.IntersectionObserver = ViewportObserver as unknown as typeof IntersectionObserver;
85
+ [autoIntersect, observed.length] = [true, 0];
86
+ URL.createObjectURL = vi.fn(() => `blob:frame-${++frames}`);
87
+ URL.revokeObjectURL = vi.fn();
88
+ usePlayerStore.setState({ previewBooted: true });
89
+ });
90
+
91
+ afterEach(() => {
92
+ Object.assign(globalThis, {
93
+ fetch: real.fetch,
94
+ Image: real.Image,
95
+ IntersectionObserver: real.IntersectionObserver,
96
+ });
97
+ Object.assign(URL, { createObjectURL: real.create, revokeObjectURL: real.revoke });
98
+ });
99
+
100
+ const mount = (compositions?: string[]) => mountCompositionsTab(compositions && { compositions });
101
+ const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 5)));
102
+ const shown = (host: HTMLElement) => host.querySelector("img")?.getAttribute("src") ?? null;
103
+
104
+ async function nextRevisionShows(host: HTMLElement, revision: string, frame: string) {
105
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
106
+ expect(renders.at(-1)!.url.searchParams.get("revision")).toBe(revision);
107
+ renders.at(-1)!.answer();
108
+ await settle();
109
+ expect(shown(host)).toBe(frame);
110
+ }
111
+
112
+ describe("composition card thumbnails", () => {
113
+ it("render through the thumbnail scheduler once the preview boots, and never point an image at the route", async () => {
114
+ usePlayerStore.getState().reset();
115
+ const host = mount();
116
+ expect(renders).toHaveLength(0);
117
+
118
+ act(() => usePlayerStore.getState().markPreviewBooted());
119
+ expect(renders.map((r) => [r.url.pathname, r.url.searchParams.get("t")])).toEqual([
120
+ ["/api/projects/demo/thumbnail/compositions/headline.html", "3.00"],
121
+ ]);
122
+ renders[0]!.answer();
123
+ await settle();
124
+ expect(shown(host)).toBe("blob:frame-1");
125
+ expect(host.querySelector("iframe")).toBeNull();
126
+
127
+ act(() => usePlayerStore.getState().setTimelineReady(false));
128
+ expect(shown(host)).toBe("blob:frame-1");
129
+ });
130
+
131
+ it("wait until a card is near the viewport, so an offscreen card never renders ahead of a visible one", () => {
132
+ autoIntersect = false;
133
+ mount(["compositions/above.html", "compositions/seen.html", "compositions/below.html"]);
134
+ expect(renders).toHaveLength(0);
135
+
136
+ const seen = observed.find((o) =>
137
+ o.element.closest("[draggable]")?.textContent?.includes("seen"),
138
+ )!;
139
+ act(() =>
140
+ seen.callback(
141
+ [{ isIntersecting: true } as IntersectionObserverEntry],
142
+ {} as IntersectionObserver,
143
+ ),
144
+ );
145
+ expect(renders.map((r) => r.url.pathname)).toEqual([
146
+ "/api/projects/demo/thumbnail/compositions/seen.html",
147
+ ]);
148
+ expect(seen.root).toBe(document.querySelector("[data-composition-list]"));
149
+ });
150
+
151
+ it("keep showing the last frame, leased, until the next revision's frame is ready", async () => {
152
+ const host = mount();
153
+ renders[0]!.answer();
154
+ await settle();
155
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
156
+ expect(shown(host)).toBe("blob:frame-1");
157
+ thumbnailScheduler.invalidateProject("demo");
158
+ expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-1");
159
+
160
+ renders.at(-1)!.answer();
161
+ await settle();
162
+ expect(shown(host)).toBe("blob:frame-2");
163
+ });
164
+
165
+ it("keep the last frame through a new revision even when the scheduler has evicted it", async () => {
166
+ const host = mount();
167
+ renders[0]!.answer();
168
+ await settle();
169
+ const fillers = Array.from({ length: 100 }, (_, i) =>
170
+ thumbnailScheduler.acquire(
171
+ {
172
+ key: `filler-${i}`,
173
+ projectId: "demo",
174
+ sessionEpoch: 0,
175
+ kind: "image",
176
+ priority: "visible",
177
+ load: async () => ({
178
+ value: { kind: "image", url: `blob:filler-${i}`, aspect: 1 },
179
+ weight: 1,
180
+ }),
181
+ },
182
+ () => {},
183
+ ),
184
+ );
185
+ while (
186
+ thumbnailScheduler.getDiagnostics().queued + thumbnailScheduler.getDiagnostics().active >
187
+ 0
188
+ ) {
189
+ await settle();
190
+ }
191
+ for (const filler of fillers) filler.release();
192
+
193
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
194
+ expect(renders.map((r) => r.url.searchParams.get("revision"))).toEqual(["0", "1"]);
195
+ expect(shown(host)).toBe("blob:frame-1");
196
+ expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-1");
197
+ });
198
+
199
+ it("share one render with the timeline's thumbnail of the same composition", () => {
200
+ mount();
201
+ const timeline = createRoot(document.body.appendChild(document.createElement("div")));
202
+ try {
203
+ act(() =>
204
+ timeline.render(
205
+ <CompositionThumbnail
206
+ previewUrl="/api/projects/demo/preview/comp/compositions/headline.html"
207
+ label=""
208
+ labelColor=""
209
+ projectId="demo"
210
+ sessionEpoch={usePlayerStore.getState().timelineSessionEpoch}
211
+ seekTime={3}
212
+ duration={0}
213
+ />,
214
+ ),
215
+ );
216
+ expect(renders).toHaveLength(1);
217
+ } finally {
218
+ act(() => timeline.unmount());
219
+ }
220
+ });
221
+
222
+ it("keep the cards and the poster render to the scheduler's cap, so Studio's own requests get a connection", async () => {
223
+ const cap = TIMELINE_VIEWPORT_BUDGETS.concurrentCompositionFetches;
224
+ mount(Array.from({ length: 6 }, (_, i) => `compositions/scene-${i}.html`));
225
+ renderPosterForNextOpen("demo");
226
+ expect(inFlight).toBe(cap);
227
+
228
+ while (renders.some((r) => !r.signal.aborted)) {
229
+ renders.shift()!.answer();
230
+ await settle();
231
+ }
232
+ expect(renders).toHaveLength(0);
233
+ expect(globalThis.fetch).toHaveBeenCalledTimes(7);
234
+ expect(peak).toBe(cap);
235
+ });
236
+
237
+ it("abort a stale revision's render, and free its frame when the scheduler evicts it", async () => {
238
+ const host = mount();
239
+ const first = renders[0]!;
240
+ await nextRevisionShows(host, "1", "blob:frame-1");
241
+ expect(first.signal.aborted).toBe(true);
242
+ await nextRevisionShows(host, "2", "blob:frame-2");
243
+
244
+ thumbnailScheduler.invalidateProject("demo");
245
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:frame-1");
246
+ expect(URL.revokeObjectURL).not.toHaveBeenCalledWith("blob:frame-2");
247
+ });
248
+
249
+ it("show a fallback when the render fails, and retry it at the next content revision", async () => {
250
+ const host = mount();
251
+ renders[0]!.answer(500);
252
+ await settle();
253
+ expect(host.textContent).toContain("Preview unavailable");
254
+ await nextRevisionShows(host, "1", "blob:frame-1");
255
+ });
256
+ });
@@ -1,7 +1,14 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
- import { memo, useCallback, useEffect, useRef, useState } from "react";
2
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
+ import {
5
+ thumbnailScheduler,
6
+ type ThumbnailRequest,
7
+ type ThumbnailSnapshot,
8
+ } from "../../player/lib/thumbnailScheduler";
3
9
  import {
4
10
  buildCompositionThumbnailUrl,
11
+ compositionThumbnailRequest,
5
12
  resolveThumbnailSeekTime,
6
13
  THUMBNAIL_SEEK_TIME_SECONDS,
7
14
  } from "../../player/components/CompositionThumbnail";
@@ -125,6 +132,38 @@ export function syncIframePlayback(iframe: HTMLIFrameElement | null, shouldPlay:
125
132
  }
126
133
  }
127
134
 
135
+ const imageUrlOf = (snapshot: ThumbnailSnapshot) =>
136
+ snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value.url : null;
137
+
138
+ // A layout effect takes the old frame's lease before the passive cleanup drops the first one.
139
+ function useLastFrame(request: ThumbnailRequest | null, snapshot: ThumbnailSnapshot) {
140
+ const current = imageUrlOf(snapshot);
141
+ const [kept, setKept] = useState<{ request: ThumbnailRequest; url: string } | null>(null);
142
+ if (request && current && kept?.request !== request) setKept({ request, url: current });
143
+ useLayoutEffect(() => {
144
+ if (!kept || kept.request === request) return;
145
+ const lease = thumbnailScheduler.acquire(kept.request, () => {});
146
+ return () => lease.release();
147
+ }, [kept, request]);
148
+ return current ?? kept?.url ?? null;
149
+ }
150
+
151
+ function useNearViewport<T extends Element>(): [(element: T | null) => void, boolean] {
152
+ const [element, setElement] = useState<T | null>(null);
153
+ const [near, setNear] = useState(false);
154
+ useEffect(() => {
155
+ if (!element || near) return;
156
+ if (typeof IntersectionObserver === "undefined") return setNear(true);
157
+ const observer = new IntersectionObserver(
158
+ (entries) => entries.some((entry) => entry.isIntersecting) && setNear(true),
159
+ { root: element.closest("[data-composition-list]"), rootMargin: "200px" },
160
+ );
161
+ observer.observe(element);
162
+ return () => observer.disconnect();
163
+ }, [element, near]);
164
+ return [setElement, near];
165
+ }
166
+
128
167
  function CompCard({
129
168
  projectId,
130
169
  comp,
@@ -153,7 +192,6 @@ function CompCard({
153
192
  const [hovered, setHovered] = useState(false);
154
193
  const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
155
194
  const [livePreviewLoaded, setLivePreviewLoaded] = useState(false);
156
- const [failedThumbnailUrl, setFailedThumbnailUrl] = useState<string | null>(null);
157
195
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
158
196
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
159
197
  const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -192,7 +230,21 @@ function CompCard({
192
230
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
193
231
  const previewUrl = compositionPreviewUrl(projectId, comp);
194
232
  const thumbnailUrl = compositionCardThumbnailUrl(projectId, comp, contentRevision);
195
- const thumbnailFailed = failedThumbnailUrl === thumbnailUrl;
233
+ const [thumbnailBox, nearViewport] = useNearViewport<HTMLDivElement>();
234
+ const timelineSessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
235
+ const thumbnailRequest = useMemo(
236
+ () =>
237
+ previewBooted && nearViewport
238
+ ? compositionThumbnailRequest(thumbnailUrl, projectId, {
239
+ sessionEpoch: timelineSessionEpoch,
240
+ rich: true,
241
+ })
242
+ : null,
243
+ [previewBooted, nearViewport, thumbnailUrl, projectId, timelineSessionEpoch],
244
+ );
245
+ const thumbnail = useThumbnailLease(thumbnailRequest);
246
+ const frameUrl = useLastFrame(thumbnailRequest, thumbnail);
247
+ const thumbnailFailed = thumbnail.status === "error";
196
248
  const previewScale = resolveCompositionPreviewScale({
197
249
  cardWidth: CARD_W,
198
250
  cardHeight: CARD_H,
@@ -250,19 +302,20 @@ function CompCard({
250
302
  : "border-l-2 border-transparent hover:bg-neutral-800/50"
251
303
  }`}
252
304
  >
253
- <div className="w-20 h-[45px] rounded-sm overflow-hidden bg-neutral-900 shrink-0 relative">
305
+ <div
306
+ ref={thumbnailBox}
307
+ className="w-20 h-[45px] rounded-sm overflow-hidden bg-neutral-900 shrink-0 relative"
308
+ >
254
309
  {thumbnailFailed ? (
255
310
  <div className="absolute inset-0 flex items-center justify-center px-1 text-center text-[8px] leading-tight text-neutral-600">
256
311
  Preview unavailable
257
312
  </div>
258
- ) : !previewBooted ? null : (
313
+ ) : !frameUrl ? null : (
259
314
  <img
260
- src={thumbnailUrl}
315
+ src={frameUrl}
261
316
  alt=""
262
317
  draggable={false}
263
- loading="lazy"
264
318
  decoding="async"
265
- onError={() => setFailedThumbnailUrl(thumbnailUrl)}
266
319
  className={`absolute inset-0 h-full w-full object-contain transition-opacity ${
267
320
  livePreviewLoaded ? "opacity-0" : "opacity-100"
268
321
  }`}
@@ -320,7 +373,7 @@ function CompCard({
320
373
  {lintInfo && lintInfo.count > 0 && (
321
374
  <span
322
375
  aria-label={`${lintInfo.count} lint finding${lintInfo.count === 1 ? "" : "s"}`}
323
- className="shrink-0 min-w-[16px] text-center rounded-full bg-amber-500/20 px-1 text-[8px] font-bold text-amber-400"
376
+ className="shrink-0 min-w-[16px] text-center rounded-full bg-amber-500/20 px-1 text-[8px] font-bold text-warning-ink"
324
377
  >
325
378
  {lintInfo.count}
326
379
  </span>
@@ -337,7 +390,7 @@ function CompCard({
337
390
  event.stopPropagation();
338
391
  onAddToTimeline();
339
392
  }}
340
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-studio-accent group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
393
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm text-neutral-600 opacity-0 transition-[color,background-color,opacity] hover:bg-neutral-800 hover:text-accent-ink group-hover/card:opacity-100 group-focus-within/card:opacity-100 focus:opacity-100"
341
394
  >
342
395
  <span aria-hidden="true">+</span>
343
396
  </button>
@@ -358,7 +411,7 @@ function CompCard({
358
411
  className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors ${
359
412
  isRendering
360
413
  ? "text-neutral-600 cursor-not-allowed"
361
- : "text-neutral-600 hover:text-studio-accent hover:bg-neutral-800"
414
+ : "text-neutral-600 hover:text-accent-ink hover:bg-neutral-800"
362
415
  }`}
363
416
  >
364
417
  <svg
@@ -405,7 +458,7 @@ export const CompositionsTab = memo(function CompositionsTab({
405
458
  }
406
459
 
407
460
  return (
408
- <div className="flex-1 overflow-y-auto">
461
+ <div className="flex-1 overflow-y-auto" data-composition-list>
409
462
  {compositions.map((comp) => (
410
463
  <CompCard
411
464
  key={`${projectId}:${comp}`}
@@ -106,10 +106,10 @@ export function PromptPreviewModal({
106
106
  <button
107
107
  className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors active:scale-[0.97] ${
108
108
  copyState === "copied"
109
- ? "bg-emerald-500 text-white"
109
+ ? "bg-accent text-on-accent"
110
110
  : copyState === "failed"
111
- ? "bg-red-500 text-white"
112
- : "bg-studio-accent/90 text-neutral-950 hover:bg-studio-accent"
111
+ ? "bg-danger text-on-danger"
112
+ : "bg-accent text-on-accent hover:bg-accent-hover"
113
113
  }`}
114
114
  onClick={handleCopy}
115
115
  >
@@ -17,7 +17,7 @@ export function SidebarLintButton({
17
17
  type="button"
18
18
  onClick={onLint}
19
19
  disabled={linting}
20
- className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 enabled:hover:text-amber-300 enabled:hover:bg-neutral-800 enabled:active:scale-[0.98] transition-colors disabled:opacity-40"
20
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 enabled:hover:text-warning-ink enabled:hover:bg-neutral-800 enabled:active:scale-[0.98] transition-colors disabled:opacity-40"
21
21
  >
22
22
  <svg
23
23
  width="12"
@@ -38,8 +38,8 @@ export function SidebarLintButton({
38
38
  style={hasError ? { animationIterationCount: ERROR_PULSES_BEFORE_IDLE } : undefined}
39
39
  className={
40
40
  hasError
41
- ? "ml-1 min-w-[16px] rounded-full bg-panel-danger/25 px-1 text-[9px] font-bold text-panel-danger animate-pulse motion-reduce:animate-none"
42
- : "ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-amber-400"
41
+ ? "ml-1 min-w-[16px] rounded-full bg-danger px-1 text-[9px] font-bold text-on-danger animate-pulse motion-reduce:animate-none"
42
+ : "ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-warning-ink"
43
43
  }
44
44
  >
45
45
  {findingCount}
@@ -0,0 +1,39 @@
1
+ import { act, type ComponentProps } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, vi } from "vitest";
4
+ import { thumbnailScheduler } from "../../player/lib/thumbnailScheduler";
5
+ import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { CompositionsTab } from "./CompositionsTab";
7
+
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
+ (
10
+ window as unknown as { happyDOM: { settings: { disableIframePageLoading: boolean } } }
11
+ ).happyDOM.settings.disableIframePageLoading = true;
12
+
13
+ let root: Root | null = null;
14
+
15
+ afterEach(() => {
16
+ if (root) act(() => root?.unmount());
17
+ root = null;
18
+ thumbnailScheduler.invalidateProject("demo");
19
+ document.body.innerHTML = "";
20
+ usePlayerStore.setState({ thumbnailRevisions: {} });
21
+ });
22
+
23
+ export function mountCompositionsTab(props: Partial<ComponentProps<typeof CompositionsTab>> = {}) {
24
+ const host = document.createElement("div");
25
+ document.body.append(host);
26
+ root = createRoot(host);
27
+ act(() => {
28
+ root?.render(
29
+ <CompositionsTab
30
+ projectId="demo"
31
+ compositions={["compositions/headline.html"]}
32
+ activeComposition={null}
33
+ onSelect={vi.fn()}
34
+ {...props}
35
+ />,
36
+ );
37
+ });
38
+ return host;
39
+ }
@@ -1,5 +1,5 @@
1
1
  // CapCut-flat icon buttons: a transparent 28px hit area with a subtle hover wash.
2
2
  export const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
3
- export const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/6 hover:text-neutral-200 active:scale-[0.98]`;
4
- export const flatActive = `${flatBtn} bg-white/8 text-neutral-100 active:scale-[0.98]`;
5
- export const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
3
+ export const flatIdle = `${flatBtn} text-text-2 hover:bg-hover hover:text-text-0 active:bg-press active:scale-[0.98]`;
4
+ export const flatActive = `${flatBtn} bg-on text-text-0 hover:bg-on-hover active:scale-[0.98]`;
5
+ export const flatDisabled = `${flatBtn} text-text-off cursor-not-allowed`;
@@ -50,8 +50,8 @@ export const buttonBase = cn(
50
50
  */
51
51
  export const buttonVariants: Record<ButtonVariant, string> = {
52
52
  primary: cn(
53
- "bg-accent text-bg-0 font-semibold",
54
- "enabled:hover:brightness-110 data-[preview-state=hover]:brightness-110",
53
+ "bg-accent text-on-accent font-semibold",
54
+ "enabled:hover:bg-accent-hover data-[preview-state=hover]:bg-accent-hover",
55
55
  "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
56
56
  ),
57
57
  secondary: cn(
@@ -60,8 +60,8 @@ export const buttonVariants: Record<ButtonVariant, string> = {
60
60
  "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
61
61
  ),
62
62
  danger: cn(
63
- "bg-danger text-text-0 font-medium",
64
- "enabled:hover:brightness-110 data-[preview-state=hover]:brightness-110",
63
+ "bg-danger text-on-danger font-medium",
64
+ "enabled:hover:brightness-95 data-[preview-state=hover]:brightness-95",
65
65
  "enabled:active:scale-[0.98] data-[preview-state=active]:scale-[0.98]",
66
66
  ),
67
67
  ghost: cn(