feat(playback): pre-transcode HEVC videos in background; veryfast preset
Long videos blocked /playback for the entire encode duration. The fix is to populate the cache before the user clicks, not when they click. Changes: - Extract ffprobe + ffmpeg helpers to services/video.py so the request handler and the background task share one sync implementation. The endpoint wraps calls in asyncio.to_thread; celery just calls them. - New tasks/video.py with pretranscode_video. Idempotent: skips when the cache is already current and skips passthrough-safe sources (h264 in mp4/m4v/webm). 30-min task time limit so the long-tail files (3GP archive, multi-minute 1080p clips) still complete. - scan_folder now dispatches pretranscode_video alongside generate_thumbnails / extract_metadata for any new video row. - POST /library/maintenance/backfill-video-cache enqueues every active video so the existing library catches up. - libx264 preset bumped from fast to veryfast. ~2x throughput on this CPU-only box, output a few % larger but well within disk budget. - /playback simplifies to: cache check, passthrough if h264 in web-safe container, else sync transcode (still there as fallback for races against the queued task). Once the backfill task drains, /playback should be near-instant for every video. Any video added afterwards is pre-transcoded at scan time, so the user keeps that property going forward.
This commit is contained in:
62
backend/app/tasks/video.py
Normal file
62
backend/app/tasks/video.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Background pre-transcode for video photos so /playback is a cache
|
||||
hit on first user click. Dispatched from scan_folder when a new video
|
||||
row is created, and from the backfill admin endpoint for the existing
|
||||
library."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from app.services.video import (
|
||||
PLAYBACK_OK_EXTS,
|
||||
PLAYBACK_OK_VCODECS,
|
||||
cache_path_for,
|
||||
ffprobe_video_codec,
|
||||
transcode_to_h264_mp4,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(
|
||||
name='pretranscode_video',
|
||||
# Override the 10-minute global task_time_limit. Long videos (30+ min
|
||||
# raw clips, the occasional .3gp from the 2016 archive) can legitimately
|
||||
# take half an hour to transcode on this CPU-only box.
|
||||
time_limit=1800,
|
||||
soft_time_limit=1740,
|
||||
)
|
||||
def pretranscode_video(photo_id: str, src_path: str):
|
||||
"""Idempotent: skip if cache exists and is newer than source, skip
|
||||
if source is already a passthrough-safe codec/container.
|
||||
|
||||
Reports the outcome as a status string so the admin backfill can
|
||||
summarise. The /playback endpoint also falls back to a sync
|
||||
transcode if the cache miss races a queued task."""
|
||||
if not os.path.exists(src_path):
|
||||
logger.debug("pretranscode skip — source missing: %s", src_path)
|
||||
return {'status': 'missing', 'photo_id': photo_id}
|
||||
|
||||
cache_path = cache_path_for(photo_id)
|
||||
if cache_path.exists():
|
||||
try:
|
||||
if os.path.getmtime(src_path) <= os.path.getmtime(cache_path):
|
||||
return {'status': 'cached', 'photo_id': photo_id}
|
||||
cache_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
ext = os.path.splitext(src_path)[1].lower()
|
||||
if ext in PLAYBACK_OK_EXTS:
|
||||
codec = ffprobe_video_codec(src_path)
|
||||
if codec in PLAYBACK_OK_VCODECS:
|
||||
return {'status': 'passthrough', 'photo_id': photo_id, 'codec': codec}
|
||||
|
||||
ok = transcode_to_h264_mp4(src_path, str(cache_path))
|
||||
return {
|
||||
'status': 'transcoded' if ok else 'failed',
|
||||
'photo_id': photo_id,
|
||||
}
|
||||
Reference in New Issue
Block a user