mirror of
https://github.com/JezzWTF/vibepod.git
synced 2026-06-13 03:58:07 +00:00
feat(phase-1): persistent generation library
- Save every completed generation to SQLite (generation_store.py) with WAV and waveform peaks written to data/generations/<id>/ - Deferred DB write until success — cancelled/errored generations never touch the DB and never appear in the library - Fixed cancel+regenerate IndexError: _reset_scheduler_caches() now directly zeros scheduler._step_index and running state in addition to clearing VibePod cache dicts; same explicit resets added in the fresh path of prepare_noise_scheduler as belt-and-suspenders - Added /library page with GenerationCard, WaveformPreview, waveform fetch, play/pause, download, delete, pagination, empty + error states - Added generation API routes (list, single, audio stream, waveform, delete) proxying to Python server - Added Library nav link to Header with active state - Persist script/speaker/CFG to localStorage so generate page state survives navigation - Updated build plan: Phase 0+1 ticked off, better-sqlite3 moved to Phase 2, architectural note on Python owning all persistence
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${pythonUrl()}/generations/${id}/audio`);
|
||||
if (!res.ok) return NextResponse.json({ error: "Audio not found" }, { status: res.status });
|
||||
return new NextResponse(res.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Disposition": `attachment; filename="${id}.wav"`,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${pythonUrl()}/generations/${id}`, { cache: "no-store" });
|
||||
if (!res.ok) return NextResponse.json({ error: "Not found" }, { status: res.status });
|
||||
return NextResponse.json(await res.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${pythonUrl()}/generations/${id}`, { method: "DELETE" });
|
||||
if (res.status === 404)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const res = await fetch(`${pythonUrl()}/generations/${id}/waveform`, { cache: "no-store" });
|
||||
if (!res.ok) return NextResponse.json({ error: "Waveform not found" }, { status: res.status });
|
||||
return NextResponse.json(await res.json(), {
|
||||
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = searchParams.get("limit") ?? "50";
|
||||
const offset = searchParams.get("offset") ?? "0";
|
||||
|
||||
try {
|
||||
const res = await fetch(`${pythonUrl()}/generations?limit=${limit}&offset=${offset}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
|
||||
return NextResponse.json(await res.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import GenerationCard from "@/components/GenerationCard";
|
||||
import type { GenerationJob, GenerationsListResponse } from "@/lib/types/generation";
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [jobs, setJobs] = useState<GenerationJob[]>([]);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchJobs = useCallback(async (currentOffset: number, replace: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/generations?limit=${PAGE_SIZE}&offset=${currentOffset}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
const data = (await res.json()) as GenerationsListResponse;
|
||||
setJobs((prev) => (replace ? data.items : [...prev, ...data.items]));
|
||||
setHasMore(data.items.length === PAGE_SIZE);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load generations");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs(0, true);
|
||||
}, [fetchJobs]);
|
||||
|
||||
function handleDelete(id: string) {
|
||||
setJobs((prev) => prev.filter((j) => j.id !== id));
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
const next = offset + PAGE_SIZE;
|
||||
setOffset(next);
|
||||
fetchJobs(next, false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: "var(--background)" }}>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2
|
||||
className="text-xl font-bold tracking-tight"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
Generation Library
|
||||
</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: "var(--muted)" }}>
|
||||
Every completed generation is saved here.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
|
||||
style={{
|
||||
background: "var(--accent-teal-dim)",
|
||||
borderColor: "transparent",
|
||||
color: "var(--accent-teal)",
|
||||
}}
|
||||
>
|
||||
+ New Generation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div
|
||||
className="rounded-xl border px-4 py-3 text-sm mb-6"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--error) 10%, transparent)",
|
||||
borderColor: "var(--error)",
|
||||
color: "var(--error)",
|
||||
}}
|
||||
>
|
||||
{error} —{" "}
|
||||
<button
|
||||
className="underline"
|
||||
onClick={() => fetchJobs(0, true)}
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && jobs.length === 0 && !error && (
|
||||
<div
|
||||
className="rounded-xl border px-8 py-16 text-center"
|
||||
style={{ borderColor: "var(--border)", borderStyle: "dashed" }}
|
||||
>
|
||||
<p className="text-4xl mb-4">🎙</p>
|
||||
<p className="font-semibold mb-1" style={{ color: "var(--foreground)" }}>
|
||||
No generations yet
|
||||
</p>
|
||||
<p className="text-sm mb-4" style={{ color: "var(--muted)" }}>
|
||||
Generate some audio and it will appear here automatically.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block px-4 py-2 rounded-lg text-sm font-medium"
|
||||
style={{
|
||||
background: "var(--accent-teal-dim)",
|
||||
color: "var(--accent-teal)",
|
||||
}}
|
||||
>
|
||||
Go generate something
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{jobs.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{jobs.map((job) => (
|
||||
<GenerationCard key={job.id} job={job} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && !loading && jobs.length > 0 && (
|
||||
<div className="mt-8 text-center">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="px-6 py-2 rounded-lg text-sm font-medium border transition-colors"
|
||||
style={{
|
||||
background: "transparent",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading spinner */}
|
||||
{loading && (
|
||||
<div className="mt-8 text-center text-sm" style={{ color: "var(--muted)" }}>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+32
-1
@@ -171,8 +171,39 @@ const initialState: AppState = {
|
||||
serverConfig: null,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "vibepod_form";
|
||||
|
||||
function loadSavedForm(): Partial<Pick<AppState, "script" | "speaker" | "cfgScale">> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Partial<Pick<AppState, "script" | "speaker" | "cfgScale">>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [state, dispatch] = useReducer(reducer, initialState, (base) => {
|
||||
const saved = loadSavedForm();
|
||||
return {
|
||||
...base,
|
||||
...(saved.script !== undefined && { script: saved.script }),
|
||||
...(saved.speaker !== undefined && { speaker: saved.speaker }),
|
||||
...(typeof saved.cfgScale === "number" && { cfgScale: saved.cfgScale }),
|
||||
};
|
||||
});
|
||||
|
||||
// Persist user-editable form fields across navigation.
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ script: state.script, speaker: state.speaker, cfgScale: state.cfgScale })
|
||||
);
|
||||
} catch {}
|
||||
}, [state.script, state.speaker, state.cfgScale]);
|
||||
|
||||
const wordCount = state.script.trim() === "" ? 0 : state.script.trim().split(/\s+/).length;
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { GenerationJob, WaveformPeaks } from "@/lib/types/generation";
|
||||
import WaveformPreview from "./WaveformPreview";
|
||||
|
||||
interface GenerationCardProps {
|
||||
job: GenerationJob;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatDuration(secs: number | null): string {
|
||||
if (secs === null) return "—";
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = Math.floor(secs % 60);
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) + "…" : text;
|
||||
}
|
||||
|
||||
export default function GenerationCard({ job, onDelete }: GenerationCardProps) {
|
||||
const [peaks, setPeaks] = useState<WaveformPeaks | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (job.status !== "complete" || !job.waveform_path) return;
|
||||
fetch(`/api/generations/${job.id}/waveform`)
|
||||
.then((r) => r.json())
|
||||
.then((data: WaveformPeaks) => setPeaks(data))
|
||||
.catch(() => {});
|
||||
}, [job.id, job.status, job.waveform_path]);
|
||||
|
||||
function handlePlayPause() {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(`/api/generations/${job.id}/audio`);
|
||||
audioRef.current.onended = () => setIsPlaying(false);
|
||||
}
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current.play().catch(() => setIsPlaying(false));
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("Delete this generation?")) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await fetch(`/api/generations/${job.id}`, { method: "DELETE" });
|
||||
onDelete(job.id);
|
||||
} catch {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isComplete = job.status === "complete";
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
complete: "var(--success)",
|
||||
generating: "var(--status-loading)",
|
||||
error: "var(--error)",
|
||||
cancelled: "var(--muted)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-4 flex flex-col gap-3"
|
||||
style={{ background: "var(--card-bg)", borderColor: "var(--border)" }}
|
||||
>
|
||||
{/* Waveform or placeholder */}
|
||||
<div
|
||||
className="rounded-lg overflow-hidden flex items-center justify-center"
|
||||
style={{ background: "var(--background)", minHeight: 48 }}
|
||||
>
|
||||
{peaks ? (
|
||||
<WaveformPreview peaks={peaks} height={48} className="px-2" />
|
||||
) : (
|
||||
<div className="w-full h-12 flex items-center justify-center">
|
||||
<span className="text-xs" style={{ color: "var(--muted)" }}>
|
||||
{job.status === "generating" ? "Generating…" : "No waveform"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Script preview */}
|
||||
<p className="text-sm leading-snug" style={{ color: "var(--foreground)" }}>
|
||||
{truncate(job.script, 120)}
|
||||
</p>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-semibold uppercase tracking-wide"
|
||||
style={{
|
||||
background: "var(--accent-teal-dim)",
|
||||
color: "var(--accent-teal)",
|
||||
}}
|
||||
>
|
||||
{job.speaker}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted)" }}>
|
||||
{formatDuration(job.duration_secs)}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted)" }}>
|
||||
CFG {job.cfg_scale}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto text-xs font-medium"
|
||||
style={{ color: statusColors[job.status] ?? "var(--muted)" }}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<p className="text-xs" style={{ color: "var(--muted)" }}>
|
||||
{formatDate(job.created_at)}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
{isComplete && (
|
||||
<>
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors"
|
||||
style={{
|
||||
background: isPlaying ? "var(--accent-teal-dim)" : "transparent",
|
||||
borderColor: "var(--accent-teal-dim)",
|
||||
color: "var(--accent-teal)",
|
||||
}}
|
||||
>
|
||||
{isPlaying ? "⏸ Pause" : "▶ Play"}
|
||||
</button>
|
||||
<a
|
||||
href={`/api/generations/${job.id}/audio`}
|
||||
download={`${job.id}.wav`}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium border text-center transition-colors"
|
||||
style={{
|
||||
background: "transparent",
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
↓ Download
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
background: "transparent",
|
||||
borderColor: "var(--error)",
|
||||
color: "var(--error)",
|
||||
}}
|
||||
>
|
||||
{isDeleting ? "…" : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
type ServerStatus = "checking" | "downloading" | "loading" | "online" | "error" | "offline";
|
||||
type Device = "cpu" | "cuda" | null;
|
||||
@@ -9,7 +11,13 @@ type Device = "cpu" | "cuda" | null;
|
||||
const FAST_INTERVAL_MS = 3000; // while checking / loading
|
||||
const SLOW_INTERVAL_MS = 30000; // once online
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: "/", label: "Generate" },
|
||||
{ href: "/library", label: "Library" },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const pathname = usePathname();
|
||||
const [status, setStatus] = useState<ServerStatus>("checking");
|
||||
const [device, setDevice] = useState<Device>(null);
|
||||
const [message, setMessage] = useState<string | undefined>();
|
||||
@@ -123,6 +131,7 @@ export default function Header() {
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<div
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-lg font-bold"
|
||||
style={{
|
||||
@@ -148,6 +157,26 @@ export default function Header() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex items-center gap-1 ml-2">
|
||||
{NAV_LINKS.map(({ href, label }) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-teal-dim)" : "transparent",
|
||||
color: active ? "var(--accent-teal)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { WaveformPeaks } from "@/lib/types/generation";
|
||||
|
||||
interface WaveformPreviewProps {
|
||||
peaks: WaveformPeaks;
|
||||
color?: string;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function WaveformPreview({
|
||||
peaks,
|
||||
color = "#2dd4bf",
|
||||
height = 48,
|
||||
className = "",
|
||||
}: WaveformPreviewProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const { width } = canvas;
|
||||
const midY = height / 2;
|
||||
const { min, max } = peaks.data;
|
||||
const len = peaks.length;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const peakIndex = Math.floor((x / width) * len);
|
||||
const minY = midY - min[peakIndex] * midY;
|
||||
const maxY = midY - max[peakIndex] * midY;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 0.5, Math.min(minY, maxY));
|
||||
ctx.lineTo(x + 0.5, Math.max(minY, maxY));
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [peaks, color, height]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={400}
|
||||
height={height}
|
||||
className={`w-full ${className}`}
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type GenerationStatus = "generating" | "complete" | "error" | "cancelled";
|
||||
|
||||
export interface GenerationJob {
|
||||
id: string;
|
||||
created_at: string;
|
||||
status: GenerationStatus;
|
||||
script: string;
|
||||
speaker: string;
|
||||
cfg_scale: number;
|
||||
inference_steps: number | null;
|
||||
duration_secs: number | null;
|
||||
sample_rate: number | null;
|
||||
audio_path: string | null;
|
||||
waveform_path: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface WaveformPeaks {
|
||||
sampleRate: number;
|
||||
durationSecs: number;
|
||||
channels: number;
|
||||
samplesPerPixel: number;
|
||||
length: number;
|
||||
data: {
|
||||
min: number[];
|
||||
max: number[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GenerationsListResponse {
|
||||
items: GenerationJob[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
Reference in New Issue
Block a user