From caa2aa6b9b3feef65e6cfaca6187c223b9f34e8e Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 29 Jun 2026 03:19:47 -0400 Subject: [PATCH] thumbnails --- scenes/admin.py | 8 +- ...ror_project_thumbnail_manifest_and_more.py | 28 +++ scenes/models.py | 3 + scenes/thumbnails.py | 228 ++++++++++++++++++ scenes/views.py | 25 +- tl/AGENTS.md | 31 +++ tl/src/tl/api.cljs | 4 + tl/src/tl/events.cljs | 45 +++- tl/src/tl/subs.cljs | 2 + tl/src/tl/views.cljs | 48 +++- 10 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 scenes/migrations/0005_project_thumbnail_error_project_thumbnail_manifest_and_more.py create mode 100644 scenes/thumbnails.py create mode 100644 tl/AGENTS.md diff --git a/scenes/admin.py b/scenes/admin.py index baa7f93..c8c5dfa 100644 --- a/scenes/admin.py +++ b/scenes/admin.py @@ -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") diff --git a/scenes/migrations/0005_project_thumbnail_error_project_thumbnail_manifest_and_more.py b/scenes/migrations/0005_project_thumbnail_error_project_thumbnail_manifest_and_more.py new file mode 100644 index 0000000..c03aded --- /dev/null +++ b/scenes/migrations/0005_project_thumbnail_error_project_thumbnail_manifest_and_more.py @@ -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), + ), + ] diff --git a/scenes/models.py b/scenes/models.py index b3a1c74..219b714 100644 --- a/scenes/models.py +++ b/scenes/models.py @@ -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) diff --git a/scenes/thumbnails.py b/scenes/thumbnails.py new file mode 100644 index 0000000..ac1df76 --- /dev/null +++ b/scenes/thumbnails.py @@ -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={}, + ) diff --git a/scenes/views.py b/scenes/views.py index 2f6dd91..9907760 100644 --- a/scenes/views.py +++ b/scenes/views.py @@ -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"]) diff --git a/tl/AGENTS.md b/tl/AGENTS.md new file mode 100644 index 0000000..6939655 --- /dev/null +++ b/tl/AGENTS.md @@ -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.` 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. diff --git a/tl/src/tl/api.cljs b/tl/src/tl/api.cljs index db09ebc..d94d79e 100644 --- a/tl/src/tl/api.cljs +++ b/tl/src/tl/api.cljs @@ -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] diff --git a/tl/src/tl/events.cljs b/tl/src/tl/events.cljs index c0e31ed..d70b128 100644 --- a/tl/src/tl/events.cljs +++ b/tl/src/tl/events.cljs @@ -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 ------------------------------------------------------- diff --git a/tl/src/tl/subs.cljs b/tl/src/tl/subs.cljs index 50a7c9f..1a48a00 100644 --- a/tl/src/tl/subs.cljs +++ b/tl/src/tl/subs.cljs @@ -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])))) diff --git a/tl/src/tl/views.cljs b/tl/src/tl/views.cljs index d69b2b9..f6b50e2 100644 --- a/tl/src/tl/views.cljs +++ b/tl/src/tl/views.cljs @@ -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