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

16
.dockerignore Normal file
View file

@ -0,0 +1,16 @@
.git
**/.venv
**/node_modules
**/.shadow-cljs
**/target
tl/resources/public/js/compiled
**/__pycache__/
db.sqlite3
media/
staticfiles/
*.mp4
*.pdf
*.log
*.mbtree
thinking.org
.DS_Store

1
.gitignore vendored
View file

@ -38,3 +38,4 @@ __pycache__/
*.pyc
db.sqlite3
/media/
staticfiles/

59
DEPLOY.md Normal file
View file

@ -0,0 +1,59 @@
# Deploy (Fly.io + Cloudflare R2)
One Fly machine (scale-to-zero) runs Django ASGI (daphne) + ffmpeg, serving the
API, the WebSocket, and the compiled SPA. SQLite lives on a Fly volume. **All
media — clips, OTIO, thumbnails — lives in Cloudflare R2** (S3-compatible, free
egress), served through Cloudflare's CDN. The frontend is a same-origin release
build, so there's no CORS in prod.
## 1. Cloudflare R2 (do this in the dashboard)
1. **Create a bucket** (R2 → Create bucket), e.g. `lumet-media`.
2. **Public access via a custom domain**: bucket → Settings → Public access →
*Connect a domain*, e.g. `media.yourdomain.com` (Cloudflare proxied). That host
is `R2_PUBLIC_DOMAIN`. (This is what makes egress free + cached.)
3. **CORS policy** on the bucket (Settings → CORS) — the app `fetch()`es the OTIO
JSON cross-origin, so allow your app origin:
```json
[{"AllowedOrigins":["https://<your-app>.fly.dev"],
"AllowedMethods":["GET"],"AllowedHeaders":["*"],"MaxAgeSeconds":3600}]
```
4. **API token**: R2 → Manage API Tokens → Create (Object Read & Write, this
bucket). Note the **Access Key ID**, **Secret Access Key**, and your account's
**S3 endpoint** `https://<accountid>.r2.cloudflarestorage.com`.
## 2. Fly secrets
```bash
fly secrets set \
DJANGO_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_urlsafe(64))')" \
DJANGO_CSRF_TRUSTED="https://<your-app>.fly.dev" \
R2_BUCKET="lumet-media" \
R2_ENDPOINT="https://<accountid>.r2.cloudflarestorage.com" \
R2_ACCESS_KEY_ID="..." \
R2_SECRET_ACCESS_KEY="..." \
R2_PUBLIC_DOMAIN="media.yourdomain.com"
```
## 3. Deploy
```bash
fly volumes create data -r iad -n 1 -s 1 # 1 GB for SQLite (once)
fly deploy
fly ssh console -C "python manage.py createsuperuser" # first admin
```
`fly.toml` already sets `DJANGO_DEBUG=0`, `DJANGO_ALLOWED_HOSTS=.fly.dev`,
`DJANGO_DB_PATH=/data/db.sqlite3`, and scale-to-zero.
## Notes / caveats
- **Thumbnails run in a background thread.** With scale-to-zero the machine can
stop right after an upload returns and kill that thread before it finishes — so
thumbnails may not generate on the very first idle deploy. Options: bump
`min_machines_running = 1`, or move thumbnailing to a proper queue/worker later.
Core app (video, annotations, sync) is unaffected.
- **Single machine only** while on SQLite + the in-memory Channels layer. To scale
out: LiteFS (SQLite replication) + `channels_redis` (Upstash).
- **Dev is unchanged**: `npm run dev` still serves UI on :8280 / API on :9001 with
local-disk media. R2 only kicks in when the `R2_*` env vars are set.

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
# ---- stage 1: compile the ClojureScript frontend (release build) ----
# Java (for the shadow-cljs compiler) + Node (npm deps / launcher).
FROM clojure:temurin-21-tools-deps AS frontend
WORKDIR /build/tl
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
&& rm -rf /var/lib/apt/lists/*
COPY tl/package.json tl/package-lock.json ./
RUN npm ci
COPY tl/ ./
# api-port is left "" in the release build → the app talks to its own origin.
RUN npx shadow-cljs release app
# ---- stage 2: Python runtime — Django ASGI (daphne) + ffmpeg ----
FROM python:3.12-slim-bookworm AS runtime
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY manage.py ./
COPY server/ ./server/
COPY scenes/ ./scenes/
# the built SPA (index.html, css, fonts, icons, js/compiled/app.js) for WhiteNoise
COPY --from=frontend /build/tl/resources/public ./tl/resources/public
RUN python manage.py collectstatic --noinput
EXPOSE 8000
# migrate on boot (idempotent; the volume holds the SQLite db), then serve ASGI.
CMD ["sh", "-c", "python manage.py migrate --noinput && exec daphne -b 0.0.0.0 -p 8000 server.asgi:application"]

30
fly.toml Normal file
View file

@ -0,0 +1,30 @@
# Fly.io — one machine, scale to zero, SQLite on a volume, video on R2.
# `app` is set by `fly launch`. After launch, set secrets (see DEPLOY.md) and:
# fly deploy
app = "lumet"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[env]
DJANGO_DEBUG = "0"
DJANGO_ALLOWED_HOSTS = ".fly.dev"
DJANGO_DB_PATH = "/data/db.sqlite3"
# also set DJANGO_CSRF_TRUSTED="https://<your-app>.fly.dev" (secret or env) for admin
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "stop" # scale to zero when idle
auto_start_machines = true # wake on the next request
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
# SQLite + uploads scratch live here (survives machine stop/start)
[[mounts]]
source = "data"
destination = "/data"

View file

@ -2,3 +2,12 @@ asgiref==3.11.1
Django==6.0.6
django-cors-headers==4.9.0
sqlparse==0.5.5
# ASGI + realtime (already used in dev, were just unpinned here)
channels==4.3.2
daphne==4.2.2
# production: static serving + R2 (S3-compatible) media storage
whitenoise==6.8.2
django-storages==1.14.4
boto3==1.35.99

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)

View file

@ -10,23 +10,32 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
def _env(key, default=""):
return os.environ.get(key, default)
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-it#u2wp)_qjx@cq61o*thw#*4_1*hk!%%lz#d+fww6^*l3j+zj'
# Real key from the environment in prod; the insecure literal is a dev fallback
# so `runserver` works out of the box.
SECRET_KEY = _env("DJANGO_SECRET_KEY") or 'django-insecure-it#u2wp)_qjx@cq61o*thw#*4_1*hk!%%lz#d+fww6^*l3j+zj'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
# DEBUG off in prod (DJANGO_DEBUG=0); on by default for local dev.
DEBUG = _env("DJANGO_DEBUG", "1") == "1"
# '.ts.net' matches any Tailscale MagicDNS name; the literal is the tailnet IP.
# DJANGO_ALLOWED_HOSTS (comma-separated, e.g. ".fly.dev") adds prod hosts.
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'testserver', '.ts.net', '100.82.84.50']
ALLOWED_HOSTS += [h for h in _env("DJANGO_ALLOWED_HOSTS").split(",") if h]
# Application definition
@ -47,6 +56,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # serves static + the SPA in prod
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@ -86,7 +96,8 @@ WSGI_APPLICATION = 'server.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
# DJANGO_DB_PATH points at the Fly volume (/data/db.sqlite3) in prod.
'NAME': _env("DJANGO_DB_PATH") or (BASE_DIR / 'db.sqlite3'),
}
}
@ -131,6 +142,38 @@ STATIC_URL = 'static/'
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Django's own static (admin) is collected here; WhiteNoise serves it and also
# serves the compiled frontend (resources/public — index.html, app.js, css,
# fonts, icons) at the site root. It's a hash-routed SPA, so '/' is all we need.
STATIC_ROOT = BASE_DIR / 'staticfiles'
WHITENOISE_ROOT = BASE_DIR / 'tl' / 'resources' / 'public'
WHITENOISE_INDEX_FILE = True
STORAGES = {
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
'staticfiles': {'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage'},
}
# Production media (clips/otio/thumbnails) → Cloudflare R2 (S3-compatible, free
# egress). FileField.url then returns the public CDN URL automatically. Set the
# R2_* env vars to switch it on; without them dev stays on local disk.
# R2_PUBLIC_DOMAIN = a Cloudflare-fronted custom domain on the bucket (no scheme).
if _env("R2_BUCKET"):
STORAGES['default'] = {
'BACKEND': 'storages.backends.s3.S3Storage',
'OPTIONS': {
'bucket_name': _env("R2_BUCKET"),
'endpoint_url': _env("R2_ENDPOINT"),
'access_key': _env("R2_ACCESS_KEY_ID"),
'secret_key': _env("R2_SECRET_ACCESS_KEY"),
'region_name': 'auto',
'signature_version': 's3v4',
'custom_domain': _env("R2_PUBLIC_DOMAIN") or None,
'querystring_auth': not bool(_env("R2_PUBLIC_DOMAIN")),
'file_overwrite': False,
},
}
# Allow clip uploads up to ~100 MB (files stream to a temp file past 2.5 MB).
DATA_UPLOAD_MAX_MEMORY_SIZE = 100 * 1024 * 1024
@ -148,5 +191,13 @@ CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = ['http://localhost:8280', 'http://127.0.0.1:8280',
'https://*.ts.net', 'http://*.ts.net',
'http://100.*']
# DJANGO_CSRF_TRUSTED (comma-separated, e.g. "https://my-app.fly.dev") for prod.
CSRF_TRUSTED_ORIGINS += [o for o in _env("DJANGO_CSRF_TRUSTED").split(",") if o]
# Behind Fly's edge TLS: trust the forwarded-proto header and mark cookies secure.
if not DEBUG:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

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]]]