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 *.pyc
db.sqlite3 db.sqlite3
/media/ /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==6.0.6
django-cors-headers==4.9.0 django-cors-headers==4.9.0
sqlparse==0.5.5 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 math
import shutil import shutil
import subprocess import subprocess
import tempfile
import threading import threading
from pathlib import Path 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 from .models import Project
@ -48,8 +50,9 @@ def _otio_clip_items(project):
if not project.otio: if not project.otio:
return [] return []
with open(project.otio.path, encoding="utf-8") as fh: # read through the storage backend (works on local disk AND R2/S3)
otio = json.load(fh) with project.otio.open("rb") as fh:
otio = json.loads(fh.read())
tracks = otio.get("tracks", {}).get("children", []) tracks = otio.get("tracks", {}).get("children", [])
source_starts = [ source_starts = [
@ -77,13 +80,19 @@ def _otio_clip_items(project):
return clips return clips
def thumbnail_root(project_id): def _thumb_key(project_id, filename):
return Path(settings.MEDIA_ROOT) / "projects" / str(project_id) / "thumbs" / "v1" return f"projects/{project_id}/thumbs/v1/{filename}"
def thumbnail_url_path(project_id, filename): def _save_thumb(project_id, filename, local_path):
media_url = "/" + settings.MEDIA_URL.strip("/") """Upload one tile through the storage backend (R2 in prod, disk in dev) and
return f"{media_url}/projects/{project_id}/thumbs/v1/{filename}" 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): def _chunks(items, size):
@ -120,98 +129,67 @@ def generate_project_thumbnails(project_id):
thumbnail_manifest={}, thumbnail_manifest={},
) )
out_dir = thumbnail_root(project_id) work = Path(tempfile.mkdtemp(prefix=f"thumbs-{project_id}-"))
tmp_dir = out_dir.with_name("v1.tmp")
try: try:
if tmp_dir.exists(): # pull the clip down to a local temp file so ffmpeg reads it directly,
shutil.rmtree(tmp_dir) # regardless of whether storage is local disk or R2.
tmp_dir.mkdir(parents=True, exist_ok=True) 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" 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) clip_items = _otio_clip_items(project)
unique_clip_frames = sorted({ unique_clip_frames = sorted({
max(0, tile["frame"]) max(0, tile["frame"])
for clip in clip_items for clip in clip_items
for tile in clip["tiles"] for tile in clip["tiles"]
}) })
if not unique_clip_frames:
raise RuntimeError("no clip frames to sample")
frame_filenames = {} frame_filenames = {}
for chunk_index, frames in _chunks(unique_clip_frames, CLIP_SELECT_CHUNK_SIZE): for chunk_index, frames in _chunks(unique_clip_frames, CLIP_SELECT_CHUNK_SIZE):
select_expr = "+".join(f"eq(n\\,{frame})" for frame in frames) select_expr = "+".join(f"eq(n\\,{frame})" for frame in frames)
chunk_pattern = f"clipframe_{chunk_index:03d}_%06d.jpg"
subprocess.run([ subprocess.run([
"ffmpeg", "ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-hide_banner", "-i", str(clip_path),
"-loglevel", "-vf", f"select='{select_expr}',{scale}",
"error", "-vsync", "0",
"-y", "-q:v", str(THUMB_JPEG_QUALITY),
"-i", str(work / f"clipframe_{chunk_index:03d}_%06d.jpg"),
str(input_path),
"-vf",
f"select='{select_expr}',{scale}",
"-vsync",
"0",
"-q:v",
str(THUMB_JPEG_QUALITY),
str(tmp_dir / chunk_pattern),
], check=True, capture_output=True, text=True) ], check=True, capture_output=True, text=True)
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
uploaded = {}
clip_thumbnails = {} clip_thumbnails = {}
for clip in clip_items: for clip in clip_items:
tiles = [] tiles = []
for tile in clip["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 continue
tiles.append({ if fname not in uploaded:
"start": tile["start"], uploaded[fname] = _save_thumb(project_id, fname, work / fname)
"end": tile["end"], tiles.append({"start": tile["start"], "end": tile["end"], "url": uploaded[fname]})
"url": thumbnail_url_path(project_id, frame_filenames[tile["frame"]]),
})
if tiles: if tiles:
clip_thumbnails[clip["id"]] = tiles clip_thumbnails[clip["id"]] = tiles
if not clip_thumbnails:
raise RuntimeError("ffmpeg produced no thumbnails")
manifest = { manifest = {
"version": 1, "version": 1,
"interval": THUMB_INTERVAL_SECONDS, "interval": THUMB_INTERVAL_SECONDS,
"max_height": THUMB_MAX_HEIGHT, "max_height": THUMB_MAX_HEIGHT,
"quality": THUMB_JPEG_QUALITY, "quality": THUMB_JPEG_QUALITY,
"count": len(files), "count": len(uploaded),
"template": thumbnail_url_path(project_id, "frame_%06d.jpg"),
"clips": clip_thumbnails, "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( Project.objects.filter(pk=project_id).update(
thumbnail_status="ready", thumbnail_status="ready",
thumbnail_manifest=manifest, thumbnail_manifest=manifest,
@ -219,10 +197,10 @@ def generate_project_thumbnails(project_id):
) )
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)
if tmp_dir.exists():
shutil.rmtree(tmp_dir, ignore_errors=True)
Project.objects.filter(pk=project_id).update( Project.objects.filter(pk=project_id).update(
thumbnail_status="failed", thumbnail_status="failed",
thumbnail_error=str(exc), thumbnail_error=str(exc),
thumbnail_manifest={}, 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/ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def _env(key, default=""):
return os.environ.get(key, default)
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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 off in prod (DJANGO_DEBUG=0); on by default for local dev.
DEBUG = True DEBUG = _env("DJANGO_DEBUG", "1") == "1"
# '.ts.net' matches any Tailscale MagicDNS name; the literal is the tailnet IP. # '.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 = ['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 # Application definition
@ -47,6 +56,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # serves static + the SPA in prod
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@ -86,7 +96,8 @@ WSGI_APPLICATION = 'server.wsgi.application'
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', '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_URL = 'media/'
MEDIA_ROOT = BASE_DIR / '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). # 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 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', CSRF_TRUSTED_ORIGINS = ['http://localhost:8280', 'http://127.0.0.1:8280',
'https://*.ts.net', 'http://*.ts.net', 'https://*.ts.net', 'http://*.ts.net',
'http://100.*'] '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' 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); } .slider input { width: 150px; touch-action: none; accent-color: var(--ink); }
.transport { display: flex; align-items: center; gap: 4px; } .transport { display: flex; align-items: center; gap: 4px; }
.play-btn { padding: 2px 12px; font-size: 14px; } .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-scroll { flex: 1; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.timeline { display: flex; align-items: flex-start; background: var(--paper); } .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; } .toolbar .dark-toggle { margin-left: auto; }
.home-head-right { display: flex; align-items: center; gap: 8px; } .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. /* 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. */ On touch devices bump every form control to 16px so tapping never zooms. */
@media (pointer: coarse) { @media (pointer: coarse) {

View file

@ -32,4 +32,8 @@
:dev :dev
{:compiler-options {:compiler-options
{:closure-defines {: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] (:require [ajax.core :as ajax]
[re-frame.core :as rf])) [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 (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})) (def ^:private json-resp (ajax/json-response-format {:keywords? true}))
@ -74,8 +83,10 @@
[id] [id]
(when-let [s @socket] (.close s)) (when-let [s @socket] (.close s))
(when id (when id
(let [proto (if (= "https:" (.. js/window -location -protocol)) "wss:" "ws:") (let [l js/window.location
url (str proto "//" (.. js/window -location -hostname) ":9001/ws/projects/" id "/") 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)] s (js/WebSocket. url)]
(reset! socket s) (reset! socket s)
(set! (.-onerror s) (fn [_] (js/console.warn "scene sync socket error; peer updates paused"))) (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) [:div.ann-bar {:title (:name a)
:on-mouse-down (when-not (:draft a) :on-mouse-down (when-not (:draft a)
(fn [e] (.stopPropagation e) (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 :style {:position "absolute" :top (+ 2 (* i 18)) :height 14
:left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom)) :left (px lo fps zoom) :width (max 4 (px (- hi lo) fps zoom))
:background (str (:color a) (if (:draft a) "44" "cc")) :background (str (:color a) (if (:draft a) "44" "cc"))
@ -611,10 +614,6 @@
[:div.muted "No description yet."]) [:div.muted "No description yet."])
(when authed? (when authed?
[:button.edit-btn {:on-click #(rf/dispatch [::events/edit-here ctx])} "✎ Edit"])]) [: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) (if (seq anns)
(doall (doall
(for [a anns] (for [a anns]
@ -753,9 +752,22 @@
(defn toolbar [] (defn toolbar []
(let [zoom @(rf/subscribe [::subs/zoom]) row-h @(rf/subscribe [::subs/row-h]) (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 [:div.toolbar
[: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.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" [:label.slider "Zoom X"
[:input {:type "range" :min 2 :max 400 :value zoom [:input {:type "range" :min 2 :max 400 :value zoom
:on-change #(rf/dispatch [::events/set-zoom (js/parseFloat (.. % -target -value))])}]] :on-change #(rf/dispatch [::events/set-zoom (js/parseFloat (.. % -target -value))])}]]
@ -894,8 +906,12 @@
proj @(rf/subscribe [::subs/project]) proj @(rf/subscribe [::subs/project])
authed? @(rf/subscribe [::subs/authed?]) authed? @(rf/subscribe [::subs/authed?])
thumb-status @(rf/subscribe [::subs/thumbnail-status]) thumb-status @(rf/subscribe [::subs/thumbnail-status])
load-err @(rf/subscribe [::subs/load-error])] load-err @(rf/subscribe [::subs/load-error])
[:div.app ;; "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.top
[:div.video-pane [video-monitor] [frame-readout]] [:div.video-pane [video-monitor] [frame-readout]]
[:div.annot-pane [annot-pane]]] [:div.annot-pane [annot-pane]]]