第30章 素材获取服务
30.1 概述
素材获取服务是剪映小助手的基础功能模块,主要负责获取各种媒体素材的信息,包括音频时长、图片动画效果、文字动画效果等。该服务通过分析媒体文件的内容,为视频编辑提供必要的素材信息支持。服务支持多种媒体格式,采用异步处理方式,确保高效和稳定的性能表现。
30.2 音频时长获取服务
30.2.1 核心实现
音频时长获取服务的核心实现位于 src/service/get_audio_duration.py 文件中:
async def get_audio_duration(request: GetAudioDurationRequest) -> GetAudioDurationResponse:
"""获取音频时长"""
logger.info(f"获取音频时长: {request.mp3_url}")
# 参数验证
if not request.mp3_url:
raise ValueError("音频URL不能为空")
# 下载音频文件
audio_file = await download_audio_file(str(request.mp3_url))
if not audio_file:
raise AUDIO_DOWNLOAD_FAILED
try:
# 获取音频时长
duration = await extract_audio_duration(audio_file)
logger.info(f"音频时长获取成功: {duration} 微秒")
return GetAudioDurationResponse(
duration=duration
)
finally:
# 清理临时文件
if os.path.exists(audio_file):
os.remove(audio_file)
logger.debug(f"清理临时文件: {audio_file}")
30.2.2 音频文件下载
下载音频文件到临时目录:
async def download_audio_file(mp3_url: str) -> str:
"""下载音频文件"""
try:
# 创建临时文件
temp_dir = tempfile.gettempdir()
file_extension = get_file_extension(mp3_url)
temp_file = os.path.join(temp_dir, f"audio_{uuid.uuid4().hex}.{file_extension}")
logger.info(f"开始下载音频文件: {mp3_url}")
# 下载文件
async with aiohttp.ClientSession() as session:
timeout = aiohttp.ClientTimeout(total=30)
async with session.get(mp3_url, timeout=timeout) as response:
if response.status != 200:
raise Exception(f"下载失败,状态码: {response.status}")
# 写入临时文件
with open(temp_file, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
# 验证文件大小
file_size = os.path.getsize(temp_file)
if file_size == 0:
raise Exception("下载的文件为空")
logger.info(f"音频文件下载成功: {temp_file}, 大小: {file_size} bytes")
return temp_file
except Exception as e:
logger.error(f"音频文件下载失败: {str(e)}")
if os.path.exists(temp_file):
os.remove(temp_file)
raise Exception(f"音频文件下载失败: {str(e)}")
30.2.3 音频时长提取
使用ffprobe提取音频时长:
async def extract_audio_duration(audio_file: str) -> int:
"""提取音频时长"""
try:
# 构建ffprobe命令
cmd = [
'ffprobe',
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
audio_file
]
logger.info(f"执行ffprobe命令: {' '.join(cmd)}")
# 执行命令
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
error_msg = stderr.decode('utf-8', errors='ignore')
logger.error(f"ffprobe执行失败: {error_msg}")
# 尝试备用方法
return await extract_duration_fallback(audio_file)
# 解析JSON输出
try:
probe_data = json.loads(stdout.decode('utf-8'))
except json.JSONDecodeError as e:
logger.error(f"ffprobe输出解析失败: {str(e)}")
return await extract_duration_fallback(audio_file)
# 查找音频流
duration = None
for stream in probe_data.get('streams', []):
if stream.get('codec_type') == 'audio':
# 优先使用流的时长
if 'duration' in stream:
duration = float(stream['duration'])
break
# 如果没有流时长,使用格式时长
elif 'duration' in probe_data.get('format', {}):
duration = float(probe_data['format']['duration'])
break
if duration is None:
logger.error("未找到音频时长信息")
return await extract_duration_fallback(audio_file)
# 转换为微秒
duration_microseconds = int(duration * 1000000)
logger.info(f"音频时长: {duration} 秒 = {duration_microseconds} 微秒")
return duration_microseconds
except Exception as e:
logger.error(f"音频时长提取失败: {str(e)}")
raise Exception(f"音频时长提取失败: {str(e)}")
30.2.4 备用时长提取方法
当ffprobe失败时的备用方法:
async def extract_duration_fallback(audio_file: str) -> int:
"""备用时长提取方法"""
try:
# 使用ffprobe的简化模式
cmd = [
'ffprobe',
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
audio_file
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode == 0:
duration = float(stdout.decode('utf-8').strip())
duration_microseconds = int(duration * 1000000)
logger.info(f"备用方法获取音频时长: {duration_microseconds} 微秒")
return duration_microseconds
else:
raise Exception(f"备用方法也失败: {stderr.decode()}")
except Exception as e:
logger.error(f"备用时长提取方法失败: {str(e)}")
raise Exception(f"无法获取音频时长: {str(e)}")
30.3 图片动画获取服务
30.3.1 图片入场动画
获取图片的入场动画效果:
async def get_image_animations(request: GetImageAnimationsRequest) -> GetImageAnimationsResponse:
"""获取图片出入场动画"""
logger.info(f"获取图片动画,类型: {request.type}, 模式: {request.mode}")
# 参数验证
if not request.type:
raise ValueError("动画类型不能为空")
# 获取动画列表
animations = await load_image_animations(request.type, request.mode)
# 过滤和排序
filtered_animations = filter_animations(animations, request)
return GetImageAnimationsResponse(
effects=json.dumps([anim.dict() for anim in filtered_animations], ensure_ascii=False)
)
30.3.2 动画数据加载
从素材库加载动画数据:
async def load_image_animations(animation_type: str, mode: int) -> List[ImageAnimationItem]:
"""加载图片动画数据"""
try:
# 构建查询条件
filters = {
'type': animation_type,
'material_type': 'sticker',
'platform': 'all'
}
# 根据模式过滤
if mode == 1: # VIP
filters['is_vip'] = True
elif mode == 2: # 免费
filters['is_free'] = True
# 从数据库或缓存获取动画数据
animation_data = await get_animation_data('image', filters)
# 转换为模型对象
animations = []
for data in animation_data:
animation = ImageAnimationItem(
resource_id=data['resource_id'],
type=data['type'],
category_id=data['category_id'],
category_name=data['category_name'],
duration=data['duration'],
id=data['id'],
name=data['name'],
icon_url=data['icon_url'],
material_type=data.get('material_type', 'sticker'),
panel=data.get('panel', ''),
path=data.get('path', ''),
platform=data.get('platform', 'all')
)
animations.append(animation)
logger.info(f"加载到 {len(animations)} 个图片动画")
return animations
except Exception as e:
logger.error(f"加载图片动画失败: {str(e)}")
raise Exception(f"加载图片动画失败: {str(e)}")
30.4 文字动画获取服务
30.4.1 文字出入场动画
获取文字的出入场动画效果:
async def get_text_animations(request: GetTextAnimationsRequest) -> GetTextAnimationsResponse:
"""获取文字出入场动画"""
logger.info(f"获取文字动画,类型: {request.type}, 模式: {request.mode}")
# 参数验证
if not request.type:
raise ValueError("动画类型不能为空")
# 获取动画列表
animations = await load_text_animations(request.type, request.mode)
# 过滤和排序
filtered_animations = filter_animations(animations, request)
return GetTextAnimationsResponse(
effects=json.dumps([anim.dict() for anim in filtered_animations], ensure_ascii=False)
)
30.4.2 文字动画特性
文字动画的特殊处理:
def process_text_animation(animation: TextAnimationItem) -> TextAnimationItem:
"""处理文字动画的特殊属性"""
# 文字动画通常需要更短的持续时间
if animation.duration > 2000000: # 超过2秒
animation.duration = 1500000 # 调整为1.5秒
# 设置文字动画的默认缓动函数
if not animation.path:
animation.path = "ease_in_out"
# 根据动画类型调整参数
if animation.type == "in":
# 入场动画从透明到不透明
animation.start = 0
elif animation.type == "out":
# 出场动画从不透明到透明
animation.start = 500000 # 延迟0.5秒开始
elif animation.type == "loop":
# 循环动画持续进行
animation.start = 0
return animation
30.5 数据结构定义
30.5.1 音频时长数据结构
class GetAudioDurationRequest(BaseModel):
"""获取音频时长请求参数"""
mp3_url: HttpUrl = Field(
...,
description="音频文件URL,支持mp3、wav、m4a等常见音频格式"
)
class Config:
json_schema_extra = {
"example": {
"mp3_url": "https://www.soundjay.com/misc/sounds/bell-ringing-05.wav"
}
}
class GetAudioDurationResponse(BaseModel):
"""获取音频时长响应参数"""
duration: int = Field(
...,
description="音频时长,单位:微秒",
ge=0
)
30.5.2 图片动画数据结构
class GetImageAnimationsRequest(BaseModel):
"""获取图片出入场动画的请求模型"""
mode: int = Field(default=0, description="动画模式:0=所有,1=VIP,2=免费")
type: Literal["in", "out", "loop"] = Field(..., description="动画类型:in=入场,out=出场,loop=循环")
class ImageAnimationItem(BaseModel):
"""单个图片动画项的数据模型"""
resource_id: str = Field(..., description="动画资源ID")
type: str = Field(..., description="动画类型")
category_id: str = Field(..., description="动画分类ID")
category_name: str = Field(..., description="动画分类名称")
duration: int = Field(..., description="动画时长(微秒)")
id: str = Field(..., description="动画唯一标识ID")
name: str = Field(..., description="动画名称")
request_id: str = Field(default="", description="请求ID")
start: int = Field(default=0, description="动画开始时间")
icon_url: str = Field(..., description="动画图标URL")
material_type: str = Field(default="sticker", description="素材类型")
panel: str = Field(default="", description="面板信息")
path: str = Field(default="", description="路径信息")
platform: str = Field(default="all", description="支持平台")
class GetImageAnimationsResponse(BaseModel):
"""获取图片出入场动画的响应模型"""
effects: str = Field(..., description="图片出入场动画数组的JSON字符串")
30.5.3 文字动画数据结构
class GetTextAnimationsRequest(BaseModel):
"""获取文字出入场动画的请求模型"""
mode: int = Field(default=0, description="动画模式:0=所有,1=VIP,2=免费")
type: Literal["in", "out", "loop"] = Field(..., description="动画类型:in=入场,out=出场,loop=循环")
class TextAnimationItem(BaseModel):
"""单个文字动画项的数据模型"""
resource_id: str = Field(..., description="动画资源ID")
type: str = Field(..., description="动画类型")
category_id: str = Field(..., description="动画分类ID")
category_name: str = Field(..., description="动画分类名称")
duration: int = Field(..., description="动画时长(微秒)")
id: str = Field(..., description="动画唯一标识ID")
name: str = Field(..., description="动画名称")
request_id: str = Field(default="", description="请求ID")
start: int = Field(default=0, description="动画开始时间")
icon_url: str = Field(..., description="动画图标URL")
material_type: str = Field(default="sticker", description="素材类型")
panel: str = Field(default="", description="面板信息")
path: str = Field(default="", description="路径信息")
platform: str = Field(default="all", description="支持平台")
class GetTextAnimationsResponse(BaseModel):
"""获取文字出入场动画的响应模型"""
effects: str = Field(..., description="文字出入场动画数组的JSON字符串")
30.6 异常处理
素材获取服务定义了完善的异常处理机制:
# 音频下载失败
AUDIO_DOWNLOAD_FAILED = HTTPException(
status_code=400,
detail="音频文件下载失败"
)
# 音频时长提取失败
AUDIO_DURATION_EXTRACTION_FAILED = HTTPException(
status_code=500,
detail="音频时长提取失败"
)
# 动画数据加载失败
ANIMATION_DATA_LOAD_FAILED = HTTPException(
status_code=500,
detail="动画数据加载失败"
)
# 不支持的音频格式
UNSUPPORTED_AUDIO_FORMAT = HTTPException(
status_code=400,
detail="不支持的音频格式"
)
30.7 API接口定义
30.7.1 音频时长接口
@router.post("/getAudioDuration", response_model=GetAudioDurationResponse)
async def get_audio_duration_endpoint(request: GetAudioDurationRequest):
"""获取音频时长"""
try:
return await get_audio_duration(request)
except Exception as e:
logger.error(f"获取音频时长失败: {str(e)}")
raise AUDIO_DURATION_EXTRACTION_FAILED
30.7.2 图片动画接口
@router.post("/getImageAnimations", response_model=GetImageAnimationsResponse)
async def get_image_animations_endpoint(request: GetImageAnimationsRequest):
"""获取图片出入场动画"""
try:
return await get_image_animations(request)
except Exception as e:
logger.error(f"获取图片动画失败: {str(e)}")
raise ANIMATION_DATA_LOAD_FAILED
30.7.3 文字动画接口
@router.post("/getTextAnimations", response_model=GetTextAnimationsResponse)
async def get_text_animations_endpoint(request: GetTextAnimationsRequest):
"""获取文字出入场动画"""
try:
return await get_text_animations(request)
except Exception as e:
logger.error(f"获取文字动画失败: {str(e)}")
raise ANIMATION_DATA_LOAD_FAILED
30.8 使用示例
30.8.1 音频时长请求示例
{
"mp3_url": "https://example.com/audio/background-music.mp3"
}
30.8.2 音频时长响应示例
{
"duration": 180000000
}
30.8.3 动画获取请求示例
{
"type": "in",
"mode": 0
}
30.8.4 动画获取响应示例
{
"effects": "[{\"resource_id\":\"anim_fade_in\",\"type\":\"in\",\"category_id\":\"basic\",\"category_name\":\"基础\",\"duration\":1000000,\"id\":\"fade_in_001\",\"name\":\"淡入\",\"icon_url\":\"https://example.com/icons/fade_in.png\",\"material_type\":\"sticker\",\"platform\":\"all\"}]"
}
30.9 性能优化
素材获取服务采用了多种性能优化策略:
30.9.1 缓存优化
# 使用Redis缓存动画数据
import aioredis
class AnimationCache:
def __init__(self):
self.redis = None
async def init_cache(self):
self.redis = await aioredis.create_redis_pool(
'redis://localhost:6379',
encoding='utf-8'
)
async def get_animation_data(self, key: str):
"""获取缓存的动画数据"""
cached_data = await self.redis.get(key)
if cached_data:
return json.loads(cached_data)
return None
async def set_animation_data(self, key: str, data: dict, expire: int = 3600):
"""设置动画数据缓存"""
await self.redis.setex(key, expire, json.dumps(data))
30.9.2 连接池优化
# 使用连接池管理HTTP连接
class HttpConnectionPool:
def __init__(self):
self.connector = aiohttp.TCPConnector(
limit=100,
limit_per_host=30,
ttl_dns_cache=300,
use_dns_cache=True,
keepalive_timeout=30
)
self.session = aiohttp.ClientSession(connector=self.connector)
async def download_file(self, url: str, timeout: int = 30):
"""下载文件"""
timeout_config = aiohttp.ClientTimeout(total=timeout)
async with self.session.get(url, timeout=timeout_config) as response:
if response.status == 200:
return await response.read()
else:
raise Exception(f"下载失败: {response.status}")
30.9.3 异步处理优化
# 使用Semaphore限制并发数
class AsyncLimiter:
def __init__(self, max_concurrent: int = 10):
self.semaphore = asyncio.Semaphore(max_concurrent)
async def acquire(self):
await self.semaphore.acquire()
def release(self):
self.semaphore.release()
async def __aenter__(self):
await self.acquire()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
self.release()
# 使用示例
limiter = AsyncLimiter(max_concurrent=5)
async def limited_download(url: str):
async with limiter:
return await download_file(url)
30.10 扩展性设计
素材获取服务具有良好的扩展性:
- 媒体格式扩展:易于添加新的媒体格式支持
- 动画类型扩展:支持动态添加新的动画类型
- 数据源扩展:支持从多个数据源获取素材信息
- 缓存策略扩展:支持自定义缓存策略和过期时间
附录
代码仓库地址:
- GitHub:
https://github.com/Hommy-master/capcut-mate - Gitee:
https://gitee.com/taohongmin-gitee/capcut-mate
接口文档地址:
- API文档地址:
https://docs.jcaigc.cn
680

被折叠的 条评论
为什么被折叠?



