返回 Pixelle-Video
video.py
根目录 / api / routers / video.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 Video generation endpoints
15
16 Supports both synchronous and asynchronous video generation.
17 """
18
19 import os
20 from fastapi import APIRouter, HTTPException, Request
21 from loguru import logger
22
23 from api.dependencies import PixelleVideoDep
24 from api.schemas.video import (
25 VideoGenerateRequest,
26 VideoGenerateResponse,
27 VideoGenerateAsyncResponse,
28 )
29 from api.tasks import task_manager, TaskType
30
31 router = APIRouter(prefix="/video", tags=["Video Generation"])
32
33
34 def path_to_url(request: Request, file_path: str) -> str:
35 """
36 Convert file path to accessible URL
37
38 Handles both absolute and relative paths, extracting the path relative
39 to the output directory for URL construction.
40
41 Args:
42 request: FastAPI Request object (provides base_url from actual request)
43 file_path: Absolute or relative file path
44
45 Returns:
46 Full URL to access the file
47
48 Examples:
49 Windows: G:\\...\\output\\20251205_233630_c939\\final.mp4
50 -> http://localhost:8000/api/files/20251205_233630_c939/final.mp4
51
52 Linux: /home/user/.../output/20251205_233630_c939/final.mp4
53 -> http://localhost:8000/api/files/20251205_233630_c939/final.mp4
54
55 Domain: With domain request -> https://your-domain.com/api/files/...
56 """
57 from pathlib import Path
58 import os
59
60 # Normalize path separators to forward slashes first (for cross-platform compatibility)
61 file_path = file_path.replace("\\", "/")
62
63 # Check if it's an absolute path (works for both Windows and Linux)
64 is_absolute = os.path.isabs(file_path) or Path(file_path).is_absolute()
65
66 if is_absolute:
67 # Find "output" in the path and get everything after it
68 # Split by / to work with normalized paths
69 parts = file_path.split("/")
70 try:
71 output_idx = parts.index("output")
72 # Get all parts after "output" and join them
73 relative_parts = parts[output_idx + 1:]
74 file_path = "/".join(relative_parts)
75 except ValueError:
76 # If "output" not in path, use the filename only
77 file_path = Path(file_path).name
78 else:
79 # If relative path starting with "output/", remove it
80 if file_path.startswith("output/"):
81 file_path = file_path[7:] # Remove "output/"
82
83 # Build URL using request's base_url (automatically matches the request host)
84 base_url = str(request.base_url).rstrip('/')
85 return f"{base_url}/api/files/{file_path}"
86
87
88 @router.post("/generate/sync", response_model=VideoGenerateResponse)
89 async def generate_video_sync(
90 request_body: VideoGenerateRequest,
91 pixelle_video: PixelleVideoDep,
92 request: Request
93 ):
94 """
95 Generate video synchronously
96
97 This endpoint blocks until video generation is complete.
98 Suitable for small videos (< 30 seconds).
99
100 **Note**: May timeout for large videos. Use `/generate/async` instead.
101
102 Request body includes all video generation parameters.
103 See VideoGenerateRequest schema for details.
104
105 Returns path to generated video, duration, and file size.
106 """
107 try:
108 logger.info(f"Sync video generation: {request_body.text[:50]}...")
109
110 # Auto-determine media_width and media_height from template meta tags (required)
111 if not request_body.frame_template:
112 raise ValueError("frame_template is required to determine media size")
113
114 from pixelle_video.services.frame_html import HTMLFrameGenerator
115 from pixelle_video.utils.template_util import resolve_template_path
116 template_path = resolve_template_path(request_body.frame_template)
117 generator = HTMLFrameGenerator(template_path)
118 media_width, media_height = generator.get_media_size()
119 logger.debug(f"Auto-determined media size from template: {media_width}x{media_height}")
120
121 # Build video generation parameters
122 video_params = {
123 "text": request_body.text,
124 "mode": request_body.mode,
125 "title": request_body.title,
126 "n_scenes": request_body.n_scenes,
127 "min_narration_words": request_body.min_narration_words,
128 "max_narration_words": request_body.max_narration_words,
129 "min_image_prompt_words": request_body.min_image_prompt_words,
130 "max_image_prompt_words": request_body.max_image_prompt_words,
131 "media_width": media_width,
132 "media_height": media_height,
133 "media_workflow": request_body.media_workflow,
134 "video_fps": request_body.video_fps,
135 "frame_template": request_body.frame_template,
136 "prompt_prefix": request_body.prompt_prefix,
137 "bgm_path": request_body.bgm_path,
138 "bgm_volume": request_body.bgm_volume,
139 }
140
141 # Add TTS workflow if specified
142 if request_body.tts_workflow:
143 video_params["tts_workflow"] = request_body.tts_workflow
144
145 # Add ref_audio if specified
146 if request_body.ref_audio:
147 video_params["ref_audio"] = request_body.ref_audio
148
149 # Legacy voice_id support (deprecated)
150 if request_body.voice_id:
151 logger.warning("voice_id parameter is deprecated, please use tts_workflow instead")
152 video_params["voice_id"] = request_body.voice_id
153
154 # Add custom template parameters if specified
155 if request_body.template_params:
156 video_params["template_params"] = request_body.template_params
157
158 # Call video generator service
159 result = await pixelle_video.generate_video(**video_params)
160
161 # Get file size
162 file_size = os.path.getsize(result.video_path) if os.path.exists(result.video_path) else 0
163
164 # Convert path to URL
165 video_url = path_to_url(request, result.video_path)
166
167 return VideoGenerateResponse(
168 video_url=video_url,
169 duration=result.duration,
170 file_size=file_size
171 )
172
173 except Exception as e:
174 logger.error(f"Sync video generation error: {e}")
175 raise HTTPException(status_code=500, detail=str(e))
176
177
178 @router.post("/generate/async", response_model=VideoGenerateAsyncResponse)
179 async def generate_video_async(
180 request_body: VideoGenerateRequest,
181 pixelle_video: PixelleVideoDep,
182 request: Request
183 ):
184 """
185 Generate video asynchronously
186
187 Creates a background task for video generation.
188 Returns immediately with a task_id for tracking progress.
189
190 **Workflow:**
191 1. Submit video generation request
192 2. Receive task_id in response
193 3. Poll `/api/tasks/{task_id}` to check status
194 4. When status is "completed", retrieve video from result
195
196 Request body includes all video generation parameters.
197 See VideoGenerateRequest schema for details.
198
199 Returns task_id for tracking progress.
200 """
201 try:
202 logger.info(f"Async video generation: {request_body.text[:50]}...")
203
204 # Create task
205 task = task_manager.create_task(
206 task_type=TaskType.VIDEO_GENERATION,
207 request_params=request_body.model_dump()
208 )
209
210 # Define async execution function
211 async def execute_video_generation():
212 """Execute video generation in background"""
213 # Auto-determine media_width and media_height from template meta tags (required)
214 if not request_body.frame_template:
215 raise ValueError("frame_template is required to determine media size")
216
217 from pixelle_video.services.frame_html import HTMLFrameGenerator
218 from pixelle_video.utils.template_util import resolve_template_path
219 template_path = resolve_template_path(request_body.frame_template)
220 generator = HTMLFrameGenerator(template_path)
221 media_width, media_height = generator.get_media_size()
222 logger.debug(f"Auto-determined media size from template: {media_width}x{media_height}")
223
224 # Build video generation parameters
225 video_params = {
226 "text": request_body.text,
227 "mode": request_body.mode,
228 "title": request_body.title,
229 "n_scenes": request_body.n_scenes,
230 "min_narration_words": request_body.min_narration_words,
231 "max_narration_words": request_body.max_narration_words,
232 "min_image_prompt_words": request_body.min_image_prompt_words,
233 "max_image_prompt_words": request_body.max_image_prompt_words,
234 "media_width": media_width,
235 "media_height": media_height,
236 "media_workflow": request_body.media_workflow,
237 "video_fps": request_body.video_fps,
238 "frame_template": request_body.frame_template,
239 "prompt_prefix": request_body.prompt_prefix,
240 "bgm_path": request_body.bgm_path,
241 "bgm_volume": request_body.bgm_volume,
242 # Progress callback can be added here if needed
243 # "progress_callback": lambda event: task_manager.update_progress(...)
244 }
245
246 # Add TTS workflow if specified
247 if request_body.tts_workflow:
248 video_params["tts_workflow"] = request_body.tts_workflow
249
250 # Add ref_audio if specified
251 if request_body.ref_audio:
252 video_params["ref_audio"] = request_body.ref_audio
253
254 # Legacy voice_id support (deprecated)
255 if request_body.voice_id:
256 logger.warning("voice_id parameter is deprecated, please use tts_workflow instead")
257 video_params["voice_id"] = request_body.voice_id
258
259 # Add custom template parameters if specified
260 if request_body.template_params:
261 video_params["template_params"] = request_body.template_params
262
263 result = await pixelle_video.generate_video(**video_params)
264
265 # Get file size
266 file_size = os.path.getsize(result.video_path) if os.path.exists(result.video_path) else 0
267
268 # Convert path to URL
269 video_url = path_to_url(request, result.video_path)
270
271 return {
272 "video_url": video_url,
273 "duration": result.duration,
274 "file_size": file_size
275 }
276
277 # Start execution
278 await task_manager.execute_task(
279 task_id=task.task_id,
280 coro_func=execute_video_generation
281 )
282
283 return VideoGenerateAsyncResponse(
284 task_id=task.task_id
285 )
286
287 except Exception as e:
288 logger.error(f"Async video generation error: {e}")
289 raise HTTPException(status_code=500, detail=str(e))
290
291
291 lines PYTHON