VideoFlow / bernini_continuation.py
jmpplbp's picture
Current release
7e8f83f
Raw History Blame Contribute Delete
7.28 kB
from __future__ import annotations
import base64
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any
_DEADLINE = None
VIDEO_SUFFIXES = {'.mp4', '.mov', '.mkv', '.webm', '.avi', '.m4v'}
def _safe_name(value: object, fallback: str) -> str:
name = str(value or fallback).replace('\\', '/').split('/')[-1]
name = re.sub(r'[^A-Za-z0-9._-]+', '_', name).strip('._')
return (name or fallback)[:160]
def _decode(value: object, label: str) -> bytes:
encoded = str(value or '').strip()
if encoded.startswith('data:') and ',' in encoded:
encoded = encoded.split(',', 1)[1]
encoded = re.sub(r'\s+', '', encoded).replace('-', '+').replace('_', '/')
encoded += '=' * ((4 - len(encoded) % 4) % 4)
try:
data = base64.b64decode(encoded, validate=True)
except Exception as exc:
raise ValueError(f'{label} is not valid base64') from exc
if not data:
raise ValueError(f'{label} is empty')
return data
def _run(command: list[str], label: str, timeout: int) -> None:
if _DEADLINE is not None: timeout = _DEADLINE.remaining(timeout)
completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout, check=False)
if completed.returncode:
detail = (completed.stderr or completed.stdout or '').strip()[-2000:]
raise RuntimeError(f'{label} failed: {detail}')
def validate_continuation(value: object) -> dict[str, Any] | None:
if value is None or value is False:
return None
if not isinstance(value, dict):
raise ValueError('continuation must be an object')
if value.get('enabled') is False:
return None
source_name = _safe_name(value.get('source_video_name'), 'bernini_source_video.mp4')
frame_name = _safe_name(value.get('last_frame_name'), 'bernini_continuation_last_frame.png')
if Path(source_name).suffix.lower() not in VIDEO_SUFFIXES:
raise ValueError('continuation source must be a video file')
if Path(frame_name).suffix.lower() != '.png':
raise ValueError('continuation last frame must be a PNG file')
try:
width = int(value.get('width'))
height = int(value.get('height'))
fps = int(value.get('fps', 20))
drop = int(value.get('drop_generated_frames', 2))
target_value = value.get('target_duration_seconds')
target_duration = float(target_value) if target_value is not None else None
except (TypeError, ValueError) as exc:
raise ValueError('invalid continuation settings') from exc
if not 256 <= width <= 2048 or not 256 <= height <= 2048 or width % 2 or height % 2:
raise ValueError('invalid continuation dimensions')
if not 1 <= fps <= 60 or not 1 <= drop <= 10:
raise ValueError('invalid continuation frame settings')
if target_duration is not None and not 1 <= target_duration <= 3600:
raise ValueError('invalid continuation target duration')
return {'source_video_name': source_name, 'last_frame_name': frame_name, 'width': width, 'height': height, 'fps': fps, 'drop_generated_frames': drop, 'target_duration_seconds': target_duration}
def prepare_continuation_assets(payload: dict[str, Any]) -> dict[str, Any] | None:
config = validate_continuation(payload.get('continuation'))
if config is None:
return None
assets = payload.get('assets')
if not isinstance(assets, list):
raise ValueError('continuation assets are missing')
source = next((item for item in assets if isinstance(item, dict) and item.get('name') == config['source_video_name']), None)
if source is None:
raise ValueError('continuation source video was not supplied')
source_bytes = _decode(source.get('data'), config['source_video_name'])
suffix = Path(config['source_video_name']).suffix.lower()
with tempfile.TemporaryDirectory(prefix='video-continuation-frame-') as temp_dir:
root = Path(temp_dir)
source_path = root / ('source' + suffix)
output_path = root / config['last_frame_name']
source_path.write_bytes(source_bytes)
resize = f"scale={config['width']}:{config['height']}:force_original_aspect_ratio=decrease,pad={config['width']}:{config['height']}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
errors = []
for offset in ('-0.1', '-0.5', '-1.0'):
output_path.unlink(missing_ok=True)
try:
_run(
['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-sseof', offset,
'-i', str(source_path), '-map', '0:v:0', '-an', '-frames:v', '1',
'-vf', resize, str(output_path)],
'continuation last-frame extraction', 300,
)
except (RuntimeError, subprocess.TimeoutExpired) as exc:
errors.append(str(exc))
if output_path.is_file() and output_path.stat().st_size > 0:
break
else:
output_path.unlink(missing_ok=True)
try:
_run(
['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-i', str(source_path),
'-map', '0:v:0', '-an', '-vf', resize, '-fps_mode', 'passthrough',
'-update', '1', str(output_path)],
'continuation full-decode fallback', 3600,
)
except (RuntimeError, subprocess.TimeoutExpired) as exc:
errors.append(str(exc))
if not output_path.is_file() or output_path.stat().st_size <= 0:
raise RuntimeError('continuation last-frame extraction produced no image: ' + '; '.join(errors)[-2000:])
assets.append({'name': config['last_frame_name'], 'data': base64.b64encode(output_path.read_bytes()).decode('ascii')})
return config
def merge_continuation_output(source_path: Path, generated_path: Path, output_path: Path, config: dict[str, Any]) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
resize = f"scale={config['width']}:{config['height']}:force_original_aspect_ratio=decrease,pad={config['width']}:{config['height']}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,format=yuv420p"
filters = (
f"[0:v]fps={config['fps']},{resize},setpts=PTS-STARTPTS[source];"
f"[1:v]fps={config['fps']},{resize},trim=start_frame={config['drop_generated_frames']},setpts=PTS-STARTPTS[generated];"
'[source][generated]concat=n=2:v=1:a=0[video]'
)
partial = output_path.with_suffix(output_path.suffix + '.part.mp4')
_run([
'ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-i', str(source_path), '-i', str(generated_path),
'-filter_complex', filters, '-map', '[video]', '-map', '0:a?', '-c:v', 'libx264', '-preset', 'fast',
'-crf', '16', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k',
*(['-t', str(config['target_duration_seconds'])] if config['target_duration_seconds'] is not None else []),
'-movflags', '+faststart', str(partial),
], 'continuation video merge', 3600)
if not partial.is_file() or partial.stat().st_size <= 0:
raise RuntimeError('continuation merge produced no video')
partial.replace(output_path)