thumbnails
This commit is contained in:
parent
5861113aeb
commit
caa2aa6b9b
10 changed files with 403 additions and 19 deletions
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
228
scenes/thumbnails.py
Normal 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={},
|
||||
)
|
||||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue