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,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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user