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

+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