Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c5600cfc9 | |||
| c66e73e5b0 | |||
| 5f9538b5ac | |||
| 19b45cbee1 | |||
| f51f1139bf | |||
| 9c03755318 | |||
| 25a98c33b9 | |||
| 8920bead38 | |||
| 827d8aafe5 |
@@ -0,0 +1,105 @@
|
||||
name: CI Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements-dev.txt'
|
||||
- 'infra/docker/ci.Dockerfile'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "手动触发 - ci-base 镜像重建"
|
||||
|
||||
concurrency:
|
||||
group: ci-base-image-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-ci-base:
|
||||
name: Build CI Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||
| bash
|
||||
|
||||
- name: Docker login to Gitea Registry
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login 尝试 $i/3 ==="
|
||||
if docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
||||
echo "✅ Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Build and push CI base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
|
||||
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
|
||||
|
||||
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
|
||||
docker build --progress=plain \
|
||||
-f infra/docker/ci.Dockerfile \
|
||||
-t "${IMAGE}:latest" \
|
||||
-t "${IMAGE}:${VERSION_TAG}" \
|
||||
.
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
echo "=== Pushing ${VERSION_TAG} ==="
|
||||
docker push "${IMAGE}:${VERSION_TAG}"
|
||||
echo "=== Pushing latest ==="
|
||||
docker push "${IMAGE}:latest"
|
||||
echo "✅ Pushed to Gitea Registry"
|
||||
|
||||
- name: Verify image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
|
||||
echo "=== Verifying pinned deps in fresh image ==="
|
||||
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
|
||||
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
|
||||
|
||||
- name: Notify result
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
|
||||
else
|
||||
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
|
||||
docker rmi "${IMAGE}:latest" 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
@@ -48,7 +48,7 @@ from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
from packages.domain.smart_match import score_asset
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
@@ -724,7 +724,12 @@ def create_clips_from_assets_editor(
|
||||
else:
|
||||
transition_compensation = 0.0
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 打乱 segments 的处理顺序(分配素材的顺序随机化),但最终 clips_data 按原始 order 排序
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
|
||||
for idx in shuffled_indices:
|
||||
_seg_order, dur_min, dur_max = segments[idx]
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
|
||||
@@ -739,14 +744,15 @@ def create_clips_from_assets_editor(
|
||||
clip_duration = 0.0
|
||||
start_time: float | None = None
|
||||
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
|
||||
asset_use_counts = {
|
||||
aid: len(used_segments.get(aid, []))
|
||||
for aid in asset_ids
|
||||
}
|
||||
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in asset_ids}
|
||||
# 排序键:smart_match 评分(注入随机噪声)→ 使用次数 → 纯随机。
|
||||
# 噪声让得分接近的素材排名每次浮动,避免同一批素材反复选出相同组合,
|
||||
# 从素材组合层面降低成片查重率;分差 > SCORE_RANDOM_NOISE_MAX 时排名稳定,
|
||||
# 质量差距显著的素材仍保持优先级。
|
||||
sorted_candidates = sorted(
|
||||
asset_ids,
|
||||
key=lambda aid: (
|
||||
-asset_smart_scores.get(aid, 0.0),
|
||||
-(asset_smart_scores.get(aid, 0.0) + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX)),
|
||||
asset_use_counts.get(aid, 0),
|
||||
random.random(),
|
||||
),
|
||||
@@ -805,7 +811,7 @@ def create_clips_from_assets_editor(
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": i,
|
||||
"order": _seg_order,
|
||||
"asset_id": asset_id,
|
||||
"start_time": start_time,
|
||||
"duration": clip_duration,
|
||||
@@ -813,6 +819,9 @@ def create_clips_from_assets_editor(
|
||||
}
|
||||
)
|
||||
|
||||
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...)
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
|
||||
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
@@ -839,15 +848,15 @@ def create_clips_from_assets_editor(
|
||||
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
|
||||
logger.warning(
|
||||
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
|
||||
plan_id, dup_rate,
|
||||
plan_id,
|
||||
dup_rate,
|
||||
)
|
||||
|
||||
# 7. 素材耗尽提示
|
||||
exhaustion_warning = None
|
||||
if all_assets_exhausted and created_count < len(segments):
|
||||
exhaustion_warning = (
|
||||
"素材可切区间不足,部分片段使用了复用素材。"
|
||||
"建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
|
||||
"素材可切区间不足,部分片段使用了复用素材。" "建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
|
||||
)
|
||||
|
||||
# 8. 立即返回响应
|
||||
@@ -860,11 +869,58 @@ def create_clips_from_assets_editor(
|
||||
)
|
||||
|
||||
|
||||
def _build_scene_segments(
|
||||
scene_changes: list[float],
|
||||
asset_duration: float,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""根据场景切换点构建镜头段列表.
|
||||
|
||||
Args:
|
||||
scene_changes: 场景切换点时间戳列表(已排序,首位为 0.0)
|
||||
asset_duration: 素材总时长
|
||||
|
||||
Returns:
|
||||
镜头段列表 [(start, end), ...]
|
||||
"""
|
||||
segments: list[tuple[float, float]] = []
|
||||
for i, ts in enumerate(scene_changes):
|
||||
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
|
||||
# 只保留有效长度的镜头段(至少 0.5 秒)
|
||||
if end - ts >= 0.5:
|
||||
segments.append((ts, end))
|
||||
return segments
|
||||
|
||||
|
||||
def _pick_start_in_scene_segment(
|
||||
seg_start: float,
|
||||
seg_end: float,
|
||||
clip_duration: float,
|
||||
) -> float | None:
|
||||
"""在镜头段内随机选取一个起始时间点.
|
||||
|
||||
确保 start + clip_duration <= seg_end。
|
||||
若镜头段长度不足以容纳片段,返回 None。
|
||||
"""
|
||||
available = seg_end - seg_start - clip_duration
|
||||
if available < 0:
|
||||
return None
|
||||
max_start = seg_start + available
|
||||
return random.uniform(seg_start, max_start)
|
||||
|
||||
|
||||
def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
) -> None:
|
||||
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
|
||||
"""后台任务:使用 SceneChange 智能选帧并更新片段的起始时间.
|
||||
|
||||
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
|
||||
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
|
||||
|
||||
降级策略:
|
||||
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
|
||||
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
|
||||
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
|
||||
|
||||
此函数在后台异步执行,不影响接口响应时间。
|
||||
失败时静默处理,不影响已创建的片段。
|
||||
@@ -886,12 +942,6 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
plan_svc = EditPlanService(db)
|
||||
|
||||
# 调用 MediaKit 获取推荐时间
|
||||
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
|
||||
if not recommendations:
|
||||
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
|
||||
return
|
||||
|
||||
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
|
||||
batch_size = 500
|
||||
all_clips = []
|
||||
@@ -914,15 +964,16 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
|
||||
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
|
||||
|
||||
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
|
||||
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
|
||||
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段)
|
||||
clips_by_asset: dict[str, list] = defaultdict(list)
|
||||
for clip in clips:
|
||||
aid = getattr(clip, "asset_id", "") or ""
|
||||
if aid and clip.start_time is not None:
|
||||
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
|
||||
if aid:
|
||||
clips_by_asset[aid].append(clip)
|
||||
for aid in clips_by_asset:
|
||||
clips_by_asset[aid].sort(key=lambda c: c.order)
|
||||
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
|
||||
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
|
||||
historical_segments = get_used_segments(db, unique_asset_ids)
|
||||
|
||||
# 已更新的片段ID(用于排除已移动的旧时间段)
|
||||
@@ -931,16 +982,22 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
updated_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
updated_count = 0
|
||||
|
||||
# 遍历片段,按 asset_id 匹配推荐时间
|
||||
for clip in clips:
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
if not asset_id or asset_id not in recommendations:
|
||||
# 尝试获取存储服务(用于生成视频 URL)
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception:
|
||||
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
|
||||
return
|
||||
|
||||
# 获取 MediaKit 客户端
|
||||
client = get_mediakit_client()
|
||||
|
||||
# 对每个素材,检测场景切换点并分配镜头段
|
||||
for asset_id in unique_asset_ids:
|
||||
asset_clips = clips_by_asset.get(asset_id, [])
|
||||
if not asset_clips:
|
||||
continue
|
||||
|
||||
recommended_start = recommendations[asset_id]
|
||||
clip_duration = clip.duration
|
||||
|
||||
# 从预加载字典获取素材(O(1) 查找)
|
||||
asset = assets_map.get(asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
@@ -948,89 +1005,147 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if asset_total <= 0:
|
||||
continue
|
||||
|
||||
# 推荐时间 + 片段时长不能超过素材总时长
|
||||
if recommended_start + clip_duration > asset_total:
|
||||
logger.info(
|
||||
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
clip_duration,
|
||||
asset_total,
|
||||
)
|
||||
continue
|
||||
|
||||
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
|
||||
other_segments: list[tuple[float, float]] = [
|
||||
(cs, ce)
|
||||
for cid, cs, ce in clips_by_asset.get(asset_id, [])
|
||||
if cid != clip.id and cid not in updated_clip_ids
|
||||
]
|
||||
other_segments.extend(updated_segments.get(asset_id, []))
|
||||
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
|
||||
# 本 plan 片段创建时已写入历史记录
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
|
||||
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
|
||||
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
|
||||
def _norm(segs):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
|
||||
|
||||
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
|
||||
|
||||
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
|
||||
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
|
||||
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
|
||||
logger.info(
|
||||
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
continue
|
||||
|
||||
# 逐个更新并捕获异常(单点失败不影响其他片段)
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
|
||||
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
|
||||
# 写新区间,最后统一 commit;任一步失败整体 rollback,
|
||||
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
# 获取素材视频 URL
|
||||
video_url: str | None = None
|
||||
storage_key = getattr(asset, "storage_key", None) or ""
|
||||
mime = getattr(asset, "mime_type", "") or ""
|
||||
if storage_key and mime.startswith("video/"):
|
||||
try:
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
video_url = storage.get_download_url(storage_key)
|
||||
except Exception as e:
|
||||
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
# 构建该素材的占用区间列表(排除已更新片段)
|
||||
def _get_other_segments(asset_id_inner, clip_id_inner):
|
||||
segs: list[tuple[float, float]] = []
|
||||
for c in clips_by_asset.get(asset_id_inner, []):
|
||||
cid = c.id
|
||||
if cid != clip_id_inner and cid not in updated_clip_ids:
|
||||
segs.append((c.start_time, c.start_time + c.duration))
|
||||
segs.extend(updated_segments.get(asset_id_inner, []))
|
||||
|
||||
# 并入历史已用区间
|
||||
def _norm(segs_in):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
|
||||
|
||||
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
|
||||
|
||||
# 优先使用 SceneChange 策略
|
||||
scene_segments: list[tuple[float, float]] = []
|
||||
if client.is_available and video_url:
|
||||
scene_changes = client.detect_scene_changes(video_url)
|
||||
if scene_changes is not None:
|
||||
scene_segments = _build_scene_segments(scene_changes, asset_total)
|
||||
logger.info(
|
||||
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
|
||||
asset_id,
|
||||
len(scene_segments),
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
if not scene_segments and video_url:
|
||||
fallback_recs = _get_mediakit_recommendations([asset_id], asset_repo)
|
||||
if fallback_recs and asset_id in fallback_recs:
|
||||
# analyze_videos 只返回单个推荐点,转为单镜头段
|
||||
rec_start = fallback_recs[asset_id]
|
||||
scene_segments = [(rec_start, asset_total)]
|
||||
logger.info(
|
||||
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
|
||||
asset_id,
|
||||
rec_start,
|
||||
)
|
||||
|
||||
if not scene_segments:
|
||||
# 所有方式都失败 → 保持现有随机 start_time
|
||||
logger.info(
|
||||
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
|
||||
asset_id,
|
||||
)
|
||||
continue
|
||||
|
||||
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
# 为每个片段分配不同的镜头段
|
||||
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
|
||||
for clip in asset_clips:
|
||||
clip_duration = clip.duration
|
||||
recommended_start: float | None = None
|
||||
|
||||
# 从镜头段池中依次尝试,选一个不冲突的
|
||||
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool):
|
||||
candidate_start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
if candidate_start is None:
|
||||
continue # 镜头段太短,跳过
|
||||
|
||||
# 检查越界
|
||||
if candidate_start + clip_duration > asset_total:
|
||||
continue
|
||||
|
||||
# 检查与已用区间冲突
|
||||
other_segs = _get_other_segments(asset_id, clip.id)
|
||||
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
|
||||
continue
|
||||
|
||||
recommended_start = candidate_start
|
||||
# 消费该镜头段(从池中移除,下一个片段用不同镜头段)
|
||||
scene_segments_pool.pop(seg_idx)
|
||||
break
|
||||
|
||||
if recommended_start is None:
|
||||
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底
|
||||
used_segs_for_calc: dict[str, list[tuple[float, float]]] = {
|
||||
asset_id: _get_other_segments(asset_id, clip.id)
|
||||
}
|
||||
fallback_start = _calc_random_start_time(
|
||||
asset_id,
|
||||
clip_duration,
|
||||
{asset_id: asset_total},
|
||||
used_segs_for_calc,
|
||||
)
|
||||
if fallback_start is None:
|
||||
continue # 完全无法分配,保持原起点
|
||||
recommended_start = fallback_start
|
||||
|
||||
# 更新片段起始时间
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
try:
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -32,7 +33,7 @@ from packages.domain.plan_generator_utils import (
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.smart_match import score_asset
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -235,9 +236,12 @@ class PlanGeneratorService:
|
||||
)
|
||||
|
||||
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
|
||||
"""按 smart_match 综合评分降序排列素材 ID。
|
||||
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
|
||||
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)排在前面。
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
|
||||
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
|
||||
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
|
||||
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
|
||||
"""
|
||||
scored: list[tuple[str, float]] = []
|
||||
for asset_id in asset_ids:
|
||||
@@ -247,8 +251,11 @@ class PlanGeneratorService:
|
||||
scored.append((asset_id, score))
|
||||
else:
|
||||
scored.append((asset_id, 0.0))
|
||||
# 按评分降序排列
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
# 评分 + 随机噪声后按降序排列
|
||||
scored.sort(
|
||||
key=lambda x: x[1] + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX),
|
||||
reverse=True,
|
||||
)
|
||||
return [aid for aid, _ in scored]
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
|
||||
@@ -1733,8 +1733,8 @@
|
||||
/* 标题预设卡片网格 */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0;
|
||||
grid-template-columns: repeat(6, 52px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
|
||||
@@ -221,7 +221,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
product.duplicateRate > 0 ? ` ${dupClass}` : ""
|
||||
}`}
|
||||
>
|
||||
查重率:{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
查重率:{product.duplicateRate != null ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,13 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
# 素材选取排序时注入的随机噪声上限(分)。
|
||||
# score_asset 综合得分范围为 0-100,噪声 0~20 意味着:
|
||||
# - 素材间得分差距 > 20 分时,排名不受影响(质量差异显著的素材保持稳定优先级)
|
||||
# - 得分接近(差距 <= 20 分)的素材排名会随机浮动,使每次生成选出的素材组合不同,
|
||||
# 从素材组合层面降低成片重复率;排名靠后的低分素材也有机会入选。
|
||||
SCORE_RANDOM_NOISE_MAX = 20.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartMatchResult:
|
||||
|
||||
@@ -119,6 +119,70 @@ class MediaKitClient:
|
||||
|
||||
return None
|
||||
|
||||
def detect_scene_changes(
|
||||
self,
|
||||
video_url: str,
|
||||
max_frames: int = 20,
|
||||
poll_interval: float = 2.0,
|
||||
max_poll_attempts: int = 30,
|
||||
) -> Optional[List[float]]:
|
||||
"""检测视频场景切换点,返回时间戳列表.
|
||||
|
||||
降级策略:
|
||||
1. 先尝试 SceneChange 策略
|
||||
2. SceneChange 失败(OOM等)→ 退回 TimeInterval(5秒间隔)
|
||||
3. MediaKit 不可用 → 返回 None
|
||||
|
||||
Returns:
|
||||
场景切换点时间戳列表,如 [0.0, 3.2, 7.8, 12.5]
|
||||
失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
logger.warning("MediaKit 未配置,跳过场景检测")
|
||||
return None
|
||||
|
||||
# 策略1:尝试 SceneChange
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SceneChange",
|
||||
max_frames=max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
# 策略2:SceneChange 失败 → 退回 TimeInterval(5秒间隔)
|
||||
if frames is None:
|
||||
logger.info("SceneChange 策略失败,降级为 TimeInterval(5秒间隔)")
|
||||
# 估算帧数:假设视频最长60秒,每5秒一帧
|
||||
ti_max_frames = max(max_frames, 12)
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="TimeInterval",
|
||||
max_frames=ti_max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
if frames is None:
|
||||
return None
|
||||
|
||||
# 从帧列表中提取 timestamp,排序
|
||||
timestamps = sorted({float(f.get("timestamp", 0.0)) for f in frames if "timestamp" in f})
|
||||
|
||||
if not timestamps:
|
||||
return None
|
||||
|
||||
# 始终在列表开头加 0.0(素材起始点)
|
||||
if timestamps[0] != 0.0:
|
||||
timestamps.insert(0, 0.0)
|
||||
|
||||
logger.info(
|
||||
"场景检测完成: video_url=%s scene_changes=%s",
|
||||
video_url[:80],
|
||||
timestamps,
|
||||
)
|
||||
return timestamps
|
||||
|
||||
def _submit_extract_task(
|
||||
self,
|
||||
video_url: str,
|
||||
|
||||
@@ -24,8 +24,10 @@ celery==5.4.0
|
||||
# 对象存储
|
||||
oss2==2.18.4
|
||||
|
||||
# HTTP 客户端
|
||||
# HTTP 客户端(pin 间接依赖防止版本漂移)
|
||||
httpx==0.27.2
|
||||
httpcore==1.0.7
|
||||
h2==4.1.0
|
||||
|
||||
# Prometheus monitoring
|
||||
prometheus-client==0.21.1
|
||||
|
||||
@@ -15,5 +15,6 @@ pytest-xdist==3.6.1
|
||||
diff-cover==8.0.3
|
||||
|
||||
# 资产质量评分依赖(与 requirements-worker.txt 保持一致)
|
||||
numpy==1.26.4
|
||||
scipy==1.13.1
|
||||
Pillow==10.4.0
|
||||
|
||||
@@ -38,6 +38,28 @@ def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
||||
return [(i, dur_min, dur_max) for i in range(count)]
|
||||
|
||||
|
||||
def _patch_zero_noise():
|
||||
"""消除 clips.py 排序随机噪声,用于确定性断言(如均衡分配)。
|
||||
|
||||
排序噪声(random.uniform(0, SCORE_RANDOM_NOISE_MAX))返回 0;
|
||||
其他 uniform 调用(片段时长随机)委托给独立 Random 实例,行为不变。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _patch_segments(segments=None):
|
||||
return patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
@@ -130,7 +152,8 @@ class TestEditorClipsBySegments:
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||||
|
||||
with _patch_segments(DEFAULT_SEGMENTS):
|
||||
# 均衡分配由 use_count 贪心保证,消除排序噪声后确定性断言
|
||||
with _patch_zero_noise(), _patch_segments(DEFAULT_SEGMENTS):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
@@ -418,8 +441,13 @@ class TestEditorClipsDurationAndStartTime:
|
||||
|
||||
assert mock_calc.call_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["start_time"] == 12.5
|
||||
assert clips_data[1]["start_time"] == 18.0
|
||||
# clips_data 按 order 排序,但分配顺序因 shuffle 而随机,
|
||||
# 因此只验证两个 start_time 值都存在
|
||||
start_times = {c["start_time"] for c in clips_data}
|
||||
assert start_times == {12.5, 18.0}
|
||||
# 验证 order 仍然有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_asset_durations_deduped(self, mock_storage):
|
||||
@@ -790,7 +818,8 @@ class TestClipsFromAssetsInvalidIds:
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"]) # type: ignore[list-item]
|
||||
|
||||
with _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
|
||||
# 消除排序噪声,确定性断言两条合法素材各被使用
|
||||
with _patch_zero_noise(), _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
|
||||
@@ -306,6 +306,27 @@ class TestGetTemplateSegments:
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _patch_zero_noise():
|
||||
"""消除 clips.py 排序随机噪声(SCORE_RANDOM_NOISE_MAX 噪声返回 0)。
|
||||
|
||||
用于均衡分配等确定性断言;其他 uniform 调用(片段时长随机)行为不变。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
@@ -446,8 +467,12 @@ class TestFromAssetsByTemplateSegments:
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
assert 3.0 <= clips_data[0]["duration"] <= 5.0
|
||||
assert 4.0 <= clips_data[1]["duration"] <= 8.0
|
||||
# PR #1614 转场补偿:2 个片段时每 clip 时长 +(2-1)*0.5/2=0.25s
|
||||
# (xfade 重叠在渲染时扣除,故 clip 时长 = segment 随机时长 + 补偿),
|
||||
# 断言上界需计入补偿与一位小数舍入余量
|
||||
comp = (2 - 1) * 0.5 / 2
|
||||
assert 3.0 + comp - 0.1 <= clips_data[0]["duration"] <= 5.0 + comp + 0.1
|
||||
assert 4.0 + comp - 0.1 <= clips_data[1]["duration"] <= 8.0 + comp + 0.1
|
||||
|
||||
def test_assets_balanced_assignment(self):
|
||||
"""素材按使用次数贪心分配(使用少的优先),保证均衡使用。"""
|
||||
@@ -466,16 +491,18 @@ class TestFromAssetsByTemplateSegments:
|
||||
mock_asset_repo.get.side_effect = get_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
# 消除排序噪声,确定性断言贪心均衡分配
|
||||
with _patch_zero_noise():
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests for scene-change smart frame selection + random shuffle of segment processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.api.app.api.routes.templates_editor.clips import (
|
||||
_build_scene_segments,
|
||||
_pick_start_in_scene_segment,
|
||||
)
|
||||
from packages.shared.mediakit_client import MediaKitClient
|
||||
|
||||
# ── Part 1: Random shuffle tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRandomShuffle:
|
||||
"""验证 segments 处理顺序随机打乱逻辑."""
|
||||
|
||||
def test_same_segments_produce_different_asset_orders(self):
|
||||
"""同一批 segments 多次处理,asset 分配顺序有变化.
|
||||
|
||||
模拟打乱后的处理顺序,验证多次运行中 asset_id 分配顺序
|
||||
存在差异(概率性验证,运行 50 次应该至少出现 2 种排列)。
|
||||
"""
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||||
asset_ids = ["A", "B", "C", "D"]
|
||||
|
||||
observed_orders: list[tuple] = set()
|
||||
|
||||
for _ in range(50):
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
order_tuple = tuple(shuffled_indices)
|
||||
observed_orders.add(order_tuple)
|
||||
|
||||
# 50 次打乱,4! = 24 种排列,应出现多种不同排列
|
||||
assert len(observed_orders) > 1, "打乱应该产生多种不同顺序"
|
||||
|
||||
def test_clips_data_order_always_sorted(self):
|
||||
"""clips_data 按 order 排序后始终有序.
|
||||
|
||||
模拟打乱处理后 clips_data 按 order 排序,验证最终 order 为 [0,1,2,3]。
|
||||
"""
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||||
|
||||
for _ in range(20):
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
|
||||
# 模拟构建 clips_data(用 _seg_order 作为 order)
|
||||
clips_data = []
|
||||
for idx in shuffled_indices:
|
||||
seg_order, _, _ = segments[idx]
|
||||
clips_data.append({"order": seg_order, "asset_id": f"asset_{idx}"})
|
||||
|
||||
# 按 order 排序
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 验证 order 始终有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2, 3], f"排序后 order 应为 [0,1,2,3],实际为 {orders}"
|
||||
|
||||
|
||||
# ── Part 2: detect_scene_changes tests ────────────────────────────────────
|
||||
|
||||
|
||||
class TestDetectSceneChanges:
|
||||
"""验证 MediaKitClient.detect_scene_changes 方法."""
|
||||
|
||||
def _make_client(self) -> MediaKitClient:
|
||||
"""创建一个可用的 MediaKitClient(mock 配置)."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "http://test"
|
||||
mock_settings.return_value.mediakit_timeout = 30
|
||||
client = MediaKitClient()
|
||||
return client
|
||||
|
||||
def test_scene_change_success(self):
|
||||
"""SceneChange 策略成功返回时间戳列表."""
|
||||
client = self._make_client()
|
||||
|
||||
mock_frames = [
|
||||
{"image_url": "url1", "timestamp": 0.0},
|
||||
{"image_url": "url2", "timestamp": 3.2},
|
||||
{"image_url": "url3", "timestamp": 7.8},
|
||||
{"image_url": "url4", "timestamp": 12.5},
|
||||
]
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0 # 始终以 0.0 开头
|
||||
assert 3.2 in result
|
||||
assert 7.8 in result
|
||||
assert 12.5 in result
|
||||
assert result == sorted(result) # 应已排序
|
||||
|
||||
def test_scene_change_fallback_to_time_interval(self):
|
||||
"""SceneChange 失败降级到 TimeInterval 策略."""
|
||||
client = self._make_client()
|
||||
|
||||
# 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果
|
||||
fallback_frames = [
|
||||
{"image_url": "url1", "timestamp": 0.0},
|
||||
{"image_url": "url2", "timestamp": 5.0},
|
||||
{"image_url": "url3", "timestamp": 10.0},
|
||||
]
|
||||
|
||||
call_count = 0
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# 第一次 SceneChange 失败
|
||||
return None
|
||||
else:
|
||||
# 第二次 TimeInterval 成功
|
||||
assert kwargs.get("strategy") == "TimeInterval"
|
||||
return fallback_frames
|
||||
|
||||
with patch.object(client, "extract_frames", side_effect=side_effect):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0
|
||||
assert 5.0 in result
|
||||
assert 10.0 in result
|
||||
|
||||
def test_mediakit_not_available_returns_none(self):
|
||||
"""MediaKit 不可用时返回 None."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "" # 未配置
|
||||
mock_settings.return_value.mediakit_base_url = "http://test"
|
||||
mock_settings.return_value.mediakit_timeout = 30
|
||||
client = MediaKitClient()
|
||||
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
assert result is None
|
||||
|
||||
def test_both_strategies_fail_returns_none(self):
|
||||
"""SceneChange 和 TimeInterval 都失败时返回 None."""
|
||||
client = self._make_client()
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=None):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_prepends_zero_if_not_present(self):
|
||||
"""若帧列表中不包含 0.0,自动在开头添加."""
|
||||
client = self._make_client()
|
||||
|
||||
# 帧列表中没有 timestamp=0.0
|
||||
mock_frames = [
|
||||
{"image_url": "url1", "timestamp": 2.0},
|
||||
{"image_url": "url2", "timestamp": 5.5},
|
||||
]
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0
|
||||
assert 2.0 in result
|
||||
assert 5.5 in result
|
||||
|
||||
|
||||
# ── Part 2.2: Scene segment building and assignment ───────────────────────
|
||||
|
||||
|
||||
class TestSceneSegments:
|
||||
"""验证镜头段构建和分配逻辑."""
|
||||
|
||||
def test_build_scene_segments(self):
|
||||
"""从场景切换点正确构建镜头段."""
|
||||
scene_changes = [0.0, 3.2, 7.8, 12.5]
|
||||
asset_duration = 15.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
|
||||
assert len(segments) == 4
|
||||
assert segments[0] == (0.0, 3.2)
|
||||
assert segments[1] == (3.2, 7.8)
|
||||
assert segments[2] == (7.8, 12.5)
|
||||
assert segments[3] == (12.5, 15.0)
|
||||
|
||||
def test_build_scene_segments_filters_short(self):
|
||||
"""过滤掉过短的镜头段(< 0.5秒)."""
|
||||
scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0]
|
||||
asset_duration = 12.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
|
||||
# (0.0, 0.1) 长度 0.1 < 0.5 → 过滤
|
||||
# (0.1, 5.0) → 保留
|
||||
# (5.0, 5.3) 长度 0.3 < 0.5 → 过滤
|
||||
# (5.3, 10.0) → 保留
|
||||
# (10.0, 12.0) → 保留
|
||||
assert len(segments) == 3
|
||||
assert segments[0] == (0.1, 5.0)
|
||||
assert segments[1] == (5.3, 10.0)
|
||||
assert segments[2] == (10.0, 12.0)
|
||||
|
||||
def test_pick_start_in_segment(self):
|
||||
"""在镜头段内随机选取起始时间."""
|
||||
seg_start = 3.0
|
||||
seg_end = 8.0
|
||||
clip_duration = 2.0
|
||||
|
||||
starts = set()
|
||||
for _ in range(100):
|
||||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
assert start is not None
|
||||
assert seg_start <= start <= seg_end - clip_duration
|
||||
starts.add(round(start, 2))
|
||||
|
||||
# 应该有多个不同的起始时间
|
||||
assert len(starts) > 1
|
||||
|
||||
def test_pick_start_segment_too_short(self):
|
||||
"""镜头段太短无法容纳片段时返回 None."""
|
||||
result = _pick_start_in_scene_segment(0.0, 1.0, 2.0)
|
||||
assert result is None
|
||||
|
||||
def test_different_clips_from_different_scenes(self):
|
||||
"""不同片段应来自不同的镜头段(模拟分配逻辑)."""
|
||||
scene_changes = [0.0, 5.0, 10.0, 15.0]
|
||||
asset_duration = 18.0
|
||||
clip_duration = 3.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18)
|
||||
|
||||
# 模拟 3 个片段从不同镜头段取点
|
||||
scene_pool = list(segments)
|
||||
assigned_starts = []
|
||||
|
||||
for _ in range(3):
|
||||
if not scene_pool:
|
||||
break
|
||||
seg_start, seg_end = scene_pool.pop(0)
|
||||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
assert start is not None
|
||||
assigned_starts.append(start)
|
||||
|
||||
# 3 个片段分别从 3 个不同镜头段中选取
|
||||
assert len(assigned_starts) == 3
|
||||
# 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12]
|
||||
assert 0.0 <= assigned_starts[0] <= 2.0
|
||||
assert 5.0 <= assigned_starts[1] <= 7.0
|
||||
assert 10.0 <= assigned_starts[2] <= 12.0
|
||||
@@ -23,7 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.smart_match import score_asset, smart_select_assets
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset, smart_select_assets
|
||||
|
||||
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -158,6 +158,38 @@ def _make_auth_user():
|
||||
return auth
|
||||
|
||||
|
||||
def _make_zero_noise_patcher(module):
|
||||
"""构造 patch(module.random.uniform):噪声调用(上界=SCORE_RANDOM_NOISE_MAX)返回 0。
|
||||
|
||||
其他 uniform 调用(如片段时长随机)委托给一个独立的 Random 实例,
|
||||
避免递归回已 patch 的全局函数。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _patch_zero_noise_clips():
|
||||
"""消除 clips.py 排序噪声,其他 uniform 调用不受影响。"""
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
return _make_zero_noise_patcher(clips_module)
|
||||
|
||||
|
||||
def _patch_zero_noise_plan_service():
|
||||
"""消除 plan_generator_service.py 排序噪声,其他 uniform 调用不受影响。"""
|
||||
from app.services import plan_generator_service as svc_module
|
||||
|
||||
return _make_zero_noise_patcher(svc_module)
|
||||
|
||||
|
||||
class TestFromAssetsSmartMatchIntegration:
|
||||
"""验证 clips.py 中 sorted_candidates 使用 smart_match 评分。"""
|
||||
|
||||
@@ -180,6 +212,7 @@ class TestFromAssetsSmartMatchIntegration:
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
|
||||
with (
|
||||
_patch_zero_noise_clips(),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments,
|
||||
@@ -219,6 +252,63 @@ class TestFromAssetsSmartMatchIntegration:
|
||||
first_clip_asset == "a_fresh"
|
||||
), f"第一个片段应分配给 smart_match 分更高的 a_fresh,实际是 {first_clip_asset}"
|
||||
|
||||
def test_score_noise_causes_varied_selection(self):
|
||||
"""得分接近(差距 < SCORE_RANDOM_NOISE_MAX)的素材,多次生成的素材组合应有变化。
|
||||
|
||||
两条同等质量/时长/新鲜度的素材(use_count 相同),smart_match 得分一致,
|
||||
噪声让两者的相对排名随机浮动,多次调用首个片段的素材分布应两者都出现。
|
||||
"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
def _get_asset(aid):
|
||||
return _make_mock_asset_for_clips(aid, 30.0, 0)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
|
||||
first_assets: set[str] = set()
|
||||
for _ in range(30):
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
|
||||
with (
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments,
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.get_used_segments",
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.record_used_segments",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
body = ClipsFromAssetsRequest(
|
||||
asset_ids=["a_x", "a_y"],
|
||||
required_clips_count=2,
|
||||
)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=f"plan-noise-{len(first_assets)}-{_}",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = mock_plan_svc.replace_all_clips_transactional.call_args.args[1]
|
||||
first_assets.add(clips_data[0]["asset_id"])
|
||||
|
||||
assert first_assets == {
|
||||
"a_x",
|
||||
"a_y",
|
||||
}, f"噪声应使两条等分素材的排名浮动,30 次调用首个片段应覆盖两者,实际 {first_assets}"
|
||||
|
||||
|
||||
# ── 一键生成路径集成测试 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -247,7 +337,8 @@ class TestPlanGeneratorSmartMatchIntegration:
|
||||
db = MagicMock()
|
||||
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
|
||||
|
||||
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
|
||||
with _patch_zero_noise_plan_service():
|
||||
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
|
||||
|
||||
# low_use (0次) 应排第一,high_use (10次) 应排最后
|
||||
assert sorted_ids[0] == "low_use"
|
||||
@@ -284,7 +375,10 @@ class TestPlanGeneratorSmartMatchIntegration:
|
||||
EditPlanClip(id="c2", plan_id="p1", clip_type="main", duration=5.0, order=1),
|
||||
]
|
||||
|
||||
with patch("app.services.plan_generator_service.distribute_assets") as mock_dist:
|
||||
with (
|
||||
_patch_zero_noise_plan_service(),
|
||||
patch("app.services.plan_generator_service.distribute_assets") as mock_dist,
|
||||
):
|
||||
svc._distribute_assets(
|
||||
clips,
|
||||
["old_asset", "new_asset"],
|
||||
@@ -322,3 +416,60 @@ class TestPlanGeneratorSmartMatchIntegration:
|
||||
)
|
||||
# random_selection=True 时不应调用 asset_repo.get(不执行排序)
|
||||
mock_asset_repo.get.assert_not_called()
|
||||
|
||||
|
||||
class TestPlanGeneratorScoreNoise:
|
||||
"""验证一键生成路径的评分排序注入了随机噪声。"""
|
||||
|
||||
def test_equal_scores_produce_varied_order(self):
|
||||
"""两条 smart_match 得分相同的素材,多次排序的首位应覆盖两者。"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
def _get_asset(aid):
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": 0}
|
||||
return asset
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
|
||||
|
||||
first_ids: set[str] = set()
|
||||
for _ in range(30):
|
||||
order = svc._sort_assets_by_smart_score(["equal_a", "equal_b"])
|
||||
first_ids.add(order[0])
|
||||
|
||||
assert first_ids == {
|
||||
"equal_a",
|
||||
"equal_b",
|
||||
}, f"噪声应使等分素材排名浮动,30 次排序首位应覆盖两者,实际 {first_ids}"
|
||||
|
||||
def test_large_score_gap_not_flipped(self):
|
||||
"""得分差距远大于噪声上限时,低分素材不会因噪声超过高分素材。
|
||||
|
||||
quality 100 vs 0 → quality 维度差距 40 分 > 噪声上限 20,
|
||||
其余维度完全一致,50 次排序高质量素材必须始终排第一。
|
||||
"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
def _get_asset(aid):
|
||||
quality = {"top": 100.0, "bad": 0.0}[aid]
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = quality
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": 0}
|
||||
return asset
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
|
||||
|
||||
for _ in range(50):
|
||||
order = svc._sort_assets_by_smart_score(["top", "bad"])
|
||||
assert order[0] == "top", f"质量差距 40 分 > 噪声上限,top 应始终排第一,实际 {order}"
|
||||
|
||||
Reference in New Issue
Block a user