Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/composer/pipeline.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

1275 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
composer.pipeline — 分镜 → 成片流水线(S2-6)
================================================
统一分镜 Schema 进 → 成片出:
① 逐镜头生成(按 shot.type 选能力 → 调生成引擎)
② 每片段去 AI 化(composer.de_ai)
③ 拼接 + 输出(composer.ffmpeg.concat_segments)
已可用的部分:
· `render_from_clips()` —— 已有片段 → 去AI化 → 拼接 → 成片(**可独立测试**)
· `render()` —— 完整链路(生成环节依赖生成引擎就绪:可灵 / 即梦等)
进度回调 `progress_cb(step, shot_id, status, message)` 供前端轮询展示。
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Callable, Optional
_SCRIPTS = Path(__file__).resolve().parent.parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from schemas import Storyboard # noqa: E402
from composer.de_ai import DeAIProcessor # noqa: E402
from composer.ffmpeg import concat_segments # noqa: E402
log = logging.getLogger("composer.pipeline")
ProgressCB = Optional[Callable[[str, str, str, str], None]]
# ════════════════════════════════════════════════════════════
# 影棚词清理(研究结论落地,2026-10-03)
# AI 感的头号来源之一:资产描述里混入「柔光箱均匀布光 / 中焦85mm / 纯灰色专业背景」
# 这类专业影棚语言 —— 生活场景/自拍 Vlog 一旦带上,画面立刻变假。
# 用户实测:角色「中国」的描述末尾就带着这三个词 → 生成画面影棚味很重。
# ════════════════════════════════════════════════════════════
_STUDIO_JARGON = (
"柔光箱均匀布光", "柔光箱", "均匀布光", "布光", "打光", "主光", "轮廓光",
"纯灰色专业背景", "纯灰色背景", "纯色背景", "灰色背景", "专业背景", "纯灰背景",
"中焦85mm", "85mm", "压缩空间", "反光板", "灯架", "摄影棚", "影棚",
"studio background", "key light", "rim light", "softbox",
)
def _clean_studio_jargon(text: str) -> str:
"""剔除影棚语言(保留其余描述),并清理残留的分隔符
例:'【神态&场景】柔光箱均匀布光、中焦85mm、纯灰色专业背景'
→ ''(清理后只剩标签 → 整体丢弃)
"""
import re as _re
out = str(text or "")
for w in _STUDIO_JARGON:
out = out.replace(w, "")
out = _re.sub(r"[、,,]\s*(?=[、,,。;)\)])", "", out) # 连续分隔符
out = _re.sub(r"[((]\s*[))]", "", out) # 空括号
out = _re.sub(r"[、,,。;]\s*$", "", out) # 结尾多余标点
out = out.strip()
# ★ 清理后若只剩标签/符号(无实质内容)→ 整体丢弃,避免"【神态&场景】"这种空壳
# 判据:**先剔除【标签】本身**,再看剩余内容长度
# (曾误把 \w 也算作标签——Python 的 \w 匹配中文 → 导致正常描述被清空)
_no_tags = _re.sub(r"【[^】]*】", "", out)
_content = _re.sub(r"[\[\]()()、,,。;:&/|\-—_ \t\n]", "", _no_tags)
if len(_content) < 2:
return ""
return out
# ── 出片引擎分流辅助 ──────────────────────────────────────────
_VIDEO_MODELS_CACHE = None
def video_channel_of(model: str) -> str:
"""查 config/video_models.yaml → 该模型走哪条通道(kling / dashscope)
留空 = "kling"(可灵,向后兼容 —— 老分镜没这个字段就还是走可灵)。
"""
global _VIDEO_MODELS_CACHE
if not model:
return "kling"
if _VIDEO_MODELS_CACHE is None:
try:
import yaml as _y
p = Path(__file__).resolve().parents[2] / "config" / "video_models.yaml"
d = _y.safe_load(p.read_text(encoding="utf-8")) or {}
_VIDEO_MODELS_CACHE = {m.get("id"): m for m in (d.get("models") or [])}
except Exception as e:
log.warning("读 video_models.yaml 失败: %s", e)
_VIDEO_MODELS_CACHE = {}
return (_VIDEO_MODELS_CACHE.get(model) or {}).get("channel") or "kling"
class PipelineError(RuntimeError):
pass
def _with_ai_negatives(p: str, ga) -> str:
"""追加「关闭 AI 默认审美」的负面约束(幂等;realism_boost=off 时跳过)
来源:anti-ai-checklist.md —— AI 感的头号来源是模型的默认审美
(电影化运镜 / 稳定画面 / 商业调色 / 完美构图 / 磨皮皮肤),必须显式关闭。
"""
try:
from composer.realism import ai_negative_block
boost = (getattr(ga, "realism_boost", "mild") or "mild")
if boost == "off":
return p
block = ai_negative_block(hard=(boost == "strong"))
if block and "关闭 AI 默认审美" not in p:
return p.rstrip("。. ") + "。" + block
except Exception:
pass
return p
class StoryboardPipeline:
"""分镜 → 成片"""
def __init__(self, work_dir: str | Path | None = None):
self.work_dir = Path(work_dir or "/tmp/ave_render")
self.work_dir.mkdir(parents=True, exist_ok=True)
self.de_ai = DeAIProcessor()
# ── 可独立测试:已有片段 → 成片 ────────────────────
def _normalize_clip(self, src: str, dest: str,
storyboard: Storyboard | None = None,
max_duration: float = 8.0) -> str:
"""规范化片段:统一分辨率(默认竖屏 1080x1920)+ 裁到最长 max_duration 秒
素材可能来自 Pexels(4K / 超长)或生成引擎(规格不一),
拼接前统一规格可避免「成片体积失控」(实测 5 段原始素材 → 37 MB)。
"""
import subprocess
from lib.ffmpeg import FFMPEG
res = "1080x1920"
if storyboard and storyboard.shots:
res = (storyboard.shots[0].output.resolution or res)
try:
w, h = (int(x) for x in str(res).lower().split("x"))
except Exception:
w, h = 1080, 1920
cmd = [FFMPEG, "-y", "-i", str(src),
"-t", str(max_duration),
"-vf", (f"scale={w}:{h}:force_original_aspect_ratio=increase,"
f"crop={w}:{h},setsar=1"),
"-c:v", "libx264", "-preset", "medium", "-crf", "23",
"-pix_fmt", "yuv420p", "-r", "30", "-an", str(dest)]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise PipelineError(f"规范化失败: {(r.stderr or '')[-200:]}")
return str(dest)
def render_from_clips(self, clip_paths: list[str], output_path: str,
storyboard: Storyboard | None = None,
apply_deai: bool = True,
progress_cb: ProgressCB = None) -> dict:
"""已有片段 → 去 AI 化 → 拼接 → 成片
返回 {output, clips, total_duration_est}
"""
if not clip_paths:
raise PipelineError("没有可用片段")
processed: list[str] = []
for i, c in enumerate(clip_paths, 1):
src = Path(c)
if not src.exists():
log.warning(" ⚠️ 片段不存在,跳过: %s", c)
continue
if progress_cb:
progress_cb("deai", f"clip_{i:02d}", "running", src.name)
# ① 先规范化(统一分辨率 + 裁到最长 N 秒)—— 避免 Pexels 等原始素材把成片撑大
norm = self.work_dir / f"norm_{i:02d}_{src.stem}.mp4"
try:
base = self._normalize_clip(str(src), str(norm), storyboard)
except Exception as e:
log.warning(" ⚠️ 规范化失败,用原片: %s", e)
base = str(src)
# ② 去 AI 化
if apply_deai:
out = self.work_dir / f"deai_{i:02d}_{src.stem}.mp4"
try:
self.de_ai.apply_to_storyboard(base, str(out), storyboard)
processed.append(str(out))
except Exception as e:
log.warning(" ⚠️ 去AI化失败,用规范化片: %s", e)
processed.append(base)
else:
processed.append(base)
if progress_cb:
progress_cb("deai", f"clip_{i:02d}", "done", "")
if not processed:
raise PipelineError("所有片段都不可用")
if progress_cb:
progress_cb("concat", "-", "running", f"{len(processed)} 段")
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
concat_segments(processed, str(out_path))
if progress_cb:
progress_cb("concat", "-", "done", str(out_path))
log.info(" 🎬 成片输出: %s(%d 段)", out_path.name, len(processed))
return {"output": str(out_path), "clips": processed,
"segments": len(processed)}
# ── 完整链路:分镜 → 成片 ─────────────────────────
def render(self, sb: Storyboard, output_path: str,
progress_cb: ProgressCB = None,
generate_fn: Optional[Callable] = None,
max_workers: int = 3,
reuse_existing: bool = False,
audio_first: bool = False,
lipsync: bool = False,
align_to_audio: bool = False,
sfx_from_video: bool = False) -> dict:
"""分镜 → 逐镜头生成 → 去AI化 → 拼接 → 成片
generate_fn(shot, storyboard, index) -> 片段路径
生成回调(由调用方注入具体引擎:可灵 / 即梦 / 素材库…)。
不传则用内置生成(可灵引擎)。
max_workers: 并发生成镜头数(默认 3)
reuse_existing: 续跑模式 —— work_dir 里已存在的 shot_NN.mp4 直接复用
audio_first: **音频先行** —— 先生成台词音频、按音频真实时长调整镜头时长,
再生成画面(口型/节奏才能对得上);音频存 pre_audio/ 供混流复用
lipsync: **对口型** —— 仅对「有台词 + 有出场人物」的镜头做口型同步
(百炼 VideoRetalk,¥0.08/秒,1800 秒免费额度);失败自动退回原片段
align_to_audio: **画面跟随音频对齐**("剪画面"策略)—— 把「有台词音频、
但没做对口型」的镜头变速到音频长度;对口型镜头不动(口型已绑定)
"""
import threading
from concurrent.futures import ThreadPoolExecutor
# ★ 音频先行:先生成台词音频 → 按**音频真实时长**调整镜头时长 → 再生成画面
# 这样口型与节奏才对得上(画面跟着声音走,而不是反过来)
# 生成的音频存到 work_dir/pre_audio/,后续混流直接复用(不重复 TTS)
if audio_first:
try:
from composer.dubbing import pre_synthesize
pre = pre_synthesize(sb, str(self.work_dir / "pre_audio"),
progress_cb=progress_cb)
log.info(" 🎙 音频先行:%d 个镜头时长已按台词音频调整(总时长 %.1fs)",
pre.get("updated", 0), sb.total_duration())
for _n in (pre.get("notes") or [])[:10]:
log.info(" · %s", _n)
except Exception as e:
log.warning(" ⚠️ 音频先行失败(继续按原时长生成画面): %s", e)
results: dict[str, str] = {}
failed: list[str] = []
clips_by_idx: dict[int, str] = {} # 按镜头序号存,保证成片顺序正确
lock = threading.Lock()
# ★ 首尾帧衔接的依赖同步:链式镜头必须等上一镜生成完(拿它的尾帧)
_chain_events: dict = {i: threading.Event() for i in range(1, len(sb.shots) + 1)}
def _chain_required(idx: int, shot) -> bool:
"""本镜是否要求承接上一镜(tech_requirements.chain_from_prev)"""
if idx <= 1:
return False
tr = getattr(shot, "tech_requirements", None) or {}
if isinstance(tr, dict):
return bool(tr.get("chain_from_prev"))
return bool(getattr(tr, "chain_from_prev", False))
def _gen_one(idx: int, shot) -> None:
"""包装:链式依赖等待 + 完成后发信号(供下一镜取尾帧)"""
if _chain_required(idx, shot):
log.info(" 🔗 %s 需承接上一镜,等待其生成完成…", getattr(shot, "shot_id", idx))
_chain_events[idx - 1].wait(timeout=1800)
try:
_gen_one_inner(idx, shot)
finally:
_chain_events[idx].set()
def _gen_one_inner(idx: int, shot) -> None:
if progress_cb:
progress_cb("generate", shot.shot_id, "running", f"{idx}/{len(sb.shots)}")
# 续跑:复用已生成的镜头片段(跳过生成 = 不产生费用)
if reuse_existing:
# 优先复用「对口型后」的片段(若已做过),否则用原始生成片段
_cands = [self.work_dir / f"lipsync_{idx:02d}.mp4",
self.work_dir / f"shot_{idx:02d}.mp4"]
exist = next((c for c in _cands
if c.exists() and c.stat().st_size > 10_000), _cands[-1])
try:
if exist.exists() and exist.stat().st_size > 10_000:
shot.status = "done"
shot.artifact = str(exist)
with lock:
results[shot.shot_id] = str(exist)
clips_by_idx[idx] = str(exist)
log.info(" ♻️ 复用已有镜头 %s:%s", shot.shot_id, exist.name)
if progress_cb:
progress_cb("generate", shot.shot_id, "done", "复用已有片段")
return
except Exception:
pass
try:
clip = (generate_fn or self._generate_shot)(shot, sb, idx)
if not clip or not Path(clip).exists():
raise PipelineError("生成未返回有效片段")
shot.status = "done"
shot.artifact = str(clip)
with lock:
results[shot.shot_id] = str(clip)
clips_by_idx[idx] = str(clip) # 并发结果按序号归位
if progress_cb:
progress_cb("generate", shot.shot_id, "done", "")
except Exception as e:
shot.status = "failed"
shot.error = str(e)
with lock:
failed.append(f"{shot.shot_id}: {e}")
log.warning(" ⚠️ 镜头生成失败 %s: %s", shot.shot_id, e)
if progress_cb:
progress_cb("generate", shot.shot_id, "failed", str(e))
n = len(sb.shots)
if max_workers > 1 and n > 1:
log.info(" ⚡ 并发生成 %d 个镜头(并发度 %d)", n, min(max_workers, n))
with ThreadPoolExecutor(max_workers=min(max_workers, n)) as ex:
list(ex.map(lambda x: _gen_one(*x), list(enumerate(sb.shots, 1))))
else:
for i, shot in enumerate(sb.shots, 1):
_gen_one(i, shot)
clips = [clips_by_idx[i] for i in sorted(clips_by_idx)] # 恢复镜头顺序
if not clips:
raise PipelineError("所有镜头都生成失败:" + "; ".join(failed[:3]))
# ★ 对口型(仅「有台词 + 有出场人物」的镜头)—— 在做去AI化/拼接之前
if lipsync:
clips = self._apply_lipsync(sb, clips, progress_cb)
# ★ 视频生音效(可灵 video-to-audio · ¥0.25/镜头)—— 给静音成片补环境音
# 产出存 sfx_audio/{shot}.mp3,由 dubbing 按镜头时长混入
if sfx_from_video:
try:
self._apply_sfx_from_video(clips, progress_cb)
except Exception as e:
log.warning(" ⚠️ 音效流程失败(不影响出片): %s", e)
# ★ 画面跟随音频对齐("剪画面"策略)——仅对**没做对口型**的镜头
# 用户洞察:音画不同步时应动画面而非声音(音频变速会变调)
if align_to_audio:
try:
from composer.align import align_clips_to_audio
_ls_idx = set()
if lipsync:
for i, _s in enumerate(list(sb.shots), 1):
if ((_s.text or "").strip()
and [x for x in (getattr(_s, "cast_slots", None) or []) if x]):
_ls_idx.add(i) # 与 _apply_lipsync 的判据一致
clips = align_clips_to_audio(self.work_dir, sb, clips,
lipsync_indexes=_ls_idx,
progress_cb=progress_cb)
except Exception as e:
log.warning(" ⚠️ 画面跟随音频对齐失败(不影响出片): %s", e)
# 去AI化 + 拼接
r = self.render_from_clips(clips, output_path, storyboard=sb, progress_cb=progress_cb)
sb.status = "done" if not failed else "partial"
sb.output_path = r["output"]
# 交付状态:诚实标注(未经完整播放审查,一律不算「合格成片」)
sb.delivery_status, sb.delivery_note = self.delivery_status(sb, failed)
return {**r, "failed": failed, "shot_artifacts": results,
"total_duration": sb.total_duration(),
"delivery_status": sb.delivery_status, "delivery_note": sb.delivery_note}
@staticmethod
def delivery_status(sb: Storyboard, failed: list, reviewed: bool = False) -> tuple[str, str]:
"""按 produce-ai-video 验收方法论判定交付状态(**诚实标注,不夸大**)
合格成片 —— 全部镜头成功 + 已完整播放审查(用户确认)
候选成片 —— 可播放,但有镜头失败 或 未经完整播放审查
未完成/待验证 —— 未生成 / 拼接失败
"""
if not sb.output_path:
return "未完成/待验证", "尚未生成成片"
if failed:
return "候选成片", f"{len(failed)} 个镜头失败(其余已出片,可重试失败镜头)"
if not reviewed:
return "候选成片", "已生成拼接,尚未完整播放审查"
return "合格成片", "全部镜头成功 + 已完整播放审查"
# ── 混流(视频 + 人声 + BGM + 字幕)────────────────
@staticmethod
def mux_final(video_path: str, output_path: str,
voice_path: str = "", bgm_path: str = "",
subtitle_path: str = "", bgm_volume: float = 0.15,
burn_subtitle: bool = True,
narration_path: str = "", narration_volume: float = 1.0) -> str:
"""把成片素材混流为最终视频(**两步法**)
⚠️ 实测发现:本机 ffmpeg 6.0 在「视频重编码(libx264) + 音频映射」同一条命令里
会**丢弃音频**(输出流声明了 aac 但编码 0 字节);而 `-c:v copy` + 音频可正常保留。
故采用两步法(稳定):
第 1 步:视频处理(烧字幕/重编码,**无音频**)→ 中间文件
第 2 步:把音频(人声 + BGM)以 `-c:v copy` 方式混入 → 最终文件
video_path 拼接后的画面(必填)
voice_path TTS 人声(可选)
bgm_path BGM(可选,自动压低 + 淡入淡出)
subtitle_path ASS 字幕(可选,烧录进画面)
"""
import subprocess
from lib.ffmpeg import FFMPEG
out = Path(output_path)
out.parent.mkdir(parents=True, exist_ok=True)
# ── 第 1 步:视频(可选烧字幕)→ 中间文件 ──
need_video_pass = bool(burn_subtitle and subtitle_path and Path(subtitle_path).exists())
if need_video_pass:
tmp_video = out.parent / f"_{out.stem}_v.mp4"
sub_esc = str(subtitle_path).replace("\\", "/").replace(":", "\\:")
cmd1 = [FFMPEG, "-y", "-i", str(video_path),
"-vf", f"ass='{sub_esc}'",
"-c:v", "libx264", "-preset", "medium", "-crf", "23",
"-pix_fmt", "yuv420p", "-an", str(tmp_video)]
r1 = subprocess.run(cmd1, capture_output=True, text=True)
if r1.returncode != 0:
raise PipelineError(f"字幕烧录失败: {(r1.stderr or '')[-300:]}")
src_video = str(tmp_video)
else:
src_video = str(video_path)
# ── 若无音频需求:直接返回(第 1 步产物或原片)──
has_voice = bool(voice_path and Path(voice_path).exists())
has_narr = bool(narration_path and Path(narration_path).exists())
has_bgm = bool(bgm_path and Path(bgm_path).exists())
if not (has_voice or has_narr or has_bgm):
if src_video != str(out):
import shutil
shutil.copy2(src_video, out)
return str(out)
# ── 第 2 步:混音频(视频 **copy**,音频重编码)──
# 支持三路:台词(voice)+ 旁白(narration,独立轨)+ BGM
cmd2 = [FFMPEG, "-y", "-i", src_video]
idx = 1
v_i = n_i = b_i = -1
if has_voice:
cmd2 += ["-i", str(voice_path)]
v_i = idx; idx += 1
if has_narr:
cmd2 += ["-i", str(narration_path)]
n_i = idx; idx += 1
if has_bgm:
cmd2 += ["-i", str(bgm_path)]
b_i = idx; idx += 1
cmd2 += ["-map", "0:v:0"]
parts: list[str] = []
labels: list[str] = []
if v_i > 0:
parts.append(f"[{v_i}:a]volume=1.0[v]")
labels.append("[v]")
if n_i > 0:
parts.append(f"[{n_i}:a]volume={narration_volume}[n]")
labels.append("[n]")
if b_i > 0:
parts.append(f"[{b_i}:a]volume={bgm_volume},afade=t=in:st=0:d=1[b]")
labels.append("[b]")
if len(labels) > 1:
parts.append(f"{''.join(labels)}amix=inputs={len(labels)}:duration=first:normalize=0[aout]")
cmd2 += ["-filter_complex", ";".join(parts), "-map", "[aout]"]
elif len(labels) == 1:
# 单路:也走 filter_complex,统一音量处理
cmd2 += ["-filter_complex", ";".join(parts + [f"{labels[0]}anull[aout]"]),
"-map", "[aout]"]
else:
cmd2 += ["-map", "1:a"]
cmd2 += ["-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", str(out)]
r2 = subprocess.run(cmd2, capture_output=True, text=True)
if r2.returncode != 0:
raise PipelineError(f"音频混流失败: {(r2.stderr or '')[-300:]}")
# 校验音轨确实写入了(防 ffmpeg 静默丢音轨)—— 用 ffmpeg -i 输出判断(最兼容)
try:
chk = subprocess.run([FFMPEG, "-i", str(out)],
capture_output=True, text=True, timeout=30)
info = (chk.stderr or "") + (chk.stdout or "")
if "Audio:" not in info:
raise PipelineError("音频混流后未检测到音轨(可尝试重新生成 TTS 音频)")
except FileNotFoundError:
pass # 无 ffmpeg 时跳过校验
# 清理中间文件(烧字幕的临时视频),保持产物目录干净
if need_video_pass:
try:
Path(tmp_video).unlink(missing_ok=True)
except Exception:
pass
log.info(" 🎬 混流完成: %s(人声=%s BGM=%s 字幕=%s)",
out.name, has_voice, has_bgm, need_video_pass)
return str(out)
# ── 生成环节(待接入引擎)──────────────────────────
def _generate_via_dashscope(self, shot, storyboard: Storyboard, prompt: str, dest: str,
ref_image: str, tail_image: str, want_image: bool) -> str:
"""走百炼(万相 KF2V)生成这一镜 —— ¥0.1/秒,可灵的 1/6
⚠️ 约束:
· 首尾帧模型**必须有尾帧**(没有就报错,不静默降级 —— 免得花了钱效果不对)
· 时长固定 5 秒(官方规格)
· 不支持 camera_control / aspect_ratio(这些是可灵参数)
"""
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from capabilities import dashscope_video as dv
if not dv.available():
raise PipelineError("百炼通道未配置 key(agent-local/tools/ave/config/local.yaml → aliyun.api_key)")
if not want_image or not ref_image:
raise PipelineError("百炼 KF2V 需要首帧 —— 本镜没有可用的参考图/关键帧")
if not tail_image:
raise PipelineError(
"百炼 KF2V 必须有尾帧。请在分镜里给这一镜设置 tail_mode,"
"或改用可灵(tech_requirements.video_model 留空)")
log.info(" ☁️ %s 走百炼 KF2V(¥0.1/秒)", shot.shot_id)
dv.generate_kf2v(ref_image, tail_image, dest, prompt=prompt,
progress_cb=lambda s: log.info(" %s", s))
return dest
def _generate_shot(self, shot, storyboard: Storyboard, index: int) -> str:
"""按镜头生成片段(可灵引擎)
生成能力决定(`shot.generation_capability` 优先,为空则自动判定):
· `image_to_video` 或(自动 + 绑了角色)→ **图生视频**(用角色参考图保住人脸一致性)
· 其他 → **文生视频**
"""
try:
from capabilities.kling_video import KlingVideoClient, KlingError
except ImportError as e:
raise PipelineError(f"生成能力未就绪: {e}")
client = KlingVideoClient()
if not client.available:
raise PipelineError(
"可灵未配置 —— 请到 Dashboard → ⚙️ 服务 → 🔑 API 配置 填入可灵 api_key"
)
dest = self.work_dir / f"shot_{index:02d}.mp4"
# 可灵 duration 仅支持 5 / 10 秒
# ★ 时长档位:可灵只有 5 秒 / 10 秒两档 → 超过 5 秒必须用 10 秒档
# (原实现阈值是 7 秒 → 6 秒的镜头会生成 5 秒,比脚本短 1 秒 → 拼接时穿帮)
dur = "10" if (shot.duration or 0) > 5.2 else "5"
# ── 决定 图生视频 / 文生视频 ──
cap = (getattr(shot, "generation_capability", "") or "").lower()
# ★ 是否"绑了角色"要同时看多角色演员表(cast_slots)
# 旧实现只看 character_id → 用演员表的分镜 has_char=False → 不走图生视频
_cast_slots = [s for s in (getattr(shot, "cast_slots", None) or []) if s]
has_char = bool((getattr(shot, "character_id", "") or "").strip() or _cast_slots)
# ★ 是否用参考图:
# · 明确指定 image_to_video → 用
# · 明确指定 text_to_video / vfx / comfyui → 不用(尊重用户选择)
# · talking_head 或未指定 → 只要有角色就用(口播镜头人脸一致性最关键!
# 且后续对口型也基于这个画面)
if cap in ("text_to_video", "vfx", "comfyui"):
want_image = False
else:
want_image = (cap == "image_to_video") or has_char
ref_image = ""
# ★ 环境镜头也走图生视频(用场景参考图保环境)—— 解决"画面没有环境"的问题
_loc_ref = self._resolve_location_ref(shot, storyboard)
# ★★ 关键帧优先(第②步 → 第③步的衔接点)★★
# 这一镜若在「关键帧工作台」里有【已采用】的关键帧,直接拿它当首帧 ——
# 关键帧是用户审过的静帧,比角色定妆照更接近成片,也让用户的审核真正生效。
_kf, _kf_src = self._keyframe_of_shot(getattr(storyboard, "project_id", ""), shot.shot_id)
if _kf:
ref_image = _kf
want_image = True
_src_txt = "已采用的关键帧" if _kf_src == "adopted" else "关键帧(未点采用·取最新版)"
log.info(" 🖼️ 镜头 %s 用【%s】作首帧:%s", shot.shot_id, _src_txt, Path(_kf).name)
elif want_image or _loc_ref:
ref_image = self._resolve_reference_image(shot, storyboard)
if not ref_image:
if cap == "image_to_video":
log.warning(" ⚠️ 镜头 %s 指定了图生视频,但未取到参考图 → 退化为文生视频",
shot.shot_id)
want_image = False
else:
_kind = "场景图" if (_loc_ref and ref_image == _loc_ref) else "角色图"
log.info(" 🖼️ 镜头 %s 用图生视频(%s:%s)", shot.shot_id, _kind, Path(ref_image).name)
# ── 特效(vfx)→ 运镜控制 ──
vfx_type, vfx_params = "", None
_vfx = getattr(shot, "vfx", None)
if _vfx is not None:
vfx_type = (getattr(_vfx, "vfx_type", "") or "").strip()
vfx_params = getattr(_vfx, "params", None)
if not vfx_type:
# 兜底:镜头语言里出现"环绕/推/拉"等词 → 映射成运镜预设
_move = (getattr(getattr(shot, "shot_contract", None), "camera_move", "") or "")
if "环绕" in _move:
vfx_type = "orbit"
elif "推" in _move:
vfx_type = "forward_up"
elif "拉" in _move:
vfx_type = "down_back"
# ★ 提示词编译(分支不同:图生视频只写动作,不重描述视觉 —— 可灵官方守则)
prompt = self.build_shot_prompt(shot, storyboard, has_ref_image=bool(want_image))
# ★ 去 AI 感:按「拍摄风格包 + 真实感强度」增强提示词
# mild → 追加真实质感锚点(噪点/毛孔/不匀光)
# strong→ 额外剔除「4K/超清/无瑕/大师作品」等反真实词 + 反完美总纲
try:
from composer.realism import apply_style_pack
_ga = getattr(storyboard, "global_anchors", None)
_style = (getattr(_ga, "shooting_style", "") or "")
_boost = (getattr(_ga, "realism_boost", "mild") or "mild")
if _style:
prompt, _rnotes = apply_style_pack(prompt, _style, _boost)
for _rn in _rnotes:
log.info(" ✨ 去AI感[%s/%s]: %s", _style, _boost, _rn)
# ★ 关闭「AI 默认审美」的负面约束(研究:AI 感的头号来源)
try:
from composer.realism import ai_negative_block
_neg = ai_negative_block(hard=(_boost == "strong"))
if _neg and "关闭 AI 默认审美" not in prompt:
prompt = prompt.rstrip("。. ") + "。" + _neg
except Exception as _ne:
log.debug("负面约束注入失败: %s", _ne)
except Exception as _e:
log.debug("风格包应用失败(忽略): %s", _e)
# ★ 首尾帧衔接(chain_from_prev):让本镜**从上一镜的收尾画面开始** → 无缝接续
# ⚠️ 语义澄清(2026-10-03 修正):
# 可灵的 `image_tail` = 指定视频的**结束画面**,用它做接续是反的 ✗
# 正确做法:把上一镜的**尾帧当作本镜的首帧**(image 参数)✓
_tail_image = ""
try:
_tr = getattr(shot, "tech_requirements", None) or {}
_chain = bool(_tr.get("chain_from_prev")) if isinstance(_tr, dict) else False
if _chain and index > 1:
from composer.frames import extract_tail_frame
_prev = None
# 优先取"已后处理"的片段(对口型 > 去AI化 > 原始),衔接点更准
for _c in (self.work_dir / f"lipsync_{index-1:02d}.mp4",
self.work_dir / f"deai_{index-1:02d}.mp4",
self.work_dir / f"shot_{index-1:02d}.mp4"):
if _c.exists() and _c.stat().st_size > 10_000:
_prev = _c
break
if _prev:
_prev_tail = extract_tail_frame(
str(_prev), str(self.work_dir / "chain" / f"tail_{index-1:02d}.jpg"))
if ref_image and Path(ref_image).exists():
# 有角色参考图:人脸一致优先,不用尾帧当首帧(两者冲突,只能二选一)
log.info(" 🔗 %s 需承接上一镜,但已有角色参考图 → 优先保人脸(未用尾帧衔接)",
shot.shot_id)
else:
# 无参考图:用上一镜尾帧当**首帧** → 画面从上一镜收尾处接着走 ✓
ref_image, want_image = _prev_tail, True
log.info(" 🔗 %s 以上一镜尾帧为首帧衔接:%s",
shot.shot_id, Path(_prev_tail).name)
except Exception as e:
log.warning(" ⚠️ 首尾帧衔接失败(按普通方式生成): %s", e)
# ★ 原生有声(sound=on):一次生成「带语音的视频」→ 音画天然同步,
# 不再需要后期 TTS / 对口型。按镜头开:tech_requirements.native_sound
_tr_sound = (getattr(shot, "tech_requirements", None) or {}).get("native_sound")
_sound = "on" if _tr_sound else "off"
if _sound == "on":
log.info(" 🔊 %s 使用「原生有声」生成(无需后期配音/口型)", shot.shot_id)
# ★ 出片引擎分流(tech_requirements.video_model,留空 = 可灵默认)
# 为什么:可灵 ¥0.6/秒,百炼 kf2v ¥0.1/秒 —— 同一部片子差 6 倍成本。
# 分流逻辑与「🎞 视频」标签保持一致,都用 config/video_models.yaml 的 channel。
_tr_model = (getattr(shot, "tech_requirements", None) or {}).get("video_model") or ""
_channel = video_channel_of(_tr_model)
if _channel == "dashscope":
out_path = self._generate_via_dashscope(
shot, storyboard, prompt, dest, ref_image, _tail_image, want_image)
else:
try:
out_path = client.generate_video(prompt, dest, image_path=ref_image if want_image else "",
duration=dur,
aspect_ratio=self._aspect_ratio(storyboard),
vfx_type=vfx_type, vfx_params=vfx_params,
tail_image_path=_tail_image,
sound=_sound)
except KlingError as e:
raise PipelineError(str(e))
# ── 变速(慢动作 / 快进):**零成本**本地 FFmpeg 处理 ──
speed = float(getattr(getattr(shot, "speed", None), "factor", 1.0) or 1.0)
if abs(speed - 1.0) > 0.01:
try:
from composer.speed_ramp import speed_ramp
ramped = self.work_dir / f"shot_{index:02d}_x{speed}.mp4"
speed_ramp(str(out_path), str(ramped), speed=speed)
log.info(" 🎞️ 镜头 %s 变速 %.2fx → %s", shot.shot_id, speed, ramped.name)
return str(ramped)
except Exception as e:
log.warning(" ⚠️ 变速失败(用原片): %s", e)
return str(out_path)
def _apply_sfx_from_video(self, clips: list, progress_cb: ProgressCB = None) -> list:
"""给镜头生成**音效**(可灵 video-to-audio · ¥0.25/镜头)
背景:我们的成片默认**完全静音** —— 这是最大的"AI 感"来源之一。
可灵提供「视频生音效」接口:给视频自动生成环境音 + BGM。
· 依赖镜头生成时记录的 task_id(sidecar `{shot}.mp4.taskid`)
· 产出存 `sfx_audio/{shot_id}.mp3`(由 dubbing 在混音时按镜头时长混入)
· 已有产物则跳过(断点续跑,不重复扣费)
· 任一镜头失败不影响其他镜头(失败仅告警)
⚠️ 成本 ¥0.25 × 镜头数(5 镜头 ≈ ¥1.25);每镜头耗时 30~90 秒。
"""
sfx_dir = self.work_dir / "sfx_audio"
sfx_dir.mkdir(parents=True, exist_ok=True)
try:
from capabilities.kling_video import KlingVideoClient
except Exception as e:
log.warning(" ⚠️ 音效模块不可用(跳过): %s", e)
return clips
client = KlingVideoClient()
done = skipped = failed = 0
for idx, clip in enumerate(clips, 1):
tid = KlingVideoClient.load_task_id(clip)
if not tid:
log.debug(" 镜头%d 无 task_id,跳过音效", idx)
continue
dest = sfx_dir / f"shot_{idx:02d}.mp3"
if dest.exists() and dest.stat().st_size > 1000:
skipped += 1
continue
if progress_cb:
progress_cb("sfx", f"shot_{idx:02d}", "running", "生成音效")
try:
client.generate_sfx_for_video(tid, str(dest))
done += 1
if progress_cb:
progress_cb("sfx", f"shot_{idx:02d}", "done", "音效就绪")
except Exception as e:
failed += 1
log.warning(" ⚠️ 镜头%d 音效生成失败(不影响出片): %s", idx, str(e)[:120])
if done or skipped:
log.info(" 🎧 音效:新增 %d · 复用 %d · 失败 %d(¥%.2f)",
done, skipped, failed, done * 0.25)
return clips
def _apply_lipsync(self, sb, clips: list, progress_cb: ProgressCB = None) -> list:
"""对口型:对「有台词 + 有出场人物」的镜头做口型同步(百炼 VideoRetalk)
· 仅 A 类镜头(有台词且有出场人物);旁白/空镜不动
· 音频复用「音频先行」产物(pre_audio/{shot_id}.mp3)—— 不重复 TTS
· 参考图用角色资产(提升人脸匹配)
· **失败自动退回原片段**(不影响出片)
· 成本 ¥0.08/秒(1800 秒免费额度内免费)
"""
try:
from composer.lipsync import lipsync_videoretalk, vr_estimate_cost
except Exception as e:
log.warning(" ⚠️ 对口型模块不可用(跳过): %s", e)
return clips
pre_dir = self.work_dir / "pre_audio"
out = list(clips)
done = 0
for idx, shot in enumerate(list(getattr(sb, "shots", []) or []), 1):
text = (getattr(shot, "text", "") or "").strip()
slots = [s for s in (getattr(shot, "cast_slots", None) or []) if s]
if not (text and slots):
continue # 非 A 类 → 跳过
if idx - 1 >= len(out):
continue
src = Path(out[idx - 1])
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
audio = pre_dir / f"{sid}.mp3"
if not (src.exists() and audio.exists()):
log.warning(" ⚠️ 对口型跳过 %s(缺视频或先行音频)", src.name)
continue
ref = self._resolve_character_ref(shot, sb)
dur = float(getattr(shot, "duration", 0) or 0)
dst = self.work_dir / f"lipsync_{idx:02d}.mp4"
log.info(" 🎭 对口型 %s(%.1fs · 约 ¥%.2f · 走免费额度优先)",
src.name, dur, vr_estimate_cost(dur))
if progress_cb:
progress_cb("lipsync", sid, "running", "口型同步中")
try:
lipsync_videoretalk(str(src), str(audio), str(dst),
ref_image_path=ref,
progress_cb=lambda p, n: progress_cb("lipsync", sid, "running", n)
if progress_cb else None)
out[idx - 1] = str(dst) # 用对口型后的片段
done += 1
if progress_cb:
progress_cb("lipsync", sid, "done", "已对口型")
log.info(" ✅ %s 对口型完成", sid)
except Exception as e:
log.warning(" ⚠️ 对口型失败(保留原片段): %s", str(e)[:120])
if progress_cb:
progress_cb("lipsync", sid, "failed", str(e)[:60])
log.info(" 🎭 对口型完成:%d 个镜头", done)
return out
# ── 关键帧(第②步产物)────────────────────────────────
@staticmethod
def _keyframe_root() -> Path:
"""关键帧根目录(agent-local/runtime/ave/keyframes)
⚠️ 路径层级:__file__ = agent-sync/05_tools/09_ave/scripts/composer/pipeline.py
parents[0]=composer [1]=scripts [2]=09_ave [3]=05_tools [4]=agent-sync
→ agent-local 与 agent-sync 同级,即 parents[4].parent/"agent-local"
"""
import os as _os
base = _os.environ.get("AGENT_LOCAL")
root = Path(base) if base else (Path(__file__).resolve().parents[4].parent / "agent-local")
return root / "runtime" / "ave" / "keyframes"
@classmethod
def _keyframe_of_shot(cls, project_id: str, shot_id: str,
fallback: bool = True) -> tuple[str, str]:
"""取该镜头的关键帧 → (图片路径, 来源)
来源取值:
"adopted" 用户明确「采用」的那一版(最可信)
"latest" 未采用,回退到最新一版(用户生成了但没点采用)
"" 完全没有关键帧
fallback=True 时,"生成了但没点采用" 也算可用 ——
符合用户直觉(我生成了就该能用),调用方可根据来源决定是否提示。
"""
if not (project_id and shot_id):
return "", ""
sdir = cls._keyframe_root() / str(project_id) / str(shot_id)
if not sdir.is_dir():
return "", ""
import json as _json
latest = ""
for jf in sorted(sdir.glob("v*.json")):
try:
m = _json.loads(jf.read_text(encoding="utf-8"))
except Exception:
continue
png = jf.with_suffix(".png")
if not png.exists():
continue
if m.get("adopted"):
return str(png), "adopted"
latest = str(png) # 保留最后一个(最新的)作为回退
if fallback and latest:
return latest, "latest"
return "", ""
@classmethod
def _adopted_keyframe(cls, project_id: str, shot_id: str) -> str:
"""兼容旧调用:只要路径(优先 adopted,无则回退最新版)"""
p, _ = cls._keyframe_of_shot(project_id, shot_id, fallback=True)
return p
@staticmethod
def _ref_image_of_character(cid: str, shot_size: str = "", angle: str = "") -> str:
"""从角色资产取参考图 —— **按镜头景别/角度选最合适的那张**
★ 2026-10-04(用户指出):套装生成了 5~7 张,但旧实现只取 front 一张,
其余全部闲置。现在按镜头类型选:
· 全景/远景 → 全身图 · 特写 → 面部特写
· 侧面/45° → 侧视图 · 背面/回头 → 回头斜侧
· 其他 → 半身正面(主定妆)
这样在"可灵只能吃一张图"的限制下把一致性做到最好。
"""
cid = (cid or "").strip()
if not cid:
return ""
try:
from assets_manager import AssetStore
st = AssetStore("characters")
item = st.get(cid) or {}
refs = item.get("reference_images") or {}
base = Path(st.kind_dir(cid))
# ★ 先按镜头类型选(让套装每张都发挥作用)
try:
from character_generator.portrait_set import pick_reference_for_shot
slot = pick_reference_for_shot(shot_size, angle, refs)
if slot and refs.get(slot):
p = base / Path(str(refs[slot])).name
if p.exists():
log.debug(" 参考图按镜头选择: %s(景别=%s 角度=%s)",
slot, shot_size or "-", angle or "-")
return str(p)
except Exception as e:
log.debug("按镜头选参考图失败,回退通用顺序: %s", e)
# 回退:通用槽位顺序(向后兼容旧角色资产)
for key in ("front", "front_face", "portrait", "side", "full"):
if refs.get(key):
p = base / Path(str(refs[key])).name
if p.exists():
return str(p)
for fn in refs.values():
if fn:
p = base / Path(str(fn)).name
if p.exists():
return str(p)
except Exception as e:
log.debug("取角色参考图失败(%s): %s", cid, e)
return ""
@classmethod
def _resolve_character_ref(cls, shot, storyboard=None) -> str:
"""取镜头所绑角色的**参考图本地路径**(用于图生视频,保人脸一致性)
★ 支持多角色演员表(2026-10-03 修复)
旧实现只看 shot.character_id → 用「演员表」的分镜 character_id 为空
→ 每个镜头都退化为文生视频 → **每次生成的人脸随机**(用户实测发现)
解析顺序:
① shot.cast_slots + storyboard.anchors.cast → 按演员表顺序取 asset_id(主角通常在前)
② 回退 shot.character_id(单角色,向后兼容)
多角色同框时图生视频只能喂一张参考图 → 取**第一个有参考图的出场角色**
"""
slots = [s for s in (getattr(shot, "cast_slots", None) or []) if s]
ga = getattr(storyboard, "global_anchors", None) if storyboard is not None else None
cast = (getattr(ga, "cast", None) or []) if ga is not None else []
cids: list[str] = []
if slots and cast:
for m in cast: # 按演员表顺序(主角在前)
if getattr(m, "slot", "") in slots and getattr(m, "asset_id", ""):
cids.append(m.asset_id)
if not cids and (getattr(shot, "character_id", "") or "").strip():
cids.append(shot.character_id.strip())
# 镜头景别/角度 → 供「按镜头类型选参考图」使用
_sc = getattr(shot, "shot_contract", None)
_size = str(getattr(_sc, "shot_size", "") or "") if _sc else ""
_angle = str(getattr(_sc, "angle", "") or "") if _sc else ""
for cid in cids:
p = cls._ref_image_of_character(cid, shot_size=_size, angle=_angle)
if p:
return p
if slots and not cids:
log.debug("镜头 %s 出场 %s 但未绑定角色资产 → 无参考图(退化为文生视频)",
getattr(shot, "shot_id", "?"), slots)
return ""
@classmethod
def _resolve_reference_image(cls, shot, storyboard=None) -> str:
"""★ 按镜头类型选参考图(零成本,不依赖多图 API)
背景(2026-10-03 研究结论):可灵官方守则要求"给每张参考图指定明确角色",
而我们的 API 只能传一张 → 改为**按镜头类型分配**:
· 环境镜头(无出场人物 / 远景·全景)→ 优先**场景参考图**(保环境)
· 人物镜头(有出场 / 近景·特写) → **角色参考图**(保人脸)
两侧都没有 → 返回空(走文生视频)
判据顺序:
① 有出场人物 且 景别是近景/特写 → 角色图
② 无出场人物 或 景别是远景/全景 → 场地图(无则退回角色图)
③ 其余 → 角色图
"""
size = ""
sc = getattr(shot, "shot_contract", None)
if sc is not None:
size = str(getattr(sc, "shot_size", "") or "")
slots = [s for s in (getattr(shot, "cast_slots", None) or []) if s]
has_char = bool(slots) or bool((getattr(shot, "character_id", "") or "").strip())
_CLOSE = ("特写", "近景", "close", "extreme")
_WIDE = ("全景", "远景", "wide", "long", "establish")
is_close = any(k in size for k in _CLOSE)
is_wide = any(k in size for k in _WIDE)
char_ref = cls._resolve_character_ref(shot, storyboard)
loc_ref = cls._resolve_location_ref(shot, storyboard)
# ① 人物特写/近景 → 角色图(保脸最重要)
if has_char and is_close and char_ref:
return char_ref
# ② 空镜 / 远景全景 → 场地图(保环境)
if (not has_char) or is_wide:
if loc_ref:
return loc_ref
if char_ref:
return char_ref
# ③ 其余 → 角色图优先,次选场地图
return char_ref or loc_ref
@classmethod
def _resolve_location_ref(cls, shot, storyboard=None) -> str:
"""取镜头所绑场景的**参考图本地路径**(保环境一致性)
场景来源:shot.location_id 优先,其次 storyboard.global_anchors.location_id
(支持 locations / real_locations 两类资产)
"""
import sys as _sys
if str(_SCRIPTS) not in _sys.path:
_sys.path.insert(0, str(_SCRIPTS))
ga = getattr(storyboard, "global_anchors", None) if storyboard is not None else None
lid = ((getattr(shot, "location_id", "") or "").strip()
or ((getattr(ga, "location_id", "") or "").strip() if ga is not None else ""))
if not lid:
return ""
try:
from assets_manager import AssetStore
except Exception:
return ""
for kind in ("locations", "real_locations"):
try:
st = AssetStore(kind)
it = st.get(lid)
if not it:
# ★ 兼容 id 规范化差异(2026-10-04):
# 资产 slug 会把下划线转成横杠("病房_302" → "病房-302")
# → 分镜里手写的 location_id 会查不到,这里再兜一层
_norm = lambda s: str(s or "").replace("-", "_") # noqa: E731
it = st.get(str(lid).replace("_", "-"))
if not it:
for _x in st.list():
if (_norm(_x.get("id")) == _norm(lid)
or str(_x.get("name") or "") == str(lid)):
it = _x
break
if not it:
continue
_aid = str(it.get("id") or lid) # 用资产**实际 id** 拼目录
refs = it.get("reference_images") or {}
# 槽位优先级:wide(广角全景)> main > front > scene
# ⚠️ 元数据里存的是**相对文件名**(如 ref_wide.png),需拼上资产目录
def _abs_ref(v):
p = Path(str(v))
if p.is_absolute() and p.exists():
return p
q = Path(st.kind_dir(_aid)) / p.name
return q if q.exists() else None
# ★ 场景图也按镜头机位角度选(2026-10-04):
# 俯视镜头 → top 图;侧向镜头 → side 图;正面 → front;默认 → wide(广角全景)
# 场景资产可上传多角度图:wide / top / front / side / back / detail
_ang = ""
try:
_sc2 = getattr(shot, "shot_contract", None)
_ang = str(getattr(_sc2, "angle", "") or "") if _sc2 else ""
except Exception:
_ang = ""
_LOC_ANGLE_SLOTS = (
(("俯视", "顶", "鸟瞰", "top", "bird"), ("top", "wide")),
(("仰视", "low"), ("front", "wide")),
(("侧", "45", "三分", "side", "profile"), ("side", "left", "right", "wide")),
(("过肩", "背后", "behind", "从后"), ("back", "wide")),
)
_pref: tuple = ()
# ★ 景别优先:特写/近景 → detail 图(否则会被 wide 抢先)
_size_s = ""
try:
_sc3 = getattr(shot, "shot_contract", None)
_size_s = str(getattr(_sc3, "shot_size", "") or "") if _sc3 else ""
except Exception:
_size_s = ""
if any(k in _size_s for k in ("特写", "近景", "close", "macro")):
_pref = ("detail",)
else:
for _keys, _slots in _LOC_ANGLE_SLOTS:
if any(k in _ang for k in _keys):
_pref = _slots
break
for slot in (_pref + ("wide", "main", "front", "scene", "side", "top", "detail")):
if refs.get(slot):
p = _abs_ref(refs[slot])
if p:
log.debug(" 场景参考图 %s/%s → %s(机位 %s)",
kind, slot, p.name, _ang or "默认")
return str(p)
for slot, v in refs.items():
p = _abs_ref(v)
if p:
return str(p)
except Exception:
continue
return ""
@staticmethod
def _aspect_ratio(sb: Storyboard) -> str:
"""按输出分辨率推断画幅(默认竖屏 9:16)"""
res = (sb.shots[0].output.resolution if sb.shots else "") or "1080x1920"
try:
w, h = (int(x) for x in res.lower().split("x"))
return "16:9" if w > h else "9:16"
except Exception:
return "9:16"
@staticmethod
def build_shot_prompt(shot, sb: Storyboard, has_ref_image: bool = False) -> str:
"""把分镜编译成生成提示词(六模块母提示词 + 写实感增强)
六模块:① 生成任务 ② 主要主体 ③ 场景状态 ④ 情绪目标 ⑤ 视觉风格 ⑥ 分段脚本
⚠️ has_ref_image=True(图生视频)时**改写策略**:
可灵官方守则 —— 图生视频"只描述运动,不重描述视觉",
因为模型会照参考图重画,此时再描述一遍视觉 = 两套冲突指令 → AI 感。
故图生视频分支只保留:动作演化 / 情绪外化 / 摄影特征 / 质感锚点 / 约束。
写实感增强(composer/realism.py):
· 情绪外化:抽象情绪词 → 具体身体动作
· 写实锚点:按拍摄风格给"设备/光源/皮肤/微瑕疵"具体锚点
· 矛盾自检:检测「画面稳定 vs 手持」这类相互抵消的组合并告警
"""
try:
from composer.realism import (externalize_emotion, get_anchors,
compose_constraints, check_conflicts)
except Exception: # 极端情况下退回基础行为
externalize_emotion = lambda s: s or ""
get_anchors = lambda st: {"device": "", "hand": "", "light": "",
"skin": "", "flaws": "", "motion": "", "_no": ""}
compose_constraints = lambda extra="", with_anti_twin=True: \
"负向约束:不要运镜特效痕迹、不要 AI 塑料质感、不要字幕水印"
check_conflicts = lambda t: []
ga = sb.global_anchors
sc = shot.shot_contract
res = (shot.output.resolution if shot.output else "") or "1080x1920"
anchors = get_anchors(getattr(ga, "shooting_style", "") or "")
parts: list[str] = []
# ══ 分支 A:图生视频(有参考图)—— 只写动作,不重描述视觉 ══
if has_ref_image:
parts.append(
f"【生成任务】{shot.duration:.0f} 秒连续镜头;"
f"**严格以参考图中的人物外貌、服装为准;"
f"不要使用参考图的背景、环境、构图与分区布局**"
f"—— 背景环境以本提示词描述的场景为准"
)
act = (getattr(shot, "action", "") or "").strip() or \
(shot.visual_description or "").strip() or "自然表演"
parts.append(f"【动作演化】{act}")
emo = externalize_emotion(getattr(shot, "sentiment", "") or "")
if emo:
parts.append(f"【情绪外化】{emo}")
cam = ";".join(x for x in [
anchors.get("device", ""), anchors.get("hand", ""), anchors.get("light", ""),
] if x)
if cam:
parts.append(f"【摄影特征】{cam}")
tex = ";".join(x for x in [anchors.get("skin", ""), anchors.get("flaws", "")] if x)
if tex:
parts.append(f"【质感锚点】{tex}")
if anchors.get("motion"):
parts.append(f"【表演细节】{anchors['motion']}")
if shot.text:
# 台词用 {} 包裹(特殊字符规范:区分"要说的话"与画面描述)
parts.append(f"【台词】{{{shot.text}}}")
# 音效用 <> 标注(规范);这些音效同时会进音效库匹配(sfx 链路)
_sfx = []
try:
_a = getattr(shot, "audio", None) or {}
_raw = (_a.get("sfx", []) if isinstance(_a, dict)
else getattr(_a, "sfx", [])) or []
if isinstance(_raw, (str, dict)):
_raw = [_raw]
for _s in _raw:
if isinstance(_s, dict):
_d = str(_s.get("desc") or "").strip()
else:
# SFX 对象(pydantic)→ 取 desc 属性
_d = str(getattr(_s, "desc", "") or _s).strip()
if _d:
_sfx.append(_d)
except Exception:
pass
if _sfx:
parts.append("音效:" + "".join(f"<{x}>" for x in _sfx))
parts.append(compose_constraints(anchors.get("_no", "")))
full = "\n".join(parts)
for w in check_conflicts(full):
log.warning(" ⚠️ 提示词矛盾:%s", w)
return _strip_shot_md(_with_ai_negatives(full, sb.global_anchors))
# ══ 分支 B:文生视频(无参考图)—— 完整视觉描述 + 写实锚点 ══
move = sc.camera_move or "自然运镜"
parts.append(
f"【生成任务】生成一个 {shot.duration:.0f} 秒的连续镜头(类型:{shot.type},"
f"画幅 {res});{'固定机位、自然表演' if '固定' in move else move}"
)
# ② 主要主体
# ★ 用资产**详情**而不是 ID("角色 中国"这种 ID 对模型毫无信息量)
# 研究结论:文字只写参考图**没有**的,但主体身份/场景环境必须具体
_char_ctx = _clean_studio_jargon((getattr(ga, "character_context", "") or "").strip())
subj = ";".join(x for x in [
_char_ctx if _char_ctx else (f"角色 {shot.character_id}" if shot.character_id else ""),
f"服装 {ga.wardrobe}" if ga.wardrobe else "",
f"动作:{shot.action}" if shot.action else "",
] if x)
if subj:
parts.append(f"【主要主体】{subj}")
# ③ 场景状态(同样用详情)
_loc_ctx = _clean_studio_jargon((getattr(ga, "location_context", "") or "").strip())
scene = ";".join(x for x in [
_loc_ctx if _loc_ctx else (f"地点 {shot.location_id}" if shot.location_id else ""),
f"光线 {ga.lighting_state}" if ga.lighting_state else anchors.get("light", ""),
f"镜头语言 {ga.camera_lens}" if ga.camera_lens else "",
f"道具 {', '.join(shot.props)}" if shot.props else "",
] if x)
if scene:
parts.append(f"【场景状态】{scene}")
# ④ 情绪目标 → 外化为具体动作
emo = externalize_emotion(shot.sentiment or "")
if emo:
parts.append(f"【情绪外化】{emo}")
# ⑤ 视觉风格(用写实锚点替换抽象形容词)
style = ";".join(x for x in [
f"拍摄风格 {ga.shooting_style}" if ga.shooting_style else "伪纪录片写实",
anchors.get("device", ""),
anchors.get("skin", ""),
anchors.get("flaws", ""),
] if x)
if style:
parts.append(f"【视觉风格】{style}")
# ⑥ 分段脚本(本镜头)
script = ";".join(x for x in [
f"景别 {sc.shot_size}" if sc.shot_size else "",
f"机位 {sc.angle}" if sc.angle else "",
f"摄影机 {sc.camera_move}" if sc.camera_move else "",
f"景深 {sc.depth_of_field}" if sc.depth_of_field else "",
f"光学 {sc.lens_feel}" if sc.lens_feel else "",
f"画面与动作:{shot.visual_description}" if shot.visual_description else "",
f"台词:{shot.text}" if shot.text else "",
] if x)
if script:
parts.append(f"【分段脚本】{script}")
# 约束(护栏)
parts.append(compose_constraints(anchors.get("_no", "")))
full = "\n".join(parts)
for w in check_conflicts(full):
log.warning(" ⚠️ 提示词矛盾:%s", w)
return _strip_shot_md(_with_ai_negatives(full, sb.global_anchors))
# 便捷入口
def _strip_shot_md(p: str) -> str:
"""去掉提示词里的 markdown 符号(** __ ` 等)
⚠️ 生成模型读的是**自然语言**,markdown 标记对它只是噪音。
实测:提示词里出现过 `**严格以参考图中的人物外貌...**` 这种写法。
"""
import re as _re
if not p:
return p
s = _re.sub(r"\*{1,3}([^*\n]+)\*{1,3}", r"\1", p) # **粗体** / *斜体*
s = _re.sub(r"__([^_\n]+)__", r"\1", s) # __粗体__
s = _re.sub(r"`([^`\n]+)`", r"\1", s) # `代码`
s = _re.sub(r"[ \t]{2,}", " ", s)
return s
def render_storyboard(sb: Storyboard, output_path: str,
generate_fn: Optional[Callable] = None,
progress_cb: ProgressCB = None) -> dict:
return StoryboardPipeline().render(sb, output_path, progress_cb=progress_cb,
generate_fn=generate_fn)