feat(points): 积分消耗系统全链路集成
后端: - 简化积分服务: 删除 freeze/settle/refund, 保留 consume/recharge/expire - 计费配置化: config/points-config.yaml 驱动 fixed/duration/free 三种模式 - TTS 时长探测: app/utils/audio_utils.py (httpx + mutagen 纯 Python) - Python 层扣费: script(5)/polish(1)/title(1)/voice_clone(200)/tts(按秒)/video(按秒) - 字幕 free_services: caption/auto_align 不扣费 - 新增 POST /points/consume 端点(402余额预检) - 新增 check_balance + /points/cost 返回 sufficient/balance/required - 新增 expire_batches 定时回收, 接入 scheduler main(每5分钟) - 删除废弃 tts_handler.py - Alembic 迁移: 删除 frozen/total_refunded 字段 - 同步 requirements.lock 添加 mutagen 前端: - Rust/IPC 层扣费: compose(5)/subtitle_burn(2)/cover_design(2) - 字幕打轴改异步: 走 scheduler subtitle handler - 对口型传 duration: VideoGeneration 传 actualDuration - 创建 pointStore: 全局余额 + fetchBalance + 充值弹窗控制 - 402 欠费弹 RechargeModal: VideoGeneration/SubtitleBurning/CoverDesign - 修复 VoiceDubbing.tsx 类型错误 (alignResult never) - 同步 PointBalance 类型(删除 frozen/available/totalRefunded) Refs: 积分消耗集成收尾
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
音频时长探测工具
|
||||
================
|
||||
|
||||
基于 mutagen,支持从内存或文件路径探测音频时长。
|
||||
不依赖 ffprobe,纯 Python 实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.wave import WAVE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_audio_duration(url: str, timeout: float = 10.0) -> float:
|
||||
"""
|
||||
探测远程音频文件的时长(秒)。
|
||||
|
||||
下载音频到内存后,用 mutagen 读取文件头获取时长。
|
||||
不写入磁盘,全部在内存中完成。
|
||||
|
||||
:param url: 音频文件 URL(需可访问)
|
||||
:param timeout: HTTP 下载超时(秒)
|
||||
:return: 音频时长(秒,float)
|
||||
:raises ValueError: 无法解析时长
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
data = io.BytesIO(resp.content)
|
||||
|
||||
# 根据文件头判断格式
|
||||
header = data.read(12)
|
||||
data.seek(0)
|
||||
|
||||
try:
|
||||
if header[:3] == b"ID3" or header[:2] == b"\xff\xfb" or header[:2] == b"\xff\xf3":
|
||||
audio = MP3(data)
|
||||
elif header[:4] == b"RIFF":
|
||||
audio = WAVE(data)
|
||||
else:
|
||||
# fallback:先尝试 MP3(大多数 TTS 返回 mp3)
|
||||
audio = MP3(data)
|
||||
|
||||
duration = audio.info.length
|
||||
if duration is None or duration <= 0:
|
||||
raise ValueError("音频时长解析失败")
|
||||
return float(duration)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"探测音频时长失败: url={url[:60]}..., error={e}")
|
||||
raise ValueError(f"无法解析音频时长: {e}") from e
|
||||
Reference in New Issue
Block a user