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

@ -16,9 +16,11 @@ primary_region = "iad"
[http_service] [http_service]
internal_port = 8000 internal_port = 8000
force_https = true force_https = true
auto_stop_machines = "stop" # scale to zero when idle auto_stop_machines = "stop" # extra machines can still scale to zero
auto_start_machines = true # wake on the next request auto_start_machines = true # wake on the next request
min_machines_running = 0 min_machines_running = 1 # keep one VM always up: background thumbnail
# jobs outlive any HTTP request, so scale-to-zero
# would let the proxy reap the VM mid-job.
[[vm]] [[vm]]
size = "shared-cpu-1x" size = "shared-cpu-1x"

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]}")

View file

@ -19,7 +19,8 @@ THUMB_MAX_HEIGHT = 360
THUMB_JPEG_QUALITY = 9 THUMB_JPEG_QUALITY = 9
# ffmpeg's select filter decodes the whole clip on every pass, so each chunk is # ffmpeg's select filter decodes the whole clip on every pass, so each chunk is
# a full re-decode. Keep it one pass (one decode) for all but absurdly long clips # a full re-decode. Keep it one pass (one decode) for all but absurdly long clips
# — a single select expression with thousands of terms is cheap to evaluate. # — thousands of terms evaluate fine once the select expression is balanced (see
# _balanced_sum); the limit is parser depth, not term count.
CLIP_SELECT_CHUNK_SIZE = 4096 CLIP_SELECT_CHUNK_SIZE = 4096
@ -103,6 +104,25 @@ def _chunks(items, size):
yield i // size, items[i:i + size] yield i // size, items[i:i + size]
def _balanced_sum(terms):
"""Join `terms` with `+` as a *balanced* tree, e.g. ((a+b)+(c+d)).
ffmpeg's expression parser is recursive-descent: a flat "a+b+c+..." chain
nests right-associatively to depth N and overflows the parser past ~120
terms ("Error while parsing expression"). Balancing makes the parse depth
O(log N), so thousands of terms evaluate fine. `+` is associative, so the
selection is identical either way."""
nodes = list(terms)
if not nodes:
return "0"
while len(nodes) > 1:
nodes = [
f"({nodes[i]}+{nodes[i + 1]})" if i + 1 < len(nodes) else nodes[i]
for i in range(0, len(nodes), 2)
]
return nodes[0]
def enqueue_thumbnail_generation(project_id): def enqueue_thumbnail_generation(project_id):
thread = threading.Thread( thread = threading.Thread(
target=generate_project_thumbnails, target=generate_project_thumbnails,
@ -113,10 +133,16 @@ def enqueue_thumbnail_generation(project_id):
thread.start() thread.start()
def generate_project_thumbnails(project_id): def generate_project_thumbnails(project_id, log=None):
"""Build the thumbnail manifest for a project. `log` is an optional
callable(str) for progress lines — the management command passes one so the
run is watchable on the console instead of only in app logs."""
emit = log if log is not None else (lambda msg: logger.info("[thumbs %s] %s", project_id, msg))
try: try:
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
except Project.DoesNotExist: except Project.DoesNotExist:
emit("project does not exist")
return return
if not project.clip: if not project.clip:
@ -124,6 +150,7 @@ def generate_project_thumbnails(project_id):
thumbnail_status="failed", thumbnail_status="failed",
thumbnail_error="project has no clip", thumbnail_error="project has no clip",
) )
emit("failed: project has no clip")
return return
Project.objects.filter(pk=project_id).update( Project.objects.filter(pk=project_id).update(
@ -136,6 +163,7 @@ def generate_project_thumbnails(project_id):
try: try:
# pull the clip down to a local temp file so ffmpeg reads it directly, # pull the clip down to a local temp file so ffmpeg reads it directly,
# regardless of whether storage is local disk or R2. # regardless of whether storage is local disk or R2.
emit(f"downloading clip {project.clip.name}")
clip_path = work / ("clip" + (Path(project.clip.name).suffix or ".mp4")) clip_path = work / ("clip" + (Path(project.clip.name).suffix or ".mp4"))
with project.clip.open("rb") as src, open(clip_path, "wb") as dst: with project.clip.open("rb") as src, open(clip_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024) shutil.copyfileobj(src, dst, length=1024 * 1024)
@ -153,21 +181,33 @@ def generate_project_thumbnails(project_id):
if not unique_clip_frames: if not unique_clip_frames:
raise RuntimeError("no clip frames to sample") raise RuntimeError("no clip frames to sample")
chunks = list(_chunks(unique_clip_frames, CLIP_SELECT_CHUNK_SIZE))
emit(f"{len(unique_clip_frames)} frames across {len(clip_items)} clips, "
f"{len(chunks)} ffmpeg pass(es)")
frame_filenames = {} frame_filenames = {}
for chunk_index, frames in _chunks(unique_clip_frames, CLIP_SELECT_CHUNK_SIZE): for chunk_index, frames in chunks:
select_expr = "+".join(f"eq(n\\,{frame})" for frame in frames) emit(f" ffmpeg pass {chunk_index + 1}/{len(chunks)} ({len(frames)} frames)…")
subprocess.run([ select_expr = _balanced_sum(f"eq(n\\,{frame})" for frame in frames)
proc = subprocess.run([
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-i", str(clip_path), "-i", str(clip_path),
"-vf", f"select='{select_expr}',{scale}", "-vf", f"select='{select_expr}',{scale}",
"-vsync", "0", "-vsync", "0",
"-q:v", str(THUMB_JPEG_QUALITY), "-q:v", str(THUMB_JPEG_QUALITY),
str(work / f"clipframe_{chunk_index:03d}_%06d.jpg"), str(work / f"clipframe_{chunk_index:03d}_%06d.jpg"),
], check=True, capture_output=True, text=True) ], capture_output=True, text=True)
if proc.returncode != 0:
# surface ffmpeg's stderr — CalledProcessError hides it, which is
# what turned the last failure into a guessing game.
raise RuntimeError(
f"ffmpeg exited {proc.returncode}: {proc.stderr.strip() or '(no stderr)'}"
)
for idx, frame in enumerate(frames, start=1): for idx, frame in enumerate(frames, start=1):
frame_filenames[frame] = f"clipframe_{chunk_index:03d}_{idx:06d}.jpg" frame_filenames[frame] = f"clipframe_{chunk_index:03d}_{idx:06d}.jpg"
# upload each needed tile once, then point the manifest at its public URL # upload each needed tile once, then point the manifest at its public URL
emit("uploading tiles…")
uploaded = {} uploaded = {}
clip_thumbnails = {} clip_thumbnails = {}
for clip in clip_items: for clip in clip_items:
@ -198,6 +238,7 @@ def generate_project_thumbnails(project_id):
thumbnail_manifest=manifest, thumbnail_manifest=manifest,
thumbnail_error="", thumbnail_error="",
) )
emit(f"ready: {len(uploaded)} tiles uploaded for {len(clip_thumbnails)} clips")
except Exception as exc: except Exception as exc:
logger.exception("thumbnail generation failed for project %s", project_id) logger.exception("thumbnail generation failed for project %s", project_id)
Project.objects.filter(pk=project_id).update( Project.objects.filter(pk=project_id).update(
@ -205,5 +246,6 @@ def generate_project_thumbnails(project_id):
thumbnail_error=str(exc), thumbnail_error=str(exc),
thumbnail_manifest={}, thumbnail_manifest={},
) )
emit(f"failed: {exc}")
finally: finally:
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)

View file

@ -364,8 +364,11 @@ html.dark .timeline-head {
1px 1px 0 var(--paper), -1px -1px 0 var(--paper); 1px 1px 0 var(--paper), -1px -1px 0 var(--paper);
} }
.playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--ink); /* one full-height playhead spanning both the pinned lanes and the scrolling
z-index: 10; pointer-events: none; } tracks; positioned in JS (see position-playhead!) so it tracks horizontal
scroll while sitting above the translucent header. */
.timeline-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--ink);
z-index: 14; pointer-events: none; }
.playhead-handle { position: absolute; top: 0; left: -5px; width: 12px; height: 10px; .playhead-handle { position: absolute; top: 0; left: -5px; width: 12px; height: 10px;
background: var(--ink); border-radius: 0; } background: var(--ink); border-radius: 0; }
@ -379,7 +382,6 @@ html.dark .timeline-head {
.ann-bar-label { position: absolute; top: 1px; font-family: var(--chicago); font-size: 9px; .ann-bar-label { position: absolute; top: 1px; font-family: var(--chicago); font-size: 9px;
line-height: 16px; color: var(--ink); white-space: nowrap; line-height: 16px; color: var(--ink); white-space: nowrap;
pointer-events: none; } pointer-events: none; }
.lane-playhead { bottom: 0; }
/* --- mobile: stack the top region vertically (video over annotation) ---- */ /* --- mobile: stack the top region vertically (video over annotation) ---- */
@media (max-width: 720px) { @media (max-width: 720px) {

View file

@ -12,6 +12,7 @@
(defonce video-el (atom nil)) (defonce video-el (atom nil))
(defonce raf (atom nil)) (defonce raf (atom nil))
(defonce scroll-el (atom nil)) (defonce scroll-el (atom nil))
(defonce playhead-el (atom nil)) ; the single full-height playhead overlay
(defonce play (atom nil)) ; {:ctx :segs :fps :idx} while playing, else nil (defonce play (atom nil)) ; {:ctx :segs :fps :idx} while playing, else nil
(defonce active-insert! (atom nil)) ; the live content-editor's (insert! link) fn (defonce active-insert! (atom nil)) ; the live content-editor's (insert! link) fn
(declare commit-link!) (declare commit-link!)
@ -71,6 +72,23 @@
(let [x (px playhead fps zoom)] (let [x (px playhead fps zoom)]
(set! (.-scrollLeft el) (max 0 (- x (/ (.-clientWidth el) 2))))))) (set! (.-scrollLeft el) (max 0 (- x (/ (.-clientWidth el) 2)))))))
;; The playhead is a single full-height overlay on `.timeline` (spanning the
;; pinned annotation lanes AND the scrolling tracks as one line) rather than a
;; bar per region. It lives outside the horizontal scroller, so we track the
;; scroller's scrollLeft/offsetLeft by hand and hide it once it slips under the
;; track-name gutter or past the right edge.
(defn- position-playhead! [fps zoom playhead]
(when-let [ph @playhead-el]
(if-let [sc @scroll-el]
(let [x (- (px playhead fps zoom) (.-scrollLeft sc))
gutter (.-offsetLeft sc)
view (.-clientWidth sc)]
(if (and (>= x 0) (<= x view))
(do (set! (.. ph -style -left) (str (+ gutter x) "px"))
(set! (.. ph -style -display) "block"))
(set! (.. ph -style -display) "none")))
(set! (.. ph -style -display) "none"))))
(defn- seg-at (defn- seg-at
"Index of the segment whose LOCAL range contains `local` (0 if none)." "Index of the segment whose LOCAL range contains `local` (0 if none)."
[segs local] [segs local]
@ -271,7 +289,10 @@
tracks-h (* row-h (count tracks)) tracks-h (* row-h (count tracks))
track-y (into {} (map-indexed (fn [i t] [(:id t) i]) tracks))] track-y (into {} (map-indexed (fn [i t] [(:id t) i]) tracks))]
(when playing? (r/after-render #(follow! fps zoom playhead))) (when playing? (r/after-render #(follow! fps zoom playhead)))
(r/after-render #(position-playhead! fps zoom playhead))
[:div.timeline {:class (when-not @tracks-open? "tracks-collapsed")} [:div.timeline {:class (when-not @tracks-open? "tracks-collapsed")}
[:div.playhead.timeline-playhead {:ref (fn [n] (reset! playhead-el n))}
[:div.playhead-handle]]
[:div.timeline-head [:div.timeline-head
[:div.gutter-lanes {:style {:height lane-h}} [:div.gutter-lanes {:style {:height lane-h}}
[:button.track-toggle {:title (if @tracks-open? "Collapse track names" "Show track names") [:button.track-toggle {:title (if @tracks-open? "Collapse track names" "Show track names")
@ -280,8 +301,6 @@
[:div.lanes-hscroll {:ref (fn [n] (reset! lanes-scroll n))} [:div.lanes-hscroll {:ref (fn [n] (reset! lanes-scroll n))}
[:div.ann-lanes {:style {:height lane-h :width width} [:div.ann-lanes {:style {:height lane-h :width width}
:on-mouse-down #(scrub! @content fps zoom %)} :on-mouse-down #(scrub! @content fps zoom %)}
[:div.playhead.lane-playhead {:style {:left (px playhead fps zoom)}}
[:div.playhead-handle]]
;; annotation bars — solid for saved, dashed for the in-progress draft ;; annotation bars — solid for saved, dashed for the in-progress draft
(for [[i a] (map-indexed vector anns) (for [[i a] (map-indexed vector anns)
[j [lo hi]] (map-indexed vector (:bars a))] [j [lo hi]] (map-indexed vector (:bars a))]
@ -310,13 +329,13 @@
[:div.gutter-label {:style {:height row-h :line-height (str (dec row-h) "px")}} [:div.gutter-label {:style {:height row-h :line-height (str (dec row-h) "px")}}
(:name t)])] (:name t)])]
[:div.hscroll {:ref (fn [n] (reset! scroll-el n)) [:div.hscroll {:ref (fn [n] (reset! scroll-el n))
:on-scroll #(when @lanes-scroll :on-scroll #(let [sl (.. % -target -scrollLeft)]
(set! (.-scrollLeft @lanes-scroll) (when @lanes-scroll
(.. % -target -scrollLeft)))} (set! (.-scrollLeft @lanes-scroll) sl))
(position-playhead! fps zoom playhead))}
[:div.content.track-content {:ref (fn [n] (reset! content n)) [:div.content.track-content {:ref (fn [n] (reset! content n))
:on-mouse-down #(scrub! @content fps zoom %) :on-mouse-down #(scrub! @content fps zoom %)
:style {:width width :height tracks-h}} :style {:width width :height tracks-h}}
[:div.playhead {:style {:left (px playhead fps zoom)}}]
(for [[i t] (map-indexed vector tracks)] (for [[i t] (map-indexed vector tracks)]
^{:key (str "lane-" (:id t))} ^{:key (str "lane-" (:id t))}
[:div.track-lane {:style {:top (* i row-h) :width width :height row-h}}]) [:div.track-lane {:style {:top (* i row-h) :width width :height row-h}}])