@hyperframes/studio 0.7.7 → 0.7.9
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-B2YXvFxf.js → index-B7fv-WA3.js} +1 -1
- package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +2715 -2061
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +5 -1
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +12 -0
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationConstants.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +99 -0
- package/src/components/editor/manualOffsetDrag.ts +38 -7
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +43 -21
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/sidebar/AssetsTab.tsx +23 -213
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +28 -9
- package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +4 -2
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useEnableKeyframes.test.ts +40 -0
- package/src/hooks/useEnableKeyframes.ts +9 -2
- package/src/hooks/useGsapAwareEditing.ts +31 -1
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.ts +6 -4
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-BSkUuN8g.css +0 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure cube projection for the 3D-transform widget. Given an element's
|
|
3
|
+
* rotationX/Y/Z (degrees), project a unit cube to 2D SVG polygons with
|
|
4
|
+
* back-face culling and painter's-order sorting — no DOM, no React, so it
|
|
5
|
+
* unit-tests in isolation. Used by Transform3DCube to draw a live preview of
|
|
6
|
+
* the element's orientation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface Vec3 {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
z: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProjectedFace {
|
|
16
|
+
/** Stable face id (front/back/left/right/top/bottom) for keying + theming. */
|
|
17
|
+
id: string;
|
|
18
|
+
/** SVG polygon points: "x,y x,y x,y x,y". */
|
|
19
|
+
points: string;
|
|
20
|
+
/** 0..1 brightness from how front-facing the rotated normal is (lighting cue). */
|
|
21
|
+
shade: number;
|
|
22
|
+
/** Mean depth of the face's corners; larger = nearer the viewer. */
|
|
23
|
+
depth: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEG = Math.PI / 180;
|
|
27
|
+
|
|
28
|
+
/** Rotate a point intrinsically by X, then Y, then Z (degrees). */
|
|
29
|
+
export function rotate(v: Vec3, rx: number, ry: number, rz: number): Vec3 {
|
|
30
|
+
let { x, y, z } = v;
|
|
31
|
+
const cx = Math.cos(rx * DEG);
|
|
32
|
+
const sx = Math.sin(rx * DEG);
|
|
33
|
+
[y, z] = [y * cx - z * sx, y * sx + z * cx];
|
|
34
|
+
const cy = Math.cos(ry * DEG);
|
|
35
|
+
const sy = Math.sin(ry * DEG);
|
|
36
|
+
[x, z] = [x * cy + z * sy, -x * sy + z * cy];
|
|
37
|
+
const cz = Math.cos(rz * DEG);
|
|
38
|
+
const sz = Math.sin(rz * DEG);
|
|
39
|
+
[x, y] = [x * cz - y * sz, x * sz + y * cz];
|
|
40
|
+
return { x, y, z };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Unit-cube corners (±1) — 0-3 back face (z=-1), 4-7 front face (z=1).
|
|
44
|
+
const CORNERS: Vec3[] = [
|
|
45
|
+
{ x: -1, y: -1, z: -1 },
|
|
46
|
+
{ x: 1, y: -1, z: -1 },
|
|
47
|
+
{ x: 1, y: 1, z: -1 },
|
|
48
|
+
{ x: -1, y: 1, z: -1 },
|
|
49
|
+
{ x: -1, y: -1, z: 1 },
|
|
50
|
+
{ x: 1, y: -1, z: 1 },
|
|
51
|
+
{ x: 1, y: 1, z: 1 },
|
|
52
|
+
{ x: -1, y: 1, z: 1 },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const FACES: { id: string; idx: [number, number, number, number]; normal: Vec3 }[] = [
|
|
56
|
+
{ id: "front", idx: [4, 5, 6, 7], normal: { x: 0, y: 0, z: 1 } },
|
|
57
|
+
{ id: "back", idx: [1, 0, 3, 2], normal: { x: 0, y: 0, z: -1 } },
|
|
58
|
+
{ id: "left", idx: [0, 4, 7, 3], normal: { x: -1, y: 0, z: 0 } },
|
|
59
|
+
{ id: "right", idx: [5, 1, 2, 6], normal: { x: 1, y: 0, z: 0 } },
|
|
60
|
+
// y=+1 corners project to screen-top (SVG y is flipped), so that face is "top".
|
|
61
|
+
{ id: "top", idx: [7, 6, 2, 3], normal: { x: 0, y: 1, z: 0 } },
|
|
62
|
+
{ id: "bottom", idx: [4, 5, 1, 0], normal: { x: 0, y: -1, z: 0 } },
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export interface ProjectOpts {
|
|
66
|
+
/** Center of the SVG viewport. */
|
|
67
|
+
cx: number;
|
|
68
|
+
cy: number;
|
|
69
|
+
/** Half-extent of the cube in SVG units (drawn cube ≈ 2·r before perspective). */
|
|
70
|
+
r: number;
|
|
71
|
+
/** Weak-perspective strength in units of `r` (larger = flatter; ~3-6 reads as 3D). */
|
|
72
|
+
persp?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function round(n: number): number {
|
|
76
|
+
return Math.round(n * 100) / 100;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Directional light (upper-front-right), normalized — drives per-face shading so
|
|
80
|
+
// top/front/side read as distinct planes instead of one flat fill.
|
|
81
|
+
const LIGHT = (() => {
|
|
82
|
+
const v = { x: 0.32, y: 0.56, z: 0.76 };
|
|
83
|
+
const m = Math.hypot(v.x, v.y, v.z);
|
|
84
|
+
return { x: v.x / m, y: v.y / m, z: v.z / m };
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Project the cube at the given orientation. Returns only the front-facing
|
|
89
|
+
* faces (≤3), painter-sorted far→near so the SVG draws nearer faces on top.
|
|
90
|
+
* Screen Y is flipped (SVG y grows downward). `shade` is a 0..1 lambert term
|
|
91
|
+
* from {@link LIGHT} for clean directional face tones.
|
|
92
|
+
*/
|
|
93
|
+
export function projectCubeFaces(
|
|
94
|
+
rx: number,
|
|
95
|
+
ry: number,
|
|
96
|
+
rz: number,
|
|
97
|
+
opts: ProjectOpts,
|
|
98
|
+
): ProjectedFace[] {
|
|
99
|
+
const { cx, cy, r } = opts;
|
|
100
|
+
const persp = opts.persp ?? 4;
|
|
101
|
+
const view = (v: Vec3) => rotate(v, rx, ry, rz);
|
|
102
|
+
const rotated = CORNERS.map(view);
|
|
103
|
+
const faces: ProjectedFace[] = [];
|
|
104
|
+
for (const f of FACES) {
|
|
105
|
+
const n = view(f.normal);
|
|
106
|
+
if (n.z <= 1e-6) continue; // back-face cull: normal must point toward viewer
|
|
107
|
+
const corners = f.idx.map((i) => rotated[i]!);
|
|
108
|
+
const depth = corners.reduce((s, p) => s + p.z, 0) / 4;
|
|
109
|
+
const points = corners
|
|
110
|
+
.map((p) => {
|
|
111
|
+
// Weak perspective: nearer corners (higher z) project slightly larger.
|
|
112
|
+
const s = persp / (persp - p.z);
|
|
113
|
+
return `${round(cx + p.x * r * s)},${round(cy - p.y * r * s)}`;
|
|
114
|
+
})
|
|
115
|
+
.join(" ");
|
|
116
|
+
const lum = Math.max(0, n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z);
|
|
117
|
+
faces.push({ id: f.id, points, shade: 0.3 + lum * 0.7, depth });
|
|
118
|
+
}
|
|
119
|
+
faces.sort((a, b) => a.depth - b.depth);
|
|
120
|
+
return faces;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ProjectedAxis {
|
|
124
|
+
id: "x" | "y" | "z";
|
|
125
|
+
/** Axis color (standard X=red, Y=green, Z=blue). */
|
|
126
|
+
color: string;
|
|
127
|
+
/** Tip position in SVG coords. */
|
|
128
|
+
x2: number;
|
|
129
|
+
y2: number;
|
|
130
|
+
/** Whether the tip points toward the viewer (drawn in front of the cube). */
|
|
131
|
+
front: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const AXES: { id: "x" | "y" | "z"; dir: Vec3; color: string }[] = [
|
|
135
|
+
{ id: "x", dir: { x: 1, y: 0, z: 0 }, color: "#ff6b81" },
|
|
136
|
+
{ id: "y", dir: { x: 0, y: 1, z: 0 }, color: "#5ff08a" },
|
|
137
|
+
{ id: "z", dir: { x: 0, y: 0, z: 1 }, color: "#62b6ff" },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Project the 3 orientation axes (from the cube center) for an X/Y/Z gizmo.
|
|
142
|
+
* Orthographic — thin lines don't need perspective. `front` lets the caller draw
|
|
143
|
+
* away-facing axes behind the cube and toward-facing axes on top.
|
|
144
|
+
*/
|
|
145
|
+
export function projectAxes(
|
|
146
|
+
rx: number,
|
|
147
|
+
ry: number,
|
|
148
|
+
rz: number,
|
|
149
|
+
opts: ProjectOpts,
|
|
150
|
+
): ProjectedAxis[] {
|
|
151
|
+
const { cx, cy, r } = opts;
|
|
152
|
+
const len = r * 1.5;
|
|
153
|
+
const view = (v: Vec3) => rotate(v, rx, ry, rz);
|
|
154
|
+
return AXES.map((a) => {
|
|
155
|
+
const p = view(a.dir);
|
|
156
|
+
return {
|
|
157
|
+
id: a.id,
|
|
158
|
+
color: a.color,
|
|
159
|
+
x2: round(cx + p.x * len),
|
|
160
|
+
y2: round(cy - p.y * len),
|
|
161
|
+
front: p.z >= -1e-6,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Wrap an angle to (-180, 180] so drag accumulation never runs away. */
|
|
167
|
+
export function wrapDeg(deg: number): number {
|
|
168
|
+
let d = deg % 360;
|
|
169
|
+
if (d > 180) d -= 360;
|
|
170
|
+
if (d <= -180) d += 360;
|
|
171
|
+
return d;
|
|
172
|
+
}
|
|
@@ -3,17 +3,17 @@ import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
5
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
|
-
import { ContextMenu
|
|
6
|
+
import { ContextMenu } from "./AssetContextMenu";
|
|
7
7
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
8
|
import {
|
|
9
9
|
type MediaCategory,
|
|
10
10
|
getCategory,
|
|
11
|
-
getAudioSubtype,
|
|
12
11
|
basename,
|
|
13
12
|
ext,
|
|
14
13
|
CATEGORY_LABELS,
|
|
15
14
|
FILTER_ORDER,
|
|
16
15
|
} from "./assetHelpers";
|
|
16
|
+
import { AudioRow } from "./AudioRow";
|
|
17
17
|
|
|
18
18
|
interface AssetsTabProps {
|
|
19
19
|
projectId: string;
|
|
@@ -23,215 +23,7 @@ interface AssetsTabProps {
|
|
|
23
23
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
projectId,
|
|
28
|
-
asset,
|
|
29
|
-
used,
|
|
30
|
-
meta,
|
|
31
|
-
onCopy,
|
|
32
|
-
isCopied,
|
|
33
|
-
onDelete,
|
|
34
|
-
onRename,
|
|
35
|
-
}: {
|
|
36
|
-
projectId: string;
|
|
37
|
-
asset: string;
|
|
38
|
-
used: boolean;
|
|
39
|
-
meta?: { description?: string; duration?: number };
|
|
40
|
-
onCopy: (path: string) => void;
|
|
41
|
-
isCopied: boolean;
|
|
42
|
-
onDelete?: (path: string) => void;
|
|
43
|
-
onRename?: (oldPath: string, newPath: string) => void;
|
|
44
|
-
}) {
|
|
45
|
-
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
46
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
47
|
-
const [playing, setPlaying] = useState(false);
|
|
48
|
-
const [bars, setBars] = useState<number[]>([]);
|
|
49
|
-
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
50
|
-
const actxRef = useRef<AudioContext | null>(null);
|
|
51
|
-
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
52
|
-
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
53
|
-
const animRef = useRef<number>(0);
|
|
54
|
-
const name = basename(asset);
|
|
55
|
-
const subtype = getAudioSubtype(asset);
|
|
56
|
-
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
57
|
-
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
return () => {
|
|
60
|
-
cancelAnimationFrame(animRef.current);
|
|
61
|
-
audioRef.current?.pause();
|
|
62
|
-
actxRef.current?.close();
|
|
63
|
-
};
|
|
64
|
-
}, []);
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
if (playing) {
|
|
68
|
-
const barCount = 24;
|
|
69
|
-
const loop = () => {
|
|
70
|
-
const analyser = analyserRef.current;
|
|
71
|
-
if (!analyser) {
|
|
72
|
-
animRef.current = requestAnimationFrame(loop);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
76
|
-
analyser.getByteFrequencyData(data);
|
|
77
|
-
const step = Math.floor(data.length / barCount);
|
|
78
|
-
const next: number[] = [];
|
|
79
|
-
for (let i = 0; i < barCount; i++) {
|
|
80
|
-
let sum = 0;
|
|
81
|
-
for (let j = 0; j < step; j++) sum += data[i * step + j];
|
|
82
|
-
next.push(sum / step / 255);
|
|
83
|
-
}
|
|
84
|
-
setBars(next);
|
|
85
|
-
if (audioRef.current && !audioRef.current.paused)
|
|
86
|
-
animRef.current = requestAnimationFrame(loop);
|
|
87
|
-
};
|
|
88
|
-
animRef.current = requestAnimationFrame(loop);
|
|
89
|
-
} else {
|
|
90
|
-
setBars([]);
|
|
91
|
-
}
|
|
92
|
-
return () => cancelAnimationFrame(animRef.current);
|
|
93
|
-
}, [playing]);
|
|
94
|
-
|
|
95
|
-
const togglePlay = useCallback(async () => {
|
|
96
|
-
if (playing) {
|
|
97
|
-
audioRef.current?.pause();
|
|
98
|
-
setPlaying(false);
|
|
99
|
-
cancelAnimationFrame(animRef.current);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (!actxRef.current) {
|
|
104
|
-
actxRef.current = new AudioContext();
|
|
105
|
-
analyserRef.current = actxRef.current.createAnalyser();
|
|
106
|
-
analyserRef.current.fftSize = 256;
|
|
107
|
-
analyserRef.current.smoothingTimeConstant = 0.7;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (!audioRef.current) {
|
|
111
|
-
const el = new Audio();
|
|
112
|
-
el.onended = () => {
|
|
113
|
-
setPlaying(false);
|
|
114
|
-
cancelAnimationFrame(animRef.current);
|
|
115
|
-
};
|
|
116
|
-
audioRef.current = el;
|
|
117
|
-
sourceRef.current = actxRef.current.createMediaElementSource(el);
|
|
118
|
-
sourceRef.current.connect(analyserRef.current!);
|
|
119
|
-
analyserRef.current!.connect(actxRef.current.destination);
|
|
120
|
-
el.src = serveUrl;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (actxRef.current.state === "suspended") await actxRef.current.resume();
|
|
124
|
-
audioRef.current.currentTime = 0;
|
|
125
|
-
await audioRef.current.play();
|
|
126
|
-
setPlaying(true);
|
|
127
|
-
}, [serveUrl, playing]);
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<>
|
|
131
|
-
<div
|
|
132
|
-
draggable
|
|
133
|
-
onClick={() => onCopy(asset)}
|
|
134
|
-
onDragStart={(e) => {
|
|
135
|
-
e.dataTransfer.effectAllowed = "copy";
|
|
136
|
-
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
137
|
-
e.dataTransfer.setData("text/plain", asset);
|
|
138
|
-
}}
|
|
139
|
-
onContextMenu={(e) => {
|
|
140
|
-
e.preventDefault();
|
|
141
|
-
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
142
|
-
}}
|
|
143
|
-
className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
|
|
144
|
-
playing
|
|
145
|
-
? "bg-panel-accent/[0.06]"
|
|
146
|
-
: isCopied
|
|
147
|
-
? "bg-panel-accent/10"
|
|
148
|
-
: "hover:bg-panel-surface-hover"
|
|
149
|
-
}`}
|
|
150
|
-
>
|
|
151
|
-
<button
|
|
152
|
-
className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
|
|
153
|
-
playing
|
|
154
|
-
? "bg-panel-accent/15 text-panel-accent"
|
|
155
|
-
: "text-panel-text-5 group-hover:text-panel-text-3"
|
|
156
|
-
}`}
|
|
157
|
-
onClick={(e) => {
|
|
158
|
-
e.stopPropagation();
|
|
159
|
-
togglePlay();
|
|
160
|
-
}}
|
|
161
|
-
>
|
|
162
|
-
{playing ? (
|
|
163
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
164
|
-
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
165
|
-
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
166
|
-
</svg>
|
|
167
|
-
) : (
|
|
168
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
169
|
-
<polygon points="6,4 20,12 6,20" />
|
|
170
|
-
</svg>
|
|
171
|
-
)}
|
|
172
|
-
</button>
|
|
173
|
-
<div className="min-w-0 flex-1">
|
|
174
|
-
<div className="flex items-center gap-1.5">
|
|
175
|
-
<span
|
|
176
|
-
className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
177
|
-
>
|
|
178
|
-
{name}
|
|
179
|
-
</span>
|
|
180
|
-
{!playing && (
|
|
181
|
-
<span className="text-[11px] text-panel-text-5 flex-shrink-0">
|
|
182
|
-
{meta?.duration ? `${meta.duration}s · ` : ""}
|
|
183
|
-
{subtype}
|
|
184
|
-
</span>
|
|
185
|
-
)}
|
|
186
|
-
{used && (
|
|
187
|
-
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
|
|
188
|
-
in use
|
|
189
|
-
</span>
|
|
190
|
-
)}
|
|
191
|
-
</div>
|
|
192
|
-
{bars.length > 0 && (
|
|
193
|
-
<div className="flex items-end gap-[2px] h-[14px] mt-0.5">
|
|
194
|
-
{bars.map((v, i) => (
|
|
195
|
-
<div
|
|
196
|
-
key={i}
|
|
197
|
-
className="flex-1 rounded-[1px]"
|
|
198
|
-
style={{
|
|
199
|
-
height: `${Math.max(10, v * 100)}%`,
|
|
200
|
-
background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
|
|
201
|
-
transition: "height 80ms ease-out",
|
|
202
|
-
}}
|
|
203
|
-
/>
|
|
204
|
-
))}
|
|
205
|
-
</div>
|
|
206
|
-
)}
|
|
207
|
-
</div>
|
|
208
|
-
</div>
|
|
209
|
-
|
|
210
|
-
{contextMenu && (
|
|
211
|
-
<ContextMenu
|
|
212
|
-
x={contextMenu.x}
|
|
213
|
-
y={contextMenu.y}
|
|
214
|
-
asset={asset}
|
|
215
|
-
onClose={() => setContextMenu(null)}
|
|
216
|
-
onCopy={onCopy}
|
|
217
|
-
onDelete={onDelete}
|
|
218
|
-
onRename={onRename}
|
|
219
|
-
/>
|
|
220
|
-
)}
|
|
221
|
-
{confirmDelete && (
|
|
222
|
-
<DeleteConfirm
|
|
223
|
-
name={name}
|
|
224
|
-
onConfirm={() => {
|
|
225
|
-
onDelete?.(asset);
|
|
226
|
-
setConfirmDelete(false);
|
|
227
|
-
}}
|
|
228
|
-
onCancel={() => setConfirmDelete(false)}
|
|
229
|
-
/>
|
|
230
|
-
)}
|
|
231
|
-
</>
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
|
|
26
|
+
// fallow-ignore-next-line complexity
|
|
235
27
|
function ImageCard({
|
|
236
28
|
projectId,
|
|
237
29
|
asset,
|
|
@@ -396,10 +188,25 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
396
188
|
Map<string, { description?: string; duration?: number; width?: number; height?: number }>
|
|
397
189
|
>(new Map());
|
|
398
190
|
|
|
191
|
+
// Projects whose media manifest 404'd — most don't have one. Cache the miss so
|
|
192
|
+
// we don't re-fetch (and spam the console) on every re-render; the effect was
|
|
193
|
+
// also keyed on the `assets` array reference, which changes each render, so it
|
|
194
|
+
// re-fired constantly. Key on a stable join + skip known-missing manifests.
|
|
195
|
+
const manifest404Ref = useRef<Set<string>>(new Set());
|
|
196
|
+
const assetsKey = assets.join("|");
|
|
399
197
|
useEffect(() => {
|
|
198
|
+
if (manifest404Ref.current.has(projectId)) return;
|
|
199
|
+
let cancelled = false;
|
|
400
200
|
fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`)
|
|
401
|
-
.then((r) =>
|
|
201
|
+
.then((r) => {
|
|
202
|
+
if (!r.ok) {
|
|
203
|
+
manifest404Ref.current.add(projectId);
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
return r.text();
|
|
207
|
+
})
|
|
402
208
|
.then((text) => {
|
|
209
|
+
if (cancelled || !text) return;
|
|
403
210
|
const m = new Map<
|
|
404
211
|
string,
|
|
405
212
|
{ description?: string; duration?: number; width?: number; height?: number }
|
|
@@ -416,7 +223,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
416
223
|
setManifest(m);
|
|
417
224
|
})
|
|
418
225
|
.catch(() => {});
|
|
419
|
-
|
|
226
|
+
return () => {
|
|
227
|
+
cancelled = true;
|
|
228
|
+
};
|
|
229
|
+
}, [projectId, assetsKey]);
|
|
420
230
|
|
|
421
231
|
const handleDrop = useCallback(
|
|
422
232
|
(e: React.DragEvent) => {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import { ContextMenu } from "./AssetContextMenu";
|
|
3
|
+
import { basename, getAudioSubtype } from "./assetHelpers";
|
|
4
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
|
|
6
|
+
export function AudioRow({
|
|
7
|
+
projectId,
|
|
8
|
+
asset,
|
|
9
|
+
used,
|
|
10
|
+
meta,
|
|
11
|
+
onCopy,
|
|
12
|
+
isCopied,
|
|
13
|
+
onDelete,
|
|
14
|
+
onRename,
|
|
15
|
+
}: {
|
|
16
|
+
projectId: string;
|
|
17
|
+
asset: string;
|
|
18
|
+
used: boolean;
|
|
19
|
+
meta?: { description?: string; duration?: number };
|
|
20
|
+
onCopy: (path: string) => void;
|
|
21
|
+
isCopied: boolean;
|
|
22
|
+
onDelete?: (path: string) => void;
|
|
23
|
+
onRename?: (oldPath: string, newPath: string) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
26
|
+
const [playing, setPlaying] = useState(false);
|
|
27
|
+
const [bars, setBars] = useState<number[]>([]);
|
|
28
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
29
|
+
const actxRef = useRef<AudioContext | null>(null);
|
|
30
|
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
31
|
+
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
32
|
+
const animRef = useRef<number>(0);
|
|
33
|
+
const name = basename(asset);
|
|
34
|
+
const subtype = getAudioSubtype(asset);
|
|
35
|
+
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
return () => {
|
|
39
|
+
cancelAnimationFrame(animRef.current);
|
|
40
|
+
audioRef.current?.pause();
|
|
41
|
+
actxRef.current?.close();
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (playing) {
|
|
47
|
+
const barCount = 24;
|
|
48
|
+
const loop = () => {
|
|
49
|
+
const analyser = analyserRef.current;
|
|
50
|
+
if (!analyser) {
|
|
51
|
+
animRef.current = requestAnimationFrame(loop);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
55
|
+
analyser.getByteFrequencyData(data);
|
|
56
|
+
const step = Math.floor(data.length / barCount);
|
|
57
|
+
const next: number[] = [];
|
|
58
|
+
for (let i = 0; i < barCount; i++) {
|
|
59
|
+
let sum = 0;
|
|
60
|
+
for (let j = 0; j < step; j++) sum += data[i * step + j];
|
|
61
|
+
next.push(sum / step / 255);
|
|
62
|
+
}
|
|
63
|
+
setBars(next);
|
|
64
|
+
if (audioRef.current && !audioRef.current.paused)
|
|
65
|
+
animRef.current = requestAnimationFrame(loop);
|
|
66
|
+
};
|
|
67
|
+
animRef.current = requestAnimationFrame(loop);
|
|
68
|
+
} else {
|
|
69
|
+
setBars([]);
|
|
70
|
+
}
|
|
71
|
+
return () => cancelAnimationFrame(animRef.current);
|
|
72
|
+
}, [playing]);
|
|
73
|
+
|
|
74
|
+
const togglePlay = useCallback(async () => {
|
|
75
|
+
if (playing) {
|
|
76
|
+
audioRef.current?.pause();
|
|
77
|
+
setPlaying(false);
|
|
78
|
+
cancelAnimationFrame(animRef.current);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!actxRef.current) {
|
|
83
|
+
actxRef.current = new AudioContext();
|
|
84
|
+
analyserRef.current = actxRef.current.createAnalyser();
|
|
85
|
+
analyserRef.current.fftSize = 256;
|
|
86
|
+
analyserRef.current.smoothingTimeConstant = 0.7;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!audioRef.current) {
|
|
90
|
+
const el = new Audio();
|
|
91
|
+
el.onended = () => {
|
|
92
|
+
setPlaying(false);
|
|
93
|
+
cancelAnimationFrame(animRef.current);
|
|
94
|
+
};
|
|
95
|
+
audioRef.current = el;
|
|
96
|
+
sourceRef.current = actxRef.current.createMediaElementSource(el);
|
|
97
|
+
sourceRef.current.connect(analyserRef.current!);
|
|
98
|
+
analyserRef.current!.connect(actxRef.current.destination);
|
|
99
|
+
el.src = serveUrl;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (actxRef.current.state === "suspended") await actxRef.current.resume();
|
|
103
|
+
audioRef.current.currentTime = 0;
|
|
104
|
+
await audioRef.current.play();
|
|
105
|
+
setPlaying(true);
|
|
106
|
+
}, [serveUrl, playing]);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<div
|
|
111
|
+
draggable
|
|
112
|
+
onClick={() => onCopy(asset)}
|
|
113
|
+
onDragStart={(e) => {
|
|
114
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
115
|
+
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
116
|
+
e.dataTransfer.setData("text/plain", asset);
|
|
117
|
+
}}
|
|
118
|
+
onContextMenu={(e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
121
|
+
}}
|
|
122
|
+
className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
|
|
123
|
+
playing
|
|
124
|
+
? "bg-panel-accent/[0.06]"
|
|
125
|
+
: isCopied
|
|
126
|
+
? "bg-panel-accent/10"
|
|
127
|
+
: "hover:bg-panel-surface-hover"
|
|
128
|
+
}`}
|
|
129
|
+
>
|
|
130
|
+
<button
|
|
131
|
+
className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
|
|
132
|
+
playing
|
|
133
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
134
|
+
: "text-panel-text-5 group-hover:text-panel-text-3"
|
|
135
|
+
}`}
|
|
136
|
+
onClick={(e) => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
togglePlay();
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{playing ? (
|
|
142
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
143
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
144
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
145
|
+
</svg>
|
|
146
|
+
) : (
|
|
147
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
148
|
+
<polygon points="6,4 20,12 6,20" />
|
|
149
|
+
</svg>
|
|
150
|
+
)}
|
|
151
|
+
</button>
|
|
152
|
+
<div className="min-w-0 flex-1">
|
|
153
|
+
<div className="flex items-center gap-1.5">
|
|
154
|
+
<span
|
|
155
|
+
className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
156
|
+
>
|
|
157
|
+
{name}
|
|
158
|
+
</span>
|
|
159
|
+
{!playing && (
|
|
160
|
+
<span className="text-[11px] text-panel-text-5 flex-shrink-0">
|
|
161
|
+
{meta?.duration ? `${meta.duration}s · ` : ""}
|
|
162
|
+
{subtype}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
{used && (
|
|
166
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
|
|
167
|
+
in use
|
|
168
|
+
</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
{bars.length > 0 && (
|
|
172
|
+
<div className="flex items-end gap-[2px] h-[14px] mt-0.5">
|
|
173
|
+
{bars.map((v, i) => (
|
|
174
|
+
<div
|
|
175
|
+
key={i}
|
|
176
|
+
className="flex-1 rounded-[1px]"
|
|
177
|
+
style={{
|
|
178
|
+
height: `${Math.max(10, v * 100)}%`,
|
|
179
|
+
background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
|
|
180
|
+
transition: "height 80ms ease-out",
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{contextMenu && (
|
|
190
|
+
<ContextMenu
|
|
191
|
+
x={contextMenu.x}
|
|
192
|
+
y={contextMenu.y}
|
|
193
|
+
asset={asset}
|
|
194
|
+
onClose={() => setContextMenu(null)}
|
|
195
|
+
onCopy={onCopy}
|
|
196
|
+
onDelete={onDelete}
|
|
197
|
+
onRename={onRename}
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -55,6 +55,7 @@ export interface DomEditActionsValue extends Pick<
|
|
|
55
55
|
| "handleGsapRemoveAllKeyframes"
|
|
56
56
|
| "handleResetSelectedElementKeyframes"
|
|
57
57
|
| "commitAnimatedProperty"
|
|
58
|
+
| "commitAnimatedProperties"
|
|
58
59
|
| "handleSetArcPath"
|
|
59
60
|
| "handleUpdateArcSegment"
|
|
60
61
|
| "handleUnroll"
|
|
@@ -164,6 +165,7 @@ export function DomEditProvider({
|
|
|
164
165
|
handleGsapRemoveAllKeyframes,
|
|
165
166
|
handleResetSelectedElementKeyframes,
|
|
166
167
|
commitAnimatedProperty,
|
|
168
|
+
commitAnimatedProperties,
|
|
167
169
|
handleSetArcPath,
|
|
168
170
|
handleUpdateArcSegment,
|
|
169
171
|
handleUnroll,
|
|
@@ -238,6 +240,7 @@ export function DomEditProvider({
|
|
|
238
240
|
handleGsapRemoveAllKeyframes,
|
|
239
241
|
handleResetSelectedElementKeyframes,
|
|
240
242
|
commitAnimatedProperty,
|
|
243
|
+
commitAnimatedProperties,
|
|
241
244
|
handleSetArcPath,
|
|
242
245
|
handleUpdateArcSegment,
|
|
243
246
|
handleUnroll,
|
|
@@ -298,6 +301,7 @@ export function DomEditProvider({
|
|
|
298
301
|
handleGsapRemoveAllKeyframes,
|
|
299
302
|
handleResetSelectedElementKeyframes,
|
|
300
303
|
commitAnimatedProperty,
|
|
304
|
+
commitAnimatedProperties,
|
|
301
305
|
handleSetArcPath,
|
|
302
306
|
handleUpdateArcSegment,
|
|
303
307
|
handleUnroll,
|