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
|
*.pyc
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
/media/
|
/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==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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
[: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"
|
[: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]]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue