@hyperframes/studio 0.7.38 → 0.7.40

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 (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -0,0 +1,68 @@
1
+ import { useState, type RefObject } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+
4
+ export interface DomEditCompositionRect {
5
+ left: number;
6
+ top: number;
7
+ width: number;
8
+ height: number;
9
+ scaleX: number;
10
+ scaleY: number;
11
+ }
12
+
13
+ function sameRect(a: DomEditCompositionRect, b: DomEditCompositionRect): boolean {
14
+ const d = (k: keyof DomEditCompositionRect) => Math.abs(a[k] - b[k]);
15
+ return (
16
+ d("left") < 0.5 &&
17
+ d("top") < 0.5 &&
18
+ d("width") < 0.5 &&
19
+ d("height") < 0.5 &&
20
+ d("scaleX") < 0.001 &&
21
+ d("scaleY") < 0.001
22
+ );
23
+ }
24
+
25
+ export function useDomEditCompositionRect({
26
+ iframeRef,
27
+ overlayRef,
28
+ }: {
29
+ iframeRef: RefObject<HTMLIFrameElement | null>;
30
+ overlayRef: RefObject<HTMLDivElement | null>;
31
+ }): DomEditCompositionRect {
32
+ const [compRect, setCompRect] = useState({
33
+ left: 0,
34
+ top: 0,
35
+ width: 0,
36
+ height: 0,
37
+ scaleX: 1,
38
+ scaleY: 1,
39
+ });
40
+
41
+ useMountEffect(() => {
42
+ let frame = 0;
43
+ // fallow-ignore-next-line complexity
44
+ const update = () => {
45
+ frame = requestAnimationFrame(update);
46
+ const iframe = iframeRef.current;
47
+ const overlayEl = overlayRef.current;
48
+ if (!iframe || !overlayEl) return;
49
+ const iRect = iframe.getBoundingClientRect();
50
+ const oRect = overlayEl.getBoundingClientRect();
51
+ const left = iRect.left - oRect.left;
52
+ const top = iRect.top - oRect.top;
53
+ if (iRect.width <= 0 || iRect.height <= 0) return;
54
+ const doc = iframe.contentDocument;
55
+ const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
56
+ const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
57
+ const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
58
+ const scaleX = dw > 0 ? iRect.width / dw : 1;
59
+ const scaleY = dh > 0 ? iRect.height / dh : 1;
60
+ const next = { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
61
+ setCompRect((prev) => (sameRect(prev, next) ? prev : next));
62
+ };
63
+ frame = requestAnimationFrame(update);
64
+ return () => cancelAnimationFrame(frame);
65
+ });
66
+
67
+ return compRect;
68
+ }
@@ -46,6 +46,7 @@ import {
46
46
  startGesture as _startGesture,
47
47
  startGroupDrag as _startGroupDrag,
48
48
  } from "./domEditOverlayStartGesture";
49
+ import { hugRectForElement } from "./domEditOverlayCrop";
49
50
  import {
50
51
  resolveSnapAdjustment,
51
52
  resolveResizeSnapAdjustment,
@@ -183,12 +184,18 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
183
184
  if (g.kind === "drag") {
184
185
  const sc = g.snapContext;
185
186
  if (sc?.snapEnabled && sc.targets.length > 0) {
186
- const movingRect = {
187
- left: g.originLeft,
188
- top: g.originTop,
189
- width: g.originWidth,
190
- height: g.originHeight,
191
- };
187
+ // Snap the element's VISIBLE (crop-hugged) edges, not the full bounds.
188
+ const movingRect = hugRectForElement(
189
+ {
190
+ left: g.originLeft,
191
+ top: g.originTop,
192
+ width: g.originWidth,
193
+ height: g.originHeight,
194
+ editScaleX: g.editScaleX,
195
+ editScaleY: g.editScaleY,
196
+ },
197
+ g.selection.element,
198
+ );
192
199
  const allTargets = sc.compositionTarget
193
200
  ? [...sc.targets, sc.compositionTarget]
194
201
  : sc.targets;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { useRef, useState, type RefObject } from "react";
6
6
  import { useMountEffect } from "../../hooks/useMountEffect";
7
+ import { hugRectForElement } from "./domEditOverlayCrop";
7
8
  import { type DomEditSelection, findElementForSelection } from "./domEditing";
8
9
  import {
9
10
  type GroupOverlayItem,
@@ -15,7 +16,7 @@ import {
15
16
  rectsEqual,
16
17
  resolveElementForOverlay,
17
18
  selectionCacheKey,
18
- toOverlayRect,
19
+ toVisibleOverlayRect,
19
20
  } from "./domEditOverlayGeometry";
20
21
 
21
22
  function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
@@ -164,7 +165,7 @@ export function useDomEditOverlayRects({
164
165
  for (let i = 0; i < descendants.length; i++) {
165
166
  const child = descendants[i] as HTMLElement;
166
167
  if (!child.getBoundingClientRect) continue;
167
- const r = toOverlayRect(overlayEl, iframe, child);
168
+ const r = toVisibleOverlayRect(overlayEl, iframe, child);
168
169
  if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
169
170
  }
170
171
  if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
@@ -203,7 +204,8 @@ export function useDomEditOverlayRects({
203
204
  if (liveGroupKeys.has(key)) continue;
204
205
  liveGroupKeys.add(key);
205
206
  const el = resolveGroupElement(doc, groupSelection);
206
- const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
207
+ const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null;
208
+ const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base;
207
209
  if (el && rect)
208
210
  nextGroupItems.push({ key, selection: groupSelection, element: el, rect });
209
211
  }
@@ -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
+ }
@@ -32,6 +32,7 @@ export function TimelineEditProvider({
32
32
  [
33
33
  value.onMoveElement,
34
34
  value.onResizeElement,
35
+ value.onToggleTrackHidden,
35
36
  value.onBlockedEditAttempt,
36
37
  value.onSplitElement,
37
38
  value.onRazorSplit,
@@ -30,6 +30,7 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec
30
30
  capabilities: {
31
31
  canSelect: true,
32
32
  canEditStyles: true,
33
+ canCrop: true,
33
34
  canMove: true,
34
35
  canResize: true,
35
36
  canApplyManualOffset: true,
@@ -5,6 +5,7 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
5
5
  import {
6
6
  commitStaticGsapPosition,
7
7
  commitStaticGsapRotation,
8
+ findExistingPositionWrite,
8
9
  parkPlayheadOnKeyframe,
9
10
  type GsapDragCommitCallbacks,
10
11
  } from "./gsapDragCommit";
@@ -75,6 +76,7 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
75
76
  });
76
77
 
77
78
  it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
79
+ // fallow-ignore-next-line code-duplication
78
80
  usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
79
81
  const { types, callbacks } = recordingCallbacks();
80
82
 
@@ -94,7 +96,9 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
94
96
  });
95
97
 
96
98
  it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
99
+ // fallow-ignore-next-line code-duplication
97
100
  usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
101
+ // fallow-ignore-next-line code-duplication
98
102
  const { types, callbacks } = recordingCallbacks();
99
103
 
100
104
  await commitGsapPositionFromDrag(
@@ -115,6 +119,7 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
115
119
  // User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
116
120
  // the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
117
121
  usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
122
+ // fallow-ignore-next-line code-duplication
118
123
  const { types, mutations, callbacks } = recordingCallbacks();
119
124
 
120
125
  await commitGsapPositionFromDrag(
@@ -338,6 +343,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
338
343
  await commitStaticGsapPosition(
339
344
  selection(),
340
345
  { x: -50, y: 30 },
346
+ // fallow-ignore-next-line code-duplication
341
347
  { x: 0, y: 0 },
342
348
  "#puck-a",
343
349
  null, // no existing set → `add` a new base gsap.set
@@ -352,6 +358,55 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
352
358
  });
353
359
  });
354
360
 
361
+ // A degenerate `tl.to("#el",{keyframes:{...},duration:0})` — what a pre-fix drag
362
+ // left behind when it routed a STATIC position hold (sitting beside a keyframed
363
+ // rotation) into the keyframe branch. A duration-0 keyframed tween renders its
364
+ // final keyframe at every playhead, so the element froze and "couldn't move".
365
+ const keyframedZeroDurationHold = (): GsapAnimation =>
366
+ ({
367
+ id: "#puck-a-frozen",
368
+ targetSelector: "#puck-a",
369
+ method: "to",
370
+ propertyGroup: "position",
371
+ duration: 0,
372
+ keyframes: {
373
+ keyframes: [
374
+ { percentage: 0, properties: { x: 100, y: 50 } },
375
+ { percentage: 100, properties: { x: -260, y: -70 } },
376
+ ],
377
+ },
378
+ properties: {},
379
+ }) as unknown as GsapAnimation;
380
+
381
+ describe("static position hold recognition + heal (frozen duration-0 keyframed tween)", () => {
382
+ beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
383
+
384
+ it("findExistingPositionWrite recognizes a keyframed zero-duration position hold", () => {
385
+ const found = findExistingPositionWrite([keyframedZeroDurationHold()], "#puck-a");
386
+ expect(found?.id).toBe("#puck-a-frozen");
387
+ });
388
+
389
+ it("commitStaticGsapPosition heals a keyframed hold by delete + clean add-set (never update-property)", async () => {
390
+ const { commits, callbacks } = optionRecordingCallbacks();
391
+
392
+ await commitStaticGsapPosition(
393
+ selection(),
394
+ { x: -50, y: 30 },
395
+ { x: 0, y: 0 },
396
+ "#puck-a",
397
+ keyframedZeroDurationHold(),
398
+ callbacks,
399
+ );
400
+
401
+ const types = commits.map((c) => c.mutation.type);
402
+ // Can't update-property into keyframes — must delete the frozen tween and
403
+ // write a clean static set, so the element becomes freely movable.
404
+ expect(types).toEqual(["delete", "add"]);
405
+ expect(types).not.toContain("update-property");
406
+ expect((commits[1].mutation as { method?: string }).method).toBe("set");
407
+ });
408
+ });
409
+
355
410
  describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
356
411
  beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
357
412
 
@@ -377,6 +432,7 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
377
432
  it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
378
433
  const { commits, callbacks } = optionRecordingCallbacks();
379
434
 
435
+ // fallow-ignore-next-line code-duplication
380
436
  await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
381
437
 
382
438
  expect(commits).toHaveLength(1);