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