fix: thumbnail parser overflow, autostop-killed jobs, unified playhead

Thumbnails failed immediately on any clip past ~2 min. The one-pass select
expression joined frames with a flat a+b+c+... chain, which overflows ffmpeg's
recursive expression parser past ~120 terms ("Error while parsing expression").
Join the same terms as a balanced tree (depth O(log N)) instead — identical
selection, still one decode pass, exact per-tile frames preserved.

- generate_thumbnails management command: runs synchronously and streams each
  phase to stdout, so jobs are watchable without tailing app logs
- surface ffmpeg's stderr instead of swallowing it in CalledProcessError
- fly: min_machines_running = 1. Background thumbnail jobs outlive the HTTP
  request that triggers them, so scale-to-zero let the proxy autostop the VM
  mid-job and leave projects stuck "running"
- timeline: replace the two split playhead bars (pinned lanes vs scrolling
  tracks) with one full-height overlay, positioned in JS to track horizontal
  scroll and sit above the translucent header

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-06-30 09:48:08 -04:00
parent 20ae2b4316
commit 28aafc047f
5 changed files with 135 additions and 17 deletions

View file

@ -0,0 +1,53 @@
"""Run the thumbnailer synchronously and stream progress to the console.
python manage.py generate_thumbnails 1 2 # specific projects
python manage.py generate_thumbnails --all # every project with a clip
python manage.py generate_thumbnails --failed # only failed/stuck ones
Unlike the upload path (which spawns a daemon thread and logs), this runs in the
foreground so you can watch each ffmpeg pass and the final status without tailing
app logs.
"""
from django.core.management.base import BaseCommand, CommandError
from scenes.models import Project
from scenes.thumbnails import generate_project_thumbnails
class Command(BaseCommand):
help = "Generate thumbnails for projects, streaming progress to stdout."
def add_arguments(self, parser):
parser.add_argument("project_ids", nargs="*", type=int,
help="Project ids to (re)generate.")
parser.add_argument("--all", action="store_true",
help="Generate for every project that has a clip.")
parser.add_argument("--failed", action="store_true",
help="Generate for projects whose status is failed/running/pending.")
def handle(self, *args, **opts):
ids = list(opts["project_ids"])
if opts["all"]:
ids += list(Project.objects.exclude(clip="").values_list("pk", flat=True))
if opts["failed"]:
ids += list(Project.objects
.filter(thumbnail_status__in=["failed", "running", "pending"])
.values_list("pk", flat=True))
ids = sorted(set(ids))
if not ids:
raise CommandError("no projects selected (pass ids, --all, or --failed)")
for pk in ids:
self.stdout.write(self.style.MIGRATE_HEADING(f"project {pk}"))
generate_project_thumbnails(pk, log=lambda msg: self.stdout.write(f" {msg}"))
# report the resulting state so a sweep ends with a clear summary
self.stdout.write("")
for project in Project.objects.filter(pk__in=ids).order_by("pk"):
line = f"project {project.pk} [{project.thumbnail_status}] {project.name}"
style = (self.style.SUCCESS if project.thumbnail_status == "ready"
else self.style.ERROR if project.thumbnail_status == "failed"
else self.style.WARNING)
self.stdout.write(style(line))
if project.thumbnail_error:
self.stdout.write(f" error: {project.thumbnail_error[:500]}")