@hyperframes/studio 0.7.38 → 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 (49) hide show
  1. package/dist/assets/index-0P10SwC_.css +1 -0
  2. package/dist/assets/{index-ClUipc8i.js → index-312a3Ceu.js} +1 -1
  3. package/dist/assets/{index-BLRTwY5l.js → index-B9YvRJz1.js} +194 -194
  4. package/dist/assets/{index-Ykq7ihge.js → index-CQHiecE7.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  6. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2171 -1458
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +7 -6
  14. package/src/components/StudioPreviewArea.tsx +8 -0
  15. package/src/components/StudioRightPanel.tsx +6 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +111 -108
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +48 -0
  20. package/src/components/editor/PropertyPanel.tsx +9 -30
  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/domEditingTypes.ts +3 -0
  30. package/src/components/editor/marqueeCommit.ts +3 -2
  31. package/src/components/editor/propertyPanelHelpers.ts +10 -34
  32. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  33. package/src/components/editor/propertyPanelTypes.ts +2 -0
  34. package/src/components/editor/snapTargetCollection.ts +2 -3
  35. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  36. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  37. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  38. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  39. package/src/components/sidebar/AssetsTab.tsx +113 -15
  40. package/src/components/sidebar/BlocksTab.tsx +2 -1
  41. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  42. package/src/hooks/domSelectionTestHarness.ts +1 -0
  43. package/src/hooks/useCropMode.ts +91 -0
  44. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  45. package/src/player/components/ShortcutsPanel.tsx +9 -0
  46. package/src/player/store/playerStore.ts +14 -0
  47. package/dist/assets/index-DJaiR8T2.css +0 -1
  48. package/dist/chunk-JND3XUJL.js.map +0 -1
  49. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.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
+ }
@@ -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,
@@ -0,0 +1,91 @@
1
+ import { useEffect, useMemo, useReducer } from "react";
2
+ import { usePlayerStore } from "../player";
3
+
4
+ export interface CropModeProps {
5
+ cropMode: boolean;
6
+ onCropModeChange: (active: boolean) => void;
7
+ }
8
+
9
+ /** Crop mode lives in the player store so the canvas toolbar, the Clip panel,
10
+ * and the overlay all share one switch without prop threading. */
11
+ export function useCropModeProps(): CropModeProps {
12
+ const cropMode = usePlayerStore((s) => s.cropMode);
13
+ const setCropMode = usePlayerStore((s) => s.setCropMode);
14
+ return useMemo(
15
+ () => ({
16
+ cropMode,
17
+ onCropModeChange: setCropMode,
18
+ }),
19
+ [cropMode, setCropMode],
20
+ );
21
+ }
22
+
23
+ import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
24
+ import type { DomEditSelection } from "../components/editor/domEditing";
25
+ import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
26
+
27
+ /** Overlay-side crop state: Escape-to-exit, toolbar availability publishing,
28
+ * and the box clip that makes the selection outline hug the cropped region.
29
+ * The box div itself always sits at the FULL element bounds — gestures write
30
+ * its position directly during drags, so moving/resizing it in React would
31
+ * fight them. The hug is purely visual: the element's inset clip-path scaled
32
+ * into overlay space and applied to the box. */
33
+ export function useCropOverlay(params: {
34
+ selection: DomEditSelection | null;
35
+ groupCount: number;
36
+ cropMode: boolean;
37
+ onCropModeChange?: (active: boolean) => void;
38
+ overlayRect: OverlayRect | null;
39
+ }) {
40
+ const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params;
41
+
42
+ useEffect(() => {
43
+ if (!cropMode || !onCropModeChange) return;
44
+ const handleKeyDown = (event: KeyboardEvent) => {
45
+ if (event.key === "Escape") onCropModeChange(false);
46
+ };
47
+ window.addEventListener("keydown", handleKeyDown);
48
+ return () => window.removeEventListener("keydown", handleKeyDown);
49
+ }, [cropMode, onCropModeChange]);
50
+
51
+ // Publish availability so the canvas toolbar shows the Crop button only
52
+ // when the selection can take a clip-path crop.
53
+ const setCropAvailable = usePlayerStore((s) => s.setCropAvailable);
54
+ const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop);
55
+ useEffect(() => {
56
+ setCropAvailable(cropAvailable);
57
+ return () => setCropAvailable(false);
58
+ }, [cropAvailable, setCropAvailable]);
59
+
60
+ // Crop-mode exit restores the element's clip in an effect cleanup — after
61
+ // this hook already read it. One forced re-render picks up the fresh insets
62
+ // so the selection box hugs the crop immediately.
63
+ const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0);
64
+ useEffect(() => {
65
+ if (!cropMode) bumpAfterExit();
66
+ }, [cropMode]);
67
+
68
+ const cropInsets = selection ? readElementCropInsets(selection.element) : null;
69
+ const hasCropInsets = Boolean(
70
+ cropInsets &&
71
+ (cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
72
+ );
73
+
74
+ // Scaled insets for the crop outline child + the resize-handle shift. The
75
+ // box div itself stays border-less at full bounds; a child draws the
76
+ // outline ON the crop boundary (a clip on the box would swallow the
77
+ // border everywhere the crop edge doesn't touch the element edge).
78
+ const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
79
+ const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
80
+ const cropOutlineInsetPx =
81
+ cropInsets && hasCropInsets && !cropMode
82
+ ? {
83
+ top: cropInsets.top * sy,
84
+ right: cropInsets.right * sx,
85
+ bottom: cropInsets.bottom * sy,
86
+ left: cropInsets.left * sx,
87
+ }
88
+ : undefined;
89
+
90
+ return { hasCropInsets, cropOutlineInsetPx };
91
+ }
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
  import { act, createElement } from "react";
3
4
  import { createRoot, type Root } from "react-dom/client";
@@ -183,6 +184,7 @@ function createSelection(
183
184
  capabilities: {
184
185
  canSelect: true,
185
186
  canEditStyles: true,
187
+ canCrop: true,
186
188
  canMove: true,
187
189
  canResize: true,
188
190
  canApplyManualOffset: true,
@@ -60,6 +60,15 @@ const SHORTCUT_SECTIONS = [
60
60
  { key: "⇧ Drag", label: "Uniform resize" },
61
61
  ],
62
62
  },
63
+ {
64
+ title: "Crop",
65
+ hints: [
66
+ { key: "DblClick", label: "Crop selected element" },
67
+ { key: "Drag edge", label: "Adjust crop side" },
68
+ { key: "Drag frame", label: "Move crop window" },
69
+ { key: "Esc", label: "Exit crop (or click outside)" },
70
+ ],
71
+ },
63
72
  {
64
73
  title: "Panels",
65
74
  hints: [