← Files 中文口播智能粗剪ARCHIVED FILE

skills/rough-cut/scripts/apply_visual_filters.py

11.7 KB · Oct 5, 2026 · 18:30 UTC

↓ Download file

#!/usr/bin/env python3
"""渲染通用口播的程序化视觉状态。

已支持:
- story-portal:故事/回忆入口的柔和暗角,短淡入淡出;
- quote-simulation:引语/角色模拟的克制色调区分、轻暗角和可选文字标签。

输入既可为编译后的 visual_plan.json,也可为含 events 的总后期计划。
不覆盖输入视频;默认输出 preview_视觉滤镜.mp4。
"""

import argparse
import json
import math
import os
import subprocess
import sys
import tempfile
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont
from runtime_support import FFMPEG, FFPROBE, encoder_attempts


SUPPORTED = {"story-portal", "quote-simulation"}
QUOTE_PRESETS = {
    "neutral": {
        "saturation": 0.84,
        "contrast": 1.04,
        "brightness": -0.008,
        "mixer": None,
        "vignette_angle": "PI/9",
    },
    "cool": {
        "saturation": 0.76,
        "contrast": 1.06,
        "brightness": -0.012,
        "mixer": "rr=.95:gg=.99:bb=1.06",
        "vignette_angle": "PI/9",
    },
    "warm": {
        "saturation": 0.82,
        "contrast": 1.05,
        "brightness": -0.008,
        "mixer": "rr=1.05:gg=1.01:bb=.95",
        "vignette_angle": "PI/10",
    },
    "memory": {
        "saturation": 0.68,
        "contrast": 1.04,
        "brightness": -0.006,
        "mixer": "rr=1.04:gg=1.01:bb=.96",
        "vignette_angle": "PI/8",
    },
}


def run(cmd):
    return subprocess.run(cmd, capture_output=True, text=True)


def probe(video):
    r = run([
        FFPROBE, "-v", "error", "-select_streams", "v:0",
        "-show_entries", "stream=width,height,r_frame_rate:format=duration",
        "-of", "json", str(video),
    ])
    if r.returncode:
        sys.exit(f"ffprobe 失败:{r.stderr[-800:]}")
    data = json.loads(r.stdout)
    stream = data["streams"][0]
    rate = stream.get("r_frame_rate", "30/1")
    num, den = rate.split("/")
    fps = float(num) / max(float(den), 1.0)
    return {
        "width": int(stream["width"]),
        "height": int(stream["height"]),
        "fps": fps,
        "duration": float(data["format"]["duration"]),
    }


def package_font():
    skill_root = Path(__file__).resolve().parents[1]
    candidates = [
        skill_root / "assets/fonts/SourceHanSerifSC-Heavy.otf",
        Path("/System/Library/Fonts/PingFang.ttc"),
        Path("/System/Library/Fonts/STHeiti Medium.ttc"),
    ]
    for path in candidates:
        if path.is_file():
            return path
    return None


def rgba(hex_color, alpha=255):
    value = str(hex_color).lstrip("#")
    if len(value) == 6:
        return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4)) + (alpha,)
    if len(value) == 8:
        return tuple(int(value[i:i + 2], 16) for i in (0, 2, 4, 6))
    return (255, 255, 255, alpha)


def render_quote_label(text, width, height, out, plan):
    canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(canvas)
    font_path = Path(plan.get("font") or package_font() or "")
    size = int(width * float(plan.get("label_size", 0.045)))
    if font_path.is_file():
        font = ImageFont.truetype(str(font_path), size)
    else:
        font = ImageFont.load_default()
    bbox = draw.textbbox((0, 0), text, font=font)
    tw = bbox[2] - bbox[0]
    th = bbox[3] - bbox[1]
    pad_x = int(width * 0.028)
    pad_y = int(height * 0.010)
    box_w = tw + pad_x * 2
    box_h = th + pad_y * 2
    side = plan.get("label_side", "left")
    x = int(width * (0.08 if side == "left" else 0.92)) - (0 if side == "left" else box_w)
    y = int(height * float(plan.get("label_y", 0.55)))
    bg = rgba(plan.get("label_box_color", "#111827"), 174)
    accent = rgba(plan.get("label_accent_color", "#39D8D0"), 255)
    color = rgba(plan.get("label_color", "#FFFFFF"), 255)
    radius = int(width * 0.018)
    draw.rounded_rectangle((x, y, x + box_w, y + box_h), radius=radius, fill=bg)
    bar = max(5, int(width * 0.007))
    draw.rounded_rectangle((x, y, x + bar, y + box_h), radius=bar // 2, fill=accent)
    tx = x + pad_x
    ty = y + pad_y - bbox[1]
    shadow = max(2, int(width * 0.002))
    draw.text((tx + shadow, ty + shadow), text, font=font, fill=(0, 0, 0, 180))
    draw.text((tx, ty), text, font=font, fill=color)
    canvas.save(out)


def weight_expr(start, end, fade):
    duration = end - start
    fade = min(max(0.04, fade), max(0.04, duration / 2))
    # blend 的 T 是秒;入口、稳定、出口组成一个平滑三段权重。
    return (
        f"between(T,{start:.4f},{end:.4f})"
        f"*min(1,max(0,(T-{start:.4f})/{fade:.4f}))"
        f"*min(1,max(0,({end:.4f}-T)/{fade:.4f}))"
    )


def clip_events(events, offset, duration):
    clipped = []
    window_end = offset + duration
    for raw in events:
        if raw.get("module") not in SUPPORTED:
            continue
        if "start" not in raw or "end" not in raw:
            continue
        start = float(raw["start"])
        end = float(raw["end"])
        if end <= offset or start >= window_end:
            continue
        ev = dict(raw)
        ev["start"] = max(start, offset) - offset
        ev["end"] = min(end, window_end) - offset
        if ev["end"] - ev["start"] >= 0.08:
            clipped.append(ev)
    clipped.sort(key=lambda x: (float(x["start"]), float(x["end"])))
    return clipped


def build_filter(events):
    parts = ["[0:v]setpts=PTS-STARTPTS,format=yuv420p[v0]"]
    current = "v0"
    label_events = []
    for index, ev in enumerate(events, start=1):
        module = ev["module"]
        visual = ev.get("visual") or {}
        start = float(ev["start"])
        end = float(ev["end"])
        fade = float(visual.get("fade", 0.18))
        weight = weight_expr(start, end, fade)
        base = f"vb{index}"
        fx = f"vf{index}"
        done = f"vd{index}"
        parts.append(f"[{current}]split=2[{base}][{fx}]")

        if module == "story-portal":
            strength = min(0.45, max(0.12, float(visual.get("strength", 0.28))))
            # vignette 的 angle 越大越明显;按规范限制在克制范围。
            angle = math.pi * (0.105 + strength * 0.19)
            parts.append(
                f"[{fx}]vignette=angle={angle:.5f}:eval=frame:mode=forward[{fx}x]")
        else:
            preset_name = visual.get("preset", "neutral")
            preset = QUOTE_PRESETS.get(preset_name, QUOTE_PRESETS["neutral"])
            saturation = float(visual.get("saturation", preset["saturation"]))
            contrast = float(visual.get("contrast", preset["contrast"]))
            brightness = float(visual.get("brightness", preset["brightness"]))
            chain = (
                f"eq=saturation={saturation:.4f}:"
                f"contrast={contrast:.4f}:brightness={brightness:.4f}"
            )
            mixer = visual.get("mixer", preset["mixer"])
            if mixer:
                chain += f",colorchannelmixer={mixer}"
            if visual.get("vignette", True):
                chain += (
                    f",vignette=angle={preset['vignette_angle']}:"
                    "eval=frame:mode=forward"
                )
            parts.append(f"[{fx}]{chain}[{fx}x]")
            if visual.get("label", True):
                label_events.append((ev, visual))

        # A 为原画面,B 为效果画面,按时间权重混合。
        parts.append(
            f"[{base}][{fx}x]blend=all_expr="
            f"'A*(1-({weight}))+B*({weight})':shortest=1[{done}]"
        )
        current = done
    parts.append(f"[{current}]null[vfiltered]")
    return ";".join(parts), label_events


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("video")
    ap.add_argument("plan")
    ap.add_argument("--out", required=True)
    ap.add_argument("--name", default="preview_视觉滤镜.mp4")
    ap.add_argument("--encoder", default="auto",
                    choices=["auto", "nvenc", "videotoolbox", "x264"])
    ap.add_argument("--preview-start", type=float, default=0.0,
                    help="只渲染短预览时的原视频起点;正式渲染保持 0")
    ap.add_argument("--preview-duration", type=float,
                    help="只渲染指定长度,供效果 QA;正式渲染省略")
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    video = Path(args.video).resolve()
    plan_path = Path(args.plan).resolve()
    out_dir = Path(args.out).resolve()
    out_dir.mkdir(parents=True, exist_ok=True)
    info = probe(video)
    source_plan = json.loads(plan_path.read_text(encoding="utf-8"))
    offset = max(0.0, args.preview_start)
    duration = min(
        args.preview_duration or (info["duration"] - offset),
        info["duration"] - offset,
    )
    if duration <= 0:
        sys.exit("预览时间范围无效")
    events = clip_events(source_plan.get("events", []), offset, duration)
    if not events:
        sys.exit("计划中没有落在当前时间范围内的 story-portal/quote-simulation 事件")

    filter_graph, label_events = build_filter(events)
    output = out_dir / args.name
    report = {
        "input": str(video),
        "plan": str(plan_path),
        "output": str(output),
        "preview_start": offset,
        "duration": duration,
        "events": events,
        "status": "dry-run" if args.dry_run else "rendered",
    }
    (out_dir / "visual_effects_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    if args.dry_run:
        print(f"✓ 视觉计划可执行:{len(events)} 个事件")
        print(f"✓ 检查报告:{out_dir / 'visual_effects_report.json'}")
        return

    with tempfile.TemporaryDirectory(prefix="rough-cut-visual-") as temp:
        temp = Path(temp)
        image_inputs = []
        overlays = []
        current = "vfiltered"
        for label_index, (ev, visual) in enumerate(label_events, start=1):
            label_path = temp / f"quote_label_{label_index:02d}.png"
            label_text = str(visual.get("label_text", "引用内容"))
            render_quote_label(
                label_text, info["width"], info["height"], label_path, visual
            )
            image_inputs += ["-loop", "1", "-framerate", f"{info['fps']:.3f}", "-i", str(label_path)]
            out_label = f"vl{label_index}"
            input_index = label_index
            overlays.append(
                f"[{current}][{input_index}:v]overlay=0:0:"
                f"enable='between(t,{float(ev['start']):.4f},{float(ev['end']):.4f})'"
                f"[{out_label}]"
            )
            current = out_label
        if overlays:
            filter_graph += ";" + ";".join(overlays)
        filter_graph += f";[{current}]null[vout]"

        base = [FFMPEG, "-y", "-loglevel", "error"]
        if offset > 0:
            base += ["-ss", f"{offset:.4f}"]
        base += ["-t", f"{duration:.4f}", "-i", str(video)]
        base += image_inputs
        common = [
            "-filter_complex", filter_graph,
            "-map", "[vout]", "-map", "0:a?",
            "-t", f"{duration:.4f}",
            "-c:a", "copy", "-movflags", "+faststart",
            str(output),
        ]
        result = None
        used = None
        for encoder_label, enc_args in encoder_attempts(args.encoder):
            result = run(base + enc_args + common)
            if result.returncode == 0:
                used = encoder_label
                break
            if args.encoder == "auto":
                print(f"{encoder_label} 编码失败,尝试下一编码器…", file=sys.stderr)
        if result.returncode:
            sys.exit(f"视觉滤镜渲染失败:\n{result.stderr[-1600:]}")

    print(f"✓ 视觉滤镜成片({used}):{output}")
    print(f"✓ 已渲染事件:{len(events)}(故事暗角/引语滤镜)")


if __name__ == "__main__":
    main()

SHA-256: 912346d97a32f15ce023bb4ebc0040bc57c1363586d579d8c3ccd8ad343ba3e2