Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from bundle
Build or revise a reusable FFmpeg timeline for short-form video editing. Use when the user wants an agent-editable API for trimming clips, cropping, fitting to
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/video_timeline_editor/infrastructure/media.py
1from __future__ import annotations23import json4import shutil5import subprocess6from pathlib import Path78from video_timeline_editor.domain.model import ClipAnalysis, CropBox, FitSpec, MediaInfo, OverlaySpec91011DEFAULT_FONT_CANDIDATES = (12"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",13"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",14)151617def run_command(command: list[str], *, cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:18result = subprocess.run(19command,20cwd=str(cwd) if cwd else None,21capture_output=True,22text=True,23)24if check and result.returncode != 0:25details = "\n".join(part for part in [result.stderr.strip(), result.stdout.strip()] if part)26raise RuntimeError(f"Command failed: {' '.join(command)}\n{details}".rstrip())27return result282930def probe_media(path: Path) -> MediaInfo:31result = run_command(32[33"ffprobe",34"-v",35"error",36"-print_format",37"json",38"-show_format",39"-show_streams",40str(path),41]42)43payload = json.loads(result.stdout)44duration = float(payload["format"]["duration"])45has_audio = any(stream.get("codec_type") == "audio" for stream in payload.get("streams", []))46return MediaInfo(duration=duration, has_audio=has_audio)474849def escape_filter_value(value: str | Path) -> str:50text = str(value)51for src, dst in (52("\\", "\\\\"),53(":", "\\:"),54(",", "\\,"),55("'", "\\'"),56):57text = text.replace(src, dst)58return text596061def quote_concat_path(path: Path) -> str:62return str(path).replace("'", "'\\''")636465def default_fontfile() -> str | None:66for candidate in DEFAULT_FONT_CANDIDATES:67if Path(candidate).exists():68return candidate69return None707172def atempo_filters(rate: float) -> list[str]:73if rate <= 0:74raise ValueError("Audio tempo must be positive")75parts: list[str] = []76remaining = rate77while remaining > 2.0:78parts.append("atempo=2.0")79remaining /= 2.080while remaining < 0.5:81parts.append("atempo=0.5")82remaining /= 0.583parts.append(f"atempo={remaining:.6f}".rstrip("0").rstrip("."))84return parts858687def crop_filters(crop: CropBox) -> list[str]:88return [89"crop="90f"floor(iw*{crop.width}/2)*2:"91f"floor(ih*{crop.height}/2)*2:"92f"floor(iw*{crop.x}/2)*2:"93f"floor(ih*{crop.y}/2)*2"94]959697def anchor_crop(anchor: str, width: int, height: int) -> str:98anchor = anchor.lower()99center_x = f"(iw-{width})/2"100center_y = f"(ih-{height})/2"101x_map = {102"center": center_x,103"top": center_x,104"bottom": center_x,105"left": "0",106"right": f"iw-{width}",107"top_left": "0",108"top_right": f"iw-{width}",109"bottom_left": "0",110"bottom_right": f"iw-{width}",111}112y_map = {113"center": center_y,114"top": "0",115"bottom": f"ih-{height}",116"left": center_y,117"right": center_y,118"top_left": "0",119"top_right": "0",120"bottom_left": f"ih-{height}",121"bottom_right": f"ih-{height}",122}123if anchor not in x_map:124raise ValueError(f"Unsupported cover anchor: {anchor}")125return f"crop={width}:{height}:{x_map[anchor]}:{y_map[anchor]}"126127128def fit_filters(fit: FitSpec, width: int, height: int, project_background: str) -> list[str]:129background = fit.background or project_background130if fit.mode == "contain":131return [132f"scale={width}:{height}:force_original_aspect_ratio=decrease",133f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:{background}",134]135if fit.mode == "cover":136return [137f"scale={width}:{height}:force_original_aspect_ratio=increase",138anchor_crop(fit.anchor, width, height),139]140raise ValueError(f"Unsupported fit mode: {fit.mode}")141142143def overlay_filters(overlays: tuple[OverlaySpec, ...], styles: dict[str, dict], work_dir: Path) -> list[str]:144filters: list[str] = []145text_dir = work_dir / "_overlay_text"146text_dir.mkdir(parents=True, exist_ok=True)147fontfile = default_fontfile()148allowed = (149"fontfile",150"fontsize",151"fontcolor",152"borderw",153"bordercolor",154"box",155"boxcolor",156"boxborderw",157"x",158"y",159"alpha",160)161for index, overlay in enumerate(overlays):162style = dict(styles.get(overlay.style, styles.get("default", {})))163style.update(overlay.options)164if fontfile and "fontfile" not in style:165style["fontfile"] = fontfile166167text_path = text_dir / f"overlay-{index:03d}.txt"168text_path.write_text(overlay.text)169parts = [f"drawtext=textfile={escape_filter_value(text_path)}"]170for key in allowed:171if key not in style:172continue173value = style[key]174if isinstance(value, bool):175value = int(value)176parts.append(f"{key}={escape_filter_value(value)}")177if overlay.start is not None or overlay.end is not None:178start = 0.0 if overlay.start is None else float(overlay.start)179end = overlay.end180if end is None:181parts.append(f"enable={escape_filter_value(f'gte(t,{start})')}")182else:183parts.append(f"enable={escape_filter_value(f'between(t,{start},{end})')}")184filters.append(":".join(parts))185return filters186187188def render_clip_file(189analysis: ClipAnalysis,190*,191width: int,192height: int,193fps: int,194background: str,195overlay_styles: dict[str, dict],196work_dir: Path,197caption_ass_path: Path | None = None,198) -> Path:199clip = analysis.clip200output_path = work_dir / f"{clip.id}.mp4"201video_filters: list[str] = []202audio_filters: list[str] = []203204if clip.crop:205video_filters.extend(crop_filters(clip.crop))206207transform = clip.transform208if transform.hflip:209video_filters.append("hflip")210if transform.vflip:211video_filters.append("vflip")212if transform.zoom != 1.0:213video_filters.append(f"scale=iw*{transform.zoom}:ih*{transform.zoom}")214video_filters.append(f"crop=iw/{transform.zoom}:ih/{transform.zoom}")215if transform.video_pts != 1.0:216video_filters.append(f"setpts={transform.video_pts}*PTS")217if transform.audio_tempo != 1.0:218audio_filters.extend(atempo_filters(transform.audio_tempo))219if transform.eq:220eq_parts = [f"{key}={value}" for key, value in transform.eq.items()]221if eq_parts:222video_filters.append("eq=" + ":".join(eq_parts))223224if clip.speed != 1.0:225video_filters.append(f"setpts={1 / clip.speed:.6f}*PTS")226audio_filters.extend(atempo_filters(clip.speed))227228video_filters.extend(fit_filters(clip.fit, width, height, background))229video_filters.extend(overlay_filters(clip.overlays, overlay_styles, work_dir))230if caption_ass_path:231video_filters.append(f"ass={escape_filter_value(caption_ass_path)}")232233use_silent_audio = clip.muted or not analysis.media.has_audio234command = [235"ffmpeg",236"-y",237"-hide_banner",238"-loglevel",239"error",240"-avoid_negative_ts",241"make_zero",242"-ss",243str(analysis.timing.actual_in),244"-to",245str(analysis.timing.actual_out),246"-i",247str(analysis.source_path),248]249if use_silent_audio:250command.extend(["-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo"])251if video_filters:252command.extend(["-vf", ",".join(video_filters)])253if not use_silent_audio:254if clip.volume != 1.0:255audio_filters.append(f"volume={clip.volume}")256if audio_filters:257command.extend(["-af", ",".join(audio_filters)])258command.extend(259[260"-map",261"0:v:0",262"-map",263"1:a:0" if use_silent_audio else "0:a:0",264"-c:v",265"libx264",266"-pix_fmt",267"yuv420p",268"-r",269str(fps),270"-c:a",271"aac",272"-b:a",273"192k",274"-ac",275"2",276"-ar",277"44100",278"-movflags",279"+faststart",280]281)282if use_silent_audio:283command.append("-shortest")284command.append(str(output_path))285run_command(command)286return output_path287288289def concat_clips(rendered_paths: list[Path], output_path: Path, work_dir: Path) -> None:290concat_path = work_dir / "concat.txt"291concat_path.write_text("".join(f"file '{quote_concat_path(path)}'\n" for path in rendered_paths))292run_command(293[294"ffmpeg",295"-y",296"-hide_banner",297"-loglevel",298"error",299"-f",300"concat",301"-safe",302"0",303"-i",304str(concat_path),305"-c",306"copy",307str(output_path),308]309)310311312def maybe_open(path: Path) -> None:313opener = shutil.which("xdg-open") or shutil.which("open")314if opener:315subprocess.Popen([opener, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)316