返回 VideoClaw
storyboard_agent.py
根目录 / video-claw / video-claw / backend / core / agents / storyboard_agent.py
1 # -*- coding: utf-8 -*-
2 """
3 阶段3: 分镜智能体
4 基于剧本JSON,逐场景拆分为带时长标签的分镜(shots),按幕分组输出。
5 支持 Segment -> Shots 嵌套结构。
6 """
7
8 import os
9 import re
10 import json
11 import asyncio
12 import logging
13 from datetime import datetime
14 from typing import Any, Optional, Callable, Dict, List, Tuple
15
16 from .base_agent import AgentInterface
17
18 logger = logging.getLogger(__name__)
19
20 def _get_storyboard_prompt(name: str, lang: str = "zh") -> str:
21 from prompts.loader import load_prompt_with_fallback
22 return load_prompt_with_fallback("storyboard", name, lang, "zh")
23
24 class StoryboardAgent(AgentInterface):
25 def __init__(self):
26 super().__init__(name="Storyboard")
27
28 MIN_SHOT_DURATION = 2
29 MIN_SEGMENT_DURATION = 5
30 MAX_SEGMENT_DURATION = 15
31 OPENING_SHOT_TYPES = {"中景", "全景"}
32
33 @staticmethod
34 def _extract_json_array(text: str) -> Optional[List[dict]]:
35 text = text.strip()
36 text = re.sub(r"^```(?:json)?\s*", "", text)
37 text = re.sub(r"\s*```$", "", text)
38 try:
39 result = json.loads(text)
40 if isinstance(result, list): return result
41 except json.JSONDecodeError: pass
42 m = re.search(r"\[.*\]", text, re.DOTALL)
43 if m:
44 try:
45 result = json.loads(m.group())
46 if isinstance(result, list): return result
47 except json.JSONDecodeError: pass
48 return None
49
50 @staticmethod
51 def _extract_json_object(text: str) -> Optional[dict]:
52 text = text.strip()
53 text = re.sub(r"^```(?:json)?\s*", "", text)
54 text = re.sub(r"\s*```$", "", text)
55 try:
56 result = json.loads(text)
57 if isinstance(result, dict):
58 return result
59 except json.JSONDecodeError:
60 pass
61 start = text.find("{")
62 end = text.rfind("}")
63 if start >= 0 and end > start:
64 try:
65 result = json.loads(text[start:end + 1])
66 if isinstance(result, dict):
67 return result
68 except json.JSONDecodeError:
69 pass
70 return None
71
72 @staticmethod
73 def _clean_script_line(line: str) -> str:
74 line = line.strip()
75 line = re.sub(r"^[-*•]\s*", "", line)
76 line = re.sub(r"^<action>\s*", "", line, flags=re.I)
77 line = re.sub(r"\s*</action>$", "", line, flags=re.I)
78 line = re.sub(r"\s+", " ", line)
79 return line.strip()
80
81 @staticmethod
82 def _strip_markup(text: str) -> str:
83 text = re.sub(r"^[#>\s]+", "", text.strip())
84 text = text.strip("*_` \t")
85 return text.strip()
86
87 @staticmethod
88 def _character_names(characters: List[dict]) -> List[str]:
89 names = []
90 for item in characters:
91 name = str(item.get("name", "")).strip()
92 if name:
93 names.append(name)
94 return sorted(set(names), key=len, reverse=True)
95
96 @staticmethod
97 def _setting_names(settings: List[dict]) -> List[str]:
98 names = []
99 for item in settings:
100 name = str(item.get("name", "")).strip()
101 if name:
102 names.append(name)
103 return sorted(set(names), key=len, reverse=True)
104
105 @classmethod
106 def _match_characters(cls, text: str, character_names: List[str]) -> List[str]:
107 """Only use the existing character list as keywords; never invent names."""
108 return [name for name in character_names if name and name in text]
109
110 @staticmethod
111 def _setting_matches_text(setting_name: str, text: str) -> bool:
112 if setting_name in text:
113 return True
114 normalized = re.sub(r"[()()【】\[\]\s]", "", setting_name)
115 text_normalized = re.sub(r"\s", "", text)
116 if normalized and normalized in text_normalized:
117 return True
118 base_name = re.sub(r"[((].*?[))]", "", setting_name).strip()
119 return bool(base_name and base_name in text)
120
121 @classmethod
122 def _resolve_location(
123 cls,
124 text: str,
125 current_location: str,
126 setting_names: List[str],
127 ) -> str:
128 for name in setting_names:
129 if cls._setting_matches_text(name, text):
130 return name
131 if current_location:
132 return current_location
133 return setting_names[0] if setting_names else ""
134
135 @classmethod
136 def _parse_scene_header_parts(cls, line: str, setting_names: List[str]) -> Optional[dict]:
137 clean = cls._strip_markup(line)
138 if not clean:
139 return None
140
141 # Examples:
142 # **第1集-第1场 日 内 高二三班教室**
143 # **1-1 夜 内 锐创科技办公室**
144 header_patterns = [
145 r"^第\d+集\s*[-—]\s*第\d+场\s+(日|夜|晨|傍晚|深夜)\s+(内|外)\s+(.+)$",
146 r"^\d+\s*[-—_]\s*\d+\s+(日|夜|晨|傍晚|深夜)\s+(内|外)\s+(.+)$",
147 ]
148 for pattern in header_patterns:
149 match = re.match(pattern, clean, flags=re.I)
150 if not match:
151 continue
152 time_of_day = match.group(1).strip()
153 scene_space = match.group(2).strip()
154 candidate = match.group(3).strip()
155 for name in setting_names:
156 if cls._setting_matches_text(name, candidate):
157 return {
158 "location": name,
159 "scene_time": time_of_day,
160 "scene_space": scene_space,
161 "scene_context": clean,
162 }
163 return {
164 "location": candidate[:40] or clean[:40],
165 "scene_time": time_of_day,
166 "scene_space": scene_space,
167 "scene_context": clean,
168 }
169 return None
170
171 @classmethod
172 def _parse_scene_header(cls, line: str, setting_names: List[str]) -> Optional[str]:
173 parts = cls._parse_scene_header_parts(line, setting_names)
174 if not parts:
175 return None
176 return parts.get("location")
177
178 @staticmethod
179 def _is_metadata_line(line: str) -> bool:
180 return bool(re.match(r"^(?:剧本名称|时长|风格|类型|标题)[::]", line))
181
182 @staticmethod
183 def _is_end_marker(line: str) -> bool:
184 return bool(re.match(r"^[((]?(?:第.+集\s*)?完[))]?$|^\(?THE END\)?$", line.strip(), flags=re.I))
185
186 @staticmethod
187 def _dialogue_parts(line: str) -> Optional[Tuple[str, str, str]]:
188 """Return speaker, tone/action, dialogue when a line looks like dialogue."""
189 match = re.match(r"^([^::]{1,24}?)(?:[((]([^))]{0,40})[))])?[::]\s*(.+)$", line)
190 if not match:
191 return None
192 speaker = match.group(1).strip()
193 tone = (match.group(2) or "").strip()
194 dialogue = match.group(3).strip()
195 if not dialogue:
196 return None
197 # Avoid treating section labels as dialogue.
198 if speaker in {"人物", "场景", "画面", "镜头", "地点", "时间"}:
199 return None
200 return speaker, tone, dialogue
201
202 @classmethod
203 def _duration_for_script_unit(cls, text: str, is_dialogue: bool) -> int:
204 if not is_dialogue:
205 return 2
206 dialogue = cls._dialogue_parts(text)
207 dialogue_text = dialogue[2] if dialogue else text
208 visible_text = re.sub(r"\s+", "", dialogue_text)
209 duration = (len(visible_text) + 4) // 5
210 return max(2, min(15, duration))
211
212 @classmethod
213 def _is_entry_action(cls, text: str, character_names: List[str]) -> bool:
214 if cls._dialogue_parts(text):
215 return False
216 if character_names and not cls._match_characters(text, character_names):
217 return False
218 return bool(re.search(
219 r"(走进|走入|进入|进来|推门而入|推门进|冲进|闯进|踏进|来到|回到|走向(?:教室|办公室|会议室|厕所|房间|门口))",
220 text,
221 ))
222
223 @classmethod
224 def _annotate_episode_script(
225 cls,
226 script_text: str,
227 characters: List[dict],
228 settings: List[dict],
229 ) -> Tuple[str, List[dict]]:
230 character_names = cls._character_names(characters)
231 setting_names = cls._setting_names(settings)
232 raw_lines = script_text.replace("\r\n", "\n").split("\n")
233
234 annotated_lines: List[str] = []
235 units: List[dict] = []
236 current_location = setting_names[0] if setting_names else ""
237 current_scene_time = ""
238 current_scene_space = ""
239 current_scene_context = ""
240 scene_characters: List[str] = []
241 scene_key = 0
242
243 for line_number, raw_line in enumerate(raw_lines, 1):
244 stripped = raw_line.strip()
245 line = cls._clean_script_line(cls._strip_markup(stripped))
246 if not line:
247 continue
248 if cls._is_metadata_line(line) or cls._is_end_marker(line):
249 annotated_lines.append(line)
250 continue
251
252 header_parts = cls._parse_scene_header_parts(stripped, setting_names)
253 if header_parts:
254 scene_key += 1
255 current_location = header_parts.get("location", current_location)
256 current_scene_time = header_parts.get("scene_time", "")
257 current_scene_space = header_parts.get("scene_space", "")
258 current_scene_context = header_parts.get("scene_context", cls._strip_markup(stripped))
259 scene_characters = cls._match_characters(line, character_names)
260 annotated_lines.append(cls._strip_markup(stripped))
261 continue
262
263 if re.match(r"^人物[::]", line):
264 scene_characters = cls._match_characters(line, character_names)
265 annotated_lines.append(line)
266 continue
267
268 dialogue = cls._dialogue_parts(line)
269 is_action = bool(re.match(r"^<action>", stripped, flags=re.I)) or bool(re.search(r"</action>$", stripped, flags=re.I))
270 if not dialogue and not is_action:
271 annotated_lines.append(line)
272 continue
273
274 unit_id = f"U{len(units) + 1:03d}"
275 is_dialogue = dialogue is not None
276 duration = cls._duration_for_script_unit(line, is_dialogue)
277 matched_chars = cls._match_characters(line, character_names)
278 unit_chars = matched_chars[:]
279 speaker = ""
280 tone = ""
281 if dialogue:
282 speaker, tone, _ = dialogue
283 speaker_matches = cls._match_characters(speaker, character_names)
284 if speaker_matches:
285 unit_chars = list(dict.fromkeys(speaker_matches + unit_chars))
286
287 units.append({
288 "unit_id": unit_id,
289 "line_number": line_number,
290 "text": line,
291 "duration": duration,
292 "is_dialogue": is_dialogue,
293 "speaker": speaker,
294 "tone": tone,
295 "characters": unit_chars,
296 "scene_characters": scene_characters[:],
297 "scene_key": scene_key,
298 "location": cls._resolve_location(line, current_location, setting_names),
299 "scene_time": current_scene_time,
300 "scene_space": current_scene_space,
301 "scene_context": current_scene_context,
302 "is_entry": cls._is_entry_action(line, character_names),
303 })
304 annotated_lines.append(f"[{duration}秒][{unit_id}] {line}")
305
306 return "\n".join(annotated_lines), units
307
308 @classmethod
309 def _segment_plan_from_units(cls, ep_n: int, segment_number: int, items: List[dict]) -> dict:
310 characters: List[str] = []
311 for item in items:
312 for name in item.get("characters") or item.get("scene_characters") or []:
313 if name not in characters:
314 characters.append(name)
315 return {
316 "episode_number": ep_n,
317 "segment_number": segment_number,
318 "location": items[0].get("location", "") if items else "",
319 "scene_time": items[0].get("scene_time", "") if items else "",
320 "scene_space": items[0].get("scene_space", "") if items else "",
321 "scene_context": items[0].get("scene_context", "") if items else "",
322 "characters": characters,
323 "total_duration": sum(int(item.get("duration") or 0) for item in items),
324 "items": items,
325 }
326
327 @classmethod
328 def _extract_plan_unit_ids(cls, seg: dict) -> List[str]:
329 ids: List[str] = []
330 for value in seg.get("unit_ids") or []:
331 if isinstance(value, str) and re.match(r"^U\d{3,}$", value):
332 ids.append(value)
333 for item in seg.get("items") or []:
334 if isinstance(item, dict):
335 unit_id = item.get("unit_id")
336 if isinstance(unit_id, str) and re.match(r"^U\d{3,}$", unit_id):
337 ids.append(unit_id)
338 if not ids:
339 raw = json.dumps(seg, ensure_ascii=False)
340 ids = re.findall(r"\bU\d{3,}\b", raw)
341 return list(dict.fromkeys(ids))
342
343 @classmethod
344 def _validate_segment_plan(cls, ep_n: int, raw_plan: List[dict], units: List[dict]) -> List[dict]:
345 unit_by_id = {item["unit_id"]: item for item in units}
346 source_ids = [item["unit_id"] for item in units]
347 flat_ids: List[str] = []
348 plans: List[dict] = []
349
350 for seg in raw_plan:
351 if not isinstance(seg, dict):
352 raise ValueError("片段规划包含非对象元素")
353 ids = cls._extract_plan_unit_ids(seg)
354 if not ids:
355 raise ValueError("片段缺少 unit_ids")
356 for unit_id in ids:
357 if unit_id not in unit_by_id:
358 raise ValueError(f"片段包含未知 unit_id: {unit_id}")
359 if unit_id in flat_ids:
360 raise ValueError(f"片段重复引用 unit_id: {unit_id}")
361
362 items = [unit_by_id[unit_id] for unit_id in ids]
363 scene_keys = {item.get("scene_key") for item in items}
364 if len(scene_keys) > 1:
365 raise ValueError("片段跨场景,场景切换必须分片段")
366 for idx, item in enumerate(items):
367 if item.get("is_entry") and idx != 0:
368 raise ValueError(f"{item['unit_id']} 是入场动作,必须作为片段开头")
369
370 duration = sum(int(item.get("duration") or 0) for item in items)
371 if duration > cls.MAX_SEGMENT_DURATION:
372 raise ValueError(f"片段时长 {duration}s 超出上限 {cls.MAX_SEGMENT_DURATION}s")
373
374 flat_ids.extend(ids)
375 plans.append(cls._segment_plan_from_units(ep_n, len(plans) + 1, items))
376
377 if flat_ids != source_ids:
378 missing = [unit_id for unit_id in source_ids if unit_id not in flat_ids]
379 extra = [unit_id for unit_id in flat_ids if unit_id not in source_ids]
380 raise ValueError(f"片段规划动作/台词缺漏或乱序,missing={missing}, extra={extra}")
381 return plans
382
383 @classmethod
384 def _fallback_segment_plan(cls, ep_n: int, units: List[dict]) -> List[dict]:
385 plans: List[dict] = []
386 current: List[dict] = []
387
388 def flush_current():
389 nonlocal current
390 if not current:
391 return
392 plans.append(cls._segment_plan_from_units(ep_n, len(plans) + 1, current))
393 current = []
394
395 for unit in units:
396 current_duration = sum(int(item.get("duration") or 0) for item in current)
397 scene_changed = current and unit.get("scene_key") != current[-1].get("scene_key")
398 entry_boundary = current and unit.get("is_entry")
399 would_overflow = current and current_duration + int(unit.get("duration") or 0) > cls.MAX_SEGMENT_DURATION
400
401 if scene_changed or entry_boundary or would_overflow:
402 flush_current()
403 current.append(unit)
404
405 flush_current()
406 return plans
407
408 @staticmethod
409 def _scene_item_payload(items: List[dict]) -> List[dict]:
410 return [
411 {
412 "unit_id": item["unit_id"],
413 "duration": item["duration"],
414 "text": item["text"],
415 "characters": item.get("characters", []),
416 "is_entry": item.get("is_entry", False),
417 "scene_context": item.get("scene_context", ""),
418 }
419 for item in items
420 ]
421
422 @classmethod
423 def _build_segmentation_prompt(
424 cls,
425 ep_n: int,
426 ep_t: str,
427 annotated_script: str,
428 characters: List[dict],
429 settings: List[dict],
430 retry_error: str = "",
431 ) -> str:
432 template = _get_storyboard_prompt("segment_plan", "zh")
433 retry_feedback = ""
434 if retry_error:
435 retry_feedback = f"上一次输出未通过校验,错误原因:{retry_error}\n请根据这个错误修正输出。"
436 from prompts.loader import format_prompt
437 return format_prompt(
438 template,
439 episode_number=ep_n,
440 episode_title=ep_t,
441 annotated_script=annotated_script,
442 asset_characters=json.dumps(characters, ensure_ascii=False),
443 asset_settings=json.dumps(settings, ensure_ascii=False),
444 retry_feedback=retry_feedback,
445 )
446
447 @classmethod
448 def _build_segment_design_prompt(
449 cls,
450 ep_n: int,
451 ep_t: str,
452 plan: dict,
453 style: str,
454 retry_error: str = "",
455 ) -> str:
456 items_payload = cls._scene_item_payload(plan.get("items", []))
457 segment_total = sum(int(item.get("duration") or 0) for item in plan.get("items", []))
458 output_total = max(cls.MIN_SEGMENT_DURATION, segment_total)
459 template = _get_storyboard_prompt("segment_design", "zh")
460 retry_feedback = ""
461 if retry_error:
462 retry_feedback = f"上一次输出未通过校验,错误原因:{retry_error}\n请根据这个错误修正输出。"
463 from prompts.loader import format_prompt
464 return format_prompt(
465 template,
466 episode_number=ep_n,
467 episode_title=ep_t,
468 segment_number=plan.get("segment_number"),
469 location=plan.get("location", ""),
470 scene_time=plan.get("scene_time", ""),
471 scene_space=plan.get("scene_space", ""),
472 scene_context=plan.get("scene_context", ""),
473 characters=json.dumps(plan.get("characters", []), ensure_ascii=False),
474 total_duration=output_total,
475 items=json.dumps(items_payload, ensure_ascii=False),
476 style=style,
477 retry_feedback=retry_feedback,
478 )
479
480 async def _query_json_array_with_retries(
481 self,
482 prompt: str,
483 llm_model: str,
484 sid: str,
485 *,
486 label: str,
487 max_retries: int = 3,
488 ) -> List[dict]:
489 from models.llm_client import LLM
490
491 loop = asyncio.get_running_loop()
492 last_error: Optional[Exception] = None
493 raw = ""
494 for attempt in range(max_retries):
495 try:
496 llm = LLM()
497 raw = await loop.run_in_executor(
498 None,
499 self._cancellable_query,
500 llm,
501 prompt,
502 [],
503 llm_model,
504 False,
505 sid,
506 False,
507 )
508 extracted = self._extract_json_array(raw)
509 if extracted is not None:
510 return extracted
511 raise ValueError("模型输出不是 JSON 数组")
512 except Exception as exc:
513 last_error = exc
514 logger.warning("[Storyboard] %s attempt %d failed: %s", label, attempt + 1, exc)
515 logger.error("[Storyboard] %s failed after retries. Last raw: %s", label, raw[:2000])
516 raise last_error or ValueError(f"{label} 失败")
517
518 async def _query_json_object_with_retries(
519 self,
520 prompt: str,
521 llm_model: str,
522 sid: str,
523 *,
524 label: str,
525 max_retries: int = 3,
526 ) -> dict:
527 from models.llm_client import LLM
528
529 loop = asyncio.get_running_loop()
530 last_error: Optional[Exception] = None
531 raw = ""
532 for attempt in range(max_retries):
533 try:
534 llm = LLM()
535 raw = await loop.run_in_executor(
536 None,
537 self._cancellable_query,
538 llm,
539 prompt,
540 [],
541 llm_model,
542 False,
543 sid,
544 False,
545 )
546 extracted = self._extract_json_object(raw)
547 if extracted is not None:
548 return extracted
549 raise ValueError("模型输出不是 JSON 对象")
550 except Exception as exc:
551 last_error = exc
552 logger.warning("[Storyboard] %s attempt %d failed: %s", label, attempt + 1, exc)
553 logger.error("[Storyboard] %s failed after retries. Last raw: %s", label, raw[:2000])
554 raise last_error or ValueError(f"{label} 失败")
555
556 @classmethod
557 def _normalize_shot_type(cls, value: Any, *, first: bool = False) -> str:
558 text = str(value or "")
559 if "全景" in text:
560 shot_type = "全景"
561 elif "中景" in text:
562 shot_type = "中景"
563 elif "近景" in text or "特写" in text:
564 shot_type = "近景"
565 else:
566 shot_type = "中景"
567 if first and shot_type not in cls.OPENING_SHOT_TYPES:
568 return "中景"
569 return shot_type
570
571 @classmethod
572 def _shot_durations_for_plan(cls, plan: dict) -> List[int]:
573 durations = [int(item.get("duration") or cls.MIN_SHOT_DURATION) for item in plan.get("items", [])]
574 total = sum(durations)
575 if durations and total < cls.MIN_SEGMENT_DURATION:
576 durations[-1] += cls.MIN_SEGMENT_DURATION - total
577 return durations
578
579 @classmethod
580 def _opening_camera_prefix(cls, shot_type: str, characters: List[str]) -> str:
581 subject = "、".join(characters) if characters else "片段中的所有人物"
582 return f"{shot_type},平视机位,镜头同时拍到{subject},站位:主要人物面向镜头或彼此成自然对话关系分布。"
583
584 @classmethod
585 def _normalize_segment_design(cls, ep_n: int, plan: dict, raw_design: dict) -> dict:
586 items = plan.get("items", [])
587 raw_shots = raw_design.get("shots") if isinstance(raw_design, dict) else None
588 if not isinstance(raw_shots, list) or not raw_shots:
589 raise ValueError("片段设计缺少 shots")
590
591 by_id = {
592 shot.get("unit_id"): shot
593 for shot in raw_shots
594 if isinstance(shot, dict) and isinstance(shot.get("unit_id"), str)
595 }
596 if by_id:
597 missing = [item["unit_id"] for item in items if item["unit_id"] not in by_id]
598 extra = [unit_id for unit_id in by_id if unit_id not in {item["unit_id"] for item in items}]
599 if missing or extra:
600 raise ValueError(f"片段设计 unit_id 不匹配,missing={missing}, extra={extra}")
601 ordered_raw = [by_id[item["unit_id"]] for item in items]
602 else:
603 if len(raw_shots) != len(items):
604 raise ValueError("片段设计 shots 数量与 items 不一致")
605 ordered_raw = raw_shots
606
607 durations = cls._shot_durations_for_plan(plan)
608 shots: List[dict] = []
609 characters = plan.get("characters", [])
610 for idx, (item, raw_shot) in enumerate(zip(items, ordered_raw)):
611 shot_type = cls._normalize_shot_type(raw_shot.get("shot_type"), first=(idx == 0))
612 content = str(raw_shot.get("content") or "").strip()
613 shots.append({
614 "shot_number": idx + 1,
615 "shot_type": shot_type,
616 "duration": durations[idx],
617 "content": content,
618 })
619
620 return cls._segment_from_shots(
621 ep_n,
622 int(plan.get("segment_number") or 1),
623 plan.get("location", ""),
624 shots,
625 characters,
626 plan.get("scene_time", ""),
627 plan.get("scene_space", ""),
628 plan.get("scene_context", ""),
629 )
630
631 @classmethod
632 def _fallback_design_segment(cls, ep_n: int, plan: dict) -> dict:
633 characters = plan.get("characters", [])
634 durations = cls._shot_durations_for_plan(plan)
635 shots: List[dict] = []
636 for idx, item in enumerate(plan.get("items", [])):
637 is_dialogue = bool(item.get("is_dialogue"))
638 speaker = item.get("speaker") or "角色"
639 shot_type = "中景" if idx == 0 or is_dialogue else "全景"
640 if idx == 0:
641 prefix = cls._opening_camera_prefix(shot_type, characters)
642 content = f"{prefix}人物朝向彼此或镜头侧前方。{item['text']}"
643 elif is_dialogue:
644 content = f"{shot_type},平视略侧机位拍摄{speaker},人物面向对话对象。{item['text']}"
645 else:
646 subject = "、".join(item.get("characters") or characters) or "场景主体"
647 content = f"{shot_type},平视跟拍{subject},人物沿动作方向移动。{item['text']}"
648 shots.append({
649 "shot_number": idx + 1,
650 "shot_type": shot_type,
651 "duration": durations[idx],
652 "content": content,
653 })
654 return cls._segment_from_shots(
655 ep_n,
656 int(plan.get("segment_number") or 1),
657 plan.get("location", ""),
658 shots,
659 characters,
660 plan.get("scene_time", ""),
661 plan.get("scene_space", ""),
662 plan.get("scene_context", ""),
663 )
664
665 @staticmethod
666 def _staging_continuity_payload(segments: List[dict]) -> List[dict]:
667 payload: List[dict] = []
668 for seg in segments:
669 payload.append({
670 "segment_number": seg.get("segment_number"),
671 "location": seg.get("location", ""),
672 "scene_context": seg.get("scene_context", ""),
673 "scene_space": seg.get("scene_space", ""),
674 "characters": seg.get("characters", []),
675 "shots": [
676 {
677 "shot_number": shot.get("shot_number"),
678 "shot_type": shot.get("shot_type", ""),
679 "content": shot.get("content", ""),
680 }
681 for shot in seg.get("shots", [])
682 if isinstance(shot, dict)
683 ],
684 })
685 return payload
686
687 @classmethod
688 def _build_staging_continuity_prompt(
689 cls,
690 ep_n: int,
691 ep_t: str,
692 segments: List[dict],
693 retry_error: str = "",
694 ) -> str:
695 template = _get_storyboard_prompt("staging_continuity", "zh")
696 retry_feedback = ""
697 if retry_error:
698 retry_feedback = f"上一次输出未通过校验,错误原因:{retry_error}\n请根据这个错误修正输出。"
699 from prompts.loader import format_prompt
700 return format_prompt(
701 template,
702 episode_number=ep_n,
703 episode_title=ep_t,
704 segments=json.dumps(cls._staging_continuity_payload(segments), ensure_ascii=False, indent=2),
705 retry_feedback=retry_feedback,
706 )
707
708 @staticmethod
709 def _apply_staging_continuity_patches(segments: List[dict], review: dict) -> int:
710 patches = review.get("patches") if isinstance(review, dict) else None
711 if not isinstance(patches, list):
712 raise ValueError("站位连续性检查输出缺少 patches 数组")
713
714 segment_by_number = {
715 int(seg.get("segment_number") or 0): seg
716 for seg in segments
717 if isinstance(seg, dict)
718 }
719 updates: List[Tuple[dict, str]] = []
720 for patch in patches:
721 if not isinstance(patch, dict):
722 raise ValueError("站位连续性补丁包含非对象元素")
723 try:
724 segment_number = int(patch.get("segment_number"))
725 shot_number = int(patch.get("shot_number"))
726 except (TypeError, ValueError):
727 raise ValueError(f"站位连续性补丁编号无效: {patch}")
728 content = str(patch.get("content") or "").strip()
729 if not content:
730 raise ValueError(f"站位连续性补丁 content 为空: {patch}")
731
732 seg = segment_by_number.get(segment_number)
733 if not seg:
734 raise ValueError(f"站位连续性补丁引用未知片段: {segment_number}")
735 shot = next(
736 (item for item in seg.get("shots", []) if int(item.get("shot_number") or 0) == shot_number),
737 None,
738 )
739 if not shot:
740 raise ValueError(f"站位连续性补丁引用未知分镜: segment={segment_number}, shot={shot_number}")
741 updates.append((shot, content))
742
743 for shot, content in updates:
744 shot["content"] = content
745 return len(updates)
746
747 async def _fix_episode_staging_continuity(
748 self,
749 ep_n: int,
750 ep_t: str,
751 segments: List[dict],
752 llm_model: str,
753 sid: str,
754 ) -> List[dict]:
755 last_error: Optional[Exception] = None
756 for attempt in range(2):
757 try:
758 prompt = self._build_staging_continuity_prompt(
759 ep_n,
760 ep_t,
761 segments,
762 retry_error=str(last_error) if last_error else "",
763 )
764 review = await self._query_json_object_with_retries(
765 prompt,
766 llm_model,
767 sid,
768 label=f"第 {ep_n} 集人物站位连续性检查",
769 max_retries=1,
770 )
771 applied = self._apply_staging_continuity_patches(segments, review)
772 issues = review.get("issues") if isinstance(review, dict) else []
773 if applied:
774 logger.info("[Storyboard] Episode %s staging continuity fixed %d shots. issues=%s", ep_n, applied, issues)
775 else:
776 logger.info("[Storyboard] Episode %s staging continuity passed.", ep_n)
777 return segments
778 except Exception as exc:
779 last_error = exc
780 logger.warning("[Storyboard] Episode %s staging continuity attempt %d failed: %s", ep_n, attempt + 1, exc)
781 logger.warning("[Storyboard] Episode %s staging continuity check skipped after retries: %s", ep_n, last_error)
782 return segments
783
784 async def _plan_episode_segments(
785 self,
786 ep_n: int,
787 ep_t: str,
788 annotated_script: str,
789 units: List[dict],
790 characters: List[dict],
791 settings: List[dict],
792 llm_model: str,
793 sid: str,
794 ) -> List[dict]:
795 last_error: Optional[Exception] = None
796 for attempt in range(3):
797 try:
798 prompt = self._build_segmentation_prompt(
799 ep_n,
800 ep_t,
801 annotated_script,
802 characters,
803 settings,
804 retry_error=str(last_error) if last_error else "",
805 )
806 raw_plan = await self._query_json_array_with_retries(
807 prompt,
808 llm_model,
809 sid,
810 label=f"第 {ep_n} 集片段规划",
811 max_retries=1,
812 )
813 return self._validate_segment_plan(ep_n, raw_plan, units)
814 except Exception as exc:
815 last_error = exc
816 logger.warning("[Storyboard] Episode %s segment plan validation attempt %d failed: %s", ep_n, attempt + 1, exc)
817
818 logger.warning("[Storyboard] Episode %s falling back to deterministic segment plan: %s", ep_n, last_error)
819 return self._fallback_segment_plan(ep_n, units)
820
821 async def _design_one_segment(
822 self,
823 ep_n: int,
824 ep_t: str,
825 plan: dict,
826 style: str,
827 llm_model: str,
828 sid: str,
829 ) -> dict:
830 last_error: Optional[Exception] = None
831 for attempt in range(3):
832 try:
833 prompt = self._build_segment_design_prompt(
834 ep_n,
835 ep_t,
836 plan,
837 style,
838 retry_error=str(last_error) if last_error else "",
839 )
840 raw_design = await self._query_json_object_with_retries(
841 prompt,
842 llm_model,
843 sid,
844 label=f"第 {ep_n} 集片段 {plan.get('segment_number')} 分镜设计",
845 max_retries=1,
846 )
847 return self._normalize_segment_design(ep_n, plan, raw_design)
848 except Exception as exc:
849 last_error = exc
850 logger.warning(
851 "[Storyboard] Episode %s segment %s design attempt %d failed: %s",
852 ep_n,
853 plan.get("segment_number"),
854 attempt + 1,
855 exc,
856 )
857
858 logger.warning(
859 "[Storyboard] Episode %s segment %s falling back to deterministic design: %s",
860 ep_n,
861 plan.get("segment_number"),
862 last_error,
863 )
864 return self._fallback_design_segment(ep_n, plan)
865
866 async def _design_episode_storyboard(
867 self,
868 ep_n: int,
869 ep_t: str,
870 ep_c: str,
871 characters: List[dict],
872 settings: List[dict],
873 style: str,
874 llm_model: str,
875 sid: str,
876 progress_note: Optional[Callable[[str], None]] = None,
877 ) -> List[dict]:
878 annotated_script, units = self._annotate_episode_script(ep_c, characters, settings)
879 if not units:
880 raise Exception(f"第 {ep_n} 集未能识别出动作或台词")
881
882 logger.info("[Storyboard] Episode %s annotated %d script units", ep_n, len(units))
883 if progress_note:
884 progress_note(f"第 {ep_n} 集已完成时长标注,正在划分片段")
885 plans = await self._plan_episode_segments(ep_n, ep_t, annotated_script, units, characters, settings, llm_model, sid)
886 if not plans:
887 raise Exception(f"第 {ep_n} 集片段规划失败")
888
889 logger.info("[Storyboard] Episode %s planned %d segments; designing in parallel", ep_n, len(plans))
890 if progress_note:
891 progress_note(f"第 {ep_n} 集已规划 {len(plans)} 个片段,正在设计分镜")
892 tasks = [
893 self._design_one_segment(ep_n, ep_t, plan, style, llm_model, sid)
894 for plan in plans
895 ]
896 segments = await asyncio.gather(*tasks)
897 segments.sort(key=lambda item: int(item.get("segment_number") or 0))
898 logger.info("[Storyboard] Episode %s checking staging continuity", ep_n)
899 if progress_note:
900 progress_note(f"第 {ep_n} 集正在检查人物站位连续性")
901 segments = await self._fix_episode_staging_continuity(ep_n, ep_t, segments, llm_model, sid)
902 return segments
903
904 @classmethod
905 def _estimate_duration(cls, text: str, is_dialogue: bool) -> int:
906 """Legacy heuristic duration in seconds."""
907 visible_text = re.sub(r"[“”\"',。!?、,.!?;;::\s]", "", text)
908 if is_dialogue:
909 duration = 3 + len(visible_text) // 18
910 else:
911 duration = 3 + len(visible_text) // 34
912 return max(cls.MIN_SHOT_DURATION, min(duration, cls.MAX_SEGMENT_DURATION))
913
914 @staticmethod
915 def _infer_shot_type(text: str, is_dialogue: bool) -> str:
916 if re.search(r"城市|办公室|窗外|全景|拉远|场景|夜景|两台电脑|屏幕并排", text):
917 return "全景"
918 if re.search(r"特写|屏幕|手机|键盘|终端|报表|手指|代码|PASS|眼睛|嘴角|表情", text):
919 return "近景"
920 if is_dialogue:
921 return "中景"
922 return "中景"
923
924 @classmethod
925 def _normalize_first_shot_type(cls, shots: List[dict]) -> None:
926 if not shots:
927 return
928 if shots[0].get("shot_type") not in cls.OPENING_SHOT_TYPES:
929 shots[0]["shot_type"] = "中景"
930 content = shots[0].get("content", "")
931 shots[0]["content"] = re.sub(r"^(近景|过肩近景|特写|大特写)", "中景", content, count=1)
932
933 @classmethod
934 def _ensure_segment_duration(cls, shots: List[dict]) -> int:
935 total = sum(int(shot.get("duration") or cls.MIN_SHOT_DURATION) for shot in shots)
936 if shots and total < cls.MIN_SEGMENT_DURATION:
937 shots[-1]["duration"] += cls.MIN_SEGMENT_DURATION - total
938 total = cls.MIN_SEGMENT_DURATION
939 return min(total, cls.MAX_SEGMENT_DURATION)
940
941 @classmethod
942 def _make_shot_content(
943 cls,
944 *,
945 shot_type: str,
946 line: str,
947 characters: List[str],
948 is_dialogue: bool,
949 speaker: str = "",
950 tone: str = "",
951 ) -> str:
952 if is_dialogue:
953 tone_text = tone or "自然、贴合当下情绪"
954 return f"{shot_type},镜头对准{speaker or '角色'},呈现动作与表情变化。{speaker}说:“{line}”。音色:{tone_text}。"
955 subject = "、".join(characters) if characters else "场景主体"
956 return f"{shot_type},镜头呈现{subject}。{line}"
957
958 @classmethod
959 def _segment_from_shots(
960 cls,
961 ep_n: int,
962 segment_number: int,
963 location: str,
964 shots: List[dict],
965 characters: List[str],
966 scene_time: str = "",
967 scene_space: str = "",
968 scene_context: str = "",
969 ) -> dict:
970 for idx, shot in enumerate(shots, 1):
971 shot["shot_number"] = idx
972 shot["duration"] = min(
973 cls.MAX_SEGMENT_DURATION,
974 max(cls.MIN_SHOT_DURATION, int(shot.get("duration") or cls.MIN_SHOT_DURATION)),
975 )
976 cls._normalize_first_shot_type(shots)
977 total_duration = cls._ensure_segment_duration(shots)
978 segment = {
979 "segment_id": f"seg_{ep_n:02d}_{segment_number:02d}",
980 "segment_number": segment_number,
981 "total_duration": total_duration,
982 "location": location,
983 "characters": characters,
984 "shots": shots,
985 "episode_number": ep_n,
986 }
987 if scene_time:
988 segment["scene_time"] = scene_time
989 if scene_space:
990 segment["scene_space"] = scene_space
991 if scene_context:
992 segment["scene_context"] = scene_context
993 return segment
994
995 @classmethod
996 def _build_segments_by_regex(
997 cls,
998 ep_n: int,
999 script_text: str,
1000 characters: List[dict],
1001 settings: List[dict],
1002 ) -> List[dict]:
1003 """Deterministically parse script text into model-ready segments.
1004
1005 This path intentionally mirrors the LLM prompt output:
1006 segment_number, total_duration, location, characters, shots[].
1007 If it cannot extract useful shots, the caller falls back to LLM.
1008 """
1009 character_names = cls._character_names(characters)
1010 setting_names = cls._setting_names(settings)
1011 raw_lines = [cls._strip_markup(line) for line in script_text.replace("\r\n", "\n").split("\n")]
1012
1013 atomic_shots: List[dict] = []
1014 current_location = setting_names[0] if setting_names else ""
1015 current_scene_time = ""
1016 current_scene_space = ""
1017 current_scene_context = ""
1018 scene_characters: List[str] = []
1019 scene_key = 0
1020 found_scene_header = False
1021
1022 for raw_line in raw_lines:
1023 line = cls._clean_script_line(raw_line)
1024 if not line or cls._is_metadata_line(line) or cls._is_end_marker(line):
1025 continue
1026 if re.match(r"^第?\d+集$", line):
1027 continue
1028
1029 header_parts = cls._parse_scene_header_parts(line, setting_names)
1030 if header_parts:
1031 found_scene_header = True
1032 scene_key += 1
1033 current_location = header_parts.get("location", current_location)
1034 current_scene_time = header_parts.get("scene_time", "")
1035 current_scene_space = header_parts.get("scene_space", "")
1036 current_scene_context = header_parts.get("scene_context", line)
1037 matched = cls._match_characters(line, character_names)
1038 if matched:
1039 scene_characters = matched
1040 continue
1041
1042 if re.match(r"^人物[::]", line):
1043 scene_characters = cls._match_characters(line, character_names)
1044 continue
1045
1046 matched_chars = cls._match_characters(line, character_names)
1047 shot_chars = matched_chars or scene_characters
1048 dialogue = cls._dialogue_parts(line)
1049 is_dialogue = dialogue is not None
1050 speaker = ""
1051 tone = ""
1052 shot_text = line
1053 if dialogue:
1054 speaker, tone, shot_text = dialogue
1055 speaker_matches = cls._match_characters(speaker, character_names)
1056 if speaker_matches:
1057 shot_chars = list(dict.fromkeys(speaker_matches + shot_chars))
1058 elif speaker in {"旁白", "独白", "画外音"}:
1059 shot_chars = shot_chars or scene_characters
1060
1061 shot_type = cls._infer_shot_type(line, is_dialogue)
1062 duration = cls._estimate_duration(shot_text, is_dialogue)
1063 location = cls._resolve_location(line, current_location, setting_names)
1064
1065 atomic_shots.append({
1066 "scene_key": scene_key,
1067 "location": location,
1068 "scene_time": current_scene_time,
1069 "scene_space": current_scene_space,
1070 "scene_context": current_scene_context,
1071 "characters": shot_chars,
1072 "shot": {
1073 "shot_number": 0,
1074 "shot_type": shot_type,
1075 "duration": duration,
1076 "content": cls._make_shot_content(
1077 shot_type=shot_type,
1078 line=shot_text,
1079 characters=shot_chars,
1080 is_dialogue=is_dialogue,
1081 speaker=speaker,
1082 tone=tone,
1083 ),
1084 },
1085 })
1086
1087 if not found_scene_header or not atomic_shots:
1088 return []
1089
1090 segments: List[dict] = []
1091 current_items: List[dict] = []
1092 current_scene_key: Optional[int] = None
1093 current_location = ""
1094 current_scene_time = ""
1095 current_scene_space = ""
1096 current_scene_context = ""
1097 current_chars: List[str] = []
1098
1099 def flush_current():
1100 nonlocal current_items, current_scene_key, current_location
1101 nonlocal current_scene_time, current_scene_space, current_scene_context, current_chars
1102 if not current_items:
1103 return
1104 shots = [item["shot"] for item in current_items]
1105 chars: List[str] = []
1106 for item in current_items:
1107 for name in item["characters"]:
1108 if name not in chars:
1109 chars.append(name)
1110 if not chars:
1111 chars = current_chars
1112 segments.append(cls._segment_from_shots(
1113 ep_n,
1114 len(segments) + 1,
1115 current_location,
1116 shots,
1117 chars,
1118 current_scene_time,
1119 current_scene_space,
1120 current_scene_context,
1121 ))
1122 current_items = []
1123 current_scene_key = None
1124 current_location = ""
1125 current_scene_time = ""
1126 current_scene_space = ""
1127 current_scene_context = ""
1128 current_chars = []
1129
1130 for item in atomic_shots:
1131 item_duration = int(item["shot"]["duration"])
1132 current_duration = sum(int(existing["shot"]["duration"]) for existing in current_items)
1133 scene_changed = current_items and (
1134 item["scene_key"] != current_scene_key or item["location"] != current_location
1135 )
1136 would_overflow = current_items and current_duration + item_duration > cls.MAX_SEGMENT_DURATION
1137
1138 # A scene switch always starts a new video-model call. Otherwise greedily
1139 # pack shots until the next one would exceed the 15s upper bound.
1140 if scene_changed or would_overflow:
1141 flush_current()
1142
1143 current_items.append(item)
1144 current_scene_key = item["scene_key"]
1145 current_location = item["location"]
1146 current_scene_time = item.get("scene_time", "")
1147 current_scene_space = item.get("scene_space", "")
1148 current_scene_context = item.get("scene_context", "")
1149 for name in item["characters"]:
1150 if name not in current_chars:
1151 current_chars.append(name)
1152
1153 flush_current()
1154 return segments
1155
1156 @classmethod
1157 def _normalize_llm_segments(cls, ep_n: int, extracted: List[dict]) -> List[dict]:
1158 valid_segments = []
1159 for seg in extracted:
1160 if not isinstance(seg, dict):
1161 continue
1162
1163 shots = seg.get("shots", [])
1164 pending_shots = []
1165 for s in shots:
1166 if not isinstance(s, dict):
1167 continue
1168 dur = s.get("duration", cls.MIN_SHOT_DURATION)
1169 try:
1170 dur = int(dur)
1171 except (TypeError, ValueError):
1172 dur = cls.MIN_SHOT_DURATION
1173 pending_shots.append({
1174 "shot_number": 0,
1175 "shot_type": s.get("shot_type", "中景"),
1176 "duration": max(cls.MIN_SHOT_DURATION, dur),
1177 "content": s.get("content", "")
1178 })
1179
1180 if not pending_shots:
1181 continue
1182
1183 chunk: List[dict] = []
1184
1185 def flush_chunk():
1186 nonlocal chunk
1187 if not chunk:
1188 return
1189 valid_segments.append(cls._segment_from_shots(
1190 ep_n,
1191 len(valid_segments) + 1,
1192 seg.get("location", ""),
1193 chunk,
1194 seg.get("characters", []),
1195 ))
1196 chunk = []
1197
1198 for shot in pending_shots:
1199 chunk_duration = sum(int(item.get("duration") or cls.MIN_SHOT_DURATION) for item in chunk)
1200 if chunk and chunk_duration + int(shot["duration"]) > cls.MAX_SEGMENT_DURATION:
1201 flush_chunk()
1202 chunk.append(shot)
1203 flush_chunk()
1204 return valid_segments
1205
1206 @staticmethod
1207 def _validate_episodes(episodes: List[dict]) -> List[dict]:
1208 """验证嵌套的 Episode -> Segment -> Shots 结构"""
1209 valid_episodes = []
1210 for ep in episodes:
1211 if not isinstance(ep, dict): continue
1212
1213 segments = ep.get("segments", [])
1214 valid_segments = []
1215 for idx, seg in enumerate(segments, 1):
1216 if not isinstance(seg, dict): continue
1217
1218 shots = seg.get("shots", [])
1219 valid_shots = []
1220 calc_total_duration = 0
1221
1222 for s in shots:
1223 if not isinstance(s, dict): continue
1224 dur = s.get("duration", 5)
1225 calc_total_duration += dur
1226 valid_shots.append({
1227 "shot_number": s.get("shot_number", len(valid_shots) + 1),
1228 "shot_type": s.get("shot_type", "中景"),
1229 "duration": dur,
1230 "content": s.get("content", "")
1231 })
1232
1233 valid_segments.append({
1234 "segment_id": seg.get("segment_id", f"seg_{str(idx).zfill(8)}"),
1235 "segment_number": seg.get("segment_number", len(valid_segments) + 1),
1236 "total_duration": seg.get("total_duration", calc_total_duration),
1237 "location": seg.get("location", ""),
1238 "scene_time": seg.get("scene_time", ""),
1239 "scene_space": seg.get("scene_space", ""),
1240 "scene_context": seg.get("scene_context", ""),
1241 "characters": seg.get("characters", []),
1242 "shots": valid_shots
1243 })
1244
1245 valid_episodes.append({
1246 "episode_number": ep.get("episode_number", len(valid_episodes) + 1),
1247 "episode_title": ep.get("episode_title", ""),
1248 "segments": valid_segments
1249 })
1250 return valid_episodes
1251
1252 async def process(self, input_data: Any, intervention: Optional[Dict] = None) -> Dict:
1253 input_data = self._merge_session_params(input_data)
1254 sid = input_data.get("session_id")
1255 if not sid: raise Exception("Missing session_id")
1256 artifacts = self._session_artifacts(input_data)
1257 session_meta = self._session_meta(input_data)
1258
1259 llm_model = input_data.get("llm_model") or session_meta.get("llm_model")
1260 if not llm_model:
1261 raise ValueError("Missing required model configuration: llm_model")
1262 style = input_data.get("style") or session_meta.get("style") or "anime"
1263
1264 # 处理人工干预/修改
1265 if intervention and "modified_storyboard" in intervention:
1266 modified_episodes = intervention["modified_storyboard"]
1267 if isinstance(modified_episodes, str): modified_episodes = json.loads(modified_episodes)
1268 return {
1269 "payload": {
1270 "session_id": sid,
1271 "episodes": modified_episodes,
1272 "user_modified": True,
1273 "updated_at": datetime.now().isoformat(),
1274 },
1275 "stage_completed": True,
1276 }
1277
1278 script_data = artifacts.get("script_generation", {})
1279 if not script_data: raise Exception("未找到剧本数据")
1280
1281 episodes = script_data.get("episodes", [])
1282 if not episodes:
1283 raise Exception("剧本数据中不包含有效集数列表(episodes)")
1284
1285 # 检查是否有已存在的分镜数据,识别需要生成的集数
1286 existing_storyboard = artifacts.get("storyboard", {})
1287 existing_story_eps = existing_storyboard.get("episodes", [])
1288
1289 # 建立已生成的 segments 索引
1290 ready_eps = {e["episode_number"] for e in existing_story_eps if e.get("segments")}
1291
1292 # 确定需要处理的集数:如果该集还没有 segments,则需要生成
1293 episodes_to_proc = [ep for ep in episodes if ep.get("episode_number") not in ready_eps]
1294
1295 if not episodes_to_proc:
1296 logger.info("[Storyboard] All episodes already have storyboard segments. Skipping generation.")
1297 return {"payload": {"session_id": sid, "episodes": existing_story_eps}, "stage_completed": True}
1298
1299 chars = script_data.get("characters", [])
1300 sets = script_data.get("settings", [])
1301
1302 self._report_progress("分镜", f"开始设计 {len(episodes_to_proc)} 集的分镜...", 5)
1303 total_to_process = len(episodes_to_proc)
1304 completed_count = 0
1305 progress_state = {"percent": 10}
1306
1307 def report_storyboard_note(message: str, percent: Optional[int] = None, data: Optional[dict] = None):
1308 if percent is not None:
1309 progress_state["percent"] = max(progress_state["percent"], percent)
1310 self._report_progress("分镜设计", message, progress_state["percent"], data)
1311
1312 async def proc_ep(ep):
1313 ep_n = ep.get("episode_number", 1)
1314 ep_t = ep.get("act_title", f"第{ep_n}集")
1315 ep_c = ep.get("content", "")
1316 report_storyboard_note(f"正在处理第 {ep_n} 集分镜")
1317 segments = await self._design_episode_storyboard(
1318 ep_n,
1319 ep_t,
1320 ep_c,
1321 chars,
1322 sets,
1323 style,
1324 llm_model,
1325 sid,
1326 progress_note=report_storyboard_note,
1327 )
1328
1329 return {
1330 "episode_number": ep_n,
1331 "episode_title": ep_t,
1332 "segments": segments
1333 }
1334
1335 # 核心:支持流式推送增量产物,让前端能看到实时进度
1336 updated_ep_map = {e["episode_number"]: e for e in existing_story_eps}
1337
1338 # 报告一次进度,带上 assets_preview 让编排器更新内存中的初步数据
1339 report_storyboard_note("准备生成分镜...", 10, {
1340 "assets_preview": {
1341 "session_id": sid,
1342 "episodes": sorted(updated_ep_map.values(), key=lambda x: x["episode_number"]),
1343 "created_at": datetime.now().isoformat(),
1344 },
1345 "persist": True,
1346 })
1347
1348 results_queue = [asyncio.create_task(proc_ep(ep)) for ep in episodes_to_proc]
1349
1350 for coro in asyncio.as_completed(results_queue):
1351 res = await coro
1352 completed_count += 1
1353 updated_ep_map[res["episode_number"]] = res
1354
1355 # 每完成一集分镜,通过编排器更新内存并受控持久化
1356 temp_eps = sorted(updated_ep_map.values(), key=lambda x: x["episode_number"])
1357
1358 # 并发多集时只按已完成集数推进全局进度,避免各集内部进度互相覆盖。
1359 pct = min(95, 10 + int(85 * completed_count / max(total_to_process, 1)))
1360 report_storyboard_note(
1361 f"已完成 {completed_count}/{total_to_process} 集分镜:第 {res['episode_number']} 集",
1362 pct,
1363 {
1364 "assets_preview": {
1365 "session_id": sid,
1366 "episodes": temp_eps,
1367 "created_at": datetime.now().isoformat(),
1368 },
1369 "persist": True,
1370 },
1371 )
1372
1373 final_all_episodes = sorted(updated_ep_map.values(), key=lambda x: x["episode_number"])
1374
1375 self._report_progress("分镜", "完成", 100)
1376 return {"payload": {"session_id": sid, "episodes": final_all_episodes}, "stage_completed": True}
1377
1377 lines PYTHON