""" composer.de_ai — 去 AI 化处理链(DCS 第八节) ================================================ 目的:消除 AI 生成视频的"塑料感/锐利边缘/过饱和",做出接近真人实拍(伪纪录片)的质感。 处理链(按 DCS 文档顺序): ① gblur 轻微模糊 —— 消除 AI 边缘过度锐利 ② noise 胶片颗粒 —— 添加真实传感器噪点 ③ eq saturation 降饱和 —— 消 AI 味的艳丽 ④ curves S 曲线 —— 拉反差,更像实拍 ⑤ colortemperature 色温 —— 偏移到 5300K(日光偏暖) 默认参数(DCS 默认,可被分镜的 post_process / color 覆盖): 模糊 sigma 0.4 | 颗粒 12 | 饱和 -0.12 | 色温 5300K 用法: from composer.de_ai import DeAIProcessor DeAIProcessor().process("in.mp4", "out.mp4") # 默认参数 DeAIProcessor().process("in.mp4", "out.mp4", blur_sigma=0.6, grain=20) """ from __future__ import annotations import logging import os import shutil import subprocess from pathlib import Path from typing import Optional log = logging.getLogger("composer.de_ai") # FFmpeg 绝对路径(本机装在 ~/.local/bin,不在默认 PATH) _FFMPEG_CANDIDATES = [ os.path.expanduser("~/.local/bin/ffmpeg"), "/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg", "ffmpeg", ] def find_ffmpeg() -> str: for c in _FFMPEG_CANDIDATES: if c == "ffmpeg" or Path(c).exists(): if c == "ffmpeg" and not shutil.which("ffmpeg"): continue return c raise FileNotFoundError("未找到 ffmpeg(请在 ~/.local/bin 或 PATH 中安装)") class DeAIProcessor: """去 AI 化处理器""" # DCS 默认参数 DEFAULT_BLUR_SIGMA = 0.4 DEFAULT_GRAIN = 12 DEFAULT_SATURATION = -0.12 # 偏移量(实际 saturation = 1 + offset = 0.88) DEFAULT_TEMPERATURE = 5300 # 色温 K DEFAULT_CRF = 23 # 画质(未显式指定时按**源码率自适应**,见 _adaptive_crf) DEFAULT_PRESET = "medium" def __init__(self, ffmpeg: str | None = None): self.ffmpeg = ffmpeg or find_ffmpeg() def _adaptive_crf(self, video_path: str) -> int: """按**源码率**自适应 CRF —— 避免对已压缩素材重编码后体积膨胀 实测:规范化后的素材(约 1.5 Mbps)用 CRF 23 重编码 → 体积膨胀到 2.7 倍; 改用 CRF 27 后回到合理区间(视觉几乎无差)。 """ import re import subprocess kbps = 0.0 try: # 用 `ffmpeg -i` 解析(本机 ffprobe 不支持 -select_streams / -show_entries, # 用它取码率会静默失败 → 一律落兜底值) r = subprocess.run([self.ffmpeg, "-i", str(video_path)], capture_output=True, text=True, timeout=20) info = (r.stderr or "") + (r.stdout or "") m = re.search(r"bitrate:\s*(\d+)\s*kb/s", info) if m: kbps = float(m.group(1)) if not kbps: # 兜底:用「文件大小 / 时长」估算 dm = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", info) if dm: dur = int(dm.group(1)) * 3600 + int(dm.group(2)) * 60 + float(dm.group(3)) if dur > 0: kbps = Path(video_path).stat().st_size * 8 / dur / 1000 except Exception: kbps = 0.0 if 0 < kbps < 3000: return 27 # 已压缩素材(含规范化后的)→ 不再加码率 if kbps and kbps < 6000: return 25 return self.DEFAULT_CRF def build_filter(self, blur_sigma: float = DEFAULT_BLUR_SIGMA, grain: int = DEFAULT_GRAIN, saturation_offset: float = DEFAULT_SATURATION, temperature: int = DEFAULT_TEMPERATURE, enable_curves: bool = True) -> str: """构造 FFmpeg 滤镜链(可单独测试)""" parts = [] if blur_sigma and blur_sigma > 0: parts.append(f"gblur=sigma={blur_sigma:.2f}") if grain and grain > 0: parts.append(f"noise=alls={int(grain)}:allf=t+u") sat = max(0.0, 1.0 + float(saturation_offset)) if abs(saturation_offset) > 1e-6: parts.append(f"eq=saturation={sat:.3f}") if enable_curves: parts.append("curves=all='0/0 0.5/0.55 1/1'") if temperature and int(temperature) != 6500: parts.append(f"colortemperature=temperature={int(temperature)}") return ",".join(parts) def process(self, input_path: str, output_path: str, blur_sigma: float | None = None, grain: int | None = None, saturation_offset: float | None = None, temperature: int | None = None, crf: int | None = None, preset: str | None = None, keep_audio: bool = True) -> str: """执行去 AI 化处理 → 返回输出路径(失败抛异常)""" inp = Path(input_path) if not inp.exists(): raise FileNotFoundError(f"输入视频不存在: {input_path}") out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) vf = self.build_filter( blur_sigma=self.DEFAULT_BLUR_SIGMA if blur_sigma is None else blur_sigma, grain=self.DEFAULT_GRAIN if grain is None else grain, saturation_offset=self.DEFAULT_SATURATION if saturation_offset is None else saturation_offset, temperature=self.DEFAULT_TEMPERATURE if temperature is None else temperature, ) cmd = [ self.ffmpeg, "-y", "-i", str(inp), "-vf", vf, "-c:v", "libx264", "-preset", preset or self.DEFAULT_PRESET, "-crf", str(crf if crf is not None else self._adaptive_crf(input_path)), # ⚠️ 必须显式压回 yuv420p:滤镜链中的 curves / colortemperature 在 RGB 域 # 运算后会把像素格式提升为 yuv444p,libx264 无此参数时会保持 444p 输出 # (High 4:4:4 Predictive profile)→ 成片在微信/抖音/iOS/多数安卓上无法播放。 "-pix_fmt", "yuv420p", ] cmd += ["-c:a", "copy"] if keep_audio else ["-an"] cmd.append(str(out)) log.info(" 🎞️ 去 AI 化: %s → %s", inp.name, out.name) log.debug(" 滤镜: %s", vf) r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: raise RuntimeError(f"ffmpeg 失败: {(r.stderr or '')[-300:]}") if not out.exists() or out.stat().st_size == 0: raise RuntimeError("ffmpeg 输出为空") log.info(" ✅ 去 AI 化完成: %.1f MB", out.stat().st_size / 1024 / 1024) return str(out) def apply_to_storyboard(self, video_path: str, output_path: str, storyboard=None) -> str: """按分镜的 post_process / color 配置处理(第一期分镜的设置)""" kw: dict = {} if storyboard is not None and getattr(storyboard, "shots", None): pp = storyboard.shots[0].post_process cc = storyboard.shots[0].color kw = { "blur_sigma": pp.blur_sigma, "grain": pp.grain_intensity, "saturation_offset": cc.saturation_offset, "temperature": 6500 + int(cc.temperature_shift * 1000), # 偏移 → 色温 } return self.process(video_path, output_path, **kw) def deai_filter_preview() -> str: """给前端展示用的默认滤镜链""" return DeAIProcessor().build_filter()