@hyperframes/studio 0.5.0-alpha.2 → 0.5.0-alpha.4

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,7 +4,7 @@
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-BExHzIDS.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-Z9LjUi1W.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-BpcIkyVP.css">
9
9
  </head>
10
10
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.5.0-alpha.2",
3
+ "version": "0.5.0-alpha.4",
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.2",
36
- "@hyperframes/player": "0.5.0-alpha.2"
35
+ "@hyperframes/player": "0.5.0-alpha.4",
36
+ "@hyperframes/core": "0.5.0-alpha.4"
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.2"
50
+ "@hyperframes/producer": "0.5.0-alpha.4"
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
 
@@ -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(() => {