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

@ -278,7 +278,16 @@ body { overflow: hidden; background: var(--desktop); background-size: 2px 2px;
.slider input { width: 150px; touch-action: none; accent-color: var(--ink); }
.transport { display: flex; align-items: center; gap: 4px; }
.play-btn { padding: 2px 12px; font-size: 14px; }
.step-btn { padding: 2px 7px; font-size: 11px; letter-spacing: -1px; }
.step-btn { padding: 2px 9px; font-size: 13px; line-height: 1; }
.step-btn:disabled, button:disabled { opacity: .3; cursor: default; }
/* prominent primary action: a filled (inverted) Chicago button with a hard shadow */
.add-annotation { font-family: var(--chicago); font-weight: bold; font-size: 13px;
background: var(--ink); color: var(--paper);
border: 1px solid var(--ink); border-radius: 8px; padding: 4px 14px;
cursor: pointer; box-shadow: 2px 2px 0 var(--ink); white-space: nowrap; }
.add-annotation:hover { background: var(--paper); color: var(--ink); }
.add-annotation:active { box-shadow: 0 0 0 var(--ink); transform: translate(2px, 2px); }
.timeline-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.timeline { display: flex; align-items: flex-start; background: var(--paper); }
@ -409,6 +418,26 @@ body { overflow: hidden; background: var(--desktop); background-size: 2px 2px;
.toolbar .dark-toggle { margin-left: auto; }
.home-head-right { display: flex; align-items: center; gap: 8px; }
/* picker mode: pulse a black ring around everything pickable so it's obvious
what to click — a 1-bit "glow" (no blur, stays HyperCard). Clips + the frame
readout pulse whenever a draft is open; annotations pulse while linking. */
@keyframes hc-pulse {
0%, 100% { box-shadow: 0 0 0 1px var(--ink); }
50% { box-shadow: 0 0 0 4px var(--ink); }
}
.picking .clip.clickable,
.picking .fr-clip.clickable,
.linking .ann-bar {
position: relative; z-index: 6;
animation: hc-pulse 0.9s ease-in-out infinite;
}
.linking .ann-bar { cursor: pointer; }
@media (prefers-reduced-motion: reduce) {
.picking .clip.clickable, .picking .fr-clip.clickable, .linking .ann-bar {
animation: none; box-shadow: 0 0 0 3px var(--ink);
}
}
/* iOS Safari zooms the page when focusing a control with font-size < 16px.
On touch devices bump every form control to 16px so tapping never zooms. */
@media (pointer: coarse) {

View file

@ -32,4 +32,8 @@
:dev
{:compiler-options
{:closure-defines
{ }}}}}}
{ }}}
:release
{:compiler-options
{:closure-defines
{tl.api/api-port ""}}}}}} ; prod: same-origin (no :9001, no CORS)

View file

@ -7,8 +7,17 @@
(:require [ajax.core :as ajax]
[re-frame.core :as rf]))
;; Dev serves the UI (shadow, :8280) and the API (Django, :9001) on different
;; ports, so the default pins the API to :9001. The production *release* build
;; overrides this to "" (see shadow-cljs.edn), so the app talks to its own origin
;; — one domain, no CORS.
(goog-define api-port "9001")
(def base
(str (.. js/window -location -protocol) "//" (.. js/window -location -hostname) ":9001"))
(let [l js/window.location]
(if (= "" api-port)
(.-origin l)
(str (.-protocol l) "//" (.-hostname l) ":" api-port))))
(def ^:private json-resp (ajax/json-response-format {:keywords? true}))
@ -74,8 +83,10 @@
[id]
(when-let [s @socket] (.close s))
(when id
(let [proto (if (= "https:" (.. js/window -location -protocol)) "wss:" "ws:")
url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/")
(let [l js/window.location
proto (if (= "https:" (.-protocol l)) "wss:" "ws:")
host (if (= "" api-port) (.-host l) (str (.-hostname l) ":" api-port))
url (str proto "//" host "/ws/projects/" id "/")
s (js/WebSocket. url)]
(reset! socket s)
(set! (.-onerror s) (fn [_] (js/console.warn "scene sync socket error; peer updates paused")))

View file

@ -282,7 +282,10 @@
[:div.ann-bar {:title (:name a)
:on-mouse-down (when-not (:draft a)
(fn [e] (.stopPropagation e)
(rf/dispatch [::events/expand (:id a)])))
(if linking
(do (.preventDefault e) ; pick: link to this timeline
(commit-link! {:kind :timeline :ref (:id a)} (:name a)))
(rf/dispatch [::events/expand (:id a)]))))
:style {:position "absolute" :top (+ 2 (* i 18)) :height 14
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
:background (str (:color a) (if (:draft a) "44" "cc"))
@ -611,10 +614,6 @@
[:div.muted "No description yet."])
(when authed?
[:button.edit-btn {:on-click #(rf/dispatch [::events/edit-here ctx])} "✎ Edit"])])
(when authed?
[:div.commentary-head
[:button.add-btn {:on-click #(rf/dispatch [::events/open-draft])}
"+ Add annotation"]])
(if (seq anns)
(doall
(for [a anns]
@ -753,9 +752,22 @@
(defn toolbar []
(let [zoom @(rf/subscribe [::subs/zoom]) row-h @(rf/subscribe [::subs/row-h])
playing? @(rf/subscribe [::subs/playing?])]
playing? @(rf/subscribe [::subs/playing?])
ph (js/Math.round @(rf/subscribe [::subs/playhead]))
len @(rf/subscribe [::subs/length])
authed? @(rf/subscribe [::subs/authed?])
authoring? (some? @(rf/subscribe [::subs/draft-group]))]
[:div.toolbar
[:button.play-btn {:on-click toggle-play! :title "Play / pause"} (if playing? "⏸" "▶")]
[:div.transport
[:button.step-btn {:title "Previous frame" :disabled (<= ph 0)
:on-click #(goto! (max 0 (dec ph)))} "◁"]
[:button.play-btn {:on-click toggle-play! :title "Play / pause"} (if playing? "⏸" "▶")]
[:button.step-btn {:title "Next frame" :disabled (>= ph len)
:on-click #(goto! (min len (inc ph)))} "▷"]]
(when (and authed? (not authoring?))
[:button.add-annotation {:title "Add annotation"
:on-click #(rf/dispatch [::events/open-draft])}
"+ Annotation"])
[:label.slider "Zoom X"
[:input {:type "range" :min 2 :max 400 :value zoom
:on-change #(rf/dispatch [::events/set-zoom (js/parseFloat (.. % -target -value))])}]]
@ -894,8 +906,12 @@
proj @(rf/subscribe [::subs/project])
authed? @(rf/subscribe [::subs/authed?])
thumb-status @(rf/subscribe [::subs/thumbnail-status])
load-err @(rf/subscribe [::subs/load-error])]
[:div.app
load-err @(rf/subscribe [::subs/load-error])
;; "picker mode": a draft is open (pick clips/frame for marks); + linking
;; (pick clips/frame/annotations for a link). Drives the pulsing glow.
authoring? (some? @(rf/subscribe [::subs/draft-group]))
linking? (some? @(rf/subscribe [::subs/linking]))]
[:div.app {:class (str (when authoring? "picking ") (when linking? "linking"))}
[:div.top
[:div.video-pane [video-monitor] [frame-readout]]
[:div.annot-pane [annot-pane]]]