返回 VideoClaw
orchestrator.py
根目录 / video-claw / video-claw / backend / core / orchestrator.py
1 # -*- coding: utf-8 -*-
2 """
3 核心编排器 / 工作流引擎
4 管理六阶段状态机,协调各智能体执行,支持用户在任意阶段介入
5 """
6
7 import json
8 import logging
9 import os
10 import re
11 import shutil
12 import threading
13 import time
14 import copy
15 import asyncio
16 from datetime import datetime
17 from enum import Enum
18 from typing import Any, Callable, Dict, List, Optional, Set
19
20 from core.agents import (
21 ScriptWriterAgent,
22 CharacterDesignerAgent,
23 StoryboardAgent,
24 ReferenceGeneratorAgent,
25 VideoDirectorAgent,
26 VideoEditorAgent,
27 )
28
29 logger = logging.getLogger(__name__)
30
31
32 class WorkflowStage(str, Enum):
33 """工作流阶段"""
34 INIT = "init"
35 SCRIPT_GENERATION = "script_generation"
36 CHARACTER_DESIGN = "character_design"
37 STORYBOARD = "storyboard"
38 REFERENCE_GENERATION = "reference_generation"
39 VIDEO_GENERATION = "video_generation"
40 POST_PRODUCTION = "post_production"
41 COMPLETED = "completed"
42
43
44 STAGE_ORDER = [
45 WorkflowStage.SCRIPT_GENERATION,
46 WorkflowStage.CHARACTER_DESIGN,
47 WorkflowStage.STORYBOARD,
48 WorkflowStage.REFERENCE_GENERATION,
49 WorkflowStage.VIDEO_GENERATION,
50 WorkflowStage.POST_PRODUCTION,
51 ]
52
53 SESSION_META_KEYS = (
54 "idea",
55 "user_textbox_input",
56 "style",
57 "video_ratio",
58 "video_resolution",
59 "expand_idea",
60 "llm_model",
61 "vlm_model",
62 "image_t2i_model",
63 "image_it2i_model",
64 "video_model",
65 "video_first_frame_model",
66 "video_start_end_model",
67 "video_reference_model",
68 "video_generation_mode",
69 "video_style",
70 "enable_concurrency",
71 "web_search",
72 "episodes",
73 )
74
75
76 def _normalize_meta_value(value: Any) -> Any:
77 if isinstance(value, str):
78 lower = value.lower()
79 if lower == "true":
80 return True
81 if lower == "false":
82 return False
83 return value
84
85
86 def _extract_session_meta(data: Dict[str, Any]) -> Dict[str, Any]:
87 """Restore session-level generation params from nested or legacy flat storage."""
88 meta: Dict[str, Any] = {}
89 nested_meta = data.get("meta")
90 if isinstance(nested_meta, dict):
91 meta.update({k: _normalize_meta_value(v) for k, v in nested_meta.items() if v is not None})
92 # Legacy session compatibility: old session JSON stored these fields at the root instead of under meta.
93 for key in SESSION_META_KEYS:
94 if key not in meta and key in data and data[key] is not None:
95 meta[key] = _normalize_meta_value(data[key])
96 return meta
97
98
99 class WorkflowState:
100 """工作流状态"""
101
102 # 阶段状态说明:
103 # - pending: 阶段还没有产物,也没有在运行
104 # - running: 阶段正在执行
105 # - waiting: 阶段已产出内容,但需要用户介入(如选择角色、选择图片等)
106 # - completed: 阶段已完成,可进入下一阶段
107 # - stopped: 阶段被用户手动停止
108 # - error: 阶段执行中遇到错误
109
110 def __init__(self, session_id: str):
111 self.session_id = session_id
112 self.current_stage: WorkflowStage = WorkflowStage.INIT
113 self.status: Dict[str, str] = {
114 stage.value: "pending" for stage in WorkflowStage if stage != WorkflowStage.INIT and stage != WorkflowStage.COMPLETED
115 }
116 self.artifacts: Dict[str, Any] = {}
117 self.error: Optional[str] = None
118 self.started_at: Optional[datetime] = None
119 self.updated_at: datetime = datetime.now()
120 self.meta: Dict[str, Any] = {}
121 self.stage_progress: Dict[str, Dict[str, Any]] = {}
122
123 def to_dict(self) -> Dict:
124 return {
125 "session_id": self.session_id,
126 "current_stage": self.current_stage.value,
127 "status": copy.deepcopy(self.status),
128 "error": self.error,
129 "artifacts": copy.deepcopy(self.artifacts),
130 "meta": copy.deepcopy(self.meta),
131 "stage_progress": copy.deepcopy(self.stage_progress),
132 "updated_at": self.updated_at,
133 }
134
135
136 class WorkflowEngine:
137 """工作流引擎 - 管理六阶段状态机"""
138
139 def __init__(self):
140 self.agent_factories = {
141 WorkflowStage.SCRIPT_GENERATION: ScriptWriterAgent,
142 WorkflowStage.CHARACTER_DESIGN: CharacterDesignerAgent,
143 WorkflowStage.STORYBOARD: StoryboardAgent,
144 WorkflowStage.REFERENCE_GENERATION: ReferenceGeneratorAgent,
145 WorkflowStage.VIDEO_GENERATION: VideoDirectorAgent,
146 WorkflowStage.POST_PRODUCTION: VideoEditorAgent,
147 }
148 self.sessions: Dict[str, WorkflowState] = {}
149 self._stop_events: Dict[str, threading.Event] = {}
150 self._active_sessions: Set[str] = set()
151 self._background_tasks: Set[Any] = set()
152 self._state_lock = threading.RLock()
153 self._session_dir = os.path.join(
154 os.path.dirname(os.path.abspath(__file__)), '..', 'code', 'data', 'sessions'
155 )
156 os.makedirs(self._session_dir, exist_ok=True)
157 self._load_sessions_from_disk()
158
159 def get_or_create_state(self, session_id: str) -> WorkflowState:
160 with self._state_lock:
161 if session_id not in self.sessions:
162 loaded_state = self.get_state(session_id)
163 if loaded_state is None:
164 self.sessions[session_id] = WorkflowState(session_id=session_id)
165 if session_id not in self._stop_events:
166 self._stop_events[session_id] = threading.Event()
167 return self.sessions[session_id]
168
169 def get_state(self, session_id: str) -> Optional[WorkflowState]:
170 with self._state_lock:
171 # 先从内存中获取
172 if session_id in self.sessions:
173 return self.sessions[session_id]
174
175 # 内存中没有,从磁盘加载
176 path = os.path.join(self._session_dir, f"{session_id}.json")
177 if os.path.exists(path):
178 try:
179 with open(path, 'r', encoding='utf-8') as f:
180 data = json.load(f)
181
182 # 从磁盘数据恢复 WorkflowState
183 state = WorkflowState(session_id=session_id)
184
185 stage_str = data.get('current_stage')
186 state.current_stage = WorkflowStage(stage_str) if stage_str else WorkflowStage.INIT
187
188 loaded_status = data.get('status')
189 if isinstance(loaded_status, str):
190 stages_completed = data.get('stages_completed', [])
191 for stage in WorkflowStage:
192 if stage != WorkflowStage.INIT and stage != WorkflowStage.COMPLETED:
193 if stage.value in stages_completed:
194 state.status[stage.value] = "completed"
195 elif stage.value == state.current_stage.value:
196 state.status[stage.value] = loaded_status
197 else:
198 state.status[stage.value] = "pending"
199 elif isinstance(loaded_status, dict):
200 state.status = loaded_status
201
202 state.artifacts = data.get('artifacts', {})
203 state.stage_progress = data.get('stage_progress', {})
204 state.meta = _extract_session_meta(data)
205 state.updated_at = data.get('updated_at', 0)
206
207 # 缓存到内存
208 self.sessions[session_id] = state
209 return state
210 except json.JSONDecodeError as e:
211 logger.warning(f"Session file {session_id} is corrupted, ignoring: {e}")
212 except Exception as e:
213 logger.warning(f"Failed to load session {session_id} from disk: {e}")
214
215 return None
216
217 def get_stop_event(self, session_id: str) -> threading.Event:
218 with self._state_lock:
219 if session_id not in self._stop_events:
220 self._stop_events[session_id] = threading.Event()
221 return self._stop_events[session_id]
222
223 def create_session(self, session_id: str, meta: Dict[str, Any]) -> Dict[str, Any]:
224 """Create or initialize a workflow session through the engine-owned state."""
225 with self._state_lock:
226 state = self.get_or_create_state(session_id)
227 state.started_at = datetime.now()
228 if not isinstance(state.status, dict):
229 state.status = {}
230 state.status[state.current_stage.value] = "completed"
231 state.meta = copy.deepcopy(meta)
232 state.updated_at = datetime.now()
233 self.save_session_to_disk(session_id, meta)
234 return {
235 "session_id": session_id,
236 "status": copy.deepcopy(state.status),
237 "meta": copy.deepcopy(state.meta),
238 }
239
240 def get_status_snapshot(self, session_id: str) -> Optional[Dict[str, Any]]:
241 """Return a deep-copied session snapshot from the unified in-memory state."""
242 with self._state_lock:
243 state = self.get_state(session_id)
244 return state.to_dict() if state else None
245
246 def get_artifact_snapshot(self, session_id: str, stage: str) -> Optional[Any]:
247 """Return a deep-copied artifact snapshot from the unified in-memory state."""
248 with self._state_lock:
249 state = self.get_state(session_id)
250 if not state:
251 raise KeyError(f"Session not found: {session_id}")
252 artifact = state.artifacts.get(stage)
253 return copy.deepcopy(artifact) if artifact is not None else None
254
255 def update_session_meta(self, session_id: str, updates: Dict[str, Any], allowed_keys: tuple[str, ...]) -> Dict[str, Any]:
256 """Update session-level generation settings through the engine-owned meta store."""
257 with self._state_lock:
258 state = self.get_state(session_id)
259 if not state:
260 raise KeyError(f"Session not found: {session_id}")
261 if not state.meta:
262 state.meta = {}
263 for key in allowed_keys:
264 if key in updates:
265 state.meta[key] = updates[key]
266 state.updated_at = datetime.now()
267 self.save_session_to_disk(session_id)
268 return {"status": "ok", "meta": copy.deepcopy(state.meta)}
269
270 def prepare_stage_execution(self, session_id: str, stage: str, body: Dict[str, Any]) -> tuple[WorkflowState, Dict[str, Any]]:
271 """Build stage input from current meta/artifacts without exposing mutable state to routers."""
272 with self._state_lock:
273 state = self.get_or_create_state(session_id)
274 input_data = copy.deepcopy(body) if isinstance(body, dict) else {}
275 input_data["session_id"] = session_id
276
277 for key, value in copy.deepcopy(state.meta).items():
278 if value is not None and (key not in input_data or not input_data[key]):
279 input_data[key] = value
280 self._inject_user_selections(copy.deepcopy(state.artifacts), stage, input_data)
281 return state, input_data
282
283 def prepare_intervention_execution(
284 self,
285 session_id: str,
286 stage: str,
287 modifications: Dict[str, Any],
288 ) -> tuple[WorkflowState, Dict[str, Any]]:
289 """Build intervention input from the latest artifact/meta snapshot."""
290 with self._state_lock:
291 state = self.get_state(session_id)
292 if not state:
293 raise KeyError(f"Session not found: {session_id}")
294
295 current_artifact = copy.deepcopy(state.artifacts.get(stage, {}))
296 input_data = current_artifact if isinstance(current_artifact, dict) else {}
297 input_data["session_id"] = session_id
298 for key, value in copy.deepcopy(state.meta).items():
299 if value is not None and key not in input_data:
300 input_data[key] = value
301 self._inject_user_selections(copy.deepcopy(state.artifacts), stage, input_data)
302 input_data.update(modifications or {})
303 return state, input_data
304
305 @staticmethod
306 def _inject_user_selections(artifacts: Dict[str, Any], stage: str, data: Dict[str, Any]):
307 """Inject persisted user choices into downstream stage input."""
308 if stage == 'video_generation' and 'selected_images' not in data:
309 ref_art = artifacts.get('reference_generation', {})
310 if isinstance(ref_art, dict):
311 scenes = ref_art.get('scenes', [])
312 selected_images = {
313 s['id']: s['selected']
314 for s in scenes
315 if isinstance(s, dict) and s.get('id') and s.get('selected')
316 }
317 if selected_images:
318 data['selected_images'] = selected_images
319
320 if stage == 'video_generation' and 'clips' not in data:
321 vid_art = artifacts.get('video_generation', {})
322 if isinstance(vid_art, dict):
323 clips = vid_art.get('clips', [])
324 if clips:
325 data['clips'] = clips
326
327 if stage == 'post_production' and 'selected_clips' not in data:
328 vid_art = artifacts.get('video_generation', {})
329 if isinstance(vid_art, dict):
330 clips = vid_art.get('clips', [])
331 selected_clips = {
332 c['id']: c['selected']
333 for c in clips
334 if isinstance(c, dict) and c.get('id') and c.get('selected')
335 }
336 if selected_clips:
337 data['selected_clips'] = selected_clips
338
339 def persist_session_snapshot(self, session_id: str) -> Dict[str, Any]:
340 """Persist the latest engine-owned state and return a status snapshot."""
341 with self._state_lock:
342 state = self.get_state(session_id)
343 if not state:
344 raise KeyError(f"Session not found: {session_id}")
345 self.save_session_to_disk(session_id)
346 return copy.deepcopy(state.status)
347
348 def stop_session(self, session_id: str):
349 self.get_stop_event(session_id).set()
350 with self._state_lock:
351 state = self.get_state(session_id)
352 if state and state.status.get(state.current_stage.value) == "running":
353 state.status[state.current_stage.value] = "stopped"
354 state.error = None # 清除错误,因为是主动停止
355 state.updated_at = datetime.now()
356 current_progress = state.stage_progress.get(state.current_stage.value, {})
357 state.stage_progress[state.current_stage.value] = {
358 **current_progress,
359 "step": "已停止",
360 "message": "已停止",
361 "updated_at": time.time(),
362 }
363 self.save_session_to_disk(session_id)
364 logger.info(f"Session {session_id} stop signal sent")
365
366 def reset_stop_event(self, session_id: str):
367 with self._state_lock:
368 if session_id in self._stop_events:
369 self._stop_events[session_id].clear()
370
371 def track_background_task(self, task: Any):
372 """Keep detached workflow tasks alive after an SSE client disconnects."""
373 with self._state_lock:
374 self._background_tasks.add(task)
375
376 def _cleanup(done_task: Any):
377 with self._state_lock:
378 self._background_tasks.discard(done_task)
379 try:
380 done_task.exception()
381 except asyncio.CancelledError:
382 pass
383 except Exception:
384 logger.exception("Detached workflow task failed")
385
386 task.add_done_callback(_cleanup)
387
388 def _get_next_stage(self, current: WorkflowStage) -> Optional[WorkflowStage]:
389 try:
390 idx = STAGE_ORDER.index(current)
391 if idx + 1 < len(STAGE_ORDER):
392 return STAGE_ORDER[idx + 1]
393 except ValueError:
394 pass
395 return None
396
397 # ──────────── 跨阶段同步逻辑 ────────────
398 def _sync_artifacts_cross_stages(self, state: WorkflowState, stage: WorkflowStage, payload: Dict):
399 """
400 跨阶段数据同步钩子:当某个阶段产生新数据时,自动推送到后续阶段。
401 例如:剧本续写产生的新角色/分镜,自动同步到 Stage 2 和 Stage 3。
402 """
403 if not isinstance(payload, dict):
404 return
405
406 # 案例 1: 剧本续写确认 (Script Confirmation)
407 if stage == WorkflowStage.SCRIPT_GENERATION:
408 # 获取合并后的角色、场景和剧集
409 new_chars = payload.get("new_characters", [])
410 new_settings = payload.get("new_settings", [])
411 new_episodes = payload.get("new_episodes", [])
412 # 如果没有新增剧集数据,无需同步到 Stage 2 和 3
413 if not new_episodes:
414 return
415
416 # (A) 同步到第二阶段 (角色设计)
417 if new_chars or new_settings:
418 # 注意:WorkflowStage.CHARACTER_DESIGN 是 Enum,这里需要使用 .value
419 char_stage_key = WorkflowStage.CHARACTER_DESIGN.value
420 char_art = state.artifacts.get(char_stage_key)
421 if not isinstance(char_art, dict):
422 char_art = {"characters": [], "settings": [], "version": 1}
423
424 existing_chars = char_art.get("characters", [])
425 for nc in new_chars:
426 if not any(c.get("id") == nc.get("character_id") for c in existing_chars):
427 existing_chars.append({
428 "id": nc.get("character_id"), "name": nc.get("name"), "description": nc.get("description"),
429 "selected": "", "versions": []
430 })
431 char_art["characters"] = existing_chars
432
433 existing_sets = char_art.get("settings", [])
434 for ns in new_settings:
435 if not any(s.get("id") == ns.get("setting_id") for s in existing_sets):
436 existing_sets.append({
437 "id": ns.get("setting_id"), "name": ns.get("name"), "description": ns.get("description"),
438 "selected": "", "versions": []
439 })
440 char_art["settings"] = existing_sets
441 state.artifacts[char_stage_key] = char_art
442
443 # (B) 同步到第三阶段 (分镜设计)
444 if new_episodes:
445 story_stage_key = WorkflowStage.STORYBOARD.value
446 story_art = state.artifacts.get(story_stage_key)
447 if not isinstance(story_art, dict):
448 story_art = {"episodes": [], "version": 1}
449
450 existing_eps = story_art.get("episodes", [])
451 for ne in new_episodes:
452 ep_num = ne.get("episode_number")
453 if not any(e.get("episode_number") == ep_num for e in existing_eps):
454 existing_eps.append({
455 "episode_number": ep_num,
456 "episode_title": ne.get("act_title") or f"第{ep_num}集",
457 "segments": []
458 })
459 existing_eps.sort(key=lambda x: x.get("episode_number", 0))
460 story_art["episodes"] = existing_eps
461 state.artifacts[story_stage_key] = story_art
462
463 # 案例 2: 分镜生成或修改同步到第四、第五阶段 (Storyboard -> Ref/Video)
464 if stage == WorkflowStage.STORYBOARD:
465 episodes = payload.get("episodes", []) if isinstance(payload, dict) else payload
466 if not isinstance(episodes, list):
467 return
468
469 all_sync_clips = []
470 for ep in episodes:
471 if not isinstance(ep, dict): continue
472 ep_n = ep.get("episode_number", 0)
473 for s_i, seg in enumerate(ep.get("segments", []), 1):
474 if not isinstance(seg, dict): continue
475 seg_id = seg.get("segment_id", f"seg_{ep_n:02d}_{s_i:02d}")
476
477 # 汇总 segment 级别的描述和时长
478 shots = seg.get("shots", [])
479 desc_video = " ".join([sh.get("plot") or sh.get("content") or "" for sh in shots]).strip()
480 desc_ref = " ".join([sh.get("visual_prompt") or sh.get("plot") or sh.get("content") or "" for sh in shots]).strip()
481 total_dur = seg.get("total_duration") or sum([sh.get("duration", 0) for sh in shots]) or 10
482
483 all_sync_clips.append({
484 "segment_id": seg_id,
485 "desc_video": desc_video,
486 "desc_ref": desc_ref,
487 "duration": total_dur,
488 "episode": ep_n,
489 "index": s_i,
490 "name": f"第{ep_n}集-片段{s_i}"
491 })
492
493 if not all_sync_clips:
494 return
495
496 # (A) 同步到第四阶段 (参考图生成)
497 ref_stage_key = WorkflowStage.REFERENCE_GENERATION.value
498 ref_art = state.artifacts.get(ref_stage_key)
499 if not isinstance(ref_art, dict):
500 ref_art = {"scenes": [], "version": 1}
501
502 existing_scenes = ref_art.get("scenes", [])
503 for c_info in all_sync_clips:
504 id = c_info["segment_id"]
505 idx = next((i for i, s in enumerate(existing_scenes) if s.get("id") == id), -1)
506 if idx == -1:
507 existing_scenes.append({
508 "id": id,
509 "name": c_info["name"],
510 "index": c_info["index"],
511 "description": c_info["desc_ref"],
512 "selected": "",
513 "versions": [],
514 "status": "pending",
515 "episode": c_info["episode"]
516 })
517 else:
518 # 更新已有记录
519 existing_scenes[idx]["description"] = c_info["desc_ref"]
520 existing_scenes[idx]["episode"] = c_info["episode"]
521 existing_scenes[idx]["index"] = c_info["index"]
522 existing_scenes[idx]["name"] = c_info["name"]
523
524 # 排序:确保片段显示顺序正确 (按 id 排序,例如 seg_01_01 < seg_07_01)
525 existing_scenes.sort(key=lambda x: x.get("id", ""))
526 ref_art["scenes"] = existing_scenes
527 state.artifacts[ref_stage_key] = ref_art
528
529 # (B) 同步到第五阶段 (视频生成)
530 video_stage_key = WorkflowStage.VIDEO_GENERATION.value
531 video_art = state.artifacts.get(video_stage_key)
532 if not isinstance(video_art, dict):
533 video_art = {"clips": [], "version": 1}
534
535 existing_clips = video_art.get("clips", [])
536 for c_info in all_sync_clips:
537 id = c_info["segment_id"]
538 idx = next((i for i, c in enumerate(existing_clips) if c.get("id") == id), -1)
539 if idx == -1:
540 existing_clips.append({
541 "id": id,
542 "name": c_info["name"],
543 "index": c_info["index"],
544 "description": c_info["desc_video"],
545 "duration": c_info["duration"],
546 "selected": "",
547 "versions": [],
548 "status": "pending",
549 "episode": c_info["episode"]
550 })
551 else:
552 existing_clips[idx]["description"] = c_info["desc_video"]
553 existing_clips[idx]["duration"] = c_info["duration"]
554 existing_clips[idx]["episode"] = c_info["episode"]
555 existing_clips[idx]["index"] = c_info["index"]
556 existing_clips[idx]["name"] = c_info["name"]
557
558 # 排序:确保片段显示顺序正确
559 existing_clips.sort(key=lambda x: x.get("id", ""))
560 video_art["clips"] = existing_clips
561 state.artifacts[video_stage_key] = video_art
562
563 # 案例 3: 角色/场景描述修改同步回剧本元数据,保证后续阶段读到用户最新描述。
564 if stage == WorkflowStage.CHARACTER_DESIGN:
565 script_art = state.artifacts.get(WorkflowStage.SCRIPT_GENERATION.value)
566 if not isinstance(script_art, dict):
567 return
568
569 char_by_id = {
570 c.get("id"): c for c in payload.get("characters", [])
571 if isinstance(c, dict) and c.get("id")
572 }
573 setting_by_id = {
574 s.get("id"): s for s in payload.get("settings", [])
575 if isinstance(s, dict) and s.get("id")
576 }
577
578 for char in script_art.get("characters", []):
579 if not isinstance(char, dict):
580 continue
581 source = char_by_id.get(char.get("character_id") or char.get("id"))
582 if source:
583 for key in ("name", "description", "species"):
584 if source.get(key):
585 char[key] = source[key]
586
587 for setting in script_art.get("settings", []):
588 if not isinstance(setting, dict):
589 continue
590 source = setting_by_id.get(setting.get("setting_id") or setting.get("id"))
591 if source:
592 for key in ("name", "description"):
593 if source.get(key):
594 setting[key] = source[key]
595
596 def _recalculate_all_statuses(self, state: WorkflowState):
597 """
598 根据各阶段 artifacts 的数据完整性重新计算 status 字典。
599 逻辑:
600 - 如果 artifacts[stage] 不存在: pending
601 - 如果存在且包含核心列表(characters/scenes/clips等):
602 - 如果列表项存在 selected 为空的情况: waiting
603 - 如果列表项全部已选择(或不需要选择): completed
604 """
605 for stage in WorkflowStage:
606 if stage in [WorkflowStage.INIT, WorkflowStage.COMPLETED]:
607 continue
608 s_val = stage.value
609 # 如果当前阶段正在运行,不自动覆盖其为 completed/waiting (除非它目前是空)
610 current_s_status = state.status.get(s_val, "pending")
611 if current_s_status == "running" or current_s_status == "error":
612 continue
613
614 art = state.artifacts.get(s_val)
615 if not art or not isinstance(art, dict):
616 state.status[s_val] = "pending"
617 continue
618
619 # 检查是否有待处理的“空数据占位”
620 has_pending = False
621
622 if s_val == "character_design":
623 chars = art.get("characters", [])
624 sets = art.get("settings", [])
625 if any(not c.get("selected") for c in chars) or any(not s.get("selected") for s in sets):
626 has_pending = True
627 elif s_val == "storyboard":
628 # 检查分镜阶段:如果存在剧集(episode)但其 segments 为空,视为 waiting
629 episodes = art.get("episodes", [])
630 if not episodes or any(not ep.get("segments") for ep in episodes):
631 has_pending = True
632 elif s_val == "reference_generation":
633 scenes = art.get("scenes", [])
634 if not scenes or any(not s.get("selected") for s in scenes):
635 has_pending = True
636 elif s_val == "video_generation":
637 clips = art.get("clips", [])
638 if not clips or any(not c.get("selected") for c in clips):
639 has_pending = True
640
641 if has_pending:
642 state.status[s_val] = "waiting"
643 else:
644 # 已经有数据且没有 pending 项,标记为完成
645 state.status[s_val] = "completed"
646
647 @staticmethod
648 def _is_background_item_regeneration(stage: WorkflowStage, intervention: Optional[Dict]) -> bool:
649 if not isinstance(intervention, dict):
650 return False
651 if stage == WorkflowStage.CHARACTER_DESIGN:
652 return isinstance(intervention.get("regenerate_characters"), list) or isinstance(intervention.get("regenerate_settings"), list)
653 if stage == WorkflowStage.REFERENCE_GENERATION:
654 return isinstance(intervention.get("regenerate_scenes"), list)
655 if stage == WorkflowStage.VIDEO_GENERATION:
656 return isinstance(intervention.get("regenerate_clips"), list)
657 if stage == WorkflowStage.POST_PRODUCTION:
658 return isinstance(intervention.get("regenerate_episodes"), list)
659 return False
660
661 @staticmethod
662 def _background_regeneration_targets(stage: WorkflowStage, intervention: Optional[Dict]) -> Dict[str, Set[str]]:
663 if not isinstance(intervention, dict):
664 return {}
665 if stage == WorkflowStage.CHARACTER_DESIGN:
666 return {
667 "characters": set(intervention.get("regenerate_characters") or []),
668 "settings": set(intervention.get("regenerate_settings") or []),
669 }
670 if stage == WorkflowStage.REFERENCE_GENERATION:
671 return {"scenes": set(intervention.get("regenerate_scenes") or [])}
672 if stage == WorkflowStage.VIDEO_GENERATION:
673 return {"clips": set(intervention.get("regenerate_clips") or [])}
674 return {}
675
676 @staticmethod
677 def _merge_item_regeneration_payload(
678 existing: Any,
679 payload: Any,
680 item_keys: List[str],
681 target_ids_by_key: Optional[Dict[str, Set[str]]] = None,
682 ) -> Dict:
683 """Merge concurrent single-item regeneration results without clobbering fresher items."""
684 if not isinstance(existing, dict):
685 existing = {}
686 if not isinstance(payload, dict):
687 return copy.deepcopy(existing)
688
689 merged = copy.deepcopy(existing)
690 for key, value in payload.items():
691 if key not in item_keys:
692 merged[key] = copy.deepcopy(value)
693
694 for key in item_keys:
695 existing_items = merged.get(key, [])
696 payload_items = payload.get(key, [])
697 if not isinstance(existing_items, list):
698 existing_items = []
699 if not isinstance(payload_items, list):
700 merged[key] = existing_items
701 continue
702
703 target_ids = (target_ids_by_key or {}).get(key)
704 by_id = {
705 item.get("id"): copy.deepcopy(item)
706 for item in existing_items
707 if isinstance(item, dict) and item.get("id")
708 }
709 order = [
710 item.get("id")
711 for item in existing_items
712 if isinstance(item, dict) and item.get("id")
713 ]
714
715 for item in payload_items:
716 if not isinstance(item, dict) or not item.get("id"):
717 continue
718 item_id = item["id"]
719 if target_ids is not None and item_id not in target_ids:
720 continue
721 current = by_id.get(item_id)
722 if item_id not in order:
723 order.append(item_id)
724 if not current:
725 by_id[item_id] = copy.deepcopy(item)
726 continue
727 current_versions = current.get("versions") if isinstance(current.get("versions"), list) else []
728 item_versions = item.get("versions") if isinstance(item.get("versions"), list) else []
729 merged_versions = WorkflowEngine._merge_asset_versions(current_versions, item_versions)
730 if len(item_versions) > len(current_versions):
731 merged_item = {**current, **copy.deepcopy(item)}
732 merged_item["versions"] = merged_versions
733 if current.get("selected"):
734 merged_item["selected"] = current.get("selected")
735 if item.get("status") in {"done", "failed"}:
736 merged_item["status"] = "done"
737 by_id[item_id] = merged_item
738 elif len(item_versions) == len(current_versions):
739 merged_item = {**current, **copy.deepcopy(item)}
740 if not item.get("selected") and current.get("selected"):
741 merged_item["selected"] = current.get("selected")
742 if current.get("selected"):
743 merged_item["selected"] = current.get("selected")
744 if item.get("status") in {"done", "failed"}:
745 merged_item["status"] = "done"
746 if merged_versions:
747 merged_item["versions"] = merged_versions
748 if (
749 current.get("status") == "failed"
750 and item.get("status") == "done"
751 and not current.get("selected")
752 ):
753 merged_item["status"] = "failed"
754 by_id[item_id] = merged_item
755
756 merged[key] = [by_id[item_id] for item_id in order if item_id in by_id]
757 return merged
758
759 @staticmethod
760 def _merge_asset_versions(current_versions: Any, new_versions: Any) -> List[str]:
761 merged: List[str] = []
762 for value in list(current_versions if isinstance(current_versions, list) else []) + list(new_versions if isinstance(new_versions, list) else []):
763 if value and value not in merged:
764 merged.append(value)
765 return merged
766
767 @staticmethod
768 def _selected_after_asset_update(current_selected: Any, new_selected: Any) -> Any:
769 return current_selected or new_selected or ""
770
771 async def execute_stage(self,
772 state: WorkflowState,
773 stage: WorkflowStage,
774 input_data: Any,
775 cancellation_check: Optional[Callable] = None,
776 progress_callback: Optional[Callable] = None,
777 intervention: Optional[Dict] = None) -> Dict:
778 import time
779
780 if not isinstance(input_data, dict):
781 input_data = {}
782 else:
783 input_data = copy.deepcopy(input_data)
784
785 with self._state_lock:
786 input_data["_session_meta"] = copy.deepcopy(state.meta)
787 input_data["_session_artifacts"] = copy.deepcopy(state.artifacts)
788
789 agent = self.agent_factories[stage]()
790 active_registered = False
791 background_item_regeneration = self._is_background_item_regeneration(stage, intervention)
792
793 # 合并会话级停止信号与请求级取消检查
794 session_stop = self.get_stop_event(state.session_id)
795 def combined_cancel_check():
796 return session_stop.is_set() or (cancellation_check and cancellation_check())
797
798 agent.set_cancellation_check(combined_cancel_check)
799
800 # 包装 progress_callback:运行中进度只写入全局内存实例,避免并发阶段频繁抢写 session JSON。
801 def persist_stage_progress(phase: str, step: str, percent: float):
802 stage_key = stage.value
803 try:
804 safe_percent = max(0, min(100, int(round(float(percent)))))
805 except (TypeError, ValueError):
806 safe_percent = 0
807 message = f"{phase}: {step}" if phase and step else (step or phase or "")
808 state.stage_progress[stage_key] = {
809 "phase": phase,
810 "step": step,
811 "message": message,
812 "percent": safe_percent,
813 "updated_at": time.time(),
814 }
815 state.updated_at = datetime.now()
816
817 def merge_progress_artifact(data: dict):
818 if not isinstance(data, dict):
819 return
820 stage_key = stage.value
821 if data.get("assets_preview"):
822 state.artifacts[stage_key] = copy.deepcopy(data["assets_preview"])
823
824 asset_update = data.get("asset_complete")
825 if not isinstance(asset_update, dict):
826 return
827
828 artifact = state.artifacts.setdefault(stage_key, {})
829 item_type = asset_update.get("type")
830 item_id = asset_update.get("id")
831 if not item_type or not item_id:
832 return
833
834 items = artifact.setdefault(item_type, [])
835 if not isinstance(items, list):
836 items = []
837 artifact[item_type] = items
838
839 for item in items:
840 if isinstance(item, dict) and item.get("id") == item_id:
841 next_status = asset_update.get("status", item.get("status"))
842 if item.get("selected") and next_status in {"done", "failed"}:
843 next_status = "done"
844 item["status"] = next_status
845 if "selected" in asset_update:
846 item["selected"] = self._selected_after_asset_update(
847 item.get("selected"),
848 asset_update.get("selected"),
849 )
850 if "versions" in asset_update:
851 item["versions"] = self._merge_asset_versions(
852 item.get("versions"),
853 asset_update.get("versions"),
854 )
855 if "rewrite_result" in asset_update:
856 item["rewrite_result"] = copy.deepcopy(asset_update["rewrite_result"])
857 return
858
859 new_item = {
860 "id": item_id,
861 "status": asset_update.get("status", "done"),
862 "selected": asset_update.get("selected", ""),
863 "versions": asset_update.get("versions", []),
864 }
865 if "rewrite_result" in asset_update:
866 new_item["rewrite_result"] = copy.deepcopy(asset_update["rewrite_result"])
867 items.append(new_item)
868
869 def wrapped_progress_callback(phase: str, step: str, percent: float, data: dict = None):
870 with self._state_lock:
871 persist_stage_progress(phase, step, percent)
872 if data:
873 merge_progress_artifact(data)
874
875 # 调用原始 callback
876 if progress_callback:
877 progress_callback(phase, step, percent, data)
878
879 should_persist = False
880 if isinstance(data, dict):
881 asset_update = data.get("asset_complete")
882 should_persist = bool(data.get("persist")) or (
883 isinstance(asset_update, dict)
884 and asset_update.get("status") in {"done", "failed"}
885 )
886 if should_persist:
887 self.save_session_to_disk(state.session_id)
888
889 if progress_callback:
890 agent.set_progress_callback(wrapped_progress_callback)
891
892 if not background_item_regeneration:
893 with self._state_lock:
894 if state.session_id in self._active_sessions:
895 raise RuntimeError(f"Session {state.session_id} is already running a stage.")
896 self._active_sessions.add(state.session_id)
897 active_registered = True
898 state.current_stage = stage
899 state.status[stage.value] = "running"
900 state.updated_at = datetime.now()
901 state.stage_progress[stage.value] = {
902 "phase": stage.value,
903 "step": "启动中...",
904 "message": "启动中...",
905 "percent": 0,
906 "updated_at": time.time(),
907 }
908 try:
909 self.save_session_to_disk(state.session_id)
910 except Exception:
911 self._active_sessions.discard(state.session_id)
912 active_registered = False
913 raise
914
915 try:
916 result = await agent.process(input_data, intervention=intervention)
917 if not isinstance(result, dict):
918 logger.error(f"[execute_stage] Agent {stage.value} returned non-dict result: {type(result)}")
919 result = {"payload": result}
920
921 payload = result.get("payload", {})
922 requires_intervention = result.get("requires_intervention", False)
923
924 with self._state_lock:
925 # 根据阶段类型处理数据持久化和同步逻辑
926 if stage == WorkflowStage.SCRIPT_GENERATION:
927 if requires_intervention:
928 # 【续写预览状态】直接保存 payload 以保留 new_episodes 供前端显示。
929 # 此时千万不要跨阶段同步(避免向第二、三阶段注入用户未确认的数据)。
930 state.artifacts[stage.value] = copy.deepcopy(payload)
931 else:
932 # 【确定续写 / 正常生成状态】
933 # 先同步增量数据到第二、三阶段
934 self._sync_artifacts_cross_stages(state, stage, payload)
935 # 然后清理第一阶段内部的临时增量字段并保存
936 clean_art = copy.deepcopy(payload)
937 for key in ["new_episodes", "new_characters", "new_settings", "sequel_idea"]:
938 clean_art.pop(key, None)
939 state.artifacts[stage.value] = clean_art
940 else:
941 # 其他阶段正常执行跨阶段同步和赋值
942 if background_item_regeneration:
943 if stage == WorkflowStage.CHARACTER_DESIGN:
944 keys = ["characters", "settings"]
945 elif stage == WorkflowStage.REFERENCE_GENERATION:
946 keys = ["scenes"]
947 elif stage == WorkflowStage.VIDEO_GENERATION:
948 keys = ["clips"]
949 else:
950 keys = []
951 target_ids = self._background_regeneration_targets(stage, intervention)
952 payload = self._merge_item_regeneration_payload(state.artifacts.get(stage.value, {}), payload, keys, target_ids)
953 self._sync_artifacts_cross_stages(state, stage, payload)
954 state.artifacts[stage.value] = payload
955
956 # 调试日志
957 logger.info(f"[execute_stage] stage={stage.value}, intervention={intervention is not None}, requires_intervention={result.get('requires_intervention')}, stage_completed={result.get('stage_completed')}")
958
959 # 重新计算所有阶段的状态(基于 artifacts 里的数据完整性)
960 self._recalculate_all_statuses(state)
961
962 # 如果 result 明确标记了完成且不是干预,则可能需要覆盖为 completed (除非 recalculate 认为是 waiting)
963 if result.get("stage_completed") and not result.get("requires_intervention"):
964 # 如果 recalculate 没把它设为 waiting,就设为 completed
965 if state.status.get(stage.value) != "waiting":
966 state.status[stage.value] = "completed"
967 elif result.get("requires_intervention"):
968 state.status[stage.value] = "waiting"
969
970 state.updated_at = datetime.now()
971 state.stage_progress[stage.value] = {
972 "phase": stage.value,
973 "step": "等待确认" if state.status.get(stage.value) == "waiting" else "已完成",
974 "message": "等待确认" if state.status.get(stage.value) == "waiting" else "已完成",
975 "percent": 100,
976 "updated_at": time.time(),
977 }
978 # 立即保存状态到磁盘,确保前端能获取到最新状态
979 self.save_session_to_disk(state.session_id)
980 return result
981
982 except asyncio.CancelledError:
983 with self._state_lock:
984 if not background_item_regeneration:
985 state.status[stage.value] = "stopped"
986 state.error = None
987 state.updated_at = datetime.now()
988 state.stage_progress[stage.value] = {
989 "phase": stage.value,
990 "step": "已取消",
991 "message": "已取消",
992 "percent": state.stage_progress.get(stage.value, {}).get("percent", 0),
993 "updated_at": time.time(),
994 }
995 self.save_session_to_disk(state.session_id)
996 raise
997
998 except Exception as e:
999 with self._state_lock:
1000 if not background_item_regeneration:
1001 state.status[stage.value] = "error"
1002 state.error = str(e)
1003 state.updated_at = datetime.now()
1004 state.stage_progress[stage.value] = {
1005 "phase": stage.value,
1006 "step": "执行失败",
1007 "message": "执行失败",
1008 "percent": state.stage_progress.get(stage.value, {}).get("percent", 0),
1009 "updated_at": time.time(),
1010 }
1011 # 确保保存错误状态
1012 self.save_session_to_disk(state.session_id)
1013 raise
1014 finally:
1015 if active_registered:
1016 with self._state_lock:
1017 self._active_sessions.discard(state.session_id)
1018
1019 async def continue_workflow(self, session_id: str) -> Dict:
1020 with self._state_lock:
1021 state = self.get_state(session_id)
1022 if not state:
1023 return {
1024 "status": "error",
1025 "openclaw": "会话不存在,请刷新后重试。",
1026 "message": "会话不存在",
1027 "current_status": "missing",
1028 }
1029 logger.info(f"[continue_workflow] session={session_id}, current_stage={state.current_stage}, status={state.status}")
1030
1031 # 检查当前状态是否已完成
1032 current_stage_str = state.current_stage.value if hasattr(state.current_stage, "value") else str(state.current_stage)
1033
1034 # 在继续之前,先重新扫描一次状态,确保最新的选择已被计入
1035 self._recalculate_all_statuses(state)
1036
1037 # 如果当前状态是 running,说明阶段还在执行中,不能继续
1038 if state.status.get(current_stage_str) == "running":
1039 return {
1040 "status": "waiting",
1041 "openclaw": f"当前阶段({current_stage_str})还在执行中,请等待完成后再调用 /continue。",
1042 "message": f"当前阶段({current_stage_str})还在执行中,请等待完成后再调用 /continue。",
1043 "current_status": "running",
1044 }
1045
1046 # 状态转换逻辑:
1047 # - waiting 或 completed: 用户确认后直接进入下一阶段
1048 # 如果是 waiting,可能需要阻止进入下一阶段,除非业务允许强制通过
1049 # 这里我们遵循用户逻辑:如果有空数据,显示为 waiting,用户需要解决它才能真正 completed。
1050 if state.status.get(current_stage_str) == "waiting":
1051 # 如果是 waiting 状态,通常不应该自动跳到下一阶段
1052 # 但如果用户点击了“继续”,可能是想补全或者强制进入
1053 pass
1054 # 注意:只有当阶段真正完成(waiting 或 completed)时才允许继续
1055
1056 current_status = state.status.get(current_stage_str)
1057 if current_status == "waiting" or current_status == "completed":
1058 # 直接进入下一阶段
1059 state.status[current_stage_str] = "completed"
1060 next_stage = self._get_next_stage(state.current_stage)
1061
1062 if not next_stage:
1063 state.status[current_stage_str] = "completed"
1064 self.save_session_to_disk(state.session_id)
1065 return {"status": "completed", "session_id": state.session_id, "status_map": copy.deepcopy(state.status)}
1066
1067 self.save_session_to_disk(state.session_id)
1068 return {"status": "ready", "next_stage": next_stage.value, "session_id": state.session_id, "status_map": copy.deepcopy(state.status)}
1069
1070 # 其他状态(如 pending, stopped, error, completed)不允许继续
1071 return {
1072 "status": "error",
1073 "openclaw": f"当前状态 {current_status} 不允许继续,请检查会话状态。",
1074 "message": f"当前状态不允许继续",
1075 "current_status": current_status,
1076 }
1077
1078 # ──────────── Artifact 统一管理 ────────────
1079
1080 def update_artifact(self, session_id: str, stage: str, body: Dict[str, Any]) -> Dict[str, Any]:
1081 """Apply a user edit to an artifact, then recalculate and persist state."""
1082 with self._state_lock:
1083 state = self.get_state(session_id)
1084 if not state:
1085 raise KeyError(f"Session not found: {session_id}")
1086
1087 self._apply_artifact_update(state, stage, body if isinstance(body, dict) else {})
1088 self._recalculate_all_statuses(state)
1089 self.save_session_to_disk(session_id)
1090 return {
1091 "status": "ok",
1092 "status_map": copy.deepcopy(state.status),
1093 "artifact": copy.deepcopy(state.artifacts.get(stage)),
1094 }
1095
1096 def _apply_artifact_update(self, state: WorkflowState, stage: str, body: Dict[str, Any]):
1097 """Apply a user edit to in-memory artifacts before the session is persisted."""
1098 merge_keys_by_stage = {
1099 "character_design": ("characters", "settings"),
1100 "reference_generation": ("scenes",),
1101 "video_generation": ("clips",),
1102 }
1103 if stage in merge_keys_by_stage:
1104 current_art = state.artifacts.get(stage, {})
1105 if isinstance(current_art, dict):
1106 for list_key in merge_keys_by_stage[stage]:
1107 if list_key not in body:
1108 continue
1109 current_items = current_art.get(list_key, [])
1110 incoming_items = body.get(list_key, [])
1111 if not isinstance(current_items, list) or not isinstance(incoming_items, list):
1112 continue
1113 current_by_id = {
1114 item.get("id"): item
1115 for item in current_items
1116 if isinstance(item, dict) and item.get("id")
1117 }
1118 merged_items = []
1119 seen_ids = set()
1120 for incoming in incoming_items:
1121 if not isinstance(incoming, dict) or not incoming.get("id"):
1122 continue
1123 item_id = incoming["id"]
1124 current = current_by_id.get(item_id, {})
1125 merged = {**current, **incoming}
1126 explicit_video_selection = (
1127 stage == "video_generation"
1128 and "selected" in incoming
1129 )
1130 if current.get("selected") and not incoming.get("selected") and not explicit_video_selection:
1131 merged["selected"] = current.get("selected")
1132 merged["versions"] = self._merge_asset_versions(
1133 current.get("versions"),
1134 incoming.get("versions"),
1135 )
1136 if current.get("selected") and incoming.get("status") in {"failed", "pending"}:
1137 merged["status"] = current.get("status", "done")
1138 merged_items.append(merged)
1139 seen_ids.add(item_id)
1140 for current in current_items:
1141 if isinstance(current, dict) and current.get("id") not in seen_ids:
1142 merged_items.append(current)
1143 body[list_key] = merged_items
1144
1145 if stage == "storyboard" and any(k in body for k in ("episodes", "segments", "shots")):
1146 for shot in body.get('shots', []):
1147 if isinstance(shot, dict) and 'is_new' in shot:
1148 shot['is_new'] = False
1149
1150 input_segments = list(body.get('segments', []))
1151 for ep in body.get('episodes', []):
1152 if isinstance(ep, dict):
1153 input_segments.extend(seg for seg in ep.get('segments', []) if isinstance(seg, dict))
1154
1155 seg_info_list = []
1156 for seg in input_segments:
1157 seg_id = seg.get('segment_id')
1158 if not seg_id:
1159 continue
1160 shots = seg.get('shots', [])
1161 desc_video = " ".join([sh.get("plot") or sh.get("content") or "" for sh in shots]).strip()
1162 total_dur = seg.get("total_duration") or sum([sh.get("duration", 0) for sh in shots]) or 10
1163 seg_info_list.append({
1164 "segment_id": seg_id,
1165 "desc": desc_video,
1166 "duration": total_dur,
1167 "visual_prompt": seg.get("visual_prompt", ""),
1168 })
1169
1170 video_art = state.artifacts.get('video_generation', {})
1171 if isinstance(video_art, dict) and isinstance(video_art.get('clips'), list):
1172 for clip in video_art['clips']:
1173 target = next((item for item in seg_info_list if item["segment_id"] == clip.get('id')), None)
1174 if target:
1175 clip['duration'] = target['duration']
1176 clip['description'] = target['desc']
1177
1178 ref_art = state.artifacts.get('reference_generation', {})
1179 if isinstance(ref_art, dict) and isinstance(ref_art.get('scenes'), list):
1180 for scene in ref_art['scenes']:
1181 target = next((item for item in seg_info_list if item["segment_id"] == scene.get('id')), None)
1182 if target and target.get("visual_prompt"):
1183 scene['description'] = target['visual_prompt']
1184
1185 if "segments" in body and "episodes" not in body:
1186 body = {k: v for k, v in body.items() if k != "segments"}
1187 body.pop('new_shot_ids', None)
1188
1189 elif stage == "reference_generation":
1190 if "segments" in body:
1191 seg_id_to_prompt = {
1192 s['segment_id']: s.get('visual_prompt', '')
1193 for s in body['segments']
1194 if isinstance(s, dict) and 'segment_id' in s
1195 }
1196
1197 storyboard_art = state.artifacts.get('storyboard', {})
1198 if isinstance(storyboard_art, dict):
1199 for ep in storyboard_art.get('episodes', []):
1200 if not isinstance(ep, dict):
1201 continue
1202 for seg in ep.get('segments', []):
1203 if isinstance(seg, dict) and seg.get('segment_id') in seg_id_to_prompt:
1204 seg['visual_prompt'] = seg_id_to_prompt[seg.get('segment_id')]
1205
1206 ref_art = state.artifacts.get('reference_generation', {})
1207 if isinstance(ref_art, dict):
1208 for scene in ref_art.get('scenes', []):
1209 if isinstance(scene, dict) and scene.get('id') in seg_id_to_prompt:
1210 scene['description'] = seg_id_to_prompt[scene.get('id')]
1211
1212 body = {k: v for k, v in body.items() if k != "segments"}
1213
1214 ref_art = state.artifacts.get('reference_generation', {})
1215 if isinstance(ref_art, dict):
1216 scenes = ref_art.get('scenes', [])
1217 is_selection_format = any(isinstance(k, str) and not isinstance(v, (list, dict)) for k, v in body.items())
1218 if is_selection_format and scenes:
1219 for scene in scenes:
1220 if isinstance(scene, dict) and scene.get('id') in body:
1221 scene['selected'] = body[scene.get('id')]
1222 body = {}
1223
1224 elif stage == "video_generation":
1225 clip_id_to_duration = {}
1226 clip_id_to_description = {}
1227 for clip_id, value in body.items():
1228 if isinstance(value, dict):
1229 if 'duration' in value:
1230 clip_id_to_duration[clip_id] = value['duration']
1231 if 'description' in value:
1232 clip_id_to_description[clip_id] = value['description']
1233
1234 if clip_id_to_duration or clip_id_to_description:
1235 storyboard_art = state.artifacts.get('storyboard', {})
1236 if isinstance(storyboard_art, dict):
1237 for ep in storyboard_art.get('episodes', []):
1238 if not isinstance(ep, dict):
1239 continue
1240 for seg in ep.get('segments', []):
1241 if isinstance(seg, dict) and seg.get('segment_id') in clip_id_to_duration:
1242 seg['total_duration'] = clip_id_to_duration[seg.get('segment_id')]
1243
1244 vid_art = state.artifacts.get('video_generation', {})
1245 if isinstance(vid_art, dict):
1246 for clip in vid_art.get('clips', []):
1247 if not isinstance(clip, dict):
1248 continue
1249 clip_id = clip.get('id')
1250 if clip_id in clip_id_to_duration:
1251 clip['duration'] = clip_id_to_duration[clip_id]
1252 if clip_id in clip_id_to_description:
1253 clip['description'] = clip_id_to_description[clip_id]
1254
1255 vid_art = state.artifacts.get('video_generation', {})
1256 if isinstance(vid_art, dict):
1257 clips = vid_art.get('clips', [])
1258 is_selection_format = any(isinstance(k, str) and not isinstance(v, (list, dict)) for k, v in body.items())
1259 if is_selection_format and clips:
1260 for clip in clips:
1261 if isinstance(clip, dict) and clip.get('id') in body:
1262 clip['selected'] = body[clip.get('id')]
1263 body = {}
1264
1265 current = state.artifacts.get(stage)
1266 if current is None:
1267 state.artifacts[stage] = body
1268 elif isinstance(current, dict):
1269 current.update(body)
1270 else:
1271 state.artifacts[stage] = body
1272
1273 def upload_artifact_image(
1274 self,
1275 session_id: str,
1276 stage: str,
1277 item_type: str,
1278 item_id: str,
1279 file_obj: Any,
1280 filename: str = "",
1281 ) -> Dict[str, Any]:
1282 """Save a user-provided image and attach it to the target artifact item."""
1283 allowed_exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
1284 ext = os.path.splitext(filename or "")[1].lower() or ".png"
1285 if ext not in allowed_exts:
1286 raise ValueError(f"仅支持 {', '.join(sorted(allowed_exts))} 格式的图片")
1287
1288 with self._state_lock:
1289 state = self.get_state(session_id)
1290 if not state:
1291 raise KeyError(f"Session not found: {session_id}")
1292
1293 cfg = self._upload_item_config(stage, item_type, item_id)
1294 absolute_path, relative_path = self._next_upload_path(session_id, cfg, ext)
1295 try:
1296 with open(absolute_path, "wb") as buffer:
1297 shutil.copyfileobj(file_obj, buffer)
1298 except Exception as exc:
1299 raise RuntimeError(f"图片保存失败: {exc}") from exc
1300
1301 artifact = state.artifacts.setdefault(stage, {})
1302 items = artifact.setdefault(cfg["list_key"], [])
1303 if not isinstance(items, list):
1304 items = []
1305 artifact[cfg["list_key"]] = items
1306
1307 target = next((item for item in items if isinstance(item, dict) and item.get("id") == item_id), None)
1308 if target is None:
1309 target = {"id": item_id, "name": item_id, "description": "", "versions": []}
1310 items.append(target)
1311
1312 versions = target.get("versions")
1313 if not isinstance(versions, list):
1314 versions = []
1315 if relative_path not in versions:
1316 versions.append(relative_path)
1317 target["versions"] = versions
1318 target["selected"] = relative_path
1319 target["status"] = "done"
1320
1321 self._recalculate_all_statuses(state)
1322 self.save_session_to_disk(session_id)
1323 return {
1324 "status": "ok",
1325 "path": relative_path,
1326 "item_id": item_id,
1327 "item_type": item_type,
1328 "artifact": copy.deepcopy(state.artifacts.get(stage)),
1329 "status_map": copy.deepcopy(state.status),
1330 }
1331
1332 @staticmethod
1333 def _upload_item_config(stage: str, item_type: str, item_id: str) -> Dict[str, str]:
1334 if stage == "character_design" and item_type == "characters":
1335 base = item_id if item_id.startswith("char_") else f"char_{item_id}"
1336 return {"list_key": "characters", "dir": os.path.join("Assets", "characters"), "base": base}
1337 if stage == "character_design" and item_type == "settings":
1338 base = item_id if item_id.startswith("set_") else f"set_{item_id}"
1339 return {"list_key": "settings", "dir": os.path.join("Assets", "settings"), "base": base}
1340 if stage == "reference_generation" and item_type == "scenes":
1341 return {"list_key": "scenes", "dir": "Scenes", "base": item_id}
1342 raise ValueError("Unsupported upload target")
1343
1344 @staticmethod
1345 def _next_upload_path(session_id: str, cfg: Dict[str, str], ext: str) -> tuple[str, str]:
1346 from config import settings
1347
1348 save_dir = os.path.join(settings.RESULT_DIR, "image", str(session_id), cfg["dir"])
1349 os.makedirs(save_dir, exist_ok=True)
1350
1351 pattern = re.compile(rf"^{re.escape(cfg['base'])}_upload_v(\d+)\.", re.IGNORECASE)
1352 max_version = 0
1353 for name in os.listdir(save_dir):
1354 match = pattern.match(name)
1355 if match:
1356 max_version = max(max_version, int(match.group(1)))
1357 version = f"v{max_version + 1}"
1358 upload_filename = f"{cfg['base']}_upload_{version}{ext}"
1359 absolute_path = os.path.join(save_dir, upload_filename)
1360 relative_path = os.path.relpath(absolute_path, settings.BASE_DIR)
1361 return absolute_path, relative_path
1362
1363 # ──────────── 会话持久化 ────────────
1364
1365 def save_session_to_disk(self, session_id: str, meta: Dict = None):
1366 """保存 / 更新会话到磁盘(原子写入)"""
1367 import tempfile
1368 import shutil
1369
1370 with self._state_lock:
1371 path = os.path.join(self._session_dir, f"{session_id}.json")
1372 data: Dict[str, Any] = {}
1373 state = self.sessions.get(session_id)
1374
1375 # 1. 准备基础数据
1376 if os.path.exists(path):
1377 try:
1378 with open(path, 'r', encoding='utf-8') as f:
1379 data = json.load(f)
1380 except (json.JSONDecodeError, Exception):
1381 pass
1382
1383 data["session_id"] = session_id
1384 if meta:
1385 normalized_meta = {k: _normalize_meta_value(v) for k, v in meta.items() if v is not None}
1386 if state:
1387 state.meta.update(normalized_meta)
1388 if "created_at" not in data:
1389 data["created_at"] = time.time()
1390
1391 # Legacy session compatibility: clean root-level generation fields left by old session JSON.
1392 for key in SESSION_META_KEYS:
1393 data.pop(key, None)
1394
1395 # 2. 将内存中的最新 state 合并到 data 中
1396 if state:
1397 data["current_stage"] = state.current_stage.value
1398 data["status"] = state.status
1399 # 这里的 state.artifacts 应该是已经经过 _sync_artifacts_cross_stages 处理的最新的内存对象
1400 data["artifacts"] = state.artifacts
1401 data["stage_progress"] = state.stage_progress
1402 data["error"] = state.error
1403 data["updated_at"] = state.updated_at.timestamp() if isinstance(state.updated_at, datetime) else time.time()
1404
1405 # 保存元数据:meta 是唯一的会话级生成参数存储位置。
1406 if state.meta:
1407 data["meta"] = copy.deepcopy(state.meta)
1408 else:
1409 data.pop("meta", None)
1410 else:
1411 data["updated_at"] = time.time()
1412
1413 # 3. 原子写入:先写临时文件,再重命名
1414 dir_path = os.path.dirname(path)
1415 fd, tmp_path = tempfile.mkstemp(dir=dir_path, suffix='.json')
1416 try:
1417 with os.fdopen(fd, 'w', encoding='utf-8') as f:
1418 json.dump(data, f, ensure_ascii=False, indent=2)
1419 shutil.move(tmp_path, path)
1420 logger.info(f"[Orchestrator] Session {session_id} saved successfully.")
1421 except Exception as e:
1422 if os.path.exists(tmp_path):
1423 os.remove(tmp_path)
1424 logger.error(f"[Orchestrator] Failed to save session {session_id}: {e}")
1425 raise
1426
1427 def _load_sessions_from_disk(self):
1428 """启动时从磁盘加载所有已保存的会话"""
1429 if not os.path.exists(self._session_dir):
1430 return
1431 for filename in os.listdir(self._session_dir):
1432 if not filename.endswith('.json'):
1433 continue
1434 try:
1435 fpath = os.path.join(self._session_dir, filename)
1436 with open(fpath, 'r', encoding='utf-8') as f:
1437 data = json.load(f)
1438 sid = data["session_id"]
1439 state = WorkflowState(sid)
1440 try:
1441 state.current_stage = WorkflowStage(data.get("current_stage", "init"))
1442 except ValueError:
1443 state.current_stage = WorkflowStage.INIT
1444
1445 # 旧版本兼容:状态名称转换及迁移
1446 old_status = data.get("status", "pending")
1447 if isinstance(old_status, str):
1448 if old_status == "waiting_intervention":
1449 old_status = "waiting"
1450 elif old_status == "completed":
1451 old_status = "completed"
1452
1453 stages_completed = data.get("stages_completed", [])
1454 for stage in WorkflowStage:
1455 if stage != WorkflowStage.INIT and stage != WorkflowStage.COMPLETED:
1456 if stage.value in stages_completed:
1457 state.status[stage.value] = "completed"
1458 elif stage.value == state.current_stage.value:
1459 state.status[stage.value] = old_status
1460 else:
1461 state.status[stage.value] = "pending"
1462 elif isinstance(old_status, dict):
1463 state.status = old_status
1464
1465 state.artifacts = data.get("artifacts", {})
1466 state.stage_progress = data.get("stage_progress", {})
1467 state.error = data.get("error")
1468 state.updated_at = data.get("updated_at", 0)
1469 state.meta = _extract_session_meta(data)
1470 self.sessions[sid] = state
1471 except json.JSONDecodeError:
1472 logger.warning(f"Skipping corrupted session file: {filename}")
1473 except Exception as e:
1474 logger.warning(f"Failed to load session {filename}: {e}")
1475
1476 def delete_session(self, session_id: str) -> bool:
1477 """删除指定会话(内存 + 磁盘 + 结果文件)"""
1478 from config import settings
1479 import shutil
1480
1481 with self._state_lock:
1482 if session_id in self._active_sessions:
1483 self.get_stop_event(session_id).set()
1484 logger.warning(f"Refusing to delete active session: {session_id}")
1485 return False
1486
1487 path = os.path.join(self._session_dir, f"{session_id}.json")
1488 exists = session_id in self.sessions or os.path.exists(path)
1489 if not exists:
1490 return False
1491
1492 # 从内存中移除
1493 self.sessions.pop(session_id, None)
1494 self._stop_events.pop(session_id, None)
1495
1496 # 1. 删除会话元数据文件
1497 if os.path.exists(path):
1498 os.remove(path)
1499
1500 # 2. 删除结果文件(剧本、图片、视频)
1501 result_base = settings.RESULT_DIR
1502
1503 # 删除剧本文件
1504 script_file = os.path.join(result_base, 'script', f'{session_id}.json')
1505 if os.path.exists(script_file):
1506 os.remove(script_file)
1507
1508 # 删除图片目录
1509 image_dir = os.path.join(result_base, 'image', session_id)
1510 if os.path.exists(image_dir):
1511 shutil.rmtree(image_dir)
1512
1513 # 删除视频目录
1514 video_dir = os.path.join(result_base, 'video', session_id)
1515 if os.path.exists(video_dir):
1516 shutil.rmtree(video_dir)
1517
1518 logger.info(f"Session and results deleted: {session_id}")
1519 return True
1520
1521 def cleanup_orphan_results(self) -> Dict[str, Any]:
1522 """Remove result files whose session no longer exists."""
1523 from config import settings
1524
1525 with self._state_lock:
1526 session_ids = set(self.sessions.keys())
1527 if os.path.isdir(self._session_dir):
1528 for filename in os.listdir(self._session_dir):
1529 if filename.endswith('.json'):
1530 session_ids.add(filename[:-5])
1531
1532 cleaned = {"scripts": [], "images": [], "videos": []}
1533 result_base = settings.RESULT_DIR
1534
1535 script_dir = os.path.join(result_base, 'script')
1536 if os.path.isdir(script_dir):
1537 for filename in os.listdir(script_dir):
1538 if not filename.endswith('.json'):
1539 continue
1540 sid = filename[:-5]
1541 if sid not in session_ids:
1542 os.remove(os.path.join(script_dir, filename))
1543 cleaned["scripts"].append(sid)
1544
1545 image_dir = os.path.join(result_base, 'image')
1546 if os.path.isdir(image_dir):
1547 for dirname in os.listdir(image_dir):
1548 if dirname != 'test_avail' and dirname not in session_ids:
1549 shutil.rmtree(os.path.join(image_dir, dirname))
1550 cleaned["images"].append(dirname)
1551
1552 video_dir = os.path.join(result_base, 'video')
1553 if os.path.isdir(video_dir):
1554 for dirname in os.listdir(video_dir):
1555 if dirname != 'test_avail' and dirname not in session_ids:
1556 shutil.rmtree(os.path.join(video_dir, dirname))
1557 cleaned["videos"].append(dirname)
1558
1559 return {"status": "cleaned", "cleaned": cleaned}
1560
1561 def get_scene_asset_counts(self, session_id: str, scene_number: int) -> Dict[str, Any]:
1562 """Count generated reference images/videos from the current artifact state only."""
1563 from config import settings
1564
1565 with self._state_lock:
1566 state = self.get_state(session_id)
1567 if not state:
1568 raise KeyError(f"Session not found: {session_id}")
1569 artifacts = copy.deepcopy(state.artifacts)
1570
1571 storyboard = artifacts.get('storyboard', {})
1572 segment_ids = self._segment_ids_for_scene(storyboard, scene_number)
1573
1574 ref_artifact = artifacts.get('reference_generation', {})
1575 ref_scenes = ref_artifact.get('scenes', []) if isinstance(ref_artifact, dict) else []
1576 ref_image_count = self._count_existing_assets(ref_scenes, segment_ids, settings.CODE_DIR, include_versions=True)
1577
1578 video_artifact = artifacts.get('video_generation', {})
1579 video_clips = video_artifact.get('clips', []) if isinstance(video_artifact, dict) else []
1580 video_count = self._count_existing_assets(video_clips, segment_ids, settings.CODE_DIR, include_versions=False)
1581
1582 return {
1583 "scene_number": scene_number,
1584 "reference_images": ref_image_count,
1585 "videos": video_count,
1586 "shot_count": len(segment_ids),
1587 }
1588
1589 @staticmethod
1590 def _segment_ids_for_scene(storyboard: Any, scene_number: int) -> List[str]:
1591 if not isinstance(storyboard, dict):
1592 return []
1593
1594 ids: List[str] = []
1595 for shot in storyboard.get('shots', []):
1596 if isinstance(shot, dict) and shot.get('scene_number') == scene_number and shot.get('shot_id'):
1597 ids.append(shot['shot_id'])
1598
1599 for episode in storyboard.get('episodes', []):
1600 if not isinstance(episode, dict):
1601 continue
1602 for segment in episode.get('segments', []):
1603 if not isinstance(segment, dict):
1604 continue
1605 segment_scene = segment.get('scene_number') or segment.get('segment_number')
1606 if segment_scene == scene_number and segment.get('segment_id'):
1607 ids.append(segment['segment_id'])
1608
1609 return list(dict.fromkeys(ids))
1610
1611 @staticmethod
1612 def _asset_exists(code_dir: str, path: str) -> bool:
1613 if not path:
1614 return False
1615 candidate = path if os.path.isabs(path) else os.path.join(code_dir, path.lstrip('/'))
1616 return os.path.exists(candidate)
1617
1618 @classmethod
1619 def _count_existing_assets(
1620 cls,
1621 items: List[Any],
1622 target_ids: List[str],
1623 code_dir: str,
1624 *,
1625 include_versions: bool,
1626 ) -> int:
1627 count = 0
1628 target_set = set(target_ids)
1629 for item in items:
1630 if not isinstance(item, dict) or item.get('id') not in target_set:
1631 continue
1632 selected = item.get('selected')
1633 if selected and cls._asset_exists(code_dir, selected):
1634 count += 1
1635 if include_versions:
1636 versions = item.get('versions', [])
1637 for version in versions if isinstance(versions, list) else []:
1638 if version and version != selected and cls._asset_exists(code_dir, version):
1639 count += 1
1640 return count
1641
1642 def list_saved_sessions(self) -> List[Dict]:
1643 """列出所有已保存的会话概要"""
1644 with self._state_lock:
1645 sessions: List[Dict] = []
1646 for sid, state in self.sessions.items():
1647 try:
1648 meta = copy.deepcopy(state.meta)
1649 artifacts = copy.deepcopy(state.artifacts)
1650 script_artifact = artifacts.get("script_generation", {})
1651 title = (
1652 script_artifact.get("title")
1653 or meta.get("idea")
1654 or meta.get("user_textbox_input")
1655 or ""
1656 )
1657 updated_at = state.updated_at
1658 if isinstance(updated_at, datetime):
1659 date_value = updated_at.timestamp()
1660 else:
1661 date_value = updated_at or 0
1662 sessions.append({
1663 "id": sid,
1664 "title": title,
1665 "idea": meta.get("idea") or meta.get("user_textbox_input") or "",
1666 "style": meta.get("style") or "",
1667 "date": date_value,
1668 "status": copy.deepcopy(state.status),
1669 "current_stage": state.current_stage.value,
1670 "meta": meta,
1671 "stage_progress": copy.deepcopy(state.stage_progress),
1672 })
1673 except Exception:
1674 continue
1675 sessions.sort(key=lambda x: x.get("date", 0), reverse=True)
1676 return sessions
1677
1677 lines PYTHON