@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/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-Bi30tos-.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Dm9VsShj.css">
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.1",
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.1",
36
- "@hyperframes/player": "0.5.0-alpha.1"
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.1"
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 audioUrl = el.src
856
- ? el.src.startsWith("http")
857
- ? el.src
858
- : `/api/projects/${pid}/preview/${el.src}`
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 audioUrl={audioUrl} label={el.id || el.tag} labelColor={style.label} />
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 (assetPath: string, placement: Pick<TimelineElement, "start" | "track">) => {
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 normalizedDuration = Number(
2169
- formatTimelineAttributeNumber(await resolveDroppedAssetDuration(pid, assetPath, kind)),
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
- uploaded.length,
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(assetPath, placements[index] ?? placements[0]);
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 [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
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 || !audioUrl) return;
71
-
72
- const ctrl = new AbortController();
73
- fetch(audioUrl, { signal: ctrl.signal })
74
- .then((r) => r.arrayBuffer())
75
- .then((buf) => {
76
- const ctx = new AudioContext();
77
- return ctx.decodeAudioData(buf).finally(() => ctx.close());
78
- })
79
- .then((decoded) => {
80
- if (ctrl.signal.aborted) return;
81
- const channel = decoded.getChannelData(0);
82
- // Extract enough peaks for wide clips (up to 4000 bars)
83
- const p = extractPeaks(channel, 4000);
84
- peaksCache.set(audioUrl, p);
85
- setPeaks(p);
86
- })
87
- .catch(() => {
88
- if (ctrl.signal.aborted) return;
89
- // Fallback to fake waveform
90
- const p = fakePeaks(audioUrl, 4000);
91
- peaksCache.set(audioUrl, p);
92
- setPeaks(p);
93
- });
94
-
95
- return () => ctrl.abort();
96
- }, [audioUrl, peaks]);
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(() => {
@@ -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("uses the dropped start and stacks multiple files onto successive tracks", () => {
82
- expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, 3)).toEqual([
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: 1.5, track: 3 },
85
- { start: 1.5, track: 4 },
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
- count: number,
50
+ durations: number[],
51
+ occupiedClips: Array<{ start: number; duration: number; track: number }> = [],
50
52
  ): Array<{ start: number; track: number }> {
51
- return Array.from({ length: Math.max(0, count) }, (_, index) => ({
52
- start: placement.start,
53
- track: placement.track + index,
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): {