@hyperframes/studio 0.7.7 → 0.7.8

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 (39) hide show
  1. package/dist/assets/{index-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
  3. package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
  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 +2699 -2052
  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/marqueeCommit.ts +63 -20
  18. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  19. package/src/components/editor/propertyPanelHelpers.ts +43 -21
  20. package/src/components/editor/transform3dProjection.test.ts +99 -0
  21. package/src/components/editor/transform3dProjection.ts +172 -0
  22. package/src/components/sidebar/AssetsTab.tsx +23 -213
  23. package/src/components/sidebar/AudioRow.tsx +202 -0
  24. package/src/contexts/DomEditContext.tsx +4 -0
  25. package/src/hooks/gsapDragCommit.test.ts +9 -5
  26. package/src/hooks/gsapDragCommit.ts +28 -9
  27. package/src/hooks/gsapRuntimeKeyframes.ts +50 -5
  28. package/src/hooks/gsapRuntimePatch.ts +162 -35
  29. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  30. package/src/hooks/useDomEditCommits.ts +0 -2
  31. package/src/hooks/useDomEditSession.ts +4 -2
  32. package/src/hooks/useDomGeometryCommits.ts +3 -31
  33. package/src/hooks/useGsapAwareEditing.ts +31 -1
  34. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  35. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  36. package/src/hooks/useGsapTweenCache.ts +6 -4
  37. package/src/utils/marqueeGeometry.test.ts +15 -98
  38. package/src/utils/marqueeGeometry.ts +1 -158
  39. package/dist/assets/index-BSkUuN8g.css +0 -1
@@ -68,12 +68,18 @@ export interface PropertyPanelProps {
68
68
  onRemoveKeyframe?: (animationId: string, percentage: number) => void;
69
69
  onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
70
70
  onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
71
- onConvertToKeyframes?: (animationId: string) => void;
71
+ onConvertToKeyframes?: (animationId: string, duration?: number) => void;
72
72
  onCommitAnimatedProperty?: (
73
73
  selection: DomEditSelection,
74
74
  property: string,
75
75
  value: number | string,
76
76
  ) => Promise<void>;
77
+ /** Batched variant: commit several props into ONE keyframe (e.g. the 3D cube's
78
+ * rotationX/Y/Z) so multi-axis edits don't race into adjacent duplicates. */
79
+ onCommitAnimatedProperties?: (
80
+ selection: DomEditSelection,
81
+ props: Record<string, number | string>,
82
+ ) => Promise<void>;
77
83
  onSeekToTime?: (time: number) => void;
78
84
  recordingState?: "idle" | "recording" | "preview";
79
85
  recordingDuration?: number;
@@ -212,10 +218,8 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3";
212
218
  export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
213
219
  export const EMPTY_STYLES: Record<string, string> = {};
214
220
 
215
- // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
216
- export const EMPTY_FILTER_VALUE = "none";
217
- // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
218
- export const BOX_SHADOW_PRESETS = {
221
+ const EMPTY_FILTER_VALUE = "none";
222
+ const BOX_SHADOW_PRESETS = {
219
223
  none: "none",
220
224
  soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
221
225
  lift: "0 18px 54px rgba(0, 0, 0, 0.38)",
@@ -275,13 +279,7 @@ export function parsePxMetricValue(value: string): number | null {
275
279
  return token.value;
276
280
  }
277
281
 
278
- // fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
279
- export function clampPanelNumber(
280
- value: number,
281
- min: number,
282
- max: number,
283
- fallback: number,
284
- ): number {
282
+ function clampPanelNumber(value: number, min: number, max: number, fallback: number): number {
285
283
  if (!Number.isFinite(value)) return fallback;
286
284
  return Math.max(min, Math.min(max, value));
287
285
  }
@@ -491,6 +489,38 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
491
489
  // ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
492
490
 
493
491
  // fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
492
+ // Core transform channels the panel ALWAYS reads live — even before a just-set
493
+ // value (e.g. rotationX) has re-parsed into `gsapAnimations`. Without this the
494
+ // cube + fields drop the prop and flicker to 0 on every commit; gsap.getProperty
495
+ // reflects the in-place instant patch, so it's the true current value.
496
+ const ALWAYS_READ_CHANNELS = [
497
+ "x",
498
+ "y",
499
+ "rotation",
500
+ "rotationX",
501
+ "rotationY",
502
+ "rotationZ",
503
+ "z",
504
+ "scale",
505
+ "transformPerspective",
506
+ "opacity",
507
+ ];
508
+
509
+ /** Every property key the panel should read for an element: animated props + the
510
+ * always-read transform channels. */
511
+ function collectPanelPropKeys(gsapAnimations: GsapAnimation[]): Set<string> {
512
+ const keys = new Set<string>(ALWAYS_READ_CHANNELS);
513
+ for (const anim of gsapAnimations) {
514
+ if (anim.keyframes) {
515
+ for (const kf of anim.keyframes.keyframes) {
516
+ for (const p of Object.keys(kf.properties)) keys.add(p);
517
+ }
518
+ }
519
+ for (const p of Object.keys(anim.properties)) keys.add(p);
520
+ }
521
+ return keys;
522
+ }
523
+
494
524
  export function readGsapRuntimeValuesForPanel(
495
525
  gsapAnimId: string | null,
496
526
  gsapAnimations: GsapAnimation[],
@@ -511,15 +541,7 @@ export function readGsapRuntimeValuesForPanel(
511
541
  if (!gsap?.getProperty) return null;
512
542
  const el = iframe.contentDocument?.querySelector(selector);
513
543
  if (!el) return null;
514
- const propKeys = new Set<string>();
515
- for (const anim of gsapAnimations) {
516
- if (anim.keyframes) {
517
- for (const kf of anim.keyframes.keyframes) {
518
- for (const p of Object.keys(kf.properties)) propKeys.add(p);
519
- }
520
- }
521
- for (const p of Object.keys(anim.properties)) propKeys.add(p);
522
- }
544
+ const propKeys = collectPanelPropKeys(gsapAnimations);
523
545
  const result: Record<string, number> = {};
524
546
  for (const prop of propKeys) {
525
547
  const v = Number(gsap.getProperty(el, prop));
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { projectAxes, projectCubeFaces, rotate, wrapDeg } from "./transform3dProjection";
3
+
4
+ const OPTS = { cx: 50, cy: 50, r: 30, persp: 4 };
5
+
6
+ describe("projectCubeFaces", () => {
7
+ it("shows the front face (and only front-facing faces) at identity", () => {
8
+ const faces = projectCubeFaces(0, 0, 0, OPTS);
9
+ const ids = faces.map((f) => f.id);
10
+ expect(ids).toContain("front");
11
+ // Head-on: side/top/bottom normals are edge-on (n.z ≈ 0) → culled.
12
+ expect(ids).not.toContain("back");
13
+ expect(faces.length).toBeGreaterThanOrEqual(1);
14
+ expect(faces.length).toBeLessThanOrEqual(3);
15
+ });
16
+
17
+ it("reveals the top face when tilted forward on X", () => {
18
+ const ids = projectCubeFaces(45, 0, 0, OPTS).map((f) => f.id);
19
+ expect(ids).toContain("front");
20
+ expect(ids).toContain("top");
21
+ expect(ids).not.toContain("bottom");
22
+ });
23
+
24
+ it("reveals a side face when rotated on Y (CSS rotateY convention)", () => {
25
+ const ids = projectCubeFaces(0, 45, 0, OPTS).map((f) => f.id);
26
+ expect(ids).toContain("front");
27
+ expect(ids).toContain("left");
28
+ expect(ids).not.toContain("right");
29
+ });
30
+
31
+ it("never returns more than 3 faces (a cube shows at most 3 at once)", () => {
32
+ for (const [rx, ry, rz] of [
33
+ [30, 30, 0],
34
+ [60, 60, 45],
35
+ [135, 20, 90],
36
+ ]) {
37
+ expect(projectCubeFaces(rx, ry, rz, OPTS).length).toBeLessThanOrEqual(3);
38
+ }
39
+ });
40
+
41
+ it("rotationZ rolls the silhouette without changing which faces are visible", () => {
42
+ const a = projectCubeFaces(0, 0, 0, OPTS)
43
+ .map((f) => f.id)
44
+ .sort();
45
+ const b = projectCubeFaces(0, 0, 90, OPTS)
46
+ .map((f) => f.id)
47
+ .sort();
48
+ expect(b).toEqual(a);
49
+ // …but the projected coordinates differ (it rolled).
50
+ expect(projectCubeFaces(0, 0, 90, OPTS)[0]?.points).not.toEqual(
51
+ projectCubeFaces(0, 0, 0, OPTS)[0]?.points,
52
+ );
53
+ });
54
+
55
+ it("paints far faces before near faces (painter's order)", () => {
56
+ const faces = projectCubeFaces(35, 35, 0, OPTS);
57
+ for (let i = 1; i < faces.length; i++) {
58
+ expect(faces[i]!.depth).toBeGreaterThanOrEqual(faces[i - 1]!.depth);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("projectAxes", () => {
64
+ it("returns the three X/Y/Z axes with standard colors", () => {
65
+ const axes = projectAxes(0, 0, 0, OPTS);
66
+ expect(axes.map((a) => a.id).sort()).toEqual(["x", "y", "z"]);
67
+ const z = axes.find((a) => a.id === "z")!;
68
+ expect(z.color).toMatch(/#/);
69
+ });
70
+
71
+ it("flags the toward-viewer axis as front at identity (Z points at the camera)", () => {
72
+ const z = projectAxes(0, 0, 0, OPTS).find((a) => a.id === "z")!;
73
+ expect(z.front).toBe(true);
74
+ });
75
+
76
+ it("rotating 180° on Y flips the Z axis away from the viewer", () => {
77
+ const z = projectAxes(0, 180, 0, OPTS).find((a) => a.id === "z")!;
78
+ expect(z.front).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe("rotate", () => {
83
+ it("90° on Y maps +x to -z (right edge swings to the back)", () => {
84
+ const v = rotate({ x: 1, y: 0, z: 0 }, 0, 90, 0);
85
+ expect(v.x).toBeCloseTo(0, 5);
86
+ expect(v.z).toBeCloseTo(-1, 5);
87
+ });
88
+ });
89
+
90
+ describe("wrapDeg", () => {
91
+ it("wraps into (-180, 180]", () => {
92
+ expect(wrapDeg(0)).toBe(0);
93
+ expect(wrapDeg(180)).toBe(180);
94
+ expect(wrapDeg(190)).toBe(-170);
95
+ expect(wrapDeg(-190)).toBe(170);
96
+ expect(wrapDeg(360)).toBe(0);
97
+ expect(wrapDeg(540)).toBe(180);
98
+ });
99
+ });
@@ -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) => {