Compare commits

..

9 Commits

Author SHA1 Message Date
CI Bot 25a98c33b9 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
2026-09-01 13:32:22 +00:00
xiaoxia 8920bead38 fix(title): 预设卡片间距调整 gap 1px + 列宽 52px 固定 (#1617)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m47s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m15s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Validate - Style (push) Failing after 6m38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m22s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m9s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m37s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m49s
CI/CD Pipeline / Unit Tests (push) Failing after 15m7s
CI/CD Pipeline / Validate - Security (push) Successful in 20m9s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
fix(title): 预设卡片间距调整 gap 1px + 列宽 52px 固定 (#1617)
2026-09-01 21:23:41 +08:00
saas-backend-agent 827d8aafe5 feat: 素材分配顺序随机打乱 + MediaKit SceneChange智能选帧
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m26s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 5m45s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m35s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m13s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Waiting to run
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m0s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 7m27s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m47s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 6m38s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 16m35s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 29m28s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
1. 素材分配顺序随机打乱
   - segments 处理顺序通过 random.shuffle 随机化
   - clips_data 最终按原始 segment order 排序返回
   - 打乱的是分配顺序,不影响播放顺序

2. MediaKit SceneChange 智能选帧
   - mediakit_client.py 新增 detect_scene_changes 方法
   - SceneChange 优先,失败降级到 TimeInterval(5秒间隔)
   - 后台任务 _update_mediakit_recommendations_async 改用 SceneChange
   - 各片段优先从不同镜头段选取起始时间
   - analyze_videos 保留为 fallback
   - 所有降级路径:MediaKit不可用/SceneChange失败 → 保持随机start_time

3. 测试
   - 新增 tests/unit/test_scene_change_shuffle.py (12个测试用例)
   - 更新 test_editor_clips_random_start.py 适配 shuffle 逻辑
   - 全部 108 个相关测试通过
   - ruff check 通过
2026-09-01 21:16:51 +08:00
xiaoxia c00a0d9eb0 perf(deploy): 健康检查轮询间隔从 5s 降至 3s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m6s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m52s
CI/CD Pipeline / Integration Tests (push) Successful in 5m14s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 7m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m35s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m25s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m16s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m37s
CI/CD Pipeline / Unit Tests (push) Successful in 14m44s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- 更快检测到服务就绪状态
- 减少约 10-20s 的等待时间
2026-09-01 20:49:55 +08:00
xiaoxia 6c3db74fd3 perf(deploy): Worker 优雅关闭超时从 300s 降至 120s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (push) Has been cancelled
- 大多数部署时 Worker 无长任务运行,120s 足够
- 减少不必要的等待时间
2026-09-01 20:49:54 +08:00
xiaoxia ab6717eeae perf(CI): Unit Tests 增量测试+依赖缓存优化
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Check push changed paths (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
- push 事件也启用增量测试选择(仅跑改动相关的测试文件)
- 持久 runner 依赖 hash 缓存(requirements 无变化时跳过 pip install)
- 3 次独立 pip install 合并为 1 次
- numpy 缓存检查(已安装则跳过)
- 预计节省 5-10 分钟
2026-09-01 20:49:54 +08:00
xiaoxia c05026c5db fix(title): 预设间距归零 + 新增16种样式 (#1616)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m57s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 5m43s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m5s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 8m9s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-01 20:40:35 +08:00
xiaoxia 9d5ae7a5bc feat: 素材选取路径接入 smart_match 评分,让用过降权真正生效 (#1615)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Validate - Style (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
feat: 素材选取路径接入 smart_match 评分,让用过降权真正生效 (#1615)
2026-09-01 20:37:25 +08:00
xiaoxia 4aeb1d5b66 fix(render): 修复生成视频时长短于模板要求时长 (#1614)
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m41s
CI/CD Pipeline / Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m10s
CI/CD Pipeline / Unit Tests (push) Successful in 17m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m4s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m2s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
fix(render): 修复生成视频时长短于模板要求时长 (#1614)
2026-09-01 20:00:12 +08:00
14 changed files with 1055 additions and 175 deletions
+227 -102
View File
@@ -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,
@@ -711,9 +715,25 @@ def create_clips_from_assets_editor(
# 素材耗尽标志:某轮循环中所有素材均被跳过时为 True
all_assets_exhausted = False
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 计算转场重叠补偿:每个 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
# 贪心分配素材:按"已使用次数"升序排列候选素材(使用最少的优先),
# 同次数随机打散,避免"A-B-C-D"的固定组合反复出现。
@@ -790,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,
@@ -798,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)
@@ -845,11 +868,58 @@ def create_clips_from_assets_editor(
)
def _build_scene_segments(
scene_changes: list[float],
asset_duration: float,
) -> list[tuple[float, float]]:
"""根据场景切换点构建镜头段列表.
Args:
scene_changes: 场景切换点时间戳列表(已排序,首位为 0.0)
asset_duration: 素材总时长
Returns:
镜头段列表 [(start, end), ...]
"""
segments: list[tuple[float, float]] = []
for i, ts in enumerate(scene_changes):
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
# 只保留有效长度的镜头段(至少 0.5 秒)
if end - ts >= 0.5:
segments.append((ts, end))
return segments
def _pick_start_in_scene_segment(
seg_start: float,
seg_end: float,
clip_duration: float,
) -> float | None:
"""在镜头段内随机选取一个起始时间点.
确保 start + clip_duration <= seg_end。
若镜头段长度不足以容纳片段,返回 None。
"""
available = seg_end - seg_start - clip_duration
if available < 0:
return None
max_start = seg_start + available
return random.uniform(seg_start, max_start)
def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str,
asset_ids: list[str],
) -> None:
"""后台任务:MediaKit 智能选并更新片段的起始时间.
"""后台任务:使SceneChange 智能选并更新片段的起始时间.
优先使用 SceneChange 策略检测视频镜头切换点,将每个素材按镜头段拆分,
各片段优先从不同镜头段中选取起始时间,实现「不同片段展示不同场景」的效果。
降级策略:
1. SceneChange 优先 → detect_scene_changes 内部已含 TimeInterval 降级
2. 若 detect_scene_changes 仍返回 None → 回退到旧的 analyze_videos 方式
3. 所有方式都失败 → 保持现有随机 start_time,不影响视频生成
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
@@ -871,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 = []
@@ -899,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(用于排除已移动的旧时间段)
@@ -916,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
@@ -933,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)
+192
View File
@@ -162,6 +162,198 @@ export const TITLE_PRESETS = [
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
},
},
{
key: "outline_yellow",
label: "黄色描边",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ffd54f",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_pink",
label: "粉色描边",
style: { size: 28, color: "#ff80ab", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#ff80ab",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_blue",
label: "蓝色描边",
style: { size: 28, color: "#82b1ff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#82b1ff",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_green",
label: "绿色描边",
style: { size: 28, color: "#69f0ae", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#69f0ae",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "outline_gray",
label: "灰色描边",
style: { size: 28, color: "#bdbdbd", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
color: "#bdbdbd",
_strokeColor: "#000000",
_strokeWidth: 2,
fontWeight: 700,
fontSize: "32px",
},
},
{
key: "bg_white",
label: "白底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffffff",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_yellow",
label: "黄底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ffd54f",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_pink",
label: "粉底黑字",
style: { size: 28, color: "#1a1a1a", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#1a1a1a",
fontWeight: 700,
fontSize: "32px",
background: "#ff80ab",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "bg_red",
label: "红底白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#ef5350",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "neon_orange",
label: "橙色发光",
style: { size: 32, color: "#ff9100", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff9100",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #ff9100, 0 0 8px #ff9100, 0 0 16px rgba(255,145,0,0.5)",
},
},
{
key: "neon_purple",
label: "紫色发光",
style: { size: 32, color: "#d500f9", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#d500f9",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 4px #d500f9, 0 0 8px #d500f9, 0 0 16px rgba(213,0,249,0.5)",
},
},
{
key: "bordered_white",
label: "白字绿框",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: false },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
background: "#1a1a1a",
border: "2px solid #69f0ae",
borderRadius: "4px",
padding: "2px 6px",
},
},
{
key: "gradient_warm",
label: "暖色渐变",
style: { size: 32, color: "#ff6d00", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ff6d00",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,109,0,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "gradient_cool",
label: "冷色渐变",
style: { size: 32, color: "#00b0ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#00b0ff",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(0,176,255,0.6), 1px 1px 2px rgba(0,0,0,0.5)",
},
},
{
key: "shadow_deep",
label: "深影白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffffff",
fontWeight: 700,
fontSize: "32px",
textShadow: "2px 2px 4px rgba(0,0,0,0.8), 0 0 8px rgba(0,0,0,0.4)",
},
},
{
key: "soft_gold",
label: "柔光金",
style: { size: 28, color: "#ffd54f", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
color: "#ffd54f",
fontWeight: 700,
fontSize: "32px",
textShadow: "0 0 6px rgba(255,213,79,0.5), 1px 1px 2px rgba(0,0,0,0.4)",
},
},
]
/* ── 封面模式 ── */
+4 -4
View File
@@ -1733,16 +1733,16 @@
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 0.5px;
grid-template-columns: repeat(6, 52px);
gap: 1px;
}
.xx-title-preset-card {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
width: 52px;
height: 52px;
padding: 0;
background: #404040;
border: 2px solid transparent;
@@ -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)相关方法 ──────────────────────────────────────────────────
+5 -2
View File
@@ -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)
+64
View File
@@ -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等)→ 退回 TimeInterval5秒间隔)
3. MediaKit 不可用 → 返回 None
Returns:
场景切换点时间戳列表,如 [0.0, 3.2, 7.8, 12.5]
失败返回 None
"""
if not self.is_available:
logger.warning("MediaKit 未配置,跳过场景检测")
return None
# 策略1:尝试 SceneChange
frames = self.extract_frames(
video_url=video_url,
strategy="SceneChange",
max_frames=max_frames,
poll_interval=poll_interval,
max_poll_attempts=max_poll_attempts,
)
# 策略2SceneChange 失败 → 退回 TimeInterval5秒间隔)
if frames is None:
logger.info("SceneChange 策略失败,降级为 TimeInterval5秒间隔)")
# 估算帧数:假设视频最长60秒,每5秒一帧
ti_max_frames = max(max_frames, 12)
frames = self.extract_frames(
video_url=video_url,
strategy="TimeInterval",
max_frames=ti_max_frames,
poll_interval=poll_interval,
max_poll_attempts=max_poll_attempts,
)
if frames is None:
return None
# 从帧列表中提取 timestamp,排序
timestamps = sorted({float(f.get("timestamp", 0.0)) for f in frames if "timestamp" in f})
if not timestamps:
return None
# 始终在列表开头加 0.0(素材起始点)
if timestamps[0] != 0.0:
timestamps.insert(0, 0.0)
logger.info(
"场景检测完成: video_url=%s scene_changes=%s",
video_url[:80],
timestamps,
)
return timestamps
def _submit_extract_task(
self,
video_url: str,
+106 -47
View File
@@ -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.xmldiff-cover需要用到
# 备份PR代码(含coverage.xmldiff-cover需要用到
find . -maxdepth 1 -mindepth 1 ! -name 'diff_coverage.html' -exec cp -r {} "$PR_CODE_DIR/" \;
rm -rf .git
git init > /dev/null 2>&1
+1 -1
View File
@@ -397,7 +397,7 @@ fi
echo "Stopping old containers..."
# 优雅关闭:先 stop(发 SIGTERM,等待),再 rm
# Worker 需要更长时间(视频任务最长可能5分钟)
docker stop -t 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
+1 -1
View File
@@ -187,7 +187,7 @@ health_check() {
return 0
fi
sleep 5
sleep 3
done
# 超时了
+5 -5
View File
@@ -197,14 +197,14 @@ class TestComputeAssetAvailability:
def test_large_gap_remains_usable(self):
"""区间之间留有 ≥3s 空闲段(扩边后仍 ≥3s)→ usable=True。"""
# [0,2] 扩边到 [0,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)也能读到区间。
+97
View File
@@ -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
+9 -3
View File
@@ -418,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):
@@ -636,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):
+9 -7
View File
@@ -32,16 +32,16 @@ class TestRecommendedTimeConflicts:
assert _recommended_time_conflicts(10.0, 5.0, [(10.0, 15.0)]) is True
def test_touching_endpoint_conflicts_due_to_edge_gap(self):
"""首尾紧贴(推荐 15 开始,已用 [10,15]):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
+257
View File
@@ -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:
"""创建一个可用的 MediaKitClientmock 配置)."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "test-key"
mock_settings.return_value.mediakit_base_url = "http://test"
mock_settings.return_value.mediakit_timeout = 30
client = MediaKitClient()
return client
def test_scene_change_success(self):
"""SceneChange 策略成功返回时间戳列表."""
client = self._make_client()
mock_frames = [
{"image_url": "url1", "timestamp": 0.0},
{"image_url": "url2", "timestamp": 3.2},
{"image_url": "url3", "timestamp": 7.8},
{"image_url": "url4", "timestamp": 12.5},
]
with patch.object(client, "extract_frames", return_value=mock_frames):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is not None
assert result[0] == 0.0 # 始终以 0.0 开头
assert 3.2 in result
assert 7.8 in result
assert 12.5 in result
assert result == sorted(result) # 应已排序
def test_scene_change_fallback_to_time_interval(self):
"""SceneChange 失败降级到 TimeInterval 策略."""
client = self._make_client()
# 第一次调用(SceneChange)返回 None,第二次(TimeInterval)返回结果
fallback_frames = [
{"image_url": "url1", "timestamp": 0.0},
{"image_url": "url2", "timestamp": 5.0},
{"image_url": "url3", "timestamp": 10.0},
]
call_count = 0
def side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# 第一次 SceneChange 失败
return None
else:
# 第二次 TimeInterval 成功
assert kwargs.get("strategy") == "TimeInterval"
return fallback_frames
with patch.object(client, "extract_frames", side_effect=side_effect):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is not None
assert result[0] == 0.0
assert 5.0 in result
assert 10.0 in result
def test_mediakit_not_available_returns_none(self):
"""MediaKit 不可用时返回 None."""
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
mock_settings.return_value.mediakit_api_key = "" # 未配置
mock_settings.return_value.mediakit_base_url = "http://test"
mock_settings.return_value.mediakit_timeout = 30
client = MediaKitClient()
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is None
def test_both_strategies_fail_returns_none(self):
"""SceneChange 和 TimeInterval 都失败时返回 None."""
client = self._make_client()
with patch.object(client, "extract_frames", return_value=None):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is None
def test_prepends_zero_if_not_present(self):
"""若帧列表中不包含 0.0,自动在开头添加."""
client = self._make_client()
# 帧列表中没有 timestamp=0.0
mock_frames = [
{"image_url": "url1", "timestamp": 2.0},
{"image_url": "url2", "timestamp": 5.5},
]
with patch.object(client, "extract_frames", return_value=mock_frames):
result = client.detect_scene_changes("https://example.com/video.mp4")
assert result is not None
assert result[0] == 0.0
assert 2.0 in result
assert 5.5 in result
# ── Part 2.2: Scene segment building and assignment ───────────────────────
class TestSceneSegments:
"""验证镜头段构建和分配逻辑."""
def test_build_scene_segments(self):
"""从场景切换点正确构建镜头段."""
scene_changes = [0.0, 3.2, 7.8, 12.5]
asset_duration = 15.0
segments = _build_scene_segments(scene_changes, asset_duration)
assert len(segments) == 4
assert segments[0] == (0.0, 3.2)
assert segments[1] == (3.2, 7.8)
assert segments[2] == (7.8, 12.5)
assert segments[3] == (12.5, 15.0)
def test_build_scene_segments_filters_short(self):
"""过滤掉过短的镜头段(< 0.5秒)."""
scene_changes = [0.0, 0.1, 5.0, 5.3, 10.0]
asset_duration = 12.0
segments = _build_scene_segments(scene_changes, asset_duration)
# (0.0, 0.1) 长度 0.1 < 0.5 → 过滤
# (0.1, 5.0) → 保留
# (5.0, 5.3) 长度 0.3 < 0.5 → 过滤
# (5.3, 10.0) → 保留
# (10.0, 12.0) → 保留
assert len(segments) == 3
assert segments[0] == (0.1, 5.0)
assert segments[1] == (5.3, 10.0)
assert segments[2] == (10.0, 12.0)
def test_pick_start_in_segment(self):
"""在镜头段内随机选取起始时间."""
seg_start = 3.0
seg_end = 8.0
clip_duration = 2.0
starts = set()
for _ in range(100):
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
assert start is not None
assert seg_start <= start <= seg_end - clip_duration
starts.add(round(start, 2))
# 应该有多个不同的起始时间
assert len(starts) > 1
def test_pick_start_segment_too_short(self):
"""镜头段太短无法容纳片段时返回 None."""
result = _pick_start_in_scene_segment(0.0, 1.0, 2.0)
assert result is None
def test_different_clips_from_different_scenes(self):
"""不同片段应来自不同的镜头段(模拟分配逻辑)."""
scene_changes = [0.0, 5.0, 10.0, 15.0]
asset_duration = 18.0
clip_duration = 3.0
segments = _build_scene_segments(scene_changes, asset_duration)
assert len(segments) == 4 # (0,5), (5,10), (10,15), (15,18)
# 模拟 3 个片段从不同镜头段取点
scene_pool = list(segments)
assigned_starts = []
for _ in range(3):
if not scene_pool:
break
seg_start, seg_end = scene_pool.pop(0)
start = _pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
assert start is not None
assigned_starts.append(start)
# 3 个片段分别从 3 个不同镜头段中选取
assert len(assigned_starts) == 3
# 第一个来自 [0, 2],第二个来自 [5, 7],第三个来自 [10, 12]
assert 0.0 <= assigned_starts[0] <= 2.0
assert 5.0 <= assigned_starts[1] <= 7.0
assert 10.0 <= assigned_starts[2] <= 12.0