返回 MoneyPrinterTurbo
material.py
根目录 / app / services / material.py
1 import os
2 import random
3 import threading
4 from typing import List
5 from urllib.parse import urlencode
6
7 import requests
8 from loguru import logger
9 from moviepy.video.io.VideoFileClip import VideoFileClip
10
11 from app.config import config
12 from app.models.schema import MaterialInfo, VideoAspect, VideoConcatMode
13 from app.utils import utils
14
15 # Thread-safe counter for API key rotation
16 _api_key_counter = 0
17 _api_key_lock = threading.Lock()
18
19
20 def _get_tls_verify() -> bool:
21 # 默认开启 TLS 证书校验,防止素材搜索和下载过程被中间人篡改。
22 # 仅在企业代理、自签证书等明确需要的场景下,允许用户通过
23 # `config.toml` 显式设置 `tls_verify = false` 临时关闭。
24 tls_verify = config.app.get("tls_verify", True)
25 if isinstance(tls_verify, str):
26 tls_verify = tls_verify.strip().lower() not in ("0", "false", "no", "off")
27
28 if not tls_verify:
29 logger.warning(
30 "TLS certificate verification is disabled by config.app.tls_verify=false. "
31 "Only use this in trusted proxy environments."
32 )
33
34 return bool(tls_verify)
35
36
37 def get_api_key(cfg_key: str):
38 api_keys = config.app.get(cfg_key)
39 if not api_keys:
40 raise ValueError(
41 f"\n\n##### {cfg_key} is not set #####\n\nPlease set it in the config.toml file: {config.config_file}\n\n"
42 f"{utils.to_json(config.app)}"
43 )
44
45 # if only one key is provided, return it
46 if isinstance(api_keys, str):
47 return api_keys
48
49 global _api_key_counter
50 with _api_key_lock:
51 _api_key_counter += 1
52 return api_keys[_api_key_counter % len(api_keys)]
53
54
55 def search_videos_pexels(
56 search_term: str,
57 minimum_duration: int,
58 video_aspect: VideoAspect = VideoAspect.portrait,
59 ) -> List[MaterialInfo]:
60 aspect = VideoAspect(video_aspect)
61 video_orientation = aspect.name
62 video_width, video_height = aspect.to_resolution()
63 api_key = get_api_key("pexels_api_keys")
64 headers = {
65 "Authorization": api_key,
66 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
67 }
68 # Build URL
69 params = {"query": search_term, "per_page": 20, "orientation": video_orientation}
70 query_url = f"https://api.pexels.com/videos/search?{urlencode(params)}"
71 logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
72
73 try:
74 r = requests.get(
75 query_url,
76 headers=headers,
77 proxies=config.proxy,
78 verify=_get_tls_verify(),
79 timeout=(30, 60),
80 )
81 response = r.json()
82 video_items = []
83 if "videos" not in response:
84 logger.error(f"search videos failed: {response}")
85 return video_items
86 videos = response["videos"]
87 # loop through each video in the result
88 for v in videos:
89 duration = v["duration"]
90 # check if video has desired minimum duration
91 if duration < minimum_duration:
92 continue
93 video_files = v["video_files"]
94 # loop through each url to determine the best quality
95 for video in video_files:
96 w = int(video["width"])
97 h = int(video["height"])
98 if w == video_width and h == video_height:
99 item = MaterialInfo()
100 item.provider = "pexels"
101 item.url = video["link"]
102 item.duration = duration
103 video_items.append(item)
104 break
105 return video_items
106 except Exception as e:
107 logger.error(f"search videos failed: {str(e)}")
108
109 return []
110
111
112 def search_videos_pixabay(
113 search_term: str,
114 minimum_duration: int,
115 video_aspect: VideoAspect = VideoAspect.portrait,
116 ) -> List[MaterialInfo]:
117 aspect = VideoAspect(video_aspect)
118
119 video_width, video_height = aspect.to_resolution()
120
121 api_key = get_api_key("pixabay_api_keys")
122 # Build URL
123 params = {
124 "q": search_term,
125 "video_type": "all", # Accepted values: "all", "film", "animation"
126 "per_page": 50,
127 "key": api_key,
128 }
129 query_url = f"https://pixabay.com/api/videos/?{urlencode(params)}"
130 logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
131
132 try:
133 r = requests.get(
134 query_url, proxies=config.proxy, verify=_get_tls_verify(), timeout=(30, 60)
135 )
136 response = r.json()
137 video_items = []
138 if "hits" not in response:
139 logger.error(f"search videos failed: {response}")
140 return video_items
141 videos = response["hits"]
142 # loop through each video in the result
143 for v in videos:
144 duration = v["duration"]
145 # check if video has desired minimum duration
146 if duration < minimum_duration:
147 continue
148 video_files = v["videos"]
149 # loop through each url to determine the best quality
150 for video_type in video_files:
151 video = video_files[video_type]
152 w = int(video["width"])
153 # h = int(video["height"])
154 if w >= video_width:
155 item = MaterialInfo()
156 item.provider = "pixabay"
157 item.url = video["url"]
158 item.duration = duration
159 video_items.append(item)
160 break
161 return video_items
162 except Exception as e:
163 logger.error(f"search videos failed: {str(e)}")
164
165 return []
166
167
168 def search_videos_coverr(
169 search_term: str,
170 minimum_duration: int,
171 video_aspect: VideoAspect = VideoAspect.portrait,
172 ) -> List[MaterialInfo]:
173 """
174 Coverr (https://coverr.co) - free HD/4K stock videos,
175 subject to Coverr license terms (https://coverr.co/license).
176
177 Coverr API notes (based on official docs at api.coverr.co/docs/):
178 - 鉴权: Authorization: Bearer <api_key>
179 - 搜索端点: GET /videos?query=...,响应结构 {"hits": [...], ...}
180 - 加 ?urls=true 在搜索响应里直接返回 mp4 直链
181 - URL 是 signed JWT(绑定 API key,无过期时间)
182 - Coverr 库以 16:9 横屏为主,9:16 portrait 占比极低(约 1%)
183 因此本函数不做 aspect_ratio 过滤,由下游 video.py 的
184 resize + letterbox 逻辑统一处理
185 - duration 字段同时存在 number 和 string 两种形态,本函数都接受
186
187 本函数使用 urls.mp4_download 字段作为下载地址 —— 按 Coverr 官方文档
188 (https://api.coverr.co/docs/videos/#download-a-video) 的说法,
189 GET 这个 URL 本身就被 Coverr 当作一次合法的 download 事件计入统计,
190 无需再调用 PATCH /videos/:id/stats/downloads。
191 """
192 api_key = get_api_key("coverr_api_keys")
193 headers = {"Authorization": f"Bearer {api_key}"}
194 params = {
195 "query": search_term,
196 "page_size": 20,
197 "urls": "true",
198 "sort": "popular",
199 }
200 query_url = f"https://api.coverr.co/videos?{urlencode(params)}"
201 logger.info(f"searching videos: {query_url}, with proxies: {config.proxy}")
202
203 try:
204 r = requests.get(
205 query_url,
206 headers=headers,
207 proxies=config.proxy,
208 verify=_get_tls_verify(),
209 timeout=(30, 60),
210 )
211 response = r.json()
212 video_items: List[MaterialInfo] = []
213
214 if not isinstance(response, dict) or "hits" not in response:
215 logger.error(f"search videos failed: {response}")
216 return video_items
217
218 for v in response["hits"]:
219 # duration 在不同响应里可能是 number(11.625) 或 string("10.500000")
220 try:
221 duration = int(float(v.get("duration") or 0))
222 except (TypeError, ValueError):
223 continue
224 if duration < minimum_duration:
225 continue
226
227 video_id = v.get("id")
228 mp4_download_url = (v.get("urls") or {}).get("mp4_download")
229 if not video_id or not mp4_download_url:
230 continue
231
232 item = MaterialInfo()
233 item.provider = "coverr"
234 item.url = mp4_download_url
235 item.duration = duration
236 video_items.append(item)
237 return video_items
238 except Exception as e:
239 logger.error(f"search videos failed: {str(e)}")
240
241 return []
242
243
244 def save_video(video_url: str, save_dir: str = "") -> str:
245 if not save_dir:
246 save_dir = utils.storage_dir("cache_videos")
247
248 if not os.path.exists(save_dir):
249 os.makedirs(save_dir)
250
251 url_without_query = video_url.split("?")[0]
252 url_hash = utils.md5(url_without_query)
253 video_id = f"vid-{url_hash}"
254 video_path = f"{save_dir}/{video_id}.mp4"
255
256 # if video already exists, return the path
257 if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
258 logger.info(f"video already exists: {video_path}")
259 return video_path
260
261 headers = {
262 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
263 }
264
265 # if video does not exist, download it
266 with open(video_path, "wb") as f:
267 f.write(
268 requests.get(
269 video_url,
270 headers=headers,
271 proxies=config.proxy,
272 verify=_get_tls_verify(),
273 timeout=(60, 240),
274 ).content
275 )
276
277 if os.path.exists(video_path) and os.path.getsize(video_path) > 0:
278 clip = None
279 try:
280 clip = VideoFileClip(video_path)
281 duration = clip.duration
282 fps = clip.fps
283 if duration > 0 and fps > 0:
284 return video_path
285 except Exception as e:
286 logger.warning(f"invalid video file: {video_path} => {str(e)}")
287 try:
288 os.remove(video_path)
289 except Exception as remove_error:
290 logger.warning(
291 f"failed to remove invalid video file: {video_path}, error: {str(remove_error)}"
292 )
293 finally:
294 if clip is not None:
295 try:
296 clip.close()
297 except Exception as close_error:
298 logger.warning(
299 f"failed to close video clip: {video_path}, error: {str(close_error)}"
300 )
301 return ""
302
303
304 def download_videos(
305 task_id: str,
306 search_terms: List[str],
307 source: str = "pexels",
308 video_aspect: VideoAspect = VideoAspect.portrait,
309 video_concat_mode: VideoConcatMode = VideoConcatMode.random,
310 audio_duration: float = 0.0,
311 max_clip_duration: int = 5,
312 match_script_order: bool = False,
313 ) -> List[str]:
314 search_videos = search_videos_pexels
315 if source == "pixabay":
316 search_videos = search_videos_pixabay
317 elif source == "coverr":
318 search_videos = search_videos_coverr
319
320 material_directory = config.app.get("material_directory", "").strip()
321 if material_directory == "task":
322 material_directory = utils.task_dir(task_id)
323 elif material_directory and not os.path.isdir(material_directory):
324 material_directory = ""
325
326 if match_script_order:
327 return _download_videos_by_script_order(
328 task_id=task_id,
329 search_terms=search_terms,
330 search_videos=search_videos,
331 video_aspect=video_aspect,
332 audio_duration=audio_duration,
333 max_clip_duration=max_clip_duration,
334 material_directory=material_directory,
335 )
336
337 valid_video_items = []
338 valid_video_urls = []
339 found_duration = 0.0
340 for search_term in search_terms:
341 video_items = search_videos(
342 search_term=search_term,
343 minimum_duration=max_clip_duration,
344 video_aspect=video_aspect,
345 )
346 logger.info(f"found {len(video_items)} videos for '{search_term}'")
347
348 for item in video_items:
349 if item.url not in valid_video_urls:
350 valid_video_items.append(item)
351 valid_video_urls.append(item.url)
352 found_duration += item.duration
353
354 logger.info(
355 f"found total videos: {len(valid_video_items)}, required duration: {audio_duration} seconds, found duration: {found_duration} seconds"
356 )
357 video_paths = []
358
359 concat_mode_value = getattr(video_concat_mode, "value", video_concat_mode)
360 if concat_mode_value == VideoConcatMode.random.value:
361 random.shuffle(valid_video_items)
362
363 total_duration = 0.0
364 for item in valid_video_items:
365 try:
366 logger.info(f"downloading video: {item.url}")
367 saved_video_path = save_video(
368 video_url=item.url, save_dir=material_directory
369 )
370 if saved_video_path:
371 logger.info(f"video saved: {saved_video_path}")
372 video_paths.append(saved_video_path)
373 seconds = min(max_clip_duration, item.duration)
374 total_duration += seconds
375 if total_duration > audio_duration:
376 logger.info(
377 f"total duration of downloaded videos: {total_duration} seconds, skip downloading more"
378 )
379 break
380 except Exception as e:
381 logger.error(f"failed to download video: {utils.to_json(item)} => {str(e)}")
382 logger.success(f"downloaded {len(video_paths)} videos")
383 return video_paths
384
385
386 def _download_videos_by_script_order(
387 task_id: str,
388 search_terms: List[str],
389 search_videos,
390 video_aspect: VideoAspect,
391 audio_duration: float,
392 max_clip_duration: int,
393 material_directory: str,
394 ) -> List[str]:
395 """
396 按脚本文案顺序下载素材。
397
398 默认下载逻辑会把所有关键词的候选素材合并成一个大列表;如果第一个
399 关键词返回很多结果,最终下载时可能一直消耗这个关键词的素材,后续
400 脚本主题就排不上时间线。这里按关键词分组后轮询下载:
401 第 1 轮取每个关键词的第 1 个候选,第 2 轮取每个关键词的第 2 个候选。
402 这样在不重写视频合成引擎的前提下,尽量保证素材顺序贴近文案顺序。
403 """
404 logger.info("downloading videos with script-order material matching")
405 candidate_groups = []
406 valid_video_urls = set()
407 found_duration = 0.0
408
409 for search_term in search_terms:
410 video_items = search_videos(
411 search_term=search_term,
412 minimum_duration=max_clip_duration,
413 video_aspect=video_aspect,
414 )
415 logger.info(f"found {len(video_items)} videos for '{search_term}'")
416
417 term_items = []
418 for item in video_items:
419 if item.url in valid_video_urls:
420 continue
421 term_items.append(item)
422 valid_video_urls.add(item.url)
423 found_duration += item.duration
424
425 if term_items:
426 candidate_groups.append((search_term, term_items))
427
428 logger.info(
429 f"found total ordered video candidates: {sum(len(items) for _, items in candidate_groups)}, "
430 f"required duration: {audio_duration} seconds, found duration: {found_duration} seconds"
431 )
432
433 video_paths = []
434 total_duration = 0.0
435 candidate_index = 0
436 while candidate_groups and total_duration <= audio_duration:
437 has_candidate = False
438 for search_term, term_items in candidate_groups:
439 if candidate_index >= len(term_items):
440 continue
441
442 has_candidate = True
443 item = term_items[candidate_index]
444 try:
445 logger.info(
446 f"downloading ordered video for '{search_term}': {item.url}"
447 )
448 saved_video_path = save_video(
449 video_url=item.url, save_dir=material_directory
450 )
451 if saved_video_path:
452 logger.info(f"video saved: {saved_video_path}")
453 video_paths.append(saved_video_path)
454 total_duration += min(max_clip_duration, item.duration)
455 if total_duration > audio_duration:
456 logger.info(
457 f"total duration of downloaded videos: {total_duration} seconds, skip downloading more"
458 )
459 break
460 except Exception as e:
461 logger.error(
462 f"failed to download ordered video: {utils.to_json(item)} => {str(e)}"
463 )
464
465 if not has_candidate:
466 break
467 candidate_index += 1
468
469 logger.success(f"downloaded {len(video_paths)} ordered videos")
470 return video_paths
471
472
473 if __name__ == "__main__":
474 download_videos(
475 "test123", ["Money Exchange Medium"], audio_duration=100, source="pixabay"
476 )
477
477 lines PYTHON