返回 VideoClaw
editor_agent.py
根目录 / video-claw / video-claw / backend / core / agents / editor_agent.py
1 # -*- coding: utf-8 -*-
2 """
3 阶段6: 后期制作智能体
4 拼接用户在阶段5选定的视频片段 → 最终成片
5 """
6
7 import os
8 import re
9 import asyncio
10 import logging
11 import subprocess
12 from typing import Any, Optional, Dict
13
14 from .base_agent import AgentInterface
15
16 logger = logging.getLogger(__name__)
17
18
19 class VideoEditorAgent(AgentInterface):
20 """后期制作:拼接用户选择的视频片段 → 最终成片"""
21
22 def __init__(self):
23 super().__init__(name="VideoEditor")
24
25 async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
26 input_data = self._merge_session_params(input_data)
27 sid = input_data["session_id"]
28 regenerate_episodes = set()
29 if isinstance(intervention, dict) and isinstance(intervention.get("regenerate_episodes"), list):
30 regenerate_episodes = {
31 int(ep)
32 for ep in intervention.get("regenerate_episodes", [])
33 if str(ep).isdigit()
34 }
35
36 # 从编排器注入的统一 session 快照读取阶段5数据。
37 artifacts = self._session_artifacts(input_data)
38 video_art = artifacts.get("video_generation", {})
39 clips_list = video_art.get("clips", [])
40 post_art = artifacts.get("post_production", {})
41
42 # 获取剧集标题映射 (从 Storyboard)
43 storyboard_art = artifacts.get("storyboard", {})
44 episodes_info = storyboard_art.get("episodes", [])
45 ep_title_map = {int(ep.get("episode_number", 0)): ep.get("act_title", f"第 {ep.get('episode_number')} 集")
46 for ep in episodes_info if ep.get("episode_number")}
47
48 if not clips_list:
49 # 兼容旧逻辑:如果 artifacts 里没有,尝试从 input_data 获取
50 selected_clips: dict = input_data.get("selected_clips", {})
51 if not selected_clips:
52 raise Exception("未找到选定的视频片段数据,请先完成阶段5")
53
54 self._report_progress("后期制作", "准备视频片段...", 5)
55
56 def run():
57 video_dir = os.path.join('code/result/video', str(sid))
58 os.makedirs(video_dir, exist_ok=True)
59 output_dir = os.path.join(video_dir, 'output')
60 os.makedirs(output_dir, exist_ok=True)
61
62 # 按剧集分组片段
63 episodes_map = {} # { episode_index: [clip_paths] }
64
65 if clips_list:
66 for clip in clips_list:
67 path = clip.get("selected")
68 if not path or not os.path.exists(path):
69 logger.warning(f"[{sid}] Clip missing: {clip.get('id')} → {path}")
70 continue
71
72 # 优先使用片段数据中的 episode 字段
73 ep_idx = clip.get("episode")
74
75 # 如果没有 episode 字段,则尝试通过 ID 解析
76 if ep_idx is None:
77 match = re.search(r'(?:seg_|shot_)?(\d{1,3})_\d{1,3}', clip.get("id", ""))
78 if match:
79 ep_idx = int(match.group(1))
80 else:
81 # 最后的归底方案:从 name 提取或默认为 1
82 name_match = re.search(r'第(\d+)集', clip.get("name", ""))
83 if not name_match:
84 name_match = re.search(r'(\d+)', clip.get("name", ""))
85 ep_idx = int(name_match.group(1)) if name_match else 1
86
87 if regenerate_episodes and int(ep_idx) not in regenerate_episodes:
88 continue
89
90 episodes_map.setdefault(int(ep_idx), []).append(path)
91 else:
92 # 兼容旧逻辑
93 def sort_key(k: str) -> tuple:
94 return tuple(int(n) for n in re.findall(r'\d+', k)) or (999,)
95 selected_clips = input_data.get("selected_clips", {})
96 for shot_id in sorted(selected_clips.keys(), key=sort_key):
97 path = selected_clips[shot_id]
98 if os.path.exists(path):
99 # 旧逻辑默认全部归为第1集
100 if not regenerate_episodes or 1 in regenerate_episodes:
101 episodes_map.setdefault(1, []).append(path)
102 else:
103 logger.warning(f"[{sid}] Clip missing: {shot_id} → {path}")
104
105 if not episodes_map:
106 raise Exception("没有可用于拼接的视频文件")
107
108 final_videos = []
109 sorted_episodes = sorted(episodes_map.keys())
110 total_eps = len(sorted_episodes)
111
112 ffmpeg_exe = 'ffmpeg'
113
114 for i, ep_idx in enumerate(sorted_episodes):
115 self._report_progress("后期制作", f"正在拼接第 {ep_idx} 集 ({i+1}/{total_eps})...", int(20 + (i/total_eps)*70))
116
117 clip_paths = episodes_map[ep_idx]
118 list_file = os.path.join(video_dir, f'concat_list_ep{ep_idx}.txt')
119 output = os.path.join(output_dir, f'{sid}_ep{ep_idx}.mp4')
120
121 with open(list_file, 'w', encoding='utf-8') as f:
122 for p in clip_paths:
123 abs_p = os.path.abspath(p).replace('\\', '/')
124 f.write(f"file '{abs_p}'\n")
125
126 cmd = [
127 ffmpeg_exe, '-y', '-f', 'concat', '-safe', '0',
128 '-i', list_file,
129 '-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
130 '-c:a', 'aac', '-pix_fmt', 'yuv420p',
131 '-movflags', '+faststart', output
132 ]
133
134 logger.info(f"[{sid}] Running ffmpeg for Ep {ep_idx}: {cmd}")
135 try:
136 result = subprocess.run(cmd, capture_output=True, text=True, check=True)
137 except subprocess.CalledProcessError as e:
138 logger.error(f"FFmpeg failed with exit code {e.returncode}")
139 logger.error(f"FFmpeg stderr: {e.stderr}")
140 raise Exception(f"视频拼接失败: {e.stderr}")
141
142 ep_title = ep_title_map.get(ep_idx, f"第 {ep_idx} 集")
143 final_videos.append({
144 "episode": ep_idx,
145 "path": output,
146 "name": ep_title
147 })
148
149 return final_videos
150
151 loop = asyncio.get_running_loop()
152 final_results = await loop.run_in_executor(None, run)
153
154 if regenerate_episodes and isinstance(post_art, dict):
155 existing_videos = post_art.get("final_videos", [])
156 if isinstance(existing_videos, list):
157 regenerated_eps = {item.get("episode") for item in final_results if isinstance(item, dict)}
158 preserved = [
159 item for item in existing_videos
160 if isinstance(item, dict) and item.get("episode") not in regenerated_eps
161 ]
162 final_results = sorted(
163 preserved + final_results,
164 key=lambda item: item.get("episode", 999) if isinstance(item, dict) else 999,
165 )
166
167 self._report_progress("后期制作", "成片完成", 100)
168
169 return {
170 "payload": {
171 "session_id": sid,
172 "final_videos": final_results,
173 "final_video": final_results[0]["path"] if final_results else "",
174 },
175 "stage_completed": True,
176 }
177
177 lines PYTHON