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
This commit is contained in:
rin.kagamine committed 2026-05-29 18:27:28 -04:00
1 parent 23672387e5
commit 8ba7133269
10 files changed
+1030 -59

No files matched your search

+3 -1
View File
@@ -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
+78 -4
View File
@@ -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()
+18 -3
View File
@@ -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. "<instrumental>"),
# 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", "")
+97 -5
View File
@@ -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)
+42 -4
View File
@@ -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
+2
View File
@@ -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:
+80
View File
@@ -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<Clip[]>([])
const [currentClip, setCurrentClip] = useState<Clip | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [selectedClip, setSelectedClip] = useState<Clip | null>(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 (
<div className="min-h-screen bg-lyria-dark">
<header className="border-b border-white/10 px-6 py-4">
@@ -72,6 +138,9 @@ function App() {
clips={clips}
onPlay={handlePlay}
onDelete={handleDelete}
onRename={handleRename}
onSelect={(clip) => setSelectedClip(clip)}
onUploadArtwork={handleUploadArtwork}
currentClipId={currentClip?.id}
/>
</div>
@@ -88,6 +157,17 @@ function App() {
}}
/>
)}
{selectedClip && (
<DetailPanel
clip={selectedClip}
onClose={() => setSelectedClip(null)}
onRename={handleRename}
onDelete={handleDelete}
onUploadArtwork={handleUploadArtwork}
onRemoveArtwork={handleRemoveArtwork}
/>
)}
</div>
)
}
+227
View File
@@ -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<string, string> = {
'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<HTMLInputElement>(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 */}
<div
className="fixed inset-0 bg-black/50 z-40"
onClick={onClose}
/>
{/* Panel */}
<div className="fixed right-0 top-0 bottom-0 w-full max-w-md bg-lyria-panel border-l border-white/10 z-50 overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-lyria-panel border-b border-white/10 px-5 py-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">Track Details</h2>
<button
onClick={onClose}
className="text-lyria-muted hover:text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5 space-y-5">
{/* Album Artwork */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-lyria-muted uppercase tracking-wider">Album Art</label>
<div className="flex items-center gap-2">
<button
onClick={() => artworkInputRef.current?.click()}
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors"
>
{clip.artwork_filename ? 'Change' : 'Upload'}
</button>
{clip.artwork_filename && (
<button
onClick={() => onRemoveArtwork(clip.id)}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Remove
</button>
)}
</div>
</div>
{clip.artwork_filename ? (
<img
src={`/api/clips/${clip.id}/artwork`}
alt="Album art"
className="w-full aspect-square rounded-lg object-cover"
/>
) : (
<div className="w-full aspect-square bg-white/5 rounded-lg flex flex-col items-center justify-center text-lyria-muted/40">
<svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
<path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className="text-xs mt-1">No album art</span>
</div>
)}
<input
ref={artworkInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
onUploadArtwork(clip.id, file)
}
e.target.value = ''
}}
/>
</div>
{/* Title */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-lyria-muted uppercase tracking-wider">Title</label>
<button
onClick={handleRename}
className="text-xs text-lyria-accent hover:text-lyria-accent/80 transition-colors"
>
Rename
</button>
</div>
<p className="text-white text-lg font-medium">
{clip.title || <span className="text-lyria-muted italic">Untitled</span>}
</p>
</div>
{/* Prompt */}
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Prompt</label>
<p className="text-sm text-white/90 whitespace-pre-wrap break-words bg-white/5 rounded-md p-3">
{clip.prompt}
</p>
</div>
{/* Status */}
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Status</label>
<span className={`text-xs px-2 py-1 rounded ${statusColor(clip.status)}`}>
{statusLabel(clip.status)}
</span>
</div>
{/* Error message */}
{clip.status === 'failed' && clip.error_message && (
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Error</label>
<p className="text-sm text-red-400/90 bg-red-500/10 rounded-md p-3 break-words">
{clip.error_message}
</p>
</div>
)}
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider block">Model</label>
<p className="text-sm text-white">{modelName(clip.model)}</p>
</div>
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider block">Duration</label>
<p className="text-sm text-white">{clip.duration_seconds}s</p>
</div>
{clip.file_size_bytes && (
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider block">File Size</label>
<p className="text-sm text-white">{(clip.file_size_bytes / 1024).toFixed(0)} KB</p>
</div>
)}
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider block">Created</label>
<p className="text-sm text-white">{formatDate(clip.created_at)}</p>
</div>
</div>
{/* ID (for reference) */}
<div>
<label className="text-xs text-lyria-muted uppercase tracking-wider mb-1 block">Clip ID</label>
<p className="text-xs text-lyria-muted/60 font-mono break-all">{clip.id}</p>
</div>
{/* Actions */}
<div className="pt-3 border-t border-white/5">
<button
onClick={() => { onDelete(clip.id); onClose() }}
className="w-full py-2 text-sm text-red-400 bg-red-500/10 hover:bg-red-500/20 rounded-md transition-colors"
>
Delete Track
</button>
</div>
</div>
</div>
</>
)
}
+393 -33
View File
@@ -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<string | null>(null)
const artworkInputRef = useRef<HTMLInputElement | null>(null)
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [view, setView] = useState<ViewMode>('ribbons')
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const inputRef = useRef<HTMLInputElement>(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<string, string> = {
'lyria-3-clip-preview': 'Clip',
'lyria-3-pro-preview': 'Pro',
}
return names[modelId] || modelId
}
if (clips.length === 0) {
return (
<div className="bg-lyria-panel rounded-lg p-8 border border-white/5 text-center">
@@ -34,33 +102,266 @@ export default function Gallery({ clips, onPlay, onDelete, currentClipId }: Prop
return (
<div className="bg-lyria-panel rounded-lg border border-white/5">
{/* Header: title, search, view toggle */}
<div className="px-5 py-4 border-b border-white/5">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
Gallery ({clips.length})
</h2>
</div>
<div className="divide-y divide-white/5">
{clips.map((clip) => (
<div
key={clip.id}
className={`px-5 py-4 flex items-center gap-4 hover:bg-white/5 transition-colors ${
currentClipId === clip.id ? 'bg-white/5' : ''
}`}
>
<button
onClick={() => onPlay(clip)}
className="w-10 h-10 rounded-full bg-lyria-accent/20 flex items-center justify-center text-lyria-accent hover:bg-lyria-accent/30 transition-colors shrink-0"
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold text-white uppercase tracking-wider shrink-0">
Gallery ({clips.length})
</h2>
<div className="relative flex-1 max-w-xs">
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-lyria-muted"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Search tracks..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-md pl-8 pr-3 py-1.5 text-sm text-white placeholder-lyria-muted focus:outline-none focus:border-lyria-accent/50 focus:ring-1 focus:ring-lyria-accent/30 transition-colors"
/>
{search && (
<button
onClick={() => handleSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-lyria-muted hover:text-white transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* View toggle */}
<div className="flex bg-white/5 rounded-md border border-white/10 overflow-hidden">
<button
onClick={() => setView('ribbons')}
className={`p-1.5 transition-colors ${view === 'ribbons' ? 'bg-lyria-accent text-white' : 'text-lyria-muted hover:text-white'}`}
title="List view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<button
onClick={() => setView('tiles')}
className={`p-1.5 transition-colors ${view === 'tiles' ? 'bg-lyria-accent text-white' : 'text-lyria-muted hover:text-white'}`}
title="Tile view"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</button>
</div>
</div>
{search && filtered.length !== clips.length && (
<p className="text-xs text-lyria-muted mt-1.5">
Showing {filtered.length} of {clips.length} clips
</p>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{clip.prompt}</p>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-1.5 py-0.5 rounded ${
{/* Clip list */}
{filtered.length === 0 ? (
<div className="px-5 py-8 text-center">
<p className="text-lyria-muted text-sm">No clips match "{search}"</p>
</div>
) : view === 'ribbons' ? (
/* ── RIBBON VIEW ── */
<div className="divide-y divide-white/5">
{paged.map((clip) => (
<div
key={clip.id}
className={`px-5 py-4 flex items-center gap-4 hover:bg-white/5 transition-colors ${
currentClipId === clip.id ? 'bg-white/5' : ''
}`}
>
<div className="shrink-0 relative group/art">
{clip.artwork_filename ? (
<img
src={`/api/clips/${clip.id}/artwork`}
alt="Album art"
className="w-10 h-10 rounded object-cover"
/>
) : (
<div className="w-10 h-10 rounded bg-white/5 flex items-center justify-center">
<svg className="w-4 h-4 text-lyria-muted/60" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v9.28a2 2 0 102 0V3h2a1 1 0 001-1V1a1 1 0 00-1-1H9a1 1 0 00-1 1v1a1 1 0 001 1h2zm-1 17a3 3 0 116 0 3 3 0 01-6 0z" />
</svg>
</div>
)}
{/* Hover overlay: play + upload art */}
<div className="absolute inset-0 rounded bg-black/60 flex items-center justify-center gap-1 opacity-0 group-hover/art:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); onPlay(clip) }}
className="text-white hover:text-lyria-accent transition-colors"
title="Play"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
setUploadingId(clip.id)
setTimeout(() => artworkInputRef.current?.click(), 0)
}}
className="text-white hover:text-lyria-accent transition-colors"
title="Upload album art"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<div className="flex-1 min-w-0">
{editingId === clip.id ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => 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 ? (
<div className="overflow-hidden">
<p className="text-sm font-medium text-white truncate">{clip.title}</p>
<p className="text-xs text-lyria-muted/60 truncate">{clip.prompt}</p>
</div>
) : (
<p className="text-sm text-white truncate">{clip.prompt}</p>
)}
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-1.5 py-0.5 rounded ${
clip.status === 'ready'
? 'bg-green-500/20 text-green-400'
: clip.status === 'generating'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-red-500/20 text-red-400'
}`}>
{clip.status}
</span>
<span className="text-xs text-lyria-muted">{clip.duration_seconds}s</span>
<span className="text-xs text-lyria-muted">{modelName(clip.model)}</span>
<span className="text-xs text-lyria-muted">{formatDate(clip.created_at)}</span>
</div>
{clip.status === 'failed' && clip.error_message && (
<p className="text-xs text-red-400/80 mt-1 break-words">
{clip.error_message}
</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); onDelete(clip.id) }}
className="text-lyria-muted hover:text-red-400 transition-colors shrink-0"
title="Delete clip"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); startRename(clip) }}
className="text-lyria-muted/40 hover:text-lyria-accent transition-colors shrink-0"
title="Rename clip"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</div>
))}
</div>
) : (
/* ── TILE VIEW ── */
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
{paged.map((clip) => (
<div
key={clip.id}
className={`bg-lyria-dark rounded-lg border border-white/5 p-3 hover:border-lyria-accent/30 transition-colors flex flex-col cursor-pointer ${
currentClipId === clip.id ? 'border-lyria-accent/50' : ''
}`}
onClick={() => onSelect(clip)}
>
{/* Artwork or placeholder */}
<div className="relative group/art mb-2">
{clip.artwork_filename ? (
<img
src={`/api/clips/${clip.id}/artwork`}
alt="Album art"
className="w-full aspect-square rounded-md object-cover"
/>
) : (
<div className="w-full aspect-square bg-white/5 rounded-md flex items-center justify-center text-lyria-accent/40">
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v9.28a2 2 0 102 0V3h2a1 1 0 001-1V1a1 1 0 00-1-1H9a1 1 0 00-1 1v1a1 1 0 001 1h2zm-1 17a3 3 0 116 0 3 3 0 01-6 0z" />
</svg>
</div>
)}
{/* Hover overlay */}
<div className="absolute inset-0 rounded-md bg-black/60 flex items-center justify-center gap-2 opacity-0 group-hover/art:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); onPlay(clip) }}
className="text-white hover:text-lyria-accent transition-colors"
title="Play"
>
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation()
setUploadingId(clip.id)
setTimeout(() => artworkInputRef.current?.click(), 0)
}}
className="text-white/80 hover:text-lyria-accent transition-colors"
title="Upload album art"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
{/* Title / prompt */}
{editingId === clip.id ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => 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
/>
) : (
<p className="text-xs font-medium text-white truncate mb-0.5 cursor-pointer" onClick={() => startRename(clip)}>
{clip.title || clip.prompt}
</p>
)}
{/* Meta */}
<div className="flex items-center gap-1.5 mt-auto">
<span className={`text-[10px] px-1 py-0.5 rounded ${
clip.status === 'ready'
? 'bg-green-500/20 text-green-400'
: clip.status === 'generating'
@@ -69,22 +370,81 @@ export default function Gallery({ clips, onPlay, onDelete, currentClipId }: Prop
}`}>
{clip.status}
</span>
<span className="text-xs text-lyria-muted">{clip.duration_seconds}s</span>
<span className="text-xs text-lyria-muted">{formatDate(clip.created_at)}</span>
<span className="text-[10px] text-lyria-muted">{clip.duration_seconds}s</span>
<span className="text-[10px] text-lyria-muted">{modelName(clip.model)}</span>
</div>
{/* Error */}
{clip.status === 'failed' && clip.error_message && (
<p className="text-[10px] text-red-400/80 mt-1 line-clamp-2">
{clip.error_message}
</p>
)}
{/* Actions */}
<div className="flex items-center gap-1 mt-2 pt-1.5 border-t border-white/5">
<button
onClick={(e) => { e.stopPropagation(); startRename(clip) }}
className="text-lyria-muted/40 hover:text-lyria-accent transition-colors text-xs"
title="Rename"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(clip.id) }}
className="text-lyria-muted/40 hover:text-red-400 transition-colors text-xs"
title="Delete"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="px-5 py-3 border-t border-white/5 flex items-center justify-between">
<p className="text-xs text-lyria-muted">
Page {page} of {totalPages} ({filtered.length} clip{filtered.length !== 1 ? 's' : ''})
</p>
<div className="flex items-center gap-2">
<button
onClick={() => onDelete(clip.id)}
className="text-lyria-muted hover:text-red-400 transition-colors shrink-0"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 text-sm rounded bg-white/5 text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Prev
</button>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 text-sm rounded bg-white/5 text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
))}
</div>
</div>
)}
{/* Hidden artwork upload input */}
<input
ref={artworkInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={(e) => {
if (uploadingId && e.target.files?.[0]) {
onUploadArtwork(uploadingId, e.target.files[0])
}
e.target.value = ''
setUploadingId(null)
}}
/>
</div>
)
}
}
+90 -9
View File
@@ -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<Record<string, ModelInfo>>({})
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) {
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Dark gabber kick at 180 BPM with distorted synth lead..."
placeholder="Dark distorted kickdrum with atmospheric synth pads, 180 BPM..."
className="w-full h-24 bg-lyria-dark border border-white/10 rounded-md px-3 py-2 text-sm text-white placeholder:text-lyria-muted/50 focus:outline-none focus:border-lyria-accent resize-none"
/>
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-xs text-lyria-muted mb-1.5">
Model
</label>
<select
value={model}
onChange={(e) => handleModelChange(e.target.value)}
className="w-full bg-lyria-dark border border-white/10 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-lyria-accent"
>
{Object.entries(models).map(([key, info]) => (
<option key={key} value={key}>
{info.name} — {info.description}
</option>
))}
</select>
</div>
<div className="w-28">
<label className="block text-xs text-lyria-muted mb-1.5">
Duration ({duration}s)
</label>
<input
type="range"
min={minDuration}
max={maxDuration}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="w-full mt-2 accent-lyria-accent"
/>
</div>
</div>
<div className="flex items-center justify-between text-xs text-lyria-muted">
<span>Model: lyria-3-clip-preview</span>
<span>30s</span>
<span>{currentModelInfo?.name || 'Clip Preview'}</span>
<span>{duration}s / {maxDuration}s max</span>
</div>
{error && (
<p className="text-xs text-red-400">{error}</p>
<p className="text-xs text-red-400 break-words">{error}</p>
)}
<button
@@ -76,4 +157,4 @@ export default function Generator({ onGenerate }: Props) {
</form>
</div>
)
}
}