@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.
- package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +27 -47
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /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)
|
|
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
|
-
|
|
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 (
|
|
198
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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) =>
|
|
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
|
-
[
|
|
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
|
|
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
|
-
{
|
|
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 /
|
|
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
|
+
}
|