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:
parent
e00a056209
commit
e6d150c375
12 changed files with 322 additions and 87 deletions
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
1
.gitignore
vendored
|
|
@ -38,3 +38,4 @@ __pycache__/
|
|||
*.pyc
|
||||
db.sqlite3
|
||||
/media/
|
||||
staticfiles/
|
||||
|
|
|
|||
59
DEPLOY.md
Normal file
59
DEPLOY.md
Normal 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
31
Dockerfile
Normal 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
30
fly.toml
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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]]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue