feat: production deploy (Fly + R2) + frame controls and picker glow

Deploy:
- Multi-stage Dockerfile (shadow-cljs release → Python+ffmpeg runtime), fly.toml
  (one machine, scale-to-zero, SQLite on a volume), .dockerignore, DEPLOY.md
- Same-origin release build: api-port defaults "9001" for dev, "" in release
  (no CORS in prod)
- env-driven settings (SECRET_KEY/DEBUG/ALLOWED_HOSTS/DB_PATH/CSRF), WhiteNoise
  serving static + the SPA, R2 (S3-compatible) media storage when R2_* is set
- thumbnails reworked storage-agnostic: download clip, ffmpeg select-pass, upload
  tiles via default_storage (R2 in prod, disk in dev)
- pin channels/daphne; add whitenoise/django-storages/boto3

UI:
- frame step buttons flanking play (disabled at timeline bounds)
- Add-annotation button moved into the toolbar, made prominent
- picker mode pulses a 1-bit glow on clips / frame readout / (while linking)
  annotation bars; linking an annotation bar inserts a timeline link

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-30 00:56:09 -04:00
parent e00a056209
commit e6d150c375
12 changed files with 322 additions and 87 deletions

View file

@ -3,10 +3,12 @@ import logging
import math
import shutil
import subprocess
import tempfile
import threading
from pathlib import Path
from django.conf import settings
from django.core.files import File
from django.core.files.storage import default_storage
from .models import Project
@ -48,8 +50,9 @@ def _otio_clip_items(project):
if not project.otio:
return []
with open(project.otio.path, encoding="utf-8") as fh:
otio = json.load(fh)
# read through the storage backend (works on local disk AND R2/S3)
with project.otio.open("rb") as fh:
otio = json.loads(fh.read())
tracks = otio.get("tracks", {}).get("children", [])
source_starts = [
@ -77,13 +80,19 @@ def _otio_clip_items(project):
return clips
def thumbnail_root(project_id):
return Path(settings.MEDIA_ROOT) / "projects" / str(project_id) / "thumbs" / "v1"
def _thumb_key(project_id, filename):
return f"projects/{project_id}/thumbs/v1/{filename}"
def thumbnail_url_path(project_id, filename):
media_url = "/" + settings.MEDIA_URL.strip("/")
return f"{media_url}/projects/{project_id}/thumbs/v1/{filename}"
def _save_thumb(project_id, filename, local_path):
"""Upload one tile through the storage backend (R2 in prod, disk in dev) and
return its public URL. Overwrites any prior tile at that key."""
key = _thumb_key(project_id, filename)
if default_storage.exists(key):
default_storage.delete(key)
with open(local_path, "rb") as fh:
default_storage.save(key, File(fh))
return default_storage.url(key)
def _chunks(items, size):
@ -120,98 +129,67 @@ def generate_project_thumbnails(project_id):
thumbnail_manifest={},
)
out_dir = thumbnail_root(project_id)
tmp_dir = out_dir.with_name("v1.tmp")
work = Path(tempfile.mkdtemp(prefix=f"thumbs-{project_id}-"))
try:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True, exist_ok=True)
# pull the clip down to a local temp file so ffmpeg reads it directly,
# regardless of whether storage is local disk or R2.
clip_path = work / ("clip" + (Path(project.clip.name).suffix or ".mp4"))
with project.clip.open("rb") as src, open(clip_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
input_path = Path(project.clip.path)
pattern = tmp_dir / "frame_%06d.jpg"
vf = (
f"fps=1/{THUMB_INTERVAL_SECONDS},"
f"scale=w=-2:h={THUMB_MAX_HEIGHT}:force_original_aspect_ratio=decrease"
)
scale = f"scale=w=-2:h={THUMB_MAX_HEIGHT}:force_original_aspect_ratio=decrease"
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(input_path),
"-vf",
vf,
"-q:v",
str(THUMB_JPEG_QUALITY),
str(pattern),
]
subprocess.run(cmd, check=True, capture_output=True, text=True)
files = sorted(tmp_dir.glob("frame_*.jpg"))
if not files:
raise RuntimeError("ffmpeg produced no thumbnails")
# the timeline only renders per-clip tiles, so extract exactly the source
# frames those tiles need (one select-pass), not a full 1 fps sequence.
clip_items = _otio_clip_items(project)
unique_clip_frames = sorted({
max(0, tile["frame"])
for clip in clip_items
for tile in clip["tiles"]
})
if not unique_clip_frames:
raise RuntimeError("no clip frames to sample")
frame_filenames = {}
for chunk_index, frames in _chunks(unique_clip_frames, CLIP_SELECT_CHUNK_SIZE):
select_expr = "+".join(f"eq(n\\,{frame})" for frame in frames)
chunk_pattern = f"clipframe_{chunk_index:03d}_%06d.jpg"
subprocess.run([
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
str(input_path),
"-vf",
f"select='{select_expr}',{scale}",
"-vsync",
"0",
"-q:v",
str(THUMB_JPEG_QUALITY),
str(tmp_dir / chunk_pattern),
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(clip_path),
"-vf", f"select='{select_expr}',{scale}",
"-vsync", "0",
"-q:v", str(THUMB_JPEG_QUALITY),
str(work / f"clipframe_{chunk_index:03d}_%06d.jpg"),
], check=True, capture_output=True, text=True)
for idx, frame in enumerate(frames, start=1):
frame_filenames[frame] = f"clipframe_{chunk_index:03d}_{idx:06d}.jpg"
# upload each needed tile once, then point the manifest at its public URL
uploaded = {}
clip_thumbnails = {}
for clip in clip_items:
tiles = []
for tile in clip["tiles"]:
if tile["frame"] not in frame_filenames:
fname = frame_filenames.get(tile["frame"])
if not fname or not (work / fname).exists():
continue
tiles.append({
"start": tile["start"],
"end": tile["end"],
"url": thumbnail_url_path(project_id, frame_filenames[tile["frame"]]),
})
if fname not in uploaded:
uploaded[fname] = _save_thumb(project_id, fname, work / fname)
tiles.append({"start": tile["start"], "end": tile["end"], "url": uploaded[fname]})
if tiles:
clip_thumbnails[clip["id"]] = tiles
if not clip_thumbnails:
raise RuntimeError("ffmpeg produced no thumbnails")
manifest = {
"version": 1,
"interval": THUMB_INTERVAL_SECONDS,
"max_height": THUMB_MAX_HEIGHT,
"quality": THUMB_JPEG_QUALITY,
"count": len(files),
"template": thumbnail_url_path(project_id, "frame_%06d.jpg"),
"count": len(uploaded),
"clips": clip_thumbnails,
}
(tmp_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
if out_dir.exists():
shutil.rmtree(out_dir)
tmp_dir.rename(out_dir)
Project.objects.filter(pk=project_id).update(
thumbnail_status="ready",
thumbnail_manifest=manifest,
@ -219,10 +197,10 @@ def generate_project_thumbnails(project_id):
)
except Exception as exc:
logger.exception("thumbnail generation failed for project %s", project_id)
if tmp_dir.exists():
shutil.rmtree(tmp_dir, ignore_errors=True)
Project.objects.filter(pk=project_id).update(
thumbnail_status="failed",
thumbnail_error=str(exc),
thumbnail_manifest={},
)
finally:
shutil.rmtree(work, ignore_errors=True)