Files
meijiaka-zy/python-api/app/services/material_service.py
T
小鱼开发 e15bdaf996 fix: 素材匹配、Step流程、UI优化
- 修复 duration 解析 bug (parseInt→parseFloat),解决素材'换一个'候选池过小
- 素材匹配策略:候选池=满足时长+最近5个,严格模式排除已用素材
- Step2 下一步按钮绑定 dubbingAudioUrl 生成状态
- 修复 VoiceDubbing 生成后未同步 projectStore
- 修复 _meta.json JSON 格式错误导致分类列表空白
- Step3/Step4 视频预览区添加标题
- 压制字幕按钮固定在底部
- 选项卡按钮高度微调
2026-04-24 15:46:06 +08:00

116 lines
3.7 KiB
Python

"""
空镜素材服务
============
从本地 JSON 配置文件加载素材库,提供匹配逻辑。
duration 从文件名 `_{N}s_` 中自动解析。
"""
import json
import random
import re
from pathlib import Path
# 正则:从文件名中提取时长,如 plumbing_10s_a23f8fcb.mp4 → 10
_DURATION_RE = re.compile(r"_(\d+)s_")
# 配置缓存(启动时加载,运行时只读)
_keywords: dict[str, str] = {}
_materials: dict[str, list[dict]] = {}
def _get_config_path() -> Path:
"""获取配置文件绝对路径"""
# 配置文件位于项目根目录的 config/ 下
return Path(__file__).resolve().parent.parent.parent / "config" / "materials.json"
def _parse_duration(url: str) -> float:
"""从 URL 文件名中解析时长(秒)"""
filename = url.split("/")[-1]
match = _DURATION_RE.search(filename)
if not match:
raise ValueError(f"无法从文件名解析时长: {filename}")
return float(match.group(1))
def load_config() -> None:
"""加载素材配置到内存缓存"""
global _keywords, _materials
config_path = _get_config_path()
if not config_path.exists():
raise FileNotFoundError(f"素材配置文件不存在: {config_path}")
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
_keywords = data.get("keywords", {})
raw_materials = data.get("materials", {})
# 解析每个素材的 duration
_materials = {}
for slug, entries in raw_materials.items():
parsed = []
for entry in entries:
url = entry["url"]
duration = _parse_duration(url)
parsed.append({"url": url, "duration": duration})
_materials[slug] = parsed
def match_material(scene: str, required_duration: float, exclude_urls: list[str] | None = None, strict: bool = False) -> dict | None:
"""
根据场景描述和所需时长匹配空镜素材
策略:
1. 收集所有满足时长要求(duration >= required_duration)的素材
2. 收集全局差值最近的 5 个素材
3. 合并去重后从候选池中随机选取,优先排除已使用的
Args:
scene: 分镜场景描述
required_duration: 所需时长(秒)
exclude_urls: 已使用的素材 URL 列表(避免重复)
Returns:
匹配到的素材 {url, duration},无匹配返回 None
"""
exclude_urls = exclude_urls or []
for keyword, slug in _keywords.items():
if keyword in scene:
all_materials = _materials.get(slug, [])
if not all_materials:
return None
# 1. 满足时长要求的素材
matching = [m for m in all_materials if m["duration"] >= required_duration]
# 2. 差值最近的 5 个素材(全局)
sorted_by_diff = sorted(all_materials, key=lambda m: abs(m["duration"] - required_duration))
closest_5 = sorted_by_diff[:5]
# 3. 合并候选池并去重(matching 在前,优先保留满足时长的)
candidate_pool = []
seen = set()
for m in matching + closest_5:
if m["url"] not in seen:
candidate_pool.append(m)
seen.add(m["url"])
# 4. 排除已使用的,从中随机选
unused = [m for m in candidate_pool if m["url"] not in exclude_urls]
if unused:
return random.choice(unused)
# 5. 严格模式下不允许返回已排除的素材
if strict:
return None
# 6. 非严格模式:全部用完则允许重复
if candidate_pool:
return random.choice(candidate_pool)
return None
return None