| 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 | Pixelle-Video Core - Service Layer |
| 15 | |
| 16 | Provides unified access to all capabilities (LLM, TTS, Image, etc.) |
| 17 | """ |
| 18 | |
| 19 | import hashlib |
| 20 | import json |
| 21 | from typing import Optional |
| 22 | |
| 23 | from loguru import logger |
| 24 | from comfykit import ComfyKit |
| 25 | |
| 26 | from pixelle_video.config import config_manager |
| 27 | from pixelle_video.services.llm_service import LLMService |
| 28 | from pixelle_video.services.tts_service import TTSService |
| 29 | from pixelle_video.services.media import MediaService |
| 30 | from pixelle_video.services.api_media import APIProviderMediaService |
| 31 | from pixelle_video.services.image_analysis import ImageAnalysisService |
| 32 | from pixelle_video.services.video_analysis import VideoAnalysisService |
| 33 | from pixelle_video.services.api_asset_analysis import APIAssetAnalysisService |
| 34 | from pixelle_video.services.video import VideoService |
| 35 | from pixelle_video.services.frame_processor import FrameProcessor |
| 36 | from pixelle_video.services.persistence import PersistenceService |
| 37 | from pixelle_video.services.history_manager import HistoryManager |
| 38 | from pixelle_video.pipelines.standard import StandardPipeline |
| 39 | from pixelle_video.pipelines.custom import CustomPipeline |
| 40 | from pixelle_video.pipelines.asset_based import AssetBasedPipeline |
| 41 | |
| 42 | |
| 43 | class PixelleVideoCore: |
| 44 | """ |
| 45 | Pixelle-Video Core - Service Layer |
| 46 | |
| 47 | Provides unified access to all capabilities. |
| 48 | |
| 49 | Usage: |
| 50 | from pixelle_video import pixelle_video |
| 51 | |
| 52 | # Initialize |
| 53 | await pixelle_video.initialize() |
| 54 | |
| 55 | # Use capabilities directly |
| 56 | answer = await pixelle_video.llm("Explain atomic habits") |
| 57 | audio = await pixelle_video.tts("Hello world") |
| 58 | media = await pixelle_video.media(prompt="a cat") |
| 59 | |
| 60 | # Check active capabilities |
| 61 | print(f"Using LLM: {pixelle_video.llm.active}") |
| 62 | print(f"Available TTS: {pixelle_video.tts.available}") |
| 63 | |
| 64 | Architecture (Simplified): |
| 65 | PixelleVideoCore (this class) |
| 66 | ├── config (configuration) |
| 67 | ├── llm (LLM service - direct OpenAI SDK) |
| 68 | ├── tts (TTS service - ComfyKit workflows) |
| 69 | ├── media (Media service - ComfyKit workflows, supports image & video) |
| 70 | └── pipelines (video generation pipelines) |
| 71 | ├── standard (standard workflow) |
| 72 | ├── custom (custom workflow template) |
| 73 | └── ... (extensible) |
| 74 | """ |
| 75 | |
| 76 | def __init__(self, config_path: str = "config.yaml"): |
| 77 | """ |
| 78 | Initialize Pixelle-Video Core |
| 79 | |
| 80 | Args: |
| 81 | config_path: Path to configuration file |
| 82 | """ |
| 83 | # Use global config manager singleton |
| 84 | self.config = config_manager.config.to_dict() |
| 85 | self._initialized = False |
| 86 | |
| 87 | # ComfyKit lazy initialization (created on first use, recreated on config change) |
| 88 | self._comfykit: Optional[ComfyKit] = None |
| 89 | self._comfykit_config_hash: Optional[str] = None |
| 90 | |
| 91 | # Core services (initialized in initialize()) |
| 92 | self.llm: Optional[LLMService] = None |
| 93 | self.tts: Optional[TTSService] = None |
| 94 | self.media: Optional[MediaService] = None |
| 95 | self.api_media: Optional[APIProviderMediaService] = None |
| 96 | self.video: Optional[VideoService] = None |
| 97 | self.frame_processor: Optional[FrameProcessor] = None |
| 98 | self.persistence: Optional[PersistenceService] = None |
| 99 | self.history: Optional[HistoryManager] = None |
| 100 | |
| 101 | # Video generation pipelines (dictionary of pipeline_name -> pipeline_instance) |
| 102 | self.pipelines = {} |
| 103 | |
| 104 | # Default pipeline callable (for backward compatibility) |
| 105 | self.generate_video = None |
| 106 | |
| 107 | def _get_comfykit_config(self) -> dict: |
| 108 | """ |
| 109 | Get current ComfyKit configuration from config_manager |
| 110 | |
| 111 | Returns: |
| 112 | ComfyKit configuration dict |
| 113 | """ |
| 114 | # Reload config from global config_manager (to support hot reload) |
| 115 | self.config = config_manager.config.to_dict() |
| 116 | |
| 117 | comfyui_config = self.config.get("comfyui", {}) |
| 118 | kit_config = {} |
| 119 | |
| 120 | if comfyui_config.get("comfyui_url"): |
| 121 | kit_config["comfyui_url"] = comfyui_config["comfyui_url"] |
| 122 | if comfyui_config.get("comfyui_api_key"): |
| 123 | kit_config["api_key"] = comfyui_config["comfyui_api_key"] |
| 124 | if comfyui_config.get("runninghub_api_key"): |
| 125 | kit_config["runninghub_api_key"] = comfyui_config["runninghub_api_key"] |
| 126 | # Only pass instance_type if it has a non-empty value |
| 127 | instance_type = comfyui_config.get("runninghub_instance_type") |
| 128 | if instance_type and instance_type.strip(): |
| 129 | kit_config["runninghub_instance_type"] = instance_type |
| 130 | |
| 131 | return kit_config |
| 132 | |
| 133 | def _compute_comfykit_config_hash(self, config: dict) -> str: |
| 134 | """ |
| 135 | Compute hash of ComfyKit configuration for change detection |
| 136 | |
| 137 | Args: |
| 138 | config: ComfyKit configuration dict |
| 139 | |
| 140 | Returns: |
| 141 | MD5 hash of config |
| 142 | """ |
| 143 | # Sort keys for consistent hash |
| 144 | config_str = json.dumps(config, sort_keys=True) |
| 145 | return hashlib.md5(config_str.encode()).hexdigest() |
| 146 | |
| 147 | async def _get_or_create_comfykit(self) -> ComfyKit: |
| 148 | """ |
| 149 | Get or create ComfyKit instance (lazy initialization with config change detection) |
| 150 | |
| 151 | This method: |
| 152 | 1. Creates ComfyKit on first use (lazy initialization) |
| 153 | 2. Detects configuration changes and recreates instance if needed |
| 154 | 3. Ensures proper cleanup of old instances |
| 155 | |
| 156 | Returns: |
| 157 | ComfyKit instance |
| 158 | """ |
| 159 | current_config = self._get_comfykit_config() |
| 160 | current_hash = self._compute_comfykit_config_hash(current_config) |
| 161 | |
| 162 | # Check if we need to create or recreate ComfyKit |
| 163 | if self._comfykit is None or self._comfykit_config_hash != current_hash: |
| 164 | # Close old instance if exists |
| 165 | if self._comfykit is not None: |
| 166 | logger.info("🔄 ComfyUI configuration changed, recreating ComfyKit instance...") |
| 167 | try: |
| 168 | await self._comfykit.close() |
| 169 | except Exception as e: |
| 170 | logger.warning(f"Failed to close old ComfyKit instance: {e}") |
| 171 | self._comfykit = None |
| 172 | |
| 173 | # Create new instance with current config |
| 174 | logger.info("✨ Creating ComfyKit instance...") |
| 175 | logger.debug(f"ComfyKit config: {current_config}") |
| 176 | self._comfykit = ComfyKit(**current_config) |
| 177 | self._comfykit_config_hash = current_hash |
| 178 | logger.info("✅ ComfyKit instance created") |
| 179 | |
| 180 | return self._comfykit |
| 181 | |
| 182 | async def initialize(self): |
| 183 | """ |
| 184 | Initialize core capabilities |
| 185 | |
| 186 | This initializes all services and must be called before using any capabilities. |
| 187 | Note: ComfyKit is NOT initialized here - it's lazily initialized on first use. |
| 188 | |
| 189 | Example: |
| 190 | await pixelle_video.initialize() |
| 191 | """ |
| 192 | if self._initialized: |
| 193 | logger.warning("Pixelle-Video already initialized") |
| 194 | return |
| 195 | |
| 196 | logger.info("🚀 Initializing Pixelle-Video...") |
| 197 | |
| 198 | # 1. Initialize core services (ComfyKit will be lazy-loaded later) |
| 199 | # Initialize services |
| 200 | self.llm = LLMService(self.config) |
| 201 | self.tts = TTSService(self.config, core=self) |
| 202 | self.api_media = APIProviderMediaService(self.config, core=self) |
| 203 | self.media = MediaService(self.config, core=self) |
| 204 | self.image = self.media # Alias for backward compatibility |
| 205 | self.image_analysis = ImageAnalysisService(self.config, core=self) |
| 206 | self.video_analysis = VideoAnalysisService(self.config, core=self) |
| 207 | self.api_asset_analysis = APIAssetAnalysisService(self.config, core=self) |
| 208 | self.video = VideoService() |
| 209 | self.frame_processor = FrameProcessor(self) |
| 210 | self.persistence = PersistenceService(output_dir="output") |
| 211 | self.history = HistoryManager(self.persistence) |
| 212 | |
| 213 | # 2. Register video generation pipelines |
| 214 | self.pipelines = { |
| 215 | "standard": StandardPipeline(self), |
| 216 | "custom": CustomPipeline(self), |
| 217 | "asset_based": AssetBasedPipeline(self), |
| 218 | } |
| 219 | logger.info(f"📹 Registered pipelines: {', '.join(self.pipelines.keys())}") |
| 220 | |
| 221 | # 3. Set default pipeline callable (for backward compatibility) |
| 222 | self.generate_video = self._create_generate_video_wrapper() |
| 223 | |
| 224 | self._initialized = True |
| 225 | logger.info("✅ Pixelle-Video initialized successfully\n") |
| 226 | |
| 227 | async def cleanup(self): |
| 228 | """ |
| 229 | Cleanup resources (close ComfyKit session) |
| 230 | |
| 231 | Example: |
| 232 | await pixelle_video.cleanup() |
| 233 | """ |
| 234 | if self._comfykit: |
| 235 | logger.info("🧹 Closing ComfyKit session...") |
| 236 | try: |
| 237 | await self._comfykit.close() |
| 238 | logger.info("✅ ComfyKit session closed") |
| 239 | except Exception as e: |
| 240 | logger.error(f"Failed to close ComfyKit: {e}") |
| 241 | finally: |
| 242 | self._comfykit = None |
| 243 | self._comfykit_config_hash = None |
| 244 | |
| 245 | async def __aenter__(self): |
| 246 | """Async context manager entry""" |
| 247 | await self.initialize() |
| 248 | return self |
| 249 | |
| 250 | async def __aexit__(self, exc_type, exc_val, exc_tb): |
| 251 | """Async context manager exit""" |
| 252 | await self.cleanup() |
| 253 | |
| 254 | def _create_generate_video_wrapper(self): |
| 255 | """ |
| 256 | Create a wrapper function for generate_video that supports pipeline selection |
| 257 | |
| 258 | This maintains backward compatibility while adding pipeline support. |
| 259 | """ |
| 260 | async def generate_video_wrapper( |
| 261 | text: str, |
| 262 | pipeline: str = "standard", |
| 263 | **kwargs |
| 264 | ): |
| 265 | """ |
| 266 | Generate video using specified pipeline |
| 267 | |
| 268 | Args: |
| 269 | text: Input text |
| 270 | pipeline: Pipeline name ("standard", "book_summary", etc.) |
| 271 | **kwargs: Pipeline-specific parameters |
| 272 | |
| 273 | Returns: |
| 274 | VideoGenerationResult |
| 275 | |
| 276 | Examples: |
| 277 | # Use standard pipeline (default) |
| 278 | result = await pixelle_video.generate_video( |
| 279 | text="如何提高学习效率", |
| 280 | n_scenes=5 |
| 281 | ) |
| 282 | |
| 283 | # Use custom pipeline |
| 284 | result = await pixelle_video.generate_video( |
| 285 | text=your_content, |
| 286 | pipeline="custom", |
| 287 | custom_param_example="custom_value" |
| 288 | ) |
| 289 | """ |
| 290 | if pipeline not in self.pipelines: |
| 291 | available = ", ".join(self.pipelines.keys()) |
| 292 | raise ValueError( |
| 293 | f"Unknown pipeline: '{pipeline}'. " |
| 294 | f"Available pipelines: {available}" |
| 295 | ) |
| 296 | |
| 297 | pipeline_instance = self.pipelines[pipeline] |
| 298 | return await pipeline_instance(text=text, **kwargs) |
| 299 | |
| 300 | return generate_video_wrapper |
| 301 | |
| 302 | @property |
| 303 | def project_name(self) -> str: |
| 304 | """Get project name from config""" |
| 305 | return self.config.get("project_name", "Pixelle-Video") |
| 306 | |
| 307 | def __repr__(self) -> str: |
| 308 | """String representation""" |
| 309 | status = "initialized" if self._initialized else "not initialized" |
| 310 | pipelines = f"pipelines={list(self.pipelines.keys())}" if self._initialized else "" |
| 311 | return f"<PixelleVideoCore project={self.project_name!r} status={status} {pipelines}>" |
| 312 | |
| 313 | |
| 314 | # Global instance |
| 315 | pixelle_video = PixelleVideoCore() |
| 316 |