Загрузка данных


#!/usr/bin/env python3
"""
Автомонтаж через ffmpeg.

Шаг 1 (вырезать паузы):
    python auto_edit.py cut input.mp4 clean.mp4

Шаг 2 (музыка, текст, картинки по таймкодам из edits.json):
    python auto_edit.py compose clean.mp4 edits.json final.mp4

Нужны: Python 3 и ffmpeg (версия 4.4 или новее), оба в PATH.
Таймкоды в edits.json считаются по видео clean.mp4 (уже без пауз).
"""
import json
import re
import subprocess
import sys


def run(cmd):
    print(" ".join(cmd))
    subprocess.run(cmd, check=True)


def duration(path):
    out = subprocess.check_output([
        "ffprobe", "-v", "error", "-show_entries", "format=duration",
        "-of", "default=nw=1:nk=1", path])
    return float(out.strip())


def cut_silence(src, dst, noise_db=-35, min_silence=0.6, pad=0.15):
    """Убирает тишину. Если режет речь - поднимите noise_db (например -30)
    или увеличьте min_silence."""
    p = subprocess.run(
        ["ffmpeg", "-i", src, "-af",
         f"silencedetect=noise={noise_db}dB:d={min_silence}",
         "-f", "null", "-"],
        capture_output=True, text=True)
    starts = [float(x) for x in re.findall(r"silence_start: (-?[\d.]+)", p.stderr)]
    ends = [float(x) for x in re.findall(r"silence_end: ([\d.]+)", p.stderr)]
    total = duration(src)

    keep, cur = [], 0.0
    for s, e in zip(starts, ends):
        s = max(s, 0.0)
        if s - cur > 0.2:
            keep.append((cur, s + pad))
        cur = max(e - pad, 0.0)
    if len(starts) > len(ends):  # тишина до самого конца файла
        if starts[-1] - cur > 0.2:
            keep.append((cur, starts[-1] + pad))
    elif total - cur > 0.2:
        keep.append((cur, total))

    if not keep:
        sys.exit("Не нашёл речи. Уменьшите noise_db (например -45).")

    parts = []
    for i, (a, b) in enumerate(keep):
        parts.append(
            f"[0:v]trim={a:.3f}:{b:.3f},setpts=PTS-STARTPTS[v{i}];"
            f"[0:a]atrim={a:.3f}:{b:.3f},asetpts=PTS-STARTPTS[a{i}];")
    labels = "".join(f"[v{i}][a{i}]" for i in range(len(keep)))
    graph = "".join(parts) + f"{labels}concat=n={len(keep)}:v=1:a=1[v][a]"
    with open("_cut_filter.txt", "w", encoding="utf-8") as f:
        f.write(graph)

    run(["ffmpeg", "-y", "-i", src, "-filter_complex_script", "_cut_filter.txt",
         "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "fast",
         "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", dst])
    print(f"Готово: {dst} (было {total:.0f} с, оставлено фрагментов: {len(keep)})")


def esc_text(t):
    return (t.replace("\\", "\\\\").replace("'", "\u2019")
             .replace(":", "\\:").replace("%", "\\%"))


def compose(src, cfg_path, dst):
    with open(cfg_path, encoding="utf-8") as f:
        cfg = json.load(f)
    music = cfg.get("music", [])
    images = cfg.get("images", [])
    texts = cfg.get("texts", [])
    font = cfg.get("font", "")
    font = font.replace("\\", "/").replace(":", "\\:")

    inputs = ["-i", src]
    idx = 1
    music_idx, image_idx = [], []
    for m in music:
        inputs += ["-stream_loop", "-1", "-i", m["file"]]
        music_idx.append(idx)
        idx += 1
    for im in images:
        inputs += ["-i", im["file"]]
        image_idx.append(idx)
        idx += 1

    g = []
    # --- звук ---
    if music:
        mix = ["[0:a]"]
        for k, (m, i) in enumerate(zip(music, music_idx)):
            s, e = float(m["start"]), float(m["end"])
            vol = float(m.get("volume", 0.15))
            fade = min(1.5, (e - s) / 3)
            g.append(
                f"[{i}:a]atrim=0:{e - s:.3f},asetpts=PTS-STARTPTS,"
                f"volume={vol},afade=t=in:d={fade:.2f},"
                f"afade=t=out:st={e - s - fade:.3f}:d={fade:.2f},"
                f"adelay={int(s * 1000)}|{int(s * 1000)}[m{k}]")
            mix.append(f"[m{k}]")
        g.append("".join(mix) +
                 f"amix=inputs={len(mix)}:duration=first:normalize=0[aout]")
    else:
        g.append("[0:a]anull[aout]")

    # --- видео ---
    cur = "0:v"
    for j, (im, i) in enumerate(zip(images, image_idx)):
        w = im.get("width", 500)
        x = im.get("x", "(W-w)/2")
        y = im.get("y", "(H-h)/2")
        g.append(f"[{i}:v]scale={w}:-1[im{j}]")
        g.append(f"[{cur}][im{j}]overlay=x={x}:y={y}:"
                 f"enable='between(t,{im['start']},{im['end']})'[vi{j}]")
        cur = f"vi{j}"
    for j, t in enumerate(texts):
        ff = f"fontfile='{font}':" if font else ""
        g.append(
            f"[{cur}]drawtext={ff}text='{esc_text(t['text'])}':"
            f"fontsize={t.get('size', 64)}:fontcolor=white:"
            f"borderw=4:bordercolor=black:x=(w-text_w)/2:"
            f"y={t.get('y', 'h*0.8')}:"
            f"enable='between(t,{t['start']},{t['end']})'[vt{j}]")
        cur = f"vt{j}"
    g.append(f"[{cur}]copy[vout]")

    with open("_compose_filter.txt", "w", encoding="utf-8") as f:
        f.write(";\n".join(g))

    run(["ffmpeg", "-y"] + inputs + [
        "-filter_complex_script", "_compose_filter.txt",
        "-map", "[vout]", "-map", "[aout]",
        "-c:v", "libx264", "-preset", "fast", "-crf", "20",
        "-pix_fmt", "yuv420p", "-c:a", "aac", dst])
    print(f"Готово: {dst}")


if __name__ == "__main__":
    if len(sys.argv) == 4 and sys.argv[1] == "cut":
        cut_silence(sys.argv[2], sys.argv[3])
    elif len(sys.argv) == 5 and sys.argv[1] == "compose":
        compose(sys.argv[2], sys.argv[3], sys.argv[4])
    else:
        print(__doc__)