diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0b333ca --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index e170f0c..7e0c5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ __pycache__/ *.pyc db.sqlite3 /media/ +staticfiles/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..62db7ba --- /dev/null +++ b/DEPLOY.md @@ -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://.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://.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://.fly.dev" \ + R2_BUCKET="lumet-media" \ + R2_ENDPOINT="https://.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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ffe83b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..0dacac9 --- /dev/null +++ b/fly.toml @@ -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://.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" diff --git a/requirements.txt b/requirements.txt index 9810cde..60b7e22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scenes/thumbnails.py b/scenes/thumbnails.py index ac1df76..1d70123 100644 --- a/scenes/thumbnails.py +++ b/scenes/thumbnails.py @@ -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) diff --git a/server/settings.py b/server/settings.py index 82b4699..0c13d0a 100644 --- a/server/settings.py +++ b/server/settings.py @@ -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' diff --git a/tl/resources/public/css/app.css b/tl/resources/public/css/app.css index 56e6657..4658ac5 100644 --- a/tl/resources/public/css/app.css +++ b/tl/resources/public/css/app.css @@ -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) { diff --git a/tl/shadow-cljs.edn b/tl/shadow-cljs.edn index bcabf3d..ba215ec 100644 --- a/tl/shadow-cljs.edn +++ b/tl/shadow-cljs.edn @@ -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) diff --git a/tl/src/tl/api.cljs b/tl/src/tl/api.cljs index c096d45..b114764 100644 --- a/tl/src/tl/api.cljs +++ b/tl/src/tl/api.cljs @@ -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"))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index b1a1fb3..c79683b 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -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]]]