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
+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)
|
||||
Reference in new issue
Block a user