@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.
- package/dist/assets/{index-B2YXvFxf.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-BeRh2hMe.js → index-BSyZKYmH.js} +194 -194
- package/dist/assets/{index-BoASKOeE.js → index-Dgeszckd.js} +1 -1
- 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 +2699 -2052
- 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/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/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
|
@@ -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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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) => {
|