parlorsky's picture
Upload ComfyUI-ShotSplitter/cutter.py with huggingface_hub
10ef49e verified
Raw
History Blame Contribute Delete
3.34 kB
"""Frame-accurate H.264+AAC shot cutter via parallel FFmpeg subprocess pool."""
import logging
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
FFMPEG_PARALLELISM = 4
@dataclass
class CutResult:
index: int
path: str
start_s: float
end_s: float
success: bool
error: Optional[str] = None
def cut_shots(
source_path: str,
intervals: List[Tuple[int, int]],
fps: float,
output_dir: str,
filename_prefix: str,
crf: int,
preset: str,
keep_audio: bool,
has_audio: bool,
vfr: bool,
) -> List[CutResult]:
"""Cut `intervals` (frame indices, half-open [start,end)) into individual mp4s.
Uses one ffmpeg subprocess per shot, in parallel up to FFMPEG_PARALLELISM workers.
Returns a list of CutResult in the same order as `intervals`.
"""
os.makedirs(output_dir, exist_ok=True)
pad = max(3, len(str(len(intervals))))
jobs: List[Tuple[int, str, float, float]] = []
for i, (start_f, end_f) in enumerate(intervals):
start_s = start_f / fps
duration_s = max((end_f - start_f) / fps, 1.0 / fps)
out_name = f"{filename_prefix}_{str(i + 1).zfill(pad)}.mp4"
out_path = os.path.join(output_dir, out_name)
jobs.append((i, out_path, start_s, duration_s))
results: List[Optional[CutResult]] = [None] * len(jobs)
def _run_one(idx: int, out_path: str, start_s: float, duration_s: float) -> CutResult:
cmd = _build_cmd(
source_path,
out_path,
start_s,
duration_s,
crf,
preset,
keep_audio and has_audio,
vfr,
fps,
)
try:
subprocess.run(cmd, check=True, capture_output=True, text=True)
return CutResult(idx, out_path, start_s, start_s + duration_s, True, None)
except subprocess.CalledProcessError as e:
logger.error("ffmpeg failed for shot %d: %s", idx, e.stderr)
return CutResult(idx, out_path, start_s, start_s + duration_s, False, e.stderr)
with ThreadPoolExecutor(max_workers=FFMPEG_PARALLELISM) as ex:
futures = {ex.submit(_run_one, *job): job[0] for job in jobs}
for fut in as_completed(futures):
idx = futures[fut]
results[idx] = fut.result()
return [r for r in results if r is not None]
def _build_cmd(
source: str,
out: str,
start_s: float,
duration_s: float,
crf: int,
preset: str,
audio: bool,
vfr: bool,
fps: float,
) -> List[str]:
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel", "error",
"-ss", f"{start_s:.6f}",
"-i", source,
"-t", f"{duration_s:.6f}",
"-map", "0:v:0",
]
if audio:
cmd += ["-map", "0:a:0?"]
if vfr:
cmd += ["-vsync", "cfr", "-r", f"{fps:.6f}"]
cmd += [
"-c:v", "libx264",
"-preset", preset,
"-crf", str(crf),
"-pix_fmt", "yuv420p",
]
if audio:
cmd += ["-c:a", "aac", "-b:a", "192k"]
else:
cmd += ["-an"]
cmd += [
"-avoid_negative_ts", "make_zero",
out,
]
return cmd