@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -36,23 +36,66 @@ export interface StartRenderOptions {
36
36
  composition?: string;
37
37
  }
38
38
 
39
+ // "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
40
+ // remembered here so hidden renders don't resurrect from the on-disk history
41
+ // on the next load. Per-project key so projects don't hide each other's rows.
42
+ function hiddenIdsKey(projectId: string): string {
43
+ return `hf-studio-hidden-renders:${projectId}`;
44
+ }
45
+
46
+ function readHiddenIds(projectId: string): Set<string> {
47
+ try {
48
+ const raw = window.localStorage.getItem(hiddenIdsKey(projectId));
49
+ const parsed: unknown = raw ? JSON.parse(raw) : [];
50
+ return new Set(Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []);
51
+ } catch {
52
+ return new Set();
53
+ }
54
+ }
55
+
56
+ function writeHiddenIds(projectId: string, ids: Set<string>): void {
57
+ try {
58
+ // Cap the list so it doesn't grow unbounded across months of renders.
59
+ window.localStorage.setItem(hiddenIdsKey(projectId), JSON.stringify([...ids].slice(-200)));
60
+ } catch {
61
+ /* localStorage may be unavailable or full */
62
+ }
63
+ }
64
+
39
65
  export function useRenderQueue(projectId: string | null) {
40
66
  const [jobs, setJobs] = useState<RenderJob[]>([]);
67
+ // History fetch failure — distinguished from "no renders yet" so the panel
68
+ // never shows a false empty state.
69
+ const [loadError, setLoadError] = useState<string | null>(null);
70
+ // Failure of a user action (delete/cancel), surfaced inline in the panel.
71
+ const [actionError, setActionError] = useState<string | null>(null);
41
72
  const eventSourceRef = useRef<EventSource | null>(null);
42
73
  const activeJobRef = useRef<string | null>(null);
43
74
 
75
+ const closeActiveEventSource = useCallback((jobId?: string) => {
76
+ if (jobId && activeJobRef.current !== jobId) return;
77
+ eventSourceRef.current?.close();
78
+ eventSourceRef.current = null;
79
+ activeJobRef.current = null;
80
+ }, []);
81
+
44
82
  // Load completed renders from the server
45
83
  const loadRenders = useCallback(async () => {
46
84
  if (!projectId) return;
47
85
  try {
48
86
  const res = await fetch(`/api/projects/${projectId}/renders`);
49
- if (!res.ok) return;
87
+ if (!res.ok) {
88
+ setLoadError(`Couldn't load render history (server error ${res.status}).`);
89
+ return;
90
+ }
50
91
  const data = await res.json();
92
+ setLoadError(null);
51
93
  if (Array.isArray(data.renders)) {
94
+ const hidden = readHiddenIds(projectId);
52
95
  setJobs((prev) => {
53
96
  const existing = new Set(prev.map((j) => j.id));
54
97
  const fromServer: RenderJob[] = data.renders
55
- .filter((r: { id: string }) => !existing.has(r.id))
98
+ .filter((r: { id: string }) => !existing.has(r.id) && !hidden.has(r.id))
56
99
  .map(
57
100
  (r: {
58
101
  id: string;
@@ -74,7 +117,7 @@ export function useRenderQueue(projectId: string | null) {
74
117
  });
75
118
  }
76
119
  } catch {
77
- // ignore
120
+ setLoadError("Couldn't load render history. Is the studio server running?");
78
121
  }
79
122
  }, [projectId]);
80
123
 
@@ -175,6 +218,8 @@ export function useRenderQueue(projectId: string | null) {
175
218
  es.addEventListener("progress", (event) => {
176
219
  try {
177
220
  const data = JSON.parse(event.data);
221
+ const terminal =
222
+ data.status === "complete" || data.status === "failed" || data.status === "cancelled";
178
223
  setJobs((prev) =>
179
224
  prev.map((j) =>
180
225
  j.id === jobId
@@ -182,21 +227,15 @@ export function useRenderQueue(projectId: string | null) {
182
227
  ...j,
183
228
  progress: data.progress ?? j.progress,
184
229
  stage: data.stage ?? data.message ?? j.stage,
185
- status:
186
- data.status === "complete"
187
- ? "complete"
188
- : data.status === "failed"
189
- ? "failed"
190
- : j.status,
230
+ status: terminal ? (data.status as RenderJob["status"]) : j.status,
191
231
  durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
192
232
  error: data.error ?? j.error,
193
233
  }
194
234
  : j,
195
235
  ),
196
236
  );
197
- if (data.status === "complete" || data.status === "failed") {
198
- es.close();
199
- activeJobRef.current = null;
237
+ if (terminal) {
238
+ closeActiveEventSource(jobId);
200
239
  }
201
240
  } catch {
202
241
  // ignore parse errors
@@ -221,21 +260,78 @@ export function useRenderQueue(projectId: string | null) {
221
260
 
222
261
  return jobId;
223
262
  },
224
- [projectId],
263
+ [projectId, closeActiveEventSource],
225
264
  );
226
265
 
227
- const deleteRender = useCallback(async (jobId: string) => {
228
- try {
229
- await fetch(`/api/render/${jobId}`, { method: "DELETE" });
230
- } catch {
231
- // ignore
232
- }
233
- setJobs((prev) => prev.filter((j) => j.id !== jobId));
234
- }, []);
266
+ // Cancel an in-flight render. The job row stays (as "cancelled") so the
267
+ // user sees the outcome; the SSE stream is closed either way.
268
+ const cancelRender = useCallback(
269
+ async (jobId: string) => {
270
+ setActionError(null);
271
+ closeActiveEventSource(jobId);
272
+ setJobs((prev) =>
273
+ prev.map((j) =>
274
+ j.id === jobId && j.status === "rendering" ? { ...j, status: "cancelled" } : j,
275
+ ),
276
+ );
277
+ try {
278
+ const res = await fetch(`/api/render/${jobId}/cancel`, { method: "POST" });
279
+ if (!res.ok && res.status !== 404) {
280
+ setActionError("Couldn't cancel on the server — the render may still be running.");
281
+ return;
282
+ }
283
+ // Reconcile with the status the route reports: if the render actually
284
+ // finished (or failed) before the cancel landed, don't leave the row
285
+ // stuck on the optimistic "cancelled" — reload to pick up the real
286
+ // outcome (and the finished file's metadata).
287
+ if (res.ok) {
288
+ const body = (await res.json().catch(() => null)) as { status?: string } | null;
289
+ if (body?.status && body.status !== "cancelled") {
290
+ void loadRenders();
291
+ }
292
+ }
293
+ } catch {
294
+ setActionError("Couldn't reach the server to cancel — the render may still be running.");
295
+ }
296
+ },
297
+ [closeActiveEventSource, loadRenders],
298
+ );
235
299
 
300
+ const deleteRender = useCallback(
301
+ async (jobId: string) => {
302
+ setActionError(null);
303
+ closeActiveEventSource(jobId);
304
+ try {
305
+ const res = await fetch(`/api/render/${jobId}`, { method: "DELETE" });
306
+ if (!res.ok) {
307
+ setActionError("Couldn't delete the render — it's still on disk.");
308
+ return;
309
+ }
310
+ } catch {
311
+ setActionError("Couldn't reach the server to delete the render.");
312
+ return;
313
+ }
314
+ setJobs((prev) => prev.filter((j) => j.id !== jobId));
315
+ },
316
+ [closeActiveEventSource],
317
+ );
318
+
319
+ // Hide finished rows from the list (view-only — files stay on disk and can
320
+ // be recovered from the renders/ directory). Remembered per project so the
321
+ // rows don't resurrect from history on reload.
236
322
  const clearCompleted = useCallback(() => {
237
- setJobs((prev) => prev.filter((j) => j.status === "rendering"));
238
- }, []);
323
+ setJobs((prev) => {
324
+ const finished = prev.filter((j) => j.status !== "rendering");
325
+ if (projectId && finished.length > 0) {
326
+ const hidden = readHiddenIds(projectId);
327
+ for (const j of finished) hidden.add(j.id);
328
+ writeHiddenIds(projectId, hidden);
329
+ }
330
+ return prev.filter((j) => j.status === "rendering");
331
+ });
332
+ }, [projectId]);
333
+
334
+ const dismissActionError = useCallback(() => setActionError(null), []);
239
335
 
240
336
  // Clean up EventSource on unmount or projectId change
241
337
  useEffect(() => {
@@ -250,10 +346,26 @@ export function useRenderQueue(projectId: string | null) {
250
346
  () => ({
251
347
  jobs,
252
348
  isRendering,
349
+ loadError,
350
+ actionError,
351
+ dismissActionError,
352
+ reloadRenders: loadRenders,
253
353
  deleteRender,
354
+ cancelRender,
254
355
  clearCompleted,
255
356
  startRender: startRender as (options: unknown) => Promise<void>,
256
357
  }),
257
- [jobs, isRendering, deleteRender, clearCompleted, startRender],
358
+ [
359
+ jobs,
360
+ isRendering,
361
+ loadError,
362
+ actionError,
363
+ dismissActionError,
364
+ loadRenders,
365
+ deleteRender,
366
+ cancelRender,
367
+ clearCompleted,
368
+ startRender,
369
+ ],
258
370
  );
259
371
  }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab";
3
+ import { globalAssetRows } from "./GlobalAssetsView";
4
+
5
+ const assets = ["bgm.mp3", "logo.png", "orphan.wav"];
6
+ const used = new Set(["bgm.mp3", "logo.png"]);
7
+
8
+ describe("filterByUsage", () => {
9
+ it("returns everything for 'all'", () => {
10
+ expect(filterByUsage(assets, used, "all")).toEqual(assets);
11
+ });
12
+
13
+ it("keeps only referenced assets for 'used'", () => {
14
+ expect(filterByUsage(assets, used, "used")).toEqual(["bgm.mp3", "logo.png"]);
15
+ });
16
+
17
+ it("keeps only unreferenced assets for 'unused'", () => {
18
+ expect(filterByUsage(assets, used, "unused")).toEqual(["orphan.wav"]);
19
+ });
20
+
21
+ it("treats everything as unused when nothing is referenced", () => {
22
+ expect(filterByUsage(assets, new Set(), "used")).toEqual([]);
23
+ expect(filterByUsage(assets, new Set(), "unused")).toEqual(assets);
24
+ });
25
+ });
26
+
27
+ describe("deriveUsedPaths", () => {
28
+ it("matches the asset-list format across every src shape", () => {
29
+ const used = deriveUsedPaths([
30
+ { src: "assets/logo.png" }, // raw authored relative path
31
+ { src: "/api/projects/demo/preview/assets/bgm.mp3" }, // served form
32
+ { src: "./assets/icon.svg" }, // ./-prefixed
33
+ { src: "assets/clip.mp4?v=2" }, // cache-busted
34
+ {}, // no src — skipped
35
+ ]);
36
+ expect(used.has("assets/logo.png")).toBe(true);
37
+ expect(used.has("assets/bgm.mp3")).toBe(true);
38
+ expect(used.has("assets/icon.svg")).toBe(true);
39
+ expect(used.has("assets/clip.mp4")).toBe(true);
40
+ expect(used.size).toBe(4);
41
+ });
42
+
43
+ it("an authored relative src lines up with the asset entry (the live bug class)", () => {
44
+ const used = deriveUsedPaths([{ src: "assets/logo.png" }]);
45
+ // asset-list entries are project-relative (see serveUrl = preview/${asset})
46
+ expect(filterByUsage(["assets/logo.png", "assets/orphan.wav"], used, "used")).toEqual([
47
+ "assets/logo.png",
48
+ ]);
49
+ });
50
+ });
51
+
52
+ describe("countUsage", () => {
53
+ it("counts used vs unused", () => {
54
+ expect(countUsage(assets, used)).toEqual({ used: 2, unused: 1 });
55
+ });
56
+
57
+ it("is all-unused with an empty used set", () => {
58
+ expect(countUsage(assets, new Set())).toEqual({ used: 0, unused: 3 });
59
+ });
60
+ });
61
+
62
+ describe("globalAssetRows", () => {
63
+ const recs = [
64
+ { id: "bgm_001", type: "bgm", description: "calm ambient" },
65
+ { id: "img_001", type: "image", entity: "Acme" },
66
+ { sha: "abc", type: "sfx" },
67
+ ];
68
+
69
+ it("maps records to display rows with a sensible label", () => {
70
+ const rows = globalAssetRows(recs);
71
+ expect(rows).toEqual([
72
+ { id: "bgm_001", type: "bgm", label: "calm ambient" },
73
+ { id: "img_001", type: "image", label: "Acme" },
74
+ { id: "abc", type: "sfx", label: "abc" },
75
+ ]);
76
+ });
77
+
78
+ it("filters by id / type / description / entity, case-insensitively", () => {
79
+ expect(globalAssetRows(recs, "ACME").map((r) => r.id)).toEqual(["img_001"]);
80
+ expect(globalAssetRows(recs, "bgm").map((r) => r.id)).toEqual(["bgm_001"]);
81
+ expect(globalAssetRows(recs, "ambient").map((r) => r.id)).toEqual(["bgm_001"]);
82
+ });
83
+
84
+ it("empty query returns all", () => {
85
+ expect(globalAssetRows(recs, " ").length).toBe(3);
86
+ });
87
+ });
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
2
3
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
4
  import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
@@ -14,6 +15,7 @@ import {
14
15
  FILTER_ORDER,
15
16
  } from "./assetHelpers";
16
17
  import { AudioRow } from "./AudioRow";
18
+ import { GlobalAssetsView } from "./GlobalAssetsView";
17
19
 
18
20
  interface AssetsTabProps {
19
21
  projectId: string;
@@ -172,6 +174,51 @@ function ImageCard({
172
174
  );
173
175
  }
174
176
 
177
+ export type UsageFilter = "all" | "used" | "unused";
178
+
179
+ /** Filter assets by whether the composition references them. Pure — unit-tested. */
180
+ export function filterByUsage(
181
+ assets: string[],
182
+ usedPaths: Set<string>,
183
+ usageFilter: UsageFilter,
184
+ ): string[] {
185
+ if (usageFilter === "used") return assets.filter((a) => usedPaths.has(a));
186
+ if (usageFilter === "unused") return assets.filter((a) => !usedPaths.has(a));
187
+ return assets;
188
+ }
189
+
190
+ /** Count used vs unused over a media set. Pure — unit-tested. */
191
+ export function countUsage(
192
+ assets: string[],
193
+ usedPaths: Set<string>,
194
+ ): { used: number; unused: number } {
195
+ let used = 0;
196
+ for (const a of assets) if (usedPaths.has(a)) used++;
197
+ return { used, unused: assets.length - used };
198
+ }
199
+
200
+ /**
201
+ * Project-relative asset paths referenced by composition elements — the set the
202
+ * "in use" badge, used-first sort, and usage filter all key on. Element src is
203
+ * the raw authored value (timelineElementHelpers sets entry.src =
204
+ * getAttribute("src")), so it can be a relative path ("assets/x.png"), a
205
+ * "./"-prefixed path, the served "/api/projects/<id>/preview/assets/x.png" form,
206
+ * or carry a ?query — normalize all of them to the bare project path so they
207
+ * match the asset-list entries. Pure — unit-tested.
208
+ */
209
+ export function deriveUsedPaths(elements: Array<{ src?: string }>): Set<string> {
210
+ const paths = new Set<string>();
211
+ for (const el of elements) {
212
+ if (!el.src) continue;
213
+ const s = el.src
214
+ .replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix
215
+ .replace(/^\.?\//, "") // strip leading ./ or /
216
+ .split(/[?#]/)[0]; // drop query / hash
217
+ if (s) paths.add(s);
218
+ }
219
+ return paths;
220
+ }
221
+
175
222
  export const AssetsTab = memo(function AssetsTab({
176
223
  projectId,
177
224
  assets,
@@ -183,7 +230,11 @@ export const AssetsTab = memo(function AssetsTab({
183
230
  const [dragOver, setDragOver] = useState(false);
184
231
  const [copiedPath, setCopiedPath] = useState<string | null>(null);
185
232
  const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
233
+ const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all");
186
234
  const [searchQuery, setSearchQuery] = useState("");
235
+ // Cross-project view: the global media-use cache (~/.media). The view itself
236
+ // (GlobalAssetsView) owns its fetch — AssetsTab only tracks which scope is active.
237
+ const [viewMode, setViewMode] = useState<"local" | "global">("local");
187
238
  const [manifest, setManifest] = useState<
188
239
  Map<string, { description?: string; duration?: number; width?: number; height?: number }>
189
240
  >(new Map());
@@ -246,19 +297,11 @@ export const AssetsTab = memo(function AssetsTab({
246
297
  }, []);
247
298
 
248
299
  const elements = usePlayerStore((s) => s.elements);
249
- const usedPaths = useMemo(() => {
250
- const paths = new Set<string>();
251
- for (const el of elements) {
252
- if (el.src) {
253
- const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, "");
254
- paths.add(src);
255
- }
256
- }
257
- return paths;
258
- }, [elements]);
300
+ const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]);
259
301
 
260
302
  const mediaAssets = useMemo(() => {
261
- const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
303
+ const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
304
+ const all = filterByUsage(media, usedPaths, usageFilter);
262
305
  if (!searchQuery) return all;
263
306
  const q = searchQuery.toLowerCase();
264
307
  return all.filter((a) => {
@@ -266,7 +309,7 @@ export const AssetsTab = memo(function AssetsTab({
266
309
  const rec = manifest.get(a);
267
310
  return rec?.description?.toLowerCase().includes(q);
268
311
  });
269
- }, [assets, searchQuery, manifest]);
312
+ }, [assets, searchQuery, manifest, usageFilter, usedPaths]);
270
313
 
271
314
  const categorized = useMemo(() => {
272
315
  const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
@@ -291,6 +334,17 @@ export const AssetsTab = memo(function AssetsTab({
291
334
  return c;
292
335
  }, [mediaAssets, categorized]);
293
336
 
337
+ // Usage counts over the full media set (independent of the active usage filter,
338
+ // so the chips don't show their own filtered totals).
339
+ const usageCounts = useMemo(
340
+ () =>
341
+ countUsage(
342
+ assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)),
343
+ usedPaths,
344
+ ),
345
+ [assets, usedPaths],
346
+ );
347
+
294
348
  const visibleCategories =
295
349
  activeFilter === "all"
296
350
  ? FILTER_ORDER.filter((c) => categorized[c].length > 0)
@@ -308,6 +362,22 @@ export const AssetsTab = memo(function AssetsTab({
308
362
  >
309
363
  {/* Header — matches design panel Section pattern */}
310
364
  <div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
365
+ {/* Scope toggle — this project's assets vs the global media-use cache */}
366
+ <div className="flex gap-1 mb-2.5 p-0.5 rounded-md bg-panel-input">
367
+ {(["local", "global"] as const).map((m) => (
368
+ <button
369
+ key={m}
370
+ onClick={() => setViewMode(m)}
371
+ className={`flex-1 px-2 py-1 text-[11px] font-medium rounded transition-colors ${
372
+ viewMode === m
373
+ ? "bg-panel-accent/15 text-panel-accent"
374
+ : "text-panel-text-3 hover:text-panel-text-1"
375
+ }`}
376
+ >
377
+ {m === "local" ? "This project" : "All projects"}
378
+ </button>
379
+ ))}
380
+ </div>
311
381
  {/* Import */}
312
382
  {onImport && (
313
383
  <>
@@ -377,8 +447,8 @@ export const AssetsTab = memo(function AssetsTab({
377
447
  </div>
378
448
  )}
379
449
 
380
- {/* Filter chips — panel-input style */}
381
- {mediaAssets.length > 0 && (
450
+ {/* Filter chips — panel-input style (local view only) */}
451
+ {viewMode === "local" && mediaAssets.length > 0 && (
382
452
  <div className="flex gap-1.5 flex-wrap">
383
453
  <button
384
454
  onClick={() => setActiveFilter("all")}
@@ -405,13 +475,41 @@ export const AssetsTab = memo(function AssetsTab({
405
475
  </button>
406
476
  ) : null,
407
477
  )}
478
+ {/* Usage filter — show only assets the composition references, or only the unused ones */}
479
+ {usageCounts.used > 0 && usageCounts.unused > 0 && (
480
+ <>
481
+ <span className="w-px self-stretch bg-panel-input mx-0.5" aria-hidden="true" />
482
+ <button
483
+ onClick={() => setUsageFilter(usageFilter === "used" ? "all" : "used")}
484
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
485
+ usageFilter === "used"
486
+ ? "bg-panel-accent/15 text-panel-accent"
487
+ : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
488
+ }`}
489
+ >
490
+ In use {usageCounts.used}
491
+ </button>
492
+ <button
493
+ onClick={() => setUsageFilter(usageFilter === "unused" ? "all" : "unused")}
494
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
495
+ usageFilter === "unused"
496
+ ? "bg-panel-accent/15 text-panel-accent"
497
+ : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
498
+ }`}
499
+ >
500
+ Unused {usageCounts.unused}
501
+ </button>
502
+ </>
503
+ )}
408
504
  </div>
409
505
  )}
410
506
  </div>
411
507
 
412
508
  {/* Asset list */}
413
509
  <div className="flex-1 overflow-y-auto mt-1">
414
- {mediaAssets.length === 0 ? (
510
+ {viewMode === "global" ? (
511
+ <GlobalAssetsView searchQuery={searchQuery} />
512
+ ) : mediaAssets.length === 0 ? (
415
513
  <div className="flex flex-col items-center justify-center h-full px-4 gap-2">
416
514
  <svg
417
515
  width="24"
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { memo, useState, useCallback, useRef, useEffect } from "react";
2
3
  import { createPortal } from "react-dom";
3
4
  import { useBlockCatalog } from "../../hooks/useBlockCatalog";
@@ -234,7 +235,7 @@ function buildAgentPrompt(
234
235
  captions: [
235
236
  `Using /hyperframes, add the "${title}" caption style (registry: ${name}) to my composition.`,
236
237
  `${description}`,
237
- `Transcribe the audio with /hyperframes-media, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
238
+ `Transcribe the audio with /media-use, then wire the transcript into this caption component. Match the font colors and animation timing to my composition's design tokens. Place it as an overlay above the main content with the highest z-index.`,
238
239
  ].join("\n\n"),
239
240
  vfx: [
240
241
  `Using /hyperframes, add the "${title}" VFX (registry: ${name}) as a full-screen overlay on my composition.`,
@@ -0,0 +1,91 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+
3
+ // Cross-project asset view — the global media-use cache (~/.media), fetched from
4
+ // /api/assets/global. Self-contained (owns its fetch + state) so AssetsTab stays
5
+ // focused on the local view.
6
+
7
+ export interface GlobalAssetRecord {
8
+ id?: string;
9
+ type?: string;
10
+ description?: string;
11
+ entity?: string;
12
+ sha?: string;
13
+ }
14
+
15
+ export interface GlobalAssetRow {
16
+ id: string;
17
+ type: string;
18
+ label: string;
19
+ }
20
+
21
+ /**
22
+ * Normalize global records into display rows, filtered by an optional query
23
+ * (id / type / description / entity). Pure — unit-tested.
24
+ */
25
+ export function globalAssetRows(records: GlobalAssetRecord[], query = ""): GlobalAssetRow[] {
26
+ const q = query.trim().toLowerCase();
27
+ return records
28
+ .filter((r) =>
29
+ !q
30
+ ? true
31
+ : [r.id, r.type, r.description, r.entity].some(
32
+ (f) => f && String(f).toLowerCase().includes(q),
33
+ ),
34
+ )
35
+ .map((r) => ({
36
+ id: r.id ?? r.sha ?? "asset",
37
+ type: r.type ?? "asset",
38
+ label: r.description || r.entity || r.id || r.sha || "asset",
39
+ }));
40
+ }
41
+
42
+ export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) {
43
+ const [records, setRecords] = useState<GlobalAssetRecord[] | null>(null);
44
+ useEffect(() => {
45
+ let cancelled = false;
46
+ fetch("/api/assets/global")
47
+ .then((r) => (r.ok ? r.json() : { assets: [] }))
48
+ .then((d) => {
49
+ if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []);
50
+ })
51
+ .catch(() => {
52
+ if (!cancelled) setRecords([]);
53
+ });
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, []);
58
+
59
+ const rows = useMemo(() => globalAssetRows(records ?? [], searchQuery), [records, searchQuery]);
60
+
61
+ if (records === null) {
62
+ return <p className="px-4 py-3 text-[11px] text-panel-text-5">Loading global assets…</p>;
63
+ }
64
+ if (rows.length === 0) {
65
+ return (
66
+ <p className="px-4 py-3 text-[11px] text-panel-text-5">
67
+ No assets in the global cache yet. Resolved media is promoted to <code>~/.media</code> and
68
+ becomes reusable across projects.
69
+ </p>
70
+ );
71
+ }
72
+ return (
73
+ <div>
74
+ <div className="px-4 py-2 border-t border-panel-border text-[11px] text-panel-text-5">
75
+ {rows.length} reusable across all projects
76
+ </div>
77
+ {rows.map((row) => (
78
+ <div
79
+ key={row.id}
80
+ className="px-4 py-1.5 flex items-center gap-2.5 border-l-2 border-transparent hover:bg-neutral-800/50"
81
+ title={`${row.id} · ${row.type}`}
82
+ >
83
+ <span className="text-[9px] font-medium text-neutral-600 uppercase w-10 flex-shrink-0">
84
+ {row.type}
85
+ </span>
86
+ <span className="text-xs text-panel-text-1 truncate">{row.label}</span>
87
+ </div>
88
+ ))}
89
+ </div>
90
+ );
91
+ }