@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.3
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-Dm9VsShj.css → index-BpcIkyVP.css} +1 -1
- package/dist/assets/{index-Bi30tos-.js → index-Z9LjUi1W.js} +34 -34
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +57 -13
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/styles/studio.css +9 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-Z9LjUi1W.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BpcIkyVP.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.3",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.5.0-alpha.
|
|
36
|
-
"@hyperframes/player": "0.5.0-alpha.
|
|
35
|
+
"@hyperframes/core": "0.5.0-alpha.3",
|
|
36
|
+
"@hyperframes/player": "0.5.0-alpha.3"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.5.0-alpha.
|
|
50
|
+
"@hyperframes/producer": "0.5.0-alpha.3"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -852,13 +852,28 @@ export function StudioApp() {
|
|
|
852
852
|
|
|
853
853
|
// Audio clips — waveform visualization
|
|
854
854
|
if (el.tag === "audio") {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
855
|
+
const previewBase = `/api/projects/${pid}/preview/`;
|
|
856
|
+
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
|
|
857
|
+
const srcRelative = el.src
|
|
858
|
+
? previewIdx !== -1
|
|
859
|
+
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
|
|
860
|
+
: el.src.startsWith("http")
|
|
861
|
+
? null
|
|
862
|
+
: el.src
|
|
863
|
+
: null;
|
|
864
|
+
const audioUrl = srcRelative
|
|
865
|
+
? `/api/projects/${pid}/preview/${srcRelative}`
|
|
866
|
+
: (el.src ?? "");
|
|
867
|
+
const waveformUrl = srcRelative
|
|
868
|
+
? `/api/projects/${pid}/waveform/${srcRelative}`
|
|
869
|
+
: undefined;
|
|
860
870
|
return (
|
|
861
|
-
<AudioWaveform
|
|
871
|
+
<AudioWaveform
|
|
872
|
+
audioUrl={audioUrl}
|
|
873
|
+
waveformUrl={waveformUrl}
|
|
874
|
+
label={el.id || el.tag}
|
|
875
|
+
labelColor={style.label}
|
|
876
|
+
/>
|
|
862
877
|
);
|
|
863
878
|
}
|
|
864
879
|
|
|
@@ -2139,7 +2154,11 @@ export function StudioApp() {
|
|
|
2139
2154
|
);
|
|
2140
2155
|
|
|
2141
2156
|
const handleTimelineAssetDrop = useCallback(
|
|
2142
|
-
async (
|
|
2157
|
+
async (
|
|
2158
|
+
assetPath: string,
|
|
2159
|
+
placement: Pick<TimelineElement, "start" | "track">,
|
|
2160
|
+
durationOverride?: number,
|
|
2161
|
+
) => {
|
|
2143
2162
|
const pid = projectIdRef.current;
|
|
2144
2163
|
if (!pid) throw new Error("No active project");
|
|
2145
2164
|
|
|
@@ -2165,9 +2184,11 @@ export function StudioApp() {
|
|
|
2165
2184
|
}
|
|
2166
2185
|
|
|
2167
2186
|
const normalizedStart = Number(formatTimelineAttributeNumber(placement.start));
|
|
2168
|
-
const
|
|
2169
|
-
|
|
2170
|
-
|
|
2187
|
+
const duration =
|
|
2188
|
+
Number.isFinite(durationOverride) && durationOverride != null && durationOverride > 0
|
|
2189
|
+
? durationOverride
|
|
2190
|
+
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
2191
|
+
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
2171
2192
|
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
2172
2193
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
2173
2194
|
|
|
@@ -2247,17 +2268,40 @@ export function StudioApp() {
|
|
|
2247
2268
|
|
|
2248
2269
|
const handleTimelineFileDrop = useCallback(
|
|
2249
2270
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
2271
|
+
const pid = projectIdRef.current;
|
|
2272
|
+
if (!pid) return;
|
|
2250
2273
|
const uploaded = await uploadProjectFiles(files);
|
|
2251
2274
|
if (uploaded.length === 0) return;
|
|
2275
|
+
const durations: number[] = [];
|
|
2276
|
+
for (const assetPath of uploaded) {
|
|
2277
|
+
const kind = getTimelineAssetKind(assetPath);
|
|
2278
|
+
const duration = kind ? await resolveDroppedAssetDuration(pid, assetPath, kind) : 0;
|
|
2279
|
+
durations.push(Number(formatTimelineAttributeNumber(duration)));
|
|
2280
|
+
}
|
|
2252
2281
|
const placements = buildTimelineFileDropPlacements(
|
|
2253
2282
|
placement ?? { start: 0, track: 0 },
|
|
2254
|
-
|
|
2283
|
+
durations,
|
|
2284
|
+
timelineElements
|
|
2285
|
+
.filter(
|
|
2286
|
+
(timelineElement) =>
|
|
2287
|
+
(timelineElement.sourceFile || activeCompPath || "index.html") ===
|
|
2288
|
+
(activeCompPath || "index.html"),
|
|
2289
|
+
)
|
|
2290
|
+
.map((timelineElement) => ({
|
|
2291
|
+
start: timelineElement.start,
|
|
2292
|
+
duration: timelineElement.duration,
|
|
2293
|
+
track: timelineElement.track,
|
|
2294
|
+
})),
|
|
2255
2295
|
);
|
|
2256
2296
|
for (const [index, assetPath] of uploaded.entries()) {
|
|
2257
|
-
await handleTimelineAssetDrop(
|
|
2297
|
+
await handleTimelineAssetDrop(
|
|
2298
|
+
assetPath,
|
|
2299
|
+
placements[index] ?? placements[0],
|
|
2300
|
+
durations[index],
|
|
2301
|
+
);
|
|
2258
2302
|
}
|
|
2259
2303
|
},
|
|
2260
|
-
[handleTimelineAssetDrop, uploadProjectFiles],
|
|
2304
|
+
[activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles],
|
|
2261
2305
|
);
|
|
2262
2306
|
|
|
2263
2307
|
// ── File Management Handlers ──
|
|
@@ -2,6 +2,7 @@ import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
|
2
2
|
|
|
3
3
|
interface AudioWaveformProps {
|
|
4
4
|
audioUrl: string;
|
|
5
|
+
waveformUrl?: string;
|
|
5
6
|
label: string;
|
|
6
7
|
labelColor: string;
|
|
7
8
|
}
|
|
@@ -49,6 +50,7 @@ function fakePeaks(url: string, count: number): number[] {
|
|
|
49
50
|
|
|
50
51
|
// Module-level cache so decoded audio persists across re-renders and re-mounts
|
|
51
52
|
const peaksCache = new Map<string, number[]>();
|
|
53
|
+
const decodeInFlight = new Map<string, Promise<number[]>>();
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* Audio waveform rendered from real PCM data via Web Audio API.
|
|
@@ -57,43 +59,56 @@ const peaksCache = new Map<string, number[]>();
|
|
|
57
59
|
*/
|
|
58
60
|
export const AudioWaveform = memo(function AudioWaveform({
|
|
59
61
|
audioUrl,
|
|
62
|
+
waveformUrl,
|
|
60
63
|
label,
|
|
61
64
|
labelColor,
|
|
62
65
|
}: AudioWaveformProps) {
|
|
63
66
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
64
67
|
const barsRef = useRef<HTMLDivElement | null>(null);
|
|
65
68
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
66
|
-
const
|
|
69
|
+
const cacheKey = waveformUrl ?? audioUrl;
|
|
70
|
+
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
|
|
67
71
|
|
|
68
|
-
// Fetch + decode audio once
|
|
69
72
|
useEffect(() => {
|
|
70
|
-
if (peaks || !
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
73
|
+
if (peaks || !cacheKey) return;
|
|
74
|
+
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
|
|
77
|
+
let promise = decodeInFlight.get(cacheKey);
|
|
78
|
+
if (!promise) {
|
|
79
|
+
promise = (
|
|
80
|
+
waveformUrl
|
|
81
|
+
? fetch(waveformUrl)
|
|
82
|
+
.then((r) => r.json())
|
|
83
|
+
.then((d: { peaks?: number[] }) => {
|
|
84
|
+
if (!Array.isArray(d.peaks)) throw new Error("bad response");
|
|
85
|
+
return d.peaks;
|
|
86
|
+
})
|
|
87
|
+
: fetch(audioUrl)
|
|
88
|
+
.then((r) => r.arrayBuffer())
|
|
89
|
+
.then((buf) => {
|
|
90
|
+
const ctx = new AudioContext();
|
|
91
|
+
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
|
92
|
+
})
|
|
93
|
+
.then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
|
|
94
|
+
)
|
|
95
|
+
.catch(() => fakePeaks(cacheKey, 4000))
|
|
96
|
+
.then((p) => {
|
|
97
|
+
peaksCache.set(cacheKey, p);
|
|
98
|
+
return p;
|
|
99
|
+
})
|
|
100
|
+
.finally(() => decodeInFlight.delete(cacheKey));
|
|
101
|
+
|
|
102
|
+
decodeInFlight.set(cacheKey, promise);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
promise.then((p) => {
|
|
106
|
+
if (!cancelled) setPeaks(p);
|
|
107
|
+
});
|
|
108
|
+
return () => {
|
|
109
|
+
cancelled = true;
|
|
110
|
+
};
|
|
111
|
+
}, [audioUrl, waveformUrl, cacheKey, peaks]);
|
|
97
112
|
|
|
98
113
|
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
|
99
114
|
const draw = useCallback(() => {
|
package/src/styles/studio.css
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
@tailwind components;
|
|
3
3
|
@tailwind utilities;
|
|
4
4
|
|
|
5
|
+
/*
|
|
6
|
+
* Studio is a dark-only UI — pin the user-agent color scheme to dark so that
|
|
7
|
+
* browser-native chrome (scrollbars, form controls, focus rings) picks the
|
|
8
|
+
* matching palette instead of defaulting to light against our #0a0a0a body.
|
|
9
|
+
*/
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: dark;
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
body {
|
|
6
15
|
margin: 0;
|
|
7
16
|
padding: 0;
|
|
@@ -12,6 +12,8 @@ describe("getTimelineAssetKind", () => {
|
|
|
12
12
|
it("detects image, video, and audio assets", () => {
|
|
13
13
|
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
|
14
14
|
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
|
|
15
|
+
expect(getTimelineAssetKind("assets/clip.mov")).toBe("video");
|
|
16
|
+
expect(getTimelineAssetKind("assets/music.mp3")).toBe("audio");
|
|
15
17
|
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
|
16
18
|
});
|
|
17
19
|
});
|
|
@@ -78,11 +80,69 @@ describe("resolveTimelineAssetSrc", () => {
|
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
describe("buildTimelineFileDropPlacements", () => {
|
|
81
|
-
it("
|
|
82
|
-
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 },
|
|
83
|
+
it("returns no placements for an empty drop set", () => {
|
|
84
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [])).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses the dropped start and spaces multiple files by duration on the same track", () => {
|
|
88
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 1.6, 1.1])).toEqual([
|
|
89
|
+
{ start: 1.5, track: 2 },
|
|
90
|
+
{ start: 2.7, track: 2 },
|
|
91
|
+
{ start: 4.3, track: 2 },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("uses fallback spacing when a duration is unavailable", () => {
|
|
96
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, [1.2, 0, 1.1])).toEqual([
|
|
83
97
|
{ start: 1.5, track: 2 },
|
|
84
|
-
{ start:
|
|
85
|
-
{ start:
|
|
98
|
+
{ start: 2.7, track: 2 },
|
|
99
|
+
{ start: 7.7, track: 2 },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("moves the spaced sequence to a clear track when the dropped row is occupied", () => {
|
|
104
|
+
expect(
|
|
105
|
+
buildTimelineFileDropPlacements(
|
|
106
|
+
{ start: 1.5, track: 2 },
|
|
107
|
+
[1.2, 1.6, 1.1],
|
|
108
|
+
[
|
|
109
|
+
{ start: 0, duration: 8, track: 2 },
|
|
110
|
+
{ start: 0, duration: 4, track: 5 },
|
|
111
|
+
],
|
|
112
|
+
),
|
|
113
|
+
).toEqual([
|
|
114
|
+
{ start: 1.5, track: 6 },
|
|
115
|
+
{ start: 2.7, track: 6 },
|
|
116
|
+
{ start: 4.3, track: 6 },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps a requested track above occupied rows when that track is clear", () => {
|
|
121
|
+
expect(
|
|
122
|
+
buildTimelineFileDropPlacements(
|
|
123
|
+
{ start: 1.5, track: 8 },
|
|
124
|
+
[1.2, 1.6],
|
|
125
|
+
[
|
|
126
|
+
{ start: 0, duration: 8, track: 2 },
|
|
127
|
+
{ start: 0, duration: 4, track: 5 },
|
|
128
|
+
],
|
|
129
|
+
),
|
|
130
|
+
).toEqual([
|
|
131
|
+
{ start: 1.5, track: 8 },
|
|
132
|
+
{ start: 2.7, track: 8 },
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("moves a default-track drop to a clear row when track 0 is occupied at time 0", () => {
|
|
137
|
+
expect(
|
|
138
|
+
buildTimelineFileDropPlacements(
|
|
139
|
+
{ start: 0, track: 0 },
|
|
140
|
+
[1.2, 1.6],
|
|
141
|
+
[{ start: 0, duration: 8, track: 0 }],
|
|
142
|
+
),
|
|
143
|
+
).toEqual([
|
|
144
|
+
{ start: 0, track: 1 },
|
|
145
|
+
{ start: 1.2, track: 1 },
|
|
86
146
|
]);
|
|
87
147
|
});
|
|
88
148
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
2
2
|
|
|
3
3
|
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
|
4
|
+
const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5;
|
|
4
5
|
|
|
5
6
|
export type TimelineAssetKind = "image" | "video" | "audio";
|
|
6
7
|
|
|
@@ -46,12 +47,33 @@ export function resolveTimelineAssetSrc(targetPath: string, assetPath: string):
|
|
|
46
47
|
|
|
47
48
|
export function buildTimelineFileDropPlacements(
|
|
48
49
|
placement: { start: number; track: number },
|
|
49
|
-
|
|
50
|
+
durations: number[],
|
|
51
|
+
occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
|
|
50
52
|
): Array<{ start: number; track: number }> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
let nextStart = Math.round(Math.max(0, placement.start) * 100) / 100;
|
|
54
|
+
const sequenceStart = nextStart;
|
|
55
|
+
const resolvedDurations = durations.map((duration) =>
|
|
56
|
+
Number.isFinite(duration) && duration > 0 ? duration : FALLBACK_TIMELINE_FILE_DROP_DURATION,
|
|
57
|
+
);
|
|
58
|
+
const sequenceEnd = resolvedDurations.reduce(
|
|
59
|
+
(end, duration) => Math.round((end + duration) * 100) / 100,
|
|
60
|
+
sequenceStart,
|
|
61
|
+
);
|
|
62
|
+
const overlapsDropTrack = occupiedClips.some((clip) => {
|
|
63
|
+
if (clip.track !== placement.track) return false;
|
|
64
|
+
const clipStart = Math.max(0, clip.start);
|
|
65
|
+
const clipEnd = clipStart + Math.max(0, clip.duration);
|
|
66
|
+
return sequenceStart < clipEnd && sequenceEnd > clipStart;
|
|
67
|
+
});
|
|
68
|
+
const track = overlapsDropTrack
|
|
69
|
+
? Math.max(placement.track, ...occupiedClips.map((clip) => clip.track)) + 1
|
|
70
|
+
: placement.track;
|
|
71
|
+
|
|
72
|
+
return resolvedDurations.map((duration) => {
|
|
73
|
+
const start = nextStart;
|
|
74
|
+
nextStart = Math.round((nextStart + duration) * 100) / 100;
|
|
75
|
+
return { start, track };
|
|
76
|
+
});
|
|
55
77
|
}
|
|
56
78
|
|
|
57
79
|
export function resolveTimelineAssetInitialGeometry(source: string): {
|