@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.
Files changed (43) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index-B7fv-WA3.js} +1 -1
  2. package/dist/assets/{index-BoASKOeE.js → index-C48wGs63.js} +1 -1
  3. package/dist/assets/{index-BeRh2hMe.js → index-e4xyxg7-.js} +187 -187
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +2715 -2061
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +5 -1
  11. package/src/components/editor/DomEditOverlay.tsx +4 -12
  12. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  13. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  14. package/src/components/editor/PropertyPanel.tsx +12 -0
  15. package/src/components/editor/Transform3DCube.tsx +313 -0
  16. package/src/components/editor/gsapAnimationConstants.ts +5 -0
  17. package/src/components/editor/manualOffsetDrag.test.ts +99 -0
  18. package/src/components/editor/manualOffsetDrag.ts +38 -7
  19. package/src/components/editor/marqueeCommit.ts +63 -20
  20. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  21. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  22. package/src/components/editor/transform3dProjection.test.ts +99 -0
  23. package/src/components/editor/transform3dProjection.ts +172 -0
  24. package/src/components/sidebar/AssetsTab.tsx +23 -213
  25. package/src/components/sidebar/AudioRow.tsx +202 -0
  26. package/src/contexts/DomEditContext.tsx +4 -0
  27. package/src/hooks/gsapDragCommit.test.ts +9 -5
  28. package/src/hooks/gsapDragCommit.ts +28 -9
  29. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  30. package/src/hooks/gsapRuntimePatch.ts +162 -35
  31. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  32. package/src/hooks/useDomEditCommits.ts +0 -2
  33. package/src/hooks/useDomEditSession.ts +4 -2
  34. package/src/hooks/useDomGeometryCommits.ts +3 -31
  35. package/src/hooks/useEnableKeyframes.test.ts +40 -0
  36. package/src/hooks/useEnableKeyframes.ts +9 -2
  37. package/src/hooks/useGsapAwareEditing.ts +31 -1
  38. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  39. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  40. package/src/hooks/useGsapTweenCache.ts +6 -4
  41. package/src/utils/marqueeGeometry.test.ts +15 -98
  42. package/src/utils/marqueeGeometry.ts +1 -158
  43. 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, DeleteConfirm } from "./AssetContextMenu";
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
- function AudioRow({
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) => (r.ok ? r.text() : ""))
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
- }, [projectId, assets]);
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,