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:
1 parent
23672387e5
commit
8ba7133269
10 files changed
+1030
-59
No files matched your search
+78
-4
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user