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:
parent
c0b138cd9a
commit
f0fc542260
7 changed files with 619 additions and 3 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue