feat(downloads): editor backend — trim/crop derivatives (phase 2)

Per-user trim/crop clips derived from a finished download via ffmpeg, riding the same
MediaAsset/DownloadJob/worker/quota/GC machinery (asset source_kind='edit', per-user cache key):
- migration 0041: download_jobs job_kind/source_job_id/source_asset_id/edit_spec
- app/downloads/edit.py: spec normalize+sig, ffmpeg trim/crop cmd (fast stream-copy vs frame-
  accurate re-encode, per-edit 'accurate' toggle), progress+cooperative-cancel runner, filmstrip
- service.enqueue_edit; worker _process_edit branch; storage.edit_rel_path (.edits/{user} tree)
- routes: POST /edit, GET /{id}/storyboard(.jpg)
This commit is contained in:
npeter83 2026-07-04 01:01:46 +02:00
parent c0b138cd9a
commit f0fc542260
7 changed files with 619 additions and 3 deletions

View file

@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
from app.auth import admin_user, require_human
from app.config import settings
from app.db import get_db
from app.downloads import edit as editmod
from app.downloads import quota, service, storage
from app.models import (
DownloadJob,
@ -81,6 +82,9 @@ def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"profile_id": job.profile_id,
"spec": job.profile_snapshot,
"display_name": job.display_name,
"job_kind": job.job_kind,
"source_job_id": job.source_job_id,
"edit_spec": job.edit_spec,
"can_download": ready and job.status == "done",
"expired": job.asset_id is None and job.status == "done",
"asset": None
@ -247,6 +251,36 @@ def enqueue_download(
return _serialize(job, asset)
@router.post("/edit")
def enqueue_edit(
payload: dict, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""Queue a phase-2 editor derivative (trim/crop) of one of the user's finished downloads."""
source_job = _own_job(db, user, int(payload.get("source_job_id") or 0))
if source_job.status != "done" or source_job.asset_id is None:
raise HTTPException(status_code=409, detail="You can only edit a finished download.")
spec = payload.get("edit_spec")
if not isinstance(spec, dict):
raise HTTPException(status_code=400, detail="edit_spec must be an object")
display_name = (payload.get("display_name") or "").strip() or None
try:
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_message(e))
except service.EditError as e:
raise HTTPException(status_code=400, detail=_edit_message(e))
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
return _serialize(job, asset)
def _edit_message(e: service.EditError) -> str:
if e.reason == "empty_edit":
return "Choose a trim range or crop area first."
if e.reason == "source_not_ready":
return "The source download isn't ready to edit."
return "That edit couldn't be created."
def _quota_message(e: quota.QuotaExceeded) -> str:
if e.reason == "max_jobs":
return f"You've reached your download limit ({e.limit} items). Remove some first."
@ -470,6 +504,49 @@ def download_file(
return FileResponse(path, filename=filename)
# --- editor filmstrip (scrub track) --------------------------------------------------------
@router.get("/{job_id}/storyboard")
def storyboard_meta(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""Filmstrip geometry for the editor's scrub track, generating the sprite on first use.
Best-effort: for a very long/high-res source the sprite may not generate in time then
`available` is False and the editor shows a plain timeline."""
job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.")
meta = editmod.ensure_storyboard(asset, settings.download_root)
if meta is None:
return {"available": False}
db.commit() # persist storyboard_path if it was just generated
return {
"available": True,
"cols": meta["cols"],
"rows": meta["rows"],
"count": meta["count"],
"interval_s": meta["interval_s"],
"url": f"/api/downloads/{job_id}/storyboard.jpg",
}
@router.get("/{job_id}/storyboard.jpg")
def storyboard_image(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
):
job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.storyboard_path:
raise HTTPException(status_code=404, detail="No filmstrip.")
path = storage.abs_path(settings.download_root, asset.storyboard_path).resolve()
root = Path(settings.download_root).resolve()
if root not in path.parents or not path.exists():
raise HTTPException(status_code=404, detail="No filmstrip.")
return FileResponse(path, media_type="image/jpeg")
# --- sharing -------------------------------------------------------------------------------
@router.post("/{job_id}/share")