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

@ -364,8 +364,11 @@ html.dark .timeline-head {
1px 1px 0 var(--paper), -1px -1px 0 var(--paper);
}
.playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--ink);
z-index: 10; pointer-events: none; }
/* one full-height playhead spanning both the pinned lanes and the scrolling
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;
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;
line-height: 16px; color: var(--ink); white-space: nowrap;
pointer-events: none; }
.lane-playhead { bottom: 0; }
/* --- mobile: stack the top region vertically (video over annotation) ---- */
@media (max-width: 720px) {

View file

@ -12,6 +12,7 @@
(defonce video-el (atom nil))
(defonce raf (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 active-insert! (atom nil)) ; the live content-editor's (insert! link) fn
(declare commit-link!)
@ -71,6 +72,23 @@
(let [x (px playhead fps zoom)]
(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
"Index of the segment whose LOCAL range contains `local` (0 if none)."
[segs local]
@ -271,7 +289,10 @@
tracks-h (* row-h (count tracks))
track-y (into {} (map-indexed (fn [i t] [(:id t) i]) tracks))]
(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.playhead.timeline-playhead {:ref (fn [n] (reset! playhead-el n))}
[:div.playhead-handle]]
[:div.timeline-head
[:div.gutter-lanes {:style {:height lane-h}}
[: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.ann-lanes {:style {:height lane-h :width width}
: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
(for [[i a] (map-indexed vector anns)
[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")}}
(:name t)])]
[:div.hscroll {:ref (fn [n] (reset! scroll-el n))
:on-scroll #(when @lanes-scroll
(set! (.-scrollLeft @lanes-scroll)
(.. % -target -scrollLeft)))}
:on-scroll #(let [sl (.. % -target -scrollLeft)]
(when @lanes-scroll
(set! (.-scrollLeft @lanes-scroll) sl))
(position-playhead! fps zoom playhead))}
[:div.content.track-content {:ref (fn [n] (reset! content n))
:on-mouse-down #(scrub! @content fps zoom %)
:style {:width width :height tracks-h}}
[:div.playhead {:style {:left (px playhead fps zoom)}}]
(for [[i t] (map-indexed vector tracks)]
^{:key (str "lane-" (:id t))}
[:div.track-lane {:style {:top (* i row-h) :width width :height row-h}}])