Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 318cffd35c fix(smart-match): limit 算法取 segments.length 下限,保证每片段至少 1 个素材
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m33s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 3m26s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m24s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 5m1s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m45s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 6m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m40s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 9m42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 0s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 2m56s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m28s
computeLimitFromSegments 原逻辑 Math.ceil(totalSeconds/15) 在短模板
(如 14s 模板 4 片段)下仅返回 1,导致后端只分配 1 个素材无法覆盖
全部片段。改为 Math.max(segments.length, ...) 保证每个片段至少能
分到 1 个素材。
2026-09-01 12:11:36 +08:00
36 changed files with 329 additions and 2119 deletions
-105
View File
@@ -1,105 +0,0 @@
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"
+99 -307
View File
@@ -23,10 +23,6 @@ import re
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session
# 默认转场时长(与 worker 端保持一致)
_DEFAULT_TRANSITION_DURATION = 0.5
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
@@ -48,7 +44,6 @@ 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.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services
@@ -587,47 +582,6 @@ def _get_mediakit_recommendations(
return {}
def _calc_plan_internal_duplicate_rate(clips_data: list[dict]) -> float:
"""估算单条成片内部重复率(%.
检查本条成片中同一素材是否有重叠的时间区间。
重叠时长 / 成片总时长 * 100 = 内部重复率。
这是一个轻量估算,不依赖视频指纹;完整查重由 worker 异步完成。
"""
if not clips_data:
return 0.0
# 按素材分组
by_asset: dict[str, list[tuple[float, float]]] = {}
total_duration = 0.0
for c in clips_data:
aid = c.get("asset_id", "")
if not aid:
continue
start = c.get("start_time", 0.0)
end = start + c.get("duration", 0.0)
by_asset.setdefault(aid, []).append((start, end))
total_duration += c.get("duration", 0.0)
if total_duration <= 0:
return 0.0
# 检查同素材内的区间重叠
overlap_duration = 0.0
for segments in by_asset.values():
if len(segments) < 2:
continue
segments_sorted = sorted(segments, key=lambda s: s[0])
last_end = segments_sorted[0][1]
for start, end in segments_sorted[1:]:
overlap = max(0.0, min(end, last_end) - start)
if overlap > 0:
overlap_duration += overlap
last_end = max(last_end, end)
return round(overlap_duration / total_duration * 100, 1)
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor(
template_id: str,
@@ -672,14 +626,10 @@ def create_clips_from_assets_editor(
# 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {}
asset_smart_scores: dict[str, float] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算 smart_match 综合评分,用于候选排序
smart_score, _ = score_asset(asset)
asset_smart_scores[asset_id] = smart_score
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
@@ -712,51 +662,19 @@ def create_clips_from_assets_editor(
return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
# 素材耗尽标志:某轮循环中所有素材均被跳过时为 True
all_assets_exhausted = False
# 计算转场重叠补偿:每个 clip 需要额外增加的时长
# 目标:渲染后视频总时长 = 模板设定的各片段时长之和
# 公式:每 clip 增加 (n_segments - 1) * td / n_segments
n_segments = len(segments)
if n_segments > 1:
transition_compensation = (n_segments - 1) * _DEFAULT_TRANSITION_DURATION / n_segments
else:
transition_compensation = 0.0
# 打乱 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]
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
raw_duration += transition_compensation
# 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先),
# 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。
# 跳过时长缺失、复用占比已超 10% 阈值的素材;
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续尝试下一个素材
# 继续轮询下一个素材
asset_id = ""
clip_duration = 0.0
start_time: float | None = None
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
asset_use_counts = {
aid: len(used_segments.get(aid, []))
for aid in asset_ids
}
sorted_candidates = sorted(
asset_ids,
key=lambda aid: (
-asset_smart_scores.get(aid, 0.0),
asset_use_counts.get(aid, 0),
random.random(),
),
)
for candidate in sorted_candidates:
n_assets = len(asset_ids)
for offset in range(n_assets):
candidate = asset_ids[(i + offset) % n_assets]
candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0:
continue
@@ -772,7 +690,7 @@ def create_clips_from_assets_editor(
continue
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 10% 则拒绝并返回 None
# 预判复用后占比超 15% 则拒绝并返回 None
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
@@ -794,7 +712,6 @@ def create_clips_from_assets_editor(
if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
all_assets_exhausted = True
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材可切区间不足,请补充新素材",
@@ -810,7 +727,7 @@ def create_clips_from_assets_editor(
clips_data.append(
{
"order": _seg_order,
"order": i,
"asset_id": asset_id,
"start_time": start_time,
"duration": clip_duration,
@@ -818,9 +735,6 @@ 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)
@@ -840,86 +754,19 @@ def create_clips_from_assets_editor(
unique_asset_ids,
)
# 6. 估算成片内部重复率(本条成片中同一素材的重叠片段时长占比)
dup_rate = _calc_plan_internal_duplicate_rate(clips_data)
duplicate_warning = None
if dup_rate > 50:
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
logger.warning(
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
plan_id, dup_rate,
)
# 7. 素材耗尽提示
exhaustion_warning = None
if all_assets_exhausted and created_count < len(segments):
exhaustion_warning = (
"素材可切区间不足,部分片段使用了复用素材。"
"建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
)
# 8. 立即返回响应
# 6. 立即返回响应
return ClipsFromAssetsResponse(
created_count=created_count,
plan_id=plan_id,
clip_ids=[],
duplicate_warning=duplicate_warning,
exhaustion_warning=exhaustion_warning,
)
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:
"""后台任务:使SceneChange 智能选并更新片段的起始时间.
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
降级策略:
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
"""后台任务:MediaKit 智能选并更新片段的起始时间.
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
@@ -941,6 +788,12 @@ 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 = []
@@ -963,16 +816,15 @@ 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 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段
clips_by_asset: dict[str, list] = defaultdict(list)
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
for clip in clips:
aid = getattr(clip, "asset_id", "") or ""
if aid:
clips_by_asset[aid].append(clip)
for aid in clips_by_asset:
clips_by_asset[aid].sort(key=lambda c: c.order)
if aid and clip.start_time is not None:
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
@@ -981,22 +833,16 @@ def _update_mediakit_recommendations_async( # pragma: no cover
updated_segments: dict[str, list[tuple[float, float]]] = {}
updated_count = 0
# 尝试获取存储服务(用于生成视频 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:
# 遍历片段,按 asset_id 匹配推荐时间
for clip in clips:
asset_id = getattr(clip, "asset_id", "") or ""
if not asset_id or asset_id not in recommendations:
continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id)
if not asset:
continue
@@ -1004,143 +850,89 @@ def _update_mediakit_recommendations_async( # pragma: no cover
if asset_total <= 0:
continue
# 获取素材视频 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:
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),
)
# 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
# 推荐时间 + 片段时长不能超过素材总时长
if recommended_start + clip_duration > asset_total:
logger.info(
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
asset_id,
recommended_start,
clip_duration,
asset_total,
)
continue
# 为每个片段分配不同的镜头段
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
for clip in asset_clips:
clip_duration = clip.duration
recommended_start: float | None = None
# 构建排除当前片段及已更新片段后的占用列表(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, []))
# 从镜头段池中依次尝试,选一个不冲突的
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 # 镜头段太短,跳过
# 并入该素材全部历史已用区间(含其他 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}
# 检查越界
if candidate_start + clip_duration > asset_total:
continue
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
# 检查与已用区间冲突
other_segs = _get_other_segments(asset_id, clip.id)
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
continue
# 检查推荐时间是否与同 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
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
# 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)
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,
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,
)
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",
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
asset_id,
recommended_start,
me,
)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
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
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,
)
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
@@ -189,8 +189,6 @@ class ClipsFromAssetsResponse(BaseModel):
plan_id: str = ""
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
# ── 封面配置 ────────────────────────────────────────────────────────────────
@@ -3,7 +3,7 @@
在素材 metadataassets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 10%),把任意两条
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``::
@@ -40,14 +40,14 @@ logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 2
MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.10
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(10%)。
REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 1.5
SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
@@ -397,12 +397,12 @@ def make_reuse_callback(
db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(10% 阈值)。
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 10%
ratio_limit: 单条成片复用时长占比上限,默认 15%
Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
@@ -32,7 +32,6 @@ 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.template_clip_config import TemplateClipConfig
logger = logging.getLogger(__name__)
@@ -219,13 +218,8 @@ class PlanGeneratorService:
) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
先用 smart_match 评分对素材排序(高分优先),再委托给
plan_generator_utils.distribute_assets 纯函数完成分配。
委托给 plan_generator_utils.distribute_assets 纯函数。
"""
# 用 smart_match 评分排序素材:高分(质量好/时长合适/新鲜/未使用)优先
if self._asset_repo and not random_selection:
asset_ids = self._sort_assets_by_smart_score(asset_ids)
distribute_assets(
clips,
asset_ids,
@@ -234,23 +228,6 @@ class PlanGeneratorService:
asset_durations=asset_durations,
)
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
"""按 smart_match 综合评分降序排列素材 ID。
评分高的素材(质量好、时长合适、新鲜、使用次数少)排在前面。
"""
scored: list[tuple[str, float]] = []
for asset_id in asset_ids:
asset = self._asset_repo.get(asset_id)
if asset:
score, _ = score_asset(asset)
scored.append((asset_id, score))
else:
scored.append((asset_id, 0.0))
# 按评分降序排列
scored.sort(key=lambda x: x[1], reverse=True)
return [aid for aid, _ in scored]
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
"""从数据库获取素材时长信息.
+14 -7
View File
@@ -1848,9 +1848,10 @@
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -1937,9 +1938,10 @@
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
@@ -3111,9 +3113,10 @@
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
@@ -4454,9 +4457,10 @@
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
@@ -5004,9 +5008,10 @@
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
@@ -5019,9 +5024,10 @@
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
@@ -5729,9 +5735,10 @@
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
-1
View File
@@ -71,5 +71,4 @@ export interface VideoItem {
generation_params: Record<string, unknown>
download_url: string
generated_at: string
duplicate_rate?: number
}
+2 -1
View File
@@ -29,6 +29,7 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
created_at: video.generated_at,
updated_at: video.generated_at,
duplicate_rate: video.duplicate_rate,
// 后端 /videos 接口暂无 duplicate_rate 字段
duplicate_rate: undefined,
}
}
@@ -141,7 +141,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
const draggingTitleRef = useRef(false)
const titleDragRef = useRef<HTMLDivElement>(null)
const handleTitlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onTitlePositionChange || !playerContainerRef.current) return
@@ -153,45 +152,32 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
},
[onTitlePositionChange],
)
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
if (titleDragRef.current) {
const handleTitlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current || !onTitlePositionChange || !playerContainerRef.current) return
e.preventDefault()
e.stopPropagation()
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const xpct = (relX / rect.width) * 100
const ypct = (relY / rect.height) * 100
titleDragRef.current.style.left = `${xpct}%`
titleDragRef.current.style.top = `${ypct}%`
}
}, [])
const handleTitlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
// 拖拽结束时才调用 onTitlePositionChange 保存最终位置
if (onTitlePositionChange && playerContainerRef.current) {
const rect = playerContainerRef.current.getBoundingClientRect()
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
}
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
const posX = Math.round((relX / rect.width) * playRes.width)
const posY = Math.round((relY / rect.height) * playRes.height)
onTitlePositionChange(posX, posY)
},
[onTitlePositionChange, playRes.width, playRes.height],
)
const handleTitlePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!draggingTitleRef.current) return
draggingTitleRef.current = false
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
try {
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
}
} catch {
/* ignore */
}
}, [])
const playerContainerRef = useRef<HTMLDivElement>(null)
const [containerHeight, setContainerHeight] = useState(0)
@@ -608,7 +594,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
? { top: "50%", transform: "translateY(-50%)" }
: { bottom: `${titleBottomPct}%` }),
}),
pointerEvents: "auto",
cursor: onTitlePositionChange ? "grab" : "default",
touchAction: "none",
userSelect: "none",
@@ -616,7 +601,6 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
padding: "8px 12px",
boxShadow: "inset 0 0 0 16px transparent",
}}
ref={titleDragRef}
onPointerDown={handleTitlePointerDown}
onPointerMove={handleTitlePointerMove}
onPointerUp={handleTitlePointerUp}
@@ -1,7 +1,5 @@
/**
* 标题预设样式网格
* 双图层渲染:底层=描边轮廓(text-shadow模拟),上层=填充色
* 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题
*/
import React from "react"
import { getFontFamily } from "../../constants"
@@ -9,10 +7,7 @@ import { getFontFamily } from "../../constants"
interface TitlePresetItem {
key: string
label: string
previewStyle: React.CSSProperties & {
_strokeColor?: string
_strokeWidth?: number
}
previewStyle: React.CSSProperties
}
interface TitlePresetsGridProps {
@@ -22,32 +17,6 @@ interface TitlePresetsGridProps {
fontFamily?: string
}
/**
* 用 text-shadow 模拟描边轮廓(8方向 + 4对角 = 12层阴影)
*/
function buildStrokeShadow(color: string, width: number): string {
const w = width
const parts: string[] = []
// 4 cardinal directions
parts.push(`${w}px 0 ${color}`)
parts.push(`${-w}px 0 ${color}`)
parts.push(`0 ${w}px ${color}`)
parts.push(`0 ${-w}px ${color}`)
// 4 diagonal directions
const d = Math.round(w * 0.71 * 10) / 10 // 0.71 ≈ sqrt(2)/2
parts.push(`${d}px ${d}px ${color}`)
parts.push(`${-d}px ${d}px ${color}`)
parts.push(`${d}px ${-d}px ${color}`)
parts.push(`${-d}px ${-d}px ${color}`)
// 4 extra mid-points for smoother stroke
const h = Math.round(w * 0.5 * 10) / 10
parts.push(`${w}px ${h}px ${color}`)
parts.push(`${w}px ${-h}px ${color}`)
parts.push(`${-w}px ${h}px ${color}`)
parts.push(`${-w}px ${-h}px ${color}`)
return parts.join(", ")
}
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
presets,
activePreset,
@@ -58,52 +27,18 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
<div className="xx-title-presets-grid">
{presets.map((p) => {
const isActive = activePreset === p.key
const { _strokeColor, _strokeWidth, ...fillStyle } = p.previewStyle
const ff = getFontFamily(fontFamily || "思源黑体")
// 底层:描边轮廓(用 text-shadow 模拟粗描边)
const strokeStyle: React.CSSProperties = {
color: _strokeColor || "transparent",
textShadow:
_strokeColor && _strokeWidth
? buildStrokeShadow(_strokeColor, _strokeWidth)
: undefined,
fontWeight: fillStyle.fontWeight,
fontSize: fillStyle.fontSize,
lineHeight: 1,
}
// 上层:仅填充色 + 可选 textShadow(发光/投影效果)
const topStyle: React.CSSProperties = {
color: fillStyle.color,
textShadow: fillStyle.textShadow,
fontWeight: fillStyle.fontWeight,
fontSize: fillStyle.fontSize,
lineHeight: 1,
}
return (
<button
key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() => onApply(p.key)}
title={p.label}
>
<span className="xx-title-preset-preview-text" style={{ position: "relative" }}>
{/* 底层:描边轮廓 */}
<span
aria-hidden
style={{
...strokeStyle,
fontFamily: ff,
position: "absolute",
top: 0,
left: 0,
}}
>
T
</span>
{/* 上层:填充色 */}
<span style={{ ...topStyle, fontFamily: ff, position: "relative" }}>T</span>
<span
className="xx-title-preset-preview-text"
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
>
T
</span>
</button>
)
+16 -218
View File
@@ -78,11 +78,10 @@ export const TITLE_PRESETS = [
label: "经典白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ffffff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
@@ -90,54 +89,45 @@ export const TITLE_PRESETS = [
label: "黑金质感",
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#d4a843",
fontWeight: 700,
fontSize: "32px",
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#e8e8e8",
fontWeight: 400,
fontSize: "32px",
},
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
},
{
key: "variety_show",
label: "综艺花字",
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: {
color: "#ff4081",
_strokeColor: "#ffffff",
_strokeWidth: 2,
fontWeight: 900,
fontSize: "32px",
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#e0e0e0",
fontWeight: 400,
fontSize: "32px",
},
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
},
{
key: "retro_film",
label: "复古胶片",
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#e8d5b7",
fontWeight: 400,
fontSize: "32px",
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
@@ -145,10 +135,10 @@ export const TITLE_PRESETS = [
label: "霓虹发光",
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#00e5ff",
fontWeight: 700,
fontSize: "32px",
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
@@ -156,202 +146,10 @@ export const TITLE_PRESETS = [
label: "手写字",
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#e0e0e0",
fontWeight: 400,
fontSize: "32px",
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
},
},
{
key: "outline_yellow",
label: "黄色描边",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ffd54f",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_pink",
label: "粉色描边",
style: { size: 28, color: "#ff80ab", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ff80ab",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_blue",
label: "蓝色描边",
style: { size: 28, color: "#82b1ff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#82b1ff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_green",
label: "绿色描边",
style: { size: 28, color: "#69f0ae", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#69f0ae",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_gray",
label: "灰色描边",
style: { size: 28, color: "#bdbdbd", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#bdbdbd",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "bg_white",
label: "白底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffffff",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_yellow",
label: "黄底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffd54f",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_pink",
label: "粉底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ff80ab",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_red",
label: "红底白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#ef5350",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "neon_orange",
label: "橙色发光",
style: { size: 32, color: "#ff9100", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff9100",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #ff9100, 0 0 8px #ff9100, 0 0 16px rgba(255,145,0,0.5)",
},
},
{
key: "neon_purple",
label: "紫色发光",
style: { size: 32, color: "#d500f9", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#d500f9",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #d500f9, 0 0 8px #d500f9, 0 0 16px rgba(213,0,249,0.5)",
},
},
{
key: "bordered_white",
label: "白字绿框",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#1a1a1a",
border: "2px solid #69f0ae",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "gradient_warm",
label: "暖色渐变",
style: { size: 32, color: "#ff6d00", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff6d00",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,109,0,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "gradient_cool",
label: "冷色渐变",
style: { size: 32, color: "#00b0ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#00b0ff",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(0,176,255,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "shadow_deep",
label: "深影白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
textShadow: "2px 2px 4px rgba(0,0,0,0.8), 0 0 8px rgba(0,0,0,0.4)",
},
},
{
key: "soft_gold",
label: "柔光金",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffd54f",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,213,79,0.5), 1px 1px 2px rgba(0,0,0,0.4)",
fontSize: "20px",
},
},
]
+12 -12
View File
@@ -1733,36 +1733,36 @@
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(6, 52px);
gap: 1px;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
.xx-title-preset-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
aspect-ratio: 1 / 1;
padding: 4px;
background: var(--bg-secondary);
border: 2px solid transparent;
border-radius: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.xx-title-preset-card:hover {
border-color: #666;
background: #4d4d4d;
border-color: var(--primary-200);
background: var(--bg-primary);
}
.xx-title-preset-card.active {
border-color: #409eff;
background: #4d4d4d;
border-color: var(--primary-color);
background: var(--primary-50);
}
.xx-title-preset-preview-text {
font-size: 32px;
line-height: 1;
user-select: none;
}
@@ -221,7 +221,7 @@ export const ProductCard: React.FC<ProductCardProps> = ({
product.duplicateRate > 0 ? ` ${dupClass}` : ""
}`}
>
{product.duplicateRate != null ? `${product.duplicateRate.toFixed(1)}%` : "-"}
{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
</span>
</div>
</div>
+27 -18
View File
@@ -91,6 +91,8 @@
height: 18px;
border: 2px solid var(--border-color);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
transition: var(--transition-all);
background: var(--bg-primary);
flex-shrink: 0;
@@ -135,7 +137,7 @@
============================================================ */
.xx-products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(4, 1fr);
gap: var(--space-md);
}
@@ -185,6 +187,8 @@
height: 22px;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: var(--radius-xs);
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
cursor: pointer;
@@ -258,8 +262,11 @@
.xx-product-thumb {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
color: var(--text-inverse);
background: var(--color-gray-950);
max-height: 320px;
}
.xx-product-thumb-bg {
@@ -282,28 +289,22 @@
}
.xx-product-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
width: 52px;
height: 52px;
border-radius: 50%;
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: #fff;
transition: all 0.2s;
cursor: pointer;
display: grid;
place-items: center;
font-size: var(--font-size-md);
transition: var(--transition-all);
}
.xx-product-card:hover .xx-product-play {
background: var(--primary-color);
transform: translate(-50%, -50%) scale(1.1);
transform: scale(1.1);
}
/* 时长标签 */
@@ -323,7 +324,7 @@
/* 卡片信息区 */
.xx-product-info {
padding: 8px 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: var(--space-sm);
@@ -505,6 +506,8 @@
z-index: 1000;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
animation: player-fade-in 0.25s ease-out;
}
@@ -543,6 +546,8 @@
background: var(--color-gray-950);
aspect-ratio: 9 / 16;
max-height: 60vh;
display: grid;
place-items: center;
}
.xx-player-video-wrap video {
@@ -564,6 +569,8 @@
backdrop-filter: blur(4px);
color: var(--text-inverse);
font-size: 28px;
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
@@ -660,6 +667,8 @@
backdrop-filter: blur(4px);
color: var(--text-inverse);
font-size: var(--font-size-md);
display: grid;
place-items: center;
cursor: pointer;
border: none;
transition: var(--transition-all);
+11 -25
View File
@@ -314,13 +314,10 @@ class VideoDeduplicator:
project_id: str,
current_video_id: str | None,
session: Session,
*,
user_id: str = "",
) -> float:
"""计算当前视频与用户库内已有视频的最高相似度百分比。
"""计算当前视频与项目内已有视频的最高相似度百分比。
优先按 user_id 全局比较(跨项目),user_id 为空时回退到项目级比较。
遍历最近 200 个其他有指纹的视频,对每个计算相似度:
遍历项目内所有其他有指纹的视频,对每个计算相似度:
- MD5 精确匹配 → 100%
- pHash 相似度 → (1.0 - avg_distance / 64) * 100
取最高值作为 duplicate_rate0~100)。
@@ -328,34 +325,23 @@ class VideoDeduplicator:
Args:
fingerprint: 当前视频的指纹
project_id: 项目 IDuser_id 为空时的回退范围)
project_id: 项目 ID
current_video_id: 当前视频 ID(排除自身,可为 None)
session: 数据库会话
user_id: 用户 ID(优先按用户全局比较)
Returns:
duplicate_rate: 0~100 的浮点数
"""
# 限制查询最近 200 个视频,避免大内存溢出
# 限制查询最近 100 个视频,避免大项目内存溢出
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
# 优先按 user_id 全局比较(跨项目),否则回退到项目级
if user_id:
query = session.query(GeneratedVideoModel).filter(
GeneratedVideoModel.user_id == user_id,
)
logger.debug("compute_duplicate_rate: user-level scope user_id=%s", user_id)
else:
query = session.query(GeneratedVideoModel).filter(
GeneratedVideoModel.project_id == project_id,
)
logger.debug("compute_duplicate_rate: project-level fallback project_id=%s", project_id)
# 排除当前视频自身(记录可能已写入 DB,必须在查询层排除)
if current_video_id:
query = query.filter(GeneratedVideoModel.id != current_video_id)
recent_models = query.order_by(GeneratedVideoModel.generated_at.desc()).limit(200).all()
recent_models = (
session.query(GeneratedVideoModel)
.filter(GeneratedVideoModel.project_id == project_id)
.order_by(GeneratedVideoModel.generated_at.desc())
.limit(100)
.all()
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
existing_videos = [video_repo._to_domain(m) for m in recent_models]
@@ -123,13 +123,7 @@ def create_video_record_and_dedup(
# 计算重复率百分比(与项目内所有已有视频对比取最高相似度)
try:
dup_rate = deduplicator.compute_duplicate_rate(
fingerprint,
project_id,
video_id,
session,
user_id=user_id,
)
dup_rate = deduplicator.compute_duplicate_rate(fingerprint, project_id, video_id, session)
generated_video.duplicate_rate = dup_rate
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
except Exception as rate_err:
@@ -200,22 +200,6 @@ class UnifiedRenderService:
# 3. 计算视频总时长(用于字幕显示时长)
video_duration = self._estimate_total_duration(layers)
# Debug: 输出各图层时长明细
for layer in layers:
layer_total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in layer.clips)
clip_details = [
f"{c.clip_id}(dur={c.duration:.3f},actual={c.actual_duration:.3f},speed={getattr(c, 'playback_speed', 1.0):.4f})"
for c in layer.clips
]
logger.info(
"[debug] layer=%s clips=%d total=%.3f transition_duration=%.3f details=%s",
layer.role,
len(layer.clips),
layer_total,
self.transition_duration,
", ".join(clip_details),
)
logger.info("[debug] estimated video_duration=%.3f", video_duration)
# 3.5 TTS 配音生成(如果配置了)
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
@@ -1438,7 +1422,6 @@ class UnifiedRenderService:
if trim_segments and len(trim_segments) > 1:
# 多段裁剪:展开为多个 clip
resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration)
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
for i, seg in enumerate(resolved_segments):
# 每个段生成一个独立的 ResolvedClip
seg_clip_id = f"{clip.id}_seg_{seg.segment_id}"
@@ -1446,19 +1429,6 @@ class UnifiedRenderService:
seg_start = seg.trim.start_time
seg_duration = seg.trim.duration
# 多段裁剪:如果段的时长超过素材实际时长,减速补偿
seg_speed = configured_speed
if actual_duration > 0 and seg_duration > actual_duration + 0.05:
seg_speed = max(0.25, round(configured_speed * actual_duration / seg_duration, 4))
logger.info(
"[debug] multi-seg clip=%s seg=%s duration=%.3f actual=%.3f → speed=%.4f",
clip.id,
seg.segment_id,
seg_duration,
actual_duration,
seg_speed,
)
rc = ResolvedClip(
clip_id=seg_clip_id,
asset_id=asset_id,
@@ -1469,7 +1439,7 @@ class UnifiedRenderService:
duration=seg_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=seg_speed,
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
config={**clip_config, "_segment_id": seg.segment_id},
actual_duration=actual_duration,
trim_config=seg.trim,
@@ -1491,7 +1461,6 @@ class UnifiedRenderService:
effective_trim: TrimConfig | None = None
final_start = clip.start_time
final_duration = clip.duration
configured_speed = getattr(clip, "playback_speed", 1.0) or 1.0
if trim_config is not None and actual_duration > 0:
effective_trim = trim_config.validate_and_resolve(actual_duration)
@@ -1505,25 +1474,6 @@ class UnifiedRenderService:
final_start = 0.0
final_duration = actual_duration
# 素材实际时长不足以覆盖配置的时长时,降低播放速度来补偿
# 例如:配置4s但素材只有3s → speed=0.75x,用满3s素材达到4s输出
if actual_duration > 0 and final_duration > actual_duration + 0.05:
compensated_speed = actual_duration / final_duration
# 保留用户设置的速度(如果已减速则叠加)
final_speed = configured_speed * compensated_speed
# 下限 0.25x
final_speed = max(0.25, round(final_speed, 4))
logger.info(
"[debug] clip=%s duration=%.3f actual=%.3f → 减速补偿 speed=%.4f (configured=%.3f)",
clip.id,
final_duration,
actual_duration,
final_speed,
configured_speed,
)
else:
final_speed = configured_speed
rc = ResolvedClip(
clip_id=clip.id,
asset_id=asset_id,
@@ -1534,25 +1484,13 @@ class UnifiedRenderService:
duration=final_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
playback_speed=final_speed,
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
config=clip_config,
actual_duration=actual_duration,
trim_config=effective_trim,
)
resolved.append(rc)
# Debug日志:记录每个clip的时长信息
eff_dur = _clip_effective_duration_pure(final_duration, actual_duration)
logger.info(
"[debug] resolved clip=%s duration=%.3f actual=%.3f effective=%.3f speed=%.4f start=%.3f",
clip.id,
final_duration,
actual_duration,
eff_dur,
final_speed,
final_start,
)
# 按 order 排序
resolved.sort(key=lambda c: c.order)
return resolved
@@ -1745,7 +1683,7 @@ class UnifiedRenderService:
if d > 0:
layer_dur = d
break
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
@@ -1754,13 +1692,6 @@ class UnifiedRenderService:
)
if xfade_filter:
filter_parts.append(xfade_filter)
logger.info(
"[unified-render] layer=%s xfade: clips=%d durations=%s estimated_dur=%.3f",
layer.role,
len(layer_labels),
[round(d, 3) for d in layer_durations],
xfade_estimated_dur,
)
layer_output_labels[layer.role] = out_label
# Step 3: 合成各层
@@ -1984,14 +1915,8 @@ class UnifiedRenderService:
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长(原速 trim 后时长)。
如果 playback_speed < 1(为补偿素材不足而减速),返回配置的 duration,
而非 min(duration, actual_duration)。
实际实现移至 packages.domain.render_layer_utils.clip_effective_duration。
"""
speed = getattr(clip, "playback_speed", 1.0) or 1.0
# 减速场景:duration 已通过降低 playback_speed 补偿,返回配置的 duration
if speed < 1.0 - 1e-6 and clip.duration > 0:
return clip.duration
return _clip_effective_duration_pure(clip.duration, clip.actual_duration)
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
+2 -5
View File
@@ -150,11 +150,8 @@ def build_xfade_filter_chain(
else:
first_input_dur = cumulative - total_transition
# 正确的 offset 计算offset 应相对于累积输出时长
# offset = 累积输出中,转场开始的时间点
# = first_input_dur - transition_duration
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
offset = max(0.0, first_input_dur - transition_duration)
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
-64
View File
@@ -119,70 +119,6 @@ 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等)→ 退回 TimeInterval5秒间隔)
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,
)
# 策略2SceneChange 失败 → 退回 TimeInterval5秒间隔)
if frames is None:
logger.info("SceneChange 策略失败,降级为 TimeInterval5秒间隔)")
# 估算帧数:假设视频最长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,
+1 -3
View File
@@ -24,10 +24,8 @@ celery==5.4.0
# 对象存储
oss2==2.18.4
# HTTP 客户端pin 间接依赖防止版本漂移)
# HTTP 客户端
httpx==0.27.2
httpcore==1.0.7
h2==4.1.0
# Prometheus monitoring
prometheus-client==0.21.1
-1
View File
@@ -15,6 +15,5 @@ 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
-24
View File
@@ -4,7 +4,6 @@
# - docker-container driver, host 网络
# - 层缓存保存在 buildkit 容器及其 _state 命名卷中,job 结束不清理
# - 宿主机 ci-docker-cleanup.sh 已豁免该 builder
# - 每次执行自动同步宿主机 docker config 到 BuildKit 容器(确保 registry 认证)
# 用法: bash scripts/ci/ensure_persistent_builder.sh
set -eu
@@ -35,26 +34,3 @@ docker buildx use "$BUILDER"
docker buildx inspect "$BUILDER" --bootstrap
echo "✅ builder ready"
docker buildx ls | head -5
# === 同步宿主机 docker config 到 BuildKit 容器(确保 registry 认证) ===
# BuildKit 容器名遵循 docker buildx 命名规则: buildx_buildkit_<builder-name>_0
BUILDKIT_CONTAINER="buildx_buildkit_${BUILDER}_0"
if docker inspect "$BUILDKIT_CONTAINER" >/dev/null 2>&1; then
# 宿主机 docker config 路径
HOST_DOCKER_CONFIG="/root/.docker/config.json"
if [ -f "$HOST_DOCKER_CONFIG" ]; then
echo "=== 同步 docker config 到 BuildKit 容器 ==="
# 确保容器内 .docker 目录存在
docker exec "$BUILDKIT_CONTAINER" mkdir -p /root/.docker
# 拷贝 config.json
docker cp "$HOST_DOCKER_CONFIG" "$BUILDKIT_CONTAINER:/root/.docker/config.json"
echo "✅ docker config 已同步到 BuildKit 容器"
else
echo "⚠️ 宿主机 docker config 不存在: $HOST_DOCKER_CONFIG(跳过同步)"
fi
else
echo "⚠️ BuildKit 容器不存在: $BUILDKIT_CONTAINER(跳过 config 同步)"
fi
+47 -106
View File
@@ -1,136 +1,77 @@
#!/bin/bash
# CI Unit Tests Job 主脚本
# 包含:依赖缓存、增量测试选择、覆盖率测试、diff覆盖率门禁
# 包含:依赖安装、增量测试选择、覆盖率测试、diff覆盖率门禁
set -eu
JOB_NAME="${1:-Unit Tests}"
echo "=== CI Unit Tests 开始 ==="
# --- 依赖缓存检查 ---
# 如果 requirements 文件未变化且依赖已安装,跳过 pip install(持久 runner 优化)
REQ_HASH_FILE="/tmp/.ci_unit_tests_req_hash"
CURRENT_REQ_HASH=""
if [ -f requirements-base.txt ] && [ -f requirements.txt ] && [ -f requirements-dev.txt ]; then
CURRENT_REQ_HASH=$(cat requirements-base.txt requirements.txt requirements-dev.txt | md5sum | cut -d' ' -f1)
fi
SKIP_PIP_INSTALL=false
if [ -n "$CURRENT_REQ_HASH" ] && [ -f "$REQ_HASH_FILE" ]; then
CACHED_HASH=$(cat "$REQ_HASH_FILE")
if [ "$CACHED_HASH" = "$CURRENT_REQ_HASH" ]; then
# 验证关键包是否还在
if python3 -c "import pytest; import celery" 2>/dev/null; then
echo "✅ 依赖无变化 (hash=$CURRENT_REQ_HASH),跳过 pip install"
SKIP_PIP_INSTALL=true
else
echo "⚠️ 依赖 hash 匹配但关键包缺失,重新安装"
fi
fi
fi
# --- 安装依赖 ---
if [ "$SKIP_PIP_INSTALL" = "false" ]; then
echo ""
echo "=== 安装 Python 依赖 ==="
# pip install 带重试(网络不稳定时自动重试),合并为一次调用减少开销
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt -r requirements.txt -r requirements-dev.txt && break
echo "pip install 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
# 保存 hash 标记
if [ -n "$CURRENT_REQ_HASH" ]; then
echo "$CURRENT_REQ_HASH" > "$REQ_HASH_FILE"
fi
fi
echo ""
echo "=== 安装 Python 依赖 ==="
# pip install 带重试(网络不稳定时自动重试)
for i in 1 2 3; do
python3 -m pip install -q -r requirements-base.txt && break
echo "pip install requirements-base.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements.txt && break
echo "pip install requirements.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
for i in 1 2 3; do
python3 -m pip install -q -r requirements-dev.txt && break
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
[ $i -eq 3 ] && exit 1
sleep 5
done
pytest --version
# 双保险:确保numpy已安装
echo "=== 验证 numpy 安装 ==="
SKIP_NUMPY_TESTS=0
if python3 -c "import numpy; assert numpy.__version__ == '1.26.4'" 2>/dev/null; then
echo " numpy 1.26.4 已就绪(缓存命中)"
else
echo "需要安装 numpy 1.26.4..."
python3 -m pip install numpy==1.26.4 || {
echo "❌ numpy 首次安装失败,尝试不使用缓存重新安装..."
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
SKIP_NUMPY_TESTS=1
}
python3 -m pip install numpy==1.26.4 || {
echo " numpy 首次安装失败,尝试不使用缓存重新安装..."
python3 -m pip install --no-cache-dir numpy==1.26.4 || {
echo "⚠️ numpy 安装失败,跳过需要 numpy 的测试"
SKIP_NUMPY_TESTS=1
}
fi
}
if [ "$SKIP_NUMPY_TESTS" = "0" ]; then
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 就绪')" || {
python3 -c "import numpy; print(f'✅ numpy {numpy.__version__} 安装成功')" || {
echo "⚠️ numpy 导入失败,跳过需要 numpy 的测试"
SKIP_NUMPY_TESTS=1
}
fi
# --- 增量测试选择(PR + push 均支持 ---
# --- 增量测试选择(PR ---
UNIT_TEST_MODE="full"
SELECTED_TEST_FILES="tests/unit"
IS_PULL_REQUEST=false
IS_PUSH=false
[ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && IS_PULL_REQUEST=true
[ "${GITHUB_EVENT_NAME:-}" = "push" ] && IS_PUSH=true
if ($IS_PULL_REQUEST || $IS_PUSH) && [ -n "${GITHUB_TOKEN:-}" ]; then
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
echo ""
echo "=== 增量测试选择 ==="
CHANGED_FILES=""
if $IS_PULL_REQUEST; then
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" \
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
elif $IS_PUSH && [ -n "${GITHUB_SHA:-}" ]; then
# Push 事件:通过 GitHub API 获取本次 push 改动的文件
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}"
RESPONSE=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github.v3.diff" "$API_URL" 2>/dev/null || echo "")
if [ -n "$RESPONSE" ]; then
CHANGED_FILES=$(echo "$RESPONSE" | grep '^diff --git' | sed 's|diff --git a/\(.*\) b/.*|\1|' || echo "")
fi
# 备用方案:获取 previous commit SHA 再查 API
if [ -z "$CHANGED_FILES" ]; then
PREV_SHA=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits?sha=${GITHUB_SHA}&per_page=2" \
| python3 -c "import sys,json; commits=json.load(sys.stdin); print(commits[1]['sha'] if len(commits)>1 else '')" 2>/dev/null || echo "")
if [ -n "$PREV_SHA" ]; then
COMPARE_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/compare/${PREV_SHA}...${GITHUB_SHA}"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$COMPARE_URL" \
| python3 -c "import sys,json; data=json.load(sys.stdin); [print(f['filename']) for f in data.get('files',[]) if f['status'] != 'removed']" 2>/dev/null || echo "")
fi
fi
fi
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
CHANGED_FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin) if f['status'] != 'removed']")
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
if [ -n "$CHANGED_FILES" ]; then
set +e
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
set -e
if [ $SELECT_EXIT -eq 0 ]; then
UNIT_TEST_MODE="incremental"
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
SELECTED_TEST_FILES="$TEST_FILES"
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "全量模式(增量选择失败)"
fi
set +e
CHANGED_FILES="$CHANGED_FILES" \
SELECTED_TESTS_OUTPUT=/tmp/selected_tests.txt \
python3 scripts/ci/select_unit_tests.py
SELECT_EXIT=$?
set -e
if [ $SELECT_EXIT -eq 0 ]; then
UNIT_TEST_MODE="incremental"
TEST_FILES=$(cat /tmp/selected_tests.txt | tr '\n' ' ')
SELECTED_TEST_FILES="$TEST_FILES"
echo "增量模式: $(cat /tmp/selected_tests.txt | wc -l) 个测试文件"
else
echo "无法获取改动文件列表,使用全量模式"
echo "全量模式"
fi
fi
@@ -175,7 +116,7 @@ if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ];
PR_CODE_DIR="/tmp/pr-code-$$"
mkdir -p "$PR_CODE_DIR"
# 备份PR代码(含coverage.xmldiff-cover需要用到
# 备份PR代码(含coverage.xmldiff-cover需要用到
find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
rm -rf .git
git init > /dev/null 2>&1
+1 -1
View File
@@ -397,7 +397,7 @@ fi
echo "Stopping old containers..."
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
# Worker 需要更长时间(视频任务最长可能5分钟)
docker stop -t 120 xiaoxia-worker-staging 2>/dev/null || true
docker stop -t 300 xiaoxia-worker-staging 2>/dev/null || true
docker stop -t 30 xiaoxia-api-staging 2>/dev/null || true
docker stop -t 10 xiaoxia-web-staging 2>/dev/null || true
docker rm xiaoxia-worker-staging xiaoxia-api-staging xiaoxia-web-staging 2>/dev/null || true
+1 -1
View File
@@ -187,7 +187,7 @@ health_check() {
return 0
fi
sleep 3
sleep 5
done
# 超时了
+5 -5
View File
@@ -197,14 +197,14 @@ class TestComputeAssetAvailability:
def test_large_gap_remains_usable(self):
"""区间之间留有 ≥3s 空闲段(扩边后仍 ≥3s)→ usable=True。"""
# [0,2] 扩边到 [0,3.5][9,10] 扩边到 [7.5,10];空闲 [3.5,7.5]=4.0s >=3
# 使用 [0,2] 与 [9,10],扩边后空闲 [3.5,7.5]=4.0s ≥3 → usable
# [0,2] 扩边到 [0,2.3][5.3,10] 扩边前为 [5,10] 扩边起 4.7;空闲 [2.3,4.7]=2.4s <3
# 改用更大间隙:[0,2] 与 [6,10],扩边后空闲 [2.3,5.7]=3.4s ≥3
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[
_range(0.0, 2.0, use_count=MAX_RANGE_USE_COUNT),
_range(9.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
_range(6.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
],
)
)
@@ -236,8 +236,8 @@ class TestComputeAssetAvailability:
assert info["usable"] is True
def test_segment_edge_gap_constant(self):
"""边缘间隙常量为 1.5s(与 MediaKit 冲突检测同口径)。"""
assert SEGMENT_EDGE_GAP == 1.5
"""边缘间隙常量为 0.3s(与 MediaKit 冲突检测同口径)。"""
assert SEGMENT_EDGE_GAP == 0.3
def test_domain_entity_metadata_dict_form(self):
"""领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。
+10 -10
View File
@@ -124,9 +124,9 @@ def _ranges(db, aid="a1"):
def test_config_constants():
assert MAX_RANGE_USE_COUNT == 2
assert REUSE_RATIO_LIMIT == 0.10
assert SEGMENT_EDGE_GAP == 1.5
assert MAX_RANGE_USE_COUNT == 3
assert REUSE_RATIO_LIMIT == 0.15
assert SEGMENT_EDGE_GAP == 0.3
# ── get_used_segments ─────────────────────────────────────────────────────────
@@ -320,13 +320,13 @@ def test_find_reusable_prefers_oldest_unused(patched_model):
def test_find_reusable_excludes_max_use_count(patched_model):
"""use_count 达到上限(2)的区间不再参与复用;全部达上限返回 None。"""
"""use_count 达到上限(3)的区间不再参与复用;全部达上限返回 None。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-01-01T00:00:00"},
{"start": 0.0, "end": 10.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
@@ -335,22 +335,22 @@ def test_find_reusable_excludes_max_use_count(patched_model):
assert find_reusable_range(db, "a1", 5.0, 30.0) is None
def test_find_reusable_third_use_rejected(patched_model):
"""同区间复用第 3 次被拒绝:use_count=1 的可复用,use_count=2 的不可复用。"""
def test_find_reusable_fourth_use_rejected(patched_model):
"""同区间复用第 4 次被拒绝:use_count=2 的可复用,use_count=3 的不可复用。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 10.0, "use_count": 1, "last_used_at": "2026-03-01T00:00:00"},
{"start": 10.0, "end": 20.0, "use_count": 2, "last_used_at": "2026-01-01T00:00:00"},
{"start": 0.0, "end": 10.0, "use_count": 2, "last_used_at": "2026-03-01T00:00:00"},
{"start": 10.0, "end": 20.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", 5.0, 30.0)
# 只能选 use_count=1 的区间(start=0),不能选 use_count=2 的(虽然它更老)
# 只能选 use_count=2 的区间(start=0),不能选 use_count=3 的(虽然它更老)
assert result is not None and result[0] == 0.0
-152
View File
@@ -1,152 +0,0 @@
"""测试 create_video_record_and_dedup 传递 user_id 到查重逻辑.
验证 P0 修复:查重范围从项目级扩大到用户级。
dedup_helpers 必须把 user_id 传给 compute_duplicate_rate。
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Mock cv2/numpy before imports
sys.modules.setdefault("cv2", MagicMock())
sys.modules.setdefault("numpy", MagicMock())
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "apps" / "api"))
sys.path.insert(0, str(ROOT / "packages"))
sys.path.insert(0, str(ROOT / "apps" / "worker"))
import os
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
class TestDedupHelpersUserIdPassthrough:
"""验证 dedup_helpers 把 user_id 传递给 compute_duplicate_rate."""
def test_user_id_passed_to_compute_duplicate_rate(self):
"""create_video_record_and_dedup 必须传 user_id 给 compute_duplicate_rate."""
from video_processing.dedup_helpers import create_video_record_and_dedup
session = MagicMock()
mock_video_repo = MagicMock()
mock_fingerprint = MagicMock()
mock_fingerprint.to_dict.return_value = {"md5": "test", "keyframe_phashes": ["aa"]}
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
mock_deduplicator.check_duplicate.return_value = None
mock_deduplicator.compute_duplicate_rate.return_value = 42.5
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_video_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
):
result = create_video_record_and_dedup(
generation_task_id="task-001",
project_id="proj-001",
user_id="user-abc",
batch_id="",
file_url="https://example.com/video.mp4",
file_size=1024,
duration=15.0,
video_path="/tmp/fake_video.mp4",
mode="smart",
session=session,
)
# 验证 compute_duplicate_rate 被调用且 user_id 正确传递
mock_deduplicator.compute_duplicate_rate.assert_called_once()
call_kwargs = mock_deduplicator.compute_duplicate_rate.call_args
assert (
call_kwargs.kwargs.get("user_id") == "user-abc"
), f"user_id 应传递给 compute_duplicate_rate,实际: {call_kwargs}"
def test_empty_user_id_still_works(self):
"""user_id 为空时仍然正常执行(回退到 project 级比较)."""
from video_processing.dedup_helpers import create_video_record_and_dedup
session = MagicMock()
mock_video_repo = MagicMock()
mock_fingerprint = MagicMock()
mock_fingerprint.to_dict.return_value = {"md5": "test"}
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
mock_deduplicator.check_duplicate.return_value = None
mock_deduplicator.compute_duplicate_rate.return_value = 0.0
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_video_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
):
result = create_video_record_and_dedup(
generation_task_id="task-002",
project_id="proj-002",
user_id="",
batch_id="",
file_url="https://example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="smart",
session=session,
)
mock_deduplicator.compute_duplicate_rate.assert_called_once()
call_kwargs = mock_deduplicator.compute_duplicate_rate.call_args
assert call_kwargs.kwargs.get("user_id") == ""
def test_duplicate_rate_saved_to_video_record(self):
"""compute_duplicate_rate 的返回值应写入 generated_video.duplicate_rate."""
from video_processing.dedup_helpers import create_video_record_and_dedup
session = MagicMock()
mock_video_repo = MagicMock()
mock_fingerprint = MagicMock()
mock_fingerprint.to_dict.return_value = {"md5": "test"}
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
mock_deduplicator.check_duplicate.return_value = None
mock_deduplicator.compute_duplicate_rate.return_value = 78.5
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_video_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
):
result = create_video_record_and_dedup(
generation_task_id="task-003",
project_id="proj-003",
user_id="user-xyz",
batch_id="",
file_url="https://example.com/v.mp4",
file_size=2048,
duration=20.0,
video_path="/tmp/fake2.mp4",
mode="smart",
session=session,
)
# 验证 update 被调用(包含 duplicate_rate 的记录)
mock_video_repo.update.assert_called_once()
updated_video = mock_video_repo.update.call_args[0][0]
assert updated_video.duplicate_rate == 78.5
+15 -93
View File
@@ -56,10 +56,9 @@ class TestComputeDuplicateRate:
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
query_mock = MagicMock()
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value.limit.return_value.all.return_value = []
session.query.return_value = query_mock
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = (
[]
)
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
assert rate == 0.0
@@ -74,6 +73,7 @@ class TestComputeDuplicateRate:
session = MagicMock()
existing = self._make_existing_video("existing1", {"md5": "exact_match_md5", "keyframe_phashes": ["aa"]})
# Create a mock model with the domain attributes
mock_model = MagicMock(spec=GeneratedVideoModel)
mock_model.id = existing.id
mock_model.project_id = existing.project_id
@@ -83,12 +83,10 @@ class TestComputeDuplicateRate:
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
mock_repo._to_domain.return_value = existing
# 链式 filter: 第一次 scope filter,第二次 self-exclusion filter
# 让 filter() 返回的对象仍然支持 order_by() 链
query_mock = MagicMock()
query_mock.filter.return_value = query_mock # filter → filter chainable
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
session.query.return_value = query_mock
# Mock the session.query chain
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
mock_model
]
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
assert rate == 100.0
@@ -115,10 +113,9 @@ class TestComputeDuplicateRate:
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
mock_repo._to_domain.return_value = existing
query_mock = MagicMock()
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
session.query.return_value = query_mock
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
mock_model
]
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
# hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875
@@ -143,10 +140,9 @@ class TestComputeDuplicateRate:
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
mock_repo._to_domain.return_value = self_video
query_mock = MagicMock()
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
session.query.return_value = query_mock
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
mock_model
]
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
assert rate == 0.0
@@ -176,89 +172,15 @@ class TestComputeDuplicateRate:
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
mock_repo._to_domain.side_effect = [existing1, existing2]
query_mock = MagicMock()
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value.limit.return_value.all.return_value = [
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
mock_model1,
mock_model2,
]
session.query.return_value = query_mock
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
# max similarity: e2 distance=1, (1-1/64)*100 = 98.4375
assert rate == pytest.approx(98.44, abs=0.1)
def test_user_id_scope_cross_project(self):
"""传 user_id 时应跨项目查询,而非仅当前项目."""
from video_processing.dedup import VideoDeduplicator
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
deduplicator = VideoDeduplicator()
fingerprint = self._make_fingerprint(md5="cross_proj_md5")
session = MagicMock()
# 模拟一个不同项目但同一用户的视频
existing = self._make_existing_video(
"existing_other_proj", {"md5": "cross_proj_md5", "keyframe_phashes": ["aa"]}
)
existing.project_id = "proj2" # 不同项目
existing.user_id = "user1"
mock_model = MagicMock(spec=GeneratedVideoModel)
mock_model.id = existing.id
mock_model.project_id = existing.project_id
mock_model.user_id = existing.user_id
mock_model.video_fingerprint = existing.video_fingerprint
mock_model.generated_at = "2026-01-01"
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
mock_repo._to_domain.return_value = existing
query_mock = MagicMock()
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value.limit.return_value.all.return_value = [mock_model]
session.query.return_value = query_mock
rate = deduplicator.compute_duplicate_rate(
fingerprint,
"proj1",
"vid1",
session,
user_id="user1",
)
# 应通过 user_id 过滤,且匹配到跨项目视频
assert rate == 100.0
def test_user_id_empty_falls_back_to_project(self):
"""user_id 为空时应回退到 project_id 过滤."""
from video_processing.dedup import VideoDeduplicator
deduplicator = VideoDeduplicator()
fingerprint = self._make_fingerprint()
session = MagicMock()
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
mock_repo = MockRepo.return_value
query_mock = MagicMock()
query_mock.filter.return_value = query_mock
query_mock.order_by.return_value.limit.return_value.all.return_value = []
session.query.return_value = query_mock
rate = deduplicator.compute_duplicate_rate(
fingerprint,
"proj1",
"vid1",
session,
user_id="",
)
assert rate == 0.0
# 验证使用的是 project_id 过滤(回退路径)
# 通过检查 filter 被调用时的参数来间接验证
class TestDuplicateRateAPI:
"""Test that duplicate_rate is returned in API responses."""
-97
View File
@@ -1,97 +0,0 @@
"""Tests for duration compensation when source video is shorter than configured duration."""
from pathlib import Path
import pytest
from apps.worker.video_processing.unified_render_service import ResolvedClip, UnifiedRenderService
class TestClipEffectiveDurationWithSpeedCompensation:
"""Test _clip_effective_duration handles speed < 1 correctly."""
def test_normal_speed_returns_min(self):
"""When speed=1.0, effective_duration = min(duration, actual_duration)."""
clip = ResolvedClip(
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/fake.mp4"),
clip_type="main",
order=0,
start_time=0.0,
duration=4.0,
actual_duration=3.0, # shorter than configured
playback_speed=1.0,
)
# Without speed compensation, effective = min(4, 3) = 3
assert UnifiedRenderService._clip_effective_duration(clip) == 3.0
def test_compensated_speed_returns_configured_duration(self):
"""When speed < 1 (compensated), effective_duration = configured duration."""
clip = ResolvedClip(
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/fake.mp4"),
clip_type="main",
order=0,
start_time=0.0,
duration=4.0,
actual_duration=3.0, # shorter than configured
playback_speed=0.75, # compensated: 3/4 = 0.75
)
# With speed compensation, effective = configured duration = 4.0
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
def test_zero_actual_duration_returns_configured(self):
"""When actual_duration=0, effective_duration = configured duration."""
clip = ResolvedClip(
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/fake.mp4"),
clip_type="main",
order=0,
start_time=0.0,
duration=4.0,
actual_duration=0.0,
playback_speed=1.0,
)
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
def test_compensated_speed_with_actual_zero(self):
"""When speed < 1 and actual=0, still returns configured duration."""
clip = ResolvedClip(
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/fake.mp4"),
clip_type="main",
order=0,
start_time=0.0,
duration=4.0,
actual_duration=0.0,
playback_speed=0.5,
)
assert UnifiedRenderService._clip_effective_duration(clip) == 4.0
class TestClipAdjustedDurationWithSpeedCompensation:
"""Test _clip_adjusted_duration accounts for compensated speed."""
def test_adjusted_duration_with_compensation(self):
"""Adjusted duration = min(duration, actual) / speed.
With compensation: min(4,3)/0.75 = 3/0.75 = 4.0
This equals the configured duration, which is the goal.
"""
clip = ResolvedClip(
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/fake.mp4"),
clip_type="main",
order=0,
start_time=0.0,
duration=4.0,
actual_duration=3.0,
playback_speed=0.75, # compensated
)
adjusted = UnifiedRenderService._clip_adjusted_duration(clip)
# min(4,3)/0.75 = 3/0.75 = 4.0 (matches configured duration)
assert abs(adjusted - 4.0) < 0.01
+8 -19
View File
@@ -58,10 +58,6 @@ def _make_mock_asset(asset_id, duration):
asset = MagicMock()
asset.id = asset_id
asset.duration = duration
# score_asset 所需的属性(避免 MagicMock 导致类型比较错误)
asset.quality_score = None
asset.created_at = None
asset.metadata = {}
return asset
@@ -146,12 +142,11 @@ class TestEditorClipsBySegments:
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 4
# 验证均衡分配(贪心策略保证):2个素材分4个片段,每个素材恰好使用2次
from collections import Counter
asset_ids = [c["asset_id"] for c in clips_data]
counts = Counter(asset_ids)
assert counts["a1"] == 2 and counts["a2"] == 2
# 验证轮询分配:a1, a2, a1, a2
assert clips_data[0]["asset_id"] == "a1"
assert clips_data[1]["asset_id"] == "a2"
assert clips_data[2]["asset_id"] == "a1"
assert clips_data[3]["asset_id"] == "a2"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_start_at_zero(self, mock_storage):
@@ -418,13 +413,8 @@ class TestEditorClipsDurationAndStartTime:
assert mock_calc.call_count == 2
clips_data = _get_clips_data_from_call(mock_plan_svc)
# 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)
assert clips_data[0]["start_time"] == 12.5
assert clips_data[1]["start_time"] == 18.0
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_asset_durations_deduped(self, mock_storage):
@@ -641,8 +631,7 @@ class TestReuseRatioGate:
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 13
# 1 个复用片段,占比 1/13 ≈ 7.7% ≤ 15%
# 转场补偿: raw_duration = 5.0 + (13-1)*0.5/13 ≈ 5.462 → round(5.462,1) = 5.5
assert abs(reused.get("a1", 0.0) - 5.5) < 0.1
assert reused.get("a1", 0.0) == 5.0
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
+7 -9
View File
@@ -32,16 +32,16 @@ class TestRecommendedTimeConflicts:
assert _recommended_time_conflicts(10.0, 5.0, [(10.0, 15.0)]) is True
def test_touching_endpoint_conflicts_due_to_edge_gap(self):
"""首尾紧贴(推荐 15 开始,已用 [10,15]):1.5s 扩边内 → 冲突。"""
"""首尾紧贴(推荐 15 开始,已用 [10,15]):0.3s 扩边内 → 冲突。"""
assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 15.0)]) is True
def test_gap_within_edge_gap_conflicts(self):
"""间隔 0.2s< 1.5s 边缘间隙)→ 冲突。"""
"""间隔 0.2s< 0.3s 边缘间隙)→ 冲突。"""
assert _recommended_time_conflicts(15.2, 5.0, [(10.0, 15.0)]) is True
def test_gap_beyond_edge_gap_no_conflict(self):
"""间隔 2.0s> 1.5s 边缘间隙)→ 不冲突。"""
assert _recommended_time_conflicts(17.0, 5.0, [(10.0, 15.0)]) is False
"""间隔 0.5s> 0.3s 边缘间隙)→ 不冲突。"""
assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)]) is False
def test_far_apart_no_conflict(self):
"""相隔很远 → 不冲突。"""
@@ -54,9 +54,7 @@ class TestRecommendedTimeConflicts:
"""多个已用区间,任一冲突即返回 True。"""
used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)]
assert _recommended_time_conflicts(12.0, 2.0, used) is True
assert (
_recommended_time_conflicts(6.5, 2.0, used) is False
) # range [6.5,8.5], just outside all expanded used ranges
assert _recommended_time_conflicts(6.0, 2.0, used) is False
def test_custom_edge_gap(self):
"""edge_gap 可配置:gap=0 时紧贴不冲突(端点相接不算重叠)。"""
@@ -66,5 +64,5 @@ class TestRecommendedTimeConflicts:
assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)], edge_gap=1.0) is True
def test_default_edge_gap_constant(self):
"""默认边缘间隙常量为 1.5s(配置常量)。"""
assert SEGMENT_EDGE_GAP == 1.5
"""默认边缘间隙常量为 0.3s(配置常量)。"""
assert SEGMENT_EDGE_GAP == 0.3
+11 -23
View File
@@ -69,13 +69,13 @@ class TestRecommendedTimeConflicts:
def test_conflict_exact_boundary_no_overlap(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
# 新语义:默认 1.5s 边缘间隙扩边,推荐 [10, 15] 与已用 [0, 10] 首尾相接
# 新语义:默认 0.3s 边缘间隙扩边,推荐 [10, 15] 与已用 [0, 10] 首尾相接
# 落在扩边范围内 → 判为冲突(避免观感重复)
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)]) is True
# 显式 edge_gap=0 时退回纯区间重叠判定:相接不算重叠
assert _recommended_time_conflicts(10.0, 5.0, [(0.0, 10.0)], edge_gap=0.0) is False
# 间隙大于边缘间隙(2.0 > 1.5)→ 不冲突
assert _recommended_time_conflicts(12.0, 5.0, [(0.0, 10.0)]) is False
# 间隙大于边缘间隙(0.5 > 0.3)→ 不冲突
assert _recommended_time_conflicts(10.5, 5.0, [(0.0, 10.0)]) is False
def test_conflict_multiple_used(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
@@ -83,10 +83,10 @@ class TestRecommendedTimeConflicts:
used = [(0.0, 5.0), (10.0, 15.0), (20.0, 25.0)]
# 推荐 [6, 11] 与 [10, 15] 冲突
assert _recommended_time_conflicts(6.0, 5.0, used) is True
# 推荐 [15, 20] 与 [10, 15] 首尾相接:1.5s 扩边内 → 冲突
# 推荐 [15, 20] 与 [10, 15] 首尾相接:0.3s 扩边内 → 冲突
assert _recommended_time_conflicts(15.0, 5.0, used) is True
# 空闲段 (6.5, 8.5) 长 2.0s:推荐 [6.6, 8.4]dur=1.8)与三区间扩边均不接触
assert _recommended_time_conflicts(6.6, 1.8, used) is False
# 空闲段 [5.3, 9.7] 长 4.4s:推荐 [5.5, 9.5]dur=4)与三区间扩边均不接触
assert _recommended_time_conflicts(5.5, 4.0, used) is False
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
@@ -347,10 +347,6 @@ def _make_rich_asset(asset_id, duration, storage_key="v.mp4", mime="video/mp4"):
asset.duration = duration
asset.storage_key = storage_key
asset.mime_type = mime
# score_asset 所需的属性(避免 MagicMock 导致类型比较错误)
asset.quality_score = None
asset.created_at = None
asset.metadata = {}
return asset
@@ -446,15 +442,11 @@ class TestFromAssetsByTemplateSegments:
)
clips_data = _get_clips_data(mock_plan_svc)
# 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
assert 3.0 <= clips_data[0]["duration"] <= 5.0
assert 4.0 <= clips_data[1]["duration"] <= 8.0
def test_assets_balanced_assignment(self):
"""素材按使用次数贪心分配(使用少的优先),保证均衡使用"""
def test_assets_round_robin_assignment(self):
"""素材按片段顺序轮询分配"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
@@ -483,11 +475,7 @@ class TestFromAssetsByTemplateSegments:
clips_data = _get_clips_data(mock_plan_svc)
asset_ids = [c["asset_id"] for c in clips_data]
# 贪心分配保证均衡:2个素材分4个片段,每个素材恰好使用2次
from collections import Counter
counts = Counter(asset_ids)
assert counts["a1"] == 2 and counts["a2"] == 2
assert asset_ids == ["a1", "a2", "a1", "a2"]
def test_orders_start_from_zero(self):
"""片段 order 从 0 开始递增。"""
-4
View File
@@ -908,10 +908,6 @@ class TestAssetDurationsAlwaysFetched:
def fake_get(asset_id):
mock_asset = MagicMock()
mock_asset.duration = 30.0 # 每个素材 30 秒
# score_asset 所需的属性
mock_asset.quality_score = None
mock_asset.created_at = None
mock_asset.metadata = {}
return mock_asset
asset_repo.get = MagicMock(side_effect=fake_get)
-257
View File
@@ -1,257 +0,0 @@
"""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:
"""创建一个可用的 MediaKitClientmock 配置)."""
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
-324
View File
@@ -1,324 +0,0 @@
"""测试 smart_match 评分集成到素材选取路径。
验证:
- 使用次数多的素材评分低于使用次数少的(unused 维度降权生效)
- from-assets 路径中 sorted_candidates 按 smart_match 评分排序
- 一键生成路径中 _sort_assets_by_smart_score 按评分降序
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
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
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
@dataclass
class FakeAsset:
"""Minimal Asset-like object."""
id: str
quality_score: float | None = 70.0
duration: float = 15.0
created_at: datetime | None = None
metadata: dict[str, Any] = field(default_factory=dict)
status: str = "ready"
file_type: str = "video"
def _asset_with_use_count(asset_id: str, use_count: int) -> FakeAsset:
"""创建指定使用次数的素材,其他维度保持一致。"""
return FakeAsset(
id=asset_id,
quality_score=70.0,
duration=15.0, # 最优区间 5-30s
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
metadata={"generation_use_count": use_count},
)
# ── score_asset 单元测试:unused 维度降权 ─────────────────────────────────────
class TestScoreAssetUnusedDiminsh:
"""验证 unused 维度:使用次数越多,评分越低。"""
def test_unused_scores_higher_than_used(self):
"""use_count=0 的素材评分高于 use_count>0 的。"""
fresh = _asset_with_use_count("fresh", 0)
used = _asset_with_use_count("used", 1)
fresh_score, _ = score_asset(fresh)
used_score, _ = score_asset(used)
assert fresh_score > used_score
def test_high_use_count_scores_lower_than_low(self):
"""use_count=5 的素材评分低于 use_count=1 的。"""
low_use = _asset_with_use_count("low", 1)
high_use = _asset_with_use_count("high", 5)
low_score, _ = score_asset(low_use)
high_score, _ = score_asset(high_use)
assert low_score > high_score
def test_unused_breakdown_values(self):
"""验证 unused 维度的具体分值。"""
fresh = _asset_with_use_count("fresh", 0)
low = _asset_with_use_count("low", 2)
high = _asset_with_use_count("high", 10)
_, fresh_bd = score_asset(fresh)
_, low_bd = score_asset(low)
_, high_bd = score_asset(high)
# use_count=0 → unused_score=100 → component=10.0
assert fresh_bd["unused"] == 10.0
# use_count=2 → unused_score=70 → component=7.0
assert low_bd["unused"] == 7.0
# use_count=10 → unused_score=30 → component=3.0
assert high_bd["unused"] == 3.0
def test_monotonically_decreasing_scores(self):
"""使用次数递增时,总评分单调不增。"""
scores = []
for count in [0, 1, 2, 3, 5, 10, 50]:
a = _asset_with_use_count(f"a{count}", count)
s, _ = score_asset(a)
scores.append(s)
# 验证非递增
for i in range(len(scores) - 1):
assert (
scores[i] >= scores[i + 1]
), f"use_count 递增时评分应不增: scores[{i}]={scores[i]} < scores[{i+1}]={scores[i+1]}"
# ── smart_select_assets 排序测试 ─────────────────────────────────────────────
class TestSmartSelectAssetsOrdering:
"""验证 smart_select_assets 返回结果按评分降序。"""
def test_less_used_assets_ranked_higher(self):
"""使用次数少的素材在结果中排名更高。"""
assets = [
_asset_with_use_count("heavily_used", 10),
_asset_with_use_count("never_used", 0),
_asset_with_use_count("lightly_used", 2),
]
results = smart_select_assets(assets)
ids = [r.asset.id for r in results]
# never_used 排第一,heavily_used 排最后
assert ids[0] == "never_used"
assert ids[-1] == "heavily_used"
def test_same_quality_different_use_count(self):
"""质量相同时,使用次数少的排名更高。"""
assets = [
_asset_with_use_count("used_5", 5),
_asset_with_use_count("used_0", 0),
]
results = smart_select_assets(assets)
assert results[0].asset.id == "used_0"
assert results[1].asset.id == "used_5"
# ── from-assets 路径集成测试 ─────────────────────────────────────────────────
def _make_mock_asset_for_clips(aid, duration, use_count=0):
"""创建带 score_asset 所需属性的 mock 素材。"""
asset = MagicMock()
asset.id = aid
asset.duration = duration
asset.quality_score = None
asset.created_at = None
asset.metadata = {"generation_use_count": use_count}
return asset
def _make_auth_user():
auth = MagicMock()
auth.user.id = "user-001"
auth.user.email = "test@example.com"
auth.user.display_name = "测试用户"
auth.user_id = "user-001"
return auth
class TestFromAssetsSmartMatchIntegration:
"""验证 clips.py 中 sorted_candidates 使用 smart_match 评分。"""
def test_sorted_candidates_prefers_high_score_low_use_count(self):
"""在 from-assets 路径中,smart_match 分高且使用次数少的素材排在前面。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_asset_repo = MagicMock()
def _get_asset(aid):
use_count = {"a_heavy": 10, "a_fresh": 0}[aid]
return _make_mock_asset_for_clips(aid, 30.0, use_count)
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
mock_plan_svc = MagicMock()
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
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_heavy", "a_fresh"],
required_clips_count=2,
)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="test-plan-001",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
# 验证 replace_all_clips_transactional 被调用
assert mock_plan_svc.replace_all_clips_transactional.called
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
clips_data = call_args.args[1]
# 第一个片段应该分配给 a_freshsmart_match 分更高)
first_clip_asset = clips_data[0]["asset_id"]
assert (
first_clip_asset == "a_fresh"
), f"第一个片段应分配给 smart_match 分更高的 a_fresh,实际是 {first_clip_asset}"
# ── 一键生成路径集成测试 ─────────────────────────────────────────────────────
class TestPlanGeneratorSmartMatchIntegration:
"""验证 PlanGeneratorService._sort_assets_by_smart_score 排序正确。"""
def test_sort_assets_by_smart_score_descending(self):
"""_sort_assets_by_smart_score 返回按评分降序排列的素材 ID。"""
from app.services.plan_generator_service import PlanGeneratorService
mock_asset_repo = MagicMock()
def _get_asset(aid):
use_count = {"high_use": 10, "low_use": 0, "mid_use": 3}[aid]
asset = MagicMock()
asset.id = aid
asset.duration = 15.0
asset.quality_score = None
asset.created_at = None
asset.metadata = {"generation_use_count": use_count}
return asset
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
db = MagicMock()
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
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"
assert sorted_ids[-1] == "high_use"
assert sorted_ids[1] == "mid_use"
def test_distribute_assets_uses_smart_score_ordering(self):
"""_distribute_assets 在非随机模式下按 smart_match 评分排序素材。"""
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.editing_mode import EditingMode
mock_asset_repo = MagicMock()
def _get_asset(aid):
use_count = {"old_asset": 10, "new_asset": 0}[aid]
asset = MagicMock()
asset.id = aid
asset.duration = 30.0
asset.quality_score = None
asset.created_at = None
asset.metadata = {"generation_use_count": use_count}
return asset
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
db = MagicMock()
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
# 创建 2 个 main clips(需要提供 id 参数)
clips = [
EditPlanClip(id="c1", plan_id="p1", clip_type="main", duration=5.0, order=0),
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:
svc._distribute_assets(
clips,
["old_asset", "new_asset"],
EditingMode.ONE_TAKE.value,
random_selection=False,
)
# 验证传给 distribute_assets 的 asset_ids 按 smart_match 排序
call_args = mock_dist.call_args
passed_ids = call_args.args[1]
# new_asset (0次使用) 应排在 old_asset (10次使用) 前面
assert passed_ids[0] == "new_asset"
assert passed_ids[1] == "old_asset"
def test_random_selection_skips_smart_score_sort(self):
"""random_selection=True 时不执行 smart_match 排序。"""
from app.services.plan_generator_service import PlanGeneratorService
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.editing_mode import EditingMode
mock_asset_repo = MagicMock()
db = MagicMock()
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
clips = [
EditPlanClip(id="c1", plan_id="p1", clip_type="main", duration=5.0, order=0),
]
with patch("app.services.plan_generator_service.distribute_assets") as mock_dist:
svc._distribute_assets(
clips,
["a1", "a2"],
EditingMode.ONE_TAKE.value,
random_selection=True,
)
# random_selection=True 时不应调用 asset_repo.get(不执行排序)
mock_asset_repo.get.assert_not_called()