@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.
- package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- 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/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/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -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/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/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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);
|