返回 Pixelle-Video
content_generators.py
根目录 / pixelle_video / utils / content_generators.py
1 # Copyright (C) 2025 AIDC-AI
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 # http://www.apache.org/licenses/LICENSE-2.0
7 # Unless required by applicable law or agreed to in writing, software
8 # distributed under the License is distributed on an "AS IS" BASIS,
9 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10 # See the License for the specific language governing permissions and
11 # limitations under the License.
12
13 """
14 Content generation utility functions
15
16 Pure/stateless functions for generating content using LLM.
17 These functions are reusable across different pipelines.
18 """
19
20 import json
21 import re
22 from typing import List, Optional, Literal
23
24 from loguru import logger
25
26
27 async def generate_title(
28 llm_service,
29 content: str,
30 strategy: Literal["auto", "direct", "llm"] = "auto",
31 max_length: int = 15
32 ) -> str:
33 """
34 Generate title from content
35
36 Args:
37 llm_service: LLM service instance
38 content: Source content (topic or script)
39 strategy: Generation strategy
40 - "auto": Auto-decide based on content length (default)
41 - "direct": Use content directly (truncated if needed)
42 - "llm": Always use LLM to generate title
43 max_length: Maximum title length (default: 15)
44
45 Returns:
46 Generated title
47 """
48 if strategy == "direct":
49 content = content.strip()
50 return content[:max_length] if len(content) > max_length else content
51
52 if strategy == "auto":
53 if len(content.strip()) <= 15:
54 return content.strip()
55 # Fall through to LLM
56
57 # Use LLM to generate title
58 from pixelle_video.prompts import build_title_generation_prompt
59
60 # Pass max_length to prompt so LLM knows the character limit
61 prompt = build_title_generation_prompt(content, max_length=max_length)
62 response = await llm_service(prompt, temperature=0.7, max_tokens=2000)
63
64 # Clean up response
65 title = response.strip()
66
67 # Remove quotes if present
68 if title.startswith('"') and title.endswith('"'):
69 title = title[1:-1]
70 if title.startswith("'") and title.endswith("'"):
71 title = title[1:-1]
72
73 # Remove trailing punctuation
74 title = title.rstrip('.,!?;:\'"')
75
76 # Safety: if still over limit, truncate smartly
77 if len(title) > max_length:
78 # Try to truncate at word boundary
79 truncated = title[:max_length]
80 last_space = truncated.rfind(' ')
81
82 # Only use word boundary if it's not too far back (at least 60% of max_length)
83 if last_space > max_length * 0.6:
84 title = truncated[:last_space]
85 else:
86 title = truncated
87
88 # Remove any trailing punctuation after truncation
89 title = title.rstrip('.,!?;:\'"')
90
91 logger.debug(f"Generated title: '{title}' (length: {len(title)})")
92 return title
93
94
95 async def generate_narrations_from_topic(
96 llm_service,
97 topic: str,
98 n_scenes: int = 5,
99 min_words: int = 5,
100 max_words: int = 20
101 ) -> List[str]:
102 """
103 Generate narrations from topic using LLM
104
105 Args:
106 llm_service: LLM service instance
107 topic: Topic/theme to generate narrations from
108 n_scenes: Number of narrations to generate
109 min_words: Minimum narration length
110 max_words: Maximum narration length
111
112 Returns:
113 List of narration texts
114 """
115 from pixelle_video.prompts import build_topic_narration_prompt
116
117 logger.info(f"Generating {n_scenes} narrations from topic: {topic}")
118
119 prompt = build_topic_narration_prompt(
120 topic=topic,
121 n_storyboard=n_scenes,
122 min_words=min_words,
123 max_words=max_words
124 )
125
126 response = await llm_service(
127 prompt=prompt,
128 temperature=0.8,
129 max_tokens=2000
130 )
131
132 logger.debug(f"LLM response: {response[:200]}...")
133
134 # Parse JSON
135 result = _parse_json(response)
136
137 if "narrations" not in result:
138 raise ValueError("Invalid response format: missing 'narrations' key")
139
140 narrations = result["narrations"]
141
142 # Validate count
143 if len(narrations) > n_scenes:
144 logger.warning(f"Got {len(narrations)} narrations, taking first {n_scenes}")
145 narrations = narrations[:n_scenes]
146 elif len(narrations) < n_scenes:
147 raise ValueError(f"Expected {n_scenes} narrations, got only {len(narrations)}")
148
149 logger.info(f"Generated {len(narrations)} narrations successfully")
150 return narrations
151
152
153 async def generate_narrations_from_content(
154 llm_service,
155 content: str,
156 n_scenes: int = 5,
157 min_words: int = 5,
158 max_words: int = 20
159 ) -> List[str]:
160 """
161 Generate narrations from user-provided content using LLM
162
163 Args:
164 llm_service: LLM service instance
165 content: User-provided content
166 n_scenes: Number of narrations to generate
167 min_words: Minimum narration length
168 max_words: Maximum narration length
169
170 Returns:
171 List of narration texts
172 """
173 from pixelle_video.prompts import build_content_narration_prompt
174
175 logger.info(f"Generating {n_scenes} narrations from content ({len(content)} chars)")
176
177 prompt = build_content_narration_prompt(
178 content=content,
179 n_storyboard=n_scenes,
180 min_words=min_words,
181 max_words=max_words
182 )
183
184 response = await llm_service(
185 prompt=prompt,
186 temperature=0.8,
187 max_tokens=2000
188 )
189
190 # Parse JSON
191 result = _parse_json(response)
192
193 if "narrations" not in result:
194 raise ValueError("Invalid response format: missing 'narrations' key")
195
196 narrations = result["narrations"]
197
198 # Validate count
199 if len(narrations) > n_scenes:
200 logger.warning(f"Got {len(narrations)} narrations, taking first {n_scenes}")
201 narrations = narrations[:n_scenes]
202 elif len(narrations) < n_scenes:
203 raise ValueError(f"Expected {n_scenes} narrations, got only {len(narrations)}")
204
205 logger.info(f"Generated {len(narrations)} narrations successfully")
206 return narrations
207
208
209 async def split_narration_script(
210 script: str,
211 split_mode: Literal["paragraph", "line", "sentence"] = "paragraph",
212 ) -> List[str]:
213 """
214 Split user-provided narration script into segments
215
216 Args:
217 script: Fixed narration script
218 split_mode: Splitting strategy
219 - "paragraph": Split by double newline (\\n\\n), preserve single newlines within paragraphs
220 - "line": Split by single newline (\\n), each line is a segment
221 - "sentence": Split by sentence-ending punctuation (。.!?!?)
222
223 Returns:
224 List of narration segments
225 """
226 logger.info(f"Splitting script (mode={split_mode}, length={len(script)} chars)")
227
228 narrations = []
229
230 if split_mode == "paragraph":
231 # Split by double newline (paragraph mode)
232 # Preserve single newlines within paragraphs
233 paragraphs = re.split(r'\n\s*\n', script)
234 for para in paragraphs:
235 # Only strip leading/trailing whitespace, preserve internal newlines
236 cleaned = para.strip()
237 if cleaned:
238 narrations.append(para)
239 logger.info(f"✅ Split script into {len(narrations)} segments (by paragraph)")
240
241 elif split_mode == "line":
242 # Split by single newline (original behavior)
243 narrations = [line.strip() for line in script.split('\n') if line.strip()]
244 logger.info(f"✅ Split script into {len(narrations)} segments (by line)")
245
246 elif split_mode == "sentence":
247 # Split by sentence-ending punctuation
248 # Supports Chinese (。!?) and English (.!?)
249 # Use regex to split while keeping sentences intact
250 cleaned = re.sub(r'\s+', ' ', script.strip())
251 # Split on sentence-ending punctuation, keeping the punctuation with the sentence
252 sentences = re.split(r'(?<=[。.!?!?])\s*', cleaned)
253 narrations = [s.strip() for s in sentences if s.strip()]
254 logger.info(f"✅ Split script into {len(narrations)} segments (by sentence)")
255
256 else:
257 # Fallback to line mode
258 logger.warning(f"Unknown split_mode '{split_mode}', falling back to 'line'")
259 narrations = [line.strip() for line in script.split('\n') if line.strip()]
260
261 # Log statistics
262 if narrations:
263 lengths = [len(s) for s in narrations]
264 logger.info(f" Min: {min(lengths)} chars, Max: {max(lengths)} chars, Avg: {sum(lengths)//len(lengths)} chars")
265
266 return narrations
267
268
269 async def generate_image_prompts(
270 llm_service,
271 narrations: List[str],
272 min_words: int = 30,
273 max_words: int = 60,
274 batch_size: int = 10,
275 max_retries: int = 3,
276 progress_callback: Optional[callable] = None
277 ) -> List[str]:
278 """
279 Generate image prompts from narrations (with batching and retry)
280
281 Args:
282 llm_service: LLM service instance
283 narrations: List of narrations
284 min_words: Min image prompt length
285 max_words: Max image prompt length
286 batch_size: Max narrations per batch (default: 10)
287 max_retries: Max retry attempts per batch (default: 3)
288 progress_callback: Optional callback(completed, total, message) for progress updates
289
290 Returns:
291 List of image prompts (base prompts, without prefix applied)
292 """
293 from pixelle_video.prompts import build_image_prompt_prompt
294
295 logger.info(f"Generating image prompts for {len(narrations)} narrations (batch_size={batch_size})")
296
297 # Split narrations into batches
298 batches = [narrations[i:i + batch_size] for i in range(0, len(narrations), batch_size)]
299 logger.info(f"Split into {len(batches)} batches")
300
301 all_prompts = []
302
303 # Process each batch
304 for batch_idx, batch_narrations in enumerate(batches, 1):
305 logger.info(f"Processing batch {batch_idx}/{len(batches)} ({len(batch_narrations)} narrations)")
306
307 # Retry logic for this batch
308 for attempt in range(1, max_retries + 1):
309 try:
310 # Generate prompts for this batch
311 prompt = build_image_prompt_prompt(
312 narrations=batch_narrations,
313 min_words=min_words,
314 max_words=max_words
315 )
316
317 response = await llm_service(
318 prompt=prompt,
319 temperature=0.7,
320 max_tokens=8192
321 )
322
323 logger.debug(f"Batch {batch_idx} attempt {attempt}: LLM response length: {len(response)} chars")
324
325 # Parse JSON
326 result = _parse_json(response)
327
328 if "image_prompts" not in result:
329 raise KeyError("Invalid response format: missing 'image_prompts'")
330
331 batch_prompts = result["image_prompts"]
332
333 # Validate count
334 if len(batch_prompts) != len(batch_narrations):
335 error_msg = (
336 f"Batch {batch_idx} prompt count mismatch (attempt {attempt}/{max_retries}):\n"
337 f" Expected: {len(batch_narrations)} prompts\n"
338 f" Got: {len(batch_prompts)} prompts"
339 )
340 logger.warning(error_msg)
341
342 if attempt < max_retries:
343 logger.info(f"Retrying batch {batch_idx}...")
344 continue
345 else:
346 raise ValueError(error_msg)
347
348 # Success!
349 logger.info(f"✅ Batch {batch_idx} completed successfully ({len(batch_prompts)} prompts)")
350 all_prompts.extend(batch_prompts)
351
352 # Report progress
353 if progress_callback:
354 progress_callback(
355 len(all_prompts),
356 len(narrations),
357 f"Batch {batch_idx}/{len(batches)} completed"
358 )
359
360 break
361
362 except json.JSONDecodeError as e:
363 logger.error(f"Batch {batch_idx} JSON parse error (attempt {attempt}/{max_retries}): {e}")
364 if attempt >= max_retries:
365 raise
366 logger.info(f"Retrying batch {batch_idx}...")
367
368 logger.info(f"✅ Generated {len(all_prompts)} image prompts")
369 return all_prompts
370
371
372 async def generate_video_prompts(
373 llm_service,
374 narrations: List[str],
375 min_words: int = 30,
376 max_words: int = 60,
377 batch_size: int = 10,
378 max_retries: int = 3,
379 progress_callback: Optional[callable] = None
380 ) -> List[str]:
381 """
382 Generate video prompts from narrations (with batching and retry)
383
384 Args:
385 llm_service: LLM service instance
386 narrations: List of narrations
387 min_words: Min video prompt length
388 max_words: Max video prompt length
389 batch_size: Max narrations per batch (default: 10)
390 max_retries: Max retry attempts per batch (default: 3)
391 progress_callback: Optional callback(completed, total, message) for progress updates
392
393 Returns:
394 List of video prompts (base prompts, without prefix applied)
395 """
396 from pixelle_video.prompts.video_generation import build_video_prompt_prompt
397
398 logger.info(f"Generating video prompts for {len(narrations)} narrations (batch_size={batch_size})")
399
400 # Split narrations into batches
401 batches = [narrations[i:i + batch_size] for i in range(0, len(narrations), batch_size)]
402 logger.info(f"Split into {len(batches)} batches")
403
404 all_prompts = []
405
406 # Process each batch
407 for batch_idx, batch_narrations in enumerate(batches, 1):
408 logger.info(f"Processing batch {batch_idx}/{len(batches)} ({len(batch_narrations)} narrations)")
409
410 # Retry logic for this batch
411 for attempt in range(1, max_retries + 1):
412 try:
413 # Generate prompts for this batch
414 prompt = build_video_prompt_prompt(
415 narrations=batch_narrations,
416 min_words=min_words,
417 max_words=max_words
418 )
419
420 response = await llm_service(
421 prompt=prompt,
422 temperature=0.7,
423 max_tokens=8192
424 )
425
426 logger.debug(f"Batch {batch_idx} attempt {attempt}: LLM response length: {len(response)} chars")
427
428 # Parse JSON
429 result = _parse_json(response)
430
431 if "video_prompts" not in result:
432 raise KeyError("Invalid response format: missing 'video_prompts'")
433
434 batch_prompts = result["video_prompts"]
435
436 # Validate batch result
437 if len(batch_prompts) != len(batch_narrations):
438 raise ValueError(
439 f"Prompt count mismatch: expected {len(batch_narrations)}, got {len(batch_prompts)}"
440 )
441
442 # Success - add to all_prompts
443 all_prompts.extend(batch_prompts)
444 logger.info(f"✓ Batch {batch_idx} completed: {len(batch_prompts)} video prompts")
445
446 # Report progress
447 if progress_callback:
448 completed = len(all_prompts)
449 total = len(narrations)
450 progress_callback(completed, total, f"Batch {batch_idx}/{len(batches)} completed")
451
452 break # Success, move to next batch
453
454 except Exception as e:
455 logger.warning(f"✗ Batch {batch_idx} attempt {attempt} failed: {e}")
456 if attempt >= max_retries:
457 raise
458 logger.info(f"Retrying batch {batch_idx}...")
459
460 logger.info(f"✅ Generated {len(all_prompts)} video prompts")
461 return all_prompts
462
463
464 def _parse_json(text: str) -> dict:
465 """
466 Parse JSON from text, with fallback to extract JSON from markdown code blocks
467
468 Args:
469 text: Text containing JSON
470
471 Returns:
472 Parsed JSON dict
473
474 Raises:
475 json.JSONDecodeError: If no valid JSON found
476 """
477 # Try direct parsing first
478 try:
479 return json.loads(text)
480 except json.JSONDecodeError:
481 pass
482
483 # Try to extract JSON from markdown code block
484 json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```'
485 match = re.search(json_pattern, text, re.DOTALL)
486 if match:
487 try:
488 return json.loads(match.group(1))
489 except json.JSONDecodeError:
490 pass
491
492 # Try to find any JSON object in the text
493 json_pattern = r'\{[^{}]*(?:"narrations"|"image_prompts")\s*:\s*\[[^\]]*\][^{}]*\}'
494 match = re.search(json_pattern, text, re.DOTALL)
495 if match:
496 try:
497 return json.loads(match.group(0))
498 except json.JSONDecodeError:
499 pass
500
501 # If all fails, raise error
502 raise json.JSONDecodeError("No valid JSON found", text, 0)
503
503 lines PYTHON