""" 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)