返回 Pixelle-Video
media.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 Media Generation Service - ComfyUI Workflow-based implementation
15
16 Supports both image and video generation workflows.
17 Automatically detects output type based on ExecuteResult.
18 """
19
20 from typing import Optional
21
22 from comfykit import ComfyKit
23 from loguru import logger
24
25 from pixelle_video.services.comfy_base_service import ComfyBaseService
26 from pixelle_video.models.media import MediaResult
27
28
29 class MediaService(ComfyBaseService):
30 """
31 Media generation service - Workflow-based
32
33 Uses ComfyKit to execute image/video generation workflows.
34 Supports both image_ and video_ workflow prefixes.
35
36 Usage:
37 # Use default workflow (workflows/image_flux.json)
38 media = await pixelle_video.media(prompt="a cat")
39 if media.is_image:
40 print(f"Generated image: {media.url}")
41 elif media.is_video:
42 print(f"Generated video: {media.url} ({media.duration}s)")
43
44 # Use specific workflow
45 media = await pixelle_video.media(
46 prompt="a cat",
47 workflow="image_flux.json"
48 )
49
50 # List available workflows
51 workflows = pixelle_video.media.list_workflows()
52 """
53
54 WORKFLOW_PREFIX = "" # Will be overridden by _scan_workflows
55 DEFAULT_WORKFLOW = None # No hardcoded default, must be configured
56 WORKFLOWS_DIR = "workflows"
57
58 def __init__(self, config: dict, core=None):
59 """
60 Initialize media service
61
62 Args:
63 config: Full application config dict
64 core: PixelleVideoCore instance (for accessing shared ComfyKit)
65 """
66 super().__init__(config, service_name="image", core=core) # Keep "image" for config compatibility
67
68 def _scan_workflows(self):
69 """
70 Scan workflows for both image_ and video_ prefixes
71
72 Override parent method to support multiple prefixes
73 """
74 from pixelle_video.utils.os_util import list_resource_dirs, list_resource_files, get_resource_path
75 from pathlib import Path
76
77 workflows = []
78
79 # Get all workflow source directories
80 source_dirs = list_resource_dirs("workflows")
81
82 if not source_dirs:
83 logger.warning("No workflow source directories found")
84 return workflows
85
86 # Scan each source directory for workflow files
87 for source_name in source_dirs:
88 # Get all JSON files for this source
89 workflow_files = list_resource_files("workflows", source_name)
90
91 # Filter to only files matching image_ or video_ prefix
92 matching_files = [
93 f for f in workflow_files
94 if (f.startswith("image_") or f.startswith("video_")) and f.endswith('.json')
95 ]
96
97 for filename in matching_files:
98 try:
99 # Get actual file path
100 file_path = Path(get_resource_path("workflows", source_name, filename))
101 workflow_info = self._parse_workflow_file(file_path, source_name)
102 workflows.append(workflow_info)
103 logger.debug(f"Found workflow: {workflow_info['key']}")
104 except Exception as e:
105 logger.error(f"Failed to parse workflow {source_name}/{filename}: {e}")
106
107 # Sort by key (source/name)
108 return sorted(workflows, key=lambda w: w["key"])
109
110 def list_workflows(self) -> list[dict]:
111 """List Comfy/RunningHub/Selfhost workflows only.
112
113 Direct provider models are exposed through core.api_media.list_workflows()
114 so UI code can keep local workflows and API models in separate selectors.
115 """
116 return super().list_workflows()
117
118 async def __call__(
119 self,
120 prompt: str,
121 workflow: Optional[str] = None,
122 # Media type specification (required for proper handling)
123 media_type: str = "image", # "image" or "video"
124 # ComfyUI connection (optional overrides)
125 comfyui_url: Optional[str] = None,
126 runninghub_api_key: Optional[str] = None,
127 # Common workflow parameters
128 width: Optional[int] = None,
129 height: Optional[int] = None,
130 duration: Optional[float] = None, # Video duration in seconds (for video workflows)
131 output_path: Optional[str] = None,
132 image_path: Optional[str] = None,
133 negative_prompt: Optional[str] = None,
134 steps: Optional[int] = None,
135 seed: Optional[int] = None,
136 cfg: Optional[float] = None,
137 sampler: Optional[str] = None,
138 **params
139 ) -> MediaResult:
140 """
141 Generate media (image or video) using workflow
142
143 Media type must be specified explicitly via media_type parameter.
144 Returns a MediaResult object containing media type and URL.
145
146 Args:
147 prompt: Media generation prompt
148 workflow: Workflow filename (default: from config or "image_flux.json")
149 media_type: Type of media to generate - "image" or "video" (default: "image")
150 comfyui_url: ComfyUI URL (optional, overrides config)
151 runninghub_api_key: RunningHub API key (optional, overrides config)
152 width: Media width
153 height: Media height
154 duration: Target video duration in seconds (only for video workflows, typically from TTS audio duration)
155 negative_prompt: Negative prompt
156 steps: Sampling steps
157 seed: Random seed
158 cfg: CFG scale
159 sampler: Sampler name
160 **params: Additional workflow parameters
161
162 Returns:
163 MediaResult object with media_type ("image" or "video") and url
164
165 Examples:
166 # Simplest: use default workflow (workflows/image_flux.json)
167 media = await pixelle_video.media(prompt="a beautiful cat")
168 if media.is_image:
169 print(f"Image: {media.url}")
170
171 # Use specific workflow
172 media = await pixelle_video.media(
173 prompt="a cat",
174 workflow="image_flux.json"
175 )
176
177 # Video workflow
178 media = await pixelle_video.media(
179 prompt="a cat running",
180 workflow="image_video.json"
181 )
182 if media.is_video:
183 print(f"Video: {media.url}, duration: {media.duration}s")
184
185 # With additional parameters
186 media = await pixelle_video.media(
187 prompt="a cat",
188 workflow="image_flux.json",
189 width=1024,
190 height=1024,
191 steps=20,
192 seed=42
193 )
194
195 # With absolute path
196 media = await pixelle_video.media(
197 prompt="a cat",
198 workflow="/path/to/custom.json"
199 )
200
201 # With custom ComfyUI server
202 media = await pixelle_video.media(
203 prompt="a cat",
204 comfyui_url="http://192.168.1.100:8188"
205 )
206 """
207 selected_workflow = workflow or self.config.get("default_workflow")
208 if selected_workflow and selected_workflow.startswith("api/"):
209 if not self.core or not getattr(self.core, "api_media", None):
210 raise RuntimeError("API media service is not initialized")
211 return await self.core.api_media(
212 prompt=prompt,
213 workflow=selected_workflow,
214 media_type=media_type,
215 width=width,
216 height=height,
217 duration=duration,
218 output_path=output_path,
219 image_path=image_path,
220 negative_prompt=negative_prompt,
221 steps=steps,
222 seed=seed,
223 cfg=cfg,
224 sampler=sampler,
225 **params
226 )
227
228 # 1. Resolve workflow (returns structured info)
229 workflow_info = self._resolve_workflow(workflow=workflow)
230
231 # 2. Build workflow parameters (ComfyKit config is now managed by core)
232 workflow_params = {"prompt": prompt}
233
234 # Add optional parameters
235 if width is not None:
236 workflow_params["width"] = width
237 if height is not None:
238 workflow_params["height"] = height
239 if duration is not None:
240 workflow_params["duration"] = duration
241 if media_type == "video":
242 logger.info(f"📏 Target video duration: {duration:.2f}s (from TTS audio)")
243 if negative_prompt is not None:
244 workflow_params["negative_prompt"] = negative_prompt
245 if steps is not None:
246 workflow_params["steps"] = steps
247 if seed is not None:
248 workflow_params["seed"] = seed
249 if cfg is not None:
250 workflow_params["cfg"] = cfg
251 if sampler is not None:
252 workflow_params["sampler"] = sampler
253
254 # Add any additional parameters
255 workflow_params.update(params)
256
257 logger.debug(f"Workflow parameters: {workflow_params}")
258
259 # 4. Execute workflow using shared ComfyKit instance from core
260 try:
261 # Get shared ComfyKit instance (lazy initialization + config hot-reload)
262 kit = await self.core._get_or_create_comfykit()
263
264 # Determine what to pass to ComfyKit based on source
265 if workflow_info["source"] == "runninghub" and "workflow_id" in workflow_info:
266 # RunningHub: pass workflow_id (ComfyKit will use runninghub backend)
267 workflow_input = workflow_info["workflow_id"]
268 logger.info(f"Executing RunningHub workflow: {workflow_input}")
269 else:
270 # Selfhost: pass file path (ComfyKit will use local ComfyUI)
271 workflow_input = workflow_info["path"]
272 logger.info(f"Executing selfhost workflow: {workflow_input}")
273
274 result = await kit.execute(workflow_input, workflow_params)
275
276 # 5. Handle result based on specified media_type
277 if result.status != "completed":
278 error_msg = result.msg or "Unknown error"
279 logger.error(f"Media generation failed: {error_msg}")
280 raise Exception(f"Media generation failed: {error_msg}")
281
282 # Extract media based on specified type
283 if media_type == "video":
284 # Video workflow - get video from result
285 if not result.videos:
286 logger.error("No video generated (workflow returned no videos)")
287 raise Exception("No video generated")
288
289 video_url = result.videos[0]
290 logger.info(f"✅ Generated video: {video_url}")
291
292 # Try to extract duration from result (if available)
293 duration = None
294 if hasattr(result, 'duration') and result.duration:
295 duration = result.duration
296
297 return MediaResult(
298 media_type="video",
299 url=video_url,
300 duration=duration
301 )
302 else: # image
303 # Image workflow - get image from result
304 if not result.images:
305 logger.error("No image generated (workflow returned no images)")
306 raise Exception("No image generated")
307
308 image_url = result.images[0]
309 logger.info(f"✅ Generated image: {image_url}")
310
311 return MediaResult(
312 media_type="image",
313 url=image_url
314 )
315
316 except Exception as e:
317 logger.error(f"Media generation error: {e}")
318 raise
319
319 lines PYTHON