返回 VideoClaw
video_client.py
根目录 / video-claw / video-claw / backend / models / video_client.py
1 """
2 统一视频生成客户端
3 根据 model 名称自动路由到对应后端:
4 - wan* → DashscopeVideoClient (DashScope VideoSynthesis)
5 - kling* → KlingVideoClient (可灵 AI)
6 """
7
8 import os
9 import sys
10
11 models_dir = os.path.dirname(os.path.abspath(__file__))
12 backend_dir = os.path.dirname(models_dir)
13 if backend_dir not in sys.path:
14 sys.path.insert(0, backend_dir)
15
16 import logging
17 from typing import Optional
18 from config import Config
19
20 try:
21 from models.video_dashscope import DashscopeVideoClient
22 from models.video_kling import KlingVideoClient
23 from models.video_seedance import SeedanceVideoClient
24 except ImportError:
25 from video_dashscope import DashscopeVideoClient
26 from video_kling import KlingVideoClient
27 from video_seedance import SeedanceVideoClient
28
29 logger = logging.getLogger(__name__)
30
31
32 class VideoClient:
33 """
34 统一视频生成客户端
35 参照 ImageClient 模式,按模型名路由到不同后端
36 """
37
38 def __init__(
39 self,
40 dashscope_api_key: Optional[str] = None,
41 dashscope_base_url: Optional[str] = None,
42 kling_api_key: Optional[str] = None,
43 kling_base_url: Optional[str] = None,
44 ark_api_key: Optional[str] = None,
45 ark_base_url: Optional[str] = None,
46 ):
47 self._dashscope_api_key = dashscope_api_key or Config.DASHSCOPE_API_KEY
48 self._dashscope_base_url = dashscope_base_url or Config.DASHSCOPE_BASE_URL
49 self._kling_api_key = kling_api_key or Config.KLING_API_KEY
50 self._kling_base_url = kling_base_url or Config.KLING_BASE_URL
51 self._ark_api_key = ark_api_key or Config.ARK_API_KEY
52 self._ark_base_url = ark_base_url or Config.ARK_BASE_URL
53
54 self._dashscope_client = None
55 self._kling_client = None
56 self._seedance_client = None
57
58 @property
59 def Dashscope_client(self):
60 if self._dashscope_client is None:
61 self._dashscope_client = DashscopeVideoClient(
62 api_key=self._dashscope_api_key,
63 base_url=self._dashscope_base_url,
64 )
65 return self._dashscope_client
66
67 @property
68 def kling_client(self):
69 if self._kling_client is None:
70 self._kling_client = KlingVideoClient(
71 api_key=self._kling_api_key,
72 base_url=self._kling_base_url,
73 )
74 return self._kling_client
75
76 @property
77 def seedance_client(self):
78 if self._seedance_client is None:
79 self._seedance_client = SeedanceVideoClient(
80 api_key=self._ark_api_key,
81 base_url=self._ark_base_url,
82 )
83 return self._seedance_client
84
85 def generate_video(
86 self,
87 prompt: str,
88 image_path: Optional[str],
89 save_path: str,
90 model: str = "wan2.7-i2v",
91 duration: int = 5,
92 shot_type: str = "multi",
93 sound: str = "",
94 video_ratio: str = "16:9",
95 resolution: Optional[str] = None,
96 last_image_path: Optional[str] = None,
97 first_clip_path: Optional[str] = None,
98 reference_image_path: Optional[str] = None,
99 reference_image_paths: Optional[list[str]] = None,
100 reference_video_paths: Optional[list[str]] = None,
101 reference_audio_path: Optional[str] = None,
102 audio_path: Optional[str] = None,
103 negative_prompt: Optional[str] = None,
104 prompt_extend: Optional[bool] = None,
105 watermark: Optional[bool] = None,
106 seed: Optional[int] = None,
107 mode: str = "pro",
108 cfg_scale: float = 0.5,
109 generate_audio: Optional[bool] = None,
110 audio: Optional[bool] = None,
111 ) -> str:
112 """
113 生成视频
114
115 Args:
116 prompt: 视频描述提示词
117 image_path: 输入图片本地路径;DashScope wan2.7 视频续写可为空并使用 first_clip_path
118 save_path: 输出视频保存路径
119 model: 模型名,决定使用哪个后端
120 duration: 视频时长(秒)
121 shot_type: 镜头类型 "single" / "multi"
122
123 Returns:
124 video_url: 远端视频 URL
125
126 Raises:
127 FileNotFoundError: 输入图片不存在
128 RuntimeError: 生成或下载失败
129 """
130 if not model:
131 model = "wan2.7-i2v"
132
133 # 确保 duration 是整数,视频模型通常要求整数秒
134 duration = int(duration)
135
136 if Config.PRINT_MODEL_INPUT:
137 lines = [
138 "---- VIDEO GENERATION REQUEST ----",
139 f"Prompt: {prompt}",
140 "Image: [Base64图片]" if image_path and str(image_path).startswith("data:") else f"Image: {image_path}",
141 f"Model: {model}",
142 f"Duration: {duration}s",
143 f"Shot Type: {shot_type}",
144 f"Video Ratio: {video_ratio}",
145 ]
146 if resolution:
147 lines.append(f"Resolution: {resolution}")
148 if last_image_path:
149 lines.append(f"Last Image: {last_image_path}")
150 if first_clip_path:
151 lines.append(f"First Clip: {first_clip_path}")
152 if reference_image_path:
153 lines.append(f"Reference Image: {reference_image_path}")
154 if reference_image_paths:
155 lines.append(f"Reference Images: {reference_image_paths}")
156 if reference_video_paths:
157 lines.append(f"Reference Videos: {reference_video_paths}")
158 if reference_audio_path:
159 lines.append(f"Reference Audio: {reference_audio_path}")
160 if audio_path:
161 lines.append(f"Audio: {audio_path}")
162 if negative_prompt:
163 lines.append(f"Negative Prompt: {negative_prompt}")
164 if sound:
165 lines.append(f"Sound: {sound}")
166 lines.extend([
167 f"Save: {save_path}",
168 "-" * 30,
169 ])
170 logger.info("\n%s", "\n".join(lines))
171
172 model_lower = model.lower()
173
174 if "kling" in model_lower:
175 return self._generate_kling(
176 prompt,
177 image_path,
178 save_path,
179 model,
180 duration,
181 sound,
182 video_ratio,
183 resolution,
184 mode,
185 cfg_scale,
186 negative_prompt or "",
187 )
188 elif "seedance" in model_lower:
189 return self._generate_seedance(
190 prompt,
191 image_path,
192 save_path,
193 model,
194 duration,
195 video_ratio,
196 resolution,
197 seed,
198 watermark,
199 generate_audio,
200 )
201 elif "wan" in model_lower or "happyhorse" in model_lower:
202 return self._generate_wan(
203 prompt,
204 image_path,
205 save_path,
206 model,
207 duration,
208 shot_type,
209 video_ratio,
210 last_image_path,
211 first_clip_path,
212 reference_image_path,
213 reference_image_paths,
214 reference_video_paths,
215 reference_audio_path,
216 audio_path,
217 negative_prompt,
218 resolution,
219 prompt_extend,
220 watermark if watermark is not None else False,
221 seed,
222 audio,
223 )
224 else:
225 raise ValueError(f"未知的视频生成模型: {model}")
226
227 @staticmethod
228 def _normalize_seedance_resolution(resolution: Optional[str]) -> str:
229 value = (resolution or "720p").strip().lower()
230 return value if value in {"720p", "1080p"} else "720p"
231
232 def _generate_wan(
233 self,
234 prompt: str,
235 image_path: Optional[str],
236 save_path: str,
237 model: str,
238 duration: int,
239 shot_type: str,
240 video_ratio: str,
241 last_image_path: Optional[str],
242 first_clip_path: Optional[str],
243 reference_image_path: Optional[str],
244 reference_image_paths: Optional[list[str]],
245 reference_video_paths: Optional[list[str]],
246 reference_audio_path: Optional[str],
247 audio_path: Optional[str],
248 negative_prompt: Optional[str],
249 resolution: Optional[str],
250 prompt_extend: Optional[bool],
251 watermark: bool,
252 seed: Optional[int],
253 audio: Optional[bool],
254 ) -> str:
255 """通过万象模型生成视频"""
256 # 设置 Wan 和 Happyhorse 系列视频生成的默认分辨率为 720P
257 resolution = resolution or "720P"
258
259 logger.info("VideoClient routed to Wan: model=%s", model)
260 return self.Dashscope_client.generate_video(
261 prompt=prompt,
262 image_path=image_path,
263 save_path=save_path,
264 model=model,
265 duration=duration,
266 shot_type=shot_type,
267 video_ratio=video_ratio,
268 last_image_path=last_image_path,
269 first_clip_path=first_clip_path,
270 reference_image_path=reference_image_path,
271 reference_image_paths=reference_image_paths,
272 reference_video_paths=reference_video_paths,
273 reference_audio_path=reference_audio_path,
274 audio_path=audio_path,
275 negative_prompt=negative_prompt,
276 resolution=resolution,
277 prompt_extend=prompt_extend,
278 watermark=watermark,
279 seed=seed,
280 audio=audio,
281 )
282
283 def _generate_kling(
284 self,
285 prompt: str,
286 image_path: Optional[str],
287 save_path: str,
288 model: str,
289 duration: int = 5,
290 sound: str = "",
291 video_ratio: str = "16:9",
292 resolution: Optional[str] = None,
293 mode: str = "pro",
294 cfg_scale: float = 0.5,
295 negative_prompt: str = "",
296 ) -> str:
297 """通过可灵模型生成视频"""
298 logger.info("VideoClient routed to Kling: model=%s", model)
299 return self.kling_client.generate_video(
300 prompt=prompt,
301 image_path=image_path,
302 save_path=save_path,
303 model=model,
304 duration=duration,
305 sound=sound,
306 video_ratio=video_ratio,
307 resolution=resolution,
308 mode=mode,
309 cfg_scale=cfg_scale,
310 negative_prompt=negative_prompt,
311 )
312
313 def _generate_seedance(
314 self,
315 prompt: str,
316 image_path: Optional[str],
317 save_path: str,
318 model: str,
319 duration: int = 5,
320 video_ratio: str = "16:9",
321 resolution: Optional[str] = None,
322 seed: Optional[int] = None,
323 watermark: Optional[bool] = None,
324 generate_audio: Optional[bool] = None,
325 ) -> str:
326 """通过 Seedance 模型生成视频"""
327 logger.info("VideoClient routed to Seedance: model=%s", model)
328 return self.seedance_client.generate_video(
329 prompt=prompt,
330 image_path=image_path,
331 save_path=save_path,
332 model=model,
333 duration=duration,
334 ratio=video_ratio,
335 resolution=self._normalize_seedance_resolution(resolution),
336 seed=seed,
337 watermark=watermark,
338 generate_audio=generate_audio,
339 )
340
341
342 def _split_csv(value: Optional[str]) -> list[str]:
343 if not value:
344 return []
345 return [item.strip() for item in value.split(",") if item.strip()]
346
347
348 def _str_to_bool(value: Optional[str]) -> Optional[bool]:
349 if value is None:
350 return None
351 normalized = value.strip().lower()
352 if normalized in {"1", "true", "yes", "on"}:
353 return True
354 if normalized in {"0", "false", "no", "off"}:
355 return False
356 raise ValueError(f"Invalid boolean value: {value}")
357
358
359 def _default_save_path(model: str, generation_mode: str) -> str:
360 safe_model = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in model)
361 return os.path.join(Config.RESULT_DIR, "video", "test_client", f"{safe_model}_{generation_mode}.mp4")
362
363
364 def _build_cli_parser():
365 import argparse
366
367 parser = argparse.ArgumentParser(description="Test VideoClient with a selected generation mode and model.")
368 parser.add_argument("--generation-mode", choices=["first_frame", "start_end_frame", "reference"], default="first_frame")
369 parser.add_argument("--model", default="wan2.7-i2v", help="Video model id, e.g. wan2.7-i2v / wan2.7-r2v / happyhorse-1.0-r2v")
370 parser.add_argument("--prompt", default="电影感画面,人物自然移动,镜头稳定推进,不要字幕或水印。")
371 parser.add_argument("--image", help="First-frame image path for first_frame/start_end_frame, or an extra reference image for reference mode.")
372 parser.add_argument("--last-image", help="Last-frame image path for start_end_frame mode.")
373 parser.add_argument("--reference-images", help="Comma-separated reference image paths for reference mode.")
374 parser.add_argument("--reference-videos", help="Comma-separated reference video paths for reference mode.")
375 parser.add_argument("--reference-audio", help="Reference audio path for supported reference-to-video models.")
376 parser.add_argument("--audio", help="Driving audio path for supported image-to-video models.")
377 parser.add_argument("--first-clip", help="First clip path for supported video continuation models.")
378 parser.add_argument("--save-path", help="Output mp4 path. Defaults to code/result/video/test_client/<model>_<mode>.mp4")
379 parser.add_argument("--duration", type=int, default=5)
380 parser.add_argument("--ratio", default="16:9")
381 parser.add_argument("--resolution", default="720P")
382 parser.add_argument("--shot-type", default="multi")
383 parser.add_argument("--sound", default="")
384 parser.add_argument("--negative-prompt", default="")
385 parser.add_argument("--prompt-extend", choices=["true", "false"])
386 parser.add_argument("--watermark", choices=["true", "false"])
387 parser.add_argument("--seed", type=int)
388 parser.add_argument("--mode", default="pro", help="Kling mode fallback: std/pro.")
389 parser.add_argument("--cfg-scale", type=float, default=0.5)
390 parser.add_argument("--generate-audio", choices=["true", "false"])
391 parser.add_argument("--audio-enabled", choices=["true", "false"], help="Pass DashScope audio boolean for supported models.")
392 return parser
393
394
395 def _cli_generate(args) -> str:
396 reference_image_paths = _split_csv(args.reference_images)
397 reference_video_paths = _split_csv(args.reference_videos)
398 image_path = args.image
399 last_image_path = None
400
401 if args.generation_mode == "first_frame":
402 if not image_path and not args.first_clip:
403 raise ValueError("first_frame mode requires --image or --first-clip.")
404 elif args.generation_mode == "start_end_frame":
405 if not image_path or not args.last_image:
406 raise ValueError("start_end_frame mode requires --image and --last-image.")
407 last_image_path = args.last_image
408 elif args.generation_mode == "reference":
409 if image_path:
410 reference_image_paths.append(image_path)
411 image_path = None
412 if not reference_image_paths and not reference_video_paths:
413 raise ValueError("reference mode requires --reference-images, --reference-videos, or --image.")
414
415 save_path = args.save_path or _default_save_path(args.model, args.generation_mode)
416 client = VideoClient()
417 return client.generate_video(
418 prompt=args.prompt,
419 image_path=image_path,
420 save_path=save_path,
421 model=args.model,
422 duration=args.duration,
423 shot_type=args.shot_type,
424 sound=args.sound,
425 video_ratio=args.ratio,
426 resolution=args.resolution,
427 last_image_path=last_image_path,
428 first_clip_path=args.first_clip,
429 reference_image_paths=reference_image_paths or None,
430 reference_video_paths=reference_video_paths or None,
431 reference_audio_path=args.reference_audio,
432 audio_path=args.audio,
433 negative_prompt=args.negative_prompt or None,
434 prompt_extend=_str_to_bool(args.prompt_extend),
435 watermark=_str_to_bool(args.watermark),
436 seed=args.seed,
437 mode=args.mode,
438 cfg_scale=args.cfg_scale,
439 generate_audio=_str_to_bool(args.generate_audio),
440 audio=_str_to_bool(args.audio_enabled),
441 )
442
443
444 if __name__ == "__main__":
445 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
446 parser = _build_cli_parser()
447 cli_args = parser.parse_args()
448 try:
449 remote_url = _cli_generate(cli_args)
450 output_path = cli_args.save_path or _default_save_path(cli_args.model, cli_args.generation_mode)
451 print("✓ Video generation completed")
452 print(f" Remote URL: {remote_url}")
453 print(f" Local file: {os.path.abspath(output_path)}")
454 except Exception as exc:
455 print(f"✗ Video generation failed: {exc}", file=sys.stderr)
456 sys.exit(1)
457
457 lines PYTHON