custom.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  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. Custom Video Generation Pipeline
  14. Template pipeline for creating your own custom video generation workflows.
  15. This serves as a reference implementation showing how to extend BasePipeline.
  16. For real projects, copy this file and modify it according to your needs.
  17. """
  18. from datetime import datetime
  19. from pathlib import Path
  20. from typing import Optional, Callable
  21. from loguru import logger
  22. from pixelle_video.pipelines.base import BasePipeline
  23. from pixelle_video.models.progress import ProgressEvent
  24. from pixelle_video.models.storyboard import (
  25. Storyboard,
  26. StoryboardFrame,
  27. StoryboardConfig,
  28. ContentMetadata,
  29. VideoGenerationResult
  30. )
  31. class CustomPipeline(BasePipeline):
  32. """
  33. Custom video generation pipeline template
  34. This is a template showing how to create your own pipeline with custom logic.
  35. You can customize:
  36. - Content processing logic
  37. - Narration generation strategy
  38. - Image prompt generation (conditional based on template)
  39. - Frame composition
  40. - Video assembly
  41. KEY OPTIMIZATION: Conditional Image Generation
  42. -----------------------------------------------
  43. This pipeline supports automatic detection of template image requirements.
  44. If your template doesn't use {{image}}, the entire image generation pipeline
  45. can be skipped, providing:
  46. ⚡ Faster generation (no image API calls)
  47. 💰 Lower cost (no LLM calls for image prompts)
  48. 🚀 Reduced dependencies (no ComfyUI needed for text-only videos)
  49. Usage patterns:
  50. 1. Text-only videos: Use templates/1080x1920/simple.html
  51. 2. AI-generated images: Use templates with {{image}} placeholder
  52. 3. Custom logic: Modify template or override the detection logic in your subclass
  53. Example usage:
  54. # 1. Create your own pipeline by copying this file
  55. # 2. Modify the __call__ method with your custom logic
  56. # 3. Register it in service.py or dynamically
  57. from pixelle_video.pipelines.custom import CustomPipeline
  58. pixelle_video.pipelines["my_custom"] = CustomPipeline(pixelle_video)
  59. # 4. Use it
  60. result = await pixelle_video.generate_video(
  61. text=your_content,
  62. pipeline="my_custom",
  63. # Your custom parameters here
  64. )
  65. """
  66. async def __call__(
  67. self,
  68. text: str,
  69. # === Custom Parameters ===
  70. # Add your own parameters here
  71. custom_param_example: str = "default_value",
  72. # === Standard Parameters (keep these for compatibility) ===
  73. tts_inference_mode: Optional[str] = None, # "local" or "comfyui"
  74. voice_id: Optional[str] = None, # Deprecated, use tts_voice
  75. tts_voice: Optional[str] = None, # Voice ID for local mode
  76. tts_workflow: Optional[str] = None,
  77. tts_speed: float = 1.2,
  78. ref_audio: Optional[str] = None,
  79. media_workflow: Optional[str] = None,
  80. # Note: media_width and media_height are auto-determined from template
  81. frame_template: Optional[str] = None,
  82. video_fps: int = 30,
  83. output_path: Optional[str] = None,
  84. bgm_path: Optional[str] = None,
  85. bgm_volume: float = 0.2,
  86. progress_callback: Optional[Callable[[ProgressEvent], None]] = None,
  87. ) -> VideoGenerationResult:
  88. """
  89. Custom video generation workflow
  90. Customize this method to implement your own logic.
  91. Args:
  92. text: Input text (customize meaning as needed)
  93. custom_param_example: Your custom parameter
  94. (other standard parameters...)
  95. Returns:
  96. VideoGenerationResult
  97. Image Generation Logic:
  98. - image_*.html templates → automatically generates images
  99. - video_*.html templates → automatically generates videos
  100. - static_*.html templates → skips media generation (faster, cheaper)
  101. - To customize: Override the template type detection logic in your subclass
  102. """
  103. logger.info("Starting CustomPipeline")
  104. logger.info(f"Input text length: {len(text)} chars")
  105. logger.info(f"Custom parameter: {custom_param_example}")
  106. # === Handle TTS parameter compatibility ===
  107. # Support both old API (voice_id) and new API (tts_inference_mode + tts_voice)
  108. final_voice_id = None
  109. final_tts_workflow = tts_workflow
  110. if tts_inference_mode:
  111. # New API from web UI
  112. if tts_inference_mode == "local":
  113. # Local Edge TTS mode - use tts_voice
  114. final_voice_id = tts_voice or "zh-CN-YunjianNeural"
  115. final_tts_workflow = None # Don't use workflow in local mode
  116. logger.debug(f"TTS Mode: local (voice={final_voice_id})")
  117. elif tts_inference_mode == "comfyui":
  118. # ComfyUI workflow mode
  119. final_voice_id = None # Don't use voice_id in ComfyUI mode
  120. # tts_workflow already set from parameter
  121. logger.debug(f"TTS Mode: comfyui (workflow={final_tts_workflow})")
  122. else:
  123. # Old API (backward compatibility)
  124. final_voice_id = voice_id or tts_voice or "zh-CN-YunjianNeural"
  125. # tts_workflow already set from parameter
  126. logger.debug(f"TTS Mode: legacy (voice_id={final_voice_id}, workflow={final_tts_workflow})")
  127. # ========== Step 0: Setup ==========
  128. self._report_progress(progress_callback, "initializing", 0.05)
  129. # Create task directory
  130. from pixelle_video.utils.os_util import (
  131. create_task_output_dir,
  132. get_task_final_video_path
  133. )
  134. task_dir, task_id = create_task_output_dir()
  135. logger.info(f"Task directory: {task_dir}")
  136. user_specified_output = None
  137. if output_path is None:
  138. output_path = get_task_final_video_path(task_id)
  139. else:
  140. user_specified_output = output_path
  141. output_path = get_task_final_video_path(task_id)
  142. # Determine frame template
  143. # Priority: explicit param > config default > hardcoded default
  144. if frame_template is None:
  145. template_config = self.core.config.get("template", {})
  146. frame_template = template_config.get("default_template", "1080x1920/default.html")
  147. # ========== Step 0.5: Check template requirements ==========
  148. # Detect template type by filename prefix
  149. from pathlib import Path
  150. from pixelle_video.services.frame_html import HTMLFrameGenerator
  151. from pixelle_video.utils.template_util import resolve_template_path, get_template_type
  152. template_name = Path(frame_template).name
  153. template_type = get_template_type(template_name)
  154. template_requires_image = (template_type == "image")
  155. # Read media size from template meta tags
  156. template_path = resolve_template_path(frame_template)
  157. generator = HTMLFrameGenerator(template_path)
  158. media_width, media_height = generator.get_media_size()
  159. logger.info(f"📐 Media size from template: {media_width}x{media_height}")
  160. if template_type == "image":
  161. logger.info(f"📸 Template requires image generation")
  162. elif template_type == "video":
  163. logger.info(f"🎬 Template requires video generation")
  164. else: # static
  165. logger.info(f"⚡ Static template - skipping media generation pipeline")
  166. logger.info(f" 💡 Benefits: Faster generation + Lower cost + No ComfyUI dependency")
  167. # ========== Step 1: Process content (CUSTOMIZE THIS) ==========
  168. self._report_progress(progress_callback, "processing_content", 0.10)
  169. # Example: Generate title using LLM
  170. from pixelle_video.utils.content_generators import generate_title
  171. title = await generate_title(self.llm, text, strategy="llm")
  172. logger.info(f"Generated title: '{title}'")
  173. # Example: Split or generate narrations
  174. # Option A: Split by lines (for fixed script)
  175. narrations = [line.strip() for line in text.split('\n') if line.strip()]
  176. # Option B: Use LLM to generate narrations (uncomment to use)
  177. # from pixelle_video.utils.content_generators import generate_narrations_from_topic
  178. # narrations = await generate_narrations_from_topic(
  179. # self.llm,
  180. # topic=text,
  181. # n_scenes=5,
  182. # min_words=20,
  183. # max_words=80
  184. # )
  185. logger.info(f"Generated {len(narrations)} narrations")
  186. # ========== Step 2: Generate image prompts (CONDITIONAL - CUSTOMIZE THIS) ==========
  187. self._report_progress(progress_callback, "generating_image_prompts", 0.25)
  188. # IMPORTANT: Check if template is image type
  189. # If your template is static_*.html, you can skip this entire step!
  190. if template_requires_image:
  191. # Template requires images - generate image prompts using LLM
  192. from pixelle_video.utils.content_generators import generate_image_prompts
  193. image_prompts = await generate_image_prompts(
  194. self.llm,
  195. narrations=narrations,
  196. min_words=30,
  197. max_words=60
  198. )
  199. # Example: Apply custom prompt prefix
  200. from pixelle_video.utils.prompt_helper import build_image_prompt
  201. custom_prefix = "cinematic style, professional lighting" # Customize this
  202. final_image_prompts = []
  203. for base_prompt in image_prompts:
  204. final_prompt = build_image_prompt(base_prompt, custom_prefix)
  205. final_image_prompts.append(final_prompt)
  206. logger.info(f"✅ Generated {len(final_image_prompts)} image prompts")
  207. else:
  208. # Template doesn't need images - skip image generation entirely
  209. final_image_prompts = [None] * len(narrations)
  210. logger.info(f"⚡ Skipped image prompt generation (template doesn't need images)")
  211. logger.info(f" 💡 Savings: {len(narrations)} LLM calls + {len(narrations)} image generations")
  212. # ========== Step 3: Create storyboard ==========
  213. config = StoryboardConfig(
  214. task_id=task_id,
  215. n_storyboard=len(narrations),
  216. min_narration_words=20,
  217. max_narration_words=80,
  218. min_image_prompt_words=30,
  219. max_image_prompt_words=60,
  220. video_fps=video_fps,
  221. tts_inference_mode=tts_inference_mode or "local", # TTS inference mode (CRITICAL FIX)
  222. voice_id=final_voice_id, # Use processed voice_id
  223. tts_workflow=final_tts_workflow, # Use processed workflow
  224. tts_speed=tts_speed,
  225. ref_audio=ref_audio,
  226. media_width=media_width,
  227. media_height=media_height,
  228. media_workflow=media_workflow,
  229. frame_template=frame_template
  230. )
  231. # Optional: Add custom metadata
  232. content_metadata = ContentMetadata(
  233. title=title,
  234. subtitle="Custom Pipeline Output"
  235. )
  236. storyboard = Storyboard(
  237. title=title,
  238. config=config,
  239. content_metadata=content_metadata,
  240. created_at=datetime.now()
  241. )
  242. # Create frames
  243. for i, (narration, image_prompt) in enumerate(zip(narrations, final_image_prompts)):
  244. frame = StoryboardFrame(
  245. index=i,
  246. narration=narration,
  247. image_prompt=image_prompt,
  248. created_at=datetime.now()
  249. )
  250. storyboard.frames.append(frame)
  251. try:
  252. # ========== Step 4: Process each frame ==========
  253. # This is the standard frame processing logic
  254. # You can customize frame processing if needed
  255. for i, frame in enumerate(storyboard.frames):
  256. base_progress = 0.3
  257. frame_range = 0.5
  258. per_frame_progress = frame_range / len(storyboard.frames)
  259. self._report_progress(
  260. progress_callback,
  261. "processing_frame",
  262. base_progress + (per_frame_progress * i),
  263. frame_current=i+1,
  264. frame_total=len(storyboard.frames)
  265. )
  266. # Use core frame processor (standard logic)
  267. processed_frame = await self.core.frame_processor(
  268. frame=frame,
  269. storyboard=storyboard,
  270. config=config,
  271. total_frames=len(storyboard.frames),
  272. progress_callback=None
  273. )
  274. storyboard.total_duration += processed_frame.duration
  275. logger.info(f"Frame {i+1} completed ({processed_frame.duration:.2f}s)")
  276. # ========== Step 5: Concatenate videos ==========
  277. self._report_progress(progress_callback, "concatenating", 0.85)
  278. segment_paths = [frame.video_segment_path for frame in storyboard.frames]
  279. from pixelle_video.services.video import VideoService
  280. video_service = VideoService()
  281. final_video_path = video_service.concat_videos(
  282. videos=segment_paths,
  283. output=output_path,
  284. bgm_path=bgm_path,
  285. bgm_volume=bgm_volume,
  286. bgm_mode="loop"
  287. )
  288. storyboard.final_video_path = final_video_path
  289. storyboard.completed_at = datetime.now()
  290. # Copy to user-specified path if provided
  291. if user_specified_output:
  292. import shutil
  293. Path(user_specified_output).parent.mkdir(parents=True, exist_ok=True)
  294. shutil.copy2(final_video_path, user_specified_output)
  295. logger.info(f"Final video copied to: {user_specified_output}")
  296. final_video_path = user_specified_output
  297. storyboard.final_video_path = user_specified_output
  298. logger.success(f"Custom pipeline video completed: {final_video_path}")
  299. # ========== Step 6: Create result ==========
  300. self._report_progress(progress_callback, "completed", 1.0)
  301. video_path_obj = Path(final_video_path)
  302. file_size = video_path_obj.stat().st_size
  303. result = VideoGenerationResult(
  304. video_path=final_video_path,
  305. storyboard=storyboard,
  306. duration=storyboard.total_duration,
  307. file_size=file_size
  308. )
  309. logger.info(f"Custom pipeline completed")
  310. logger.info(f"Title: {title}")
  311. logger.info(f"Duration: {storyboard.total_duration:.2f}s")
  312. logger.info(f"Size: {file_size / (1024*1024):.2f} MB")
  313. logger.info(f"Frames: {len(storyboard.frames)}")
  314. # ========== Step 7: Persist metadata and storyboard ==========
  315. await self._persist_task_data(
  316. storyboard=storyboard,
  317. result=result,
  318. input_params={
  319. "text": text,
  320. "custom_param_example": custom_param_example,
  321. "voice_id": voice_id,
  322. "tts_workflow": tts_workflow,
  323. "tts_speed": tts_speed,
  324. "ref_audio": ref_audio,
  325. "media_workflow": media_workflow,
  326. "frame_template": frame_template,
  327. "bgm_path": bgm_path,
  328. "bgm_volume": bgm_volume,
  329. }
  330. )
  331. return result
  332. except Exception as e:
  333. logger.error(f"Custom pipeline failed: {e}")
  334. raise
  335. # ==================== Persistence ====================
  336. async def _persist_task_data(
  337. self,
  338. storyboard: Storyboard,
  339. result: VideoGenerationResult,
  340. input_params: dict
  341. ):
  342. """
  343. Persist task metadata and storyboard to filesystem
  344. Args:
  345. storyboard: Complete storyboard
  346. result: Video generation result
  347. input_params: Input parameters used for generation
  348. """
  349. try:
  350. task_id = storyboard.config.task_id
  351. if not task_id:
  352. logger.warning("No task_id in storyboard, skipping persistence")
  353. return
  354. # Build metadata
  355. # If user didn't provide a title, use the generated one from storyboard
  356. input_with_title = input_params.copy()
  357. if not input_with_title.get("title"):
  358. input_with_title["title"] = storyboard.title
  359. metadata = {
  360. "task_id": task_id,
  361. "created_at": storyboard.created_at.isoformat() if storyboard.created_at else None,
  362. "completed_at": storyboard.completed_at.isoformat() if storyboard.completed_at else None,
  363. "status": "completed",
  364. "input": input_with_title,
  365. "result": {
  366. "video_path": result.video_path,
  367. "duration": result.duration,
  368. "file_size": result.file_size,
  369. "n_frames": len(storyboard.frames)
  370. },
  371. "config": {
  372. "llm_model": self.core.config.get("llm", {}).get("model", "unknown"),
  373. "llm_base_url": self.core.config.get("llm", {}).get("base_url", "unknown"),
  374. "comfyui_url": self.core.config.get("comfyui", {}).get("comfyui_url", "unknown"),
  375. "runninghub_enabled": bool(self.core.config.get("comfyui", {}).get("runninghub_api_key")),
  376. }
  377. }
  378. # Save metadata
  379. await self.core.persistence.save_task_metadata(task_id, metadata)
  380. logger.info(f"💾 Saved task metadata: {task_id}")
  381. # Save storyboard
  382. await self.core.persistence.save_storyboard(task_id, storyboard)
  383. logger.info(f"💾 Saved storyboard: {task_id}")
  384. except Exception as e:
  385. logger.error(f"Failed to persist task data: {e}")
  386. # Don't raise - persistence failure shouldn't break video generation
  387. # ==================== Custom Helper Methods ====================
  388. # Add your own helper methods here
  389. async def _custom_content_analysis(self, text: str) -> dict:
  390. """
  391. Example: Custom content analysis logic
  392. You can add your own helper methods to process content,
  393. extract metadata, or perform custom transformations.
  394. """
  395. # Your custom logic here
  396. return {
  397. "processed": text,
  398. "metadata": {}
  399. }
  400. async def _custom_prompt_generation(self, context: str) -> str:
  401. """
  402. Example: Custom prompt generation logic
  403. Create specialized prompts based on your use case.
  404. """
  405. prompt = f"Generate content based on: {context}"
  406. response = await self.llm(prompt, temperature=0.7, max_tokens=500)
  407. return response.strip()
  408. # ==================== Usage Examples ====================
  409. """
  410. Example 1: Text-only video (no AI image generation)
  411. ---------------------------------------------------
  412. from pixelle_video import pixelle_video
  413. from pixelle_video.pipelines.custom import CustomPipeline
  414. # Initialize
  415. await pixelle_video.initialize()
  416. # Register custom pipeline
  417. pixelle_video.pipelines["my_custom"] = CustomPipeline(pixelle_video)
  418. # Use text-only template - no image generation!
  419. result = await pixelle_video.generate_video(
  420. text="Your content here",
  421. pipeline="my_custom",
  422. frame_template="1080x1920/simple.html" # Template without {{image}}
  423. )
  424. # Benefits: ⚡ Fast, 💰 Cheap, 🚀 No ComfyUI needed
  425. Example 2: AI-generated image video
  426. ---------------------------------------------------
  427. # Use template with {{image}} - automatic image generation
  428. result = await pixelle_video.generate_video(
  429. text="Your content here",
  430. pipeline="my_custom",
  431. frame_template="1080x1920/default.html" # Template with {{image}}
  432. )
  433. # Will automatically generate images via LLM + ComfyUI
  434. Example 3: Create your own pipeline class
  435. ----------------------------------------
  436. from pixelle_video.pipelines.custom import CustomPipeline
  437. class MySpecialPipeline(CustomPipeline):
  438. async def __call__(self, text: str, **kwargs):
  439. # Your completely custom logic
  440. logger.info("Running my special pipeline")
  441. # You can reuse parts from CustomPipeline or start from scratch
  442. # ...
  443. return result
  444. Example 4: Inline custom pipeline
  445. ----------------------------------------
  446. from pixelle_video.pipelines.base import BasePipeline
  447. class QuickPipeline(BasePipeline):
  448. async def __call__(self, text: str, **kwargs):
  449. # Quick custom logic
  450. narrations = text.split('\\n')
  451. for narration in narrations:
  452. audio = await self.tts(narration)
  453. image = await self.image(prompt=f"illustration of {narration}")
  454. # ... process frame
  455. # ... concatenate and return
  456. return result
  457. # Use immediately
  458. pixelle_video.pipelines["quick"] = QuickPipeline(pixelle_video)
  459. result = await pixelle_video.generate_video(text=content, pipeline="quick")
  460. """