thumbnails

This commit is contained in:
Your Name 2026-06-29 03:19:47 -04:00
parent 5861113aeb
commit caa2aa6b9b
10 changed files with 403 additions and 19 deletions

View file

@ -5,12 +5,14 @@ from .models import Project, Revision
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "fps", "updated")
list_filter = ("owner",)
list_display = ("name", "owner", "fps", "thumbnail_status", "updated")
list_filter = ("owner", "thumbnail_status")
search_fields = ("name",)
filter_horizontal = ("collaborators",)
readonly_fields = ("created", "updated")
readonly_fields = ("thumbnail_status", "thumbnail_manifest", "thumbnail_error",
"created", "updated")
fields = ("name", "owner", "collaborators", "otio", "clip", "fps", "scene",
"thumbnail_status", "thumbnail_manifest", "thumbnail_error",
"created", "updated")

View file

@ -0,0 +1,28 @@
# Generated by Django 6.0.6 on 2026-06-29 06:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('scenes', '0004_project_clip'),
]
operations = [
migrations.AddField(
model_name='project',
name='thumbnail_error',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='project',
name='thumbnail_manifest',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='project',
name='thumbnail_status',
field=models.CharField(default='missing', max_length=20),
),
]

View file

@ -17,6 +17,9 @@ class Project(models.Model):
otio = models.FileField(upload_to="otio/", blank=True, null=True)
clip = models.FileField(upload_to="clips/", blank=True, null=True)
fps = models.FloatField(default=NTSC_24)
thumbnail_status = models.CharField(max_length=20, default="missing")
thumbnail_manifest = models.JSONField(default=dict, blank=True)
thumbnail_error = models.TextField(blank=True)
scene = models.JSONField(default=dict, blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)

228
scenes/thumbnails.py Normal file
View file

@ -0,0 +1,228 @@
import json
import logging
import math
import shutil
import subprocess
import threading
from pathlib import Path
from django.conf import settings
from .models import Project
logger = logging.getLogger(__name__)
THUMB_INTERVAL_SECONDS = 1.0
THUMB_MAX_HEIGHT = 360
THUMB_JPEG_QUALITY = 9
CLIP_SELECT_CHUNK_SIZE = 30
def _otio_frames(rational_time):
return rational_time.get("value", 0) if rational_time else 0
def _clip_sample_frame(start, duration):
end = start + duration
first = math.ceil(start)
last = math.ceil(end) - 1
if last < first:
return max(0, math.floor(start))
midpoint = math.floor(start + (duration / 2))
return max(0, min(max(midpoint, first), last))
def _clip_tiles(start, duration, fps):
frames_per_tile = max(1, round(fps * THUMB_INTERVAL_SECONDS))
tiles = []
offset = 0
while offset < duration:
end = min(duration, offset + frames_per_tile)
sample = _clip_sample_frame(start + offset, end - offset)
tiles.append({"start": offset, "end": end, "frame": sample})
offset = end
return tiles
def _otio_clip_items(project):
if not project.otio:
return []
with open(project.otio.path, encoding="utf-8") as fh:
otio = json.load(fh)
tracks = otio.get("tracks", {}).get("children", [])
source_starts = [
_otio_frames(item.get("source_range", {}).get("start_time"))
for track in tracks
for item in track.get("children", [])
if str(item.get("OTIO_SCHEMA", "")).startswith("Clip")
]
media_offset = min(source_starts) if source_starts else 0
clips = []
for track_index, track in enumerate(tracks):
clip_index = 0
for item in track.get("children", []):
if not str(item.get("OTIO_SCHEMA", "")).startswith("Clip"):
continue
source_range = item.get("source_range", {})
start = _otio_frames(source_range.get("start_time")) - media_offset
duration = _otio_frames(source_range.get("duration"))
clips.append({
"id": f"t{track_index}-c{clip_index}",
"tiles": _clip_tiles(start, duration, project.fps),
})
clip_index += 1
return clips
def thumbnail_root(project_id):
return Path(settings.MEDIA_ROOT) / "projects" / str(project_id) / "thumbs" / "v1"
def thumbnail_url_path(project_id, filename):
media_url = "/" + settings.MEDIA_URL.strip("/")
return f"{media_url}/projects/{project_id}/thumbs/v1/{filename}"
def _chunks(items, size):
for i in range(0, len(items), size):
yield i // size, items[i:i + size]
def enqueue_thumbnail_generation(project_id):
thread = threading.Thread(
target=generate_project_thumbnails,
args=(project_id,),
name=f"thumbs-project-{project_id}",
daemon=True,
)
thread.start()
def generate_project_thumbnails(project_id):
try:
project = Project.objects.get(pk=project_id)
except Project.DoesNotExist:
return
if not project.clip:
Project.objects.filter(pk=project_id).update(
thumbnail_status="failed",
thumbnail_error="project has no clip",
)
return
Project.objects.filter(pk=project_id).update(
thumbnail_status="running",
thumbnail_error="",
thumbnail_manifest={},
)
out_dir = thumbnail_root(project_id)
tmp_dir = out_dir.with_name("v1.tmp")
try:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True, exist_ok=True)
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")
clip_items = _otio_clip_items(project)
unique_clip_frames = sorted({
max(0, tile["frame"])
for clip in clip_items
for tile in clip["tiles"]
})
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),
], 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"
clip_thumbnails = {}
for clip in clip_items:
tiles = []
for tile in clip["tiles"]:
if tile["frame"] not in frame_filenames:
continue
tiles.append({
"start": tile["start"],
"end": tile["end"],
"url": thumbnail_url_path(project_id, frame_filenames[tile["frame"]]),
})
if tiles:
clip_thumbnails[clip["id"]] = tiles
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"),
"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,
thumbnail_error="",
)
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={},
)

View file

@ -1,6 +1,7 @@
import json
from django.contrib.auth import authenticate, login as dj_login, logout as dj_logout
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import get_object_or_404
@ -9,6 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
from .attribution import annotation_layer, apply_attribution
from .models import Project, Revision
from .thumbnails import enqueue_thumbnail_generation
MAX_CLIP_BYTES = 100 * 1024 * 1024
@ -33,6 +35,17 @@ def _editable(request, pk):
def _meta(request, project):
thumbnails = dict(project.thumbnail_manifest or {})
if thumbnails.get("template"):
thumbnails["template"] = request.build_absolute_uri(thumbnails["template"])
if thumbnails.get("clips"):
thumbnails["clips"] = {
clip_id: [
dict(tile, url=request.build_absolute_uri(tile["url"]))
for tile in tiles
]
for clip_id, tiles in thumbnails["clips"].items()
}
return {
"id": project.pk,
"name": project.name,
@ -40,6 +53,9 @@ def _meta(request, project):
"owner": project.owner.get_username(),
"otio": request.build_absolute_uri(project.otio.url) if project.otio else None,
"clip": request.build_absolute_uri(project.clip.url) if project.clip else None,
"thumbnail_status": project.thumbnail_status,
"thumbnail_error": project.thumbnail_error,
"thumbnails": thumbnails or None,
}
@ -89,7 +105,14 @@ def projects(request):
return JsonResponse({"detail": "name, otio and clip are required"}, status=400)
if clip.size > MAX_CLIP_BYTES:
return JsonResponse({"detail": "clip exceeds 100 MB"}, status=400)
project = Project.objects.create(owner=request.user, name=name, otio=otio, clip=clip)
project = Project.objects.create(
owner=request.user,
name=name,
otio=otio,
clip=clip,
thumbnail_status="pending",
)
transaction.on_commit(lambda: enqueue_thumbnail_generation(project.pk))
return JsonResponse(_meta(request, project), status=201)
return HttpResponseNotAllowed(["GET", "POST"])

31
tl/AGENTS.md Normal file
View file

@ -0,0 +1,31 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a ClojureScript single-page app built with Shadow CLJS, Reagent, and re-frame. Application source lives in `src/tl/`; core namespaces include `core.cljs` for startup, `db.cljs` for default app state, `events.cljs` and `subs.cljs` for re-frame wiring, and `views.cljs` for UI. Static files are served from `resources/public/`, including `index.html`, CSS in `resources/public/css/`, icons, OTIO data, and local media assets. Development helpers live in `dev/`, including `dev.sh`, `media_server.py`, and `user.cljs`. Generated output goes to `resources/public/js/compiled/`, `target/`, and `.shadow-cljs/`; do not edit or commit generated files.
## Build, Test, and Development Commands
- `npm install`: install JavaScript tooling and React dependencies.
- `npm run dev`: run the project development script in `dev/dev.sh`.
- `npm run watch`: start `shadow-cljs watch app` with hot reload; open `http://localhost:8280/`.
- `npm run media`: start the Python media server from `dev/media_server.py`.
- `npm run release`: produce an optimized app build in `resources/public/js/compiled/`.
- `npm run build-report`: generate `target/build-report.html` for bundle inspection.
- `npm run ancient`: check Clojure dependency freshness via `antq`.
## Coding Style & Naming Conventions
Use idiomatic ClojureScript formatting with two-space indentation and aligned maps where useful. Namespace files should match `tl.<name>` and use kebab-case for functions, events, subscriptions, and local vars. Prefer qualified re-frame event keywords such as `::initialize-db` and keep side effects in registered effects or `reg-event-fx` handlers. Keep comments short and focused on non-obvious behavior.
## Testing Guidelines
`shadow-cljs.edn` includes `test` in `:source-paths`, but this repository currently has no committed test suite or npm test script. Add tests under `test/tl/` using matching namespace names such as `tl.otio-test`. When adding test support, include a runnable npm script and document the command here. Until then, verify changes with `npm run watch` for interactive behavior and `npm run release` before merging.
## Commit & Pull Request Guidelines
Recent commits use short, imperative messages with optional conventional prefixes, for example `feat: prev/next frame buttons flanking play` and `fix: video won't seek`. Prefer `feat:`, `fix:`, or another clear scope when applicable. Pull requests should describe the user-visible change, note verification commands run, link related issues, and include screenshots or short screen recordings for UI changes.
## Security & Configuration Tips
Do not commit large local media, compiled assets, logs, cache directories, or secrets. Keep runtime data in `resources/public/` only when it is safe to serve directly to the browser.

View file

@ -35,6 +35,10 @@
(defn fetch-projects! []
(-> (GET "/api/projects/") (.then #(rf/dispatch [:tl.events/set-projects %]))))
(defn fetch-project-detail! [id]
(-> (GET (str "/api/projects/" id "/"))
(.then #(rf/dispatch [:tl.events/project-detail-refreshed %]))))
(defn load-project!
"Detail → OTIO file → scene, then hand it all to ::project-ready in one shot."
[id]

View file

@ -13,11 +13,15 @@
(rf/reg-fx :fetch-me (fn [_] (api/fetch-me!)))
(rf/reg-fx :fetch-projects (fn [_] (api/fetch-projects!)))
(rf/reg-fx :load-project (fn [id] (api/load-project! id)))
(rf/reg-fx :refresh-project-detail (fn [id] (api/fetch-project-detail! id)))
(rf/reg-fx :put-scene (fn [{:keys [id changed deleted]}] (api/put-scene! id changed deleted)))
(rf/reg-fx :create-project (fn [{:keys [name otio clip]}] (api/create-project! name otio clip)))
(rf/reg-fx :goto (fn [hash] (set! (.. js/window -location -hash) hash)))
(rf/reg-fx :login (fn [{:keys [username password]}] (api/login! username password)))
(rf/reg-fx :logout (fn [_] (api/logout!)))
(rf/reg-fx :poll-thumbnails
(fn [id]
(js/setTimeout #(rf/dispatch [::refresh-project-detail id]) 1500)))
;; --- auth + routing -------------------------------------------------------
@ -42,18 +46,41 @@
{:db (-> db (assoc :page :editor) (assoc-in [:load :status] :loading))
:load-project id}))
(rf/reg-event-db
(defn- thumbnails-pending? [project]
(contains? #{"pending" "running"} (:thumbnail_status project)))
(rf/reg-event-fx
::project-ready
(fn [db [_ detail otio-raw scene-resp]]
(fn [{:keys [db]} [_ detail otio-raw scene-resp]]
(let [parsed (otio/parse otio-raw)
base (scene/from-otio parsed)
anns (scene/restore-annotations (get-in scene-resp [:scene :groups] {}))]
(-> db
(assoc :project detail :page :editor)
(assoc :fps (or (:fps scene-resp) (:fps parsed)))
(assoc :scene (update base :groups merge anns))
(assoc :view (:view db/default-db)) ; fresh stack/playheads per project
(assoc-in [:load :status] :ready)))))
anns (scene/restore-annotations (get-in scene-resp [:scene :groups] {}))
next-db (-> db
(assoc :project detail :page :editor)
(assoc :fps (or (:fps scene-resp) (:fps parsed)))
(assoc :scene (update base :groups merge anns))
(assoc :view (:view db/default-db))
(assoc-in [:load :status] :ready))]
(cond-> {:db next-db}
(thumbnails-pending? detail) (assoc :poll-thumbnails (:id detail))))))
(rf/reg-event-fx
::refresh-project-detail
(fn [{:keys [db]} _]
(if-let [id (get-in db [:project :id])]
{:refresh-project-detail id}
{})))
(rf/reg-event-fx
::project-detail-refreshed
(fn [{:keys [db]} [_ detail]]
(let [current-id (get-in db [:project :id])
next-db (if (= current-id (:id detail))
(assoc db :project detail)
db)]
(cond-> {:db next-db}
(and (= current-id (:id detail)) (thumbnails-pending? detail))
(assoc :poll-thumbnails (:id detail))))))
;; --- create project -------------------------------------------------------

View file

@ -11,6 +11,8 @@
(rf/reg-sub ::projects (fn [db] (:projects db)))
(rf/reg-sub ::project (fn [db] (:project db)))
(rf/reg-sub ::clip-url (fn [db] (get-in db [:project :clip])))
(rf/reg-sub ::thumbnails (fn [db] (get-in db [:project :thumbnails])))
(rf/reg-sub ::thumbnail-status (fn [db] (get-in db [:project :thumbnail_status])))
(rf/reg-sub ::create-error (fn [db] (:create-error db)))
(rf/reg-sub ::login-error (fn [db] (:login-error db)))
(rf/reg-sub ::authed? (fn [db] (boolean (get-in db [:auth :authenticated]))))

View file

@ -14,6 +14,23 @@
(defn- px [local fps zoom] (* (/ local fps) zoom))
(defn- clip-thumbnail-layer [thumbs fps zoom row-h {:keys [mark]}]
(when-let [tiles (seq (get-in thumbs [:clips mark]))]
(let [clip-w (px (-> tiles last :end) fps zoom)
tile-w (max 1 (* (- row-h 2) (/ 16 9)))
n (min (count tiles) (max 1 (js/Math.ceil (/ clip-w tile-w))))]
(into [:<>]
(for [i (range n)
:let [left (* i tile-w)
width (min tile-w (- clip-w left))
url (:url (nth tiles i))]
:when (pos? width)]
^{:key i}
[:div.clip-thumb
{:style {:left left
:width width
:background-image (str "url(" url ")")}}])))))
;; --- playback: the VIDEO is the clock ------------------------------------
;; Play it; a rAF reads currentTime and maps it into the current context's local
;; timeline (::tick). Moving the playhead (jump/scrub) just seeks the video.
@ -25,12 +42,22 @@
(defn- seg-at
"Index of the segment whose LOCAL range contains `local` (0 if none)."
[segs local]
(or (first (keep-indexed (fn [i {[c d] :local}] (when (<= c local d) i)) segs)) 0))
(or (first (keep-indexed (fn [i {[c d] :local}]
(when (or (and (<= c local) (< local d))
(< local c))
i))
segs))
(max 0 (dec (count segs)))))
(defn- seg-at-src
"Index of the segment whose SOURCE range contains source frame `sf` (0 if none)."
[segs sf]
(or (first (keep-indexed (fn [i {[a b] :src}] (when (<= a sf b) i)) segs)) 0))
(or (first (keep-indexed (fn [i {[a b] :src}]
(when (or (and (<= a sf) (< sf b))
(< sf a))
i))
segs))
(max 0 (dec (count segs)))))
;; The <video> element is the source of truth for play/pause. Its :on-play /
;; :on-pause (below) engage/disengage this segment-aware tick, so the native
@ -109,7 +136,7 @@
segs @(rf/subscribe [::subs/segments])
scene @(rf/subscribe [::subs/scene])
auth? (some? @(rf/subscribe [::subs/draft-group]))
seg (some (fn [{[c d] :local :as s}] (when (<= c ph d) s)) segs)
seg (some (fn [{[c d] :local :as s}] (when (and (<= c ph) (< ph d)) s)) segs)
cf (when seg (js/Math.round (- ph (first (:local seg)))))]
[:div.frame-readout
[:span.fr-abs (str (js/Math.round ph) "f")]
@ -149,6 +176,7 @@
segs @(rf/subscribe [::subs/segments])
tracks @(rf/subscribe [::subs/tracks])
anns @(rf/subscribe [::subs/annotations])
thumbs @(rf/subscribe [::subs/thumbnails])
playhead @(rf/subscribe [::subs/playhead])
playing? @(rf/subscribe [::subs/playing?])
authoring? (some? @(rf/subscribe [::subs/draft-group]))
@ -198,7 +226,8 @@
:style {:left (px c fps zoom) :width (max 1 (px (- d c) fps zoom))
:top (+ lane-h (* (get track-y track 0) row-h)) :height (- row-h 2)
:line-height (str (- row-h 2) "px") :position "absolute"}}
(scene/clip-name scene mark)]))]]]))))
[clip-thumbnail-layer thumbs fps zoom row-h seg]
[:div.clip-name (scene/clip-name scene mark)]]))]]]))))
;; --- annotation list (commentary) + authoring ----------------------------
@ -350,14 +379,21 @@
(defn editor []
(let [status @(rf/subscribe [::subs/status])
draft? (some? @(rf/subscribe [::subs/draft-group]))
proj @(rf/subscribe [::subs/project])]
proj @(rf/subscribe [::subs/project])
thumb-status @(rf/subscribe [::subs/thumbnail-status])]
[:div.app
[:div.top
[:div.video-pane [video-monitor] [frame-readout]]
[:div.annot-pane (if draft? [annotation-form] [commentary])]]
[:div.divider-h {:on-pointer-down drag-top!}]
[:div.timeline-pane
[:div.project-bar [:a.home-link {:href "#/"} "← projects"] [:span.project-name (:name proj)]]
[:div.project-bar
[:a.home-link {:href "#/"} "← projects"]
[:span.project-name (:name proj)]
(when (#{"pending" "running"} thumb-status)
[:span.muted "thumbnails processing"])
(when (= "failed" thumb-status)
[:span.err "thumbnail generation failed"])]
[breadcrumbs]
[toolbar]
[:div.timeline-scroll