mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): server-side waveform generation with caching
This commit is contained in:
@@ -6,6 +6,7 @@ import { registerPreviewRoutes } from "./routes/preview.js";
|
||||
import { registerLintRoutes } from "./routes/lint.js";
|
||||
import { registerRenderRoutes } from "./routes/render.js";
|
||||
import { registerThumbnailRoutes } from "./routes/thumbnail.js";
|
||||
import { registerWaveformRoutes } from "./routes/waveform.js";
|
||||
|
||||
/**
|
||||
* Create a Hono sub-app with all studio API routes.
|
||||
@@ -22,6 +23,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
|
||||
registerLintRoutes(api, adapter);
|
||||
registerRenderRoutes(api, adapter);
|
||||
registerThumbnailRoutes(api, adapter);
|
||||
registerWaveformRoutes(api, adapter);
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export const MIME_TYPES: Record<string, string> = {
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".flac": "audio/flac",
|
||||
".opus": "audio/ogg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
@@ -30,3 +33,7 @@ export function getMimeType(path: string): string {
|
||||
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||
return MIME_TYPES[ext] || "application/octet-stream";
|
||||
}
|
||||
|
||||
export function isAudioFile(name: string): boolean {
|
||||
return (getMimeType(name) ?? "").startsWith("audio/");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SAMPLE_RATE = 4000;
|
||||
const PEAK_COUNT = 4000;
|
||||
export const WAVEFORM_CACHE_VERSION = "v2";
|
||||
|
||||
export function buildWaveformCacheKey(assetPath: string): string {
|
||||
return `${WAVEFORM_CACHE_VERSION}_${assetPath.replace(/[/\\]/g, "_")}.json`;
|
||||
}
|
||||
|
||||
function computePeaks(floats: Float32Array, count: number): number[] {
|
||||
const step = floats.length / count;
|
||||
const peaks: number[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const start = Math.floor(i * step);
|
||||
const end = Math.min(Math.floor((i + 1) * step), floats.length);
|
||||
let max = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(floats[j] ?? 0);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
peaks.push(max);
|
||||
}
|
||||
const maxPeak = Math.max(...peaks, 0.001);
|
||||
return peaks.map((p) => p / maxPeak);
|
||||
}
|
||||
|
||||
export function decodeAudioPeaks(audioPath: string): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-i",
|
||||
audioPath,
|
||||
"-af",
|
||||
"atrim=start_sample=1152",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
String(SAMPLE_RATE),
|
||||
"-vn",
|
||||
"pipe:1",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "ignore"] },
|
||||
);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
proc.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
proc.on("close", (code) => {
|
||||
if (code !== 0 && chunks.length === 0) {
|
||||
reject(new Error(`ffmpeg exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
const numSamples = Math.floor(buf.length / 4);
|
||||
if (numSamples === 0) {
|
||||
reject(new Error("ffmpeg produced no audio samples"));
|
||||
return;
|
||||
}
|
||||
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + numSamples * 4);
|
||||
resolve(computePeaks(new Float32Array(ab), PEAK_COUNT));
|
||||
});
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateWaveformCache(projectDir: string, assetPath: string): Promise<void> {
|
||||
const audioPath = join(projectDir, assetPath);
|
||||
if (!existsSync(audioPath)) return;
|
||||
|
||||
const cacheDir = join(projectDir, ".waveform-cache");
|
||||
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
|
||||
if (existsSync(cachePath)) return;
|
||||
|
||||
const peaks = await decodeAudioPeaks(audioPath);
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(cachePath, JSON.stringify(peaks));
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "node:fs";
|
||||
import { resolve, dirname, join } from "node:path";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { isAudioFile } from "../helpers/mime.js";
|
||||
import { generateWaveformCache } from "../helpers/waveform.js";
|
||||
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
|
||||
import { isSafePath } from "../helpers/safePath.js";
|
||||
import { removeElementFromHtml } from "../helpers/sourceMutation.js";
|
||||
@@ -345,7 +347,11 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
continue;
|
||||
}
|
||||
writeFileSync(finalPath, buffer);
|
||||
uploaded.push(subDir ? join(subDir, finalName) : finalName);
|
||||
const relativePath = subDir ? join(subDir, finalName) : finalName;
|
||||
uploaded.push(relativePath);
|
||||
if (isAudioFile(finalName)) {
|
||||
generateWaveformCache(project.dir, relativePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, files: uploaded, skipped, invalid }, 201);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Hono } from "hono";
|
||||
import type { StudioApiAdapter } from "../types.js";
|
||||
import { decodeAudioPeaks, buildWaveformCacheKey } from "../helpers/waveform.js";
|
||||
|
||||
export { isAudioFile } from "../helpers/mime.js";
|
||||
export { generateWaveformCache } from "../helpers/waveform.js";
|
||||
|
||||
export function registerWaveformRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
api.get("/projects/:id/waveform/*", async (c) => {
|
||||
const project = await adapter.resolveProject(c.req.param("id"));
|
||||
if (!project) return c.json({ error: "not found" }, 404);
|
||||
|
||||
const assetPath = decodeURIComponent(
|
||||
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? "",
|
||||
);
|
||||
const audioPath = join(project.dir, assetPath);
|
||||
if (!existsSync(audioPath)) return c.json({ error: "file not found" }, 404);
|
||||
|
||||
const cacheDir = join(project.dir, ".waveform-cache");
|
||||
const cachePath = join(cacheDir, buildWaveformCacheKey(assetPath));
|
||||
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
const peaks = JSON.parse(readFileSync(cachePath, "utf-8")) as number[];
|
||||
return c.json({ peaks });
|
||||
} catch {
|
||||
// corrupt cache — regenerate
|
||||
}
|
||||
}
|
||||
|
||||
let peaks: number[];
|
||||
try {
|
||||
peaks = await decodeAudioPeaks(audioPath);
|
||||
} catch {
|
||||
return c.json({ error: "failed to decode audio" }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
writeFileSync(cachePath, JSON.stringify(peaks));
|
||||
} catch {
|
||||
// cache write failure is non-fatal
|
||||
}
|
||||
|
||||
return c.json({ peaks });
|
||||
});
|
||||
}
|
||||
@@ -405,13 +405,28 @@ export function StudioApp() {
|
||||
|
||||
// Audio clips — waveform visualization
|
||||
if (el.tag === "audio") {
|
||||
const audioUrl = el.src
|
||||
? el.src.startsWith("http")
|
||||
? el.src
|
||||
: `/api/projects/${pid}/preview/${el.src}`
|
||||
: "";
|
||||
const previewBase = `/api/projects/${pid}/preview/`;
|
||||
const previewIdx = el.src?.startsWith("http") ? el.src.indexOf(previewBase) : -1;
|
||||
const srcRelative = el.src
|
||||
? previewIdx !== -1
|
||||
? decodeURIComponent(el.src.slice(previewIdx + previewBase.length))
|
||||
: el.src.startsWith("http")
|
||||
? null
|
||||
: el.src
|
||||
: null;
|
||||
const audioUrl = srcRelative
|
||||
? `/api/projects/${pid}/preview/${srcRelative}`
|
||||
: (el.src ?? "");
|
||||
const waveformUrl = srcRelative
|
||||
? `/api/projects/${pid}/waveform/${srcRelative}`
|
||||
: undefined;
|
||||
return (
|
||||
<AudioWaveform audioUrl={audioUrl} label={el.id || el.tag} labelColor={style.label} />
|
||||
<AudioWaveform
|
||||
audioUrl={audioUrl}
|
||||
waveformUrl={waveformUrl}
|
||||
label={el.id || el.tag}
|
||||
labelColor={style.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface AudioWaveformProps {
|
||||
audioUrl: string;
|
||||
waveformUrl?: string;
|
||||
label: string;
|
||||
labelColor: string;
|
||||
}
|
||||
@@ -49,6 +50,7 @@ function fakePeaks(url: string, count: number): number[] {
|
||||
|
||||
// Module-level cache so decoded audio persists across re-renders and re-mounts
|
||||
const peaksCache = new Map<string, number[]>();
|
||||
const decodeInFlight = new Map<string, Promise<number[]>>();
|
||||
|
||||
/**
|
||||
* Audio waveform rendered from real PCM data via Web Audio API.
|
||||
@@ -57,43 +59,56 @@ const peaksCache = new Map<string, number[]>();
|
||||
*/
|
||||
export const AudioWaveform = memo(function AudioWaveform({
|
||||
audioUrl,
|
||||
waveformUrl,
|
||||
label,
|
||||
labelColor,
|
||||
}: AudioWaveformProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const barsRef = useRef<HTMLDivElement | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(audioUrl) ?? null);
|
||||
const cacheKey = waveformUrl ?? audioUrl;
|
||||
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
|
||||
|
||||
// Fetch + decode audio once
|
||||
useEffect(() => {
|
||||
if (peaks || !audioUrl) return;
|
||||
if (peaks || !cacheKey) return;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
fetch(audioUrl, { signal: ctrl.signal })
|
||||
let cancelled = false;
|
||||
|
||||
let promise = decodeInFlight.get(cacheKey);
|
||||
if (!promise) {
|
||||
promise = (
|
||||
waveformUrl
|
||||
? fetch(waveformUrl)
|
||||
.then((r) => r.json())
|
||||
.then((d: { peaks?: number[] }) => {
|
||||
if (!Array.isArray(d.peaks)) throw new Error("bad response");
|
||||
return d.peaks;
|
||||
})
|
||||
: fetch(audioUrl)
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((buf) => {
|
||||
const ctx = new AudioContext();
|
||||
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
||||
})
|
||||
.then((decoded) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const channel = decoded.getChannelData(0);
|
||||
// Extract enough peaks for wide clips (up to 4000 bars)
|
||||
const p = extractPeaks(channel, 4000);
|
||||
peaksCache.set(audioUrl, p);
|
||||
setPeaks(p);
|
||||
.then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
|
||||
)
|
||||
.catch(() => fakePeaks(cacheKey, 4000))
|
||||
.then((p) => {
|
||||
peaksCache.set(cacheKey, p);
|
||||
return p;
|
||||
})
|
||||
.catch(() => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Fallback to fake waveform
|
||||
const p = fakePeaks(audioUrl, 4000);
|
||||
peaksCache.set(audioUrl, p);
|
||||
setPeaks(p);
|
||||
});
|
||||
.finally(() => decodeInFlight.delete(cacheKey));
|
||||
|
||||
return () => ctrl.abort();
|
||||
}, [audioUrl, peaks]);
|
||||
decodeInFlight.set(cacheKey, promise);
|
||||
}
|
||||
|
||||
promise.then((p) => {
|
||||
if (!cancelled) setPeaks(p);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [audioUrl, waveformUrl, cacheKey, peaks]);
|
||||
|
||||
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
||||
const draw = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user