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

+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)