【剪映小助手源码精讲】第30章 素材获取服务

第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
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值