From 8ba7133269b09e7f0dc938e46a28fa3cad6ebae0 Mon Sep 17 00:00:00 2001 From: Kagamine Rin Date: Fri, 29 May 2026 18:27:28 -0400 Subject: [PATCH] feat: Add album artwork support - Backend: artwork_filename column on clips table (auto-migration) - Backend: PATCH /api/clips/{id}/artwork (upload image, 5MB limit) - Backend: GET /api/clips/{id}/artwork (serve image with correct content-type) - Backend: DELETE /api/clips/{id}/artwork (remove artwork file + null out DB) - Backend: DELETE /api/clips/{id} now cascade-removes artwork file - Frontend: Clip interface includes artwork_filename - Frontend: Gallery ribbons show artwork thumbnail with hover overlay (play + upload) - Frontend: Gallery tiles show artwork with hover overlay (play + upload) - Frontend: DetailPanel shows full artwork with upload/change/remove controls - Frontend: App.tsx handleUploadArtwork + handleRemoveArtwork handlers - Storage: /data/artwork/ directory for image files --- backend/.env.example | 4 +- backend/src/database.py | 82 ++++- backend/src/lyria_client.py | 21 +- backend/src/main.py | 102 +++++- backend/src/models.py | 46 ++- docker-compose.yml | 2 + frontend/src/App.tsx | 80 +++++ frontend/src/components/DetailPanel.tsx | 227 +++++++++++++ frontend/src/components/Gallery.tsx | 426 ++++++++++++++++++++++-- frontend/src/components/Generator.tsx | 99 +++++- 10 files changed, 1030 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/DetailPanel.tsx diff --git a/backend/.env.example b/backend/.env.example index 4c28776..e3bcb80 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1 +1,3 @@ -LYRIA_API_KEY=your_api_key_here +LYRIA_API_KEY=your-api-key-here +LYRIA_DEFAULT_MODEL=lyria-3-clip-preview +TZ=America/New_York \ No newline at end of file diff --git a/backend/src/database.py b/backend/src/database.py index 64d57d5..4dac2f1 100644 --- a/backend/src/database.py +++ b/backend/src/database.py @@ -1,14 +1,41 @@ import aiosqlite +import os import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path DB_PATH = Path(__file__).parent.parent / "data" / "lyria.db" DB_PATH.parent.mkdir(parents=True, exist_ok=True) +# Timezone from TZ env var (default: America/New_York) +# This respects DST automatically via zoneinfo +try: + from zoneinfo import ZoneInfo + _tz_name = os.getenv("TZ", "America/New_York") + DISPLAY_TZ = ZoneInfo(_tz_name) +except Exception: + DISPLAY_TZ = None # Falls back to UTC + +def format_timestamp(ts: str) -> str: + """Convert a UTC timestamp string to the configured display timezone.""" + if not ts: + return ts + try: + # Handle both ISO format (T separator) and SQLite format (space separator) + ts_clean = ts.replace('T', ' ') + utc_dt = datetime.strptime(ts_clean, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + if DISPLAY_TZ: + local_dt = utc_dt.astimezone(DISPLAY_TZ) + else: + local_dt = utc_dt + return local_dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, TypeError): + return ts + INIT_SQL = """ CREATE TABLE IF NOT EXISTS clips ( id TEXT PRIMARY KEY, + title TEXT, prompt TEXT NOT NULL, model TEXT NOT NULL, duration_seconds INTEGER, @@ -19,12 +46,28 @@ CREATE TABLE IF NOT EXISTS clips ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Migration: add title column to existing databases +-- ALTER TABLE clips ADD COLUMN title TEXT; -- run separately if needed + CREATE INDEX IF NOT EXISTS idx_clips_created ON clips(created_at DESC); + +-- Migration: add artwork_filename column to existing databases +-- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed """ async def init_db(): async with aiosqlite.connect(DB_PATH) as db: await db.executescript(INIT_SQL) + # Migration: add title column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN title TEXT") + except Exception: + pass # Column already exists + # Migration: add artwork_filename column if it doesn't exist + try: + await db.execute("ALTER TABLE clips ADD COLUMN artwork_filename TEXT") + except Exception: + pass # Column already exists await db.commit() async def create_clip(prompt: str, model: str, duration: int) -> str: @@ -65,16 +108,47 @@ async def get_clips(limit: int = 50, offset: int = 0): (limit, offset) ) as cursor: rows = await cursor.fetchall() - return [dict(row) for row in rows] + clips = [dict(row) for row in rows] + for clip in clips: + clip["created_at"] = format_timestamp(clip.get("created_at")) + return clips async def get_clip(clip_id: str): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM clips WHERE id = ?", (clip_id,)) as cursor: row = await cursor.fetchone() - return dict(row) if row else None + if row: + clip = dict(row) + clip["created_at"] = format_timestamp(clip.get("created_at")) + return clip + return None + +async def rename_clip(clip_id: str, title: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE clips SET title = ? WHERE id = ?", + (title, clip_id) + ) + await db.commit() + +async def set_artwork(clip_id: str, filename: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE clips SET artwork_filename = ? WHERE id = ?", + (filename, clip_id) + ) + await db.commit() + +async def remove_artwork(clip_id: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE clips SET artwork_filename = NULL WHERE id = ?", + (clip_id,) + ) + await db.commit() async def delete_clip(clip_id: str): async with aiosqlite.connect(DB_PATH) as db: await db.execute("DELETE FROM clips WHERE id = ?", (clip_id,)) - await db.commit() + await db.commit() \ No newline at end of file diff --git a/backend/src/lyria_client.py b/backend/src/lyria_client.py index 5f27272..4753eb7 100644 --- a/backend/src/lyria_client.py +++ b/backend/src/lyria_client.py @@ -44,17 +44,31 @@ async def generate_clip(prompt: str, model: str = "lyria-3-clip-preview", durati response.raise_for_status() data = response.json() + # Check for safety/copyright filter blocks first + # Google's filter can reject prompts and return finish_reason: "SAFETY" + # or blockReason with a message about copyrighted material + block_reason = data.get("blockReason", "") + if block_reason: + block_message = data.get("blockReasonMessage", "") + logger.warning(f"Lyria API blocked: {block_reason} — {block_message}") + return {"success": False, "error": f"Prompt was filtered. Try describing the sound using mood, instruments, and tempo instead of genre names. ({block_message or block_reason})"} + # Extract base64 audio from response # Lyria returns parts array: first part may be text (e.g. ""), # audio is in inlineData of a subsequent part. candidates = data.get("candidates", []) if not candidates: - return {"success": False, "error": "No candidates in response"} + return {"success": False, "error": "Prompt was filtered. Try describing the sound using mood, instruments, and tempo instead of genre names."} + + # Check finish_reason for blocks + finish_reason = candidates[0].get("finishReason", "") + if finish_reason == "SAFETY": + return {"success": False, "error": "Prompt was filtered by safety controls. Try rephrasing — describe the sound using mood, instruments, and tempo instead of specific genre or artist names."} content = candidates[0].get("content", {}) parts = content.get("parts", []) if not parts: - return {"success": False, "error": "No parts in response"} + return {"success": False, "error": "No audio was generated. Try rephrasing your prompt — describe the sound using mood, instruments, and tempo."} # Find the part with inlineData (audio) — not necessarily parts[0] audio_part = None @@ -64,7 +78,8 @@ async def generate_clip(prompt: str, model: str = "lyria-3-clip-preview", durati break if audio_part is None: - return {"success": False, "error": "No audio data in response (no inlineData part found)"} + # No audio in any part — likely filtered + return {"success": False, "error": "No audio was generated. Try rephrasing your prompt — describe the sound using mood, instruments, and tempo instead of specific genre names."} inline_data = audio_part.get("inlineData", {}) mime_type = inline_data.get("mimeType", "") diff --git a/backend/src/main.py b/backend/src/main.py index b48c22e..bc2e6a1 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,15 +1,16 @@ -from fastapi import FastAPI, HTTPException, BackgroundTasks +from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pathlib import Path import os +import shutil from dotenv import load_dotenv # Load environment variables BEFORE importing lyria_client (it reads env at module level) load_dotenv(Path(__file__).parent.parent / ".env") -from models import GenerateRequest, GenerateResponse, ClipList -from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status +from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, LYRIA_MODELS, DEFAULT_MODEL +from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork from lyria_client import generate_clip, save_clip_file app = FastAPI(title="Lyria Studio", version="0.1.0") @@ -25,6 +26,12 @@ app.add_middleware( CLIPS_DIR = Path(__file__).parent.parent / "data" / "clips" CLIPS_DIR.mkdir(parents=True, exist_ok=True) +ARTWORK_DIR = Path(__file__).parent.parent / "data" / "artwork" +ARTWORK_DIR.mkdir(parents=True, exist_ok=True) + +ALLOWED_ARTWORK_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} +MAX_ARTWORK_SIZE = 5 * 1024 * 1024 # 5 MB + @app.on_event("startup") async def startup(): await init_db() @@ -33,6 +40,10 @@ async def startup(): async def health(): return {"status": "ok", "version": "0.1.0"} +@app.get("/api/models") +async def list_models(): + return {"models": LYRIA_MODELS, "default": DEFAULT_MODEL} + async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: int): """Background task: call Lyria API and save the result.""" try: @@ -60,7 +71,8 @@ async def _generate_and_save(clip_id: str, prompt: str, model: str, duration: in @app.post("/api/generate", response_model=GenerateResponse) async def generate(request: GenerateRequest, background_tasks: BackgroundTasks): - clip_id = await create_clip(request.prompt, request.model, request.duration) + duration = request.get_effective_duration() + clip_id = await create_clip(request.prompt, request.model, duration) # Kick off generation in background background_tasks.add_task( @@ -68,7 +80,7 @@ async def generate(request: GenerateRequest, background_tasks: BackgroundTasks): clip_id, request.prompt, request.model, - request.duration + duration ) return GenerateResponse( @@ -107,9 +119,89 @@ async def remove_clip(clip_id: str): file_path = CLIPS_DIR / clip["filename"] if file_path.exists(): file_path.unlink() + # Also delete artwork if it exists + if clip.get("artwork_filename"): + artwork_path = ARTWORK_DIR / clip["artwork_filename"] + if artwork_path.exists(): + artwork_path.unlink() await delete_clip(clip_id) return {"deleted": True} + +@app.get("/api/clips/{clip_id}/artwork") +async def get_artwork(clip_id: str): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + if not clip.get("artwork_filename"): + raise HTTPException(status_code=404, detail="No artwork for this clip") + artwork_path = ARTWORK_DIR / clip["artwork_filename"] + if not artwork_path.exists(): + raise HTTPException(status_code=404, detail="Artwork file not found") + # Determine media type from extension + ext = artwork_path.suffix.lower() + media_types = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp"} + media_type = media_types.get(ext, "application/octet-stream") + return FileResponse(artwork_path, media_type=media_type) + + +@app.patch("/api/clips/{clip_id}/artwork") +async def upload_artwork(clip_id: str, file: UploadFile = File(...)): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + # Validate content type + if file.content_type not in ALLOWED_ARTWORK_TYPES: + raise HTTPException( + status_code=400, + detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_ARTWORK_TYPES)}" + ) + # Read and validate size + contents = await file.read() + if len(contents) > MAX_ARTWORK_SIZE: + raise HTTPException(status_code=400, detail="File too large. Maximum size is 5 MB.") + # Determine extension + ext_map = {"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"} + ext = ext_map.get(file.content_type or "", ".jpg") + artwork_filename = f"{clip_id}{ext}" + artwork_path = ARTWORK_DIR / artwork_filename + # Remove old artwork if it exists with a different extension + if clip.get("artwork_filename") and clip["artwork_filename"] != artwork_filename: + old_path = ARTWORK_DIR / clip["artwork_filename"] + if old_path.exists(): + old_path.unlink() + # Save the file + with open(artwork_path, "wb") as f: + f.write(contents) + # Update DB + await set_artwork(clip_id, artwork_filename) + updated = await get_clip(clip_id) + return updated + + +@app.delete("/api/clips/{clip_id}/artwork") +async def remove_artwork_endpoint(clip_id: str): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + if not clip.get("artwork_filename"): + raise HTTPException(status_code=404, detail="No artwork for this clip") + artwork_path = ARTWORK_DIR / clip["artwork_filename"] + if artwork_path.exists(): + artwork_path.unlink() + await remove_artwork(clip_id) + updated = await get_clip(clip_id) + return updated + +@app.patch("/api/clips/{clip_id}") +async def rename_clip_endpoint(clip_id: str, request: RenameRequest): + clip = await get_clip(clip_id) + if not clip: + raise HTTPException(status_code=404, detail="Clip not found") + await rename_clip(clip_id, request.title) + updated = await get_clip(clip_id) + return updated + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/src/models.py b/backend/src/models.py index a34e7d5..da45270 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -1,11 +1,44 @@ -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from datetime import datetime from typing import Optional +# Supported models and their constraints +LYRIA_MODELS = { + "lyria-3-clip-preview": { + "name": "Clip Preview", + "max_duration": 30, + "default_duration": 30, + "description": "30-second clip, fastest generation", + }, + "lyria-3-pro-preview": { + "name": "Pro", + "max_duration": 184, + "default_duration": 120, + "description": "Full songs up to ~3 min, structure-aware", + }, +} + +DEFAULT_MODEL = "lyria-3-clip-preview" + class GenerateRequest(BaseModel): prompt: str - model: str = "lyria-3-clip-preview" - duration: int = 30 + model: str = DEFAULT_MODEL + duration: Optional[int] = None # defaults to model default + + @field_validator('model') + @classmethod + def validate_model(cls, v): + v = v.strip() + if v not in LYRIA_MODELS: + valid = ', '.join(LYRIA_MODELS.keys()) + raise ValueError(f"Unknown model '{v}'. Supported: {valid}") + return v + + def get_effective_duration(self) -> int: + """Return duration clamped to model limits, defaulting to model default if not specified.""" + info = LYRIA_MODELS[self.model] + dur = self.duration if self.duration is not None else info["default_duration"] + return min(dur, info["max_duration"]) class GenerateResponse(BaseModel): id: str @@ -14,15 +47,20 @@ class GenerateResponse(BaseModel): class Clip(BaseModel): id: str + title: Optional[str] = None prompt: str model: str duration_seconds: int filename: str + artwork_filename: Optional[str] = None file_size_bytes: Optional[int] = None status: str error_message: Optional[str] = None created_at: datetime +class RenameRequest(BaseModel): + title: str + class ClipList(BaseModel): clips: list[Clip] - total: int + total: int \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 527d1fc..35541e6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,8 @@ services: - ./data:/app/data environment: - LYRIA_API_KEY=${LYRIA_API_KEY} + - LYRIA_DEFAULT_MODEL=${LYRIA_DEFAULT_MODEL:-lyria-3-clip-preview} + - TZ=${TZ:-America/New_York} restart: unless-stopped frontend: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d5c2e2..f0dd840 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,13 +2,18 @@ import { useState, useEffect } from 'react' import Generator from './components/Generator' import Gallery from './components/Gallery' import Player from './components/Player' +import DetailPanel from './components/DetailPanel' interface Clip { id: string + title?: string prompt: string model: string duration_seconds: number status: string + error_message?: string + file_size_bytes?: number + artwork_filename?: string | null created_at: string } @@ -16,6 +21,7 @@ function App() { const [clips, setClips] = useState([]) const [currentClip, setCurrentClip] = useState(null) const [isPlaying, setIsPlaying] = useState(false) + const [selectedClip, setSelectedClip] = useState(null) const fetchClips = async () => { try { @@ -51,6 +57,66 @@ function App() { } } + const handleRename = async (id: string, title: string) => { + try { + await fetch(`/api/clips/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }) + setClips(prev => prev.map(c => c.id === id ? { ...c, title } : c)) + if (currentClip?.id === id) { + setCurrentClip({ ...currentClip, title }) + } + if (selectedClip?.id === id) { + setSelectedClip({ ...selectedClip, title }) + } + } catch (err) { + console.error('Failed to rename clip:', err) + } + } + + const handleUploadArtwork = async (id: string, file: File) => { + try { + const formData = new FormData() + formData.append('file', file) + const res = await fetch(`/api/clips/${id}/artwork`, { + method: 'PATCH', + body: formData, + }) + if (!res.ok) { + const err = await res.json() + console.error('Artwork upload failed:', err.detail) + return + } + const updated = await res.json() + setClips(prev => prev.map(c => c.id === id ? { ...c, artwork_filename: updated.artwork_filename } : c)) + if (currentClip?.id === id) { + setCurrentClip({ ...currentClip, artwork_filename: updated.artwork_filename }) + } + if (selectedClip?.id === id) { + setSelectedClip({ ...selectedClip, artwork_filename: updated.artwork_filename }) + } + } catch (err) { + console.error('Failed to upload artwork:', err) + } + } + + const handleRemoveArtwork = async (id: string) => { + try { + await fetch(`/api/clips/${id}/artwork`, { method: 'DELETE' }) + setClips(prev => prev.map(c => c.id === id ? { ...c, artwork_filename: null } : c)) + if (currentClip?.id === id) { + setCurrentClip({ ...currentClip, artwork_filename: null }) + } + if (selectedClip?.id === id) { + setSelectedClip({ ...selectedClip, artwork_filename: null }) + } + } catch (err) { + console.error('Failed to remove artwork:', err) + } + } + return (
@@ -72,6 +138,9 @@ function App() { clips={clips} onPlay={handlePlay} onDelete={handleDelete} + onRename={handleRename} + onSelect={(clip) => setSelectedClip(clip)} + onUploadArtwork={handleUploadArtwork} currentClipId={currentClip?.id} />
@@ -88,6 +157,17 @@ function App() { }} /> )} + + {selectedClip && ( + setSelectedClip(null)} + onRename={handleRename} + onDelete={handleDelete} + onUploadArtwork={handleUploadArtwork} + onRemoveArtwork={handleRemoveArtwork} + /> + )} ) } diff --git a/frontend/src/components/DetailPanel.tsx b/frontend/src/components/DetailPanel.tsx new file mode 100644 index 0000000..f309dbe --- /dev/null +++ b/frontend/src/components/DetailPanel.tsx @@ -0,0 +1,227 @@ +import { useRef } from 'react' + +interface Clip { + id: string + title?: string + prompt: string + model: string + duration_seconds: number + status: string + error_message?: string + file_size_bytes?: number + artwork_filename?: string | null + created_at: string +} + +interface Props { + clip: Clip + onClose: () => void + onRename: (id: string, title: string) => void + onDelete: (id: string) => void + onUploadArtwork: (id: string, file: File) => void + onRemoveArtwork: (id: string) => void +} + +const modelName = (modelId: string) => { + const names: Record = { + 'lyria-3-clip-preview': 'Clip Preview', + 'lyria-3-pro-preview': 'Pro', + } + return names[modelId] || modelId +} + +export default function DetailPanel({ clip, onClose, onRename, onDelete, onUploadArtwork, onRemoveArtwork }: Props) { + const artworkInputRef = useRef(null) + const handleRename = () => { + const newTitle = window.prompt('Rename track:', clip.title || '') + if (newTitle !== null) { + onRename(clip.id, newTitle) + } + } + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + const statusColor = (status: string) => { + switch (status) { + case 'ready': return 'bg-green-500/20 text-green-400' + case 'generating': return 'bg-yellow-500/20 text-yellow-400' + case 'failed': return 'bg-red-500/20 text-red-400' + default: return 'bg-white/10 text-lyria-muted' + } + } + + const statusLabel = (status: string) => { + switch (status) { + case 'ready': return 'Ready' + case 'generating': return 'Generating...' + case 'failed': return 'Failed' + default: return status + } + } + + return ( + <> + {/* Backdrop */} +
+ {/* Panel */} +
+ {/* Header */} +
+

Track Details

+ +
+ +
+ {/* Album Artwork */} +
+
+ +
+ + {clip.artwork_filename && ( + + )} +
+
+ {clip.artwork_filename ? ( + Album art + ) : ( +
+ + + + No album art +
+ )} + { + const file = e.target.files?.[0] + if (file) { + onUploadArtwork(clip.id, file) + } + e.target.value = '' + }} + /> +
+ + {/* Title */} +
+
+ + +
+

+ {clip.title || Untitled} +

+
+ + {/* Prompt */} +
+ +

+ {clip.prompt} +

+
+ + {/* Status */} +
+ + + {statusLabel(clip.status)} + +
+ + {/* Error message */} + {clip.status === 'failed' && clip.error_message && ( +
+ +

+ {clip.error_message} +

+
+ )} + + {/* Metadata grid */} +
+
+ +

{modelName(clip.model)}

+
+
+ +

{clip.duration_seconds}s

+
+ {clip.file_size_bytes && ( +
+ +

{(clip.file_size_bytes / 1024).toFixed(0)} KB

+
+ )} +
+ +

{formatDate(clip.created_at)}

+
+
+ + {/* ID (for reference) */} +
+ +

{clip.id}

+
+ + {/* Actions */} +
+ +
+
+
+ + ) +} \ No newline at end of file diff --git a/frontend/src/components/Gallery.tsx b/frontend/src/components/Gallery.tsx index 800e2cf..a320fda 100644 --- a/frontend/src/components/Gallery.tsx +++ b/frontend/src/components/Gallery.tsx @@ -1,9 +1,17 @@ +import { useState, useMemo, useRef } from 'react' + +const PAGE_SIZE = 10 + interface Clip { id: string + title?: string prompt: string model: string duration_seconds: number status: string + error_message?: string + file_size_bytes?: number + artwork_filename?: string | null created_at: string } @@ -11,10 +19,62 @@ interface Props { clips: Clip[] onPlay: (clip: Clip) => void onDelete: (id: string) => void + onRename: (id: string, title: string) => void + onSelect: (clip: Clip) => void + onUploadArtwork: (id: string, file: File) => void currentClipId?: string } -export default function Gallery({ clips, onPlay, onDelete, currentClipId }: Props) { +type ViewMode = 'ribbons' | 'tiles' + +export default function Gallery({ clips, onPlay, onDelete, onRename, onSelect, onUploadArtwork, currentClipId }: Props) { + const [uploadingId, setUploadingId] = useState(null) + const artworkInputRef = useRef(null) + const [search, setSearch] = useState('') + const [page, setPage] = useState(1) + const [view, setView] = useState('ribbons') + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + const inputRef = useRef(null) + + const filtered = useMemo(() => { + const q = search.toLowerCase().trim() + if (!q) return clips + return clips.filter(clip => + clip.prompt.toLowerCase().includes(q) || + (clip.title && clip.title.toLowerCase().includes(q)) || + clip.model.toLowerCase().includes(q) || + clip.status.toLowerCase().includes(q) || + (clip.error_message && clip.error_message.toLowerCase().includes(q)) + ) + }, [clips, search]) + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) + const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + + const handleSearch = (value: string) => { + setSearch(value) + setPage(1) + } + + const startRename = (clip: Clip) => { + setEditingId(clip.id) + setEditValue(clip.title || '') + setTimeout(() => inputRef.current?.select(), 0) + } + + const commitRename = () => { + if (editingId) { + onRename(editingId, editValue.trim()) + setEditingId(null) + } + } + + const cancelRename = () => { + setEditingId(null) + setEditValue('') + } + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', @@ -24,6 +84,14 @@ export default function Gallery({ clips, onPlay, onDelete, currentClipId }: Prop }) } + const modelName = (modelId: string) => { + const names: Record = { + 'lyria-3-clip-preview': 'Clip', + 'lyria-3-pro-preview': 'Pro', + } + return names[modelId] || modelId + } + if (clips.length === 0) { return (
@@ -34,33 +102,266 @@ export default function Gallery({ clips, onPlay, onDelete, currentClipId }: Prop return (
+ {/* Header: title, search, view toggle */}
-

- Gallery ({clips.length}) -

-
- -
- {clips.map((clip) => ( -
- + +
+
+ {search && filtered.length !== clips.length && ( +

+ Showing {filtered.length} of {clips.length} clips +

+ )} +
-
-

{clip.prompt}

-
- +

No clips match "{search}"

+
+ ) : view === 'ribbons' ? ( + /* ── RIBBON VIEW ── */ +
+ {paged.map((clip) => ( +
+
+ {clip.artwork_filename ? ( + Album art + ) : ( +
+ + + +
+ )} + {/* Hover overlay: play + upload art */} +
+ + +
+
+ +
+ {editingId === clip.id ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') cancelRename() + }} + onBlur={commitRename} + placeholder="Enter a name..." + className="w-full bg-white/5 border border-lyria-accent/50 rounded px-2 py-1 text-sm text-white placeholder-lyria-muted focus:outline-none focus:ring-1 focus:ring-lyria-accent/50" + autoFocus + /> + ) : clip.title ? ( +
+

{clip.title}

+

{clip.prompt}

+
+ ) : ( +

{clip.prompt}

+ )} +
+ + {clip.status} + + {clip.duration_seconds}s + {modelName(clip.model)} + {formatDate(clip.created_at)} +
+ {clip.status === 'failed' && clip.error_message && ( +

+ {clip.error_message} +

+ )} +
+ + + +
+ ))} +
+ ) : ( + /* ── TILE VIEW ── */ +
+ {paged.map((clip) => ( +
onSelect(clip)} + > + {/* Artwork or placeholder */} +
+ {clip.artwork_filename ? ( + Album art + ) : ( +
+ + + +
+ )} + {/* Hover overlay */} +
+ + +
+
+ + {/* Title / prompt */} + {editingId === clip.id ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') cancelRename() + }} + onBlur={commitRename} + placeholder="Enter a name..." + className="w-full bg-white/5 border border-lyria-accent/50 rounded px-2 py-1 text-xs text-white placeholder-lyria-muted focus:outline-none focus:ring-1 focus:ring-lyria-accent/50 mb-1" + autoFocus + /> + ) : ( +

startRename(clip)}> + {clip.title || clip.prompt} +

+ )} + + {/* Meta */} +
+ {clip.status} - {clip.duration_seconds}s - {formatDate(clip.created_at)} + {clip.duration_seconds}s + {modelName(clip.model)} +
+ + {/* Error */} + {clip.status === 'failed' && clip.error_message && ( +

+ {clip.error_message} +

+ )} + + {/* Actions */} +
+ +
+ ))} +
+ )} + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} ({filtered.length} clip{filtered.length !== 1 ? 's' : ''}) +

+
+
- ))} -
+
+ )} + {/* Hidden artwork upload input */} + { + if (uploadingId && e.target.files?.[0]) { + onUploadArtwork(uploadingId, e.target.files[0]) + } + e.target.value = '' + setUploadingId(null) + }} + />
) -} +} \ No newline at end of file diff --git a/frontend/src/components/Generator.tsx b/frontend/src/components/Generator.tsx index 9522326..4028917 100644 --- a/frontend/src/components/Generator.tsx +++ b/frontend/src/components/Generator.tsx @@ -1,4 +1,11 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' + +interface ModelInfo { + name: string + max_duration: number + default_duration: number + description: string +} interface Props { onGenerate: () => void @@ -6,8 +13,35 @@ interface Props { export default function Generator({ onGenerate }: Props) { const [prompt, setPrompt] = useState('') + const [model, setModel] = useState('lyria-3-clip-preview') + const [duration, setDuration] = useState(30) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') + const [models, setModels] = useState>({}) + + useEffect(() => { + fetch('/api/models') + .then(res => res.json()) + .then(data => { + setModels(data.models || {}) + setModel(data.default || 'lyria-3-clip-preview') + const defaultInfo = data.models?.[data.default] + setDuration(defaultInfo?.default_duration || 30) + }) + .catch(() => {}) // Use hardcoded defaults on failure + }, []) + + const handleModelChange = (newModel: string) => { + setModel(newModel) + const info = models[newModel] + if (info) { + setDuration(info.default_duration) + } + } + + const currentModelInfo = models[model] + const maxDuration = currentModelInfo?.max_duration || 30 + const minDuration = 5 const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -22,12 +56,26 @@ export default function Generator({ onGenerate }: Props) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: prompt.trim(), - model: 'lyria-3-clip-preview', - duration: 30 + model, + duration }) }) - if (!res.ok) throw new Error('Generation failed') + if (!res.ok) { + try { + const errorData = await res.json() + throw new Error(errorData.error || errorData.detail || 'Generation failed') + } catch (parseErr) { + if (parseErr instanceof Error && parseErr.message !== 'Generation failed') throw parseErr + throw new Error('Generation failed') + } + } + + const data = await res.json() + + if (data.error) { + throw new Error(data.error) + } setPrompt('') onGenerate() @@ -52,18 +100,51 @@ export default function Generator({ onGenerate }: Props) {