content_generators.py 17 KB

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