返回 VideoClaw
standard.py
1 import json
2 import logging
3 import os
4 import re
5 from typing import Optional
6
7 from models.llm_client import LLM
8 from prompts.loader import format_prompt, load_prompt
9
10 from .api_media import generate_image_api, generate_video_api
11 from .storage import append_artifact, task_output_dir, update_task
12 from .tts import generate_edge_tts
13 from .utils import (
14 artifact,
15 concat_videos,
16 create_static_image_clip,
17 media_duration_seconds,
18 render_static_text_image,
19 render_template_media_video,
20 render_template_text_image,
21 replace_video_audio,
22 run_blocking,
23 template_media_spec,
24 write_json,
25 write_text,
26 )
27
28 DEFAULT_STYLE_CONTROL = (
29 "Minimalist black-and-white matchstick figure style illustration, clean lines, simple sketch style"
30 )
31
32 logger = logging.getLogger(__name__)
33
34
35 def required_param(params: dict, key: str) -> str:
36 value = params.get(key)
37 if not value:
38 raise ValueError(f"standard pipeline requires {key}")
39 return str(value)
40
41
42 def split_by_periods(text: str) -> list[str]:
43 parts = re.findall(r"[^。..]+[。..]?|[^。..]+$", text.strip())
44 return [part.strip() for part in parts if part.strip()]
45
46
47 def clamp_segment_count(value, default: int = 6) -> int:
48 try:
49 count = int(value)
50 except (TypeError, ValueError):
51 count = default
52 return max(1, min(20, count))
53
54
55 def parse_json_object_response(text: str) -> dict:
56 response = text.strip()
57 try:
58 return json.loads(response)
59 except json.JSONDecodeError:
60 match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.S)
61 if not match:
62 match = re.search(r"(\{.*\})", response, re.S)
63 if not match:
64 raise ValueError("Model response did not contain a JSON object.")
65 return json.loads(match.group(1))
66
67
68 def parse_narrations_response(text: str, expected_count: int) -> list[str]:
69 data = parse_json_object_response(text)
70 narrations = data.get("narrations") if isinstance(data, dict) else None
71 if not isinstance(narrations, list):
72 raise ValueError("Model response missing narrations array.")
73
74 segments = [str(item).strip() for item in narrations if str(item).strip()]
75 if len(segments) != expected_count:
76 raise ValueError(f"Expected {expected_count} narrations, got {len(segments)}.")
77 return segments
78
79
80 def parse_image_prompts_response(text: str, expected_count: int) -> list[str]:
81 data = parse_json_object_response(text)
82
83 prompts = data.get("image_prompts") if isinstance(data, dict) else None
84 if not isinstance(prompts, list):
85 raise ValueError("Model response missing image_prompts array.")
86
87 image_prompts = [str(prompt).strip() for prompt in prompts if str(prompt).strip()]
88 if len(image_prompts) != expected_count:
89 raise ValueError(
90 f"Expected {expected_count} image prompts, got {len(image_prompts)}."
91 )
92 return image_prompts
93
94
95 def build_image_prompt(
96 visual_prompt: str,
97 style_control: str,
98 *,
99 subtitle: Optional[str] = None,
100 render_subtitle_in_image: bool = False,
101 ) -> str:
102 style = style_control.strip()
103 no_text_instruction = (
104 "Do not include any text, captions, logos, watermarks, labels, typography, "
105 "or written characters in the image."
106 )
107 text_in_pic_instruction = (
108 "Render exactly one subtitle directly inside the image: "
109 f"\"{subtitle or ''}\". Place this subtitle in a visually appropriate area "
110 "that does not cover the main subject, using readable typography that matches "
111 "the image style, strong contrast, balanced spacing, and clean composition. "
112 "Copy the subtitle text exactly, preserving language, characters, and punctuation. "
113 "Do not add a title. Do not add logos, watermarks, labels, or any other text."
114 )
115 text_instruction = text_in_pic_instruction if render_subtitle_in_image else no_text_instruction
116 if not style:
117 return f"{text_instruction}\n{visual_prompt}"
118 return f"{style}\n{text_instruction}\n{visual_prompt}"
119
120
121 async def generate_image_prompts(
122 narrations: list[str],
123 style_control: str,
124 llm: LLM,
125 llm_model: str,
126 *,
127 render_subtitle_in_image: bool = False,
128 ) -> list[str]:
129 template = load_prompt("pipelines", "standard_image_prompt_generation", "en")
130 prompt = format_prompt(
131 template,
132 narrations_count=len(narrations),
133 narrations_json=json.dumps({"narrations": narrations}, ensure_ascii=False, indent=2),
134 )
135 response = await run_blocking(llm.query, prompt, model=llm_model)
136 visual_prompts = parse_image_prompts_response(response, len(narrations))
137 return [
138 build_image_prompt(
139 visual_prompt,
140 style_control,
141 subtitle=narrations[idx],
142 render_subtitle_in_image=render_subtitle_in_image,
143 )
144 for idx, visual_prompt in enumerate(visual_prompts)
145 ]
146
147
148 async def generate_narrations_from_inspiration(
149 inspiration: str,
150 segment_count: int,
151 llm: LLM,
152 llm_model: str,
153 ) -> list[str]:
154 template = load_prompt("pipelines", "standard_narration_generation", "zh")
155 prompt = format_prompt(
156 template,
157 inspiration=inspiration,
158 segment_count=segment_count,
159 )
160 response = await run_blocking(llm.query, prompt, model=llm_model)
161 return parse_narrations_response(response, segment_count)
162
163
164 async def run(task_id: str, params: dict) -> tuple[dict, list[dict]]:
165 output_dir = task_output_dir(task_id)
166 os.makedirs(output_dir, exist_ok=True)
167
168 text = params.get("text") or params.get("topic") or ""
169 if not text.strip():
170 raise ValueError("standard pipeline requires narration text")
171
172 mode = params.get("mode") or "copy"
173 source_text = text.strip()
174 llm = None
175 llm_model = required_param(params, "llm_model")
176 if mode == "inspiration":
177 segment_count = clamp_segment_count(params.get("segment_count"))
178 update_task(task_id, progress=6, message="Writing narration from inspiration")
179 llm = LLM()
180 narrations = await generate_narrations_from_inspiration(
181 source_text,
182 segment_count,
183 llm,
184 llm_model,
185 )
186 source_text = "\n".join(narrations)
187 else:
188 narrations = split_by_periods(source_text)
189
190 if not narrations:
191 raise RuntimeError("No narration segments were generated.")
192
193 title = (params.get("title") or "").strip()
194 if not title:
195 update_task(task_id, progress=8, message="Generating title")
196 if llm is None:
197 llm = LLM()
198 title = await run_blocking(
199 llm.query,
200 f"为下面的文艺短视频旁白生成一个简短中文标题,只输出标题:\n{source_text}",
201 model=llm_model,
202 )
203 title = title.strip().splitlines()[0]
204
205 style_control = (params.get("style_control") or params.get("negative_prompt") or DEFAULT_STYLE_CONTROL).strip()
206 video_ratio = params.get("video_ratio") or "9:16"
207 image_model = params.get("image_model") or params.get("image_workflow")
208 if not image_model:
209 raise ValueError("standard pipeline requires image_model")
210 image_resolution = params.get("image_resolution") or "1080P"
211 enable_subtitles = bool(params.get("enable_subtitles", False))
212 subtitle_render_mode = params.get("subtitle_render_mode") or "postprocess"
213 if subtitle_render_mode not in {"postprocess", "image_model"}:
214 subtitle_render_mode = "postprocess"
215 render_subtitle_in_image = (
216 enable_subtitles
217 and subtitle_render_mode == "image_model"
218 and not params.get("subtitle_template")
219 )
220 subtitle_template = params.get("subtitle_template")
221 subtitle_template_fields = params.get("subtitle_template_fields") or {}
222 template_media = template_media_spec(subtitle_template, video_ratio) if subtitle_template else None
223 template_media_kind = params.get("template_media_kind") or "image"
224 if template_media_kind not in {"image", "video"}:
225 template_media_kind = "image"
226 template_video_mode = bool(subtitle_template and template_media_kind == "video")
227 if template_video_mode and template_media and not template_media.get("supports_video"):
228 raise ValueError(f"Subtitle template does not support video media: {subtitle_template}")
229 media_video_ratio = template_media["media_ratio"] if template_media else video_ratio
230 media_resolution = template_media["media_resolution"] if template_media else image_resolution
231 video_mode = params.get("video_mode") or "image_concat"
232 dynamic_video = video_mode == "dynamic_video" or bool(params.get("generate_videos", False))
233 video_model = params.get("video_model")
234 video_resolution = params.get("video_resolution") or params.get("resolution") or "720P"
235 if (dynamic_video or template_video_mode) and not video_model:
236 raise ValueError("standard pipeline video generation requires video_model")
237 video_duration = clamp_segment_count(params.get("video_duration") or params.get("duration") or 5, default=5)
238
239 update_task(task_id, progress=9, message="Generating image prompts")
240 if llm is None:
241 llm = LLM()
242 try:
243 image_prompts = await generate_image_prompts(
244 narrations,
245 style_control,
246 llm,
247 llm_model,
248 render_subtitle_in_image=render_subtitle_in_image,
249 )
250 except Exception as exc:
251 logger.warning(
252 "Failed to generate structured image prompts, fallback to narration prompts: task_id=%s error=%s",
253 task_id,
254 exc,
255 )
256 image_prompts = [
257 build_image_prompt(
258 narration,
259 style_control,
260 subtitle=narration,
261 render_subtitle_in_image=render_subtitle_in_image,
262 )
263 for narration in narrations
264 ]
265
266 storyboard = {
267 "title": title,
268 "mode": mode,
269 "input_text": text,
270 "segment_count": len(narrations),
271 "video_mode": "template_video" if template_video_mode else ("dynamic_video" if dynamic_video else "image_concat"),
272 "style_control": style_control,
273 "subtitle_render_mode": subtitle_render_mode,
274 "subtitle_template": subtitle_template,
275 "subtitle_template_fields": subtitle_template_fields,
276 "template_media_kind": template_media_kind,
277 "template_media": template_media,
278 "frames": [
279 {"index": idx + 1, "narration": narration, "image_prompt": image_prompts[idx]}
280 for idx, narration in enumerate(narrations)
281 ],
282 }
283 storyboard_path = write_json(os.path.join(output_dir, "storyboard.json"), storyboard)
284 narration_path = write_text(os.path.join(output_dir, "narration.txt"), "\n".join(narrations))
285
286 artifacts = [artifact(storyboard_path, "text", "storyboard"), artifact(narration_path, "text", "narration")]
287 for item in artifacts:
288 append_artifact(task_id, item)
289
290 images = []
291 for idx, prompt in enumerate(image_prompts, 1):
292 update_task(
293 task_id,
294 progress=10 + int(40 * idx / len(image_prompts)),
295 message=f"Generating image {idx}/{len(image_prompts)}",
296 )
297 image_path = await run_blocking(
298 generate_image_api,
299 prompt=prompt,
300 model=image_model,
301 output_dir=output_dir,
302 task_id=task_id,
303 video_ratio=media_video_ratio,
304 resolution=media_resolution,
305 )
306 images.append(image_path)
307 image_artifact = artifact(image_path, "image", f"image_{idx:02d}")
308 artifacts.append(image_artifact)
309 append_artifact(task_id, image_artifact)
310 storyboard["frames"][idx - 1]["image_path"] = image_path
311 write_json(storyboard_path, storyboard)
312
313 audios = []
314 for idx, narration in enumerate(narrations, 1):
315 update_task(
316 task_id,
317 progress=50 + int(20 * idx / len(narrations)),
318 message=f"Generating audio {idx}/{len(narrations)}",
319 )
320 audio_path = os.path.join(output_dir, f"audio_{idx:02d}.mp3")
321 await generate_edge_tts(
322 narration,
323 output_path=audio_path,
324 voice=params.get("tts_voice", "zh-CN-YunjianNeural"),
325 speed=float(params.get("tts_speed", 1.0)),
326 )
327 audios.append(audio_path)
328 audio_artifact = artifact(audio_path, "audio", f"audio_{idx:02d}")
329 artifacts.append(audio_artifact)
330 append_artifact(task_id, audio_artifact)
331 storyboard["frames"][idx - 1]["audio_path"] = audio_path
332 write_json(storyboard_path, storyboard)
333
334 videos = []
335 for idx, (image_path, audio_path) in enumerate(zip(images, audios), 1):
336 update_task(
337 task_id,
338 progress=70 + int(20 * idx / len(images)),
339 message=f"{'Rendering template video' if template_video_mode else ('Generating dynamic video' if dynamic_video else 'Creating static clip')} {idx}/{len(images)}",
340 )
341 duration = media_duration_seconds(audio_path) or 3.0
342 clip_image_path = image_path
343 video_path = os.path.join(output_dir, f"video_{idx:02d}.mp4")
344 if enable_subtitles or (subtitle_template and template_video_mode):
345 if subtitle_template and template_video_mode:
346 media_video_path = os.path.join(output_dir, f"template_media_{idx:02d}.mp4")
347 await run_blocking(
348 generate_video_api,
349 prompt=image_prompts[idx - 1],
350 model=video_model,
351 output_path=media_video_path,
352 image_path=image_path,
353 duration=max(video_duration, int(duration + 0.999)),
354 video_ratio=media_video_ratio,
355 video_resolution=video_resolution,
356 )
357 storyboard["frames"][idx - 1]["template_media_video_path"] = media_video_path
358
359 template_video_path = os.path.join(output_dir, f"video_{idx:02d}_template.mp4")
360 await run_blocking(
361 render_template_media_video,
362 media_video_path,
363 template_video_path,
364 poster_image_path=image_path,
365 subtitle=narrations[idx - 1],
366 title=title or None,
367 video_ratio=video_ratio,
368 template_id=subtitle_template,
369 template_values=subtitle_template_fields,
370 index=idx,
371 duration=duration,
372 )
373 storyboard["frames"][idx - 1]["template_video_path"] = template_video_path
374 video_path = await run_blocking(
375 replace_video_audio,
376 template_video_path,
377 audio_path,
378 video_path,
379 )
380 elif subtitle_template:
381 captioned_image_path = os.path.join(output_dir, f"captioned_image_{idx:02d}.jpg")
382 clip_image_path = await run_blocking(
383 render_template_text_image,
384 image_path,
385 captioned_image_path,
386 subtitle=narrations[idx - 1],
387 title=title or None,
388 video_ratio=video_ratio,
389 template_id=subtitle_template,
390 template_values=subtitle_template_fields,
391 index=idx,
392 )
393 elif subtitle_render_mode == "postprocess":
394 captioned_image_path = os.path.join(output_dir, f"captioned_image_{idx:02d}.jpg")
395 clip_image_path = await run_blocking(
396 render_static_text_image,
397 image_path,
398 captioned_image_path,
399 subtitle=narrations[idx - 1],
400 title=title or None,
401 video_ratio=video_ratio,
402 )
403 else:
404 storyboard["frames"][idx - 1]["captioned_image_path"] = clip_image_path
405 storyboard["frames"][idx - 1]["subtitle_rendered_in_image"] = True
406 if not template_video_mode and (subtitle_template or subtitle_render_mode == "postprocess"):
407 captioned_artifact = artifact(clip_image_path, "image", f"captioned_image_{idx:02d}")
408 artifacts.append(captioned_artifact)
409 append_artifact(task_id, captioned_artifact)
410 storyboard["frames"][idx - 1]["captioned_image_path"] = clip_image_path
411 if subtitle_template and template_video_mode:
412 pass
413 elif dynamic_video:
414 video_only_segment_path = os.path.join(output_dir, f"video_{idx:02d}_motion.mp4")
415 await run_blocking(
416 generate_video_api,
417 prompt=image_prompts[idx - 1],
418 model=video_model,
419 output_path=video_only_segment_path,
420 image_path=clip_image_path,
421 duration=max(video_duration, int(duration + 0.999)),
422 video_ratio=video_ratio,
423 video_resolution=video_resolution,
424 )
425 motion_artifact = artifact(video_only_segment_path, "video", f"video_{idx:02d}_motion")
426 artifacts.append(motion_artifact)
427 append_artifact(task_id, motion_artifact)
428 storyboard["frames"][idx - 1]["motion_video_path"] = video_only_segment_path
429 video_path = await run_blocking(
430 replace_video_audio,
431 video_only_segment_path,
432 audio_path,
433 video_path,
434 )
435 else:
436 await run_blocking(
437 create_static_image_clip,
438 clip_image_path,
439 audio_path,
440 video_path,
441 video_ratio=video_ratio,
442 duration=duration,
443 )
444 videos.append(video_path)
445 video_artifact = artifact(video_path, "video", f"video_{idx:02d}")
446 artifacts.append(video_artifact)
447 append_artifact(task_id, video_artifact)
448 storyboard["frames"][idx - 1]["video_path"] = video_path
449 storyboard["frames"][idx - 1]["duration"] = duration
450 write_json(storyboard_path, storyboard)
451
452 video_only_path = None
453 video_only_path = concat_videos(videos, os.path.join(output_dir, "final.mp4"))
454 if not video_only_path:
455 raise RuntimeError("Static short-video generation did not produce a final video.")
456
457 final_path = video_only_path
458
459 final_artifact = artifact(final_path, "video", "final")
460 artifacts.append(final_artifact)
461 append_artifact(task_id, final_artifact)
462
463 write_json(storyboard_path, storyboard)
464 output = {
465 "title": title,
466 "storyboard_path": storyboard_path,
467 "narration_path": narration_path,
468 "images": images,
469 "audios": audios,
470 "videos": videos,
471 "video_mode": "template_video" if template_video_mode else ("dynamic_video" if dynamic_video else "image_concat"),
472 "video_model": video_model if (dynamic_video or template_video_mode) else None,
473 "subtitle_render_mode": subtitle_render_mode,
474 "subtitle_template": subtitle_template,
475 "subtitle_template_fields": subtitle_template_fields,
476 "template_media_kind": template_media_kind,
477 "template_media": template_media,
478 "video_only_path": video_only_path,
479 "final_video": final_path,
480 }
481 return output, artifacts
482
482 lines PYTHON