video-shuoshan/backend/utils/media_utils.py
seaislee1209 da9a1413c3 v0.18.0 素材库多类型支持 + @ 引用改为单素材
对齐火山 API 文档(Asset URI 小写、HEIC/HEIF、DeleteAsset)
素材库支持视频/音频上传(按类型分三区显示、前端校验、拖拽上传)
@ 引用从素材组改为单个素材(搜索返回具体素材、即时数量/时长检查)
ffmpeg 视频封面帧提取 + 音频时长读取(Celery 异步)
生产级安全修复(跨团队校验、异常信息脱敏、下载大小限制)
2026-04-04 17:36:35 +08:00

116 lines
3.8 KiB
Python

"""Media utilities: extract video thumbnails and durations using ffmpeg/ffprobe."""
import logging
import subprocess
import tempfile
import os
import requests
from django.core.files.uploadedfile import SimpleUploadedFile
logger = logging.getLogger(__name__)
MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024 # 100MB safety limit
def _download_to_temp(url: str, suffix: str) -> str:
"""Download a URL to a temporary file. Returns the temp file path."""
resp = requests.get(url, timeout=30, stream=True)
resp.raise_for_status()
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
downloaded = 0
try:
for chunk in resp.iter_content(8192):
downloaded += len(chunk)
if downloaded > MAX_DOWNLOAD_SIZE:
tmp.close()
os.unlink(tmp.name)
raise ValueError(f'File too large: {downloaded} bytes')
tmp.write(chunk)
tmp.close()
except Exception:
tmp.close()
if os.path.exists(tmp.name):
os.unlink(tmp.name)
raise
return tmp.name
def _get_duration_ffprobe(file_path: str) -> float:
"""Get media duration in seconds using ffprobe."""
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', file_path],
capture_output=True, text=True, timeout=15,
)
return float(result.stdout.strip())
except Exception as e:
logger.warning('ffprobe duration failed: %s', e)
return 0
def _extract_first_frame(video_path: str, output_path: str) -> bool:
"""Extract the first frame of a video as JPEG using ffmpeg."""
try:
subprocess.run(
['ffmpeg', '-y', '-i', video_path, '-vframes', '1',
'-f', 'image2', '-q:v', '2', output_path],
capture_output=True, timeout=15,
)
return os.path.exists(output_path) and os.path.getsize(output_path) > 0
except Exception as e:
logger.warning('ffmpeg frame extraction failed: %s', e)
return False
def extract_video_info(video_url: str) -> tuple:
"""Extract first frame thumbnail + duration from a video URL.
Returns (thumbnail_file: SimpleUploadedFile | None, duration: float).
"""
tmp_video = None
tmp_thumb = None
try:
# Determine suffix from URL
suffix = '.mp4'
if '.mov' in video_url.lower():
suffix = '.mov'
tmp_video = _download_to_temp(video_url, suffix)
# Get duration
duration = _get_duration_ffprobe(tmp_video)
# Extract first frame
tmp_thumb = tmp_video + '_thumb.jpg'
if _extract_first_frame(tmp_video, tmp_thumb):
with open(tmp_thumb, 'rb') as f:
thumb_file = SimpleUploadedFile(
'thumbnail.jpg', f.read(), content_type='image/jpeg'
)
return thumb_file, duration
return None, duration
except Exception as e:
logger.warning('extract_video_info failed for %s: %s', video_url, e)
return None, 0
finally:
if tmp_video and os.path.exists(tmp_video):
os.unlink(tmp_video)
if tmp_thumb and os.path.exists(tmp_thumb):
os.unlink(tmp_thumb)
def get_audio_duration(audio_url: str) -> float:
"""Get audio duration in seconds from a URL."""
tmp_audio = None
try:
suffix = '.wav' if '.wav' in audio_url.lower() else '.mp3'
tmp_audio = _download_to_temp(audio_url, suffix)
return _get_duration_ffprobe(tmp_audio)
except Exception as e:
logger.warning('get_audio_duration failed for %s: %s', audio_url, e)
return 0
finally:
if tmp_audio and os.path.exists(tmp_audio):
os.unlink(tmp_audio)