返回 JoyAI-Echo
director.py
1 """Director tools for structured video-workspace orchestration.
2
3 These tools keep a work-specific state machine on disk so the model can rely
4 on tool-managed state instead of reconstructing progress from conversation
5 history alone.
6 """
7
8 from __future__ import annotations
9
10 import asyncio
11 import hashlib
12 import json
13 import re
14 import time
15 from contextvars import ContextVar
16 from datetime import datetime, timezone
17 from pathlib import Path
18 from typing import Any
19 from urllib import error as urllib_error
20 from urllib import request as urllib_request
21 from urllib.parse import unquote, urlparse
22
23 from loguru import logger
24
25 from nanobot.agent.tools import (
26 ArraySchema,
27 BooleanSchema,
28 IntegerSchema,
29 ObjectSchema,
30 StringSchema,
31 Tool,
32 tool_parameters,
33 tool_parameters_schema,
34 )
35 from nanobot.integrations.echo_admission import (
36 UNAVAILABLE_MESSAGE,
37 EchoAdmissionController,
38 EchoGeneratorBusyError,
39 EchoGeneratorUnavailableError,
40 is_connection_refused,
41 )
42 from nanobot.prompts import prompts
43 from nanobot.prompts.manager import PEManager
44 from nanobot.session.auto_generate import (
45 effective_auto_generate_shot_count,
46 get_auto_generate,
47 locked_shot_count_from_goal,
48 )
49 from nanobot.session.reference_image import (
50 clear_reference_image_needs_story_rewrite,
51 is_reference_image_locked,
52 lock_reference_image,
53 normalize_reference_image,
54 reference_image_needs_story_rewrite,
55 reference_image_present,
56 )
57 from nanobot.utils.helpers import write_json_atomic
58
59 DIRECTOR_CONTEXT_TOOL_NAMES = frozenset(
60 {
61 "start_director",
62 "set_director_goal",
63 "get_workplace_status",
64 "get_story",
65 "write_story",
66 "get_shot",
67 "create_shot_prompt",
68 "review_shot",
69 "set_shot_references",
70 "set_shot_memory_recommendations",
71 "generate_echo_shot",
72 "merge_shot",
73 }
74 )
75
76 DIRECTOR_MUTATING_TOOL_NAMES = frozenset(
77 {
78 "start_director",
79 "set_director_goal",
80 "write_story",
81 "create_shot_prompt",
82 "review_shot",
83 "set_shot_references",
84 "set_shot_memory_recommendations",
85 "generate_echo_shot",
86 "merge_shot",
87 }
88 )
89
90 # Shown in stepwise chat after the user locks shot_count and before they click
91 # Workplace 01 「下一步」. Never used for input-box one-click (auto_generate).
92 SHOT_COUNT_NEXT_STEP_HINT = (
93 "点击「下一步」即可预览分镜脚本。满意脚本后接下来可以生成分镜镜头,"
94 "确认无误并接受所有分镜后,就能合成最终成片了。有任何问题可以随时找我~"
95 )
96 SHOT_COUNT_NEXT_STEP_HINT_PENDING_KEY = "shot_count_next_step_hint_pending"
97 _STAGES_PAST_SHOT_COUNT_HINT = frozenset(
98 {
99 "shot_planning",
100 "shot_generating",
101 "shot_reviewing",
102 "shot_revising",
103 "merging",
104 "done",
105 "cancelled",
106 "awaiting_memory_review",
107 "failed",
108 }
109 )
110
111
112 def consume_shot_count_next_step_hint(
113 workspace: Path,
114 session_key: str | None,
115 *,
116 auto_generate: bool = False,
117 emit: bool = True,
118 ) -> str | None:
119 """Return the stepwise 「下一步」 hint once after shot_count is first locked."""
120 if not session_key:
121 return None
122 tool = GetWorkplaceStatusTool(workspace=workspace)
123 tool.set_context("websocket", "direct", effective_key=session_key)
124 return tool._consume_shot_count_next_step_hint(
125 auto_generate=auto_generate,
126 emit=emit,
127 )
128
129
130 def stepwise_shot_count_next_step_hint_eligible(
131 workspace: Path,
132 session_key: str | None,
133 *,
134 auto_generate: bool = False,
135 ) -> bool:
136 """True when stepwise work is still on 01 with a locked shot_count."""
137 if not session_key or auto_generate:
138 return False
139 tool = GetWorkplaceStatusTool(workspace=workspace)
140 tool.set_context("websocket", "direct", effective_key=session_key)
141 work_id = tool._active_work_id()
142 if not work_id:
143 return False
144 state = tool._load_state(work_id)
145 if bool(state.get("auto_generate")):
146 return False
147 if str(state.get("stage") or "") in _STAGES_PAST_SHOT_COUNT_HINT:
148 return False
149 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
150 try:
151 return int(goal.get("shot_count") or 0) > 0
152 except (TypeError, ValueError):
153 return False
154
155 _REMOTE_PROTOCOL_VERSION = "director-http-v1"
156 _R2V_SUBMIT_ATTEMPTS = 3
157 _R2V_TRANSIENT_HTTP_CODES = frozenset({429, 502, 503, 504})
158
159 _REMOTE_ENDPOINT_PATHS = {
160 # Stable Echo Server routes used by the release workflow.
161 "merge_shot": "/merge",
162 # R2V unified generation (T2V / I2V / R2V)
163 "r2v_generate": "/r2v",
164 }
165
166 _REMOTE_CALLBACK_PATHS = {
167 "generate_echo_shot": "/api/director/echo-generate-shot/callback",
168 "merge_shot": "/api/director/merge-shot/callback",
169 }
170
171 # Workplace workflow transitions are button-driven. Chat turns may only advance
172 # stages when the agent loop sets one of these injected workplace events.
173 WORKFLOW_GATE_BYPASS = "workplace_test_bypass"
174 _WORKFLOW_INJECTED_EVENT: ContextVar[str | None] = ContextVar(
175 "director_workflow_injected_event",
176 default=None,
177 )
178 _WORKFLOW_CONTEXT_UNSET = object()
179 _WORKFLOW_GATE_OPERATIONS: dict[str, frozenset[str]] = {
180 "write_story_confirmed": frozenset(
181 {
182 "workplace_workflow_confirm_story",
183 "workplace_workflow_start_generation",
184 "workplace_beats_edit",
185 WORKFLOW_GATE_BYPASS,
186 }
187 ),
188 "create_shot_prompt": frozenset(
189 {
190 "workplace_workflow_start_generation",
191 "workplace_beats_edit",
192 "workplace_shot_revision",
193 WORKFLOW_GATE_BYPASS,
194 }
195 ),
196 "set_shot_references": frozenset(
197 {
198 "workplace_workflow_start_generation",
199 WORKFLOW_GATE_BYPASS,
200 }
201 ),
202 "set_shot_memory_recommendations": frozenset(
203 {
204 "workplace_memory_recommendation",
205 WORKFLOW_GATE_BYPASS,
206 }
207 ),
208 "generate_echo_shot": frozenset(
209 {
210 "workplace_workflow_start_generation",
211 "workplace_shot_revision",
212 WORKFLOW_GATE_BYPASS,
213 }
214 ),
215 "merge_shot": frozenset(
216 {
217 "workplace_workflow_start_merge",
218 WORKFLOW_GATE_BYPASS,
219 }
220 ),
221 "review_shot": frozenset({WORKFLOW_GATE_BYPASS}),
222 }
223
224
225 def _workflow_gate_error(operation: str) -> str:
226 return prompts.text("director.workflow_gate_error", operation=operation)
227
228
229 def _allow_workflow_operation(operation: str) -> bool:
230 event = _WORKFLOW_INJECTED_EVENT.get()
231 if event == WORKFLOW_GATE_BYPASS:
232 return True
233 if not event:
234 return False
235 return event in _WORKFLOW_GATE_OPERATIONS.get(operation, frozenset())
236
237
238 def _now_iso() -> str:
239 return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
240
241
242 def _slugify(value: str, *, fallback: str) -> str:
243 slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower())
244 slug = re.sub(r"-{2,}", "-", slug).strip("-")
245 return slug or fallback
246
247
248 def _json_dump(data: Any) -> str:
249 return json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)
250
251
252 def _story_profile_validation_error(story_profile: Any) -> str | None:
253 if not isinstance(story_profile, dict):
254 return "Error: story_profile must be a JSON object with summary and beats."
255 summary = story_profile.get("summary")
256 if not isinstance(summary, str) or not summary.strip():
257 return "Error: story_profile.summary must be a non-empty string."
258 beats = story_profile.get("beats")
259 if not isinstance(beats, list) or len(beats) < 1:
260 return "Error: story_profile.beats must contain at least one beat."
261 for index, beat in enumerate(beats):
262 if not isinstance(beat, dict):
263 return (
264 f"Error: story_profile.beats[{index}] must be an object with shot_id and summary."
265 )
266 beat_summary = beat.get("summary")
267 if not isinstance(beat_summary, str) or not beat_summary.strip():
268 return f"Error: story_profile.beats[{index}].summary must be a non-empty string."
269 return None
270
271
272 def _story_profile_language_validation_error(
273 story_profile: dict[str, Any],
274 ) -> str | None:
275 """Keep all natural-language story-profile prose in the selected language."""
276 from nanobot.session.generation_settings import normalize_language
277
278 normalized = normalize_language(story_profile.get("language"))
279 if normalized is None:
280 normalized = normalize_language(story_profile.get("caption_language"))
281 if normalized is None:
282 normalized = normalize_language(story_profile.get("dialogue_language"))
283 if normalized is None:
284 return None
285
286 summaries: list[tuple[str, str]] = []
287 prose: list[tuple[str, str]] = []
288
289 def collect_text(value: Any, path: str) -> None:
290 if isinstance(value, str):
291 if value.strip():
292 prose.append((path, value))
293 return
294 if isinstance(value, list):
295 for index, item in enumerate(value):
296 collect_text(item, f"{path}[{index}]")
297 return
298 if isinstance(value, dict):
299 for key, item in value.items():
300 collect_text(item, f"{path}.{key}")
301
302 summary = story_profile.get("summary")
303 if isinstance(summary, str):
304 summaries.append(("story_profile.summary", summary))
305 beats = story_profile.get("beats")
306 if isinstance(beats, list):
307 for index, beat in enumerate(beats):
308 if not isinstance(beat, dict):
309 continue
310 if isinstance(beat.get("summary"), str):
311 summaries.append((f"story_profile.beats[{index}].summary", beat["summary"]))
312 collect_text(
313 beat.get("dialogue_intent"),
314 f"story_profile.beats[{index}].dialogue_intent",
315 )
316
317 for field in ("anchors", "scene_anchors", "shot_to_content"):
318 collect_text(story_profile.get(field), f"story_profile.{field}")
319
320 def _has_chinese(value: str) -> bool:
321 return bool(re.search(r"[\u3400-\u4dbf\u4e00-\u9fff]", value))
322
323 if normalized == "zh":
324 invalid_summaries = [name for name, value in summaries if not _has_chinese(value)]
325 invalid_prose = [name for name, value in prose if not _has_chinese(value)]
326 if invalid_prose:
327 invalid = invalid_summaries + invalid_prose
328 return (
329 "Error: Chinese story-profile prose is required for this work. "
330 "Rewrite all natural-language story_profile fields in Simplified Chinese. "
331 f"Invalid fields: {', '.join(invalid)}."
332 )
333 if invalid_summaries:
334 return (
335 "Error: Chinese storyboard summaries are required for this work. "
336 "Rewrite story_profile.summary and every beats[].summary in Simplified Chinese. "
337 f"Invalid fields: {', '.join(invalid_summaries)}."
338 )
339 elif normalized == "en":
340 invalid_summaries = [name for name, value in summaries if _has_chinese(value)]
341 invalid_prose = [name for name, value in prose if _has_chinese(value)]
342 if invalid_prose:
343 invalid = invalid_summaries + invalid_prose
344 return (
345 "Error: English story-profile prose is required for this work. "
346 "Rewrite all natural-language story_profile fields in English. "
347 f"Invalid fields: {', '.join(invalid)}."
348 )
349 if invalid_summaries:
350 return (
351 "Error: English storyboard summaries are required for this work. "
352 "Rewrite story_profile.summary and every beats[].summary in English. "
353 f"Invalid fields: {', '.join(invalid_summaries)}."
354 )
355 return None
356
357
358 def _story_md_language_validation_error(
359 story_md: str,
360 story_profile: dict[str, Any],
361 ) -> str | None:
362 """Keep the displayed screenplay aligned with the selected story language."""
363 from nanobot.session.generation_settings import normalize_language
364
365 normalized = normalize_language(story_profile.get("language"))
366 if normalized is None:
367 normalized = normalize_language(story_profile.get("caption_language"))
368 if normalized is None:
369 normalized = normalize_language(story_profile.get("dialogue_language"))
370 if normalized is None or not story_md.strip():
371 return None
372
373 has_chinese = bool(re.search(r"[\u3400-\u4dbf\u4e00-\u9fff]", story_md))
374 if normalized == "zh" and not has_chinese:
375 return (
376 "Error: Chinese screenplay prose is required for this work. "
377 "Rewrite story_md in Simplified Chinese."
378 )
379 if normalized == "en" and has_chinese:
380 return (
381 "Error: English screenplay prose is required for this work. "
382 "Rewrite story_md in English."
383 )
384 return None
385
386
387 def _normalize_story_profile(profile: dict[str, Any]) -> None:
388 beats = profile.get("beats")
389 if not isinstance(beats, list):
390 return
391 normalized: list[dict[str, Any]] = []
392 for index, beat in enumerate(beats):
393 if not isinstance(beat, dict):
394 continue
395 summary = str(beat.get("summary") or "").strip()
396 if not summary:
397 continue
398 normalized.append({"shot_id": index + 1, "summary": summary})
399 profile["beats"] = normalized
400 shot_to_content: dict[str, str] = {}
401 content_to_shots: dict[str, list[str]] = {}
402 for beat in normalized:
403 shot_id = int(beat["shot_id"])
404 shot_key = f"shot_{shot_id:03d}"
405 shot_to_content[shot_key] = str(beat["summary"])
406 content_to_shots[f"beat_{shot_id:03d}"] = [shot_key]
407 profile["shot_to_content"] = shot_to_content
408 profile["content_to_shots"] = content_to_shots
409
410
411 def _apply_story_profile_language(profile: dict[str, Any], language: str | None) -> None:
412 """Stamp UI language onto story_profile and keep caption/dialogue locks in sync."""
413 from nanobot.session.generation_settings import (
414 language_to_caption_language,
415 language_to_dialogue_language,
416 normalize_language,
417 )
418
419 normalized = normalize_language(language)
420 if normalized is None:
421 return
422 profile["language"] = normalized
423 dialogue = language_to_dialogue_language(normalized)
424 if dialogue:
425 profile["dialogue_language"] = dialogue
426 caption = language_to_caption_language(normalized)
427 if caption and not str(profile.get("caption_language") or "").strip():
428 profile["caption_language"] = caption
429
430
431 def _ensure_story_profile_caption_language(profile: dict[str, Any]) -> None:
432 """Derive the full-caption language for legacy profiles that only locked dialogue."""
433 from nanobot.session.generation_settings import (
434 language_to_caption_language,
435 normalize_language,
436 )
437
438 if str(profile.get("caption_language") or "").strip():
439 return
440 normalized = normalize_language(profile.get("language"))
441 if normalized is None:
442 normalized = normalize_language(profile.get("dialogue_language"))
443 caption = language_to_caption_language(normalized)
444 if caption:
445 profile["caption_language"] = caption
446
447
448 def _preserve_story_profile_language(
449 profile: dict[str, Any],
450 previous: dict[str, Any] | None = None,
451 ) -> None:
452 """Keep language / dialogue_language when an overwrite omits them."""
453 from nanobot.session.generation_settings import normalize_language
454
455 if normalize_language(profile.get("language")) is not None:
456 _apply_story_profile_language(profile, profile.get("language"))
457 return
458 if isinstance(previous, dict):
459 prev_language = normalize_language(previous.get("language"))
460 if prev_language is not None:
461 _apply_story_profile_language(profile, prev_language)
462 return
463 prev_dialogue = previous.get("dialogue_language")
464 if isinstance(prev_dialogue, str) and prev_dialogue.strip() and "dialogue_language" not in profile:
465 profile["dialogue_language"] = prev_dialogue.strip()
466 _ensure_story_profile_caption_language(profile)
467
468
469 def _caption_language_validation_error(
470 caption: str,
471 story_profile: dict[str, Any],
472 ) -> str | None:
473 """Reject natural-language prose that violates the work's caption-language lock.
474
475 The target language must account for at least 90% of the meaningful character
476 count (excluding technical tokens). A handful of proper-noun transliterations
477 in the source language are tolerated below the 10% threshold.
478 """
479 from nanobot.session.generation_settings import normalize_language
480
481 caption_language = story_profile.get("caption_language")
482 normalized = normalize_language(caption_language)
483 if normalized is None:
484 normalized = normalize_language(story_profile.get("language"))
485 if normalized is None:
486 normalized = normalize_language(story_profile.get("dialogue_language"))
487 if normalized is None:
488 return None
489
490 # Strip technical tokens that are allowed in either language.
491 prose = re.sub(
492 r"(?<![A-Za-z0-9_])ID_[A-Z0-9]+(?![A-Za-z0-9_])",
493 "",
494 caption,
495 flags=re.IGNORECASE,
496 )
497 prose = re.sub(
498 r"(?<![A-Za-z0-9_])shot\d+(?![A-Za-z0-9_])",
499 "",
500 prose,
501 flags=re.IGNORECASE,
502 )
503 prose = re.sub(
504 r"(?<![A-Za-z])OCR(?![A-Za-z])",
505 "",
506 prose,
507 flags=re.IGNORECASE,
508 )
509
510 chinese_chars = re.findall(r"[\u3400-\u4dbf\u4e00-\u9fff]", prose)
511 english_words = re.findall(r"[A-Za-z]+(?:'[A-Za-z]+)?", prose)
512
513 chinese_char_count = len(chinese_chars)
514 english_char_count = sum(len(w) for w in english_words)
515
516 total_meaningful = chinese_char_count + english_char_count
517 if total_meaningful == 0:
518 return None
519
520 if normalized == "en":
521 en_ratio = english_char_count / total_meaningful
522 if en_ratio >= 0.9:
523 return None
524 preview = "".join(chinese_chars[:16])
525 return (
526 "Error: English caption required for this work (currently "
527 f"{en_ratio:.0%} English). Rewrite the entire caption in English "
528 "before calling create_shot_prompt again; translate all Chinese "
529 f"descriptions, actions, dialogue, and declarations. "
530 f"Chinese found: {preview}."
531 )
532
533 if normalized == "zh":
534 zh_ratio = chinese_char_count / total_meaningful
535 if zh_ratio >= 0.9:
536 return None
537 preview = ", ".join(english_words[:8])
538 return (
539 "Error: Chinese caption required for this work (currently "
540 f"{zh_ratio:.0%} Chinese). Rewrite the entire caption in Chinese "
541 "before calling create_shot_prompt again. Keep only required "
542 "technical tokens such as ID_A, shot1:, and OCR; use ID_A说 for "
543 "speech; translate all descriptions, actions, camera, sound, music, "
544 f"and declarations. English found: {preview}."
545 )
546
547 return None
548
549
550 def _shot_key(shot_id: int) -> str:
551 return f"shot_{shot_id:03d}"
552
553
554 def _job_id(kind: str, work_id: str, suffix: str) -> str:
555 stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
556 return f"{kind}-{work_id}-{suffix}-{stamp}"
557
558
559 def _shot_id_from_key(shot_key: str) -> int:
560 if not shot_key.startswith("shot_"):
561 raise ValueError(f"Invalid shot key: {shot_key}")
562 return int(shot_key.split("_", 1)[1])
563
564
565 # Echo generate-shot timing: see echo_generate_shot.md (25fps, num_frames = 1 + 8k, clamp [25, 241]).
566 ECHO_SHOT_FPS = 25
567 ECHO_MIN_NUM_FRAMES = 25
568 ECHO_MAX_NUM_FRAMES = 241
569 ECHO_DEFAULT_NUM_FRAMES = 241
570 ECHO_DEFAULT_DURATION_SEC = 4.0
571
572
573 def snap_echo_num_frames(raw_frames: int) -> int:
574 """Snap upward to the nearest valid 1+8k frame count and clamp to Echo bounds.
575
576 Echo requires ``num_frames = 1 + 8k``. Snapping up keeps generated duration
577 from falling below the caller's requested length (except at the hard max).
578 """
579 frames = int(raw_frames)
580 frames = max(ECHO_MIN_NUM_FRAMES, min(ECHO_MAX_NUM_FRAMES, frames))
581 remainder = (frames - 1) % 8
582 if remainder:
583 frames += 8 - remainder
584 if frames > ECHO_MAX_NUM_FRAMES:
585 # Largest valid 1+8k at or below the hard max (241 == 1+8*30).
586 frames = ECHO_MAX_NUM_FRAMES
587 remainder = (frames - 1) % 8
588 if remainder:
589 frames -= remainder
590 return max(ECHO_MIN_NUM_FRAMES, min(ECHO_MAX_NUM_FRAMES, frames))
591
592
593 def duration_sec_to_num_frames(duration_sec: float) -> int:
594 """Convert desired seconds to the frame count Echo will actually generate.
595
596 Ceil to frames then snap upward so playback length is >= the request
597 (unless capped by ``ECHO_MAX_NUM_FRAMES``).
598 """
599 import math
600
601 return snap_echo_num_frames(math.ceil(float(duration_sec) * ECHO_SHOT_FPS))
602
603
604 def num_frames_to_duration_sec(num_frames: int) -> float:
605 """Return duration in whole seconds (nearest second of frames/fps) for UI/state."""
606 return float(round(int(num_frames) / ECHO_SHOT_FPS))
607
608
609 def num_frames_to_exact_duration_sec(num_frames: int) -> float:
610 """Exact playback seconds for the given frame count (no rounding).
611
612 Sent to the Echo backend so it does not re-derive frames from a rounded
613 ``duration_sec`` (e.g. 7.0) and snap back downward.
614 """
615 return float(num_frames) / float(ECHO_SHOT_FPS)
616
617
618 def sync_shot_echo_duration(shot: dict[str, Any], duration_sec: float) -> int:
619 """Persist snapped Echo timing on the shot record."""
620 num_frames = duration_sec_to_num_frames(duration_sec)
621 actual_duration_sec = num_frames_to_duration_sec(num_frames)
622 shot["duration_sec"] = actual_duration_sec
623 shot["num_frames"] = num_frames
624 return num_frames
625
626
627 def resolve_echo_duration_seconds(
628 shot: dict[str, Any],
629 state: dict[str, Any] | None = None,
630 ) -> float:
631 """Resolve per-shot seconds for Echo generation (aligned with workplace UI defaults)."""
632 for key in ("duration_sec", "duration_seconds"):
633 try:
634 value = float(shot.get(key))
635 if value > 0:
636 return value
637 except (TypeError, ValueError):
638 pass
639 try:
640 shot_frames = shot.get("num_frames")
641 if shot_frames is not None:
642 value = float(num_frames_to_duration_sec(int(shot_frames)))
643 if value > 0:
644 return value
645 except (TypeError, ValueError):
646 pass
647 goal = (
648 state.get("goal") if isinstance(state, dict) and isinstance(state.get("goal"), dict) else {}
649 )
650 try:
651 goal_duration = float(goal.get("shot_duration_sec") or 0)
652 if goal_duration > 0:
653 return goal_duration
654 except (TypeError, ValueError):
655 pass
656 return ECHO_DEFAULT_DURATION_SEC
657
658
659 def rewrite_prompt_for_i2v(original_prompt: str, caption_language: str) -> str:
660 """Rewrite a shot prompt for I2V by prepending the language-matched first-frame sentence.
661
662 Follows ``pe/v7_cinematic_full/skills/i2v-tail-frame-prompt-rewriter/SKILL.md``.
663 Only the opening sentence is added; the rest of the prompt is preserved unchanged.
664 """
665 caption_language = (caption_language or "").strip().lower()
666 is_chinese = caption_language in {"simplified chinese", "zh", "chinese", "mandarin chinese"}
667
668 first_frame_zh = (
669 "以当前图片作为视频首帧,并基于首帧中已有的人物、物体、环境、构图、机位、光线和动作状态自然延续。"
670 )
671 first_frame_en = (
672 "Use the current image as the first frame of the video, and continue naturally "
673 "from the characters, objects, environment, composition, camera position, lighting, "
674 "and action state already shown in it."
675 )
676
677 first_frame_sentence = first_frame_zh if is_chinese else first_frame_en
678
679 trimmed = original_prompt.strip()
680
681 # Detect if the prompt has a cut-count style opening (e.g. "1 cut" / "1个镜头").
682 # Insert the first-frame sentence before the cut-count sentence.
683 cut_count_pattern = re.compile(
684 r"^(\d+)\s*(?:cuts?|个镜头|个景别)",
685 re.IGNORECASE,
686 )
687 match = cut_count_pattern.match(trimmed)
688 if match:
689 prefix = trimmed[: match.end()]
690 rest = trimmed[match.end() :]
691 return f"{first_frame_sentence}\n{prefix}{rest}"
692
693 return f"{first_frame_sentence}\n{trimmed}"
694
695
696 class DirectorTool(Tool):
697 """Shared helpers for director-state tools."""
698
699 _DEFAULT_STAGE = "story_discussion"
700 _FINAL_STAGES = frozenset({"done", "cancelled"})
701
702 @property
703 def description(self) -> str:
704 """Pull each tool's description from the active PE set, keyed by tool name."""
705 return prompts.text(f"director.tool.{self.name}.description")
706
707 def __init__(
708 self,
709 workspace: Path,
710 *,
711 tools_config: Any | None = None,
712 callback_base_url: str | None = None,
713 ):
714 from nanobot.config.schema import ToolsConfig
715
716 self.workspace = workspace
717 self._tools_config = tools_config or ToolsConfig()
718 self._callback_base_url = (
719 callback_base_url.rstrip("/")
720 if isinstance(callback_base_url, str) and callback_base_url.strip()
721 else None
722 )
723 self._channel: ContextVar[str] = ContextVar("director_channel", default="cli")
724 self._chat_id: ContextVar[str] = ContextVar("director_chat_id", default="direct")
725 self._session_key: ContextVar[str] = ContextVar(
726 "director_session_key",
727 default="cli:direct",
728 )
729
730 def set_context(
731 self,
732 channel: str,
733 chat_id: str,
734 effective_key: str | None = None,
735 *,
736 injected_event: str | None | object = _WORKFLOW_CONTEXT_UNSET,
737 ) -> None:
738 self._channel.set(channel)
739 self._chat_id.set(chat_id)
740 self._session_key.set(effective_key or f"{channel}:{chat_id}")
741 if injected_event is not _WORKFLOW_CONTEXT_UNSET:
742 _WORKFLOW_INJECTED_EVENT.set(injected_event)
743
744 @staticmethod
745 def allow_workflow_gate_bypass() -> None:
746 """Test helper: allow gated workflow tools without a workplace injection."""
747 _WORKFLOW_INJECTED_EVENT.set(WORKFLOW_GATE_BYPASS)
748
749 @property
750 def director_root(self) -> Path:
751 return self.workspace / "director"
752
753 @property
754 def works_root(self) -> Path:
755 return self.director_root / "works"
756
757 @property
758 def active_work_path(self) -> Path:
759 return self.director_root / "active_work.json"
760
761 @property
762 def session_map_path(self) -> Path:
763 return self.director_root / "session_map.json"
764
765 def _ensure_root(self) -> None:
766 self.works_root.mkdir(parents=True, exist_ok=True)
767
768 def _read_reference_image_from_session(self) -> dict[str, Any] | None:
769 """从 session metadata 读取首帧参考图信息。"""
770 try:
771 from nanobot.session.manager import SessionManager
772
773 session_key = self._session_key.get()
774 if not session_key:
775 return None
776 session = SessionManager(self.workspace).get_or_create(session_key)
777 metadata = session.metadata if isinstance(session.metadata, dict) else {}
778 return normalize_reference_image(metadata.get("reference_image"))
779 except Exception:
780 logger.exception("director: failed to read session reference_image")
781 return None
782
783 def _session_auto_generate(self) -> bool:
784 try:
785 from nanobot.session.manager import SessionManager
786
787 session_key = self._session_key.get()
788 if not session_key:
789 return False
790 session = SessionManager(self.workspace).get_or_create(session_key)
791 metadata = session.metadata if isinstance(session.metadata, dict) else {}
792 return get_auto_generate(metadata)
793 except Exception:
794 logger.exception("director: failed to read session auto_generate")
795 return False
796
797 def _consume_shot_count_next_step_hint(
798 self,
799 *,
800 auto_generate: bool = False,
801 emit: bool = True,
802 ) -> str | None:
803 work_id = self._active_work_id()
804 if not work_id:
805 return None
806 state = self._load_state(work_id)
807 pending = bool(state.pop(SHOT_COUNT_NEXT_STEP_HINT_PENDING_KEY, False))
808 if not pending:
809 return None
810 stage = str(state.get("stage") or "")
811 should_emit = (
812 emit
813 and not auto_generate
814 and not bool(state.get("auto_generate"))
815 and stage not in _STAGES_PAST_SHOT_COUNT_HINT
816 )
817 self._save_state(work_id, state)
818 return SHOT_COUNT_NEXT_STEP_HINT if should_emit else None
819
820 def _lock_session_reference_image(self) -> None:
821 try:
822 from nanobot.session.manager import SessionManager
823
824 session_key = self._session_key.get()
825 if not session_key:
826 return
827 manager = SessionManager(self.workspace)
828 session = manager.get_or_create(session_key)
829 if not isinstance(session.metadata, dict):
830 session.metadata = {}
831 if session.metadata.get("reference_image_locked") is True:
832 return
833 session.metadata["reference_image_locked"] = True
834 manager.save(session)
835 except Exception:
836 logger.exception("director: failed to lock session reference_image")
837
838 def _clear_reference_image_story_rewrite_flag(self) -> None:
839 try:
840 from nanobot.session.manager import SessionManager
841
842 session_key = self._session_key.get()
843 if not session_key:
844 return
845 manager = SessionManager(self.workspace)
846 session = manager.get_or_create(session_key)
847 if not isinstance(session.metadata, dict):
848 return
849 if not session.metadata.get("reference_image_needs_story_rewrite"):
850 return
851 clear_reference_image_needs_story_rewrite(session.metadata)
852 manager.save(session)
853 except Exception:
854 logger.exception(
855 "director: failed to clear reference_image_needs_story_rewrite"
856 )
857
858 def _session_reference_inject_failed(self) -> bool:
859 try:
860 from nanobot.session.manager import SessionManager
861
862 session_key = self._session_key.get()
863 if not session_key:
864 return False
865 session = SessionManager(self.workspace).get_or_create(session_key)
866 metadata = session.metadata if isinstance(session.metadata, dict) else {}
867 return bool(metadata.get("reference_image_inject_failed"))
868 except Exception:
869 logger.exception("director: failed to read reference_image_inject_failed")
870 return False
871
872 def _session_reference_needs_rewrite(self) -> bool:
873 try:
874 from nanobot.session.manager import SessionManager
875
876 session_key = self._session_key.get()
877 if not session_key:
878 return False
879 session = SessionManager(self.workspace).get_or_create(session_key)
880 metadata = session.metadata if isinstance(session.metadata, dict) else {}
881 return reference_image_needs_story_rewrite(metadata)
882 except Exception:
883 logger.exception("director: failed to read reference_image_needs_story_rewrite")
884 return False
885
886 def _effective_auto_generate_shot_count(self, goal: dict[str, Any] | None) -> int | None:
887 try:
888 from nanobot.session.manager import SessionManager
889
890 session_key = self._session_key.get()
891 metadata: dict[str, Any] | None = None
892 if session_key:
893 session = SessionManager(self.workspace).get_or_create(session_key)
894 metadata = session.metadata if isinstance(session.metadata, dict) else {}
895 return effective_auto_generate_shot_count(goal=goal, metadata=metadata)
896 except Exception:
897 logger.exception("director: failed to resolve auto_generate shot_count")
898 return locked_shot_count_from_goal(goal)
899
900 def _state_first_frame_url(self, state: dict[str, Any]) -> str | None:
901 ref = normalize_reference_image(state.get("reference_image"))
902 if not ref:
903 return None
904 url = ref.get("url")
905 return url if isinstance(url, str) and url.strip() else None
906
907 # ── tail-frame extraction pipeline (shared by agent + REST paths) ──
908
909 @staticmethod
910 def _extract_tail_frame(video_path: Path, output_path: Path) -> bool:
911 """Extract the last frame of *video_path* as a PNG using ffmpeg."""
912 from nanobot.director.memory_coordinator import _resolve_media_binary
913
914 ffmpeg = _resolve_media_binary("ffmpeg")
915 cmd = [
916 ffmpeg, "-sseof", "-1", "-i", str(video_path),
917 "-update", "1", "-q:v", "1", str(output_path), "-y",
918 ]
919 import subprocess
920
921 try:
922 subprocess.run(cmd, check=True, capture_output=True, timeout=60)
923 return output_path.is_file() and output_path.stat().st_size > 0
924 except Exception:
925 return False
926
927 def _publish_tail_frame(
928 self, image_path: Path, work_id: str, shot_id: int
929 ) -> str | None:
930 """Persist tail frame locally. Returns the public URL or None on failure."""
931 from nanobot.storage.files import configured_file_publisher
932
933 name = f"tail_frames/shot_{shot_id:03d}.png"
934 try:
935 publisher = configured_file_publisher(
936 work_id,
937 storage=self._tools_config.file_storage,
938 workspace=self.workspace,
939 )
940 return publisher(str(image_path), name)
941 except Exception:
942 return None
943
944 def _extract_and_publish_tail_frame(
945 self, work_id: str, shot_id: int, video_url: str,
946 ) -> str | None:
947 """Download video, extract last frame, publish locally. Returns public URL."""
948 import shutil
949 import tempfile
950 import urllib.request
951
952 tmp_dir = Path(tempfile.mkdtemp(prefix="tail_frame_"))
953 try:
954 video_path = tmp_dir / "source.mp4"
955 frame_path = tmp_dir / "tail.png"
956
957 # download
958 if video_url.startswith(("http://", "https://")):
959 req = urllib.request.Request(video_url, headers={"Accept": "video/mp4,*/*"})
960 with urllib.request.urlopen(req, timeout=120) as resp:
961 with open(video_path, "wb") as f:
962 shutil.copyfileobj(resp, f)
963 else:
964 src = Path(video_url)
965 if not src.is_file():
966 return None
967 shutil.copyfile(str(src), str(video_path))
968
969 if not video_path.is_file() or video_path.stat().st_size <= 0:
970 return None
971
972 # extract
973 if not DirectorTool._extract_tail_frame(video_path, frame_path):
974 return None
975
976 # upload
977 return self._publish_tail_frame(frame_path, work_id, shot_id)
978 except Exception:
979 return None
980 finally:
981 shutil.rmtree(tmp_dir, ignore_errors=True)
982
983 @staticmethod
984 def _read_json(path: Path, default: Any) -> Any:
985 if not path.exists():
986 return default
987 try:
988 return json.loads(path.read_text(encoding="utf-8"))
989 except (json.JSONDecodeError, OSError):
990 return default
991
992 @staticmethod
993 def _write_json(path: Path, data: Any) -> None:
994 write_json_atomic(path, data)
995
996 @staticmethod
997 def _write_text(path: Path, content: str) -> None:
998 path.parent.mkdir(parents=True, exist_ok=True)
999 path.write_text(content.rstrip() + "\n", encoding="utf-8")
1000
1001 def _remote_http_base_url(self) -> str | None:
1002 echo_generator = getattr(self._tools_config, "echo_generator", None)
1003 base = ""
1004 if echo_generator is not None:
1005 base = str(getattr(echo_generator, "base_url", "") or "").strip()
1006 return base.rstrip("/") if base else None
1007
1008 def _remote_callback_base_url(self) -> str | None:
1009 if self._callback_base_url:
1010 return self._callback_base_url
1011 echo_generator = getattr(self._tools_config, "echo_generator", None)
1012 base = ""
1013 if echo_generator is not None:
1014 base = str(getattr(echo_generator, "callback_base_url", "") or "").strip()
1015 return base.rstrip("/") if base else None
1016
1017 def _remote_http_timeout_sec(self) -> float:
1018 echo_generator = getattr(self._tools_config, "echo_generator", None)
1019 raw_timeout = (
1020 getattr(echo_generator, "http_timeout_sec", 30.0) if echo_generator is not None else 30.0
1021 )
1022 try:
1023 return max(1.0, float(raw_timeout))
1024 except (TypeError, ValueError):
1025 return 30.0
1026
1027 def _remote_endpoint_path(self, operation: str) -> str:
1028 endpoint = _REMOTE_ENDPOINT_PATHS.get(operation)
1029 if not endpoint:
1030 raise RuntimeError(f"No remote endpoint mapping exists for operation '{operation}'.")
1031 return endpoint
1032
1033 def _remote_callback_path(self, operation: str) -> str | None:
1034 return _REMOTE_CALLBACK_PATHS.get(operation)
1035
1036 def _remote_callback_url(self, operation: str) -> str | None:
1037 base_url = self._remote_callback_base_url()
1038 callback_path = self._remote_callback_path(operation)
1039 if not base_url or not callback_path:
1040 return None
1041 return f"{base_url}{callback_path}"
1042
1043 def _build_remote_callback_contract(
1044 self,
1045 work_id: str,
1046 job_id: str,
1047 operation: str,
1048 target: str | list[str],
1049 ) -> dict[str, Any]:
1050 contract = {
1051 "event_type": "director_remote_result",
1052 "protocol_version": _REMOTE_PROTOCOL_VERSION,
1053 "operation": operation,
1054 "work_id": work_id,
1055 "job_id": job_id,
1056 "target": target,
1057 "channel": self._channel.get(),
1058 "chat_id": self._chat_id.get(),
1059 "session_key": self._session_key.get(),
1060 "inject_back_to_agent": True,
1061 "note": (
1062 "When the backend finishes, your client-side callback handler should "
1063 "clear the pending_remote_jobs entry, update the director workspace, "
1064 "and publish an InboundMessage for this session."
1065 ),
1066 }
1067 callback_url = self._remote_callback_url(operation)
1068 if callback_url:
1069 contract["url"] = callback_url
1070 return contract
1071
1072 def _build_remote_request_envelope(
1073 self,
1074 operation: str,
1075 work_id: str,
1076 job_id: str,
1077 target: str | list[str],
1078 payload: dict[str, Any],
1079 ) -> dict[str, Any]:
1080 return {
1081 "protocol_version": _REMOTE_PROTOCOL_VERSION,
1082 "operation": operation,
1083 "job": {
1084 "job_id": job_id,
1085 "work_id": work_id,
1086 "target": target,
1087 "created_at": _now_iso(),
1088 },
1089 "callback": self._build_remote_callback_contract(work_id, job_id, operation, target),
1090 "payload": payload,
1091 }
1092
1093 def _post_remote_http_request(
1094 self,
1095 endpoint_url: str,
1096 envelope: dict[str, Any],
1097 ) -> dict[str, Any]:
1098 headers = {
1099 "Content-Type": "application/json",
1100 "Accept": "application/json",
1101 }
1102 callback = envelope.get("callback")
1103 if isinstance(callback, dict):
1104 callback_url = callback.get("url")
1105 if isinstance(callback_url, str) and callback_url.strip():
1106 headers["X-Nanobot-Director-Callback-Url"] = callback_url.strip()
1107 body = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
1108 request = urllib_request.Request(
1109 endpoint_url,
1110 data=body,
1111 headers=headers,
1112 method="POST",
1113 )
1114 try:
1115 with urllib_request.urlopen(
1116 request, timeout=self._remote_http_timeout_sec()
1117 ) as response:
1118 raw = response.read().decode("utf-8")
1119 except urllib_error.URLError as exc:
1120 raise RuntimeError(f"Remote HTTP request failed: {exc}") from exc
1121 if not raw.strip():
1122 return {}
1123 try:
1124 parsed = json.loads(raw)
1125 except json.JSONDecodeError:
1126 return {"raw_response": raw}
1127 return parsed if isinstance(parsed, dict) else {"response": parsed}
1128
1129 def _active_work_id(self) -> str | None:
1130 session_map = self._read_json(self.session_map_path, {})
1131 if not isinstance(session_map, dict):
1132 return None
1133 entry = session_map.get(self._session_key.get())
1134 if isinstance(entry, dict):
1135 return entry.get("active")
1136 # Backwards compat: old format stored bare work_id string
1137 if isinstance(entry, str):
1138 return entry
1139 return None
1140
1141 def _session_work_history(self) -> list[str]:
1142 session_map = self._read_json(self.session_map_path, {})
1143 if not isinstance(session_map, dict):
1144 return []
1145 entry = session_map.get(self._session_key.get())
1146 if isinstance(entry, dict):
1147 history = entry.get("history", [])
1148 return history if isinstance(history, list) else []
1149 # Backwards compat: old format stored bare work_id string
1150 if isinstance(entry, str):
1151 return [entry]
1152 return []
1153
1154 def _set_active_work(self, work_id: str) -> None:
1155 self._ensure_root()
1156 session_key = self._session_key.get()
1157 session_map = self._read_json(self.session_map_path, {})
1158 if not isinstance(session_map, dict):
1159 session_map = {}
1160 entry = session_map.get(session_key)
1161 # Migrate old bare-string entries
1162 if isinstance(entry, str):
1163 entry = {"active": entry, "history": [entry]}
1164 elif not isinstance(entry, dict):
1165 entry = {"active": None, "history": []}
1166 history = entry.get("history", [])
1167 if not isinstance(history, list):
1168 history = []
1169 if work_id not in history:
1170 history.append(work_id)
1171 entry["active"] = work_id
1172 entry["history"] = history
1173 session_map[session_key] = entry
1174 self._write_json(self.session_map_path, session_map)
1175 self._write_json(
1176 self.active_work_path,
1177 {
1178 "work_id": work_id,
1179 "channel": self._channel.get(),
1180 "chat_id": self._chat_id.get(),
1181 "session_key": session_key,
1182 "updated_at": _now_iso(),
1183 },
1184 )
1185
1186 def _resolve_work_id(self, work_id: str | None = None) -> tuple[str | None, Path | None]:
1187 self._ensure_root()
1188 candidate = work_id or self._active_work_id()
1189 if not candidate:
1190 return None, None
1191 work_dir = self.works_root / candidate
1192 if not work_dir.exists():
1193 return None, None
1194 return candidate, work_dir
1195
1196 def _paths(self, work_id: str) -> dict[str, Path]:
1197 work_dir = self.works_root / work_id
1198 return {
1199 "work_dir": work_dir,
1200 "state": work_dir / "state.json",
1201 "fact": work_dir / "fact.md",
1202 "work_memory": work_dir / "work_memory_lite.md",
1203 "story": work_dir / "story.md",
1204 "story_profile": work_dir / "story_profile.json",
1205 "shots": work_dir / "shots",
1206 "jobs": work_dir / "jobs",
1207 "outputs": work_dir / "outputs",
1208 "memory_bank": work_dir / "memory" / "memory_bank.json",
1209 "previous_shot_memory": work_dir / "memory" / "previous_shot.json",
1210 "manual_memory_workspace": work_dir / "memory" / "manual" / "workspace.json",
1211 "memory_asset_profiles": work_dir / "memory" / "asset_profiles.json",
1212 }
1213
1214 @staticmethod
1215 def _automatic_memory_asset_id(raw: dict[str, Any], memory_id: str, kind: str) -> str:
1216 fingerprint = json.dumps(
1217 [
1218 memory_id,
1219 int(raw.get("source_shot_id") or 0),
1220 int(raw.get("frame_index") or 0),
1221 kind,
1222 ],
1223 ensure_ascii=False,
1224 separators=(",", ":"),
1225 )
1226 return "auto_" + hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:20]
1227
1228 def _memory_asset_catalog(self, work_id: str) -> list[dict[str, Any]]:
1229 """Return profile-bearing assets safe for the agent to reason over."""
1230 paths = self._paths(work_id)
1231 overrides = self._read_json(paths["memory_asset_profiles"], {})
1232 overrides = overrides if isinstance(overrides, dict) else {}
1233 assets: list[dict[str, Any]] = []
1234
1235 def add_automatic(raw: Any, memory_id: str, kind: str) -> None:
1236 if not isinstance(raw, dict):
1237 return
1238 asset_id = self._automatic_memory_asset_id(raw, memory_id, kind)
1239 override = overrides.get(asset_id)
1240 override = override if isinstance(override, dict) else {}
1241 profile_text = str(
1242 override.get("profile_text")
1243 or raw.get("profile_text")
1244 or raw.get("reasoning")
1245 or ""
1246 ).strip()
1247 if not profile_text:
1248 return
1249 identities = override.get("identity_ids") or raw.get("visible_character_ids")
1250 if not isinstance(identities, list):
1251 identities = [memory_id] if memory_id.startswith("ID_") else []
1252 reference_type = str(
1253 override.get("reference_type")
1254 if "reference_type" in override
1255 else raw.get("reference_type") or ""
1256 ).strip()
1257 reference_label = str(
1258 override.get("reference_label")
1259 if "reference_label" in override
1260 else raw.get("reference_label") or ""
1261 ).strip()
1262 assets.append({
1263 "asset_id": asset_id,
1264 "media_type": (
1265 "image_audio" if raw.get("image_path") and raw.get("audio_path")
1266 else "audio" if raw.get("audio_path")
1267 else "image"
1268 ),
1269 "profile_text": profile_text,
1270 "identity_ids": [str(value) for value in identities if str(value).strip()],
1271 **({"reference_type": reference_type} if reference_type else {}),
1272 **({"reference_label": reference_label} if reference_label else {}),
1273 "source": {
1274 "type": "generated_shot",
1275 "shot_id": int(raw.get("source_shot_id") or 0),
1276 "timestamp_sec": float(raw.get("timestamp_sec") or 0),
1277 },
1278 })
1279
1280 bank = self._read_json(paths["memory_bank"], {})
1281 if isinstance(bank, dict):
1282 for memory_id, raw in bank.items():
1283 add_automatic(raw, str(memory_id), "character")
1284 previous = self._read_json(paths["previous_shot_memory"], None)
1285 add_automatic(previous, "PREVIOUS_SHOT", "previous_shot")
1286
1287 manual = self._read_json(paths["manual_memory_workspace"], {})
1288 rows = manual.get("assets") if isinstance(manual, dict) else []
1289 for raw in rows if isinstance(rows, list) else []:
1290 if not isinstance(raw, dict):
1291 continue
1292 profile_text = str(raw.get("profile_text") or "").strip()
1293 asset_id = str(raw.get("asset_id") or "").strip()
1294 if not asset_id or not profile_text:
1295 continue
1296 has_image = bool(raw.get("image_path"))
1297 has_audio = bool(raw.get("audio_path"))
1298 reference_type = str(raw.get("reference_type") or "").strip()
1299 reference_label = str(raw.get("reference_label") or "").strip()
1300 source_shot_id = int(raw.get("source_shot_id") or 0)
1301 source = (
1302 {
1303 "type": "generated_shot",
1304 "shot_id": source_shot_id,
1305 "timestamp_sec": float(raw.get("timestamp_sec") or 0),
1306 **(
1307 {"audio_start_sec": float(raw["audio_start_sec"])}
1308 if raw.get("audio_start_sec") is not None
1309 else {}
1310 ),
1311 **(
1312 {"audio_end_sec": float(raw["audio_end_sec"])}
1313 if raw.get("audio_end_sec") is not None
1314 else {}
1315 ),
1316 }
1317 if source_shot_id > 0
1318 else {"type": "local_upload"}
1319 )
1320 assets.append({
1321 "asset_id": asset_id,
1322 "media_type": (
1323 "image_audio" if has_image and has_audio
1324 else "audio" if has_audio
1325 else "image"
1326 ),
1327 "profile_text": profile_text,
1328 "identity_ids": [
1329 str(value) for value in raw.get("identity_ids", []) if str(value).strip()
1330 ],
1331 **({"reference_type": reference_type} if reference_type else {}),
1332 **({"reference_label": reference_label} if reference_label else {}),
1333 "source": source,
1334 })
1335 return assets
1336
1337 def _load_state(self, work_id: str) -> dict[str, Any]:
1338 state = self._read_json(self._paths(work_id)["state"], {})
1339 return state if isinstance(state, dict) else {}
1340
1341 def _save_state(self, work_id: str, state: dict[str, Any]) -> None:
1342 state["story_profile"] = self._load_story_profile(work_id)
1343 state["updated_at"] = _now_iso()
1344 self._write_json(self._paths(work_id)["state"], state)
1345
1346 def _shot_path(self, work_id: str, shot_id: int) -> Path:
1347 return self._paths(work_id)["shots"] / f"{_shot_key(shot_id)}.json"
1348
1349 def _job_path(self, work_id: str, job_id: str) -> Path:
1350 return self._paths(work_id)["jobs"] / f"{job_id}.json"
1351
1352 def _load_job(self, work_id: str, job_id: str) -> dict[str, Any]:
1353 data = self._read_json(self._job_path(work_id, job_id), {})
1354 return data if isinstance(data, dict) else {}
1355
1356 def _save_job(self, work_id: str, job_id: str, job: dict[str, Any]) -> None:
1357 self._write_json(self._job_path(work_id, job_id), job)
1358
1359 def _load_shot(self, work_id: str, shot_id: int) -> dict[str, Any]:
1360 data = self._read_json(self._shot_path(work_id, shot_id), {})
1361 return data if isinstance(data, dict) else {}
1362
1363 def _save_shot(self, work_id: str, shot_id: int, shot: dict[str, Any]) -> None:
1364 shot["updated_at"] = _now_iso()
1365 self._write_json(self._shot_path(work_id, shot_id), shot)
1366
1367 def _load_story_profile(self, work_id: str) -> dict[str, Any]:
1368 data = self._read_json(self._paths(work_id)["story_profile"], {})
1369 return data if isinstance(data, dict) else {}
1370
1371 def _save_story_profile(self, work_id: str, story_profile: dict[str, Any]) -> None:
1372 previous = self._load_story_profile(work_id)
1373 profile = dict(story_profile)
1374 _preserve_story_profile_language(profile, previous)
1375 # Prefer session UI language when profile still has none.
1376 if "language" not in profile:
1377 from nanobot.session.generation_settings import get_generation_settings
1378 from nanobot.session.manager import SessionManager
1379
1380 session = SessionManager(self.workspace).get_or_create(self._session_key.get())
1381 metadata = session.metadata if isinstance(session.metadata, dict) else {}
1382 settings = get_generation_settings(metadata)
1383 _apply_story_profile_language(profile, str(settings.get("language") or ""))
1384 _normalize_story_profile(profile)
1385 self._write_json(self._paths(work_id)["story_profile"], profile)
1386
1387 def _default_state(self, work_id: str, *, title: str | None, goal: str) -> dict[str, Any]:
1388 return {
1389 "work_id": work_id,
1390 "title": title or "",
1391 "goal_brief": goal,
1392 "stage": self._DEFAULT_STAGE,
1393 "story_confirmed": False,
1394 "goal": {
1395 "shot_count": None,
1396 "shot_duration_sec": None,
1397 "generation_mode": "sequential",
1398 },
1399 "shots": {},
1400 "pending_remote_jobs": {},
1401 "latest_story_summary": "",
1402 "story_profile": {},
1403 "latest_merge_job_id": None,
1404 "final_output_path": None,
1405 "final_output_url": None,
1406 "reference_image": None,
1407 "reference_image_locked": False,
1408 "auto_generate": False,
1409 "created_at": _now_iso(),
1410 "updated_at": _now_iso(),
1411 }
1412
1413 def _ensure_work_files(self, work_id: str, *, title: str | None, goal: str) -> dict[str, Any]:
1414 self._ensure_root()
1415 paths = self._paths(work_id)
1416 for key in ("work_dir", "shots", "jobs", "outputs"):
1417 paths[key].mkdir(parents=True, exist_ok=True)
1418 if not paths["story"].exists():
1419 self._write_text(paths["story"], "")
1420 if not paths["work_memory"].exists():
1421 self._write_text(paths["work_memory"], "# Work Memory Lite\n")
1422 if not paths["story_profile"].exists():
1423 from nanobot.session.generation_settings import get_generation_settings
1424 from nanobot.session.manager import SessionManager
1425
1426 profile: dict[str, Any] = {}
1427 session = SessionManager(self.workspace).get_or_create(self._session_key.get())
1428 metadata = session.metadata if isinstance(session.metadata, dict) else {}
1429 settings = get_generation_settings(metadata)
1430 _apply_story_profile_language(profile, str(settings.get("language") or ""))
1431 self._write_json(paths["story_profile"], profile)
1432 if not paths["state"].exists():
1433 self._save_state(work_id, self._default_state(work_id, title=title, goal=goal))
1434 state = self._load_state(work_id)
1435 changed = False
1436 if not is_reference_image_locked(state):
1437 ref = self._read_reference_image_from_session()
1438 if ref and state.get("reference_image") != ref:
1439 state["reference_image"] = ref
1440 changed = True
1441 auto = self._session_auto_generate()
1442 if auto and not bool(state.get("auto_generate")):
1443 state["auto_generate"] = True
1444 changed = True
1445 if changed:
1446 self._save_state(work_id, state)
1447 self._refresh_fact(work_id, state)
1448 return state
1449
1450 def _is_unfinished(self, state: dict[str, Any]) -> bool:
1451 stage = str(state.get("stage") or self._DEFAULT_STAGE)
1452 return stage not in self._FINAL_STAGES
1453
1454 def _shot_entries(self, state: dict[str, Any]) -> list[dict[str, Any]]:
1455 shots = state.get("shots", {})
1456 if not isinstance(shots, dict):
1457 return []
1458 items = []
1459 for shot_key, payload in shots.items():
1460 if isinstance(payload, dict):
1461 items.append({"shot_key": shot_key, **payload})
1462 return sorted(items, key=lambda item: int(item.get("shot_id", 0)))
1463
1464 def _status_counts(self, state: dict[str, Any]) -> dict[str, int]:
1465 counts: dict[str, int] = {}
1466 for item in self._shot_entries(state):
1467 status = str(item.get("status") or "planned")
1468 counts[status] = counts.get(status, 0) + 1
1469 return counts
1470
1471 def _pending_remote_jobs(self, state: dict[str, Any]) -> dict[str, Any]:
1472 pending = state.setdefault("pending_remote_jobs", {})
1473 if not isinstance(pending, dict):
1474 pending = {}
1475 state["pending_remote_jobs"] = pending
1476 return pending
1477
1478 def _register_pending_remote_job(self, state: dict[str, Any], job: dict[str, Any]) -> None:
1479 if job.get("status") != "queued":
1480 return
1481 pending = self._pending_remote_jobs(state)
1482 pending[str(job["job_id"])] = {
1483 "kind": job.get("kind"),
1484 "target": job.get("target"),
1485 "created_at": job.get("created_at"),
1486 }
1487
1488 def _clear_pending_remote_job(self, state: dict[str, Any], job_id: str) -> None:
1489 pending = self._pending_remote_jobs(state)
1490 pending.pop(job_id, None)
1491
1492 def _clear_pending_remote_jobs_for_target(
1493 self,
1494 state: dict[str, Any],
1495 kind: str,
1496 target: str | list[str],
1497 ) -> None:
1498 pending = self._pending_remote_jobs(state)
1499 target_key = json.dumps(target, ensure_ascii=False, sort_keys=True)
1500 stale_job_ids = [
1501 job_id
1502 for job_id, item in pending.items()
1503 if isinstance(item, dict)
1504 and item.get("kind") == kind
1505 and json.dumps(item.get("target"), ensure_ascii=False, sort_keys=True) == target_key
1506 ]
1507 for job_id in stale_job_ids:
1508 pending.pop(job_id, None)
1509
1510 def _clear_pending_remote_jobs_for_shot(
1511 self,
1512 state: dict[str, Any],
1513 shot_id: int,
1514 *,
1515 kinds: set[str] | None = None,
1516 ) -> None:
1517 pending = self._pending_remote_jobs(state)
1518 shot_key = _shot_key(shot_id)
1519 stale_job_ids = []
1520 for job_id, item in pending.items():
1521 if not isinstance(item, dict):
1522 continue
1523 if kinds is not None and str(item.get("kind")) not in kinds:
1524 continue
1525 target = item.get("target")
1526 if target == shot_key or (
1527 isinstance(target, list) and any(str(value) == shot_key for value in target)
1528 ):
1529 stale_job_ids.append(job_id)
1530 for job_id in stale_job_ids:
1531 pending.pop(job_id, None)
1532
1533 @staticmethod
1534 def _final_output_is_playable(locator: str) -> bool:
1535 raw = locator.strip()
1536 if not raw:
1537 return False
1538 parsed = urlparse(raw)
1539 if parsed.scheme in {"http", "https"}:
1540 return True
1541 suffix = Path(unquote(parsed.path or raw)).suffix.lower()
1542 return suffix in {".mp4", ".webm", ".mov", ".m4v", ".mkv"}
1543
1544 def _sync_stage_from_state(self, state: dict[str, Any]) -> None:
1545 final_output = state.get("final_output_url") or state.get("final_output_path")
1546 if final_output and self._final_output_is_playable(str(final_output)):
1547 state["stage"] = "done"
1548 return
1549 if final_output:
1550 state["stage"] = "merging"
1551 return
1552 pending_remote_jobs = self._pending_remote_jobs(state)
1553 pending_kinds = {
1554 str(item.get("kind")) for item in pending_remote_jobs.values() if isinstance(item, dict)
1555 }
1556 if "merge_shot" in pending_kinds:
1557 state["stage"] = "merging"
1558 return
1559 if "generate_echo_shot" in pending_kinds:
1560 state["stage"] = "shot_generating"
1561 return
1562 current_stage = str(state.get("stage") or "")
1563 # Keep Memory review / generate-fail on 03 even if shot rows look idle.
1564 if current_stage in {"awaiting_memory_review", "failed"}:
1565 return
1566 shots = self._shot_entries(state)
1567 if any(item.get("status") in {"review_fail", "error"} for item in shots):
1568 state["stage"] = "shot_revising"
1569 return
1570 if any(item.get("status") in {"generated", "review_pass", "approved"} for item in shots):
1571 state["stage"] = "shot_reviewing"
1572 return
1573 if any(item.get("status") == "queued" for item in shots):
1574 state["stage"] = "shot_generating"
1575 return
1576 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
1577 try:
1578 shot_count = int(goal.get("shot_count") or 0)
1579 except (TypeError, ValueError):
1580 shot_count = 0
1581 if current_stage in {
1582 "shot_generating",
1583 "shot_reviewing",
1584 "shot_revising",
1585 "merging",
1586 }:
1587 return
1588 # 02 分镜脚本 only after workplace confirm_story (「下一步」).
1589 # Chat set_director_goal / early shot files must not skip 策划剧本.
1590 if shot_count <= 0:
1591 if state.get("story_confirmed"):
1592 state["stage"] = "story_confirmed"
1593 else:
1594 state["stage"] = self._DEFAULT_STAGE
1595 return
1596 if current_stage == "shot_planning":
1597 return
1598 if state.get("story_confirmed"):
1599 state["stage"] = "story_confirmed"
1600 return
1601 state["stage"] = self._DEFAULT_STAGE
1602
1603 def _refresh_fact(self, work_id: str, state: dict[str, Any]) -> str:
1604 paths = self._paths(work_id)
1605 story_exists = (
1606 paths["story"].exists() and paths["story"].read_text(encoding="utf-8").strip() != ""
1607 )
1608 story_profile = self._load_story_profile(work_id)
1609 goal = state.get("goal", {}) if isinstance(state.get("goal"), dict) else {}
1610 shot_items = self._shot_entries(state)
1611 counts = self._status_counts(state)
1612 pending_remote = self._pending_remote_jobs(state)
1613 lines = [
1614 "# Director Fact",
1615 "",
1616 f"- work_id: `{work_id}`",
1617 f"- work_dir: `{paths['work_dir']}`",
1618 f"- stage: `{state.get('stage', self._DEFAULT_STAGE)}`",
1619 f"- story_confirmed: `{bool(state.get('story_confirmed'))}`",
1620 f"- story_exists: `{story_exists}`",
1621 f"- story_profile_exists: `{bool(story_profile)}`",
1622 f"- goal_brief: {state.get('goal_brief') or '(empty)'}",
1623 f"- reference_image_present: `{reference_image_present(state.get('reference_image'))}`",
1624 f"- reference_image_locked: `{is_reference_image_locked(state)}`",
1625 f"- auto_generate: `{bool(state.get('auto_generate'))}`",
1626 f"- auto_generate_shot_count: `{self._effective_auto_generate_shot_count(goal)}`",
1627 f"- reference_image_needs_story_rewrite: `{self._session_reference_needs_rewrite()}`",
1628 f"- reference_image_inject_failed: `{self._session_reference_inject_failed()}`",
1629 "",
1630 "## Goal",
1631 "",
1632 f"- shot_count: `{goal.get('shot_count')}`",
1633 f"- shot_duration_sec: `{goal.get('shot_duration_sec')}`",
1634 f"- generation_mode: `{goal.get('generation_mode', 'sequential')}`",
1635 "",
1636 "## Progress",
1637 "",
1638 f"- total_shots: `{len(shot_items)}`",
1639 f"- pending_remote_jobs: `{len(pending_remote)}`",
1640 ]
1641 if counts:
1642 for status, count in sorted(counts.items()):
1643 lines.append(f"- {status}: `{count}`")
1644 else:
1645 lines.append("- shot_statuses: `(none yet)`")
1646 lines += [
1647 "",
1648 "## Paths",
1649 "",
1650 f"- state_json: `{paths['state']}`",
1651 f"- story_md: `{paths['story']}`",
1652 f"- story_profile_json: `{paths['story_profile']}`",
1653 f"- shots_dir: `{paths['shots']}`",
1654 f"- jobs_dir: `{paths['jobs']}`",
1655 f"- outputs_dir: `{paths['outputs']}`",
1656 "",
1657 "## Tool Ownership",
1658 "",
1659 "- Director state files are tool-owned. Use director tools instead of raw file edits whenever possible.",
1660 ]
1661 content = "\n".join(lines)
1662 self._write_text(paths["fact"], content)
1663 return content
1664
1665 def _summary_from_shot(self, shot: dict[str, Any]) -> str:
1666 if isinstance(shot.get("summary"), str) and shot["summary"].strip():
1667 return shot["summary"].strip()
1668 caption = shot.get("caption")
1669 if isinstance(caption, str) and caption.strip():
1670 return caption.strip()[:160]
1671 prompt = shot.get("prompt")
1672 if isinstance(prompt, str) and prompt.strip():
1673 return prompt.strip()[:160]
1674 return ""
1675
1676 def _shot_artifact_locator(self, shot: dict[str, Any]) -> str | None:
1677 artifact_url = shot.get("artifact_url")
1678 if isinstance(artifact_url, str) and artifact_url.strip():
1679 return artifact_url.strip()
1680 echo = shot.get("echo")
1681 if isinstance(echo, dict):
1682 result_url = echo.get("result_url")
1683 if isinstance(result_url, str) and result_url.strip():
1684 return result_url.strip()
1685 artifact_path = shot.get("artifact_path")
1686 if isinstance(artifact_path, str) and artifact_path.strip():
1687 return artifact_path.strip()
1688 remote_result = shot.get("remote_result")
1689 if isinstance(remote_result, dict):
1690 video_path = remote_result.get("video_path")
1691 if isinstance(video_path, str) and video_path.strip():
1692 return video_path.strip()
1693 return None
1694
1695 def _state_shot_entry(self, shot: dict[str, Any]) -> dict[str, Any]:
1696 return {
1697 "shot_id": int(shot["shot_id"]),
1698 "status": shot.get("status"),
1699 "summary": self._summary_from_shot(shot),
1700 "cut": bool(shot.get("cut", True)),
1701 "has_shot_spec": bool(shot.get("caption")),
1702 "has_artifact": bool(self._shot_artifact_locator(shot)),
1703 "artifact_path": shot.get("artifact_path"),
1704 "artifact_url": shot.get("artifact_url"),
1705 "last_review": shot.get("last_review"),
1706 "review_notes": shot.get("review_notes") or "",
1707 "generation_error": shot.get("generation_error") or "",
1708 "updated_at": _now_iso(),
1709 }
1710
1711 def _mark_shot_generation_error(
1712 self,
1713 work_id: str,
1714 shot_id: int,
1715 *,
1716 error_message: str,
1717 job_id: str | None = None,
1718 ) -> dict[str, Any]:
1719 shot = self._load_shot(work_id, shot_id)
1720 if not shot:
1721 raise ValueError(f"Shot {shot_id} does not exist in work {work_id}.")
1722 shot["status"] = "error"
1723 shot["generation_error"] = error_message
1724 shot["last_review"] = "error"
1725 if job_id:
1726 shot["last_job_id"] = job_id
1727 echo = shot.get("echo")
1728 if isinstance(echo, dict):
1729 echo.update(
1730 {
1731 "status": "failed",
1732 "last_error": error_message,
1733 }
1734 )
1735 self._save_shot(work_id, shot_id, shot)
1736 return shot
1737
1738 @staticmethod
1739 def _review_history(shot: dict[str, Any]) -> list[dict[str, Any]]:
1740 history = shot.get("review_history")
1741 if isinstance(history, list):
1742 return [item for item in history if isinstance(item, dict)]
1743 return []
1744
1745 @staticmethod
1746 def _is_revised_prompt_update(shot: dict[str, Any]) -> bool:
1747 if str(shot.get("status") or "") == "review_fail":
1748 return True
1749 if shot.get("last_review") == "revise":
1750 return True
1751 return any(item.get("verdict") == "revise" for item in DirectorTool._review_history(shot))
1752
1753 def _append_review_history(
1754 self,
1755 shot: dict[str, Any],
1756 *,
1757 verdict: str,
1758 review_source: str,
1759 feedback: str | None,
1760 ) -> None:
1761 history = self._review_history(shot)
1762 history.append(
1763 {
1764 "verdict": verdict,
1765 "source": review_source,
1766 "feedback": feedback or "",
1767 "created_at": _now_iso(),
1768 }
1769 )
1770 shot["review_history"] = history
1771
1772 def _apply_shot_review(
1773 self,
1774 work_id: str,
1775 shot_id: int,
1776 *,
1777 verdict: str,
1778 review_source: str,
1779 feedback: str | None,
1780 ) -> tuple[dict[str, Any], dict[str, Any]]:
1781 shot = self._load_shot(work_id, shot_id)
1782 if not shot:
1783 raise ValueError(f"Shot {shot_id} does not exist in work {work_id}.")
1784
1785 normalized_feedback = (
1786 feedback.strip() if isinstance(feedback, str) and feedback.strip() else None
1787 )
1788 if verdict == "revise" and not normalized_feedback:
1789 raise ValueError("feedback is required when verdict='revise'.")
1790
1791 if verdict == "accept":
1792 shot["status"] = "approved"
1793 shot["last_review"] = "accepted"
1794 shot["review_notes"] = ""
1795 shot["approved_at"] = _now_iso()
1796 else:
1797 shot["status"] = "review_fail"
1798 shot["last_review"] = "revise"
1799 shot["review_notes"] = normalized_feedback or ""
1800
1801 self._append_review_history(
1802 shot,
1803 verdict=verdict,
1804 review_source=review_source,
1805 feedback=normalized_feedback,
1806 )
1807 self._save_shot(work_id, shot_id, shot)
1808
1809 state = self._load_state(work_id)
1810 if verdict == "revise":
1811 self._clear_pending_remote_jobs_for_shot(
1812 state,
1813 shot_id,
1814 kinds={"generate_echo_shot"},
1815 )
1816 state.pop("merge_confirmation_requested_at", None)
1817 shots = state.setdefault("shots", {})
1818 if isinstance(shots, dict):
1819 shots[_shot_key(shot_id)] = self._state_shot_entry(shot)
1820 self._sync_stage_from_state(state)
1821 self._save_state(work_id, state)
1822 self._refresh_fact(work_id, state)
1823 return shot, state
1824
1825 def _normalize_reference_shot_ids(
1826 self,
1827 shot_id: int,
1828 reference_shot_ids: list[int],
1829 *,
1830 cut: bool,
1831 ) -> list[int]:
1832 normalized_reference_ids = sorted({int(item) for item in reference_shot_ids})
1833 if len(normalized_reference_ids) != len(reference_shot_ids):
1834 raise ValueError("reference_shot_ids must not contain duplicates.")
1835 if any(ref_id <= 0 for ref_id in normalized_reference_ids):
1836 raise ValueError("reference_shot_ids must contain positive shot IDs only.")
1837 if any(ref_id >= shot_id for ref_id in normalized_reference_ids):
1838 raise ValueError("reference_shot_ids must refer only to earlier shots.")
1839 if not cut and shot_id > 1 and (shot_id - 1) not in normalized_reference_ids:
1840 raise ValueError(
1841 "Shots with cut=false must include the immediately previous shot as a reference."
1842 )
1843 return normalized_reference_ids
1844
1845 def _build_echo_payload(
1846 self,
1847 work_id: str,
1848 shot_id: int,
1849 reference_shot_ids: list[int],
1850 selection_note: str | None,
1851 condition_image_url: str | None = None,
1852 i2v_prompt: str | None = None,
1853 ) -> tuple[dict[str, Any], dict[str, Any], list[int]]:
1854 shot = self._load_shot(work_id, shot_id)
1855 if not shot:
1856 raise ValueError(f"Shot {shot_id} does not exist in work {work_id}.")
1857 caption = shot.get("caption")
1858 if not isinstance(caption, str) or not caption.strip():
1859 raise ValueError(f"Shot {shot_id} has no caption yet. Call create_shot_prompt first.")
1860
1861 normalized_reference_ids = self._normalize_reference_shot_ids(
1862 shot_id,
1863 reference_shot_ids,
1864 cut=bool(shot.get("cut", True)),
1865 )
1866
1867 reference_shots: list[dict[str, Any]] = []
1868 for ref_id in normalized_reference_ids:
1869 reference_shot = self._load_shot(work_id, ref_id)
1870 if not reference_shot:
1871 raise ValueError(f"Reference shot {ref_id} does not exist in work {work_id}.")
1872 reference_shots.append(
1873 {
1874 "shot_id": ref_id,
1875 "shot_key": _shot_key(ref_id),
1876 "summary": self._summary_from_shot(reference_shot),
1877 "cut": bool(reference_shot.get("cut", True)),
1878 "artifact_url": reference_shot.get("artifact_url"),
1879 "artifact_path": reference_shot.get("artifact_path"),
1880 }
1881 )
1882
1883 state = self._load_state(work_id)
1884 story_profile = self._load_story_profile(work_id)
1885 duration_value = resolve_echo_duration_seconds(shot, state)
1886 num_frames = sync_shot_echo_duration(shot, duration_value)
1887 shot_payload: dict[str, Any] = {
1888 "shot_id": shot_id,
1889 "shot_key": _shot_key(shot_id),
1890 "cut": bool(shot.get("cut", True)),
1891 "summary": self._summary_from_shot(shot),
1892 "text": i2v_prompt.strip() if i2v_prompt else caption.strip(),
1893 "num_frames": num_frames,
1894 # Exact seconds matching num_frames (e.g. 177 → 7.08). A rounded
1895 # 7.0 would let the Echo service re-snap downward to 169 frames.
1896 "duration_sec": num_frames_to_exact_duration_sec(num_frames),
1897 }
1898 if condition_image_url:
1899 shot_payload["condition_image_url"] = condition_image_url
1900 shot_payload["generation_mode"] = "i2v"
1901 memory_slots = shot.get("approved_memory_slots")
1902 if isinstance(memory_slots, list) and memory_slots:
1903 shot_payload["memory_slots"] = memory_slots
1904 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
1905 width = goal.get("width")
1906 height = goal.get("height")
1907 if width is not None:
1908 shot_payload["width"] = int(width)
1909 if height is not None:
1910 shot_payload["height"] = int(height)
1911 payload = {
1912 "work_id": work_id,
1913 "shot": shot_payload,
1914 "reference_shot_ids": normalized_reference_ids,
1915 "reference_shots": reference_shots,
1916 "selection_note": selection_note.strip()
1917 if isinstance(selection_note, str) and selection_note.strip()
1918 else None,
1919 "story_context": {
1920 "latest_story_summary": state.get("latest_story_summary") or None,
1921 "story_profile_summary": story_profile.get("summary")
1922 if isinstance(story_profile, dict)
1923 else None,
1924 },
1925 }
1926 return payload, shot, normalized_reference_ids
1927
1928 def _build_r2v_payload(
1929 self,
1930 work_id: str,
1931 shot_id: int,
1932 *,
1933 prompt: str,
1934 num_frames: int,
1935 width: int | None = None,
1936 height: int | None = None,
1937 condition_image_url: str | None = None,
1938 memory_slots: list[dict[str, Any]] | None = None,
1939 ) -> dict[str, Any]:
1940 """Build request body for POST /r2v (unified T2V/I2V/R2V)."""
1941 payload: dict[str, Any] = {
1942 "work_id": work_id,
1943 "shot_id": _shot_key(shot_id),
1944 "prompt": prompt.strip(),
1945 "num_frames": num_frames,
1946 "memory_slots": memory_slots if isinstance(memory_slots, list) else [],
1947 }
1948 if condition_image_url:
1949 from nanobot.storage.files import outbound_file_url
1950
1951 payload["condition_img"] = outbound_file_url(
1952 condition_image_url,
1953 workspace=self.workspace,
1954 work_id=work_id,
1955 name=f"request_assets/shot_{shot_id:03d}_condition.jpg",
1956 storage=self._tools_config.file_storage,
1957 )
1958 if width is not None:
1959 payload["width"] = int(width)
1960 if height is not None:
1961 payload["height"] = int(height)
1962 return payload
1963
1964 def _build_memory_slots(
1965 self,
1966 approved_memory_slots: Any,
1967 reference_shot_ids: list[int],
1968 *,
1969 work_id: str | None = None,
1970 ) -> list[dict[str, Any]]:
1971 """Resolve only human/auto-approved Memory slots for R2V.
1972
1973 ``reference_shot_ids`` remains screenplay context and request metadata;
1974 it must never be converted into a media slot behind the user's back.
1975 """
1976 slots: list[dict[str, Any]] = (
1977 [dict(slot) for slot in approved_memory_slots if isinstance(slot, dict)]
1978 if isinstance(approved_memory_slots, list)
1979 else []
1980 )
1981 from nanobot.storage.files import outbound_file_url
1982
1983 resolved_work_id = work_id or self._active_work_id() or "work"
1984 for index, slot in enumerate(slots):
1985 for key in ("image_url", "audio_url"):
1986 value = slot.get(key)
1987 if isinstance(value, str) and value.strip():
1988 suffix = Path(value.split("?", 1)[0]).suffix
1989 if not suffix:
1990 suffix = ".jpg" if key == "image_url" else ".wav"
1991 slot[key] = outbound_file_url(
1992 value,
1993 workspace=self.workspace,
1994 work_id=resolved_work_id,
1995 name=f"request_assets/slot_{index:02d}_{key}{suffix}",
1996 storage=self._tools_config.file_storage,
1997 )
1998 if len(slots) > 7:
1999 raise ValueError("approved memory slots cannot exceed 7")
2000 return slots
2001
2002 def _build_merge_payload(
2003 self,
2004 work_id: str,
2005 shot_ids: list[int],
2006 selected_shots: list[dict[str, Any]],
2007 ) -> dict[str, Any]:
2008 from nanobot.storage.files import LocalFilePublisher, resolve_local_asset_path
2009
2010 inputs: list[dict[str, Any]] = []
2011 for shot in selected_shots:
2012 shot_id = int(shot["shot_id"])
2013 echo = shot.get("echo")
2014 version_id = echo.get("version_id") if isinstance(echo, dict) else None
2015 item: dict[str, Any] = {"shot_id": shot_id}
2016 source = None
2017 if isinstance(echo, dict):
2018 source = echo.get("result_url") or echo.get("base_result_url")
2019 source = source or shot.get("artifact_url") or shot.get("artifact_path")
2020 if isinstance(source, str) and source.strip():
2021 source = source.strip()
2022 if source.startswith(("http://", "https://")):
2023 item["video_url"] = source
2024 else:
2025 local_config = self._tools_config.file_storage.local
2026 local_path = resolve_local_asset_path(
2027 source,
2028 workspace=self.workspace,
2029 config=local_config,
2030 ) or Path(source).expanduser()
2031 if not str(local_config.base_url).strip():
2032 raise ValueError(
2033 "tools.fileStorage.local.baseUrl is required to merge a local video."
2034 )
2035 else:
2036 item["video_url"] = LocalFilePublisher(
2037 local_config,
2038 workspace=self.workspace,
2039 work_id=work_id,
2040 )(str(local_path), f"merge_inputs/shot_{shot_id:03d}.mp4")
2041 elif isinstance(version_id, str) and version_id.strip():
2042 # Version records are process-local on the public Echo server and
2043 # disappear after a restart. Use them only when no durable artifact
2044 # locator was saved with the completed shot.
2045 item["version_id"] = version_id.strip()
2046 else:
2047 raise ValueError(
2048 f"Shot {shot_id} has no Echo version or playable video artifact."
2049 )
2050 inputs.append(item)
2051 return {
2052 "work_id": work_id,
2053 "shot_ids": shot_ids,
2054 "shots": inputs,
2055 }
2056
2057 def _submit_remote_request(
2058 self,
2059 work_id: str,
2060 job_id: str,
2061 request_payload: dict[str, Any],
2062 *,
2063 target: str | list[str],
2064 operation: str,
2065 ) -> dict[str, Any]:
2066 payload_path = self._paths(work_id)["outputs"] / f"{job_id}.payload.json"
2067 envelope = self._build_remote_request_envelope(
2068 operation,
2069 work_id,
2070 job_id,
2071 target,
2072 request_payload,
2073 )
2074 request_path = self._paths(work_id)["outputs"] / f"{job_id}.request.json"
2075 self._write_json(payload_path, request_payload)
2076 self._write_json(request_path, envelope)
2077
2078 job: dict[str, Any] = {
2079 "job_id": job_id,
2080 "kind": operation,
2081 "status": "queued",
2082 "work_id": work_id,
2083 "target": target,
2084 "created_at": _now_iso(),
2085 "completed_at": None,
2086 "request_payload_path": str(payload_path),
2087 "request_envelope_path": str(request_path),
2088 "remote": {
2089 "transport": "http",
2090 "protocol_version": _REMOTE_PROTOCOL_VERSION,
2091 "endpoint_path": self._remote_endpoint_path(operation),
2092 "remote_task_id": None,
2093 "callback_expected": True,
2094 "callback_contract": envelope["callback"],
2095 },
2096 }
2097
2098 callback_url = envelope.get("callback", {}).get("url")
2099 if not isinstance(callback_url, str) or not callback_url.strip():
2100 raise RuntimeError(
2101 "Echo callback URL is not configured. "
2102 "Set tools.echoGenerator.callbackBaseUrl to the local Agent URL."
2103 )
2104
2105 EchoAdmissionController.from_tools_config(self._tools_config).ensure_allowed(
2106 operation=operation,
2107 )
2108
2109 base_url = self._remote_http_base_url()
2110 if not base_url:
2111 raise RuntimeError(
2112 "No Echo generator base URL configured. "
2113 "Set tools.echoGenerator.baseUrl for real HTTP submission."
2114 )
2115 endpoint_url = f"{base_url}{self._remote_endpoint_path(operation)}"
2116 remote_ack = self._post_remote_http_request(endpoint_url, envelope)
2117 remote_task_id = remote_ack.get("remote_task_id") or remote_ack.get("task_id")
2118 job["remote"].update(
2119 {
2120 "endpoint_url": endpoint_url,
2121 "remote_task_id": remote_task_id,
2122 "version_id": remote_ack.get("version_id"),
2123 "status_url": remote_ack.get("status_url"),
2124 "ack": remote_ack,
2125 }
2126 )
2127 return job
2128
2129 def _submit_r2v_request(
2130 self,
2131 work_id: str,
2132 job_id: str,
2133 request_payload: dict[str, Any],
2134 *,
2135 target: str,
2136 ) -> dict[str, Any]:
2137 """Submit a generation job via POST /r2v (no director envelope)."""
2138 payload_path = self._paths(work_id)["outputs"] / f"{job_id}.payload.json"
2139 request_path = self._paths(work_id)["outputs"] / f"{job_id}.request.json"
2140 session = {
2141 "session_key": self._session_key.get() if self._session_key.get() else None,
2142 "channel": self._channel.get() if self._channel.get() else None,
2143 "chat_id": self._chat_id.get() if self._chat_id.get() else None,
2144 }
2145 callback_url = self._remote_callback_url("generate_echo_shot")
2146 outbound_payload = dict(request_payload)
2147 outbound_payload.update(
2148 {
2149 "job_id": job_id,
2150 "callback_context": session,
2151 }
2152 )
2153 if callback_url:
2154 outbound_payload["callback_url"] = callback_url
2155 self._write_json(payload_path, request_payload)
2156 self._write_json(request_path, outbound_payload)
2157
2158 job: dict[str, Any] = {
2159 "job_id": job_id,
2160 "kind": "generate_echo_shot",
2161 "status": "queued",
2162 "work_id": work_id,
2163 "target": target,
2164 "created_at": _now_iso(),
2165 "completed_at": None,
2166 "request_payload_path": str(payload_path),
2167 "request_envelope_path": str(request_path),
2168 "remote": {
2169 "transport": "http",
2170 "endpoint_path": self._remote_endpoint_path("r2v_generate"),
2171 "remote_task_id": None,
2172 "r2v": True,
2173 "callback_expected": True,
2174 "callback_url": callback_url,
2175 },
2176 "session": session,
2177 }
2178 if not callback_url:
2179 raise RuntimeError(
2180 "Echo callback URL is not configured. "
2181 "Set tools.echoGenerator.callbackBaseUrl to the local Agent URL."
2182 )
2183
2184 EchoAdmissionController.from_tools_config(self._tools_config).ensure_allowed(
2185 operation="r2v_generate",
2186 )
2187
2188 base_url = self._remote_http_base_url()
2189 if not base_url:
2190 raise RuntimeError(
2191 "No Echo generator base URL configured. "
2192 "Set tools.echoGenerator.baseUrl in config."
2193 )
2194 endpoint_url = f"{base_url}{self._remote_endpoint_path('r2v_generate')}"
2195
2196 headers: dict[str, str] = {
2197 "Content-Type": "application/json",
2198 "Accept": "application/json",
2199 }
2200
2201 body = json.dumps(outbound_payload, ensure_ascii=False).encode("utf-8")
2202 # This request only submits the job. Rendering completes through the
2203 # callback, so it must not inherit a generation-length timeout.
2204 timeout = self._remote_http_timeout_sec()
2205 last_exc: Exception | None = None
2206 raw = ""
2207 for attempt in range(1, _R2V_SUBMIT_ATTEMPTS + 1):
2208 req = urllib_request.Request(
2209 endpoint_url, data=body, headers=headers, method="POST"
2210 )
2211 try:
2212 with urllib_request.urlopen(req, timeout=timeout) as resp:
2213 raw = resp.read().decode("utf-8")
2214 last_exc = None
2215 break
2216 except urllib_error.HTTPError as exc:
2217 last_exc = exc
2218 transient = exc.code in _R2V_TRANSIENT_HTTP_CODES
2219 try:
2220 response_detail = exc.read(4096).decode("utf-8", errors="replace").strip()
2221 except Exception: # noqa: BLE001 - HTTPError may have no response stream.
2222 response_detail = ""
2223 logger.error(
2224 "R2V submit HTTP {} work_id={} job_id={} attempt={}/{} "
2225 "transient={} error={} response={}",
2226 exc.code,
2227 work_id,
2228 job_id,
2229 attempt,
2230 _R2V_SUBMIT_ATTEMPTS,
2231 transient,
2232 exc,
2233 response_detail or "-",
2234 )
2235 if not transient or attempt >= _R2V_SUBMIT_ATTEMPTS:
2236 suffix = f"; response: {response_detail}" if response_detail else ""
2237 raise RuntimeError(
2238 f"R2V submit failed with HTTP {exc.code}: {exc}{suffix}"
2239 ) from exc
2240 time.sleep(min(2 * attempt, 6))
2241 except (urllib_error.URLError, TimeoutError, OSError) as exc:
2242 last_exc = exc
2243 if is_connection_refused(exc):
2244 logger.error(
2245 "R2V submit unreachable work_id={} job_id={} error={}",
2246 work_id,
2247 job_id,
2248 exc,
2249 )
2250 raise EchoGeneratorUnavailableError(UNAVAILABLE_MESSAGE) from exc
2251 logger.error(
2252 "R2V submit failed work_id={} job_id={} attempt={}/{} "
2253 "retryable={} timeout_s={} error={}",
2254 work_id,
2255 job_id,
2256 attempt,
2257 _R2V_SUBMIT_ATTEMPTS,
2258 True,
2259 timeout,
2260 exc,
2261 )
2262 if attempt >= _R2V_SUBMIT_ATTEMPTS:
2263 raise RuntimeError(f"R2V submit failed: {exc}") from exc
2264 time.sleep(min(2 * attempt, 6))
2265 if last_exc is not None:
2266 raise RuntimeError(f"R2V submit failed: {last_exc}") from last_exc
2267
2268 try:
2269 remote_ack = json.loads(raw) if raw.strip() else {}
2270 except (json.JSONDecodeError, UnicodeDecodeError) as exc:
2271 raise RuntimeError(f"R2V submit returned invalid JSON: {exc}") from exc
2272
2273 if not isinstance(remote_ack, dict):
2274 raise RuntimeError(
2275 f"R2V submit returned unexpected payload type: {type(remote_ack).__name__}"
2276 )
2277
2278 version_id = remote_ack.get("version_id")
2279 if not isinstance(version_id, str) or not version_id.strip():
2280 raise RuntimeError(
2281 "R2V submit response is missing a non-empty 'version_id'"
2282 )
2283
2284 task_id = remote_ack.get("task_id")
2285 status_url = remote_ack.get("status_url") or f"/version/{version_id}"
2286 job["remote"].update(
2287 {
2288 "endpoint_url": endpoint_url,
2289 "remote_task_id": task_id,
2290 "version_id": version_id,
2291 "status_url": status_url,
2292 "ack": remote_ack,
2293 }
2294 )
2295 return job
2296
2297 @staticmethod
2298 def _target_shot_ids(target: Any) -> list[int]:
2299 if isinstance(target, list):
2300 shot_ids: list[int] = []
2301 for item in target:
2302 if isinstance(item, str):
2303 shot_ids.append(_shot_id_from_key(item))
2304 elif isinstance(item, int):
2305 shot_ids.append(item)
2306 return shot_ids
2307 if isinstance(target, str):
2308 return [_shot_id_from_key(target)]
2309 if isinstance(target, int):
2310 return [target]
2311 return []
2312
2313 @staticmethod
2314 def _extract_remote_stored_locator(*sources: dict[str, Any]) -> str | None:
2315 """Resolve the canonical stored locator from a remote callback payload.
2316
2317 Preference order: configured ``asset_urls`` entries, then direct HTTP-style URLs.
2318 PFS/local paths are ignored because the browser cannot play them.
2319 """
2320 from nanobot.integrations.remote_video_url import resolve_public_video_url
2321
2322 return resolve_public_video_url(*sources)
2323
2324 def _normalize_echo_callback_result(
2325 self,
2326 callback_payload: dict[str, Any],
2327 job: dict[str, Any],
2328 ) -> dict[str, Any]:
2329 default_shot_ids = self._target_shot_ids(job.get("target"))
2330 default_shot_id = default_shot_ids[0] if default_shot_ids else None
2331 raw_result = callback_payload.get("result")
2332 sources: list[dict[str, Any]] = [callback_payload]
2333 if isinstance(raw_result, dict):
2334 sources.insert(0, raw_result)
2335 shot_id = raw_result.get("shot_id", default_shot_id)
2336 else:
2337 shot_id = callback_payload.get("shot_id", default_shot_id)
2338 result_url = self._extract_remote_stored_locator(*sources)
2339 if shot_id is None or not result_url:
2340 raise ValueError(
2341 "Echo callback requires shot_id plus a public asset_urls URL or result_url, "
2342 "either at the top level or inside result={...}."
2343 )
2344 normalized_shot_id = (
2345 _shot_id_from_key(shot_id)
2346 if isinstance(shot_id, str) and shot_id.startswith("shot_")
2347 else int(shot_id)
2348 )
2349 normalized: dict[str, Any] = {
2350 "shot_id": normalized_shot_id,
2351 "result_url": result_url,
2352 }
2353 for key in ("video_id",):
2354 value = next((source.get(key) for source in sources if source.get(key)), None)
2355 if value is not None:
2356 normalized[key] = value
2357 return normalized
2358
2359 def apply_echo_callback_payload(self, callback_payload: dict[str, Any]) -> dict[str, Any]:
2360 work_id = callback_payload.get("work_id")
2361 job_id = callback_payload.get("job_id")
2362 if not isinstance(work_id, str) or not work_id.strip():
2363 raise ValueError("Echo callback missing work_id.")
2364 if not isinstance(job_id, str) or not job_id.strip():
2365 raise ValueError("Echo callback missing job_id.")
2366
2367 job = self._load_job(work_id, job_id)
2368 if not job:
2369 raise ValueError(f"Director job '{job_id}' was not found for work '{work_id}'.")
2370 if job.get("kind") != "generate_echo_shot":
2371 raise ValueError(f"Director job '{job_id}' is not a generate_echo_shot job.")
2372
2373 existing_status = str(job.get("status") or "")
2374 if existing_status in {"completed", "failed"}:
2375 result_url = job.get("result_url")
2376 return {
2377 "status": existing_status,
2378 "operation": "generate_echo_shot",
2379 "work_id": work_id,
2380 "job_id": job_id,
2381 "duplicate": True,
2382 "result_urls": [result_url]
2383 if isinstance(result_url, str) and result_url.strip()
2384 else [],
2385 "updated_shots": [],
2386 }
2387
2388 status = str(callback_payload.get("status") or "completed")
2389 if status not in {"completed", "failed"}:
2390 raise ValueError("Echo callback status must be 'completed' or 'failed'.")
2391
2392 state = self._load_state(work_id)
2393 result = (
2394 self._normalize_echo_callback_result(callback_payload, job)
2395 if status == "completed"
2396 else None
2397 )
2398 default_shot_ids = self._target_shot_ids(job.get("target"))
2399 target_shot_id = (
2400 int(result["shot_id"])
2401 if isinstance(result, dict)
2402 else (default_shot_ids[0] if default_shot_ids else None)
2403 )
2404 if target_shot_id is None:
2405 raise ValueError(
2406 "Echo callback could not determine shot_id from callback or job target."
2407 )
2408
2409 shot = self._load_shot(work_id, target_shot_id)
2410 if not shot:
2411 raise ValueError(f"Shot {target_shot_id} does not exist in work {work_id}.")
2412
2413 current_status = str(shot.get("status") or "")
2414 if current_status in {"prompt_ready", "revised_prompt_ready", "planned"}:
2415 job["status"] = status
2416 job["completed_at"] = callback_payload.get("completed_at") or _now_iso()
2417 remote = job.get("remote")
2418 if not isinstance(remote, dict):
2419 remote = {}
2420 job["remote"] = remote
2421 remote["callback_received_at"] = _now_iso()
2422 remote["ignored_stale_callback"] = True
2423 if callback_payload.get("remote_task_id"):
2424 remote["remote_task_id"] = callback_payload.get("remote_task_id")
2425 job["callback_payload"] = callback_payload
2426 self._save_job(work_id, job_id, job)
2427 self._clear_pending_remote_job(state, job_id)
2428 self._save_state(work_id, state)
2429 return {
2430 "status": "ignored",
2431 "operation": "generate_echo_shot",
2432 "work_id": work_id,
2433 "job_id": job_id,
2434 "shot_id": target_shot_id,
2435 "reason": f"shot is {current_status} after replan; callback ignored",
2436 }
2437
2438 job["status"] = status
2439 job["completed_at"] = callback_payload.get("completed_at") or _now_iso()
2440 remote = job.get("remote")
2441 if not isinstance(remote, dict):
2442 remote = {}
2443 job["remote"] = remote
2444 remote["callback_received_at"] = _now_iso()
2445 if callback_payload.get("remote_task_id"):
2446 remote["remote_task_id"] = callback_payload.get("remote_task_id")
2447 job["callback_payload"] = callback_payload
2448
2449 echo = shot.get("echo")
2450 if not isinstance(echo, dict):
2451 echo = {}
2452 shot["echo"] = echo
2453
2454 updated_shots: list[dict[str, Any]] = []
2455 result_urls: list[str] = []
2456 if status == "completed" and isinstance(result, dict):
2457 result_url = result["result_url"]
2458 shot["status"] = "generated"
2459 shot.pop("generation_error", None)
2460 shot["artifact_url"] = result_url
2461 shot["last_job_id"] = job_id
2462 echo.update(
2463 {
2464 "status": "completed",
2465 "result_url": result_url,
2466 "completed_at": job.get("completed_at"),
2467 "callback_received_at": remote.get("callback_received_at"),
2468 "remote_task_id": remote.get("remote_task_id"),
2469 }
2470 )
2471 for key in ("video_id",):
2472 if result.get(key) is not None:
2473 echo[key] = result[key]
2474 job["result_url"] = result_url
2475 updated_shots.append(
2476 {
2477 "shot_id": target_shot_id,
2478 "shot_key": _shot_key(target_shot_id),
2479 "result_url": result_url,
2480 }
2481 )
2482 result_urls.append(result_url)
2483 else:
2484 error_message = callback_payload.get("error") or "Remote Echo generation failed."
2485 job["error"] = error_message
2486 shot = self._mark_shot_generation_error(
2487 work_id,
2488 target_shot_id,
2489 error_message=error_message,
2490 job_id=job_id,
2491 )
2492 echo = shot.get("echo")
2493 if isinstance(echo, dict):
2494 echo.update(
2495 {
2496 "callback_received_at": remote.get("callback_received_at"),
2497 "remote_task_id": remote.get("remote_task_id"),
2498 }
2499 )
2500 shot["echo"] = echo
2501 self._save_shot(work_id, target_shot_id, shot)
2502
2503 if status == "completed":
2504 self._save_shot(work_id, target_shot_id, shot)
2505 shots = state.setdefault("shots", {})
2506 if isinstance(shots, dict):
2507 shots[_shot_key(target_shot_id)] = self._state_shot_entry(shot)
2508 self._save_job(work_id, job_id, job)
2509 self._clear_pending_remote_job(state, job_id)
2510 self._sync_stage_from_state(state)
2511 self._save_state(work_id, state)
2512 self._refresh_fact(work_id, state)
2513
2514 if status == "completed":
2515 message = prompts.text(
2516 "director.callback.generate_echo_shot.completed",
2517 work_id=work_id,
2518 shot_key=_shot_key(target_shot_id),
2519 result_url=result_urls[0],
2520 )
2521 else:
2522 message = prompts.text(
2523 "director.callback.generate_echo_shot.failed",
2524 work_id=work_id,
2525 shot_key=_shot_key(target_shot_id),
2526 error=job.get("error"),
2527 )
2528 return {
2529 "status": status,
2530 "operation": "generate_echo_shot",
2531 "work_id": work_id,
2532 "job_id": job_id,
2533 "result_urls": result_urls,
2534 "updated_shots": updated_shots,
2535 "injection_message": message,
2536 "session_key": callback_payload.get("session_key"),
2537 "channel": callback_payload.get("channel"),
2538 "chat_id": callback_payload.get("chat_id"),
2539 }
2540
2541 @staticmethod
2542 def _normalize_merge_callback_result(callback_payload: dict[str, Any]) -> dict[str, str | None]:
2543 raw_result = callback_payload.get("result")
2544 sources: list[dict[str, Any]] = [callback_payload]
2545 if isinstance(raw_result, dict):
2546 sources.insert(0, raw_result)
2547
2548 stored_locator = DirectorTool._extract_remote_stored_locator(*sources)
2549
2550 artifact_path = None
2551 for source in sources:
2552 candidate = source.get("artifact_path") or source.get("output_path")
2553 if isinstance(candidate, str) and candidate.strip():
2554 artifact_path = candidate.strip()
2555 break
2556
2557 normalized_path = artifact_path
2558 normalized_url = stored_locator
2559 if not normalized_path and not normalized_url:
2560 raise ValueError(
2561 "Merge callback requires a public asset_urls URL, artifact_path, "
2562 "or artifact_url/result_url, "
2563 "either at the top level or inside result={...}."
2564 )
2565 return {
2566 "artifact_path": normalized_path,
2567 "artifact_url": normalized_url,
2568 }
2569
2570 def apply_merge_callback_payload(self, callback_payload: dict[str, Any]) -> dict[str, Any]:
2571 work_id = callback_payload.get("work_id")
2572 job_id = callback_payload.get("job_id")
2573 if not isinstance(work_id, str) or not work_id.strip():
2574 raise ValueError("Merge callback missing work_id.")
2575 if not isinstance(job_id, str) or not job_id.strip():
2576 raise ValueError("Merge callback missing job_id.")
2577
2578 job = self._load_job(work_id, job_id)
2579 if not job:
2580 raise ValueError(f"Director job '{job_id}' was not found for work '{work_id}'.")
2581 if job.get("kind") != "merge_shot":
2582 raise ValueError(f"Director job '{job_id}' is not a merge_shot job.")
2583
2584 existing_status = str(job.get("status") or "")
2585 if existing_status in {"completed", "failed"}:
2586 final_output = job.get("artifact_url") or job.get("artifact_path")
2587 return {
2588 "status": existing_status,
2589 "operation": "merge_shot",
2590 "work_id": work_id,
2591 "job_id": job_id,
2592 "duplicate": True,
2593 "final_output": final_output,
2594 "final_output_path": job.get("artifact_path"),
2595 "final_output_url": job.get("artifact_url"),
2596 "media": [final_output]
2597 if isinstance(final_output, str) and final_output.strip()
2598 else [],
2599 }
2600
2601 status = str(callback_payload.get("status") or "completed")
2602 if status not in {"completed", "failed"}:
2603 raise ValueError("Merge callback status must be 'completed' or 'failed'.")
2604
2605 state = self._load_state(work_id)
2606 result = (
2607 self._normalize_merge_callback_result(callback_payload)
2608 if status == "completed"
2609 else None
2610 )
2611
2612 job["status"] = status
2613 job["completed_at"] = callback_payload.get("completed_at") or _now_iso()
2614 remote = job.get("remote")
2615 if not isinstance(remote, dict):
2616 remote = {}
2617 job["remote"] = remote
2618 remote["callback_received_at"] = _now_iso()
2619 if callback_payload.get("remote_task_id"):
2620 remote["remote_task_id"] = callback_payload.get("remote_task_id")
2621 job["callback_payload"] = callback_payload
2622
2623 final_output_path = None
2624 final_output_url = None
2625 if status == "completed" and isinstance(result, dict):
2626 state.pop("generation_error", None)
2627 final_output_path = result["artifact_path"]
2628 final_output_url = result["artifact_url"]
2629 state["final_output_path"] = final_output_path
2630 state["final_output_url"] = final_output_url
2631 if final_output_path is not None:
2632 job["artifact_path"] = final_output_path
2633 if final_output_url is not None:
2634 job["artifact_url"] = final_output_url
2635 else:
2636 error_message = callback_payload.get("error") or "Remote merge failed."
2637 job["error"] = error_message
2638 state["generation_error"] = error_message
2639 state["stage"] = "failed"
2640
2641 self._save_job(work_id, job_id, job)
2642 self._clear_pending_remote_job(state, job_id)
2643 self._sync_stage_from_state(state)
2644 self._save_state(work_id, state)
2645 self._refresh_fact(work_id, state)
2646
2647 final_output = final_output_url or final_output_path
2648 if status == "completed":
2649 message = prompts.text(
2650 "director.callback.merge_shot.completed",
2651 work_id=work_id,
2652 final_output=final_output,
2653 )
2654 else:
2655 message = prompts.text(
2656 "director.callback.merge_shot.failed",
2657 work_id=work_id,
2658 job_id=job_id,
2659 error=job.get("error"),
2660 )
2661 return {
2662 "status": status,
2663 "operation": "merge_shot",
2664 "work_id": work_id,
2665 "job_id": job_id,
2666 "final_output": final_output,
2667 "final_output_path": final_output_path,
2668 "final_output_url": final_output_url,
2669 "media": [final_output]
2670 if isinstance(final_output, str) and final_output.strip()
2671 else [],
2672 "injection_message": message,
2673 "session_key": callback_payload.get("session_key"),
2674 "channel": callback_payload.get("channel"),
2675 "chat_id": callback_payload.get("chat_id"),
2676 }
2677
2678 def _next_action(self, state: dict[str, Any]) -> str:
2679 if not state.get("story_confirmed"):
2680 return ""
2681 goal = state.get("goal", {}) if isinstance(state.get("goal"), dict) else {}
2682 if goal.get("shot_count") in (None, 0):
2683 return ""
2684 shot_count = int(goal.get("shot_count") or 0)
2685 shots = self._shot_entries(state)
2686 if len(shots) < shot_count:
2687 if self._session_auto_generate() or bool(state.get("auto_generate")):
2688 return ""
2689 return prompts.text("director.next_action.storyboard_ready")
2690 if any(
2691 item.get("status") in {"planned", "prompt_ready", "revised_prompt_ready", "queued"}
2692 for item in shots
2693 ):
2694 return prompts.text("director.next_action.start_generation")
2695 if state.get("review_completed_at"):
2696 return prompts.text("director.next_action.review_complete")
2697 if not state.get("final_output_path"):
2698 return prompts.text("director.next_action.merge_ready")
2699 return prompts.text("director.next_action.work_complete")
2700
2701 def _confirm(self, work_id: str) -> dict[str, Any]:
2702 state = self._load_state(work_id)
2703 self._sync_stage_from_state(state)
2704 self._save_state(work_id, state)
2705 fact = self._refresh_fact(work_id, state)
2706 goal = state.get("goal", {}) if isinstance(state.get("goal"), dict) else {}
2707 shots = self._shot_entries(state)
2708 approved = sum(1 for item in shots if item.get("status") in {"review_pass", "approved"})
2709 generated = sum(1 for item in shots if item.get("status") == "generated")
2710 return {
2711 "work_id": work_id,
2712 "stage": state.get("stage", self._DEFAULT_STAGE),
2713 "story_confirmed": bool(state.get("story_confirmed")),
2714 "goal": goal,
2715 "story_exists": self._paths(work_id)["story"].read_text(encoding="utf-8").strip() != "",
2716 "shot_total": len(shots),
2717 "shot_generated": generated,
2718 "shot_approved": approved,
2719 "next_recommended_action": self._next_action(state),
2720 "fact_md": fact,
2721 }
2722
2723
2724 @tool_parameters(
2725 tool_parameters_schema(
2726 goal=StringSchema("Brief description of the user's intended video or story work"),
2727 title=StringSchema("Optional short title for the work"),
2728 continue_policy=StringSchema(
2729 "How to handle an unfinished existing work: ask, resume, or new",
2730 enum=["ask", "resume", "new"],
2731 ),
2732 required=["goal"],
2733 )
2734 )
2735 class StartDirectorTool(DirectorTool):
2736 @property
2737 def name(self) -> str:
2738 return "start_director"
2739
2740 async def execute(
2741 self,
2742 goal: str,
2743 title: str | None = None,
2744 continue_policy: str = "ask",
2745 **kwargs: Any,
2746 ) -> str:
2747 # Check current active work first, then scan history for unfinished works
2748 existing_work_id: str | None = None
2749 existing_state: dict[str, Any] | None = None
2750
2751 active_id = self._active_work_id()
2752 if active_id:
2753 state = self._load_state(active_id)
2754 if state and self._is_unfinished(state):
2755 existing_work_id = active_id
2756 existing_state = state
2757
2758 if not existing_work_id:
2759 for hist_id in reversed(self._session_work_history()):
2760 if hist_id == active_id:
2761 continue
2762 state = self._load_state(hist_id)
2763 if state and self._is_unfinished(state):
2764 existing_work_id = hist_id
2765 existing_state = state
2766 break
2767
2768 if existing_work_id and continue_policy == "ask":
2769 return _json_dump(
2770 {
2771 "status": "needs_confirmation",
2772 "message": "An unfinished director work already exists for this session.",
2773 "existing_work_id": existing_work_id,
2774 "stage": existing_state.get("stage") if existing_state else None,
2775 "goal_brief": existing_state.get("goal_brief") if existing_state else None,
2776 "next_step": "Ask the user whether to continue the existing work or create a new one.",
2777 }
2778 )
2779 if existing_work_id and continue_policy == "resume":
2780 self._set_active_work(existing_work_id)
2781 return _json_dump(
2782 {
2783 "status": "resumed",
2784 "work_id": existing_work_id,
2785 "stage": existing_state.get("stage") if existing_state else None,
2786 "goal_brief": existing_state.get("goal_brief") if existing_state else None,
2787 }
2788 )
2789
2790 slug_source = title or goal[:48]
2791 stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
2792 work_id = f"work-{stamp}-{_slugify(slug_source, fallback='video')}"
2793 state = self._ensure_work_files(work_id, title=title, goal=goal)
2794 self._set_active_work(work_id)
2795 return _json_dump(
2796 {
2797 "status": "created",
2798 "work_id": work_id,
2799 "work_dir": str(self._paths(work_id)["work_dir"]),
2800 "stage": state.get("stage"),
2801 "goal_brief": goal,
2802 }
2803 )
2804
2805
2806 @tool_parameters(
2807 tool_parameters_schema(
2808 work_id=StringSchema(
2809 "Optional explicit work ID; defaults to the active work", nullable=True
2810 ),
2811 shot_count=IntegerSchema(description="Target number of shots", minimum=1, nullable=True),
2812 shot_duration_sec=IntegerSchema(
2813 description="Nominal duration per shot in seconds",
2814 minimum=1,
2815 nullable=True,
2816 ),
2817 generation_mode=StringSchema(
2818 "Shot generation mode",
2819 enum=["sequential", "parallel"],
2820 nullable=True,
2821 ),
2822 )
2823 )
2824 class SetDirectorGoalTool(DirectorTool):
2825 @property
2826 def name(self) -> str:
2827 return "set_director_goal"
2828
2829 async def execute(
2830 self,
2831 work_id: str | None = None,
2832 shot_count: int | None = None,
2833 shot_duration_sec: int | None = None,
2834 generation_mode: str | None = None,
2835 **kwargs: Any,
2836 ) -> str:
2837 resolved_work_id, _ = self._resolve_work_id(work_id)
2838 if not resolved_work_id:
2839 return "Error: No active director work. Call start_director first."
2840 if all(
2841 value is None
2842 for value in (shot_count, shot_duration_sec, generation_mode)
2843 ):
2844 return "Error: At least one goal field must be provided."
2845 state = self._load_state(resolved_work_id)
2846 goal = state.setdefault("goal", {})
2847 if not isinstance(goal, dict):
2848 goal = {}
2849 state["goal"] = goal
2850 try:
2851 previous_shot_count = int(goal.get("shot_count") or 0)
2852 except (TypeError, ValueError):
2853 previous_shot_count = 0
2854 if shot_count is not None:
2855 goal["shot_count"] = shot_count
2856 lock_reference_image(state)
2857 self._lock_session_reference_image()
2858 try:
2859 new_shot_count = int(shot_count)
2860 except (TypeError, ValueError):
2861 new_shot_count = 0
2862 if (
2863 previous_shot_count <= 0
2864 and new_shot_count > 0
2865 and not self._session_auto_generate()
2866 and not bool(state.get("auto_generate"))
2867 ):
2868 state[SHOT_COUNT_NEXT_STEP_HINT_PENDING_KEY] = True
2869 if shot_duration_sec is not None:
2870 goal["shot_duration_sec"] = shot_duration_sec
2871 if generation_mode is not None:
2872 goal["generation_mode"] = generation_mode
2873 self._sync_stage_from_state(state)
2874 self._save_state(resolved_work_id, state)
2875 self._refresh_fact(resolved_work_id, state)
2876 return _json_dump(
2877 {
2878 "status": "ok",
2879 "work_id": resolved_work_id,
2880 "goal": goal,
2881 "stage": state.get("stage"),
2882 }
2883 )
2884
2885
2886 @tool_parameters(
2887 tool_parameters_schema(
2888 work_id=StringSchema(
2889 "Optional explicit work ID; defaults to the active work", nullable=True
2890 ),
2891 include_shots=BooleanSchema(description="Include per-shot summary rows", default=True),
2892 include_jobs=BooleanSchema(description="Include recent job rows", default=True),
2893 limit=IntegerSchema(description="Maximum shots/jobs to return", minimum=1, maximum=200),
2894 )
2895 )
2896 class GetWorkplaceStatusTool(DirectorTool):
2897 @property
2898 def name(self) -> str:
2899 return "get_workplace_status"
2900
2901 async def execute(
2902 self,
2903 work_id: str | None = None,
2904 include_shots: bool = True,
2905 include_jobs: bool = True,
2906 limit: int = 20,
2907 **kwargs: Any,
2908 ) -> str:
2909 resolved_work_id, work_dir = self._resolve_work_id(work_id)
2910 if not resolved_work_id or not work_dir:
2911 return "Error: No active director work. Call start_director first."
2912 state = self._load_state(resolved_work_id)
2913 self._sync_stage_from_state(state)
2914 shots = self._shot_entries(state)
2915 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
2916 payload: dict[str, Any] = {
2917 "work_id": resolved_work_id,
2918 "work_dir": str(work_dir),
2919 "stage": state.get("stage"),
2920 "story_confirmed": bool(state.get("story_confirmed")),
2921 "goal_brief": state.get("goal_brief"),
2922 "goal": state.get("goal", {}),
2923 "final_output_path": state.get("final_output_path"),
2924 "final_output_url": state.get("final_output_url"),
2925 "reference_image_present": reference_image_present(state.get("reference_image")),
2926 "reference_image_locked": is_reference_image_locked(state),
2927 "auto_generate": bool(state.get("auto_generate")),
2928 "auto_generate_shot_count": self._effective_auto_generate_shot_count(goal),
2929 "reference_image_needs_story_rewrite": self._session_reference_needs_rewrite(),
2930 "counts": self._status_counts(state),
2931 "pending_remote_jobs": self._pending_remote_jobs(state),
2932 "story_path": str(self._paths(resolved_work_id)["story"]),
2933 "story_profile_path": str(self._paths(resolved_work_id)["story_profile"]),
2934 "fact_path": str(self._paths(resolved_work_id)["fact"]),
2935 "next_recommended_action": self._next_action(state),
2936 # Only assets with a textual profile are exposed to the agent.
2937 # Binary media stays in the local workspace and is resolved only
2938 # after a human approves the recommendation.
2939 "memory_assets": self._memory_asset_catalog(resolved_work_id),
2940 }
2941 if include_shots:
2942 payload["shots"] = [
2943 {
2944 "shot_id": item.get("shot_id"),
2945 "shot_key": item.get("shot_key"),
2946 "status": item.get("status"),
2947 "summary": item.get("summary"),
2948 "cut": bool(item.get("cut", True)),
2949 "has_shot_spec": bool(item.get("has_shot_spec")),
2950 "has_artifact": bool(item.get("artifact_path") or item.get("artifact_url")),
2951 "artifact_path": item.get("artifact_path"),
2952 "artifact_url": item.get("artifact_url"),
2953 "last_review": item.get("last_review"),
2954 "review_notes": item.get("review_notes") or "",
2955 }
2956 for item in shots[:limit]
2957 ]
2958 if include_jobs:
2959 jobs_dir = self._paths(resolved_work_id)["jobs"]
2960 jobs: list[dict[str, Any]] = []
2961 for path in sorted(jobs_dir.glob("*.json"), reverse=True)[:limit]:
2962 data = self._read_json(path, {})
2963 if isinstance(data, dict):
2964 jobs.append(
2965 {
2966 "job_id": data.get("job_id"),
2967 "kind": data.get("kind"),
2968 "status": data.get("status"),
2969 "target": data.get("target"),
2970 "created_at": data.get("created_at"),
2971 }
2972 )
2973 payload["jobs"] = jobs
2974 return _json_dump(payload)
2975
2976
2977 @tool_parameters(
2978 tool_parameters_schema(
2979 work_id=StringSchema(
2980 "Optional explicit work ID; defaults to the active work", nullable=True
2981 ),
2982 )
2983 )
2984 class GetStoryTool(DirectorTool):
2985 @property
2986 def name(self) -> str:
2987 return "get_story"
2988
2989 async def execute(self, work_id: str | None = None, **kwargs: Any) -> str:
2990 resolved_work_id, _ = self._resolve_work_id(work_id)
2991 if not resolved_work_id:
2992 return "Error: No active director work. Call start_director first."
2993 paths = self._paths(resolved_work_id)
2994 return _json_dump(
2995 {
2996 "work_id": resolved_work_id,
2997 "story_md": paths["story"].read_text(encoding="utf-8"),
2998 "story_profile": self._load_story_profile(resolved_work_id),
2999 }
3000 )
3001
3002
3003 @tool_parameters(
3004 tool_parameters_schema(
3005 topic=StringSchema(
3006 "Guidance topic to load, e.g. 'shot-sequence-patterns' or 'shot-prompt-writer'"
3007 ),
3008 required=["topic"],
3009 )
3010 )
3011 class GetGuidanceTool(DirectorTool):
3012 @property
3013 def name(self) -> str:
3014 return "get_guidance"
3015
3016 async def execute(self, topic: str = "", **kwargs: Any) -> str:
3017 manager = PEManager.instance()
3018 active = manager.active_for_session(self._session_key.get())
3019 path = manager.resolve_reference(topic, name=active)
3020 if path is None:
3021 available = ", ".join(manager.list_references(name=active)) or "(none)"
3022 return prompts.text(
3023 "director.guidance.not_found", topic=topic, available=available
3024 )
3025 return path.read_text(encoding="utf-8")
3026
3027
3028 @tool_parameters(
3029 tool_parameters_schema(
3030 work_id=StringSchema(
3031 "Optional explicit work ID; defaults to the active work", nullable=True
3032 ),
3033 story_md=StringSchema("Story markdown or screenplay text"),
3034 story_profile=ObjectSchema(
3035 description=(
3036 "Structured story profile JSON; use for shot mapping and beat lookup. "
3037 "When provided, must include a non-empty summary and beats "
3038 "(array of {shot_id, summary})."
3039 ),
3040 additional_properties=True,
3041 nullable=True,
3042 ),
3043 confirmed=BooleanSchema(
3044 description=(
3045 "Whether the screenplay is locked for generation. "
3046 "Set to true only after the user explicitly confirms the screenplay in chat."
3047 ),
3048 default=False,
3049 ),
3050 summary=StringSchema("Optional short story summary to cache in state", nullable=True),
3051 required=["story_md"],
3052 )
3053 )
3054 class WriteStoryTool(DirectorTool):
3055 @property
3056 def name(self) -> str:
3057 return "write_story"
3058
3059 async def execute(
3060 self,
3061 story_md: str,
3062 work_id: str | None = None,
3063 story_profile: dict[str, Any] | None = None,
3064 confirmed: bool = False,
3065 summary: str | None = None,
3066 **kwargs: Any,
3067 ) -> str:
3068 resolved_work_id, _ = self._resolve_work_id(work_id)
3069 if not resolved_work_id:
3070 return "Error: No active director work. Call start_director first."
3071 paths = self._paths(resolved_work_id)
3072 if isinstance(story_profile, dict):
3073 profile_error = _story_profile_validation_error(story_profile)
3074 if profile_error:
3075 return profile_error
3076 previous_profile = self._load_story_profile(resolved_work_id)
3077 prepared_profile = dict(story_profile)
3078 _preserve_story_profile_language(prepared_profile, previous_profile)
3079 if "language" not in prepared_profile:
3080 from nanobot.session.generation_settings import get_generation_settings
3081 from nanobot.session.manager import SessionManager
3082
3083 session = SessionManager(self.workspace).get_or_create(self._session_key.get())
3084 metadata = session.metadata if isinstance(session.metadata, dict) else {}
3085 settings = get_generation_settings(metadata)
3086 _apply_story_profile_language(
3087 prepared_profile,
3088 str(settings.get("language") or ""),
3089 )
3090 language_error = _story_profile_language_validation_error(prepared_profile)
3091 if language_error:
3092 return language_error
3093 screenplay_error = _story_md_language_validation_error(story_md, prepared_profile)
3094 if screenplay_error:
3095 return screenplay_error
3096 self._save_story_profile(resolved_work_id, prepared_profile)
3097 story_profile = prepared_profile
3098 else:
3099 screenplay_error = _story_md_language_validation_error(
3100 story_md,
3101 self._load_story_profile(resolved_work_id),
3102 )
3103 if screenplay_error:
3104 return screenplay_error
3105 self._write_text(paths["story"], story_md)
3106 self._clear_reference_image_story_rewrite_flag()
3107 if confirmed:
3108 profile_error = _story_profile_validation_error(
3109 self._load_story_profile(resolved_work_id),
3110 )
3111 if profile_error:
3112 return (
3113 "Error: Cannot confirm story without a valid story_profile. "
3114 "Call write_story with story_profile including a non-empty summary "
3115 "and at least one beat in beats."
3116 )
3117 state = self._load_state(resolved_work_id)
3118 if summary:
3119 state["latest_story_summary"] = summary
3120 elif isinstance(story_profile, dict):
3121 state["latest_story_summary"] = story_profile["summary"].strip()
3122 if confirmed:
3123 state["story_confirmed"] = True
3124 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
3125 try:
3126 if int(goal.get("shot_count") or 0) > 0:
3127 lock_reference_image(state)
3128 self._lock_session_reference_image()
3129 except (TypeError, ValueError):
3130 pass
3131 # Agent has reconciled the user's story edit — clear the pending flag.
3132 state.pop("story_pending_agent_review", None)
3133 self._sync_stage_from_state(state)
3134 self._save_state(resolved_work_id, state)
3135 confirm = self._confirm(resolved_work_id)
3136 return _json_dump(
3137 {
3138 "status": "ok",
3139 "work_id": resolved_work_id,
3140 "story_confirmed": bool(state.get("story_confirmed")),
3141 "stage": state.get("stage"),
3142 "confirmation": confirm,
3143 }
3144 )
3145
3146
3147 @tool_parameters(
3148 tool_parameters_schema(
3149 work_id=StringSchema(
3150 "Optional explicit work ID; defaults to the active work", nullable=True
3151 ),
3152 )
3153 )
3154 class GetFactTool(DirectorTool):
3155 @property
3156 def name(self) -> str:
3157 return "get_fact"
3158
3159 async def execute(self, work_id: str | None = None, **kwargs: Any) -> str:
3160 resolved_work_id, _ = self._resolve_work_id(work_id)
3161 if not resolved_work_id:
3162 return "Error: No active director work. Call start_director first."
3163 return self._paths(resolved_work_id)["fact"].read_text(encoding="utf-8")
3164
3165
3166 @tool_parameters(
3167 tool_parameters_schema(
3168 work_id=StringSchema(
3169 "Optional explicit work ID; defaults to the active work", nullable=True
3170 ),
3171 )
3172 )
3173 class ConfirmFactTool(DirectorTool):
3174 @property
3175 def name(self) -> str:
3176 return "confirm_fact"
3177
3178 async def execute(self, work_id: str | None = None, **kwargs: Any) -> str:
3179 resolved_work_id, _ = self._resolve_work_id(work_id)
3180 if not resolved_work_id:
3181 return "Error: No active director work. Call start_director first."
3182 return _json_dump(self._confirm(resolved_work_id))
3183
3184
3185 @tool_parameters(
3186 tool_parameters_schema(
3187 work_id=StringSchema(
3188 "Optional explicit work ID; defaults to the active work", nullable=True
3189 ),
3190 shot_id=IntegerSchema(description="1-based shot number", minimum=1),
3191 required=["shot_id"],
3192 )
3193 )
3194 class GetShotTool(DirectorTool):
3195 @property
3196 def name(self) -> str:
3197 return "get_shot"
3198
3199 async def execute(self, shot_id: int, work_id: str | None = None, **kwargs: Any) -> str:
3200 resolved_work_id, _ = self._resolve_work_id(work_id)
3201 if not resolved_work_id:
3202 return "Error: No active director work. Call start_director first."
3203 shot = self._load_shot(resolved_work_id, shot_id)
3204 if not shot:
3205 return f"Error: Shot {shot_id} does not exist in work {resolved_work_id}."
3206 return _json_dump(shot)
3207
3208
3209 def build_create_shot_prompt_parameters() -> dict[str, Any]:
3210 """Build create_shot_prompt's JSON schema at call time so PE switches take effect."""
3211 return tool_parameters_schema(
3212 work_id=StringSchema(
3213 "Optional explicit work ID; defaults to the active work", nullable=True
3214 ),
3215 shot_id=IntegerSchema(description="1-based shot number", minimum=1),
3216 cut=BooleanSchema(
3217 description="Whether this shot starts a fresh cut. false means generate as a continuation from the previous shot tail frame."
3218 ),
3219 caption=StringSchema(prompts.text("director.shot_caption.description")),
3220 status=StringSchema(
3221 "Optional explicit shot status",
3222 enum=[
3223 "planned",
3224 "prompt_ready",
3225 "revised_prompt_ready",
3226 "queued",
3227 "generated",
3228 "error",
3229 "review_pass",
3230 "review_fail",
3231 "approved",
3232 ],
3233 nullable=True,
3234 ),
3235 required=["shot_id", "cut", "caption"],
3236 )
3237
3238
3239 class CreateShotPromptTool(DirectorTool):
3240 @property
3241 def name(self) -> str:
3242 return "create_shot_prompt"
3243
3244 @property
3245 def parameters(self) -> dict[str, Any]:
3246 return build_create_shot_prompt_parameters()
3247
3248 async def execute(
3249 self,
3250 shot_id: int,
3251 work_id: str | None = None,
3252 cut: bool = True,
3253 caption: str | None = None,
3254 status: str | None = None,
3255 **kwargs: Any,
3256 ) -> str:
3257 resolved_work_id, _ = self._resolve_work_id(work_id)
3258 if not resolved_work_id:
3259 return "Error: No active director work. Call start_director first."
3260 if not _allow_workflow_operation("create_shot_prompt"):
3261 return _workflow_gate_error("create_shot_prompt")
3262 if not isinstance(caption, str) or not caption.strip():
3263 return "Error: caption is required."
3264 story_profile = self._load_story_profile(resolved_work_id)
3265 language_error = _caption_language_validation_error(caption, story_profile)
3266 if language_error:
3267 return language_error
3268 shot = self._load_shot(resolved_work_id, shot_id)
3269 is_revised_prompt = self._is_revised_prompt_update(shot)
3270 shot["shot_id"] = shot_id
3271 shot["shot_key"] = _shot_key(shot_id)
3272 shot["cut"] = cut
3273 shot["caption"] = caption.strip()
3274 shot.pop("shot_spec", None)
3275 shot.pop("summary", None)
3276 shot["summary"] = self._summary_from_shot(shot)
3277 shot.pop("prompt", None)
3278 shot.pop("negative_prompt", None)
3279 for key in (
3280 "artifact_url",
3281 "artifact_path",
3282 "generation_error",
3283 "last_job_id",
3284 "echo",
3285 "remote_result",
3286 "last_review",
3287 "review_notes",
3288 ):
3289 shot.pop(key, None)
3290 if status is not None:
3291 shot["status"] = status
3292 else:
3293 shot["status"] = "revised_prompt_ready" if is_revised_prompt else "prompt_ready"
3294 state = self._load_state(resolved_work_id)
3295 sync_shot_echo_duration(shot, resolve_echo_duration_seconds(shot, state))
3296 self._save_shot(resolved_work_id, shot_id, shot)
3297
3298 shots = state.setdefault("shots", {})
3299 if not isinstance(shots, dict):
3300 shots = {}
3301 state["shots"] = shots
3302 shots[shot["shot_key"]] = self._state_shot_entry(shot)
3303 self._sync_stage_from_state(state)
3304 self._save_state(resolved_work_id, state)
3305 confirm = self._confirm(resolved_work_id)
3306 return _json_dump(
3307 {
3308 "status": "ok",
3309 "work_id": resolved_work_id,
3310 "shot_id": shot_id,
3311 "shot_key": shot["shot_key"],
3312 "shot_status": shot["status"],
3313 "confirmation": confirm,
3314 }
3315 )
3316
3317
3318 @tool_parameters(
3319 tool_parameters_schema(
3320 work_id=StringSchema(
3321 "Optional explicit work ID; defaults to the active work", nullable=True
3322 ),
3323 shot_id=IntegerSchema(description="1-based shot number", minimum=1),
3324 verdict=StringSchema(
3325 "Review result for the shot",
3326 enum=["accept", "revise"],
3327 ),
3328 review_source=StringSchema(
3329 "Who provided the review result",
3330 enum=["human", "vlm"],
3331 ),
3332 feedback=StringSchema(
3333 "Required when verdict='revise'; concise feedback for the next revision round",
3334 nullable=True,
3335 ),
3336 required=["shot_id", "verdict"],
3337 )
3338 )
3339 class ReviewShotTool(DirectorTool):
3340 @property
3341 def name(self) -> str:
3342 return "review_shot"
3343
3344 async def execute(
3345 self,
3346 shot_id: int,
3347 verdict: str,
3348 work_id: str | None = None,
3349 review_source: str = "human",
3350 feedback: str | None = None,
3351 **kwargs: Any,
3352 ) -> str:
3353 resolved_work_id, _ = self._resolve_work_id(work_id)
3354 if not resolved_work_id:
3355 return "Error: No active director work. Call start_director first."
3356 if not _allow_workflow_operation("review_shot"):
3357 return _workflow_gate_error("review_shot")
3358 try:
3359 shot, state = self._apply_shot_review(
3360 resolved_work_id,
3361 shot_id,
3362 verdict=verdict,
3363 review_source=review_source,
3364 feedback=feedback,
3365 )
3366 except ValueError as exc:
3367 return f"Error: {exc}"
3368 return _json_dump(
3369 {
3370 "status": "ok",
3371 "work_id": resolved_work_id,
3372 "shot_id": shot_id,
3373 "shot_status": shot.get("status"),
3374 "stage": state.get("stage"),
3375 "review_notes": shot.get("review_notes") or "",
3376 "last_review": shot.get("last_review"),
3377 }
3378 )
3379
3380
3381 @tool_parameters(
3382 tool_parameters_schema(
3383 work_id=StringSchema(
3384 "Optional explicit work ID; defaults to the active work", nullable=True
3385 ),
3386 shot_id=IntegerSchema(description="1-based shot number", minimum=1),
3387 reference_shot_ids=ArraySchema(
3388 IntegerSchema(
3389 description="Earlier logical shot ID used as visual reference", minimum=1
3390 ),
3391 description="Logical prior shot IDs selected as Echo references for this shot.",
3392 ),
3393 selection_note=StringSchema(
3394 "Optional short note explaining why these references were selected",
3395 nullable=True,
3396 ),
3397 required=["shot_id", "reference_shot_ids"],
3398 )
3399 )
3400 class SetShotReferencesTool(DirectorTool):
3401 @property
3402 def name(self) -> str:
3403 return "set_shot_references"
3404
3405 def apply_set_references(
3406 self,
3407 work_id: str,
3408 shot_id: int,
3409 reference_shot_ids: list[int],
3410 selection_note: str | None = None,
3411 ) -> dict[str, Any]:
3412 shot = self._load_shot(work_id, shot_id)
3413 if not shot:
3414 raise ValueError(f"Shot {shot_id} does not exist in work {work_id}.")
3415 if not isinstance(shot.get("caption"), str) or not shot.get("caption", "").strip():
3416 raise ValueError(f"Shot {shot_id} has no caption yet. Call create_shot_prompt first.")
3417 normalized = self._normalize_reference_shot_ids(
3418 shot_id,
3419 reference_shot_ids,
3420 cut=bool(shot.get("cut", True)),
3421 )
3422 shot["planned_reference_shot_ids"] = normalized
3423 if isinstance(selection_note, str) and selection_note.strip():
3424 shot["reference_selection_note"] = selection_note.strip()
3425 self._save_shot(work_id, shot_id, shot)
3426 state = self._load_state(work_id)
3427 shots = state.setdefault("shots", {})
3428 if isinstance(shots, dict):
3429 shots[_shot_key(shot_id)] = self._state_shot_entry(shot)
3430 self._save_state(work_id, state)
3431 return shot
3432
3433 async def execute(
3434 self,
3435 shot_id: int,
3436 reference_shot_ids: list[int],
3437 work_id: str | None = None,
3438 selection_note: str | None = None,
3439 **kwargs: Any,
3440 ) -> str:
3441 resolved_work_id, _ = self._resolve_work_id(work_id)
3442 if not resolved_work_id:
3443 return "Error: No active director work. Call start_director first."
3444 if not _allow_workflow_operation("set_shot_references"):
3445 return _workflow_gate_error("set_shot_references")
3446 try:
3447 shot = self.apply_set_references(
3448 resolved_work_id,
3449 shot_id,
3450 reference_shot_ids,
3451 selection_note=selection_note,
3452 )
3453 except ValueError as exc:
3454 return f"Error: {exc}"
3455 return _json_dump(
3456 {
3457 "status": "ok",
3458 "work_id": resolved_work_id,
3459 "shot_id": shot_id,
3460 "planned_reference_shot_ids": shot.get("planned_reference_shot_ids") or [],
3461 "reference_selection_note": shot.get("reference_selection_note") or "",
3462 }
3463 )
3464
3465
3466 @tool_parameters(
3467 tool_parameters_schema(
3468 work_id=StringSchema(
3469 "Optional explicit work ID; defaults to the active work", nullable=True
3470 ),
3471 shot_id=IntegerSchema(description="Target 1-based shot number", minimum=1),
3472 recommendations=ArraySchema(
3473 ObjectSchema(
3474 image_asset_id=StringSchema(
3475 "Profile-bearing Memory Workspace asset used for the slot image"
3476 ),
3477 audio_asset_id=StringSchema(
3478 "Optional profile-bearing asset used for slot audio", nullable=True
3479 ),
3480 reason=StringSchema("Short reason this slot helps the target shot"),
3481 required=["image_asset_id", "reason"],
3482 additional_properties=False,
3483 ),
3484 description="Ordered recommendation draft; zero to seven slots.",
3485 max_items=7,
3486 ),
3487 required=["shot_id", "recommendations"],
3488 )
3489 )
3490 class SetShotMemoryRecommendationsTool(DirectorTool):
3491 """Let the agent propose slots without granting generation approval."""
3492
3493 @property
3494 def name(self) -> str:
3495 return "set_shot_memory_recommendations"
3496
3497 async def execute(
3498 self,
3499 shot_id: int,
3500 recommendations: list[dict[str, Any]],
3501 work_id: str | None = None,
3502 **kwargs: Any,
3503 ) -> str:
3504 resolved_work_id, _ = self._resolve_work_id(work_id)
3505 if not resolved_work_id:
3506 return "Error: No active director work. Call start_director first."
3507 if not _allow_workflow_operation("set_shot_memory_recommendations"):
3508 return _workflow_gate_error("set_shot_memory_recommendations")
3509 if len(recommendations) > 7:
3510 return "Error: recommendations cannot exceed 7 slots."
3511 shot = self._load_shot(resolved_work_id, shot_id)
3512 if not shot:
3513 return f"Error: Shot {shot_id} does not exist in work {resolved_work_id}."
3514 catalog = {
3515 item["asset_id"]: item for item in self._memory_asset_catalog(resolved_work_id)
3516 }
3517 normalized: list[dict[str, Any]] = []
3518 seen_images: set[str] = set()
3519 for raw in recommendations:
3520 if not isinstance(raw, dict):
3521 return "Error: each recommendation must be an object."
3522 image_id = str(raw.get("image_asset_id") or "").strip()
3523 audio_id = str(raw.get("audio_asset_id") or "").strip() or None
3524 reason = str(raw.get("reason") or "").strip()
3525 if not image_id or image_id not in catalog:
3526 return f"Error: unknown or unprofiled image asset '{image_id}'."
3527 if catalog[image_id].get("media_type") == "audio":
3528 return f"Error: asset '{image_id}' has no image."
3529 if image_id in seen_images:
3530 return f"Error: duplicate image asset '{image_id}'."
3531 if audio_id is not None and audio_id not in catalog:
3532 return f"Error: unknown or unprofiled audio asset '{audio_id}'."
3533 if audio_id is not None and catalog[audio_id].get("media_type") == "image":
3534 return f"Error: asset '{audio_id}' has no audio."
3535 if not reason:
3536 return "Error: every recommendation needs a reason."
3537 seen_images.add(image_id)
3538 normalized.append({
3539 "image_asset_id": image_id,
3540 **({"audio_asset_id": audio_id} if audio_id else {}),
3541 "reason": reason[:500],
3542 })
3543 shot["recommended_memory_slot_refs"] = normalized
3544 shot["memory_recommendation_source"] = "agent"
3545 shot["memory_recommendation_updated_at"] = _now_iso()
3546 self._save_shot(resolved_work_id, shot_id, shot)
3547 return _json_dump({
3548 "status": "ok",
3549 "work_id": resolved_work_id,
3550 "shot_id": shot_id,
3551 "recommended_memory_slot_refs": normalized,
3552 "approval": "pending_human",
3553 })
3554
3555
3556 @tool_parameters(
3557 tool_parameters_schema(
3558 work_id=StringSchema(
3559 "Optional explicit work ID; defaults to the active work", nullable=True
3560 ),
3561 shot_id=IntegerSchema(description="1-based shot number", minimum=1),
3562 reference_shot_ids=ArraySchema(
3563 IntegerSchema(
3564 description="Earlier logical shot ID used as visual reference", minimum=1
3565 ),
3566 description=(
3567 "Logical prior shot IDs the agent selected as Echo references. "
3568 "Almost every shot MUST reference at least one earlier shot for visual continuity — "
3569 "an empty list is allowed ONLY for shot_id=1, or when the shot introduces a completely new scene "
3570 "with entirely new characters that have zero visual overlap with any previous shot. "
3571 "When in doubt, include at least the most recent shot that shares a character, environment, or prop."
3572 ),
3573 ),
3574 selection_note=StringSchema(
3575 "Optional short note explaining why these references were selected",
3576 nullable=True,
3577 ),
3578 required=["shot_id", "reference_shot_ids"],
3579 )
3580 )
3581 class GenerateEchoShotTool(DirectorTool):
3582 @property
3583 def name(self) -> str:
3584 return "generate_echo_shot"
3585
3586 def apply_generate(
3587 self,
3588 work_id: str,
3589 shot_id: int,
3590 reference_shot_ids: list[int],
3591 selection_note: str | None = None,
3592 condition_image_url: str | None = None,
3593 i2v_prompt: str | None = None,
3594 ) -> dict[str, Any]:
3595 state = self._load_state(work_id)
3596 existing_shot = self._load_shot(work_id, shot_id)
3597 if (
3598 shot_id > 1
3599 and str(state.get("stage") or "") == "awaiting_memory_build"
3600 and not existing_shot.get("memory_slots_user_configured")
3601 and not state.get("auto_generate")
3602 ):
3603 raise ValueError(
3604 "Build Memory must be reviewed and applied before generating this shot."
3605 )
3606 caption = existing_shot.get("caption")
3607 # Skip language validation when an I2V prompt is supplied — the prompt
3608 # has already been rewritten by rewrite_prompt_for_i2v with the correct
3609 # first-frame contract sentence, and the underlying caption may contain
3610 # technical tokens from PE re-captioning that the validator flags.
3611 if isinstance(caption, str) and not i2v_prompt:
3612 language_error = _caption_language_validation_error(
3613 caption,
3614 self._load_story_profile(work_id),
3615 )
3616 if language_error:
3617 raise ValueError(language_error.removeprefix("Error: "))
3618
3619 duration_value = resolve_echo_duration_seconds(existing_shot, state)
3620 num_frames = sync_shot_echo_duration(existing_shot, duration_value)
3621 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
3622 width = goal.get("width")
3623 height = goal.get("height")
3624 prompt_text = i2v_prompt.strip() if i2v_prompt else caption.strip()
3625
3626 # Normalize references early so invalid refs fail before submission.
3627 normalized_reference_ids = self._normalize_reference_shot_ids(
3628 shot_id, reference_shot_ids, cut=bool(existing_shot.get("cut", True))
3629 )
3630
3631 memory_slots = self._build_memory_slots(
3632 existing_shot.get("approved_memory_slots"), normalized_reference_ids,
3633 work_id=work_id,
3634 )
3635
3636 request_payload = self._build_r2v_payload(
3637 work_id,
3638 shot_id,
3639 prompt=prompt_text,
3640 num_frames=num_frames,
3641 width=int(width) if width is not None else None,
3642 height=int(height) if height is not None else None,
3643 condition_image_url=condition_image_url,
3644 memory_slots=memory_slots,
3645 )
3646
3647 state["stage"] = "shot_generating"
3648 job_id = _job_id("echo", work_id, _shot_key(shot_id))
3649 try:
3650 job = self._submit_r2v_request(
3651 work_id,
3652 job_id,
3653 request_payload,
3654 target=_shot_key(shot_id),
3655 )
3656 except EchoGeneratorBusyError:
3657 raise
3658 except EchoGeneratorUnavailableError:
3659 raise
3660 except RuntimeError as exc:
3661 raise ValueError(str(exc)) from exc
3662
3663 self._write_json(self._job_path(work_id, job_id), job)
3664 self._clear_pending_remote_jobs_for_target(state, "generate_echo_shot", _shot_key(shot_id))
3665 self._register_pending_remote_job(state, job)
3666
3667 existing_shot["status"] = "queued"
3668 existing_shot.pop("generation_error", None)
3669 existing_shot["last_job_id"] = job_id
3670 existing_shot["reference_shot_ids"] = normalized_reference_ids
3671 if selection_note is not None:
3672 existing_shot["reference_selection_note"] = selection_note
3673 echo = existing_shot.get("echo")
3674 if not isinstance(echo, dict):
3675 echo = {}
3676 existing_shot["echo"] = echo
3677 echo.update(
3678 {
3679 "status": "queued",
3680 "reference_shot_ids": normalized_reference_ids,
3681 "selection_note": selection_note,
3682 "request_payload_path": job.get("request_payload_path"),
3683 "request_envelope_path": job.get("request_envelope_path"),
3684 "remote_task_id": (
3685 job.get("remote", {}).get("remote_task_id")
3686 if isinstance(job.get("remote"), dict)
3687 else None
3688 ),
3689 "version_id": (
3690 job.get("remote", {}).get("version_id")
3691 if isinstance(job.get("remote"), dict)
3692 else None
3693 ),
3694 }
3695 )
3696 self._save_shot(work_id, shot_id, existing_shot)
3697
3698 shots = state.setdefault("shots", {})
3699 if isinstance(shots, dict):
3700 shots[_shot_key(shot_id)] = self._state_shot_entry(existing_shot)
3701 self._save_state(work_id, state)
3702 self._refresh_fact(work_id, state)
3703 return job
3704
3705 def apply_generate_continuous(
3706 self,
3707 work_id: str,
3708 shot_id: int,
3709 condition_image_url: str,
3710 reference_shot_ids: list[int],
3711 selection_note: str | None = None,
3712 i2v_prompt: str | None = None,
3713 ) -> dict[str, Any]:
3714 """Submit a shot for I2V generation using the previous shot's tail frame as condition image.
3715
3716 Rewrites the current shot's prompt for I2V format, then submits to the Echo
3717 backend with ``condition_image_url`` pointing to the tail-frame image.
3718 """
3719 state = self._load_state(work_id)
3720 existing_shot = self._load_shot(work_id, shot_id)
3721 if (
3722 shot_id > 1
3723 and str(state.get("stage") or "") == "awaiting_memory_build"
3724 and not existing_shot.get("memory_slots_user_configured")
3725 and not state.get("auto_generate")
3726 ):
3727 raise ValueError(
3728 "Build Memory must be reviewed and applied before generating this shot."
3729 )
3730 caption = existing_shot.get("caption")
3731 if not isinstance(caption, str) or not caption.strip():
3732 raise ValueError(f"Shot {shot_id} has no caption yet. Call create_shot_prompt first.")
3733
3734 # Defensive caption-language check (mirrors apply_generate).
3735 language_error = _caption_language_validation_error(
3736 caption,
3737 self._load_story_profile(work_id),
3738 )
3739 if language_error:
3740 raise ValueError(language_error.removeprefix("Error: "))
3741
3742 story_profile = self._load_story_profile(work_id)
3743 caption_language = str(
3744 story_profile.get("caption_language")
3745 or story_profile.get("language")
3746 or ""
3747 )
3748
3749 # The WebSocket continuation path performs the multimodal rewrite
3750 # (ordinary PE + I2V skill + the extracted tail frame). Keep the
3751 # deterministic helper as a fallback for existing internal callers.
3752 rewritten_prompt = (
3753 i2v_prompt.strip()
3754 if isinstance(i2v_prompt, str) and i2v_prompt.strip()
3755 else rewrite_prompt_for_i2v(caption.strip(), caption_language)
3756 )
3757
3758 # Persist the I2V prompt on the shot record that will be saved.
3759 existing_shot["i2v_prompt"] = rewritten_prompt
3760
3761 duration_value = resolve_echo_duration_seconds(existing_shot, state)
3762 num_frames = sync_shot_echo_duration(existing_shot, duration_value)
3763 goal = state.get("goal") if isinstance(state.get("goal"), dict) else {}
3764 width = goal.get("width")
3765 height = goal.get("height")
3766
3767 # Normalize references early so invalid refs fail before submission.
3768 normalized_reference_ids = self._normalize_reference_shot_ids(
3769 shot_id, reference_shot_ids, cut=bool(existing_shot.get("cut", True))
3770 )
3771
3772 memory_slots = self._build_memory_slots(
3773 existing_shot.get("approved_memory_slots"), normalized_reference_ids,
3774 work_id=work_id,
3775 )
3776
3777 request_payload = self._build_r2v_payload(
3778 work_id,
3779 shot_id,
3780 prompt=rewritten_prompt,
3781 num_frames=num_frames,
3782 width=int(width) if width is not None else None,
3783 height=int(height) if height is not None else None,
3784 condition_image_url=condition_image_url,
3785 memory_slots=memory_slots,
3786 )
3787
3788 state["stage"] = "shot_generating"
3789 job_id = _job_id("echo", work_id, _shot_key(shot_id))
3790 try:
3791 job = self._submit_r2v_request(
3792 work_id,
3793 job_id,
3794 request_payload,
3795 target=_shot_key(shot_id),
3796 )
3797 except EchoGeneratorBusyError:
3798 raise
3799 except EchoGeneratorUnavailableError:
3800 raise
3801 except RuntimeError as exc:
3802 raise ValueError(str(exc)) from exc
3803
3804 self._write_json(self._job_path(work_id, job_id), job)
3805 self._clear_pending_remote_jobs_for_target(state, "generate_echo_shot", _shot_key(shot_id))
3806 self._register_pending_remote_job(state, job)
3807
3808 existing_shot["status"] = "queued"
3809 existing_shot.pop("generation_error", None)
3810 existing_shot["last_job_id"] = job_id
3811 existing_shot["reference_shot_ids"] = normalized_reference_ids
3812 if selection_note is not None:
3813 existing_shot["reference_selection_note"] = selection_note
3814 echo = existing_shot.get("echo")
3815 if not isinstance(echo, dict):
3816 echo = {}
3817 existing_shot["echo"] = echo
3818 echo.update(
3819 {
3820 "status": "queued",
3821 "reference_shot_ids": normalized_reference_ids,
3822 "selection_note": selection_note,
3823 "request_payload_path": job.get("request_payload_path"),
3824 "request_envelope_path": job.get("request_envelope_path"),
3825 "remote_task_id": (
3826 job.get("remote", {}).get("remote_task_id")
3827 if isinstance(job.get("remote"), dict)
3828 else None
3829 ),
3830 "version_id": (
3831 job.get("remote", {}).get("version_id")
3832 if isinstance(job.get("remote"), dict)
3833 else None
3834 ),
3835 }
3836 )
3837 self._save_shot(work_id, shot_id, existing_shot)
3838
3839 shots = state.setdefault("shots", {})
3840 if isinstance(shots, dict):
3841 shots[_shot_key(shot_id)] = self._state_shot_entry(existing_shot)
3842 self._save_state(work_id, state)
3843 self._refresh_fact(work_id, state)
3844 return job
3845
3846 async def execute(
3847 self,
3848 shot_id: int,
3849 reference_shot_ids: list[int],
3850 work_id: str | None = None,
3851 selection_note: str | None = None,
3852 **kwargs: Any,
3853 ) -> str:
3854 resolved_work_id, _ = self._resolve_work_id(work_id)
3855 if not resolved_work_id:
3856 return "Error: No active director work. Call start_director first."
3857 if not _allow_workflow_operation("generate_echo_shot"):
3858 return _workflow_gate_error("generate_echo_shot")
3859
3860 # 检查是否开启首尾衔接 → I2V 生成
3861 shot = self._load_shot(resolved_work_id, shot_id)
3862 use_continuous = (
3863 bool(shot.get("continuous_enabled"))
3864 and shot_id > 1
3865 and not bool(self._load_state(resolved_work_id).get("auto_generate"))
3866 )
3867
3868 try:
3869 if use_continuous:
3870 previous_shot_id = shot_id - 1
3871 prev_shot = self._load_shot(resolved_work_id, previous_shot_id)
3872 video_url = prev_shot.get("artifact_url") or (
3873 prev_shot.get("echo") or {}
3874 ).get("result_url")
3875 if not video_url:
3876 return (
3877 f"Error: previous shot {previous_shot_id} has no video "
3878 f"artifact; cannot extract tail frame for continuous generation"
3879 )
3880 logger.info(
3881 "agent continuous-generate: shot_id={} using previous shot {} "
3882 "tail frame, extracting from video_url={}",
3883 shot_id, previous_shot_id, video_url,
3884 )
3885 condition_image_url = await asyncio.to_thread(
3886 self._extract_and_publish_tail_frame,
3887 resolved_work_id,
3888 previous_shot_id,
3889 video_url,
3890 )
3891 if not condition_image_url:
3892 return (
3893 f"Error: failed to extract tail frame from shot "
3894 f"{previous_shot_id}"
3895 )
3896 logger.info(
3897 "agent continuous-generate: tail frame ready, "
3898 "shot_id={} condition_image_url={}",
3899 shot_id, condition_image_url,
3900 )
3901 # 确保 previous_shot_id 在 reference_shot_ids 中
3902 if previous_shot_id not in reference_shot_ids:
3903 reference_shot_ids = sorted(
3904 set(reference_shot_ids) | {previous_shot_id}
3905 )
3906 job = await asyncio.to_thread(
3907 self.apply_generate_continuous,
3908 resolved_work_id,
3909 shot_id,
3910 condition_image_url,
3911 reference_shot_ids,
3912 selection_note=selection_note,
3913 )
3914 else:
3915 first_frame_url = None
3916 if shot_id == 1:
3917 first_frame_url = self._state_first_frame_url(
3918 self._load_state(resolved_work_id)
3919 )
3920 if first_frame_url:
3921 logger.info(
3922 "agent generate_echo_shot: shot_id=1 using state.reference_image "
3923 "url={}",
3924 first_frame_url,
3925 )
3926 profile = self._load_story_profile(resolved_work_id)
3927 language = ""
3928 if isinstance(profile, dict):
3929 language = str(
3930 profile.get("caption_language") or profile.get("language") or ""
3931 )
3932 caption = str(shot.get("caption") or "").strip()
3933 i2v_prompt = (
3934 rewrite_prompt_for_i2v(caption, language) if caption else None
3935 )
3936 job = await asyncio.to_thread(
3937 self.apply_generate,
3938 resolved_work_id,
3939 shot_id,
3940 reference_shot_ids,
3941 selection_note=selection_note,
3942 condition_image_url=first_frame_url,
3943 i2v_prompt=i2v_prompt,
3944 )
3945 else:
3946 job = await asyncio.to_thread(
3947 self.apply_generate,
3948 resolved_work_id,
3949 shot_id,
3950 reference_shot_ids,
3951 selection_note=selection_note,
3952 )
3953 except EchoGeneratorBusyError as exc:
3954 return f"Error: {exc}"
3955 except EchoGeneratorUnavailableError as exc:
3956 return f"Error: {exc}"
3957 except ValueError as exc:
3958 return f"Error: {exc}"
3959 return _json_dump(job)
3960
3961
3962 @tool_parameters(
3963 tool_parameters_schema(
3964 work_id=StringSchema(
3965 "Optional explicit work ID; defaults to the active work", nullable=True
3966 ),
3967 shot_ids=ArraySchema(
3968 IntegerSchema(description="1-based shot number"),
3969 description="Optional explicit shot list; defaults to all known shots in order",
3970 nullable=True,
3971 ),
3972 )
3973 )
3974 class MergeShotTool(DirectorTool):
3975 @property
3976 def name(self) -> str:
3977 return "merge_shot"
3978
3979 async def execute(
3980 self,
3981 work_id: str | None = None,
3982 shot_ids: list[int] | None = None,
3983 **kwargs: Any,
3984 ) -> str:
3985 try:
3986 job = await asyncio.to_thread(
3987 self.apply_merge,
3988 work_id=work_id,
3989 shot_ids=shot_ids,
3990 )
3991 except (RuntimeError, ValueError) as exc:
3992 return f"Error: {exc}"
3993 return _json_dump(job)
3994
3995 def apply_merge(
3996 self,
3997 work_id: str | None = None,
3998 shot_ids: list[int] | None = None,
3999 ) -> dict[str, Any]:
4000 resolved_work_id, _ = self._resolve_work_id(work_id)
4001 if not resolved_work_id:
4002 raise ValueError("No active director work. Call start_director first.")
4003 if not _allow_workflow_operation("merge_shot"):
4004 raise ValueError(_workflow_gate_error("merge_shot"))
4005 state = self._load_state(resolved_work_id)
4006 state["stage"] = "merging"
4007 state.pop("generation_error", None)
4008 available = self._shot_entries(state)
4009 if not available:
4010 raise ValueError("No shots exist yet. Create shot prompts first.")
4011 selected_ids = shot_ids or [int(item["shot_id"]) for item in available]
4012 selected_shots = []
4013 for shot_id in selected_ids:
4014 shot = self._load_shot(resolved_work_id, shot_id)
4015 if not shot:
4016 raise ValueError(f"Shot {shot_id} does not exist in work {resolved_work_id}.")
4017 selected_shots.append(shot)
4018 job_id = _job_id("merge", resolved_work_id, "final")
4019 payload = self._build_merge_payload(
4020 resolved_work_id,
4021 selected_ids,
4022 selected_shots,
4023 )
4024 job = self._submit_remote_request(
4025 resolved_work_id,
4026 job_id,
4027 payload,
4028 target="final",
4029 operation="merge_shot",
4030 )
4031 self._write_json(self._job_path(resolved_work_id, job_id), job)
4032 self._clear_pending_remote_jobs_for_target(state, "merge_shot", "final")
4033 self._register_pending_remote_job(state, job)
4034 state.pop("merge_confirmation_requested_at", None)
4035 state["latest_merge_job_id"] = job_id
4036 self._sync_stage_from_state(state)
4037 self._save_state(resolved_work_id, state)
4038 self._refresh_fact(resolved_work_id, state)
4039 return job
4040
4041
4042 @tool_parameters(
4043 tool_parameters_schema(
4044 job_id=StringSchema("Director job ID to inspect"),
4045 work_id=StringSchema(
4046 "Optional explicit work ID; defaults to the active work or a repo-wide search",
4047 nullable=True,
4048 ),
4049 required=["job_id"],
4050 )
4051 )
4052 class GetDirectorJobTool(DirectorTool):
4053 @property
4054 def name(self) -> str:
4055 return "get_director_job"
4056
4057 async def execute(self, job_id: str, work_id: str | None = None, **kwargs: Any) -> str:
4058 candidates: list[Path] = []
4059 resolved_work_id, _ = self._resolve_work_id(work_id)
4060 if resolved_work_id:
4061 candidates.append(self._job_path(resolved_work_id, job_id))
4062 else:
4063 self._ensure_root()
4064 for jobs_dir in self.works_root.glob("*/jobs"):
4065 candidates.append(jobs_dir / f"{job_id}.json")
4066 for path in candidates:
4067 if path.exists():
4068 data = self._read_json(path, {})
4069 if isinstance(data, dict):
4070 return _json_dump(data)
4071 return f"Error: Director job '{job_id}' was not found."
4072
4073
4074 def apply_echo_generate_shot_callback(
4075 workspace: Path,
4076 callback_payload: dict[str, Any],
4077 *,
4078 tools_config: Any | None = None,
4079 ) -> dict[str, Any]:
4080 """Apply one generate_echo_shot remote callback to the director workspace.
4081
4082 Expected callback payload shape:
4083 - required: `work_id`, `job_id`
4084 - optional: `status` (`completed` or `failed`, defaults to `completed`)
4085 - completed result:
4086 - either top-level `shot_id` + a public `asset_urls` entry (preferred) or `result_url`
4087 - or `result={"shot_id": 8, "asset_urls": {"primary": {"url": "https://..."}}}` / `result_url`
4088 - injection routing:
4089 - `session_key`, `channel`, `chat_id`
4090 """
4091 tool = GenerateEchoShotTool(workspace=workspace, tools_config=tools_config)
4092 return tool.apply_echo_callback_payload(callback_payload)
4093
4094
4095 def apply_merge_shot_callback(
4096 workspace: Path,
4097 callback_payload: dict[str, Any],
4098 *,
4099 tools_config: Any | None = None,
4100 ) -> dict[str, Any]:
4101 """Apply one merge_shot remote callback to the director workspace.
4102
4103 Expected callback payload shape:
4104 - required: `work_id`, `job_id`
4105 - optional: `status` (`completed` or `failed`, defaults to `completed`)
4106 - completed result:
4107 - preferred: the first public URL in top-level or `result.asset_urls`
4108 - fallback: `artifact_path`, `artifact_url`, `result_url`, or `result={...}` equivalents
4109 - injection routing:
4110 - `session_key`, `channel`, `chat_id`
4111 """
4112 tool = MergeShotTool(workspace=workspace, tools_config=tools_config)
4113 return tool.apply_merge_callback_payload(callback_payload)
4114
4114 lines PYTHON