Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a98c33b9 | |||
| 8920bead38 | |||
| 827d8aafe5 | |||
| c00a0d9eb0 | |||
| 6c3db74fd3 | |||
| ab6717eeae | |||
| c05026c5db | |||
| 9d5ae7a5bc | |||
| 4aeb1d5b66 | |||
| 57621d9346 | |||
| 0a5bd4f7e6 | |||
| a0c14db33c | |||
| b6ed45fc1b | |||
| 0299678389 | |||
| 5f18f844c1 | |||
| b48daf346b | |||
| 65f41b1ba4 | |||
| c11d70d9c9 | |||
| 783e426255 | |||
| 6fbc593916 | |||
| 262b730aca | |||
| f685d689d6 | |||
| 3c8b794efb | |||
| 0bf1cfa7f4 | |||
| 89413d6822 | |||
| adf6b517fb | |||
| f761a6bf07 | |||
| 0bc53172fc | |||
| 70c34da507 | |||
| 1cb5991c4b | |||
| 9ad729d917 | |||
| 1225e65cc3 | |||
| dedb076c94 | |||
| 3a9a4334df | |||
| a3f615a2d1 | |||
| 1413cb80fe | |||
| 1a2c4b89be | |||
| 24f1ca35d5 | |||
| ff40a7b5f3 | |||
| 6ea77725a5 | |||
| 2e3b2d7680 | |||
| 33f9f5021b | |||
| 974ca188b3 | |||
| 6e4b711ba2 | |||
| 83cbcd6c76 | |||
| 071d17d947 | |||
| 6a12608104 | |||
| c08a064e55 | |||
| 83cb632653 | |||
| 45c07789e0 | |||
| 3745970515 | |||
| 34e8c56584 | |||
| 41fdd7e066 | |||
| 7e266d5d64 | |||
| 743d5d5467 | |||
| 8e962b3af2 | |||
| ea51a8af26 | |||
| 56766c6a1a | |||
| d45f0d6bdb | |||
| bc9df316dc | |||
| 5ed2ee1192 | |||
| 061554af89 | |||
| 87cca302f4 | |||
| deb127ae08 | |||
| a9438ed996 | |||
| 72f47592a9 | |||
| b6c340a352 | |||
| 6e76b45d34 | |||
| cd8e77c1f6 |
@@ -150,6 +150,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-style-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -252,6 +253,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-security-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -352,6 +354,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-python-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -456,6 +459,7 @@ jobs:
|
||||
run: bash scripts/ci/step_install_ffmpeg.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -664,6 +668,7 @@ jobs:
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
@@ -876,7 +881,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash scripts/ci/ci_push_paths.sh
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_push_paths.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_push_paths.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_push_paths.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -1145,9 +1150,7 @@ jobs:
|
||||
- check-push-paths
|
||||
- build-staging
|
||||
- retag-staging-skipped
|
||||
# 显式 success() 状态检查:上游 build/retag 被路径过滤 if 跳过(skipped)时不阻塞本 job;
|
||||
# 上游真正失败时仍然阻断(act_runner 对无状态函数的 if 隐式包 success(),纯 skipped 也会连带跳过)
|
||||
if: success() && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
if: (!cancelled()) && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -3,7 +3,7 @@ name: PR Auto Scan
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
|
||||
- cron: "*/15 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""migrate template_segments data to template_clip_configs
|
||||
|
||||
Revision ID: 060_migrate_segments
|
||||
Revises: 059_duplicate_rate
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "060_migrate_segments"
|
||||
down_revision = "059_duplicate_rate"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
dialect = op.get_bind().dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN json_build_object('material_type', s.material_type)::jsonb "
|
||||
"ELSE '{}'::jsonb END"
|
||||
)
|
||||
empty_json = "'{}'::jsonb"
|
||||
else:
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN JSON_OBJECT('material_type', s.material_type) "
|
||||
"ELSE '{}' END"
|
||||
)
|
||||
empty_json = "'{}'"
|
||||
|
||||
sql_str = (
|
||||
"INSERT INTO template_clip_configs "
|
||||
'(id, template_id, clip_type, "order", min_duration, max_duration, '
|
||||
"text_template, material_requirements, transition_effect, config, "
|
||||
"created_at, updated_at) "
|
||||
"SELECT "
|
||||
"s.id, s.template_id, 'main', s.segment_order, "
|
||||
"s.duration_min, s.duration_max, "
|
||||
"'', " + empty_json + ", "
|
||||
"'cut', " + config_expr + ", "
|
||||
"s.created_at, s.updated_at "
|
||||
"FROM template_segments s "
|
||||
"WHERE NOT EXISTS ("
|
||||
" SELECT 1 FROM template_clip_configs c "
|
||||
" WHERE c.template_id = s.template_id"
|
||||
")"
|
||||
)
|
||||
op.execute(sa.text(sql_str))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -579,39 +579,45 @@ def smart_match_assets(
|
||||
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
|
||||
total_candidates = len(filtered_assets)
|
||||
|
||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(
|
||||
filtered_assets,
|
||||
limit=request.limit,
|
||||
kind=None,
|
||||
)
|
||||
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ──────────
|
||||
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补,
|
||||
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。
|
||||
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。
|
||||
|
||||
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
|
||||
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
|
||||
filtered_results = []
|
||||
for r in results:
|
||||
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
|
||||
usable_assets = []
|
||||
exhausted_assets = []
|
||||
for a in filtered_assets:
|
||||
try:
|
||||
avail = compute_asset_availability(r.asset)
|
||||
avail = compute_asset_availability(a)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"smart-match 余量计算失败,按可用处理: asset_id=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(a, "id", "?"),
|
||||
exc_info=True,
|
||||
)
|
||||
avail = None
|
||||
if avail is not None and not avail["usable"]:
|
||||
logger.info(
|
||||
"smart-match 排除已用尽素材: asset_id=%s name=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(r.asset, "name", ""),
|
||||
)
|
||||
continue
|
||||
filtered_results.append(r)
|
||||
exhausted_assets.append(a)
|
||||
else:
|
||||
usable_assets.append(a)
|
||||
|
||||
# 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
if exhausted_assets:
|
||||
logger.info(
|
||||
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
|
||||
len(filtered_assets), len(exhausted_assets),
|
||||
)
|
||||
|
||||
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
|
||||
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
|
||||
# (全选同样会选到这些素材,且不经过评分排序)。
|
||||
pool = usable_assets if usable_assets else filtered_assets
|
||||
|
||||
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
MAX_RECENT_USE_COUNT = 3
|
||||
if filtered_results:
|
||||
asset_ids = [getattr(r.asset, "id", "") for r in filtered_results if getattr(r.asset, "id", "")]
|
||||
high_freq_assets = set()
|
||||
if pool:
|
||||
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
|
||||
if asset_ids:
|
||||
try:
|
||||
use_counts = get_asset_recent_use_counts(
|
||||
@@ -619,27 +625,35 @@ def smart_match_assets(
|
||||
asset_ids=asset_ids,
|
||||
recent_video_count=5,
|
||||
)
|
||||
high_use_excluded = set()
|
||||
for r in filtered_results:
|
||||
aid = getattr(r.asset, "id", "")
|
||||
for a in pool:
|
||||
aid = getattr(a, "id", "")
|
||||
count = use_counts.get(aid, 0)
|
||||
if count > MAX_RECENT_USE_COUNT:
|
||||
high_freq_assets.add(aid)
|
||||
logger.info(
|
||||
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
|
||||
aid, count, MAX_RECENT_USE_COUNT,
|
||||
)
|
||||
high_use_excluded.add(id(r))
|
||||
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
|
||||
# 不再全部排除,保留全部可用素材
|
||||
if high_freq_assets:
|
||||
remaining_count = len(pool) - len(high_freq_assets)
|
||||
enough = request.limit is None or remaining_count >= request.limit
|
||||
if remaining_count > 0 and enough:
|
||||
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
|
||||
else:
|
||||
pass
|
||||
# 如果排除后不够 limit,放宽到不限制
|
||||
remaining = [r for r in filtered_results if id(r) not in high_use_excluded]
|
||||
if len(remaining) >= request.limit:
|
||||
filtered_results = remaining
|
||||
else:
|
||||
logger.info("smart-match 高频排除后素材不足(%d<%d),保留全部", len(remaining), request.limit)
|
||||
logger.info(
|
||||
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d 条",
|
||||
remaining_count,
|
||||
request.limit if request.limit is not None else "不限",
|
||||
len(pool),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
|
||||
|
||||
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(pool, limit=request.limit, kind=None)
|
||||
|
||||
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
|
||||
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
|
||||
items = [
|
||||
@@ -648,7 +662,7 @@ def smart_match_assets(
|
||||
score=r.score,
|
||||
breakdown=r.breakdown,
|
||||
)
|
||||
for r in filtered_results
|
||||
for r in results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
@@ -23,6 +23,10 @@ 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,
|
||||
@@ -37,10 +41,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
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
|
||||
@@ -411,7 +419,25 @@ def _get_template_segments(
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
|
||||
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
|
||||
|
||||
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
|
||||
try:
|
||||
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
direct_configs = direct_repo.list_by_template(template_id)
|
||||
if direct_configs:
|
||||
result = []
|
||||
for cc in direct_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
@@ -561,6 +587,47 @@ 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,
|
||||
@@ -605,10 +672,14 @@ 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 中持久化的历史已用区间(跨任务/跨调用去重),
|
||||
@@ -641,19 +712,51 @@ def create_clips_from_assets_editor(
|
||||
return False
|
||||
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
|
||||
|
||||
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
|
||||
# 素材耗尽标志:某轮循环中所有素材均被跳过时为 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]
|
||||
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
|
||||
raw_duration = random.uniform(dur_min, dur_max)
|
||||
# 加上转场补偿,确保最终输出时长 = 模板设定总时长
|
||||
raw_duration += transition_compensation
|
||||
|
||||
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
|
||||
# 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先),
|
||||
# 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。
|
||||
# 跳过时长缺失、复用占比已超 10% 阈值的素材;
|
||||
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
|
||||
# 继续轮询下一个素材
|
||||
# 继续尝试下一个素材
|
||||
asset_id = ""
|
||||
clip_duration = 0.0
|
||||
start_time: float | None = None
|
||||
n_assets = len(asset_ids)
|
||||
for offset in range(n_assets):
|
||||
candidate = asset_ids[(i + offset) % n_assets]
|
||||
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
|
||||
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:
|
||||
candidate_total = asset_durations.get(candidate, 0.0)
|
||||
if candidate_total <= 0:
|
||||
continue
|
||||
@@ -669,7 +772,7 @@ def create_clips_from_assets_editor(
|
||||
continue
|
||||
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
|
||||
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
|
||||
# 预判复用后占比超 15% 则拒绝并返回 None)
|
||||
# 预判复用后占比超 10% 则拒绝并返回 None)
|
||||
candidate_start = _calc_random_start_time(
|
||||
candidate,
|
||||
candidate_duration,
|
||||
@@ -691,6 +794,7 @@ 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="素材可切区间不足,请补充新素材",
|
||||
@@ -706,7 +810,7 @@ def create_clips_from_assets_editor(
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
"order": i,
|
||||
"order": _seg_order,
|
||||
"asset_id": asset_id,
|
||||
"start_time": start_time,
|
||||
"duration": clip_duration,
|
||||
@@ -714,6 +818,9 @@ def create_clips_from_assets_editor(
|
||||
}
|
||||
)
|
||||
|
||||
# 按原始 segment order 排序,确保 clips_data 的 order 字段有序(0,1,2,3...)
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
|
||||
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
@@ -733,19 +840,86 @@ def create_clips_from_assets_editor(
|
||||
unique_asset_ids,
|
||||
)
|
||||
|
||||
# 6. 立即返回响应
|
||||
# 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. 立即返回响应
|
||||
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:
|
||||
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
|
||||
"""后台任务:使用 SceneChange 智能选帧并更新片段的起始时间.
|
||||
|
||||
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
|
||||
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
|
||||
|
||||
降级策略:
|
||||
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
|
||||
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
|
||||
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
|
||||
|
||||
此函数在后台异步执行,不影响接口响应时间。
|
||||
失败时静默处理,不影响已创建的片段。
|
||||
@@ -767,12 +941,6 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
asset_repo = SQLAlchemyAssetRepository(db)
|
||||
plan_svc = EditPlanService(db)
|
||||
|
||||
# 调用 MediaKit 获取推荐时间
|
||||
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
|
||||
if not recommendations:
|
||||
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
|
||||
return
|
||||
|
||||
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
|
||||
batch_size = 500
|
||||
all_clips = []
|
||||
@@ -795,15 +963,16 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
|
||||
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
|
||||
|
||||
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
|
||||
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
|
||||
# 按 asset_id 预分组片段对象(按 order 排序,保证按模板顺序分配镜头段)
|
||||
clips_by_asset: dict[str, list] = defaultdict(list)
|
||||
for clip in clips:
|
||||
aid = getattr(clip, "asset_id", "") or ""
|
||||
if aid and clip.start_time is not None:
|
||||
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
|
||||
if aid:
|
||||
clips_by_asset[aid].append(clip)
|
||||
for aid in clips_by_asset:
|
||||
clips_by_asset[aid].sort(key=lambda c: c.order)
|
||||
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
|
||||
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录)
|
||||
historical_segments = get_used_segments(db, unique_asset_ids)
|
||||
|
||||
# 已更新的片段ID(用于排除已移动的旧时间段)
|
||||
@@ -812,16 +981,22 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
updated_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
updated_count = 0
|
||||
|
||||
# 遍历片段,按 asset_id 匹配推荐时间
|
||||
for clip in clips:
|
||||
asset_id = getattr(clip, "asset_id", "") or ""
|
||||
if not asset_id or asset_id not in recommendations:
|
||||
# 尝试获取存储服务(用于生成视频 URL)
|
||||
try:
|
||||
storage = get_storage_service()
|
||||
except Exception:
|
||||
logger.warning("后台任务: 获取存储服务失败,跳过 SceneChange 更新")
|
||||
return
|
||||
|
||||
# 获取 MediaKit 客户端
|
||||
client = get_mediakit_client()
|
||||
|
||||
# 对每个素材,检测场景切换点并分配镜头段
|
||||
for asset_id in unique_asset_ids:
|
||||
asset_clips = clips_by_asset.get(asset_id, [])
|
||||
if not asset_clips:
|
||||
continue
|
||||
|
||||
recommended_start = recommendations[asset_id]
|
||||
clip_duration = clip.duration
|
||||
|
||||
# 从预加载字典获取素材(O(1) 查找)
|
||||
asset = assets_map.get(asset_id)
|
||||
if not asset:
|
||||
continue
|
||||
@@ -829,89 +1004,143 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if asset_total <= 0:
|
||||
continue
|
||||
|
||||
# 推荐时间 + 片段时长不能超过素材总时长
|
||||
if recommended_start + clip_duration > asset_total:
|
||||
logger.info(
|
||||
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
clip_duration,
|
||||
asset_total,
|
||||
)
|
||||
continue
|
||||
|
||||
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
|
||||
other_segments: list[tuple[float, float]] = [
|
||||
(cs, ce)
|
||||
for cid, cs, ce in clips_by_asset.get(asset_id, [])
|
||||
if cid != clip.id and cid not in updated_clip_ids
|
||||
]
|
||||
other_segments.extend(updated_segments.get(asset_id, []))
|
||||
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
|
||||
# 本 plan 片段创建时已写入历史记录
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
|
||||
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
|
||||
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
|
||||
def _norm(segs):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
|
||||
|
||||
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
|
||||
|
||||
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
|
||||
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
|
||||
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
|
||||
logger.info(
|
||||
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
continue
|
||||
|
||||
# 逐个更新并捕获异常(单点失败不影响其他片段)
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
|
||||
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
|
||||
# 写新区间,最后统一 commit;任一步失败整体 rollback,
|
||||
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
# 获取素材视频 URL
|
||||
video_url: str | None = None
|
||||
storage_key = getattr(asset, "storage_key", None) or ""
|
||||
mime = getattr(asset, "mime_type", "") or ""
|
||||
if storage_key and mime.startswith("video/"):
|
||||
try:
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
video_url = storage.get_download_url(storage_key)
|
||||
except Exception as e:
|
||||
logger.warning("后台任务: 获取素材URL失败: asset_id=%s error=%s", asset_id, e)
|
||||
|
||||
# 构建该素材的占用区间列表(排除已更新片段)
|
||||
def _get_other_segments(asset_id_inner, clip_id_inner):
|
||||
segs: list[tuple[float, float]] = []
|
||||
for c in clips_by_asset.get(asset_id_inner, []):
|
||||
cid = c.id
|
||||
if cid != clip_id_inner and cid not in updated_clip_ids:
|
||||
segs.append((c.start_time, c.start_time + c.duration))
|
||||
segs.extend(updated_segments.get(asset_id_inner, []))
|
||||
# 并入历史已用区间
|
||||
def _norm(segs_in):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
|
||||
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
|
||||
|
||||
# 优先使用 SceneChange 策略
|
||||
scene_segments: list[tuple[float, float]] = []
|
||||
if client.is_available and video_url:
|
||||
scene_changes = client.detect_scene_changes(video_url)
|
||||
if scene_changes is not None:
|
||||
scene_segments = _build_scene_segments(scene_changes, asset_total)
|
||||
logger.info(
|
||||
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
|
||||
asset_id, len(scene_segments),
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
if not scene_segments and video_url:
|
||||
fallback_recs = _get_mediakit_recommendations([asset_id], asset_repo)
|
||||
if fallback_recs and asset_id in fallback_recs:
|
||||
# analyze_videos 只返回单个推荐点,转为单镜头段
|
||||
rec_start = fallback_recs[asset_id]
|
||||
scene_segments = [(rec_start, asset_total)]
|
||||
logger.info(
|
||||
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
|
||||
asset_id, rec_start,
|
||||
)
|
||||
|
||||
if not scene_segments:
|
||||
# 所有方式都失败 → 保持现有随机 start_time
|
||||
logger.info(
|
||||
"后台任务: SceneChange 与 analyze_videos 均无结果,保持随机起点: asset_id=%s",
|
||||
asset_id,
|
||||
)
|
||||
continue
|
||||
|
||||
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
# 为每个片段分配不同的镜头段
|
||||
scene_segments_pool = list(scene_segments) # 可消费的镜头段池
|
||||
for clip in asset_clips:
|
||||
clip_duration = clip.duration
|
||||
recommended_start: float | None = None
|
||||
|
||||
# 从镜头段池中依次尝试,选一个不冲突的
|
||||
for seg_idx, (seg_start, seg_end) in enumerate(scene_segments_pool):
|
||||
candidate_start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
if candidate_start is None:
|
||||
continue # 镜头段太短,跳过
|
||||
|
||||
# 检查越界
|
||||
if candidate_start + clip_duration > asset_total:
|
||||
continue
|
||||
|
||||
# 检查与已用区间冲突
|
||||
other_segs = _get_other_segments(asset_id, clip.id)
|
||||
if _recommended_time_conflicts(candidate_start, clip_duration, other_segs):
|
||||
continue
|
||||
|
||||
recommended_start = candidate_start
|
||||
# 消费该镜头段(从池中移除,下一个片段用不同镜头段)
|
||||
scene_segments_pool.pop(seg_idx)
|
||||
break
|
||||
|
||||
if recommended_start is None:
|
||||
# 镜头段用完或都冲突 → 尝试 _calc_random_start_time 兜底
|
||||
used_segs_for_calc: dict[str, list[tuple[float, float]]] = {
|
||||
asset_id: _get_other_segments(asset_id, clip.id)
|
||||
}
|
||||
fallback_start = _calc_random_start_time(
|
||||
asset_id,
|
||||
clip_duration,
|
||||
{asset_id: asset_total},
|
||||
used_segs_for_calc,
|
||||
)
|
||||
if fallback_start is None:
|
||||
continue # 完全无法分配,保持原起点
|
||||
recommended_start = fallback_start
|
||||
|
||||
# 更新片段起始时间
|
||||
try:
|
||||
old_start = clip.start_time
|
||||
old_end = old_start + clip_duration
|
||||
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
try:
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
recommended_start + clip_duration,
|
||||
plan_id,
|
||||
)
|
||||
except Exception as me:
|
||||
logger.warning(
|
||||
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
|
||||
clip.id,
|
||||
me,
|
||||
)
|
||||
db.rollback()
|
||||
continue
|
||||
db.commit()
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间(场景选帧): clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
asset_id,
|
||||
recommended_start,
|
||||
)
|
||||
except Exception as ue:
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
|
||||
|
||||
|
||||
@@ -189,6 +189,8 @@ 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 @@
|
||||
在素材 metadata(assets.classification_result JSON)中持久化已使用的片段时间区间,
|
||||
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
|
||||
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
|
||||
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
|
||||
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 10%),把任意两条
|
||||
成片的画面重复率控制在阈值内。
|
||||
|
||||
metadata 中的记录字段 ``used_time_ranges``::
|
||||
@@ -40,14 +40,14 @@ logger = logging.getLogger(__name__)
|
||||
USED_RANGES_KEY = "used_time_ranges"
|
||||
|
||||
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
|
||||
MAX_RANGE_USE_COUNT = 3
|
||||
MAX_RANGE_USE_COUNT = 2
|
||||
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
|
||||
|
||||
REUSE_RATIO_LIMIT = 0.15
|
||||
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
|
||||
REUSE_RATIO_LIMIT = 0.10
|
||||
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(10%)。
|
||||
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
|
||||
|
||||
SEGMENT_EDGE_GAP = 0.3
|
||||
SEGMENT_EDGE_GAP = 1.5
|
||||
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
|
||||
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
|
||||
|
||||
@@ -397,12 +397,12 @@ def make_reuse_callback(
|
||||
db: SQLAlchemy session
|
||||
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
|
||||
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
|
||||
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
|
||||
会把本次片段时长累加进去,供调用方统计成片复用占比(10% 阈值)。
|
||||
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
|
||||
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
|
||||
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
|
||||
(保证成片复用占比不超阈值)。
|
||||
ratio_limit: 单条成片复用时长占比上限,默认 15%。
|
||||
ratio_limit: 单条成片复用时长占比上限,默认 10%。
|
||||
|
||||
Returns:
|
||||
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
|
||||
|
||||
@@ -32,6 +32,7 @@ from packages.domain.plan_generator_utils import (
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.smart_match import score_asset
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -218,8 +219,13 @@ class PlanGeneratorService:
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
先用 smart_match 评分对素材排序(高分优先),再委托给
|
||||
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,
|
||||
@@ -228,6 +234,23 @@ 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]:
|
||||
"""从数据库获取素材时长信息.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Generated
+7
-14
@@ -1848,10 +1848,9 @@
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@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",
|
||||
@@ -1938,10 +1937,9 @@
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@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": {
|
||||
@@ -3113,10 +3111,9 @@
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
@@ -4457,10 +4454,9 @@
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/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"
|
||||
@@ -5008,10 +5004,9 @@
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/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",
|
||||
@@ -5024,10 +5019,9 @@
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -5735,10 +5729,9 @@
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/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": {
|
||||
|
||||
@@ -71,4 +71,5 @@ export interface VideoItem {
|
||||
generation_params: Record<string, unknown>
|
||||
download_url: string
|
||||
generated_at: string
|
||||
duplicate_rate?: number
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||
created_at: video.generated_at,
|
||||
updated_at: video.generated_at,
|
||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||
duplicate_rate: undefined,
|
||||
duplicate_rate: video.duplicate_rate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,12 +338,15 @@ const GeneratePage: React.FC = () => {
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom",
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
shadow: titleSettings.shadow,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
)}
|
||||
{currentStep >= 5 && generated && finalVideo && (
|
||||
|
||||
@@ -32,12 +32,15 @@ interface FrontendPreviewPlayerProps {
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
posX?: number | null
|
||||
posY?: number | null
|
||||
}
|
||||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -105,6 +108,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips),
|
||||
@@ -126,6 +130,69 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
})()
|
||||
const customTitleXPct =
|
||||
titleSettings?.posX != null && playRes.width > 0
|
||||
? (titleSettings.posX / playRes.width) * 100
|
||||
: null
|
||||
const customTitleYPct =
|
||||
titleSettings?.posY != null && playRes.height > 0
|
||||
? (titleSettings.posY / playRes.height) * 100
|
||||
: null
|
||||
|
||||
// ── 拖拽状态(用 ref 避免在每帧渲染中触发重渲染)──
|
||||
const draggingTitleRef = useRef(false)
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const handleTitlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||||
draggingTitleRef.current = true
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||||
},
|
||||
[onTitlePositionChange],
|
||||
)
|
||||
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 拖拽过程中直接修改 DOM,不触发 React 渲染(避免频繁重渲染导致换行)
|
||||
if (titleDragRef.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 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 */
|
||||
}
|
||||
},
|
||||
[onTitlePositionChange, playRes.width, playRes.height],
|
||||
)
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -524,15 +591,36 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center",
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
...(customTitleXPct != null && customTitleYPct != null
|
||||
? {
|
||||
left: `${customTitleXPct}%`,
|
||||
top: `${customTitleYPct}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
textAlign: "center" as const,
|
||||
}
|
||||
: {
|
||||
left: `${titleSidePct}%`,
|
||||
right: `${titleSidePct}%`,
|
||||
textAlign: "center" as const,
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translateY(-50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: "auto",
|
||||
cursor: onTitlePositionChange ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
padding: "8px 12px",
|
||||
boxShadow: "inset 0 0 0 16px transparent",
|
||||
}}
|
||||
ref={titleDragRef}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -23,7 +23,7 @@ const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange
|
||||
onClick={() => onModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
AI智能匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* 标题预设样式网格
|
||||
* 双图层渲染:底层=描边轮廓(text-shadow模拟),上层=填充色
|
||||
* 避免 -webkit-text-stroke 在 Chromium 中吞掉填充色的问题
|
||||
*/
|
||||
import React from "react"
|
||||
import { getFontFamily } from "../../constants"
|
||||
@@ -7,7 +9,10 @@ import { getFontFamily } from "../../constants"
|
||||
interface TitlePresetItem {
|
||||
key: string
|
||||
label: string
|
||||
previewStyle: React.CSSProperties
|
||||
previewStyle: React.CSSProperties & {
|
||||
_strokeColor?: string
|
||||
_strokeWidth?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface TitlePresetsGridProps {
|
||||
@@ -17,6 +22,32 @@ 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,
|
||||
@@ -27,20 +58,53 @@ 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={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
|
||||
>
|
||||
标题
|
||||
<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>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
{ value: "custom", label: "自定义" },
|
||||
]
|
||||
|
||||
/* ── 标题字体选项 ── */
|
||||
@@ -77,10 +78,11 @@ export const TITLE_PRESETS = [
|
||||
label: "经典白字",
|
||||
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#ffffff",
|
||||
WebkitTextStroke: "1px #000000",
|
||||
fontSize: "20px",
|
||||
_strokeColor: "#000000",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -88,45 +90,54 @@ export const TITLE_PRESETS = [
|
||||
label: "黑金质感",
|
||||
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#d4a843",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
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: { fontWeight: 400, color: "#333333", fontSize: "18px" },
|
||||
previewStyle: {
|
||||
color: "#e8e8e8",
|
||||
fontWeight: 400,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "variety_show",
|
||||
label: "综艺花字",
|
||||
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 900,
|
||||
color: "#ff4081",
|
||||
WebkitTextStroke: "1.5px #ffffff",
|
||||
_strokeColor: "#ffffff",
|
||||
_strokeWidth: 2,
|
||||
fontWeight: 900,
|
||||
fontSize: "32px",
|
||||
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: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
|
||||
previewStyle: {
|
||||
color: "#e0e0e0",
|
||||
fontWeight: 400,
|
||||
fontSize: "32px",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "retro_film",
|
||||
label: "复古胶片",
|
||||
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 400,
|
||||
color: "#e8d5b7",
|
||||
fontWeight: 400,
|
||||
fontSize: "32px",
|
||||
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
|
||||
fontSize: "18px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -134,10 +145,10 @@ export const TITLE_PRESETS = [
|
||||
label: "霓虹发光",
|
||||
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
fontWeight: 700,
|
||||
fontSize: "32px",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -145,10 +156,202 @@ export const TITLE_PRESETS = [
|
||||
label: "手写字",
|
||||
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
|
||||
previewStyle: {
|
||||
color: "#e0e0e0",
|
||||
fontWeight: 400,
|
||||
color: "#333333",
|
||||
fontSize: "32px",
|
||||
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
{
|
||||
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)",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1733,50 +1733,40 @@
|
||||
/* 标题预设卡片网格 */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(6, 52px);
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
background: var(--bg-secondary);
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
background: #404040;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-title-preset-card:hover {
|
||||
border-color: var(--primary-200);
|
||||
background: var(--bg-primary);
|
||||
border-color: #666;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-50);
|
||||
border-color: #409eff;
|
||||
background: #4d4d4d;
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-title-preset-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active .xx-title-preset-card-label {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 样式按钮组 */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -20,13 +20,14 @@ const SECONDS_PER_ASSET = 15
|
||||
/**
|
||||
* 根据模板 segments 计算所需素材数量上限。
|
||||
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
|
||||
* 再按 15 秒/素材估算需要多少个素材;结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材);
|
||||
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
*/
|
||||
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
if (!segments || segments.length === 0) return DEFAULT_LIMIT
|
||||
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
|
||||
if (totalSeconds <= 0) return DEFAULT_LIMIT
|
||||
const limit = Math.ceil(totalSeconds / SECONDS_PER_ASSET)
|
||||
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
|
||||
return Math.max(1, Math.min(limit, 200))
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ export function useCanvasPlayer(
|
||||
fontSize: number
|
||||
fontFamily: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom"
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
bold?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
|
||||
@@ -27,6 +27,8 @@ const DEFAULT_TITLE_SETTINGS: TitleSettings = {
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
color: "#ffffff",
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
|
||||
export interface GenerateFormState {
|
||||
|
||||
@@ -117,6 +117,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
font_size: props.titleSettings.size,
|
||||
font_color: props.titleSettings.color,
|
||||
position: props.titleSettings.position,
|
||||
...(props.titleSettings.position === "custom" &&
|
||||
props.titleSettings.posX != null &&
|
||||
props.titleSettings.posY != null
|
||||
? {
|
||||
pos_x: Math.round(props.titleSettings.posX),
|
||||
pos_y: Math.round(props.titleSettings.posY),
|
||||
}
|
||||
: {}),
|
||||
bold: props.titleSettings.bold,
|
||||
stroke: props.titleSettings.stroke,
|
||||
shadow: props.titleSettings.shadow,
|
||||
|
||||
@@ -46,7 +46,16 @@ export function useTitleStyleUpdaters({
|
||||
|
||||
const updatePosition = useCallback(
|
||||
(position: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position })
|
||||
// 切回三档预设时清掉自定义坐标
|
||||
onTitleSettingsChange({ ...titleSettings, position, posX: null, posY: null })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
/** 拖拽更新自定义位置(由预览播放器调用) */
|
||||
const updateTitlePosition = useCallback(
|
||||
(posX: number, posY: number) => {
|
||||
onTitleSettingsChange({ ...titleSettings, position: "custom", posX, posY })
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
@@ -111,6 +120,7 @@ export function useTitleStyleUpdaters({
|
||||
updateTitle,
|
||||
toggleAiAutoSelect,
|
||||
updatePosition,
|
||||
updateTitlePosition,
|
||||
updateFont,
|
||||
updateSize,
|
||||
updateColor,
|
||||
|
||||
@@ -16,6 +16,9 @@ export interface TitleSettings {
|
||||
stroke: boolean
|
||||
shadow: boolean
|
||||
color: string
|
||||
/** 自由位置坐标(PlayRes 像素),仅当 position="custom" 时有效 */
|
||||
posX: number | null
|
||||
posY: number | null
|
||||
}
|
||||
|
||||
/* ── 智能匹配结果 ── */
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
import { formatTime } from "../utils"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductItem
|
||||
@@ -210,11 +210,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
<div className="xx-product-meta-grid">
|
||||
<span className="xx-product-meta-item">分辨率:{product.resolution || "-"}</span>
|
||||
<span className="xx-product-meta-item">
|
||||
时长:{product.duration > 0 ? formatTime(product.duration) : "-"}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-product-meta-item">大小:{formatSize(product.fileSize)}</span>
|
||||
<span
|
||||
className={`xx-product-meta-item xx-product-dup-rate${
|
||||
product.duplicateRate > 0 ? ` ${dupClass}` : ""
|
||||
}`}
|
||||
>
|
||||
查重率:{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -91,8 +91,6 @@
|
||||
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;
|
||||
@@ -137,7 +135,7 @@
|
||||
============================================================ */
|
||||
.xx-products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -187,8 +185,6 @@
|
||||
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;
|
||||
@@ -262,11 +258,8 @@
|
||||
.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 {
|
||||
@@ -276,11 +269,11 @@
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* 内联视频播放器 */
|
||||
/* 内联视频播放器(cover 填满容器,竖屏视频不留左右空白) */
|
||||
.xx-product-thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -289,22 +282,28 @@
|
||||
}
|
||||
|
||||
.xx-product-play {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--font-size-md);
|
||||
transition: var(--transition-all);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-product-card:hover .xx-product-play {
|
||||
background: var(--primary-color);
|
||||
transform: scale(1.1);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
|
||||
/* 时长标签 */
|
||||
@@ -324,7 +323,7 @@
|
||||
|
||||
/* 卡片信息区 */
|
||||
.xx-product-info {
|
||||
padding: 14px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
@@ -347,6 +346,21 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 卡片信息网格:分辨率/时长 一行,大小/查重率 一行 */
|
||||
.xx-product-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-product-meta-item {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.xx-product-status {
|
||||
padding: 2px 10px;
|
||||
@@ -491,8 +505,6 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -531,8 +543,6 @@
|
||||
background: var(--color-gray-950);
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 60vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.xx-player-video-wrap video {
|
||||
@@ -554,8 +564,6 @@
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--text-inverse);
|
||||
font-size: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: var(--transition-all);
|
||||
@@ -652,8 +660,6 @@
|
||||
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);
|
||||
|
||||
@@ -314,10 +314,13 @@ 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_rate(0~100)。
|
||||
@@ -325,23 +328,34 @@ class VideoDeduplicator:
|
||||
|
||||
Args:
|
||||
fingerprint: 当前视频的指纹
|
||||
project_id: 项目 ID
|
||||
project_id: 项目 ID(user_id 为空时的回退范围)
|
||||
current_video_id: 当前视频 ID(排除自身,可为 None)
|
||||
session: 数据库会话
|
||||
user_id: 用户 ID(优先按用户全局比较)
|
||||
|
||||
Returns:
|
||||
duplicate_rate: 0~100 的浮点数
|
||||
"""
|
||||
# 限制查询最近 100 个视频,避免大项目内存溢出
|
||||
# 限制查询最近 200 个视频,避免大库内存溢出
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
recent_models = (
|
||||
session.query(GeneratedVideoModel)
|
||||
.filter(GeneratedVideoModel.project_id == project_id)
|
||||
.order_by(GeneratedVideoModel.generated_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
# 优先按 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()
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
existing_videos = [video_repo._to_domain(m) for m in recent_models]
|
||||
|
||||
|
||||
@@ -123,7 +123,13 @@ def create_video_record_and_dedup(
|
||||
|
||||
# 计算重复率百分比(与项目内所有已有视频对比取最高相似度)
|
||||
try:
|
||||
dup_rate = deduplicator.compute_duplicate_rate(fingerprint, project_id, video_id, session)
|
||||
dup_rate = deduplicator.compute_duplicate_rate(
|
||||
fingerprint,
|
||||
project_id,
|
||||
video_id,
|
||||
session,
|
||||
user_id=user_id,
|
||||
)
|
||||
generated_video.duplicate_rate = dup_rate
|
||||
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
|
||||
except Exception as rate_err:
|
||||
|
||||
@@ -200,6 +200,22 @@ 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)
|
||||
@@ -1422,6 +1438,7 @@ 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}"
|
||||
@@ -1429,6 +1446,19 @@ 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,
|
||||
@@ -1439,7 +1469,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=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||||
playback_speed=seg_speed,
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
@@ -1461,6 +1491,7 @@ 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)
|
||||
@@ -1474,6 +1505,25 @@ 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,
|
||||
@@ -1484,13 +1534,25 @@ 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=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||||
playback_speed=final_speed,
|
||||
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
|
||||
@@ -1683,7 +1745,7 @@ class UnifiedRenderService:
|
||||
if d > 0:
|
||||
layer_dur = d
|
||||
break
|
||||
xfade_filter, _ = self._transition_engine.build_xfade_chain(
|
||||
xfade_filter, xfade_estimated_dur = self._transition_engine.build_xfade_chain(
|
||||
clip_durations=layer_durations,
|
||||
clip_video_labels=layer_labels,
|
||||
transitions=layer_transitions,
|
||||
@@ -1692,6 +1754,13 @@ 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: 合成各层
|
||||
@@ -1915,8 +1984,14 @@ 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)相关方法 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +30,14 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-bake ffmpeg: unit-tests run in fresh containers each time; installing ffmpeg
|
||||
# on every job cost ~24 min (apt update + hundreds of codec deps). Bake it into the
|
||||
# image so step_install_ffmpeg.sh detects it and exits instantly.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& ffmpeg -version | head -1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-install base deps (layer cache)
|
||||
COPY requirements-base.txt ./
|
||||
RUN python -m venv "$VIRTUAL_ENV" \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Build stage
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
ARG SOURCE_HASH=""
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
@@ -18,8 +19,10 @@ COPY apps/web/ ./
|
||||
|
||||
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
|
||||
# node_modules直接使用镜像中已安装的(layer缓存保证完整性)
|
||||
# SOURCE_HASH 变化时强制重新执行(防止 buildkit 幽灵缓存命中)
|
||||
RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& echo "SOURCE_HASH=${SOURCE_HASH}" > .cache_bust \
|
||||
&& ./node_modules/.bin/tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& ./node_modules/.bin/vite build
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""SQLAlchemy implementation of TemplateRepository."""
|
||||
"""SQLAlchemy implementation of TemplateRepository.
|
||||
|
||||
模板 segments 数据源已统一为 template_clip_configs 表。
|
||||
读取时优先 template_clip_configs,回退 template_segments(兼容历史数据)。
|
||||
写入全部走 template_clip_configs。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
EditPlanModel,
|
||||
TemplateCategoryModel,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
@@ -47,27 +53,38 @@ class SQLAlchemyTemplateRepository:
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.filter(TemplateModel.name.like(like_pattern))
|
||||
if tag:
|
||||
# JSON 数组包含指定标签(MySQL JSON_CONTAINS / SQLite json_each 兼容写法用 LIKE)
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
models = query.order_by(TemplateModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
templates = [self._model_to_entity(m) for m in models]
|
||||
# 批量加载所有 segments,避免 N+1 查询
|
||||
# 批量加载 segments —— 优先 template_clip_configs
|
||||
if templates:
|
||||
template_ids = [t.id for t in templates]
|
||||
seg_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
clip_models = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
# 按 template_id 分组
|
||||
seg_map: dict[str, list] = {}
|
||||
for sm in seg_models:
|
||||
seg_map.setdefault(sm.template_id, []).append(
|
||||
self._segment_model_to_entity(sm),
|
||||
clip_map: dict[str, list] = {}
|
||||
for cm in clip_models:
|
||||
clip_map.setdefault(cm.template_id, []).append(
|
||||
self._clip_config_to_segment(cm),
|
||||
)
|
||||
# 对没有 clip_configs 的模板,回退读 template_segments
|
||||
missing_ids = [t.id for t in templates if t.id not in clip_map]
|
||||
if missing_ids:
|
||||
old_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(missing_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
for om in old_models:
|
||||
clip_map.setdefault(om.template_id, []).append(
|
||||
self._segment_model_to_entity(om),
|
||||
)
|
||||
for t in templates:
|
||||
t.segments = seg_map.get(t.id, [])
|
||||
t.segments = clip_map.get(t.id, [])
|
||||
return templates
|
||||
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
@@ -100,7 +117,6 @@ class SQLAlchemyTemplateRepository:
|
||||
is_active=template.is_active,
|
||||
)
|
||||
self.session.add(model)
|
||||
# flush 而非 commit,让 create + create_segments 在同一事务中提交
|
||||
self.session.flush()
|
||||
self.session.refresh(model)
|
||||
result = self._model_to_entity(model)
|
||||
@@ -145,11 +161,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if model is None:
|
||||
return False
|
||||
model.is_active = False
|
||||
# 级联清理关联的 segments,避免孤儿数据
|
||||
self.session.query(TemplateSegmentModel).filter(
|
||||
TemplateSegmentModel.template_id == template_id,
|
||||
).delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
# 复用 delete_segments_by_template 清理两张表的关联数据
|
||||
self.delete_segments_by_template(template_id)
|
||||
return True
|
||||
|
||||
def count_by_user(
|
||||
@@ -172,7 +185,7 @@ class SQLAlchemyTemplateRepository:
|
||||
if keyword:
|
||||
query = query.filter(TemplateModel.name.like(f"%{keyword}%"))
|
||||
if tag:
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
return query.count()
|
||||
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template:
|
||||
@@ -181,9 +194,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if source is None:
|
||||
raise ValueError(f"Template {template_id} not found")
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
new_template = Template(
|
||||
id=new_id,
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=new_name,
|
||||
mode=source.mode,
|
||||
@@ -197,28 +209,22 @@ class SQLAlchemyTemplateRepository:
|
||||
)
|
||||
created = self.create(new_template)
|
||||
|
||||
# 复制 segments
|
||||
# 复用 create_segments 写入 template_clip_configs
|
||||
new_segments: List[TemplateSegment] = []
|
||||
for seg in source.segments:
|
||||
new_seg = TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=new_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
new_segments.append(
|
||||
TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=created.id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
)
|
||||
)
|
||||
new_segments.append(new_seg)
|
||||
model = TemplateSegmentModel(
|
||||
id=new_seg.id,
|
||||
template_id=new_seg.template_id,
|
||||
segment_order=new_seg.segment_order,
|
||||
duration_min=new_seg.duration_min,
|
||||
duration_max=new_seg.duration_max,
|
||||
material_type=new_seg.material_type,
|
||||
)
|
||||
self.session.add(model)
|
||||
if new_segments:
|
||||
self.create_segments(new_segments)
|
||||
else:
|
||||
self.session.commit()
|
||||
|
||||
created.segments = new_segments
|
||||
@@ -227,34 +233,58 @@ class SQLAlchemyTemplateRepository:
|
||||
# ── Segments ──
|
||||
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]:
|
||||
models = (
|
||||
"""优先从 template_clip_configs 读取,回退读 template_segments。"""
|
||||
clips = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
if clips:
|
||||
return [self._clip_config_to_segment(m) for m in clips]
|
||||
# 回退:旧表
|
||||
old = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
return [self._segment_model_to_entity(m) for m in models]
|
||||
return [self._segment_model_to_entity(m) for m in old]
|
||||
|
||||
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
|
||||
"""写入 template_clip_configs 表。material_type 存入 config JSON。"""
|
||||
for seg in segments:
|
||||
model = TemplateSegmentModel(
|
||||
config = {"material_type": seg.material_type} if seg.material_type else {}
|
||||
model = TemplateClipConfigModel(
|
||||
id=seg.id,
|
||||
template_id=seg.template_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
min_duration=seg.duration_min,
|
||||
max_duration=seg.duration_max,
|
||||
text_template="",
|
||||
material_requirements={},
|
||||
transition_effect="cut",
|
||||
config=config,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return segments
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == template_id).delete()
|
||||
"""删除两张表中的 segments 数据,返回删除总数。"""
|
||||
c1 = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
c2 = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
return c1 + c2
|
||||
|
||||
# ── Categories ──
|
||||
|
||||
@@ -366,6 +396,23 @@ class SQLAlchemyTemplateRepository:
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clip_config_to_segment(model: TemplateClipConfigModel) -> TemplateSegment:
|
||||
"""将 TemplateClipConfigModel 转换为 TemplateSegment 域实体。"""
|
||||
material_type = None
|
||||
if model.config and isinstance(model.config, dict):
|
||||
material_type = model.config.get("material_type")
|
||||
return TemplateSegment(
|
||||
id=model.id,
|
||||
template_id=model.template_id,
|
||||
segment_order=model.order,
|
||||
duration_min=model.min_duration,
|
||||
duration_max=model.max_duration,
|
||||
material_type=material_type,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
|
||||
return TemplateCategory(
|
||||
|
||||
@@ -256,6 +256,42 @@ def _wrap_title_text(
|
||||
return "\\N".join(wrapped_segments)
|
||||
|
||||
|
||||
def _parse_title_position(
|
||||
title_config: dict[str, Any],
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
) -> tuple[int, int] | None:
|
||||
"""解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。
|
||||
|
||||
要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height]
|
||||
闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。
|
||||
|
||||
Args:
|
||||
title_config: 标题配置 dict
|
||||
video_width: PlayResX(视频宽度像素)
|
||||
video_height: PlayResY(视频高度像素)
|
||||
|
||||
Returns:
|
||||
(x, y) 整数坐标,或 None 表示不使用自由位置
|
||||
"""
|
||||
if "pos_x" not in title_config or "pos_y" not in title_config:
|
||||
return None
|
||||
raw_x = title_config["pos_x"]
|
||||
raw_y = title_config["pos_y"]
|
||||
# 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True)
|
||||
# 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退
|
||||
if isinstance(raw_x, bool) or isinstance(raw_y, bool):
|
||||
return None
|
||||
if not isinstance(raw_x, int) or not isinstance(raw_y, int):
|
||||
return None
|
||||
x, y = raw_x, raw_y
|
||||
if video_width <= 0 or video_height <= 0:
|
||||
return None
|
||||
if not (0 <= x <= video_width and 0 <= y <= video_height):
|
||||
return None
|
||||
return (x, y)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
*,
|
||||
video_width: int,
|
||||
@@ -343,7 +379,16 @@ def build_ass_content(
|
||||
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
||||
)
|
||||
|
||||
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
|
||||
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
|
||||
# pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。
|
||||
# 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心),
|
||||
# Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退
|
||||
# position → alignment 三档逻辑,现有输出保持一字节不变。
|
||||
title_pos = _parse_title_position(title_config, video_width, video_height)
|
||||
|
||||
title_alignment = 5 if title_pos is not None else position_to_ass_alignment(
|
||||
title_config.get("position", "top")
|
||||
)
|
||||
|
||||
styles.append(
|
||||
build_ass_style(
|
||||
@@ -370,6 +415,10 @@ def build_ass_content(
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
|
||||
if title_pos is not None:
|
||||
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
|
||||
|
||||
events.append(
|
||||
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
||||
)
|
||||
|
||||
@@ -150,8 +150,11 @@ def build_xfade_filter_chain(
|
||||
else:
|
||||
first_input_dur = cumulative - total_transition
|
||||
|
||||
# 原始 offset 计算
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
# 正确的 offset 计算:offset 应相对于累积输出时长
|
||||
# offset = 累积输出中,转场开始的时间点
|
||||
# = first_input_dur - transition_duration
|
||||
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
|
||||
offset = max(0.0, first_input_dur - transition_duration)
|
||||
|
||||
# 安全钳制:offset + td 不能超过第一个输入的时长
|
||||
available = max(0.0, first_input_dur - offset)
|
||||
|
||||
@@ -119,6 +119,70 @@ class MediaKitClient:
|
||||
|
||||
return None
|
||||
|
||||
def detect_scene_changes(
|
||||
self,
|
||||
video_url: str,
|
||||
max_frames: int = 20,
|
||||
poll_interval: float = 2.0,
|
||||
max_poll_attempts: int = 30,
|
||||
) -> Optional[List[float]]:
|
||||
"""检测视频场景切换点,返回时间戳列表.
|
||||
|
||||
降级策略:
|
||||
1. 先尝试 SceneChange 策略
|
||||
2. SceneChange 失败(OOM等)→ 退回 TimeInterval(5秒间隔)
|
||||
3. MediaKit 不可用 → 返回 None
|
||||
|
||||
Returns:
|
||||
场景切换点时间戳列表,如 [0.0, 3.2, 7.8, 12.5]
|
||||
失败返回 None
|
||||
"""
|
||||
if not self.is_available:
|
||||
logger.warning("MediaKit 未配置,跳过场景检测")
|
||||
return None
|
||||
|
||||
# 策略1:尝试 SceneChange
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="SceneChange",
|
||||
max_frames=max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
# 策略2:SceneChange 失败 → 退回 TimeInterval(5秒间隔)
|
||||
if frames is None:
|
||||
logger.info("SceneChange 策略失败,降级为 TimeInterval(5秒间隔)")
|
||||
# 估算帧数:假设视频最长60秒,每5秒一帧
|
||||
ti_max_frames = max(max_frames, 12)
|
||||
frames = self.extract_frames(
|
||||
video_url=video_url,
|
||||
strategy="TimeInterval",
|
||||
max_frames=ti_max_frames,
|
||||
poll_interval=poll_interval,
|
||||
max_poll_attempts=max_poll_attempts,
|
||||
)
|
||||
|
||||
if frames is None:
|
||||
return None
|
||||
|
||||
# 从帧列表中提取 timestamp,排序
|
||||
timestamps = sorted({float(f.get("timestamp", 0.0)) for f in frames if "timestamp" in f})
|
||||
|
||||
if not timestamps:
|
||||
return None
|
||||
|
||||
# 始终在列表开头加 0.0(素材起始点)
|
||||
if timestamps[0] != 0.0:
|
||||
timestamps.insert(0, 0.0)
|
||||
|
||||
logger.info(
|
||||
"场景检测完成: video_url=%s scene_changes=%s",
|
||||
video_url[:80],
|
||||
timestamps,
|
||||
)
|
||||
return timestamps
|
||||
|
||||
def _submit_extract_task(
|
||||
self,
|
||||
video_url: str,
|
||||
|
||||
@@ -28,15 +28,12 @@ if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+Security,Type Check+Migration 合并为 Python
|
||||
# 与 pr_auto_scan.py 的 REQUIRED_CONTEXTS_APPROVE 保持一致
|
||||
"CI/CD Pipeline / Validate - Style (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Security (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
@@ -153,4 +150,4 @@ done
|
||||
|
||||
echo
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
exit 0
|
||||
exit 0
|
||||
@@ -19,6 +19,18 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash
|
||||
# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行
|
||||
if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then
|
||||
SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "")
|
||||
if [ -n "$SOURCE_HASH" ]; then
|
||||
echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}"
|
||||
else
|
||||
echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust"
|
||||
fi
|
||||
fi
|
||||
|
||||
BUILDER_NAME="ci-builder-persist"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
|
||||
@@ -31,6 +31,18 @@ for arg in "$@"; do
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
|
||||
done
|
||||
|
||||
# Web 镜像 cache bust:计算 apps/web/ 的 git tree hash
|
||||
# 当源码变化时 hash 变化,buildx 的 ARG 缓存键失效 → vite build 必定重新执行
|
||||
if [ "${DOCKERFILE##*/}" = "web.Dockerfile" ]; then
|
||||
SOURCE_HASH=$(git rev-parse HEAD:apps/web 2>/dev/null || echo "")
|
||||
if [ -n "$SOURCE_HASH" ]; then
|
||||
echo "Web cache bust: SOURCE_HASH=${SOURCE_HASH}"
|
||||
BUILD_ARGS="$BUILD_ARGS --build-arg SOURCE_HASH=${SOURCE_HASH}"
|
||||
else
|
||||
echo "⚠️ 无法计算 apps/web tree hash,跳过 cache bust"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 确保持久 builder 存在并使用(幂等)
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
echo "持久 builder 不存在,创建中..."
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# - 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
|
||||
|
||||
@@ -34,3 +35,26 @@ 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
|
||||
|
||||
|
||||
@@ -303,9 +303,10 @@ def main():
|
||||
"CI/CD Pipeline / CI Gate (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
# CI 优化后 job 名称(2026-08):Code Quality 拆分为 Style+Security,Type Check+Migration 合并为 Python
|
||||
"CI/CD Pipeline / Validate - Style (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Security (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
|
||||
@@ -197,8 +197,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
# 创建主数据库(xdist 模式下各 worker 会创建自己的数据库,主库作为 fallback)
|
||||
echo "创建主测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
@@ -304,8 +304,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
|
||||
# 清理所有以 CI_DB_NAME 开头的数据库(主库 + worker 库)
|
||||
PGPASSWORD="${SHARED_PG_PASSWORD}" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='${SHARED_PG_HOST}', port=${SHARED_PG_PORT}, user='${SHARED_PG_USER}', password='${SHARED_PG_PASSWORD}', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -344,4 +344,4 @@ python3 scripts/ci_coverage_summary.py
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "=== CI Integration Tests 全部通过 ✅ ==="
|
||||
echo "=== CI Integration Tests 全部通过 ✅ ==="
|
||||
+106
-47
@@ -1,77 +1,136 @@
|
||||
#!/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
|
||||
|
||||
# --- 安装依赖 ---
|
||||
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
|
||||
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
|
||||
pytest --version
|
||||
|
||||
# 双保险:确保numpy已安装
|
||||
echo "=== 验证 numpy 安装 ==="
|
||||
SKIP_NUMPY_TESTS=0
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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) ---
|
||||
# --- 增量测试选择(PR + push 均支持) ---
|
||||
UNIT_TEST_MODE="full"
|
||||
SELECTED_TEST_FILES="tests/unit"
|
||||
|
||||
if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
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
|
||||
echo ""
|
||||
echo "=== 增量测试选择 ==="
|
||||
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']")
|
||||
|
||||
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
|
||||
|
||||
echo "改动文件数: $(echo "$CHANGED_FILES" | grep -c . || echo 0)"
|
||||
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) 个测试文件"
|
||||
|
||||
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
|
||||
else
|
||||
echo "全量模式"
|
||||
echo "无法获取改动文件列表,使用全量模式"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -116,7 +175,7 @@ if [ "${GITHUB_EVENT_NAME:-}" = "pull_request" ] && [ -n "${GITHUB_TOKEN:-}" ];
|
||||
|
||||
PR_CODE_DIR="/tmp/pr-code-$$"
|
||||
mkdir -p "$PR_CODE_DIR"
|
||||
# 备份PR代码(含coverage.xml,diff-cover需要用到
|
||||
# 备份PR代码(含coverage.xml,diff-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
|
||||
|
||||
@@ -409,8 +409,8 @@ except:
|
||||
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
@@ -431,8 +431,8 @@ conn.close()
|
||||
# 清理数据库
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
|
||||
@@ -169,8 +169,8 @@ if [ "$USE_SHARED_PG" = "true" ]; then
|
||||
|
||||
echo "创建测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
@@ -185,8 +185,8 @@ conn.close()
|
||||
echo ""
|
||||
echo "清理测试数据库: $CI_DB_NAME"
|
||||
PGPASSWORD="$SHARED_PG_PASSWORD" python3 -c "
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
import psycopg
|
||||
conn = psycopg.connect(host='$SHARED_PG_HOST', port=$SHARED_PG_PORT, user='$SHARED_PG_USER', password='$SHARED_PG_PASSWORD', dbname='postgres')
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute(f'DROP DATABASE IF EXISTS \"$CI_DB_NAME\" WITH (FORCE)')
|
||||
|
||||
@@ -8,7 +8,13 @@ echo "=== CI Validate: 安全扫描 ==="
|
||||
# --- 密钥检测 ---
|
||||
echo ""
|
||||
echo "=== [1/4] Secret detection (detect-secrets) ==="
|
||||
python3 -m pip install -q detect-secrets
|
||||
python3 -m pip install -q --no-cache-dir detect-secrets || {
|
||||
echo "⚠️ detect-secrets install failed, retrying without cache..."
|
||||
python3 -m pip install -q --no-cache-dir --no-binary :all: detect-secrets || {
|
||||
echo "❌ detect-secrets install failed after retry"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
detect-secrets --version
|
||||
|
||||
detect-secrets scan \
|
||||
@@ -72,17 +78,27 @@ fi
|
||||
# --- Pip-audit 依赖漏洞扫描(仅告警)---
|
||||
echo ""
|
||||
echo "=== [3/4] Python dependency vulnerability scan (pip-audit, advisory only) ==="
|
||||
python3 -m pip install -q pip-audit
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
python3 -m pip install -q --no-cache-dir pip-audit || {
|
||||
echo "⚠️ pip-audit install failed (cache issue?), retrying..."
|
||||
python3 -m pip install -q --no-cache-dir pip-audit || {
|
||||
echo "⚠️ pip-audit unavailable, skipping dependency vulnerability scan (advisory)"
|
||||
pip-audit --version 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
if command -v pip-audit >/dev/null 2>&1 || python3 -m pip show pip-audit >/dev/null 2>&1; then
|
||||
pip-audit --version
|
||||
EXIT_CODE=0
|
||||
for req_file in requirements.txt requirements-base.txt requirements-dev.txt; do
|
||||
if [ -f "$req_file" ]; then
|
||||
echo "--- Scanning $req_file ---"
|
||||
pip-audit -r "$req_file" --desc on 2>&1 | head -40 || EXIT_CODE=$?
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
echo "pip-audit scan completed (advisory mode - warnings only, not blocking CI)"
|
||||
else
|
||||
echo "⚠️ pip-audit not available, skipping dependency vulnerability scan (advisory)"
|
||||
fi
|
||||
|
||||
# --- CI脚本语法校验 ---
|
||||
echo ""
|
||||
|
||||
@@ -24,7 +24,7 @@ echo "✅ Code formatting checks passed"
|
||||
echo ""
|
||||
echo "=== [3/3] Dead code detection (vulture, advisory only) ==="
|
||||
set +e
|
||||
python3 -m pip install -q vulture
|
||||
python3 -m pip install -q --no-cache-dir vulture || echo "⚠️ vulture install failed, skipping dead code detection"
|
||||
vulture --version
|
||||
echo "告警模式,不阻断CI。置信度>=90%建议尽快确认。"
|
||||
echo ""
|
||||
|
||||
@@ -397,7 +397,7 @@ fi
|
||||
echo "Stopping old containers..."
|
||||
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
|
||||
# Worker 需要更长时间(视频任务最长可能5分钟)
|
||||
docker stop -t 300 xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker stop -t 120 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
|
||||
|
||||
@@ -187,7 +187,7 @@ health_check() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 超时了
|
||||
|
||||
@@ -602,3 +602,148 @@ class TestFontsizeCompensation:
|
||||
result = build_ass_style("S", font_size=0)
|
||||
parts = result.split(",")
|
||||
assert int(parts[2]) >= 1
|
||||
|
||||
|
||||
# ── 标题自由位置拖拽(工单 #1405 方案 B)──────────────────────────────────────
|
||||
|
||||
|
||||
def _title_style_line(content: str) -> str:
|
||||
return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
|
||||
|
||||
def _title_dialogue_line(content: str) -> str:
|
||||
return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0]
|
||||
|
||||
|
||||
class TestTitleFreePosition:
|
||||
"""pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。"""
|
||||
|
||||
def _base_kwargs(self):
|
||||
return dict(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=8.0,
|
||||
title_text="测试标题",
|
||||
)
|
||||
|
||||
def test_valid_position_injects_pos_tag_and_alignment_5(self):
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300},
|
||||
)
|
||||
# Dialogue 文本前注入 {\pos(540,300)}
|
||||
dialogue = _title_dialogue_line(content)
|
||||
assert "{\\pos(540,300)}" in dialogue
|
||||
# TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "5"
|
||||
|
||||
def test_boundary_coordinates_zero_and_max_accepted(self):
|
||||
"""边界值 0 和 video_width/video_height 合法(闭区间)。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 0, "pos_y": 1920},
|
||||
)
|
||||
assert "{\\pos(0,1920)}" in _title_dialogue_line(content)
|
||||
|
||||
content2 = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 1080, "pos_y": 0},
|
||||
)
|
||||
assert "{\\pos(1080,0)}" in _title_dialogue_line(content2)
|
||||
|
||||
def test_no_coords_output_identical_to_before(self):
|
||||
"""不传坐标 → 输出与现有断言完全一致(回归保护)。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
# 无 \pos 注入
|
||||
assert "\\pos(" not in content
|
||||
# Alignment 走 position 映射(top → 8)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "8"
|
||||
|
||||
def test_out_of_bounds_falls_back(self):
|
||||
"""越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||||
)
|
||||
assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected"
|
||||
assert content == base, f"({pos_x},{pos_y}) output differs from fallback"
|
||||
|
||||
def test_invalid_coords_falls_back(self):
|
||||
"""非法类型坐标 → 回退原逻辑。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36},
|
||||
)
|
||||
for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y},
|
||||
)
|
||||
assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected"
|
||||
assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback"
|
||||
|
||||
def test_only_one_coord_falls_back(self):
|
||||
"""只传 pos_x 或 pos_y → 回退原逻辑。"""
|
||||
base = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36},
|
||||
)
|
||||
content_x = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36, "pos_x": 540},
|
||||
)
|
||||
content_y = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": "center", "size": 36, "pos_y": 300},
|
||||
)
|
||||
assert content_x == base
|
||||
assert content_y == base
|
||||
assert "\\pos(" not in content_x
|
||||
assert "\\pos(" not in content_y
|
||||
|
||||
def test_position_three_levels_unchanged_without_coords(self):
|
||||
"""无坐标时 top/center/bottom 三档 Alignment 输出不变。"""
|
||||
for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": position, "size": 36},
|
||||
)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == expected_align
|
||||
|
||||
def test_pos_overrides_position_alignment(self):
|
||||
"""有合法坐标时,无论 position 是什么,Alignment 都固定为 5。"""
|
||||
for position in ["top", "center", "bottom"]:
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200},
|
||||
)
|
||||
fields = [f.strip() for f in _title_style_line(content).split(",")]
|
||||
assert fields[18] == "5"
|
||||
assert "{\\pos(100,200)}" in _title_dialogue_line(content)
|
||||
|
||||
def test_subtitle_not_affected_by_pos(self):
|
||||
"""pos_x/pos_y 只影响 Title,Subtitle 输出不变。"""
|
||||
content = build_ass_content(
|
||||
**self._base_kwargs(),
|
||||
title_config={"pos_x": 540, "pos_y": 300},
|
||||
subtitle_text="配音字幕",
|
||||
subtitle_config={"position": "bottom", "size": 24},
|
||||
)
|
||||
sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||||
sub_fields = [f.strip() for f in sub_style.split(",")]
|
||||
assert sub_fields[18] == "2" # bottom
|
||||
sub_dialogue = [
|
||||
line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line
|
||||
][0]
|
||||
assert "\\pos(" not in sub_dialogue
|
||||
|
||||
@@ -197,14 +197,14 @@ class TestComputeAssetAvailability:
|
||||
|
||||
def test_large_gap_remains_usable(self):
|
||||
"""区间之间留有 ≥3s 空闲段(扩边后仍 ≥3s)→ usable=True。"""
|
||||
# [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
|
||||
# [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
|
||||
info = compute_asset_availability(
|
||||
_make_asset(
|
||||
duration=10.0,
|
||||
ranges=[
|
||||
_range(0.0, 2.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
_range(6.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
|
||||
_range(9.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):
|
||||
"""边缘间隙常量为 0.3s(与 MediaKit 冲突检测同口径)。"""
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
"""边缘间隙常量为 1.5s(与 MediaKit 冲突检测同口径)。"""
|
||||
assert SEGMENT_EDGE_GAP == 1.5
|
||||
|
||||
def test_domain_entity_metadata_dict_form(self):
|
||||
"""领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。
|
||||
@@ -395,11 +395,13 @@ class TestSmartMatchFiltersExhausted:
|
||||
# 返回的素材全部 usable=True
|
||||
assert all(item.usable for item in resp.items)
|
||||
|
||||
def test_all_exhausted_returns_empty(self):
|
||||
"""全部素材已用尽时返回空列表(不报错,前端显示空结果)。"""
|
||||
def test_all_exhausted_falls_back_to_all(self):
|
||||
"""全部素材已用尽时回退保留全部(不返回空——空结果会让前端回退全选,
|
||||
反而绕过评分排序;耗尽素材仍可走复用区间)。"""
|
||||
assets = [_exhausted_asset("a-ex-1"), _exhausted_asset("a-ex-2")]
|
||||
resp = self._call(assets)
|
||||
assert resp.items == []
|
||||
returned_ids = {item.id for item in resp.items}
|
||||
assert returned_ids == {"a-ex-1", "a-ex-2"}
|
||||
assert resp.total_candidates == 2
|
||||
|
||||
def test_fresh_assets_all_returned(self):
|
||||
|
||||
@@ -124,9 +124,9 @@ def _ranges(db, aid="a1"):
|
||||
|
||||
|
||||
def test_config_constants():
|
||||
assert MAX_RANGE_USE_COUNT == 3
|
||||
assert REUSE_RATIO_LIMIT == 0.15
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
assert MAX_RANGE_USE_COUNT == 2
|
||||
assert REUSE_RATIO_LIMIT == 0.10
|
||||
assert SEGMENT_EDGE_GAP == 1.5
|
||||
|
||||
|
||||
# ── 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 达到上限(3)的区间不再参与复用;全部达上限返回 None。"""
|
||||
"""use_count 达到上限(2)的区间不再参与复用;全部达上限返回 None。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"start": 0.0, "end": 10.0, "use_count": 3, "last_used_at": "2026-01-01T00:00:00"},
|
||||
{"start": 0.0, "end": 10.0, "use_count": 2, "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_fourth_use_rejected(patched_model):
|
||||
"""同区间复用第 4 次被拒绝:use_count=2 的可复用,use_count=3 的不可复用。"""
|
||||
def test_find_reusable_third_use_rejected(patched_model):
|
||||
"""同区间复用第 3 次被拒绝:use_count=1 的可复用,use_count=2 的不可复用。"""
|
||||
models = {
|
||||
"a1": FakeModel(
|
||||
"a1",
|
||||
{
|
||||
"used_time_ranges": [
|
||||
{"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"},
|
||||
{"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"},
|
||||
]
|
||||
},
|
||||
)
|
||||
}
|
||||
db = _db(models)
|
||||
result = find_reusable_range(db, "a1", 5.0, 30.0)
|
||||
# 只能选 use_count=2 的区间(start=0),不能选 use_count=3 的(虽然它更老)
|
||||
# 只能选 use_count=1 的区间(start=0),不能选 use_count=2 的(虽然它更老)
|
||||
assert result is not None and result[0] == 0.0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""测试 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
|
||||
@@ -56,9 +56,10 @@ class TestComputeDuplicateRate:
|
||||
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.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)
|
||||
|
||||
assert rate == 0.0
|
||||
@@ -73,7 +74,6 @@ 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,10 +83,12 @@ class TestComputeDuplicateRate:
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
# Mock the session.query chain
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
# 链式 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
|
||||
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
||||
|
||||
assert rate == 100.0
|
||||
@@ -113,9 +115,10 @@ class TestComputeDuplicateRate:
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = existing
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
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)
|
||||
|
||||
# hamming distance = 2, similarity = (1 - 2/64) * 100 = 96.875
|
||||
@@ -140,9 +143,10 @@ class TestComputeDuplicateRate:
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.return_value = self_video
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
mock_model
|
||||
]
|
||||
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)
|
||||
|
||||
assert rate == 0.0
|
||||
@@ -172,15 +176,89 @@ class TestComputeDuplicateRate:
|
||||
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
||||
mock_repo = MockRepo.return_value
|
||||
mock_repo._to_domain.side_effect = [existing1, existing2]
|
||||
session.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value = query_mock
|
||||
query_mock.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."""
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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
|
||||
@@ -58,6 +58,10 @@ 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
|
||||
|
||||
|
||||
@@ -142,11 +146,12 @@ class TestEditorClipsBySegments:
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 4
|
||||
|
||||
# 验证轮询分配: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"
|
||||
# 验证均衡分配(贪心策略保证):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
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_orders_start_at_zero(self, mock_storage):
|
||||
@@ -413,8 +418,13 @@ class TestEditorClipsDurationAndStartTime:
|
||||
|
||||
assert mock_calc.call_count == 2
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert clips_data[0]["start_time"] == 12.5
|
||||
assert clips_data[1]["start_time"] == 18.0
|
||||
# clips_data 按 order 排序,但分配顺序因 shuffle 而随机,
|
||||
# 因此只验证两个 start_time 值都存在
|
||||
start_times = {c["start_time"] for c in clips_data}
|
||||
assert start_times == {12.5, 18.0}
|
||||
# 验证 order 仍然有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == sorted(orders)
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_asset_durations_deduped(self, mock_storage):
|
||||
@@ -631,7 +641,8 @@ class TestReuseRatioGate:
|
||||
clips_data = _get_clips_data_from_call(mock_plan_svc)
|
||||
assert len(clips_data) == 13
|
||||
# 1 个复用片段,占比 1/13 ≈ 7.7% ≤ 15%
|
||||
assert reused.get("a1", 0.0) == 5.0
|
||||
# 转场补偿: 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
|
||||
|
||||
@patch("app.api.routes.templates_editor.clips.get_storage_service")
|
||||
def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""_get_template_segments 回退路径测试.
|
||||
|
||||
验证三级回退链:
|
||||
1. 新模板系统(tpl_svc.list_clip_configs)正常 → 直接返回
|
||||
2. 新模板系统主表不存在(ValueError)→ 直接查 template_clip_configs 表兜底
|
||||
3. 直接查表也失败 → 回退旧模板系统(template_segments)
|
||||
4. 全部失败 → 返回空列表
|
||||
|
||||
覆盖 P0 修复:自建模板在 edit_templates 主表不存在但在 template_clip_configs 有记录时,
|
||||
from-assets 流程不再 400。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock
|
||||
|
||||
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"))
|
||||
|
||||
from app.api.routes.templates_editor.clips import _get_template_segments
|
||||
|
||||
TEST_TEMPLATE_ID = "tmpl-orphan-001"
|
||||
DEFAULT_DUR = 5.0 # _DEFAULT_EDITOR_CLIP_DURATION
|
||||
|
||||
|
||||
def _make_clip_config(order: int, min_dur: float = 3.0, max_dur: float = 8.0):
|
||||
"""构造 mock TemplateClipConfig 领域实体."""
|
||||
cc = MagicMock()
|
||||
cc.order = order
|
||||
cc.min_duration = min_dur
|
||||
cc.max_duration = max_dur
|
||||
return cc
|
||||
|
||||
|
||||
def _make_old_segment(segment_order: int, dur_min: float = 4.0, dur_max: float = 7.0):
|
||||
"""构造 mock 旧 TemplateSegment."""
|
||||
s = MagicMock()
|
||||
s.segment_order = segment_order
|
||||
s.duration_min = dur_min
|
||||
s.duration_max = dur_max
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTemplateSegmentsFallback:
|
||||
"""_get_template_segments 三级回退链."""
|
||||
|
||||
def test_new_system_works(self):
|
||||
"""路径1:新模板系统正常返回 → 直接使用."""
|
||||
configs = [_make_clip_config(0, 2.0, 6.0), _make_clip_config(1, 3.0, 9.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0] == (0, 2.0, 6.0)
|
||||
assert result[1] == (1, 3.0, 9.0)
|
||||
tpl_svc.list_clip_configs.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_succeeds(self):
|
||||
"""路径2(P0修复):主表不存在 ValueError → 直接查表成功.
|
||||
|
||||
模拟自建模板在 edit_templates 主表已删除/不存在,
|
||||
但 template_clip_configs 表有记录。
|
||||
"""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError(f"模板不存在: {TEST_TEMPLATE_ID}")
|
||||
db = MagicMock()
|
||||
|
||||
# Mock SQLAlchemyTemplateClipConfigRepository
|
||||
direct_configs = [
|
||||
_make_clip_config(0, 2.0, 5.0),
|
||||
_make_clip_config(1, 3.0, 7.0),
|
||||
_make_clip_config(2, 4.0, 8.0),
|
||||
]
|
||||
with (
|
||||
__import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls,
|
||||
):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
assert result[1] == (1, 3.0, 7.0)
|
||||
assert result[2] == (2, 4.0, 8.0)
|
||||
mock_repo.list_by_template.assert_called_once_with(TEST_TEMPLATE_ID)
|
||||
|
||||
def test_main_table_missing_direct_query_empty_falls_to_old(self):
|
||||
"""路径2→3:主表不存在 + 直接查表为空 → 回退旧系统."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
old_segments = [_make_old_segment(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [] # 新表也没记录
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = old_segments
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_all_fail_returns_empty(self):
|
||||
"""路径4:三级全部失败 → 返回空列表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.side_effect = Exception("DB error")
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository"
|
||||
) as mock_old_cls:
|
||||
mock_old = MagicMock()
|
||||
mock_old.list_segments.return_value = [] # 旧表也空
|
||||
mock_old_cls.return_value = mock_old
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_direct_query_sorts_by_order(self):
|
||||
"""直接查表返回的结果按 order 排序."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
# 故意乱序
|
||||
configs = [
|
||||
_make_clip_config(2, 5.0, 10.0),
|
||||
_make_clip_config(0, 2.0, 4.0),
|
||||
_make_clip_config(1, 3.0, 6.0),
|
||||
]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert [r[0] for r in result] == [0, 1, 2]
|
||||
assert result[0] == (0, 2.0, 4.0)
|
||||
assert result[1] == (1, 3.0, 6.0)
|
||||
assert result[2] == (2, 5.0, 10.0)
|
||||
|
||||
def test_direct_query_handles_none_durations(self):
|
||||
"""直接查表时 min/max_duration 为 None → 使用默认值."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.side_effect = ValueError("模板不存在")
|
||||
db = MagicMock()
|
||||
|
||||
cc = MagicMock()
|
||||
cc.order = 0
|
||||
cc.min_duration = None
|
||||
cc.max_duration = None
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = [cc]
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
assert len(result) == 1
|
||||
# None → default (5.0), max(None or None) → default (5.0)
|
||||
assert result[0] == (0, DEFAULT_DUR, DEFAULT_DUR)
|
||||
|
||||
def test_new_system_returns_empty_tries_direct(self):
|
||||
"""新模板系统返回空列表(非异常)→ 继续尝试直接查表."""
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = [] # 空列表,非异常
|
||||
db = MagicMock()
|
||||
|
||||
direct_configs = [_make_clip_config(0, 3.0, 6.0)]
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_template.return_value = direct_configs
|
||||
mock_repo_cls.return_value = mock_repo
|
||||
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
|
||||
# 新系统返回空 → 不走 except → 但也没 return → 继续往下走
|
||||
# 直接查表有数据 → 返回
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 3.0, 6.0)
|
||||
|
||||
def test_existing_template_unaffected(self):
|
||||
"""正常模板(主表存在)行为不变."""
|
||||
configs = [_make_clip_config(0, 2.0, 5.0)]
|
||||
tpl_svc = MagicMock()
|
||||
tpl_svc.list_clip_configs.return_value = configs
|
||||
db = MagicMock()
|
||||
|
||||
with __import__("unittest.mock", fromlist=["patch"]).patch(
|
||||
"app.api.routes.templates_editor.clips.SQLAlchemyTemplateClipConfigRepository"
|
||||
) as mock_repo_cls:
|
||||
result = _get_template_segments(TEST_TEMPLATE_ID, tpl_svc, db)
|
||||
# 直接查表不应被调用(新系统已返回)
|
||||
mock_repo_cls.assert_not_called()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == (0, 2.0, 5.0)
|
||||
@@ -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]):0.3s 扩边内 → 冲突。"""
|
||||
"""首尾紧贴(推荐 15 开始,已用 [10,15]):1.5s 扩边内 → 冲突。"""
|
||||
assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 15.0)]) is True
|
||||
|
||||
def test_gap_within_edge_gap_conflicts(self):
|
||||
"""间隔 0.2s(< 0.3s 边缘间隙)→ 冲突。"""
|
||||
"""间隔 0.2s(< 1.5s 边缘间隙)→ 冲突。"""
|
||||
assert _recommended_time_conflicts(15.2, 5.0, [(10.0, 15.0)]) is True
|
||||
|
||||
def test_gap_beyond_edge_gap_no_conflict(self):
|
||||
"""间隔 0.5s(> 0.3s 边缘间隙)→ 不冲突。"""
|
||||
assert _recommended_time_conflicts(15.5, 5.0, [(10.0, 15.0)]) is False
|
||||
"""间隔 2.0s(> 1.5s 边缘间隙)→ 不冲突。"""
|
||||
assert _recommended_time_conflicts(17.0, 5.0, [(10.0, 15.0)]) is False
|
||||
|
||||
def test_far_apart_no_conflict(self):
|
||||
"""相隔很远 → 不冲突。"""
|
||||
@@ -54,7 +54,9 @@ 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.0, 2.0, used) is False
|
||||
assert (
|
||||
_recommended_time_conflicts(6.5, 2.0, used) is False
|
||||
) # range [6.5,8.5], just outside all expanded used ranges
|
||||
|
||||
def test_custom_edge_gap(self):
|
||||
"""edge_gap 可配置:gap=0 时紧贴不冲突(端点相接不算重叠)。"""
|
||||
@@ -64,5 +66,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):
|
||||
"""默认边缘间隙常量为 0.3s(配置常量)。"""
|
||||
assert SEGMENT_EDGE_GAP == 0.3
|
||||
"""默认边缘间隙常量为 1.5s(配置常量)。"""
|
||||
assert SEGMENT_EDGE_GAP == 1.5
|
||||
|
||||
@@ -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
|
||||
|
||||
# 新语义:默认 0.3s 边缘间隙扩边,推荐 [10, 15] 与已用 [0, 10] 首尾相接
|
||||
# 新语义:默认 1.5s 边缘间隙扩边,推荐 [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
|
||||
# 间隙大于边缘间隙(0.5 > 0.3)→ 不冲突
|
||||
assert _recommended_time_conflicts(10.5, 5.0, [(0.0, 10.0)]) is False
|
||||
# 间隙大于边缘间隙(2.0 > 1.5)→ 不冲突
|
||||
assert _recommended_time_conflicts(12.0, 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] 首尾相接:0.3s 扩边内 → 冲突
|
||||
# 推荐 [15, 20] 与 [10, 15] 首尾相接:1.5s 扩边内 → 冲突
|
||||
assert _recommended_time_conflicts(15.0, 5.0, used) is True
|
||||
# 空闲段 [5.3, 9.7] 长 4.4s:推荐 [5.5, 9.5](dur=4)与三区间扩边均不接触
|
||||
assert _recommended_time_conflicts(5.5, 4.0, used) is False
|
||||
# 空闲段 (6.5, 8.5) 长 2.0s:推荐 [6.6, 8.4](dur=1.8)与三区间扩边均不接触
|
||||
assert _recommended_time_conflicts(6.6, 1.8, used) is False
|
||||
|
||||
|
||||
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
|
||||
@@ -347,6 +347,10 @@ 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
|
||||
|
||||
|
||||
@@ -445,8 +449,8 @@ class TestFromAssetsByTemplateSegments:
|
||||
assert 3.0 <= clips_data[0]["duration"] <= 5.0
|
||||
assert 4.0 <= clips_data[1]["duration"] <= 8.0
|
||||
|
||||
def test_assets_round_robin_assignment(self):
|
||||
"""素材按片段顺序轮询分配。"""
|
||||
def test_assets_balanced_assignment(self):
|
||||
"""素材按使用次数贪心分配(使用少的优先),保证均衡使用。"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
@@ -475,7 +479,11 @@ class TestFromAssetsByTemplateSegments:
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
assert asset_ids == ["a1", "a2", "a1", "a2"]
|
||||
# 贪心分配保证均衡:2个素材分4个片段,每个素材恰好使用2次
|
||||
from collections import Counter
|
||||
|
||||
counts = Counter(asset_ids)
|
||||
assert counts["a1"] == 2 and counts["a2"] == 2
|
||||
|
||||
def test_orders_start_from_zero(self):
|
||||
"""片段 order 从 0 开始递增。"""
|
||||
|
||||
@@ -908,6 +908,10 @@ 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)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Tests for scene-change smart frame selection + random shuffle of segment processing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.api.app.api.routes.templates_editor.clips import (
|
||||
_build_scene_segments,
|
||||
_pick_start_in_scene_segment,
|
||||
)
|
||||
from packages.shared.mediakit_client import MediaKitClient
|
||||
|
||||
# ── Part 1: Random shuffle tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRandomShuffle:
|
||||
"""验证 segments 处理顺序随机打乱逻辑."""
|
||||
|
||||
def test_same_segments_produce_different_asset_orders(self):
|
||||
"""同一批 segments 多次处理,asset 分配顺序有变化.
|
||||
|
||||
模拟打乱后的处理顺序,验证多次运行中 asset_id 分配顺序
|
||||
存在差异(概率性验证,运行 50 次应该至少出现 2 种排列)。
|
||||
"""
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||||
asset_ids = ["A", "B", "C", "D"]
|
||||
|
||||
observed_orders: list[tuple] = set()
|
||||
|
||||
for _ in range(50):
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
order_tuple = tuple(shuffled_indices)
|
||||
observed_orders.add(order_tuple)
|
||||
|
||||
# 50 次打乱,4! = 24 种排列,应出现多种不同排列
|
||||
assert len(observed_orders) > 1, "打乱应该产生多种不同顺序"
|
||||
|
||||
def test_clips_data_order_always_sorted(self):
|
||||
"""clips_data 按 order 排序后始终有序.
|
||||
|
||||
模拟打乱处理后 clips_data 按 order 排序,验证最终 order 为 [0,1,2,3]。
|
||||
"""
|
||||
segments = [(0, 3.0, 5.0), (1, 4.0, 6.0), (2, 3.0, 5.0), (3, 4.0, 6.0)]
|
||||
|
||||
for _ in range(20):
|
||||
shuffled_indices = list(range(len(segments)))
|
||||
random.shuffle(shuffled_indices)
|
||||
|
||||
# 模拟构建 clips_data(用 _seg_order 作为 order)
|
||||
clips_data = []
|
||||
for idx in shuffled_indices:
|
||||
seg_order, _, _ = segments[idx]
|
||||
clips_data.append({"order": seg_order, "asset_id": f"asset_{idx}"})
|
||||
|
||||
# 按 order 排序
|
||||
clips_data.sort(key=lambda c: c["order"])
|
||||
|
||||
# 验证 order 始终有序
|
||||
orders = [c["order"] for c in clips_data]
|
||||
assert orders == [0, 1, 2, 3], f"排序后 order 应为 [0,1,2,3],实际为 {orders}"
|
||||
|
||||
|
||||
# ── Part 2: detect_scene_changes tests ────────────────────────────────────
|
||||
|
||||
|
||||
class TestDetectSceneChanges:
|
||||
"""验证 MediaKitClient.detect_scene_changes 方法."""
|
||||
|
||||
def _make_client(self) -> MediaKitClient:
|
||||
"""创建一个可用的 MediaKitClient(mock 配置)."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "test-key"
|
||||
mock_settings.return_value.mediakit_base_url = "http://test"
|
||||
mock_settings.return_value.mediakit_timeout = 30
|
||||
client = MediaKitClient()
|
||||
return client
|
||||
|
||||
def test_scene_change_success(self):
|
||||
"""SceneChange 策略成功返回时间戳列表."""
|
||||
client = self._make_client()
|
||||
|
||||
mock_frames = [
|
||||
{"image_url": "url1", "timestamp": 0.0},
|
||||
{"image_url": "url2", "timestamp": 3.2},
|
||||
{"image_url": "url3", "timestamp": 7.8},
|
||||
{"image_url": "url4", "timestamp": 12.5},
|
||||
]
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0 # 始终以 0.0 开头
|
||||
assert 3.2 in result
|
||||
assert 7.8 in result
|
||||
assert 12.5 in result
|
||||
assert result == sorted(result) # 应已排序
|
||||
|
||||
def test_scene_change_fallback_to_time_interval(self):
|
||||
"""SceneChange 失败降级到 TimeInterval 策略."""
|
||||
client = self._make_client()
|
||||
|
||||
# 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果
|
||||
fallback_frames = [
|
||||
{"image_url": "url1", "timestamp": 0.0},
|
||||
{"image_url": "url2", "timestamp": 5.0},
|
||||
{"image_url": "url3", "timestamp": 10.0},
|
||||
]
|
||||
|
||||
call_count = 0
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# 第一次 SceneChange 失败
|
||||
return None
|
||||
else:
|
||||
# 第二次 TimeInterval 成功
|
||||
assert kwargs.get("strategy") == "TimeInterval"
|
||||
return fallback_frames
|
||||
|
||||
with patch.object(client, "extract_frames", side_effect=side_effect):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0
|
||||
assert 5.0 in result
|
||||
assert 10.0 in result
|
||||
|
||||
def test_mediakit_not_available_returns_none(self):
|
||||
"""MediaKit 不可用时返回 None."""
|
||||
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
||||
mock_settings.return_value.mediakit_api_key = "" # 未配置
|
||||
mock_settings.return_value.mediakit_base_url = "http://test"
|
||||
mock_settings.return_value.mediakit_timeout = 30
|
||||
client = MediaKitClient()
|
||||
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
assert result is None
|
||||
|
||||
def test_both_strategies_fail_returns_none(self):
|
||||
"""SceneChange 和 TimeInterval 都失败时返回 None."""
|
||||
client = self._make_client()
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=None):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_prepends_zero_if_not_present(self):
|
||||
"""若帧列表中不包含 0.0,自动在开头添加."""
|
||||
client = self._make_client()
|
||||
|
||||
# 帧列表中没有 timestamp=0.0
|
||||
mock_frames = [
|
||||
{"image_url": "url1", "timestamp": 2.0},
|
||||
{"image_url": "url2", "timestamp": 5.5},
|
||||
]
|
||||
|
||||
with patch.object(client, "extract_frames", return_value=mock_frames):
|
||||
result = client.detect_scene_changes("https://example.com/video.mp4")
|
||||
|
||||
assert result is not None
|
||||
assert result[0] == 0.0
|
||||
assert 2.0 in result
|
||||
assert 5.5 in result
|
||||
|
||||
|
||||
# ── Part 2.2: Scene segment building and assignment ───────────────────────
|
||||
|
||||
|
||||
class TestSceneSegments:
|
||||
"""验证镜头段构建和分配逻辑."""
|
||||
|
||||
def test_build_scene_segments(self):
|
||||
"""从场景切换点正确构建镜头段."""
|
||||
scene_changes = [0.0, 3.2, 7.8, 12.5]
|
||||
asset_duration = 15.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
|
||||
assert len(segments) == 4
|
||||
assert segments[0] == (0.0, 3.2)
|
||||
assert segments[1] == (3.2, 7.8)
|
||||
assert segments[2] == (7.8, 12.5)
|
||||
assert segments[3] == (12.5, 15.0)
|
||||
|
||||
def test_build_scene_segments_filters_short(self):
|
||||
"""过滤掉过短的镜头段(< 0.5秒)."""
|
||||
scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0]
|
||||
asset_duration = 12.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
|
||||
# (0.0, 0.1) 长度 0.1 < 0.5 → 过滤
|
||||
# (0.1, 5.0) → 保留
|
||||
# (5.0, 5.3) 长度 0.3 < 0.5 → 过滤
|
||||
# (5.3, 10.0) → 保留
|
||||
# (10.0, 12.0) → 保留
|
||||
assert len(segments) == 3
|
||||
assert segments[0] == (0.1, 5.0)
|
||||
assert segments[1] == (5.3, 10.0)
|
||||
assert segments[2] == (10.0, 12.0)
|
||||
|
||||
def test_pick_start_in_segment(self):
|
||||
"""在镜头段内随机选取起始时间."""
|
||||
seg_start = 3.0
|
||||
seg_end = 8.0
|
||||
clip_duration = 2.0
|
||||
|
||||
starts = set()
|
||||
for _ in range(100):
|
||||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
assert start is not None
|
||||
assert seg_start <= start <= seg_end - clip_duration
|
||||
starts.add(round(start, 2))
|
||||
|
||||
# 应该有多个不同的起始时间
|
||||
assert len(starts) > 1
|
||||
|
||||
def test_pick_start_segment_too_short(self):
|
||||
"""镜头段太短无法容纳片段时返回 None."""
|
||||
result = _pick_start_in_scene_segment(0.0, 1.0, 2.0)
|
||||
assert result is None
|
||||
|
||||
def test_different_clips_from_different_scenes(self):
|
||||
"""不同片段应来自不同的镜头段(模拟分配逻辑)."""
|
||||
scene_changes = [0.0, 5.0, 10.0, 15.0]
|
||||
asset_duration = 18.0
|
||||
clip_duration = 3.0
|
||||
|
||||
segments = _build_scene_segments(scene_changes, asset_duration)
|
||||
assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18)
|
||||
|
||||
# 模拟 3 个片段从不同镜头段取点
|
||||
scene_pool = list(segments)
|
||||
assigned_starts = []
|
||||
|
||||
for _ in range(3):
|
||||
if not scene_pool:
|
||||
break
|
||||
seg_start, seg_end = scene_pool.pop(0)
|
||||
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||||
assert start is not None
|
||||
assigned_starts.append(start)
|
||||
|
||||
# 3 个片段分别从 3 个不同镜头段中选取
|
||||
assert len(assigned_starts) == 3
|
||||
# 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12]
|
||||
assert 0.0 <= assigned_starts[0] <= 2.0
|
||||
assert 5.0 <= assigned_starts[1] <= 7.0
|
||||
assert 10.0 <= assigned_starts[2] <= 12.0
|
||||
@@ -0,0 +1,258 @@
|
||||
"""smart-match 过滤回退策略测试:余量过滤/高频排除导致结果集为空时必须回退。
|
||||
|
||||
线上事故:路由旧实现先 smart_select_assets(limit=N) 截取,再对这 N 条做
|
||||
usable / 高频过滤,过滤后不回补——排名靠前素材全部被排除时返回空 items,
|
||||
前端回退全选。修复后过滤全部前置,且每级过滤后为空/不足时回退保留。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(ROOT / "packages"))
|
||||
|
||||
from app.api.routes.assets import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
from packages.domain import (
|
||||
Asset,
|
||||
AssetLibrary,
|
||||
AssetLibraryKind,
|
||||
AssetStatus,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class _StubProjectRepo:
|
||||
def __init__(self, projects):
|
||||
self._projects = projects
|
||||
|
||||
def find_by_id(self, pid):
|
||||
return self._projects.get(pid)
|
||||
|
||||
|
||||
class _StubAssetLibraryRepo:
|
||||
def __init__(self, libraries):
|
||||
self._libraries = libraries
|
||||
|
||||
def get(self, lid):
|
||||
return self._libraries.get(lid)
|
||||
|
||||
|
||||
class _StubAssetRepo:
|
||||
"""模拟仓储;session 属性供 get_asset_recent_use_counts 使用(测试中会被 patch)。"""
|
||||
|
||||
def __init__(self, assets):
|
||||
self._assets = assets
|
||||
self.session = MagicMock(name="stub-session")
|
||||
|
||||
def find_by_library(self, lid, skip=0, limit=100, status=None):
|
||||
result = [a for a in self._assets if a.library_id == lid]
|
||||
if status:
|
||||
result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
|
||||
return result[skip : skip + limit]
|
||||
|
||||
def find_by_library_and_file_type(self, lid, file_type, skip=0, limit=100, status=None):
|
||||
result = [a for a in self._assets if a.library_id == lid and a.file_type == file_type]
|
||||
if status:
|
||||
result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
|
||||
return result[skip : skip + limit]
|
||||
|
||||
|
||||
def _make_app(asset_repo, lib_repo, proj_repo):
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/assets")
|
||||
fake_user = MagicMock()
|
||||
fake_user.user = User(id="user-1", email="test@test.com", display_name="Test")
|
||||
app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=fake_user.user)
|
||||
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[get_asset_library_repository] = lambda: lib_repo
|
||||
app.dependency_overrides[get_project_repository] = lambda: proj_repo
|
||||
app.dependency_overrides[get_storage_service] = lambda: MagicMock()
|
||||
return app
|
||||
|
||||
|
||||
def _library():
|
||||
return AssetLibrary(id="lib-1", project_id="proj-1", name="Videos", kind=AssetLibraryKind.VIDEO)
|
||||
|
||||
|
||||
def _exhausted_ranges(duration=15.0):
|
||||
"""构造 used_time_ranges:整片覆盖 + 每区间 use_count 达上限 3 → usable=False。"""
|
||||
return [
|
||||
{"start": 0.0, "end": duration, "use_count": 3, "plan_id": "p1"},
|
||||
]
|
||||
|
||||
|
||||
def _video_asset(name, duration=15.0, quality=90, used_ranges=None):
|
||||
meta = {"used_time_ranges": used_ranges} if used_ranges is not None else {}
|
||||
return Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"key-{name}",
|
||||
mime_type="video/mp4",
|
||||
metadata=meta,
|
||||
quality_score=quality,
|
||||
duration=duration,
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
|
||||
|
||||
class TestSmartMatchAvailabilityFallback:
|
||||
"""余量过滤回退:全部素材 usable=False 时不返回空。"""
|
||||
|
||||
def test_all_exhausted_returns_assets_instead_of_empty(self):
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("v1.mp4", used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("v2.mp4", used_ranges=_exhausted_ranges(25)),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
# 回退:保留全部候选,不返回空
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total_candidates"] == 2
|
||||
|
||||
def test_mixed_exhausted_and_fresh_excludes_exhausted(self):
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("exhausted.mp4", quality=99, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("fresh.mp4", quality=50, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
assert "fresh.mp4" in names
|
||||
assert "exhausted.mp4" not in names
|
||||
|
||||
def test_limit_backfills_from_lower_ranked_when_top_exhausted(self):
|
||||
"""limit=1 且排名第一的素材耗尽时,必须回补排名靠后的可用素材,不返回空。"""
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("second-fresh.mp4", quality=40, used_ranges=None),
|
||||
_video_asset("third-fresh.mp4", quality=30, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 1})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 1
|
||||
# 不能是空,也不能是耗尽的高分素材
|
||||
assert data["items"][0]["name"] == "second-fresh.mp4"
|
||||
|
||||
|
||||
class TestSmartMatchHighFreqFallback:
|
||||
"""高频排除回退:排除后为空/不足 limit 时保留全部可用素材。"""
|
||||
|
||||
def test_all_high_freq_keeps_all(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)]
|
||||
repo = _StubAssetRepo(assets)
|
||||
|
||||
# 全部素材在最近 5 个视频中使用 5 次(> 3)
|
||||
fake_counts = {assets[0].id: 5, assets[1].id: 5}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
# 全部高频 → 回退保留全部
|
||||
assert len(data["items"]) == 2
|
||||
|
||||
def test_high_freq_partial_exclusion_with_enough_remaining(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("hot.mp4", quality=99),
|
||||
_video_asset("cool1.mp4", quality=80),
|
||||
_video_asset("cool2.mp4", quality=70),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
fake_counts = {assets[0].id: 9, assets[1].id: 1, assets[2].id: 0}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
assert "hot.mp4" not in names
|
||||
assert set(names) == {"cool1.mp4", "cool2.mp4"}
|
||||
|
||||
def test_high_freq_insufficient_for_limit_keeps_all(self, monkeypatch):
|
||||
"""3 个素材、limit=5、2 个高频 → 剩余 1 < limit → 保留全部。"""
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("hot1.mp4", quality=99),
|
||||
_video_asset("hot2.mp4", quality=98),
|
||||
_video_asset("cool.mp4", quality=50),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
fake_counts = {assets[0].id: 8, assets[1].id: 7, assets[2].id: 0}
|
||||
monkeypatch.setattr(
|
||||
routes_mod, "get_asset_recent_use_counts", lambda db, asset_ids, recent_video_count=5: fake_counts
|
||||
)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 5})
|
||||
assert resp.status_code == 200, resp.text
|
||||
names = [item["name"] for item in resp.json()["items"]]
|
||||
# 剩余 1 < limit=5 → 回退保留全部 3 条
|
||||
assert set(names) == {"hot1.mp4", "hot2.mp4", "cool.mp4"}
|
||||
|
||||
def test_high_freq_query_failure_skips_exclusion(self, monkeypatch):
|
||||
import app.api.routes.assets as routes_mod
|
||||
|
||||
def _boom(db, asset_ids, recent_video_count=5):
|
||||
raise RuntimeError("DB down")
|
||||
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [_video_asset("v1.mp4", quality=90), _video_asset("v2.mp4", quality=80)]
|
||||
repo = _StubAssetRepo(assets)
|
||||
monkeypatch.setattr(routes_mod, "get_asset_recent_use_counts", _boom)
|
||||
|
||||
app = _make_app(repo, _StubAssetLibraryRepo({"lib-1": _library()}), _StubProjectRepo({"proj-1": project}))
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(resp.json()["items"]) == 2
|
||||
@@ -0,0 +1,324 @@
|
||||
"""测试 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_fresh(smart_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()
|
||||
@@ -0,0 +1,209 @@
|
||||
"""统一模板 segments 数据源单元测试。
|
||||
|
||||
验证 template_repository 从 template_clip_configs 读取 segments,
|
||||
写入走 template_clip_configs,回退兼容 template_segments。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.template import Template, TemplateSegment
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
s = Session()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def repo(session):
|
||||
return SQLAlchemyTemplateRepository(session)
|
||||
|
||||
|
||||
def _make_template(template_id=None, user_id="u1", name="测试模板", mode="one_take"):
|
||||
tid = template_id or str(uuid.uuid4())
|
||||
return Template(
|
||||
id=tid,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
mode=mode,
|
||||
category="",
|
||||
tags=[],
|
||||
estimated_duration=30.0,
|
||||
is_active=True,
|
||||
segments=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_segment(template_id, order=1, material_type=None):
|
||||
return TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=template_id,
|
||||
segment_order=order,
|
||||
duration_min=5.0,
|
||||
duration_max=10.0,
|
||||
material_type=material_type,
|
||||
)
|
||||
|
||||
|
||||
class TestCreateSegments:
|
||||
def test_writes_to_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).all()
|
||||
assert len(clips) == 1
|
||||
assert clips[0].clip_type == "main"
|
||||
assert clips[0].order == 1
|
||||
assert clips[0].min_duration == 5.0
|
||||
|
||||
def test_material_type_stored_in_config(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
clip = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).first()
|
||||
assert clip.config["material_type"] == "voiceover"
|
||||
|
||||
|
||||
class TestListSegments:
|
||||
def test_reads_from_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].material_type == "voiceover"
|
||||
|
||||
def test_fallback_to_old_table(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=tpl.id,
|
||||
segment_order=1,
|
||||
duration_min=3.0,
|
||||
duration_max=8.0,
|
||||
material_type="场景",
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].material_type == "场景"
|
||||
|
||||
def test_clip_configs_takes_priority(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_segments(tpl.id)
|
||||
assert len(result) == 1
|
||||
assert result[0].duration_min == 5.0
|
||||
|
||||
|
||||
class TestListByUser:
|
||||
def test_batch_loads_from_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="人物")
|
||||
repo.create_segments([seg])
|
||||
result = repo.list_by_user("u1")
|
||||
assert len(result) == 1
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].material_type == "人物"
|
||||
|
||||
def test_fallback_for_old_data(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=2.0, duration_max=6.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
result = repo.list_by_user("u1")
|
||||
assert len(result) == 1
|
||||
assert len(result[0].segments) == 1
|
||||
assert result[0].segments[0].duration_min == 2.0
|
||||
|
||||
|
||||
class TestCopyTemplate:
|
||||
def test_copy_writes_to_clip_configs(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1, material_type="voiceover")
|
||||
repo.create_segments([seg])
|
||||
copied = repo.copy_template(tpl.id, "u1", "副本模板")
|
||||
assert copied.id != tpl.id
|
||||
clips = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == copied.id).all()
|
||||
assert len(clips) == 1
|
||||
assert clips[0].config["material_type"] == "voiceover"
|
||||
|
||||
def test_copy_empty_segments(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
copied = repo.copy_template(tpl.id, "u1", "空副本")
|
||||
assert len(copied.segments) == 0
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_cleans_both_tables(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=1, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
repo.delete(tpl.id, "u1")
|
||||
c1 = session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == tpl.id).count()
|
||||
c2 = session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == tpl.id).count()
|
||||
assert c1 == 0
|
||||
assert c2 == 0
|
||||
|
||||
def test_delete_segments_by_template(self, repo, session):
|
||||
tpl = _make_template()
|
||||
repo.create(tpl)
|
||||
seg = _make_segment(tpl.id, order=1)
|
||||
repo.create_segments([seg])
|
||||
old = TemplateSegmentModel(
|
||||
id=str(uuid.uuid4()), template_id=tpl.id, segment_order=2, duration_min=1.0, duration_max=2.0
|
||||
)
|
||||
session.add(old)
|
||||
session.commit()
|
||||
count = repo.delete_segments_by_template(tpl.id)
|
||||
assert count == 2
|
||||
Reference in New Issue
Block a user