Compare commits

...

83 Commits

Author SHA1 Message Date
CI Bot 99cbff8abb fix(segments): 余量计算兼容 Asset 领域实体(metadata dict)
staging 真实接口验收发现:路由 repository 返回的是 Asset 领域实体而非
ORM AssetModel,区间记录在 metadata dict(repository 与 classification_result
JSON 互转);_read_meta 只读 classification_result 列导致 AttributeError,
余量计算全部走异常兜底,四字段在真实接口里恒为 None。

_read_meta 双形态兼容:实体 metadata 为 dict 时直接用;否则读
classification_result JSON 字符串(ORM 形态)。
测试:补充实体形态/ORM 形态对比用例(13913 passed)。
2026-08-30 08:02:27 +08:00
CI Bot 8c760d4a2d style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m16s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m34s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m46s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m25s
AI Code Review / AI Code Review (pull_request) Successful in 9m23s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m25s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 46s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m30s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m53s
2026-08-29 23:21:59 +00:00
CI Bot 78e9463825 fix(segments): AI Review 二轮修复 — 批量无源plan索引守卫 + 区间set精度归一化
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker 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 API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m8s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m36s
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
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m2s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
- [阻塞] count>1 但 source_edit_plan_id 为空时 variant_plan_ids 为空列表,
  循环 task_index>0 直接索引 variant_plan_ids[task_index-1] 抛 IndexError
  导致批量创建崩溃;加非空守卫,无源 plan 时各任务走自身随机选片
  (新增回归测试 test_batch_no_plan_guard:还原守卫即红、修复后绿)
- [建议] MediaKit 历史区间 set 去重前 round(3) 归一化浮点精度,避免
  1.0 与 1.0000000001 被误判为不同区间
- [建议] smart-match 循环调用 compute_asset_availability 为纯函数
  (只读 model 内存字段、无 DB 查询),无 N+1 问题,无需修改
2026-08-30 07:14:16 +08:00
CI Bot b2d4589949 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m8s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m51s
AI Code Review / AI Code Review (pull_request) Successful in 3m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m21s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m9s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m27s
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 / Production Browser E2E (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 24s
2026-08-29 18:38:42 +00:00
CI Bot b2a334f99b fix(segments): AI Review 阻塞项修复 — 记录写入行级锁 + 克隆失败不降级共用
CI/CD Pipeline / Check push changed paths (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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m52s
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
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m42s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m52s
AI Code Review / AI Code Review (pull_request) Failing after 4m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m24s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
- record_used_segments 读取 AssetModel 加 with_for_update() 行级锁,
  序列化并发事务对 classification_result 的读-改-写,杜绝区间记录丢失更新
  (SQLite 下 with_for_update 为 no-op)
- 批量生成:变体 plan 克隆前置到任务创建之前,全部成功才建任务(无脏数据);
  克隆含 1 次重试抗 DB 抖动,仍失败则 500 中断,不再静默退回共用源 plan
- 预览生成:克隆失败标记任务 failed 并返回 500,不再退回原 plan
  (两条修复共同保证批量/多预览视频内容不重复的业务约束)
- 测试:FakeSession 支持 with_for_update;预览相关测试 mock EditPlanService
  或显式置空 source_edit_plan_id;zip 加 strict(B905)
2026-08-30 02:33:19 +08:00
CI Bot 7cb36a5c3d style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m9s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 3m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m3s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
AI Code Review / AI Code Review (pull_request) Failing after 4m42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m19s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 24s
2026-08-29 18:16:26 +00:00
CI Bot e33502956c feat(segments): 片段重复率控制(受控复用+15%闸门)与素材余量四字段
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m5s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m38s
AI Code Review / AI Code Review (pull_request) Failing after 3m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m24s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Task G:任意两条成片画面重复率 ≤15%
- MediaKit 智能选片避让历史区间:历史区间并入冲突检查、0.3s 边缘间隙、
  撞历史时放弃推荐保留原起点(不再挪到已用画面)
- 自动轮回改造为受控复用:历史永不自动清空;used_time_ranges 增加
  use_count/last_used_at;100 次随机失败后复用 last_used_at 最老且
  use_count<3 的区间;单条成片复用占比 >15% 时该素材停止分配(分配闸门 +
  复用回调内预判双闸门);全部素材超限返回 400「素材可切区间不足,请补充新素材」
- 复用回调返回 None 时 calc 直接返回 None(删除末尾/0.0 重叠降级),
  from-assets 轮询内 calc 失败切换下一素材
- 批量 N 条视频片段独立:count>1 时克隆独立 plan,禁止共用
  source_edit_plan_id/clips(合并 Issue #1282);预览同样克隆独立变体
- 常量:REUSE_RATIO_LIMIT=0.15 / SEGMENT_EDGE_GAP=0.3 / MAX_RANGE_USE_COUNT=3

Task H:素材余量透出与已用尽素材排除
- GET /assets 列表与详情响应附加 used_duration/available_duration/
  used_ratio/usable 四字段(非视频/无时长/异常时 None + usable=True,零影响)
- usable=false:零重复可切区间已耗尽(扩边后无 ≥3s 空闲段)且所有历史
  区间 use_count 均达上限
- smart-match 结果层过滤 usable=false 素材(评分逻辑不动)
- from-assets 对已用尽素材天然兜底:calc 返回 None → 轮询跳过 →
  全部素材用尽时 400

测试:新增 test_mediakit_conflicts(10)/test_clone_plan_variant(7)/
test_asset_availability(23),更新 tracker/clips/mediakit 既有用例;
全量 unit 13910 passed
2026-08-30 02:10:06 +08:00
xiaoxia 6422472ef3 feat(voices): 配音素材卡片 UI 对齐预置音色 + 删除常驻 + 批量删除 (#1545)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 / Check push changed paths (push) Successful in 5m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m21s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 7m33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m15s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m39s
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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 49s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 10m41s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 10m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m41s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 12m34s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 12m37s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m49s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m21s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 20m11s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 20m35s
CI/CD Pipeline / Unit Tests (push) Successful in 30m9s
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 29m30s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 22m50s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 22m34s
CI/CD Pipeline / CI Gate (pull_request) Successful in 26s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 23:21:00 +08:00
xiaoxia 0a061af582 feat(voices): 配音素材卡片真实播放 + 预置/克隆音色 TTS 试听 (#1544)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m6s
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 / Check push changed paths (push) Successful in 4m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m13s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m31s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m48s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 8m52s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 51s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 49s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 49s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 12m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 13m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 14m7s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 15m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 33m45s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 33m52s
CI/CD Pipeline / Unit Tests (push) Successful in 43m54s
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 41m52s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 26m49s
CI/CD Pipeline / Integration Tests (push) Successful in 26m43s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m49s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 21:56:00 +08:00
xiaoxia f655508f88 Merge pull request 'feat: 素材片段区间持久化去重 + 区间用尽自动轮回' (#1543) from feat/persist-used-segments into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m20s
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 / 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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 40s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m44s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
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 / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 37s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 11m4s
AI Code Review / AI Code Review (pull_request) Failing after 11m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 11m32s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 12m40s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 13m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 21m39s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 22m1s
CI/CD Pipeline / Unit Tests (push) Successful in 29m55s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 28m36s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-29 21:19:15 +08:00
CI Bot 1b83ec9952 fix: AI Code Review 阻塞问题修复
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / 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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m10s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m10s
AI Code Review / AI Code Review (pull_request) Successful in 5m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 9m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 10m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 18m2s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 21m17s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 19m16s
CI/CD Pipeline / CI Gate (pull_request) Successful in 28s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m5s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m30s
1. remove_used_segment 旧数据兼容:记录缺 plan_id(本功能上线前的旧数据)时
   退化为按时间匹配删除,避免旧区间永远删不掉导致素材容量泄漏
2. MediaKit 异步更新事务一致性:clip start_time 更新与 metadata 区间记录
   删旧/写新放入同一事务,metadata 失败时 rollback 本次 clip 更新,
   统一 commit,消除 clip 已提交、metadata 未更新的不一致
3. (建议) on_exhausted 回调异常增加 logger.warning,不再静默吞掉
4. 补充旧数据兼容单测
2026-08-29 20:38:56 +08:00
CI Bot 5d705307c1 style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m36s
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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m45s
AI Code Review / AI Code Review (pull_request) Failing after 7m6s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 9m5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 10m19s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-29 12:26:01 +00:00
CI Bot 2174e91c48 feat: 素材片段区间持久化去重 + 区间用尽自动轮回
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / 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 / 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 / Check if frontend-only change (pull_request) Successful in 2m39s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m43s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m30s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 8m33s
AI Code Review / AI Code Review (pull_request) Successful in 8m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 9m35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- 新增 asset_segment_tracker 服务:素材 metadata(used_time_ranges) 持久化片段级已用区间
- from-assets 创建片段时读取历史区间,新片段跨任务/跨调用自动避开
- 片段记录与 replace_all_clips_transactional 同事务,失败整体回滚
- MediaKit 异步移动片段起点后同步更新 metadata 区间记录(失败静默)
- _calc_random_start_time 新增 on_exhausted 回调:100次找不到时清空该素材历史区间再重试,实现轮完一圈自动循环

详见 PR body 的 metadata schema 说明
2026-08-29 20:14:28 +08:00
xiaoxia 700d6f9130 Merge pull request 'chore: 删除素材选取 random 模式,保持代码干净' (#1542) from chore/remove-random-asset-mode into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web 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 3m58s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m0s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m40s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m38s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m55s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m43s
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m26s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m27s
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 / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 55s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m20s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m13s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m15s
AI Code Review / AI Code Review (pull_request) Successful in 6m33s
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m21s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m59s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m24s
CI/CD Pipeline / Unit Tests (push) Successful in 16m11s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m20s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m20s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
2026-08-29 18:14:50 +08:00
CI Bot 5a00f8b8fb style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m36s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m40s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m11s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 45s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 50s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m41s
AI Code Review / AI Code Review (pull_request) Successful in 6m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m51s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m11s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m36s
2026-08-29 10:02:10 +00:00
CI Bot 05d75db0cf chore: 删除素材选取 random 模式
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m5s
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
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m20s
AI Code Review / AI Code Review (pull_request) Successful in 3m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m57s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- generation_tasks.py: 删除 import random、if mode==random 分支、docstring 中 random 描述
- generation_task.py schema: description 移除 random=随机选取,count 描述仅保留 smart
- 选取入口条件 in ("random","smart") 改为 in ("smart",)
- test_asset_select_mode.py: 删除 TestSelectAssetsRandomMode 类(3个用例)
- domain/test_generation_task.py + test_wave77_generation_task.py: random→smart

不改:_calc_random_start_time(片段级随机)、smart_match.py、前端
2026-08-29 17:54:15 +08:00
xiaoxia f327171e9b fix(voice): 修复克隆音色弹窗「选文件无反应」死锁(isSubmittingRef 三处误用) (#1541)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m54s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m23s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m33s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m17s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m7s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 53s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m42s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 4m36s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m27s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m54s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 56s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m33s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 1m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m31s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m22s
CI/CD Pipeline / Integration Tests (push) Successful in 4m6s
CI/CD Pipeline / Unit Tests (push) Successful in 16m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m0s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m25s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 17:36:00 +08:00
xiaoxia d4b3fa2ae5 test(e2e): 修正步骤4预览就绪判定——等待占位「准备预览素材」消失
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m50s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m51s
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 / 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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m45s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m53s
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 / Validate - Migration (alembic) (push) Successful in 6m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m0s
AI Code Review / AI Code Review (pull_request) Failing after 7m53s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m58s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m13s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m34s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 50s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 52s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m2s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m37s
CI/CD Pipeline / Integration Tests (push) Successful in 3m56s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m37s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m57s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m14s
CI/CD Pipeline / Unit Tests (push) Successful in 16m57s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m36s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 25s
xx-inline-video-player 是步骤5成片播放器 class,标题页实时预览为
FrontendPreviewPlayer(内联样式无 class),未就绪时显示「准备预览素材...」
占位,素材解析完成后占位消失。改等占位 detached(元素不存在时立即通过)
2026-08-29 16:50:39 +08:00
xiaoxia 353a9a27a9 test(e2e): 生成向导用例同步6步流程(修复 Staging E2E 失败) (#1540)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m9s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m23s
CI/CD Pipeline / Check push changed paths (push) Successful in 4m25s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (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 / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m44s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m5s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m21s
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m22s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m13s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m27s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Successful in 1m30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m29s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Successful in 2m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m41s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m52s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m9s
CI/CD Pipeline / Integration Tests (push) Successful in 4m5s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m55s
CI/CD Pipeline / Unit Tests (push) Successful in 17m17s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m30s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 16:01:00 +08:00
xiaoxia ef09338098 fix(ci): push 路径检测改用 compare 响应内每提交 files 聚合 (#1539)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m16s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 3m26s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m48s
CI/CD Pipeline / Check push changed paths (push) Successful in 5m18s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 40s
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 / 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 / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m30s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
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 / Validate - Code Quality (push) Successful in 9m4s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m9s
CI/CD Pipeline / Integration Tests (push) Successful in 3m23s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m24s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m11s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m7s
CI/CD Pipeline / Unit Tests (push) Successful in 16m45s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m58s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 7m40s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 15:21:06 +08:00
xiaoxia ece9ac48a8 fix(voices): 配音库「配音素材」tab 补删除按钮 (#1538)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m24s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 3m26s
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 / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m31s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m37s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m22s
AI Code Review / AI Code Review (pull_request) Successful in 4m22s
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m32s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (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 / 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 / Validate - Code Quality (pull_request) Successful in 10m6s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 15:11:00 +08:00
xiaoxia 5672757e23 perf(ci): 流水线提速 - 路径过滤/持久builder缓存/依赖缓存卷/部署job迁移ci-l2 (#1536)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m49s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 3m11s
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 / Check if frontend-only change (pull_request) Successful in 2m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4m11s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m58s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m54s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m46s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m36s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m40s
AI Code Review / AI Code Review (pull_request) Failing after 7m3s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m40s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m7s
CI/CD Pipeline / Integration Tests (push) Successful in 4m19s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m32s
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) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m32s
CI/CD Pipeline / Unit Tests (push) Successful in 15m46s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Unit Tests (pull_request) Successful in 12m52s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
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 / CI Gate (pull_request) Successful in 32s
CI/CD Pipeline / Production Browser E2E (push) 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 / Production Browser E2E (pull_request) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 14:36:04 +08:00
xiaoxia f4754045c1 fix: worker 音频 mime 推断补充 .m4a/.mp3/.wav 等 10 种音频后缀 (#1537)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m34s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m0s
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 / 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 / Build Staging API Image (push) Successful in 5m35s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m49s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m11s
AI Code Review / AI Code Review (pull_request) Failing after 6m51s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m6s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m37s
CI/CD Pipeline / Unit Tests (push) Successful in 16m29s
CI/CD Pipeline / Integration Tests (push) Successful in 5m28s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m59s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m30s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m18s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m20s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 7m54s
2026-08-29 12:37:13 +08:00
CI Bot 874893900d style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m38s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m4s
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
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m13s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m18s
AI Code Review / AI Code Review (pull_request) Successful in 4m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m29s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m2s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m28s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m33s
CI/CD Pipeline / CI Gate (pull_request) Successful in 29s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m50s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m33s
2026-08-29 04:25:30 +00:00
xiaoxia 81bc72f86a ci: trigger re-run
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m35s
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
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m10s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m50s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
2026-08-29 12:20:18 +08:00
xiaoxia 250702de59 fix: worker 音频 mime 推断补充 .m4a/.mp3/.wav/.flac/.ogg/.aac/.wma/.amr/.opus/.weba
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m33s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
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 Worker Image (pull_request) Successful in 1m40s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m17s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 14m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 14m12s
PR Automation / Auto Approve on CI Green (pull_request) Failing after 14m13s
AI Code Review / AI Code Review (pull_request) Failing after 14m13s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Failing after 14m15s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Failing after 14m15s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 14m15s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m17s
CI/CD Pipeline / CI Gate (pull_request) Failing after 24s
infer_mime_type_from_storage_key 此前只识别视频后缀,其他一律返回
image/jpeg,导致所有音频文件在 ingest 后被标记为 file_type=image,
配音上传和克隆音色上传均失败。

补充音频后缀分支,返回对应 audio/... mime type。
新增 10 个音频单测,共 13 测试全过。
2026-08-29 12:00:52 +08:00
xiaoxia 7d88ddc9d5 fix(generate): 标题位置强制默认顶部,不从模板/草稿同步
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Check if frontend-only change (pull_request) Successful in 3m39s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m48s
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 / Validate - Type Check (mypy) (pull_request) Successful in 4m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m21s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m22s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m45s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m40s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 11m49s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 12m32s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m8s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m40s
CI/CD Pipeline / Unit Tests (push) Failing after 23m28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 23m35s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m57s
CI/CD Pipeline / Integration Tests (push) Failing after 16m33s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17m54s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 22m39s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 26s
三处自动同步 position 的逻辑去掉覆盖:
- useTitleCoverSync: 模板切换不再用 tpl.title_config.position 覆盖
- usePlanConfigLoader planConfigStr 分支: 不从草稿配置同步 position
- usePlanConfigLoader getEditPlan 分支: 不从远程草稿同步 position

保留 title/font/size/color/aiAutoSelect 等其他字段的同步逻辑不变。
用户手动在 TitleStylePanel 切换位置仍然正常工作。
2026-08-29 11:26:48 +08:00
xiaoxia e6c90a2346 fix(generate): 修复步骤1/2/3缺少下一步按钮 (#1536)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m15s
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 / 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 / Build Staging Worker Image (push) Successful in 4m47s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m14s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m46s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m58s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m59s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m1s
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
AI Code Review / AI Code Review (pull_request) Failing after 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 9m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m58s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m12s
CI/CD Pipeline / Integration Tests (push) Successful in 3m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m13s
CI/CD Pipeline / Unit Tests (push) Successful in 16m41s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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
CI/CD Pipeline / Staging E2E Tests (push) Failing after 8m33s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
2026-08-29 10:30:01 +08:00
xiaoxia 425a7cb623 Merge PR #1534: fix 封面选帧输入源改为最终成片 + 接口安全加固
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m14s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m42s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m10s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m28s
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m55s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m34s
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
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 7m37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 8m39s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m11s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m14s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
AI Code Review / AI Code Review (pull_request) Successful in 5m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m23s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 13m21s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 13m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m26s
CI/CD Pipeline / Integration Tests (push) Successful in 3m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 7m57s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 14m20s
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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m3s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 28s
封面选帧接口输入源从预览片段改为最终成片;

- generated_video_id/video_url 定位最终成片视频,MediaKit 选帧逻辑不变
- 权限双重校验(GeneratedVideo.user_id + 关联任务归属),归属无法确认时 403
- video_url 增加 OSS 域名白名单 + ipaddress 标准库判定,防 SSRF
- 预览片段/源素材回退链保留,兼容旧流程

Co-authored-by: AI Code Review bot
2026-08-29 01:19:01 +08:00
CI Bot f9d243a5af style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m51s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m22s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m52s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m21s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m43s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m6s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m24s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 3m41s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m15s
2026-08-28 16:59:07 +00:00
xiaoxia cf7e295f35 fix: SSRF 内网判定改用 ipaddress 标准库,覆盖全部 IPv4/IPv6 私有段
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m20s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m30s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m19s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m48s
AI Code Review / AI Code Review (pull_request) Failing after 3m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
AI Code Review 第三轮指出:IPv6 链路本地地址使用字符串前缀匹配
(fe80/fe90/...)不严谨且易漏。

修复:新增 _is_private_or_reserved_host(),统一用标准库
ipaddress.ip_address().is_private/is_loopback/is_link_local/
is_reserved 判定,一次性准确覆盖:
- IPv4: 10/8、172.16/12、192.168/16、127/8、169.254/16、0.0.0.0
- IPv6: ::1、fc00::/7(ULA)、fe80::/10(链路本地)、保留段
删除手工字符串切片逻辑,可读性和正确性都更好。

36 个测试全部通过。
2026-08-29 00:49:11 +08:00
xiaoxia 1eb9d8667a feat(generate): 确认生成按钮移至标题步骤(步骤4直接触发最终渲染) (#1535)
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m31s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m1s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 10m25s
CI/CD Pipeline / Check if frontend-only change (pull_request) Failing after 12m37s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m29s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m31s
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 / 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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m59s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m47s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m21s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m38s
CI/CD Pipeline / Validate - Code Quality (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 / 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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-29 00:47:25 +08:00
xiaoxia bde37af2bb fix: SSRF 白名单加固 — 修复 endpoint 解析绕过 + 补充 IPv6 内网拦截
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 / Check if frontend-only change (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m52s
AI Code Review / AI Code Review (pull_request) Failing after 5m18s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 5m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 8m9s
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
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 - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review 第二轮指出的两个阻塞问题:

1. endpoint 主机名解析绕过:原逻辑 ep.split(':')[0] 在 endpoint
   带 scheme(http://host:9000)时取到 'http',虽白名单顺序使
   public_url 先生效,但 endpoint 分支可能错误匹配 '.http' 后缀。
   修复:新增 _endpoint_host() 统一用 urlparse 提取主机名,
   兼容有无 scheme、带端口等各种配置形式。

2. 缺失 IPv6 内网地址校验:[::1]、fe80::/10(链路本地)、
   fc00::/7(唯一本地)等 IPv6 本地地址未拦截。
   修复:补充 IPv6 回环/链路本地/ULA 地址显式拒绝。

附带:
- 移除函数内重复的 urlparse 导入,统一使用顶部导入
- 新增 2 个白名单单元测试(IPv4/IPv6/元数据拦截 + scheme 解析)
- 共 36 个测试全部通过
2026-08-29 00:39:05 +08:00
xiaoxia d3fc15ddd9 feat(generate): 确认生成按钮移至标题步骤 (#1535)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m44s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m26s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m34s
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 / 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 / Build Staging API Image (push) Successful in 2m41s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (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 Web Image (push) Has been cancelled
CI/CD Pipeline / Build 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 / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
- 步骤4(选择标题)底部按钮改为「确认生成视频」,点击后执行标题/预览
  校验,直接调用 createGenerationTask 创建最终渲染任务,成功后跳转步骤5
- 步骤5不再有「开始生成」按钮,仅展示渲染进度;渲染中禁用下一步,
  完成后停留本页,手动点下一步进入封面(不自动跳转)
- 步骤6封面页底部仅保留上一步(修正确认按钮误挂最后一步的遗留问题)
- 生成失败:创建失败留在步骤4可重试;轮询失败在步骤5展示错误卡片
- generate() 返回 boolean,任务创建成功才跳转
- 修复「查看结果」滚动选择器失效(xx-preview-section → xx-inline-video-player)
- 步骤5增加未开始生成空状态提示
2026-08-29 00:39:04 +08:00
CI Bot e5e18ef269 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m41s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m0s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m5s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 8m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m41s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m33s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-28 16:27:56 +00:00
xiaoxia 3b828ab184 fix: 封面选帧接口安全加固 — 权限校验防绕过 + video_url SSRF 白名单
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m31s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m3s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
AI Code Review 指出的两个阻塞问题:

1. 权限校验绕过(步骤0a):原逻辑仅在关联任务存在且
   created_by_user_id 非空时才校验归属;若 GeneratedVideo 的
   generation_task_id 为空或关联任务被删除,校验被静默跳过。
   修复:
   - 优先校验 GeneratedVideo.user_id 直接归属
   - 关联任务存在时校验 task.created_by_user_id
   - video 无 owner 且关联任务也查不到(归属无法确认)→ 403,
     不再静默放行

2. video_url SSRF(步骤0b):原逻辑直接使用请求体传入的 URL,
   攻击者可传入内网/云元数据地址诱导服务端请求。
   修复:新增 _is_trusted_media_url() 白名单校验:
   - 仅允许 http/https 且主机为自家 OSS bucket/endpoint 域名
   - 显式拒绝 localhost/127.*/10.*/192.168.*/169.254.*/172.16-31.*
   - 校验失败静默忽略该 URL,回退后续查找链

附带改进(review 建议项):
- 函数内联 import 全部移至模块顶部(storage/re)
- 新增 2 个安全测试:SSRF 内网地址拦截、归属无法确认 403
- 现有 32 个测试适配新的 mock 命名空间,共 34 个测试全过
2026-08-29 00:21:49 +08:00
xiaoxia 2957ad724c feat: 调换确认生成与选择封面顺序,封面从最终成片选帧 (#1533)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m26s
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 (push) Successful in 1m31s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 3m28s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m47s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m7s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 9m55s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m6s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 11m32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 8m35s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m23s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 10m57s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m57s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m58s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 21m56s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 22m48s
CI/CD Pipeline / Integration Tests (push) Successful in 5m20s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m24s
CI/CD Pipeline / Unit Tests (push) Successful in 24m3s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 21m54s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-28 23:41:29 +08:00
CI Bot b6f211ebe6 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Check if frontend-only change (pull_request) Successful in 2m31s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m57s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m16s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m53s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
AI Code Review / AI Code Review (pull_request) Failing after 7m15s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 11m22s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 12m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 28m3s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 25m1s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m50s
CI/CD Pipeline / CI Gate (pull_request) Successful in 31s
2026-08-28 15:35:01 +00:00
xiaoxia 3e71a00b12 fix: 封面选帧输入源改为最终成片,预览片段作为兼容回退
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 / Check if frontend-only change (pull_request) Successful in 1m37s
AI Code Review / AI Code Review (pull_request) Failing after 3m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m38s
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 / PR Build Worker Image (pull_request) Successful in 53s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m19s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 11m47s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 13m51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- GenerateCoverRequest 新增可选字段 generated_video_id / video_url,
  与前端 PR #1533 契约对齐:前端在「选择封面」步骤传入确认生成产出的
  最终视频 ID 或 URL,后端据此定位最终成片文件并抽帧
- 视频源查找优先级调整:
  0a. 请求体 generated_video_id → GeneratedVideo.file_url
  0b. 请求体 video_url → 直接使用
  1. plan.config.rendered_storage_key
  2. plan.config.generation_task_id
  3. source_edit_plan 关联的 is_preview=False 已完成任务(最终成片优先)
  4. source_edit_plan 关联的 is_preview=True 预览任务(回退)
  5. user+template 最近预览任务(兜底)
- cover_url 查找同步调整:优先最终成片任务的 cover_url,再回退预览
- MediaKit 的 strategy/max_frames/轮询/重试/降级逻辑全部不变
- 权限校验:通过 generated_video_id 关联的 generation_task 校验归属
- 新增 5 个单测覆盖:schema 字段、video_id 抽帧、video_url 抽帧、
  最终成片优先于预览、权限校验 403
2026-08-28 23:13:18 +08:00
xiaoxia d512cd2ac1 Merge PR #1533: 调换确认生成与选择封面顺序,封面从最终成片选帧
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m51s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m19s
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 / Build Staging Web Image (push) Successful in 2m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 3m6s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 12m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m44s
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 / Validate - Type Check (mypy) (push) Successful in 15m1s
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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m9s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 9m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m11s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 12m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m16s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m55s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m52s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 37m18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 33m46s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-28 22:59:19 +08:00
xiaoxia 1c9c9c79c2 Merge PR #1531: 合并步骤4(选择标题)和步骤5(生成预览)为一个步骤
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m10s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m35s
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 / Build Staging Web Image (push) Successful in 2m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 13m33s
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
AI Code Review / AI Code Review (pull_request) Successful in 7m25s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m15s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 15m55s
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
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 10m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m0s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m12s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m1s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 12m55s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m27s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 32m30s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 37m19s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-28 22:19:00 +08:00
CI Bot 7ed6962bab Merge PR #1530: fix 素材缩略图全黑
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m25s
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 / Check if frontend-only change (pull_request) Successful in 4m29s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m28s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m33s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m55s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m19s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m52s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 41s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m21s
AI Code Review / AI Code Review (pull_request) Failing after 6m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m58s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m10s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 19m18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 19m49s
CI/CD Pipeline / Unit Tests (push) Successful in 20m30s
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 22m59s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 32m50s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 33m53s
CI/CD Pipeline / CI Gate (pull_request) Successful in 25s
2026-08-28 19:15:27 +08:00
xiaoxia c74b2d4d8d style: fix lint issues in batch_thumbnail.py
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 / Check if frontend-only change (pull_request) Successful in 3m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 4m11s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m18s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m29s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m5s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 4m8s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m13s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m47s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m22s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m1s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 1m36s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m8s
2026-08-28 18:46:46 +08:00
CI Bot df8c64fc81 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Check if frontend-only change (pull_request) Successful in 2m30s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m12s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 5m5s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m33s
AI Code Review / AI Code Review (pull_request) Successful in 4m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m0s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m34s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 4m5s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-28 10:32:33 +00:00
xiaoxia 4dfe827344 fix: 标题位置默认改为顶部(top) (#1529)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m53s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m28s
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m58s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m21s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m50s
AI Code Review / AI Code Review (pull_request) Successful in 8m20s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m30s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m38s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m2s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m11s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m37s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m48s
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 - Migration (alembic) (push) Failing after 12m51s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Failing after 12m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m21s
CI/CD Pipeline / Unit Tests (push) Successful in 15m55s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m46s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 9m21s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m35s
CI/CD Pipeline / Integration Tests (push) Successful in 9m3s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m2s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m3s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 31s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-28 18:32:27 +08:00
xiaoxia 8e89717166 fix: 素材缩略图全黑 — 签名URL + 批量修复 + 前端兜底
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 / Check if frontend-only change (pull_request) Successful in 2m3s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m18s
AI Code Review / AI Code Review (pull_request) Successful in 3m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m19s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
问题根因:
1. OSS bucket 为私有,upload_to_oss 返回的公开 URL 返回 403
2. API 降级逻辑把视频 .mp4 的 signed URL 当作 thumbnail_url 返回
3. 浏览器 <img> 无法渲染 .mp4 文件,显示黑屏

修复:
- ingest.py: 存储 storage_key 而非公开 URL,API 层统一签名
- assets.py: 移除 .mp4 降级,thumbnail_url 通过 get_download_url 签名
- AssetCard.tsx: 检测视频 URL 后缀 + onError 降级显示类型图标
- 新增 batch_generate_thumbnails Celery 任务,为 2587 个历史视频补生成缩略图
- 同时处理 242 条旧格式公开 URL → storage_key 迁移
2026-08-28 18:23:41 +08:00
xiaoxia 74ecae5062 Merge PR #1529: 标题位置默认改为顶部(top)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m35s
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 / Validate - Migration (alembic) (push) Successful in 6m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m45s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m19s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m24s
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 / Validate - Migration (alembic) (pull_request) Successful in 3m30s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m47s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m30s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 6m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m19s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m38s
AI Code Review / AI Code Review (pull_request) Successful in 9m33s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m53s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration 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
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-28 18:14:04 +08:00
xiaoxia 79074370b6 Merge pull request 'fix: thumbnail_url未写入generated_videos + 缩略图key避免覆盖' (#1528) from fix/thumbnail-v2 into develop
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m17s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m58s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m58s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m32s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m55s
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 / Build Staging API Image (push) Successful in 6m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m23s
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 - Type Check (mypy) (push) Successful in 7m17s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m54s
AI Code Review / AI Code Review (pull_request) Failing after 8m34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m6s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m8s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m14s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 10m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m17s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 3m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m2s
CI/CD Pipeline / Integration Tests (push) Successful in 5m47s
CI/CD Pipeline / Unit Tests (push) Successful in 13m53s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m19s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 25s
2026-08-28 16:06:26 +08:00
CI Bot 1b19e17cb7 fix: AI Code Review 澄清注释 - job_id是方法参数 + thumbnail_url通过构造函数返回
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m29s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m24s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m38s
AI Code Review / AI Code Review (pull_request) Successful in 4m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m20s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m50s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m0s
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m21s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m34s
2026-08-28 15:47:28 +08:00
CI Bot 87deb7e467 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m38s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m34s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m27s
AI Code Review / AI Code Review (pull_request) Failing after 4m35s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m55s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 5m42s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m25s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-28 07:37:56 +00:00
CI Bot 5a9e3fdb5e fix: thumbnail_url未写入generated_videos表 + 缩略图存储key避免覆盖
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m37s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m43s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m43s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m46s
AI Code Review / AI Code Review (pull_request) Failing after 4m51s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m42s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m5s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
Bug 1: render_adapter渲染完成后thumbnail_url未传递给create_video_record_and_dedup
- _render_from_edit_plan 补充返回 result.thumbnail_url
- _upload_and_record 新增 thumbnail_url 参数并透传
- generate_video 调用处同步更新

Bug 2: 缩略图存储key rendered/{plan_id}/thumbnail.jpg 导致同plan多次渲染互相覆盖
- 改为 rendered/{plan_id}/thumbnails/{task_id}.jpg
2026-08-28 15:30:09 +08:00
xiaoxia 5be051a683 fix(player): 预览播放器进度条不动 — play前未seek到segment起始时间
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Build Staging Worker Image (push) Successful in 4m13s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m44s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m45s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5m28s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m43s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m0s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m1s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m11s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 7m47s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m38s
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 / PR Build Worker Image (pull_request) Successful in 51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m18s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m40s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m9s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 12m6s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m0s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m29s
CI/CD Pipeline / Integration Tests (push) Successful in 4m17s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m20s
CI/CD Pipeline / Unit Tests (push) Successful in 13m20s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m27s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
根因:useSegmentScheduler.play() 直接调用 video.play() 没有先 seek 到
seg.startTime。当后端 from-assets 返回的片段 start_time > 0(MediaKit
推荐起始时间),视频从0开始播放,但 tick 计算 globalTime 时减去了
startTime,导致进度条卡在0不动,直到 video.currentTime >= startTime。

修复:play() 在调用 video.play() 前,seek 到 seg.startTime。
2026-08-28 14:03:24 +08:00
xiaoxia 12f51d43bf feat(from-assets): 改为异步执行MediaKit分析 (#1525)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m38s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m8s
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 / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m16s
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 / Build Staging Web Image (push) Successful in 5m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m4s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m5s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m6s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m41s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 5m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m27s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m11s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m58s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Unit Tests (push) Successful in 15m1s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m39s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-28 12:26:00 +08:00
xiaoxia bcafaa4eff refactor(generate): 去掉30秒轮询,from-assets秒级返回后直接生成
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m22s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m49s
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 / 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 / Build Staging API Image (push) Successful in 3m25s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m13s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m11s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m18s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m16s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m0s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m44s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m43s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m20s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 58s
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
AI Code Review / AI Code Review (pull_request) Successful in 10m40s
CI/CD Pipeline / Integration Tests (push) Successful in 4m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m26s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m58s
CI/CD Pipeline / Unit Tests (push) Successful in 18m11s
CI/CD Pipeline / CI Gate (push) Has been skipped
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m13s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m4s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m2s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m5s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m34s
- 删除 waitForClipsCreated 轮询逻辑(30s等待、1.5s间隔)
- handleGenerate 改为单次 listClips 兜底检查,无片段则补调 from-assets
- 后端已改异步秒级返回,前端无需长时间等待
- 生成前显示 loading '正在生成预览视频...'
- useStep2Materials 错误提示统一为'智能选片失败,请重试'
- 注释更新:反映后端异步行为
2026-08-28 10:30:54 +08:00
xiaoxia b6af6892cd feat(from-assets): 按模板segment创建片段 (#1524)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m19s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m21s
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 / Check if frontend-only change (pull_request) Successful in 4m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m12s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m8s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m1s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m50s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 9m14s
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 / PR Build Worker Image (pull_request) Successful in 57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m36s
AI Code Review / AI Code Review (pull_request) Successful in 6m37s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 12m10s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m14s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m2s
CI/CD Pipeline / Unit Tests (push) Successful in 16m50s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 5m56s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m33s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m25s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 46s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 21m34s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 23m45s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-28 01:19:57 +08:00
CI Bot 506b29991b fix: include clip_type in clips_data for replace_all_clips_transactional
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m54s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m49s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m3s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m40s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 6m10s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m12s
AI Code Review / AI Code Review (pull_request) Successful in 9m11s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m45s
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 3m33s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 5m32s
- Add clip_type field to clips_data dict (body.clip_type or main)
- Service layer reads clip_type from clips_data instead of hardcoding
- 48 tests passing
2026-08-28 01:02:04 +08:00
CI Bot ab81c0115a style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m9s
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
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m50s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m54s
AI Code Review / AI Code Review (pull_request) Failing after 6m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m17s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m7s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 16:53:21 +00:00
CI Bot ecfa478a24 fix: guard empty asset_ids + validate clip_duration > 0 + fix old-system default
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m33s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m50s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m55s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m51s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m0s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
- Add empty asset_ids check before modulo (defense-in-depth, schema also enforces min_length=1)
- Add clip_duration <= 0 guard after asset_total capping
- Fix old-system fallback default from 0.0 to _DEFAULT_EDITOR_CLIP_DURATION
- Add test for empty asset_ids 400
- 48 tests passing
2026-08-28 00:47:21 +08:00
CI Bot 9711b6a545 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m8s
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
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m54s
AI Code Review / AI Code Review (pull_request) Failing after 4m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m7s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m11s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m1s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m28s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 16:37:26 +00:00
CI Bot d771fee45a fix: validate segment durations with _safe_segment_duration + upgrade fallback logs to warning
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m5s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m44s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m9s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m27s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m21s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m36s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 5m56s
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
- Add _safe_segment_duration() to handle None/invalid/negative/NaN values
- Swap dur_min/dur_max if dirty data has min > max
- Change fallback exception logs from DEBUG to WARNING for observability
- Add 7 unit tests for _safe_segment_duration
- 47 tests passing
2026-08-28 00:31:24 +08:00
CI Bot 753a3206a7 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-08-27 16:29:06 +00:00
CI Bot 5c62dad798 fix: atomic clip replacement via replace_all_clips_transactional + asset_total=0 guard
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 / Check if frontend-only change (pull_request) Successful in 2m37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m4s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m44s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m15s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m2s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m33s
AI Code Review / AI Code Review (pull_request) Failing after 7m50s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- Use replace_all_clips_transactional for atomic delete+create (fixes data loss risk)
- Guard asset_total <= 0 with HTTP 400 (fixes invalid clip creation)
- Update tests to verify transactional replace and zero-duration guard
- 40 tests passing
2026-08-28 00:20:04 +08:00
CI Bot fdc9573baa style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 35s
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 / Validate - Type Check (mypy) (pull_request) Successful in 4m22s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m43s
AI Code Review / AI Code Review (pull_request) Failing after 3m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m26s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m28s
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 - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 16:11:55 +00:00
xiaoxia 60263703a6 fix(generate): 片段校验改为只确认已创建,不死等 ready
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m35s
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 / Build Staging API Image (push) Successful in 4m59s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m25s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m38s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m38s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m51s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m47s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m59s
AI Code Review / AI Code Review (pull_request) Successful in 6m5s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m12s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m58s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m48s
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 - Code Quality (pull_request) Successful in 14m29s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Integration Tests (push) Successful in 8m28s
CI/CD Pipeline / Unit Tests (push) Successful in 16m29s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m57s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
上一版要求片段 status=ready 才放行 generate,导致:
- E2E 测试环境 MediaKit 不就绪时,前端死等 90s,generate 请求不发出,
  waitForResponse 30s 超时(core-generation.spec.ts:232)
- 真实环境 MediaKit 失败时用户无法触发生成

改为:轮询 listClips 只要存在任意片段(无论 ready/pending)即放行,
最多等 30s,超时也放行,由后端生成流程自行处理片段就绪/兜底。
loading 文案改为'正在准备素材片段...'。
2026-08-28 00:05:08 +08:00
CI Bot 030220f24d test: update existing clip tests for template-segment-based creation
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 / Check if frontend-only change (pull_request) Successful in 2m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 4m14s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m49s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 7m56s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 8m22s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 23:58:56 +08:00
xiaoxia 6c047cdc20 fix(e2e): 素材卡片点击命中播放按钮导致选中失败
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m49s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m17s
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 / 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 / Build Staging Web Image (push) Successful in 6m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
AI Code Review / AI Code Review (pull_request) Failing after 4m50s
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 API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m47s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m28s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 14m50s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 15m53s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m31s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 19m2s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 19m12s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 41m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 42m16s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
根因:Playwright 默认点击卡片中心(150px 宽卡片中心约 x=55,y=100),
正好命中 36×36 播放按钮。播放按钮 stopPropagation 只切换视频播放,
不会触发 onToggle 选中,因此勾选标记 ✓ 永远不出现,E2E 5s 超时失败。

修复:
- MaterialCard 勾选标记加 data-testid=material-card-check,卡片加 data-testid=material-card
- E2E 用 testid 定位卡片,点击左上角 (15,15) 避开播放按钮
- 勾选标记断言改用 testid,避免 ✓ 字符文本匹配不稳定
2026-08-27 23:22:21 +08:00
CI Bot 9510f69816 fix: rename unused loop var and add type annotation for ruff/mypy
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 / Check if frontend-only change (pull_request) Successful in 2m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m57s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 5m0s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m7s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 17m11s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 21m20s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 48m3s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 45m57s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 23:01:57 +08:00
CI Bot 42c4f22b21 style: auto-format with black + isort + prettier [skip ci-format-check]
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 / Check if frontend-only change (pull_request) Successful in 2m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m54s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-08-27 14:55:51 +00:00
CI Bot a2b37e376d feat(from-assets): create clips based on template segments instead of required_clips_count
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 27s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
AI Code Review / AI Code Review (pull_request) Successful in 6m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m29s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m4s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 14m32s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 49s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- Query template_segments/clip_configs by template_id, create exactly N clips matching segment count
- Each clip duration randomly chosen between segment duration_min and duration_max (1 decimal)
- Assets round-robin assigned across segments; same asset can produce multiple clips
- Delete all existing clips for the plan before creating new ones (idempotent)
- MediaKit recommendation only applied to the first clip using each asset; subsequent clips use random start times
- MediaKit timeout/poll settings unchanged (60s/2s/15)
- mark_clips_ready called immediately after creation
- Ignore required_clips_count from frontend
- Clips order starts from 0 since old clips are cleared
- Add _get_template_segments helper supporting both new (clip_configs) and old (template_segments) systems
- 28 unit tests covering segment query, clip creation, round-robin, deletion, duration bounds, MediaKit integration
2026-08-27 22:31:40 +08:00
xiaoxia 185c240557 refactor(editor): 移除模板编辑器素材 Tab
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) 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 / Check if frontend-only change (pull_request) Successful in 4m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m24s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m37s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m38s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 7m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m39s
AI Code Review / AI Code Review (pull_request) Failing after 12m32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 17m14s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 18m15s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
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 - Type Check (mypy) (push) Successful in 21m5s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 21m18s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m55s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m58s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m0s
CI/CD Pipeline / Validate - Code Quality (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 / 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
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
模板编辑器只定义模板规则(片段数量、时长范围),不再承载素材管理:
- MediaPanel 移除'📁 素材'Tab 及 AssetSelector 引用
- EditingPlanner 移除 mediaAssets/selectedAssetIds state
- useTemplateManagement/useTemplateList 不再加载 getMediaAssets
- 不影响用户端智能剪辑流程的素材选择功能
2026-08-27 22:30:07 +08:00
xiaoxia 5e8dcb9843 fix(generate): from-assets 去重 + 60s 超时 + 片段就绪轮询
- 移除 handleGenerate 中 from-assets 重复调用(useStep2Materials 已在选素材时 debounce 800ms 调用)
- createClipsFromAssets 单独设置 60s timeout(与后端 MediaKit 一致),支持 AbortSignal
- 调 generate 前轮询 listClips 确认存在 ready 片段,未就绪显示'正在智能选片中,请稍候...'
- 轮询最多 90s(MediaKit 60s + 缓冲),超时提示'智能选片超时,请重试'
- from-assets 请求超时/失败在 useStep2Materials 中 message.error 友好提示
2026-08-27 22:30:07 +08:00
xiaoxia 00067907f2 fix(generate): from-assets 改用 selectedTemplate,修复智能剪辑流程片段不创建
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m34s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m18s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m27s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m37s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m59s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m9s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m50s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m54s
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
AI Code Review / AI Code Review (pull_request) Successful in 8m7s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m58s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m4s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m48s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 10m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 10m59s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m53s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m34s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m3s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m21s
CI/CD Pipeline / Integration Tests (push) Successful in 4m37s
CI/CD Pipeline / Unit Tests (push) Successful in 14m17s
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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 13m41s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
sourceEditPlanId 在智能剪辑流程中为 null,导致条件判断永远为 false,
from-assets 不执行、clips 表为空,生成必然失败。
改用 selectedTemplate(模板 ID),后端 get_draft_plan_id 通过
template_id 自动关联 draft plan,与 useStep2Materials 行为一致。
2026-08-27 17:51:26 +08:00
xiaoxia 1667b3878d fix(ui): 模板选择卡片字体放大(名称13px、时长12px)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m2s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m19s
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 / 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 / Build Staging Worker Image (push) Successful in 4m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m31s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m55s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m11s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m38s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m55s
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 / PR Build Worker Image (pull_request) Successful in 1m42s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m7s
AI Code Review / AI Code Review (pull_request) Failing after 6m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m9s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m39s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m4s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 13m4s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 17:37:08 +08:00
xiaoxia 89cd853bbe fix(generate): 确认生成时显式调用 from-assets 创建片段
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m5s
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 / 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 / Validate - Type Check (mypy) (pull_request) Successful in 5m38s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m42s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 6m3s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 6m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m10s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m7s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m25s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m14s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m56s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 11m0s
AI Code Review / AI Code Review (pull_request) Failing after 6m46s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 12m36s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 13m2s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m39s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m15s
CI/CD Pipeline / Unit Tests (push) Successful in 17m8s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 5m19s
CI/CD Pipeline / Integration Tests (push) Successful in 5m7s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m23s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m1s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
在 handleGenerate 调用 createGenerationTask 之前,先显式调用
createClipsFromAssets 确保片段已创建,不再依赖 useStep2Materials
中 useEffect 的异步触发时机,避免用户快速点击生成时片段尚未
就绪导致后端报错「没有可渲染的就绪片段」。
2026-08-27 16:50:27 +08:00
xiaoxia 3bbe87b5c8 fix(ui): 模板选择卡片缩小为一屏6列(Task G)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Build Staging API Image (push) Successful in 4m36s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5m11s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m56s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m1s
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 / Validate - Migration (alembic) (push) Successful in 6m8s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m14s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m55s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 7m23s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m54s
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 / PR Build Worker Image (pull_request) Successful in 1m7s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m26s
CI/CD Pipeline / Validate - Code Quality (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 / 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 / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- xx-choice-list grid: 3列 → 6列,gap 12px → 8px
- 卡片内边距 10px → 6px,圆角 md → sm
- 封面缩略图 max-width 80px → 52px,height 36px → 24px
- 标题字号 13px → 11px,描述字号 11px → 10px
- 768px 断点 2列 → 4列
- 选中状态(蓝色边框+勾选标记)功能不变
2026-08-27 16:40:02 +08:00
xiaoxia 0c216bc545 feat(editor): MediaKit智能选片接入 (#1523)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 / Check if frontend-only change (pull_request) Successful in 4m39s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m1s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 5m5s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m6s
CI/CD Pipeline / PR Build Web Image (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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m47s
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
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m39s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m57s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m1s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 5m24s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m16s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m5s
AI Code Review / AI Code Review (pull_request) Failing after 8m19s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m19s
CI/CD Pipeline / Validate - Code Quality (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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 16:25:09 +08:00
xiaoxia f0dce3d41d feat(generate): 预览播放器对接服务端 clips,使用随机 start_time 和正确片段数
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m58s
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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m25s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m50s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m53s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 7m14s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m15s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m54s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 10m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m3s
AI Code Review / AI Code Review (pull_request) Successful in 7m44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 12m8s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m54s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration 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
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- useStep2Materials 在调用 /clips/from-assets 后获取服务端 clips 并通过回调解构
- GeneratePage 将 serverClips 传递给 FrontendPreviewPlayer
- buildPlaybackSegments 优先使用服务端 clips 的 start_time/duration,无 clips 时 fallback 到本地构建
- 确保预览与最终生成结果一致(片段数量、起始时间随机)
2026-08-27 16:12:10 +08:00
xiaoxia c2fe02cf06 fix(ui): 模板选择预览条宽度缩短至1/3,素材卡片改为点击播放交互
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m45s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m1s
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 (push) Successful in 5m6s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m26s
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 / Validate - Type Check (mypy) (pull_request) Successful in 5m49s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 6m41s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m50s
AI Code Review / AI Code Review (pull_request) Successful in 7m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m28s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m25s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m18s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m10s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 11m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m36s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m56s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 6m55s
CI/CD Pipeline / Integration Tests (push) Successful in 7m8s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m33s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 4m1s
CI/CD Pipeline / Unit Tests (push) Successful in 16m53s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m53s
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 / 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
CI/CD Pipeline / Unit Tests (pull_request) Successful in 16m48s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
- generate.css: xx-choice-thumb 宽度从 100% 改为 33%(max-width 80px),居中显示
- ManualMaterialList.tsx: 重构为独立 MaterialCard 组件
  - 移除 hover 自动播放逻辑
  - 默认显示视频封面/缩略图(非黑色)
  - 点击播放按钮开始播放,播放时隐藏按钮
  - 播放中点击视频区域可暂停并恢复按钮
  - 卡片内嵌播放,无弹窗
2026-08-27 15:47:28 +08:00
xiaoxia 195339d0f8 fix(e2e): 适配素材卡片网格UI + 注册限流重试
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m50s
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 / Check if frontend-only change (pull_request) Successful in 3m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m49s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m17s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m13s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m47s
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 / Validate - Migration (alembic) (pull_request) Successful in 5m15s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m52s
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
AI Code Review / AI Code Review (pull_request) Failing after 6m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m23s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m12s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m57s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m34s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m13s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m40s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m12s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- core-generation.spec.ts: 素材选择从 checkbox 改为点击卡片+验证✓标记
- test_asset.spec.ts: 注册接口增加 429 限流重试逻辑
2026-08-27 15:33:31 +08:00
xiaoxia 29c0d76677 fix: cross-task segment dedup - build used_segments from existing clips (#1522)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
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 Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3m17s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m14s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 5m28s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m30s
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 / Build Staging Web Image (push) Successful in 3m23s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m31s
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m5s
CI/CD Pipeline / Validate - Code Quality (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 / 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 / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-08-27 15:25:04 +08:00
xiaoxia 608e200c0c fix: 恢复被误回退的后端文件(#1519 #1520)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m54s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m35s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m39s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m32s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m50s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m41s
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 / Validate - Migration (alembic) (pull_request) Successful in 4m8s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m25s
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 6m41s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m43s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m22s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m11s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m34s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m8s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Integration Tests (push) Successful in 4m59s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 13m50s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 4m12s
CI/CD Pipeline / Unit 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 / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 15:06:00 +08:00
xiaoxia b12bb24d08 fix(ui): 模板选择预览条缩短+素材列表改为竖屏9:16卡片网格 (#1521)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (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 / 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 if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
2026-08-27 15:04:17 +08:00
xiaoxia 7cdd06802d fix: ensure cover title matches current video title (#1520)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m27s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m16s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m36s
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 / Validate - Migration (alembic) (push) Successful in 4m50s
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 / Build Staging Worker Image (push) Successful in 1m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m9s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m35s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m24s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m47s
AI Code Review / AI Code Review (pull_request) Failing after 4m1s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m42s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m12s
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 - Code Quality (push) Successful in 10m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m23s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m39s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m12s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m18s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 6m4s
CI/CD Pipeline / Unit Tests (push) Successful in 16m55s
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
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
2026-08-27 14:43:42 +08:00
84 changed files with 7258 additions and 1819 deletions
+201 -81
View File
@@ -82,6 +82,9 @@ jobs:
name: Validate - Code Quality
runs-on: ci-l2
timeout-minutes: 8
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
permissions:
contents: write
steps:
@@ -94,6 +97,14 @@ jobs:
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: /root/.cache/pip
key: ${{ runner.os }}-pip-codequality-${{ hashFiles('requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-codequality-
${{ runner.os }}-pip-
- name: Install dependencies
shell: sh
run: |
@@ -329,6 +340,8 @@ jobs:
runs-on: ci-l2
timeout-minutes: 8
env:
PIP_CACHE_DIR: /root/.cache/pip
PIP_NO_CACHE_DIR: ''
USE_IN_MEMORY_DB: 'true'
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
@@ -347,6 +360,14 @@ jobs:
- name: Install ffmpeg
shell: sh
run: bash scripts/ci/step_install_ffmpeg.sh
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: /root/.cache/pip
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-unittests-
${{ runner.os }}-pip-
- name: Run unit tests with coverage
shell: bash
env:
@@ -531,6 +552,13 @@ jobs:
- name: Record job start time
shell: sh
run: bash scripts/ci/step_timer_start.sh
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: /root/.npm
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install frontend dependencies (vitest only, with retry)
shell: sh
run: |
@@ -667,18 +695,7 @@ jobs:
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
# Worker: 始终用普通docker build(基础镜像已预装全部依赖,无需buildx)
if [ "${{ matrix.service }}" = "worker" ]; then
echo "Worker: 使用普通docker build"
BUILD_ARG_STR=""
for arg in $EXTRA_BUILD_ARGS; do
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
done
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
echo "PR Build successful (worker, no buildx)"
exit 0
fi
# Worker 与 API/Web 统一走持久 builderci-builder-persist),共享宿主机层缓存
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "PR Build attempt $i/3"
@@ -696,14 +713,14 @@ jobs:
done
echo
echo "${{ matrix.service_display }} PR build verified: ${IMAGE_TAG}"
- name: Cleanup buildx builder
- name: Builder cache note
if: always()
shell: sh
run: |
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
# 持久 builder (ci-builder-persist) 跨 job 共享,不删除不 prune;
# 残留容器/卷由宿主机 /usr/local/bin/ci-docker-cleanup.sh 兜底清理
docker buildx ls | head -5
echo "Persistent builder kept warm for next job"
- name: Job duration summary
if: always()
shell: sh
@@ -729,11 +746,49 @@ jobs:
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
check-push-paths:
name: Check push changed paths
runs-on: ci-l2
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
outputs:
skip_backend: ${{ steps.check.outputs.skip_backend }}
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Check changed paths
id: check
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
bash scripts/ci/ci_push_paths.sh
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
build-staging:
name: Build Staging ${{ matrix.service_display }} Image
runs-on: runtime-builder
timeout-minutes: ${{ matrix.timeout }}
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
needs: check-push-paths
if: |
github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') && (
(matrix.service == 'web' && needs.check-push-paths.outputs.skip_frontend != 'true') ||
(matrix.service != 'web' && needs.check-push-paths.outputs.skip_backend != 'true')
)
strategy:
fail-fast: false
matrix:
@@ -797,23 +852,16 @@ jobs:
echo "Cache mode: read-only"
fi
- name: Setup buildx builder
if: matrix.service != 'worker'
- name: Ensure persistent buildx builder
shell: sh
run: |
set -eu
if ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then
docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container
echo "Created ci-builder (docker-container driver)"
else
docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}
echo "Using existing ci-builder"
fi
docker buildx inspect --bootstrap
run: bash scripts/ci/ensure_persistent_builder.sh
- name: Pre-build worker base image (fallback if not exist)
- name: Pre-pull worker base image (fallback build if not exist)
if: matrix.service == 'worker'
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
@@ -821,11 +869,13 @@ jobs:
echo "检查 Worker 基础镜像..."
if docker pull "$BASE_IMAGE" 2>/dev/null; then
echo "✅ 基础镜像已存在"
echo "✅ 基础镜像已存在buildkit 可直接命中)"
else
echo "⚠️ 基础镜像不存在,本地构建fallback..."
docker build -f infra/docker/worker-base.Dockerfile -t "$BASE_IMAGE" .
echo "✅ Worker 基础镜像本地构建完成"
echo "⚠️ 基础镜像不存在,用持久 builder 构建并推送fallback..."
docker buildx build --builder ci-builder-persist \
-f infra/docker/worker-base.Dockerfile \
-t "$BASE_IMAGE" --push .
echo "✅ Worker 基础镜像构建推送完成"
fi
- name: Build and push ${{ matrix.service_display }} image
@@ -833,48 +883,34 @@ jobs:
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}"
IMAGE_FULL="${REGISTRY}/${{ matrix.image_name }}"
IMAGE_TAG="${IMAGE_FULL}:${GITHUB_SHA}"
# 同时推分支 tag,作为未重建镜像 retag 的稳定来源
BRANCH_TAG="${IMAGE_FULL}:${GITHUB_REF_NAME}"
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
if [ "${{ matrix.service }}" = "worker" ]; then
# Worker: plain docker build(基础镜像已预装全部依赖,无需 buildx)
echo "=== Worker: plain docker build ==="
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" --build-arg APP_VERSION="${GITHUB_SHA}" .
docker push "${IMAGE_TAG}"
echo "✅ Worker image pushed: ${IMAGE_TAG}"
else
# API/Web: buildx with registry cache
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 ==="
if bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
echo "❌ Docker build 失败(尝试 $i/3"
[ $i -eq 3 ] && exit 1
sleep 10
if [ $i -eq 2 ]; then
NO_CACHE_FLAG="--no-cache"
echo "下次重试将使用 --no-cache"
fi
done
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG}"
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
if [ "${{ matrix.service }}" = "web" ]; then
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
fi
- name: Cleanup buildx builder
if: matrix.service != 'worker' && always()
shell: sh
run: |
docker buildx rm ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} 2>/dev/null || true
docker buildx rm ci-builder 2>/dev/null || true
docker buildx prune -f 2>/dev/null || true
echo "Builder cleanup done"
NO_CACHE_FLAG=""
for i in 1 2 3; do
echo "=== Docker build 尝试 $i/3 (${{ matrix.service_display }}) ==="
if EXTRA_TAGS="$BRANCH_TAG" bash scripts/ci/docker_build_push.sh $NO_CACHE_FLAG ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}" $EXTRA_BUILD_ARGS; then
echo "✅ Docker build 成功"
break
fi
echo "❌ Docker build 失败(尝试 $i/3"
[ $i -eq 3 ] && exit 1
sleep 10
if [ $i -eq 2 ]; then
NO_CACHE_FLAG="--no-cache"
echo "下次重试将使用 --no-cache"
fi
done
echo "${{ matrix.service_display }} image pushed: ${IMAGE_TAG} (+ ${BRANCH_TAG})"
- name: Job duration summary
if: always()
@@ -902,16 +938,100 @@ jobs:
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
retag-staging-skipped:
name: Retag skipped Staging ${{ matrix.service_display }} Image
runs-on: ci-l2
timeout-minutes: 10
needs:
- check-push-paths
- build-staging
if: |
github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop') && (
(matrix.service == 'web' && needs.check-push-paths.outputs.skip_frontend == 'true') ||
(matrix.service != 'web' && needs.check-push-paths.outputs.skip_backend == 'true')
)
strategy:
fail-fast: false
matrix:
include:
- service: api
service_display: API
image_name: xiaoxia-saas-api
- service: worker
service_display: Worker
image_name: xiaoxia-saas-worker
- service: web
service_display: Web
image_name: xiaoxia-saas-web
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
- name: Docker login to Registry
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "Docker login 失败($i/3),5s 后重试..."
sleep 5
done
- name: Retag previous branch image to new SHA
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
bash scripts/ci/retag_skipped_image.sh \
"${REGISTRY}/${{ matrix.image_name }}" \
"${GITHUB_SHA}" \
"${GITHUB_REF_NAME}"
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Retag Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
- name: Report CI trace
if: always()
shell: sh
env:
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
run: |
STATUS="ok"
[ ${{ job.status }} = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
deploy-staging:
name: Deploy Staging (Watchtower auto-deploy)
runs-on: runtime-builder
runs-on: ci-l2
timeout-minutes: 15
concurrency:
group: deploy-staging-${{ gitea.ref }}
cancel-in-progress: false
needs:
- check-push-paths
- build-staging
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
- 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')
steps:
- name: Checkout code
shell: sh
@@ -1062,7 +1182,7 @@ jobs:
name: Staging E2E Tests
runs-on: runtime-builder
timeout-minutes: 15
if: github.ref_name == 'develop' || github.ref_name == 'main'
if: success() && (github.ref_name == 'develop' || github.ref_name == 'main')
needs: deploy-staging
steps:
- name: Checkout code
@@ -1109,7 +1229,7 @@ jobs:
name: Staging API Integration Tests
runs-on: runtime-builder
timeout-minutes: 10
if: github.ref_name == 'develop' || github.ref_name == 'main'
if: success() && (github.ref_name == 'develop' || github.ref_name == 'main')
needs: deploy-staging
steps:
- name: Checkout code
@@ -1491,7 +1611,7 @@ jobs:
acr-cleanup:
name: ACR Image Cleanup
runs-on: runtime-builder
runs-on: ci-l2
timeout-minutes: 10
needs:
- deploy-staging
@@ -1817,4 +1937,4 @@ jobs:
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
START_TIME=""
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
+52 -5
View File
@@ -26,6 +26,7 @@ from app.schemas.asset import (
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from app.services.asset_segment_tracker import compute_asset_availability
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.domain.smart_match import smart_select_assets
@@ -35,6 +36,23 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _asset_availability_fields(item) -> dict:
"""视频素材返回余量四字段;非视频/无时长/异常时返回 None + usable=True(零影响)。"""
try:
info = compute_asset_availability(item)
except Exception:
logger.warning("计算素材余量失败,按可用处理: asset_id=%s", getattr(item, "id", "?"), exc_info=True)
info = None
if info is None:
return {
"used_duration": None,
"available_duration": None,
"used_ratio": None,
"usable": True,
}
return info
def _to_asset_response(item, storage_service=None) -> AssetResponse:
# 生成签名文件 URL(用于视频播放 / 文件下载)
file_url = None
@@ -46,10 +64,16 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
file_url = None
# 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
thumbnail_url = item.thumbnail_url
if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
thumbnail_url = file_url
# 缩略图:存储的是 storage_key,需要生成签名 URL 供前端使用
# 不再降级使用视频文件 URL(浏览器 <img> 无法渲染 .mp4,会显示黑屏)
thumbnail_url = None
if item.thumbnail_url:
try:
svc = storage_service or get_storage_service()
thumbnail_url = svc.get_download_url(item.thumbnail_url)
except Exception:
logger.warning("生成缩略图签名URL失败: key=%s", item.thumbnail_url, exc_info=True)
thumbnail_url = None
return AssetResponse(
id=item.id,
@@ -73,6 +97,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []),
**_asset_availability_fields(item),
)
@@ -563,13 +588,35 @@ def smart_match_assets(
kind=None,
)
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
filtered_results = []
for r in results:
try:
avail = compute_asset_availability(r.asset)
except Exception:
logger.warning(
"smart-match 余量计算失败,按可用处理: asset_id=%s",
getattr(r.asset, "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)
items = [
SmartMatchItem(
asset=_to_asset_response(r.asset),
score=r.score,
breakdown=r.breakdown,
)
for r in results
for r in filtered_results
]
return SmartMatchResponse(items=items, total_candidates=total_candidates)
+326 -76
View File
@@ -1,15 +1,18 @@
"""封面生成路由 — Generation 模块.
端点:
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退
挂载路径: /api/v1/generation/generate-cover
"""
from __future__ import annotations
import ipaddress
import logging
import re
from typing import Any, List, Optional
from urllib.parse import urlparse
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository
@@ -24,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from packages.shared.storage import get_shared_storage_service
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
@@ -51,6 +55,14 @@ class GenerateCoverRequest(BaseModel):
default=None,
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
)
generated_video_id: Optional[str] = Field(
default=None,
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。",
)
video_url: Optional[str] = Field(
default=None,
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。",
)
class GenerateCoverResponse(BaseModel):
@@ -121,8 +133,6 @@ def _persist_cover_frame(
exc_info=True,
)
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
@@ -140,6 +150,106 @@ def _persist_cover_frame(
Path(tmp_path).unlink(missing_ok=True)
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task_id)
if videos:
return getattr(videos[0], "file_url", "") or ""
except Exception:
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
return None
def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]:
"""将 storage_key 或完整 URL 转换为可访问的裸 URL。"""
if not storage_key:
return None
try:
if storage_key.startswith("http"):
url = storage_key
else:
storage_svc = get_shared_storage_service()
url = storage_svc.get_url(storage_key)
if url:
url = re.sub(r"(?<!:)//", "/", url)
return url
except Exception as e:
logger.warning("[封面生成] storage_key 转 URL 失败: key=%s err=%s", storage_key, e)
return None
def _endpoint_host(value: str) -> str:
"""从 endpoint / URL 字符串中安全提取主机名(兼容有无 scheme 两种配置)。"""
v = (value or "").strip().lower()
if not v:
return ""
if "://" in v:
return (urlparse(v).hostname or "").lower()
# 无 scheme:去掉可能的端口(host:port),urlparse 补 // 以正确解析
return (urlparse("//" + v).hostname or "").lower()
def _is_private_or_reserved_host(host: str) -> bool:
"""判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。
使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。
"""
h = host.strip().lower()
if h in {"localhost", "0.0.0.0", "::", "::1"}:
return True
try:
addr = ipaddress.ip_address(h)
# is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、
# ::1、fc00::/7、fe80::/10 等全部私有/保留段
return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved)
except ValueError:
return False
def _is_trusted_media_url(url: str) -> bool:
"""校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。
用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL
因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。
"""
if not url:
return False
try:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").lower()
if not host:
return False
# 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定)
if _is_private_or_reserved_host(host):
return False
# 允许:自家 OSS bucket 域名(<bucket>.<endpoint>)或 endpoint 自身及其子域
try:
storage_svc = get_shared_storage_service()
trusted_hosts = set()
public_base = getattr(storage_svc, "public_url", "") or ""
h1 = _endpoint_host(public_base)
if h1:
trusted_hosts.add(h1)
h2 = _endpoint_host(getattr(storage_svc, "endpoint", "") or "")
if h2:
trusted_hosts.add(h2)
for trusted in trusted_hosts:
if host == trusted or host.endswith("." + trusted):
return True
except Exception:
logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True)
return False
return False
except Exception:
logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True)
return False
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def generate_cover(
body: GenerateCoverRequest,
@@ -149,12 +259,16 @@ def generate_cover(
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse:
"""AI 生成封面 — 从预览视频中抽帧.
"""AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段.
流程(串行):
1. 预览视频已渲染完成(通过 3 步查找获取 URL)
2. 用裸 URL 让 MediaKit 下载视频并抽帧
3. 帧图下载后上传到 OSS covers/ 路径
1. 优先使用前端传入的 generation_task_id 定位最终成片任务,
或自动查找 plan 关联的已完成最终成片任务(is_preview=False
2. 回退:从预览片段获取视频 URL(兼容旧流程)
3. 用裸 URL 让 MediaKit 下载视频并抽帧
4. 帧图下载后上传到 OSS covers/ 路径
MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。
"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
@@ -182,27 +296,111 @@ def generate_cover(
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
# 第一步:从 plan.config 读取
# ── 查找用于抽帧的视频 URL ────────────────────────────────────────
# 优先级:
# 0. 请求体显式传入的 generation_task_id(最终成片任务)
# 1. plan.config.rendered_storage_key
# 2. plan.config.generation_task_id 对应的任务
# 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False
# 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退)
# 5. user + template 最近的已完成预览任务(兜底)
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
# 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url
if not rendered_storage_key:
# 0a:通过 generated_video_id 查找最终成片视频
if body.generated_video_id:
logger.info(
"[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
try:
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
file_url = getattr(gv, "file_url", "") or ""
if file_url:
# 权限校验(双重,任何一层确认归属不符即拒绝):
# 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行)
gv_owner = (getattr(gv, "user_id", "") or "").strip()
if gv_owner and gv_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
# 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行:
# 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问
gv_task_id = getattr(gv, "generation_task_id", "") or ""
task0 = None
if gv_task_id:
try:
task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id)
except Exception:
logger.warning(
"[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s",
plan_id,
gv_task_id,
exc_info=True,
)
if task0 is not None:
task_owner = (getattr(task0, "created_by_user_id", "") or "").strip()
if task_owner and task_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
elif not gv_owner:
# video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权
logger.warning(
"[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
raise HTTPException(status_code=403, detail="无权访问该视频")
rendered_storage_key = file_url
logger.info(
"[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s",
plan_id,
body.generated_video_id,
file_url[:80],
)
except HTTPException:
raise
except Exception:
logger.warning(
"[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
exc_info=True,
)
# 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF
if not rendered_storage_key and body.video_url:
if _is_trusted_media_url(body.video_url):
logger.info(
"[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
rendered_storage_key = body.video_url
else:
logger.warning(
"[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
# 步骤 2:通过 plan.config.generation_task_id 查找
if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "")
logger.info(
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
)
if generation_task_id:
logger.info(
"[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
)
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
task = gen_task_repo.get(generation_task_id)
_repo = SQLAlchemyGenerationTaskRepository(db)
task = _repo.get(generation_task_id)
if task:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
rendered_storage_key = _get_task_video_url(db, task.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
plan_id,
@@ -211,26 +409,23 @@ def generate_cover(
)
except Exception:
logger.warning(
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
"[封面生成] 步骤2查找失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务
# 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False
if not rendered_storage_key:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id)
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(pt.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id)
all_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in all_tasks:
if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s",
"[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
rendered_storage_key[:80],
@@ -238,66 +433,74 @@ def generate_cover(
break
except Exception:
logger.warning(
"封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s",
"[封面生成] 步骤3查找最终成片失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
# 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务
if not rendered_storage_key:
try:
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
preview_tasks = gen_task_repo.list_latest_completed_preview(
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id)
preview_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤4查找预览任务失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 5:按 user + template 查找最近的已完成预览任务(兜底)
if not rendered_storage_key:
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info(
"[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s",
plan_id,
template_id,
)
preview_tasks = _repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
if preview_tasks:
completed_preview = preview_tasks[0]
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(completed_preview.id)
if videos:
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or ""
if rendered_storage_key:
logger.info(
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
"[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s",
plan_id,
template_id,
completed_preview.id,
preview_tasks[0].id,
)
except Exception:
logger.warning(
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s",
plan_id,
template_id,
exc_info=True,
)
# 使用裸 URLrendered/* 已配置公开读);找不到渲染视频时不立即报错,
# 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
# 将 storage_key 转换为可访问 URL;找不到视频时不立即报错,
# 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
primary_video_url = None
if rendered_storage_key:
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
try:
if rendered_storage_key.startswith("http"):
primary_video_url = rendered_storage_key
else:
from packages.shared.storage import get_shared_storage_service
storage_svc = get_shared_storage_service()
primary_video_url = storage_svc.get_url(rendered_storage_key)
if primary_video_url:
import re as _re
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
logger.info(
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
plan_id,
primary_video_url[:80] if primary_video_url else "",
)
except Exception as e:
logger.warning("获取预览视频URL失败: plan_id=%s err=%s", plan_id, e)
primary_video_url = None
primary_video_url = _resolve_storage_key_to_url(rendered_storage_key)
logger.info(
"[封面生成] 封面抽帧视频URL: plan_id=%s url=%s",
plan_id,
primary_video_url[:80] if primary_video_url else "",
)
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
@@ -326,20 +529,67 @@ def generate_cover(
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联预览任务的 cover_url
# 步骤 A2:通过 generated_video_id 查找关联任务的 cover_url
if not cover_url_from_task and body.generated_video_id:
try:
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
gv_task_id = getattr(gv, "generation_task_id", "") or ""
if gv_task_id:
task_a2 = gen_task_repo.get(gv_task_id)
if task_a2 and getattr(task_a2, "cover_url", ""):
cover_url_from_task = task_a2.cover_url
logger.info(
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
plan_id,
body.generated_video_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤A2读取 cover_url 失败: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联任务的 cover_url
# 优先最终成片任务(is_preview=False),其次预览任务
if not cover_url_from_task:
try:
preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "cover_url", ""):
all_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
# 先找最终成片
for pt in all_tasks:
if (
getattr(pt, "status", "") == "completed"
and not getattr(pt, "is_preview", False)
and getattr(pt, "cover_url", "")
):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤B-source_plan): plan_id=%s task_id=%s url=%s",
"[封面生成] 封面(步骤B-final): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
# 再找预览
if not cover_url_from_task:
for pt in all_tasks:
if (
getattr(pt, "status", "") == "completed"
and getattr(pt, "is_preview", False)
and getattr(pt, "cover_url", "")
):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 封面(步骤B-preview): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
@@ -358,6 +358,41 @@ def create_preview_generation_task(
exc_info=True,
)
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
# 保证各预览版本内容不同
if task.source_edit_plan_id:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
_preview_plan = _plan_svc.clone_plan_for_variant(
task.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
task.source_edit_plan_id = _preview_plan.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
task.id,
_preview_plan.id,
)
except Exception as clone_err:
# 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
# 标记任务失败并中断,前端可重新发起预览
logger.error(
"[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
task.id,
clone_err,
exc_info=True,
)
_mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from clone_err
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
+68 -13
View File
@@ -1,5 +1,4 @@
import logging
import random
import uuid
from typing import Any
@@ -116,8 +115,8 @@ def _select_assets_from_library(
Args:
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效)
Returns:
选中的素材 ID 列表
@@ -127,12 +126,6 @@ def _select_assets_from_library(
if not ready_video_assets:
return []
if mode == "random":
selected = (
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
)
return [a.id for a in selected]
if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
@@ -169,8 +162,22 @@ def _writeback_edit_plan_config(
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id, old_title_text, new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
@@ -278,8 +285,8 @@ def create_generation_task(
mode=request.asset_select_mode,
count=request.asset_select_count,
)
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id)
if assets:
resolved_asset_ids = _select_assets_from_library(
@@ -411,8 +418,56 @@ def create_generation_task(
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
effective_strategy_id = "one_take"
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
variant_plan_ids: list[str] = []
if count > 1 and request.source_edit_plan_id:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
try:
for _ in range(count):
for task_index in range(count):
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
@@ -424,7 +479,7 @@ def create_generation_task(
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
source_edit_plan_id=effective_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
+564 -65
View File
@@ -15,17 +15,33 @@
from __future__ import annotations
import json
import logging
import random
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
from app.dependencies import get_asset_repository, get_db_session
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
get_used_segments,
make_reuse_callback,
record_used_segments,
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
from packages.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
@@ -351,29 +367,244 @@ def batch_delete_editor_clips(
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
def _safe_segment_duration(value, default: float) -> float:
"""安全地将数据库中的时长值转换为正浮点数.
处理 None、无效类型、负数、NaN 等异常情况。
"""
if value is None:
return default
try:
result = float(value)
except (ValueError, TypeError):
return default
if result != result or result <= 0: # NaN check or non-positive
return default
return result
def _get_template_segments(
template_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_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:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
return []
def _recommended_time_conflicts(
start: float,
duration: float,
used: list[tuple[float, float]],
edge_gap: float = SEGMENT_EDGE_GAP,
) -> bool:
"""检查推荐起始时间是否与已使用时间段冲突.
冲突检测统一加 ``edge_gap`` 秒边缘间隙:已用区间按 [s-gap, e+gap] 扩边后判定,
避免推荐片段与已用片段首尾紧贴导致画面观感重复。
"""
end = start + duration
for used_start, used_end in used:
if start < used_end + edge_gap and end > used_start - edge_gap:
return True
return False
def _get_mediakit_recommendations(
asset_ids: list[str],
asset_repo,
) -> dict[str, float]:
"""调用 MediaKit 视频理解,获取智能选片推荐起始时间.
尝试让 MediaKit 分析视频内容,返回每个素材的推荐起始时间。
任何异常都优雅降级,返回空字典(调用方降级到随机选择)。
"""
try:
client = get_mediakit_client()
if not client.is_available:
logger.info("MediaKit 未配置,使用随机起始时间")
return {}
storage = get_storage_service()
video_urls: list[str] = []
valid_asset_ids: list[str] = []
for asset_id in asset_ids[:10]:
asset = asset_repo.get(asset_id)
if not asset or not getattr(asset, "storage_key", None):
continue
mime = getattr(asset, "mime_type", "")
if not mime.startswith("video/"):
continue
try:
url = storage.get_download_url(asset.storage_key)
if url:
video_urls.append(url)
valid_asset_ids.append(asset_id)
except Exception as e:
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
if not video_urls:
return {}
prompt = (
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
'请严格以JSON数组格式返回,不要包含其他文字:'
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
)
contents = client.analyze_videos(
video_urls=video_urls,
prompt=prompt,
level="Economy",
poll_interval=2.0,
max_poll_attempts=15,
)
if not contents:
logger.info("MediaKit 分析无结果,降级为随机选择")
return {}
# 按索引映射结果:contents[i] 对应 valid_asset_ids[i]
recommendations: dict[str, float] = {}
for idx, content_text in enumerate(contents):
if idx >= len(valid_asset_ids):
break
asset_id = valid_asset_ids[idx]
if not content_text:
continue
# 尝试从文本中提取 JSON
parsed = False
# 尝试直接解析
try:
data = json.loads(content_text.strip())
if isinstance(data, list) and data:
for item in data:
if isinstance(item, dict) and "recommended_start_time" in item:
recommendations[asset_id] = float(item["recommended_start_time"])
parsed = True
break
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 尝试从 markdown 代码块中提取 JSON
if not parsed:
json_match = re.search(r"\[\s*(\{.*?\})\s*\]", content_text, re.DOTALL)
if json_match:
try:
item = json.loads(json_match.group(1))
if isinstance(item, dict) and "recommended_start_time" in item:
recommendations[asset_id] = float(item["recommended_start_time"])
parsed = True
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 尝试正则提取
if not parsed:
time_match = re.search(
r'recommended_start_time["\s:]+([\d.]+)', content_text
)
if time_match:
try:
recommendations[asset_id] = float(time_match.group(1))
except (ValueError, TypeError):
pass
if recommendations:
logger.info("MediaKit 智能选片推荐: %s", recommendations)
else:
logger.info("MediaKit 结果解析失败,降级为随机选择")
return recommendations
except Exception as e:
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
return {}
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor(
template_id: str,
body: ClipsFromAssetsRequest,
background_tasks: BackgroundTasks,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(支持同一素材切多个片段 + 随机起始时间 + 去重.
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新.
逻辑:
1. 模板要求 N 个片段,必须创建 N 个(不管素材有几个
2. 素材数量 < 片段数量时,同一素材轮询切多个片段
3. 每个片段从素材中随机选取不重复时间
4. 素材时长不足 5s 时缩短 clip duration
5. 新片段追加到时间线末尾(order 在现有最大值基础上递增
1. 模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间)
5. 立即返回响应(目标 <1秒
6. 后台异步任务:调用 MediaKit 智能选片并更新片段的 start_time
7. 素材时长为 0 或缺失时报 400,不创建无效片段
"""
_, plan_svc = services
tpl_svc, plan_svc = services
required_count = body.required_clips_count if body.required_clips_count is not None else len(body.asset_ids)
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
)
# 去重后批量获取素材实际时长,避免重复查询
if not body.asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材列表为空,无法创建片段",
)
# 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
asset_durations: dict[str, float] = {}
for asset_id in unique_asset_ids:
@@ -381,74 +612,342 @@ def create_clips_from_assets_editor(
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 计算追加起始 order:当前 plan 已有片段的最大 order + 1
# list_clips 返回 List[EditPlanClip]
existing_clips_list = plan_svc.list_clips(plan_id)
next_order = max((c.order for c in existing_clips_list), default=-1) + 1
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
# 格式与 _calc_random_start_time 的 used_segments 参数一致
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(
db, unique_asset_ids
)
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
# 复用片段时长累加到 reused_durations 供 15% 占比控制
reused_durations: dict[str, float] = {}
# 本条成片中每个素材被分配的片段总时长(复用占比分母)
asset_assigned_durations: dict[str, float] = {}
# 受控复用回调:区间耗尽时复用最久未用且 use_count<3 的历史区间;
# 回调内部预判复用后占比是否超 15%,超限拒绝复用(返回 None)
reuse_cb = make_reuse_callback(
db,
asset_durations,
reused_durations,
assigned_tracker=asset_assigned_durations,
)
clips_data: list[dict] = []
used_segments: dict[str, list[tuple[float, float]]] = {}
clips = []
def _reuse_ratio_exceeded(aid: str, extra: float = 0.0) -> bool:
"""该素材在本条成片中「已复用片段时长 / 已分配片段总时长」是否已超 15%
for i in range(required_count):
# 轮询分配素材:素材不够时同一素材切多个片段
asset_id = body.asset_ids[i % len(body.asset_ids)]
在为下一片段选素材时调用:本片段尚未分配,复用状态只在分配后的回调里
更新,因此直接检查当前占比——一旦已超 15%,该素材不再参与后续分配。
assigned=0(首个片段)放行;reused=0(尚未发生复用)时不误拦正常分配。
"""
assigned = asset_assigned_durations.get(aid, 0.0)
if assigned <= 0:
return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
# 素材时长不足时缩短 clip duration
asset_total = asset_durations.get(asset_id, 0.0)
if asset_total > 0:
clip_duration = min(_DEFAULT_EDITOR_CLIP_DURATION, asset_total)
else:
clip_duration = _DEFAULT_EDITOR_CLIP_DURATION
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
# 计算随机 start_time,避开已使用的时间段
start_time = _calc_random_start_time(asset_id, clip_duration, asset_durations, used_segments)
if start_time is None:
# 素材时长信息缺失,无法计算随机起始时间
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续轮询下一个素材
asset_id = ""
clip_duration = 0.0
start_time: float | None = None
n_assets = len(body.asset_ids)
for offset in range(n_assets):
candidate = body.asset_ids[(i + offset) % n_assets]
candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0:
continue
candidate_duration = min(round(raw_duration, 1), candidate_total)
if candidate_duration <= 0:
continue
if _reuse_ratio_exceeded(candidate, candidate_duration):
logger.info(
"from-assets 素材复用占比超 %.0f%% 阈值,跳过分配: asset_id=%s",
REUSE_RATIO_LIMIT * 100,
candidate,
)
continue
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 15% 则拒绝并返回 None)
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
)
if candidate_start is None:
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材
logger.info(
"from-assets 素材无可用可切区间(复用被拒),轮询下一素材: asset_id=%s",
candidate,
)
continue
asset_id = candidate
clip_duration = candidate_duration
start_time = candidate_start
break
if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"素材 {asset_id} 时长信息缺失,无法创建片段",
detail="素材可切区间不足,请补充新素材",
)
# 记录已使用时间段(用于去重
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
# 记录已使用时间段(内存,供本次后续片段避开
used_segments.setdefault(asset_id, []).append(
(start_time, start_time + clip_duration)
)
asset_assigned_durations[asset_id] = (
asset_assigned_durations.get(asset_id, 0.0) + clip_duration
)
# 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional
# 处于同一事务,任一步失败整体回滚,不留脏数据);
# 复用区间与历史记录高度重叠时 record 内部自动累加 use_count
record_used_segments(
db, asset_id, start_time, start_time + clip_duration, plan_id
)
try:
clip = plan_svc.create_clip(
plan_id,
clip_type=body.clip_type or "main",
order=next_order + i,
duration=clip_duration,
start_time=start_time,
asset_id=asset_id,
)
clips.append(clip)
except ValueError as exc:
logger.error(
"创建片段失败: plan_id=%s asset_id=%s order=%d error=%s",
plan_id,
asset_id,
next_order + i,
exc,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"创建片段失败: {exc}",
) from exc
clips_data.append(
{
"order": i,
"asset_id": asset_id,
"start_time": start_time,
"duration": clip_duration,
"clip_type": body.clip_type or "main",
}
)
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
logger.info(
"模板编辑器从素材创建片段: template_id=%s plan_id=%s required=%d actual=%d by user=%s",
"from-assets按模板创建片段(异步): template_id=%s plan_id=%s segments=%d created=%d by user=%s",
template_id,
plan_id,
required_count,
len(clips),
len(segments),
created_count,
current_user.user.id,
)
# 新创建的片段已分配素材,立即标记为 ready,否则渲染管线找不到就绪片段
plan_svc.mark_clips_ready(plan_id)
return ClipsFromAssetsResponse(
created_count=len(clips),
plan_id=plan_id,
clip_ids=[c.id for c in clips],
# 5. 触发后台任务:异步调用 MediaKit 并更新片段起始时间
background_tasks.add_task(
_update_mediakit_recommendations_async,
plan_id,
unique_asset_ids,
)
# 6. 立即返回响应
return ClipsFromAssetsResponse(
created_count=created_count,
plan_id=plan_id,
clip_ids=[],
)
def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str,
asset_ids: list[str],
) -> None:
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
"""
from collections import defaultdict
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal
db = None
try:
# 复用应用全局 Session(避免每次创建新连接池导致资源泄漏)
if SessionLocal is None:
logger.warning("后台任务: SessionLocal 未初始化,跳过 MediaKit 更新")
return
db = SessionLocal()
# 初始化服务
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 = []
offset = 0
while True:
batch = plan_svc.list_clips(plan_id, skip=offset, limit=batch_size)
if not batch:
break
all_clips.extend(batch)
if len(batch) < batch_size:
break
offset += batch_size
clips = all_clips
if not clips:
logger.info("后台任务: plan_id=%s 无片段,跳过更新", plan_id)
return
# 批量预加载所有涉及的素材(消除 N+1 查询)
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)
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)
)
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
updated_clip_ids: set[str] = set()
# 已更新的时间段
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:
continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id)
if not asset:
continue
asset_total = float(getattr(asset, "duration", 0.0) or 0.0)
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)
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)
except Exception as ue:
logger.warning(
"后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue
)
try:
db.rollback()
except Exception:
pass
continue
updated_segments.setdefault(asset_id, []).append(
(recommended_start, recommended_start + clip_duration)
)
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
except Exception as e:
# 后台任务失败不影响已创建的片段,静默处理
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
if db:
try:
db.rollback()
except Exception:
pass
finally:
if db:
try:
db.close()
except Exception:
pass
+8
View File
@@ -53,6 +53,14 @@ class AssetResponse(BaseModel):
created_at: str
uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list)
# 片段级余量信息(仅视频素材返回,非视频/无时长记录为 None,前端按可用处理)
used_duration: float | None = Field(default=None, description="已使用片段时长(秒,历史区间合并去重后)")
available_duration: float | None = Field(default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长")
used_ratio: float | None = Field(default=None, description="已用时长占比(0~1")
usable: bool = Field(
default=True,
description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" "use_count)均达上限时为 false",
)
MAX_BATCH_SIZE = 200
+2 -4
View File
@@ -45,11 +45,9 @@ class CreateGenerationTaskRequest(BaseModel):
# ── 素材库自动匹配 ──
asset_select_mode: str = Field(
default="all",
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
description="素材选取模式:all=全部ready视频, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(default=0, ge=0, le=100, description="选取数量,0表示全部(仅 smart 模式有效)")
# ── 自动重试 ──
auto_retry_enabled: bool = Field(
default=False,
@@ -0,0 +1,440 @@
"""素材片段级使用记录追踪与受控复用.
在素材 metadataassets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``::
"used_time_ranges": [
{
"start": 12.5, "end": 20.3,
"plan_id": "plan-xxx",
"created_at": "2026-08-29T12:00:00+00:00",
"use_count": 1, # 该区间累计被使用次数(复用一次 +1)
"last_used_at": "2026-08-29T12:00:00+00:00" # 最近一次使用时间
},
...
]
注意:本模块所有函数都不自行 commit,由调用方控制事务边界
from-assets 与 replace_all_clips_transactional 同事务;异步任务各自 commit)。
历史记录永不自动清空(自动轮回重置已下线,reset_used_segments 仅保留给运维/测试)。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel
logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
# 判定"新片段与历史区间为同一次使用(复用)"的重叠率阈值:
# 重叠时长 / 新区间时长超过该比例视为复用该历史区间(累加 use_count)而非新增记录。
_REUSE_OVERLAP_RATIO = 0.6
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _read_meta(model) -> dict:
"""读取素材 metadata dict。
兼容两种对象:
- ORM ``AssetModel``metadata 以 JSON 字符串存在 ``classification_result`` 列;
- 领域实体 ``Asset``(路由层 repository 返回):metadata 直接是 dict 属性
repository 与 classification_result 互转,见 asset_repository.py)。
"""
# 领域实体:metadata 已是 dict
meta = getattr(model, "metadata", None)
if isinstance(meta, dict):
return meta
raw = getattr(model, "classification_result", None)
if not raw:
return {}
try:
data = json.loads(raw) if isinstance(raw, str) else raw
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _get_model(db: Session, asset_id: str, for_update: bool = False) -> AssetModel | None:
query = db.query(AssetModel).filter(AssetModel.id == asset_id)
if for_update:
# 行级锁(PostgreSQL SELECT ... FOR UPDATE):序列化同一素材的
# classification_result 读-改-写,避免并发事务丢失使用记录。
# SQLite 不支持时 SQLAlchemy 会忽略该子句(no-op)。
query = query.with_for_update()
return query.first()
def get_used_segments(db: Session, asset_ids: list[str]) -> dict[str, list[tuple[float, float]]]:
"""聚合多个素材的历史已用片段区间。
Returns:
``{asset_id: [(start, end), ...]}`` 格式,与 ``_calc_random_start_time`` 的
``used_segments`` 参数格式一致,可直接传入。
"""
if not asset_ids:
return {}
result: dict[str, list[tuple[float, float]]] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(list(set(asset_ids)))).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
segments: list[tuple[float, float]] = []
for r in ranges:
try:
segments.append((float(r["start"]), float(r["end"])))
except (KeyError, TypeError, ValueError):
continue
if segments:
result[model.id] = segments
return result
def record_used_segments(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str,
) -> None:
"""记录一次片段使用(不 commit.
若新区间与某条历史区间高度重叠(复用场景,如受控复用回调返回的区间、
MediaKit 挪到历史区间),则累加该记录的 ``use_count`` 并刷新 ``last_used_at``
不新增记录;否则追加一条新记录(use_count=1)。
"""
# 行级锁读取:与并发生成任务互斥,保证区间记录读-改-写一致
model = _get_model(db, asset_id, for_update=True)
if model is None:
logger.warning("[片段追踪] 素材不存在,跳过记录: asset_id=%s", asset_id)
return
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
new_start = round(float(start), 3)
new_end = round(float(end), 3)
new_dur = max(new_end - new_start, 1e-6)
now = _now_iso()
for r in ranges:
try:
rs, re_ = float(r["start"]), float(r["end"])
except (KeyError, TypeError, ValueError):
continue
overlap = max(0.0, min(new_end, re_) - max(new_start, rs))
if overlap / new_dur >= _REUSE_OVERLAP_RATIO:
# 复用同一条历史区间:累加次数、刷新时间
r["use_count"] = int(r.get("use_count", 1)) + 1
r["last_used_at"] = now
r["plan_id"] = plan_id
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return
ranges.append(
{
"start": new_start,
"end": new_end,
"plan_id": plan_id,
"created_at": now,
"use_count": 1,
"last_used_at": now,
}
)
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
def remove_used_segment(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str | None = None,
tolerance: float = 0.5,
) -> bool:
"""删除素材 metadata 中匹配的一条使用记录(不 commit).
匹配规则:start/end 与记录值相差不超过 tolerance 秒;plan_id 非空时,
记录有 plan_id 则需相等,记录缺 plan_id(本功能上线前的旧数据)时按时间匹配。
Returns:
是否找到并删除了记录。
"""
model = _get_model(db, asset_id)
if model is None:
return False
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
remaining: list[dict] = []
removed = False
for r in ranges:
try:
match = (
abs(float(r["start"]) - float(start)) <= tolerance and abs(float(r["end"]) - float(end)) <= tolerance
)
except (KeyError, TypeError, ValueError):
remaining.append(r)
continue
# plan_id 校验:传入 plan_id 时,记录有 plan_id 则必须相等;
# 记录本身缺 plan_id(旧数据)时退化为按时间匹配,避免旧区间永远删不掉
if plan_id is not None and r.get("plan_id") is not None and r.get("plan_id") != plan_id:
match = False
if match and not removed:
removed = True
continue
remaining.append(r)
if removed:
meta[USED_RANGES_KEY] = remaining
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return removed
def reset_used_segments(db: Session, asset_id: str) -> None:
"""清空单个素材的历史片段使用记录(不 commit).
仅供运维/测试使用;正常生成流程中历史记录永不自动清空(受控复用取代自动轮回)。
"""
model = _get_model(db, asset_id)
if model is None:
return
meta = _read_meta(model)
if meta.get(USED_RANGES_KEY):
meta[USED_RANGES_KEY] = []
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
# ── 素材余量/可用性计算(Task H:素材库角标 + smart-match 过滤)──────────────
# 判定「是否还有空闲可切区间」时使用的最小片段时长(秒):空闲段长于此值才视为可切
_MIN_FREE_CLIP_DURATION = 3.0
def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""合并重叠/相接的时间区间,返回升序不重叠区间列表。"""
if not intervals:
return []
ordered = sorted((float(a), float(b)) for a, b in intervals if b > a)
merged: list[tuple[float, float]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _has_free_gap(used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION) -> bool:
"""素材 [0, total] 中是否存在长度 ≥ min_free 的空闲段(考虑边缘间隙)。"""
if total <= 0:
return False
# 历史区间按边缘间隙扩边后判定空闲(与选片冲突检测同一口径)
expanded = [(max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) for s, e in used]
merged = _merge_intervals(expanded)
cursor = 0.0
for start, end in merged:
if start - cursor >= min_free:
return True
cursor = max(cursor, end)
return total - cursor >= min_free
def compute_asset_availability(
model: "AssetModel | None",
min_free_clip_duration: float = _MIN_FREE_CLIP_DURATION,
) -> dict | None:
"""计算单个素材的余量与可用性(纯函数,不读写 DB)。
Returns:
视频素材返回 ``{"used_duration", "available_duration", "used_ratio", "usable"}``
非视频 / 无 model / 无时长信息返回 None(调用方按可用处理,零影响)。
usable=False 条件(与受控复用机制一致):
零重复可切区间已耗尽(不存在 ≥ min_free 的空闲段)且
所有历史区间 use_count 均达 MAX_RANGE_USE_COUNT 上限(无区间可复用)。
"""
if model is None:
return None
file_type = getattr(model, "file_type", None) or getattr(model, "mime_type", "") or ""
if file_type != "video" and not str(file_type).startswith("video/"):
return None
total = float(getattr(model, "duration", 0.0) or 0.0)
if total <= 0:
return None
meta = _read_meta(model)
raw_ranges = meta.get(USED_RANGES_KEY) or []
intervals: list[tuple[float, float]] = []
use_counts: list[int] = []
for r in raw_ranges:
try:
start = float(r["start"])
end = float(r["end"])
except (KeyError, TypeError, ValueError):
continue
if end <= start:
continue
intervals.append((start, end))
try:
use_counts.append(int(r.get("use_count", 1)))
except (TypeError, ValueError):
use_counts.append(1)
merged = _merge_intervals(intervals)
used_duration = round(sum(e - s for s, e in merged), 3)
used_duration = min(used_duration, total)
available_duration = round(max(total - used_duration, 0.0), 3)
used_ratio = round(min(used_duration / total, 1.0), 4)
has_free = _has_free_gap(intervals, total, min_free_clip_duration)
if has_free:
usable = True
else:
# 空闲段耗尽:仅当存在历史区间且全部达复用上限时才判定不可用;
# 无历史区间(理论上不会走到,因为 has_free=True)按可用处理
if not use_counts:
usable = True
else:
usable = any(uc < MAX_RANGE_USE_COUNT for uc in use_counts)
return {
"used_duration": used_duration,
"available_duration": available_duration,
"used_ratio": used_ratio,
"usable": usable,
}
def find_reusable_range(
db: Session,
asset_id: str,
clip_duration: float,
asset_total: float,
*,
max_use_count: int = MAX_RANGE_USE_COUNT,
) -> tuple[float, float] | None:
"""受控复用:在素材历史区间中选一条可复用区间返回 (start, end)。
选择规则:
1. 仅选 ``use_count < max_use_count`` 的历史区间;
2. 优先返回能完整容纳当前 clip_duration(起点后不越素材边界)的最久未用区间;
3. 没有能容纳的,则返回 last_used_at 最老(或缺失 last_used_at 的旧数据优先)
且 use_count 最低的区间起点(可能与其他历史区间重叠,属降级复用);
4. 无任何可复用区间(记录为空或全部达上限)返回 None。
本函数只读不写;复用次数的累加由后续 record_used_segments 完成。
"""
model = _get_model(db, asset_id)
if model is None:
return None
meta = _read_meta(model)
ranges = [r for r in (meta.get(USED_RANGES_KEY) or []) if int(r.get("use_count", 1)) < max_use_count]
if not ranges:
return None
def _last_used(r: dict) -> str:
return str(r.get("last_used_at") or r.get("created_at") or "")
max_start = max(0.0, asset_total - clip_duration)
# 2. 能完整容纳当前片段的候选:按 last_used_at 升序(最久未用优先)
fit = sorted(
[r for r in ranges if float(r["start"]) <= max_start + 1e-6],
key=_last_used,
)
if fit:
start = min(float(fit[0]["start"]), max_start)
return (start, start + clip_duration)
# 3. 降级:最久未用 + use_count 最低的区间起点
fallback = sorted(ranges, key=lambda r: (_last_used(r), int(r.get("use_count", 1))))[0]
start = min(float(fallback["start"]), max_start)
return (start, start + clip_duration)
def make_reuse_callback(
db: Session,
asset_durations: dict[str, float],
reused_tracker: dict[str, float] | None = None,
assigned_tracker: dict[str, float] | None = None,
ratio_limit: float = REUSE_RATIO_LIMIT,
) -> Callable[[str, float], tuple[float, float] | None]:
"""构造给 ``_calc_random_start_time`` 用的受控复用回调.
Args:
db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 15%
Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
回调内吞掉 DB 异常返回 None,不影响主生成流程。
"""
def _reuse(asset_id: str, clip_duration: float) -> tuple[float, float] | None:
try:
total = float(asset_durations.get(asset_id, 0.0) or 0.0)
if total <= 0:
return None
# 占比闸门:预判复用本片段后是否超限(仅当调用方提供了 assigned tracker
if assigned_tracker is not None:
assigned = float(assigned_tracker.get(asset_id, 0.0) or 0.0)
reused_amt = float((reused_tracker or {}).get(asset_id, 0.0) or 0.0)
if assigned > 0 and (reused_amt + clip_duration) / (assigned + clip_duration) > ratio_limit:
logger.info(
"[片段追踪] 复用占比预判超 %.0f%% 阈值,拒绝复用: asset_id=%s "
"reused=%.1f assigned=%.1f clip=%.1f",
ratio_limit * 100,
asset_id,
reused_amt,
assigned,
clip_duration,
)
return None
result = find_reusable_range(db, asset_id, clip_duration, total)
except Exception:
logger.warning("[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True)
return None
if result is not None and reused_tracker is not None:
reused_tracker[asset_id] = reused_tracker.get(asset_id, 0.0) + clip_duration
return result
return _reuse
+117 -1
View File
@@ -9,6 +9,12 @@ from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
get_used_segments,
make_reuse_callback,
record_used_segments,
)
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
@@ -400,7 +406,7 @@ class EditPlanService:
order = clip_item.get("order") or i
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type="main",
clip_type=clip_item.get("clip_type", "main"),
order=order,
asset_id=clip_item.get("asset_id", ""),
start_time=clip_item.get("start_time", 0.0),
@@ -453,6 +459,116 @@ class EditPlanService:
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
raise
def clone_plan_for_variant(
self,
source_plan_id: str,
*,
created_by_user_id: str = "",
name_suffix: str = "变体",
reuse_tracker: Optional[dict] = None,
) -> EditPlan:
"""为批量/多预览场景克隆一份独立 plan,片段起点全部重算(受控随机/复用)。
复制源 plan 的模板归属、config 与片段结构(asset_id / duration / clip_type /
order 不变),每个片段重新调用 ``_calc_random_start_time``:读取素材持久化的
历史已用区间避让,耗尽时受控复用(use_count<3、最久未用),从而保证 N 条
成片片段区间互不相同,且复用占比受控。
- 不替换/不修改源 plan,源 plan 保留用户手动编辑结果。
- 片段区间记录(record_used_segments)随新片段写入素材 metadata,与新 plan
同事务;复用历史区间时由 record 自动累加 use_count。
- 克隆的 clips 复用区间累计时长写入 reuse_tracker(可选),供调用方统计占比。
Raises:
ValueError: 源 plan 不存在或无可用片段。
"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.plan_generator_utils import _calc_random_start_time
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
raise ValueError(f"源 plan 无片段,无法克隆变体: {source_plan_id}")
# 创建新 plan(复制模板归属与 config
new_plan = self.create_plan(
template_id=source.template_id,
name=f"{source.name or '剪辑计划'} · {name_suffix}",
config=dict(source.config or {}),
total_duration=source.total_duration,
project_id=source.project_id or "",
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 素材时长映射(O(N) 单查)
asset_ids = list({c.asset_id for c in clips if c.asset_id})
db = self._clip_repo.session
durations: dict[str, float] = {}
if asset_ids:
for m in db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all():
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
used_segments = get_used_segments(db, asset_ids)
reused: dict[str, float] = reuse_tracker if reuse_tracker is not None else {}
asset_assigned: dict[str, float] = {}
# 回调内部预判复用后占比超 15% 则拒绝复用(calc 返回 None → 保留原起点)
reuse_cb = make_reuse_callback(db, durations, reused, assigned_tracker=asset_assigned)
clips_data: list[dict] = []
for i, c in enumerate(clips):
aid = c.asset_id
dur = float(c.duration or 0.0)
total = durations.get(aid, 0.0)
if aid and total > 0 and dur > 0:
# 复用占比闸门:本片段尚未分配,检查当前已复用占比
# reused / assigned 是否超 15%,超则该素材不再分配(保留原起点);
# assigned=0(首个片段)放行,reused=0 时不误拦正常分配
assigned = asset_assigned.get(aid, 0.0)
eff_dur = min(dur, total)
reused_amt = reused.get(aid, 0.0)
ratio_blocked = assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT
start = None
if not ratio_blocked:
start = _calc_random_start_time(aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb)
if start is None:
start = float(c.start_time or 0.0)
asset_assigned[aid] = assigned + eff_dur
used_segments.setdefault(aid, []).append((start, start + eff_dur))
record_used_segments(db, aid, start, start + eff_dur, new_plan.id)
else:
start = float(c.start_time or 0.0)
clips_data.append(
{
"order": c.order if c.order is not None else i,
"asset_id": aid,
"start_time": start,
"duration": dur,
"clip_type": c.clip_type,
}
)
# 事务性写入新 plan 的片段(内部统一 commit/rollback
self.replace_all_clips_transactional(new_plan.id, clips_data)
logger.info(
"克隆变体 plan: source=%s new=%s clips=%d",
source_plan_id,
new_plan.id,
len(clips_data),
)
return new_plan
# ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
+51 -30
View File
@@ -52,7 +52,7 @@ type AssetListResponse = {
test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 })
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
test("walks through 6-step wizard and starts generation", async ({ page, request }) => {
test.setTimeout(360_000)
await routeBrowserApiToTestApi(page)
@@ -185,15 +185,17 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click()
// Step 2: select material
// Step 2: select material (card grid UI)
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName })
const materialLabel = page.getByText(sourceFileName).locator("..")
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
timeout: 10_000,
})
await materialLabel.locator("input[type='checkbox']").check()
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
await expect(materialCard).toBeVisible({ timeout: 10_000 })
await materialCard.click({ position: { x: 15, y: 15 } })
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
@@ -212,18 +214,14 @@ test.describe("Core generation flow", () => {
const titleText = `E2E Test ${suffix}`
await titleInput.fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 5: preview — 前端实时预览架构改造,无需后端生成预览
await expect(page.getByRole("heading", { name: /预览设置/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
await page.getByRole("button", { name: "下一步" }).click()
// Step 7: confirm and generate
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
// Step 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位,
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning
await page
.getByText("准备预览素材")
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {})
// Wait for generation API to be called
// 前端直接创建生成任务:POST /generation/tasks
@@ -236,10 +234,10 @@ test.describe("Core generation flow", () => {
{ timeout: 30_000 },
)
// Click generate button
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
// 点击「确认生成视频」
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click()
// Verify generation was triggered successfully
// Verify generation was triggered
const genResp = await generatePromise
if (!genResp.ok()) {
const body = await genResp.text()
@@ -256,19 +254,42 @@ test.describe("Core generation flow", () => {
}
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({
timeout: 15_000,
})
// Step 5 → Step 6:等待渲染终态
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
const renderSucceeded = await page
.getByText("视频生成完成", { exact: false })
.waitFor({ timeout: 180_000 })
.then(() => true)
.catch(() => false)
if (renderSucceeded) {
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 15_000,
})
} else {
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
await page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
}
// Generation may fail in test env (no worker), that's OK
// Just verify the flow started - check page shows generation-related UI
await page
.getByText(/生成中|生成完成|生成失败/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
// If we see progress or result, great; if not, flow still reached the end
// which is sufficient for an E2E smoke test
// Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products")
await expect(page).toHaveURL(/\/app\/products/)
+22 -3
View File
@@ -37,14 +37,33 @@ async function loginWithRetry(
})
}
async function registerWithRetry(
request: APIRequestContext,
email: string,
username: string,
password: string,
displayName: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/register`, {
data: { email, password, username, display_name: displayName },
})
if (response.status() !== 429) return response
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000))
}
return request.post(`${apiBase}/auth/register`, {
data: { email, password, username, display_name: displayName },
})
}
/** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label)
const username = uniqueUsername(label)
const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
})
const reg = await registerWithRetry(request, email, username, PASSWORD, `E2E ${label}`)
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json()
+9 -2
View File
@@ -12,7 +12,14 @@ export interface GenerateCoverTitleConfig {
}
export interface GenerateCoverRequest {
asset_ids: string[]
/**
* 封面源视频标识(二选一):
* - generated_video_id:确认生成任务产出的最终视频 ID
* - video_url:最终视频 URL(兜底)
* 后端根据此标识定位最终成片文件并抽帧,MediaKit 选帧逻辑不变
*/
generated_video_id?: string
video_url?: string
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
/** 标题样式,用于在封面上叠加标题文字 */
@@ -31,7 +38,7 @@ export interface GenerateCoverResponse {
}
}
/** AI 生成封面 — 从预览视频中抽帧 */
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
@@ -91,6 +91,7 @@ export async function createClipsFromAssets(
assetIds: string[],
clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal },
): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
@@ -99,9 +100,11 @@ export async function createClipsFromAssets(
if (requiredClipsCount !== undefined) {
body.required_clips_count = requiredClipsCount
}
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`,
body,
{ timeout: 60000, signal: opts?.signal },
)
return response.data
}
@@ -59,8 +59,23 @@ const AssetCard: React.FC<AssetCardProps> = ({
>
{/* 缩略图 */}
<div className="as-card-thumb">
{asset.thumbnail_url ? (
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
{asset.thumbnail_url && !asset.thumbnail_url.match(/\.(mp4|mov|avi|webm|mkv)(\?|$)/i) ? (
<img
src={asset.thumbnail_url}
alt={asset.name}
loading="lazy"
onError={(e) => {
// 图片加载失败时降级显示类型图标
const parent = (e.target as HTMLElement).parentElement
if (parent) {
;(e.target as HTMLElement).style.display = "none"
const icon = document.createElement("span")
icon.className = "as-card-thumb-icon"
icon.textContent = MATERIAL_TYPE_ICONS[asset.type] || "🎬"
parent.appendChild(icon)
}
}}
/>
) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)}
@@ -57,8 +57,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setVoiceDescription("")
setSelectedFile(null)
setDragActive(false)
if (isSubmittingRef.current) return
isSubmittingRef.current = true
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit
// 此前在此上锁且无复位路径,弹窗打开即死锁
setErrorMessage("")
resetRecorder()
}, [getNextDefaultName, resetRecorder])
@@ -99,8 +99,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setErrorMessage(error)
setSelectedFile(null)
} else {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
setErrorMessage("")
setSelectedFile(file)
resetRecorder()
@@ -132,8 +132,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
setErrorMessage(error)
setSelectedFile(null)
} else {
if (isSubmittingRef.current) return
isSubmittingRef.current = true
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
setErrorMessage("")
setSelectedFile(file)
resetRecorder()
@@ -15,8 +15,6 @@ import React, { useState } from "react"
import { useSearchParams } from "react-router-dom"
import { MODE_LABELS } from "@/api/editing-planner"
import { MODE_LIST } from "./constants"
import type { MediaAsset } from "@/api/template-editor"
import MediaPanel from "./components/MediaPanel"
import PreviewPlayer from "./components/PreviewPlayer"
import TimelinePanel from "./components/TimelinePanel"
@@ -79,14 +77,6 @@ const EditingPlanner: React.FC = () => {
/* ── 右侧栏 Tab ── */
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
/* ── 素材库 ── */
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([])
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
const handleAssetSelect = (ids: string[]) => {
setSelectedAssetIds(ids)
}
/* ── 配音素材 ── */
const {
voiceMaterials,
@@ -113,7 +103,6 @@ const EditingPlanner: React.FC = () => {
resetClips,
setClips,
setSelectedClipId: clipOps.setSelectedClipId,
setMediaAssets,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
@@ -164,9 +153,6 @@ const EditingPlanner: React.FC = () => {
onLoadTemplate={tpl.handleLoadTemplate}
onSearchChange={tpl.setSearchQuery}
onFilterChange={tpl.setCurrentFilter}
mediaAssets={mediaAssets}
onAssetSelect={handleAssetSelect}
selectedAssetIds={selectedAssetIds}
/>
{/* 中栏 flex-1 */}
@@ -1,12 +1,10 @@
/**
* 左侧面板 — V8 原型 1:1 还原
* Tab 切换:模板列表 + 素材库
* 左侧面板 — 模板列表
* 模板编辑器只负责定义模板规则(片段数量、时长范围),不承载素材管理。
*/
import React, { useState } from "react"
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import { MODE_LABELS } from "@/api/editing-planner"
import type { MediaAsset } from "@/api/template-editor"
import AssetSelector from "@/components/asset-selector/AssetSelector"
interface MediaPanelProps {
templates: EditingTemplate[]
@@ -18,10 +16,6 @@ interface MediaPanelProps {
onLoadTemplate: (id: string) => void
onSearchChange: (q: string) => void
onFilterChange: (f: string) => void
// 素材相关
mediaAssets?: MediaAsset[]
onAssetSelect?: (ids: string[]) => void
selectedAssetIds?: string[]
}
const MediaPanel: React.FC<MediaPanelProps> = ({
@@ -34,113 +28,73 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
onLoadTemplate,
onSearchChange,
onFilterChange,
mediaAssets = [],
onAssetSelect,
selectedAssetIds = [],
}) => {
const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates")
return (
<div className="ep-left-panel">
{/* Tab 切换 */}
<div className="ep-left-tabs">
<button
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
onClick={() => setActiveTab("templates")}
>
📋
</button>
<button
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
onClick={() => setActiveTab("assets")}
>
📁
</button>
{/* 搜索 */}
<div className="ep-search-wrap ep-media-panel-inner">
<span className="ep-search-icon">🔍</span>
<input
className="ep-search-input"
placeholder="搜索模板..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* 模板 Tab */}
{activeTab === "templates" && (
<>
{/* 搜索 */}
<div className="ep-search-wrap ep-media-panel-inner">
<span className="ep-search-icon">🔍</span>
<input
className="ep-search-input"
placeholder="搜索模板..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Chip 分类筛选 */}
<div className="ep-filter-chips">
{filterCategories.map((cat) => (
<button
key={cat}
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
onClick={() => onFilterChange(cat)}
>
{cat}
</button>
))}
</div>
{/* Chip 分类筛选 */}
<div className="ep-filter-chips">
{filterCategories.map((cat) => (
<button
key={cat}
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
onClick={() => onFilterChange(cat)}
>
{cat}
</button>
))}
{/* 模板列表 */}
<div className="ep-template-list">
{loading ? (
<div className="ep-loading">
<span></span>
<span>...</span>
</div>
{/* 模板列表 */}
<div className="ep-template-list">
{loading ? (
<div className="ep-loading">
<span></span>
<span>...</span>
) : templates.length === 0 ? (
<div className="ep-empty">
<span>📭</span>
<span></span>
</div>
) : (
templates.map((tpl) => (
<div
key={tpl.id}
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
onClick={() => onLoadTemplate(tpl.id)}
>
<div className="ep-template-card-header">
<span className="ep-template-card-name">{tpl.name}</span>
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
</div>
) : templates.length === 0 ? (
<div className="ep-empty">
<span>📭</span>
<span></span>
<div className="ep-template-card-meta">
<span> {tpl.estimated_duration}s</span>
<span>📐 {tpl.segments.length}</span>
</div>
) : (
templates.map((tpl) => (
<div
key={tpl.id}
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
onClick={() => onLoadTemplate(tpl.id)}
>
<div className="ep-template-card-header">
<span className="ep-template-card-name">{tpl.name}</span>
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
</div>
<div className="ep-template-card-meta">
<span> {tpl.estimated_duration}s</span>
<span>📐 {tpl.segments.length}</span>
</div>
{tpl.tags.length > 0 && (
<div className="ep-template-card-tags">
{tpl.tags.map((tag) => (
<span key={tag} className="ep-template-tag">
{tag}
</span>
))}
</div>
)}
{tpl.tags.length > 0 && (
<div className="ep-template-card-tags">
{tpl.tags.map((tag) => (
<span key={tag} className="ep-template-tag">
{tag}
</span>
))}
</div>
))
)}
</div>
</>
)}
{/* 素材 Tab */}
{activeTab === "assets" && (
<div className="ep-assets-tab">
<AssetSelector
assets={mediaAssets}
selectedIds={selectedAssetIds}
onSelectionChange={onAssetSelect}
showQualityFilter={false}
showBatchSelect={false}
compact
/>
</div>
)}
)}
</div>
))
)}
</div>
</div>
)
}
@@ -6,16 +6,13 @@ import {
type EditingTemplate,
type TemplateCategory,
} from "@/api/editing-planner"
import { getMediaAssets, type MediaAsset } from "@/api/template-editor"
import { FILTER_CATEGORIES } from "../../constants"
/**
* 模板列表 + 分类 + 筛选搜索
* 模板编辑器只负责模板规则定义,不再加载/管理业务素材。
*/
export function useTemplateList(
setMediaAssets: (assets: MediaAsset[]) => void,
initialTemplateId: string | null,
) {
export function useTemplateList(initialTemplateId: string | null) {
const [templates, setTemplates] = useState<EditingTemplate[]>([])
const [categories, setCategories] = useState<TemplateCategory[]>([])
const [loadingTemplates, setLoadingTemplates] = useState(false)
@@ -24,26 +21,20 @@ export function useTemplateList(
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(initialTemplateId)
/**
* 并行加载模板列表分类、素材库
* 三个接口无依赖关系,用 Promise.all 并发
* 并行加载模板列表分类(两者无依赖关系)
*/
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true)
try {
const [tpls, cats, assets] = await Promise.all([
getEditingTemplates(),
getTemplateCategories(),
getMediaAssets(),
])
const [tpls, cats] = await Promise.all([getEditingTemplates(), getTemplateCategories()])
setTemplates(tpls)
setCategories(cats)
setMediaAssets(assets)
} catch {
message.error("加载模板失败")
} finally {
setLoadingTemplates(false)
}
}, [setMediaAssets])
}, [])
useEffect(() => {
loadTemplates()
@@ -1,6 +1,6 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from "react"
import type { TemplateMode } from "@/api/editing-planner"
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
import type { TitleConfig } from "@/api/template-editor"
import type {
ClipData,
WatermarkConfig,
@@ -24,7 +24,6 @@ interface UseTemplateManagementParams {
resetClips: (clips: ClipData[]) => void
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
setSelectedClipId: (id: string | null) => void
setMediaAssets: (assets: MediaAsset[]) => void
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
@@ -52,7 +51,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
resetClips,
setClips,
setSelectedClipId,
setMediaAssets,
setTitleConfig,
setSubtitleSettings,
setBgmSettings,
@@ -86,7 +84,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
filteredTemplates,
currentTemplate,
loadTemplates,
} = useTemplateList(setMediaAssets, urlTemplateId || null)
} = useTemplateList(urlTemplateId || null)
/* ── 保存 ── */
const {
+58 -27
View File
@@ -1,14 +1,14 @@
/**
* 智能剪辑页面 — 前端实时预览架构
* 7 步向导:选择模板 → 素材 → 配音 → 标题 → 预览 → 封面 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 6 步向导:选择模板 → 素材 → 配音 → 标题(含预览) → 确认生成 → 选择封面
*
* 架构:
* - 步骤 4-6 右侧显示 FrontendPreviewPlayer 实时预览
* - 步骤 7 右侧内联播放生成最终视频
* - 步骤 4 右侧显示 FrontendPreviewPlayer 实时预览
* - 步骤 5 右侧内联播放生成中的/最终视频
* - 步骤 6 封面从最终成片中智能选帧(MediaKit)
* - 点"确认生成"时调用 createGenerationTask 创建一次服务器渲染任务
*/
import React, { useMemo, useState, useEffect, useRef } from "react"
import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
import { message } from "antd"
import { useNavigate } from "react-router-dom"
import type { VoiceClone } from "@/api/voice-clone"
@@ -74,6 +74,8 @@ const GeneratePage: React.FC = () => {
setPreviewTaskId,
storedSourceEditPlanId,
setStoredSourceEditPlanId,
serverClips,
setServerClips,
} = formState
/* ── 标题样式回调 ── */
@@ -161,7 +163,16 @@ const GeneratePage: React.FC = () => {
/* ── 加载素材详情(供前端预览播放器使用 + 配音时长校验) ── */
const previewAssetsEnabled = previewAssetIds.length > 0
const { assets: previewAssets } = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
const { assets: previewAssets, ready: previewAssetsReady } = usePreviewAssets(
previewAssetIds,
previewAssetsEnabled,
)
/* ── 预览就绪:素材已加载,且有模板 ── */
const previewReady = useMemo(
() => previewAssetsReady && !!currentTemplate,
[previewAssetsReady, currentTemplate],
)
/* ── 视频总时长计算 ── */
const totalVideoDuration = useMemo(() => {
@@ -170,17 +181,6 @@ const GeneratePage: React.FC = () => {
return estimateTotalVideoDuration(currentTemplate ?? undefined)
}, [previewAssets, currentTemplate])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
titleSettings,
})
/* ── 视频生成核心逻辑 ── */
const {
generating,
@@ -217,6 +217,38 @@ const GeneratePage: React.FC = () => {
},
})
/* ── 步骤4「确认生成视频」:校验标题/预览 → 创建最终渲染任务 → 成功后进入步骤5 ── */
const handleConfirmGenerate = useCallback(async () => {
if (!titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (!previewReady) {
message.warning("预览视频正在加载,请稍候")
return
}
const ok = await handleGenerate()
if (ok) {
setCurrentStep(5)
}
}, [titleSettings.title, previewReady, handleGenerate, setCurrentStep])
/* ── 步骤导航 ── */
const { goNext, goPrev } = useStepNavigation({
currentStep,
setCurrentStep,
selectedTemplate,
materialMode,
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
generated,
})
/* ── 最终成片(步骤5/6 右侧播放) ── */
const finalVideo = generatedVideos[0]
/* ================================================================
渲染
================================================================ */
@@ -253,16 +285,14 @@ const GeneratePage: React.FC = () => {
onApplyPreset={styleUpdaters.applyPreset}
activePreset={styleUpdaters.activePreset}
titlePresets={styleUpdaters.titlePresets}
onPreviewTaskCreated={setPreviewTaskId}
onSourceEditPlanIdExtracted={setStoredSourceEditPlanId}
bgm={bgm}
bgmConfig={bgmConfig}
coverSettings={coverSettings}
onCoverSettingsChange={setCoverSettings}
duration={duration}
selectedVoice={selectedVoice}
onSelectedVoiceChange={setSelectedVoice}
totalVideoDuration={totalVideoDuration}
onServerClipsChange={setServerClips}
voiceMode={voiceMode}
onVoiceModeChange={setVoiceMode}
selectedClonedVoice={selectedClonedVoice}
@@ -286,21 +316,22 @@ const GeneratePage: React.FC = () => {
currentStep={currentStep}
onPrev={goPrev}
onNext={goNext}
onGenerate={handleGenerate}
onConfirmGenerate={handleConfirmGenerate}
generating={generating}
generated={generated}
generateError={generateError}
/>
</div>
{/* ════ 右侧:步骤 4-6 实时预览,步骤 7 最终视频 ════ */}
{/* ════ 右侧:步骤4实时预览,步骤5/6最终视频 ════ */}
<div className="xx-generate-right-col">
{currentStep >= 4 && currentStep <= 6 && !!currentTemplate && (
{currentStep === 4 && !!currentTemplate && (
<FrontendPreviewPlayer
assets={previewAssets}
template={currentTemplate}
videoRatio={videoRatio}
ready={previewAssets.length > 0}
serverClips={serverClips}
voiceAudioUrl={previewVoiceAudioUrl || undefined}
titleSettings={{
title: titleSettings.title,
@@ -315,14 +346,14 @@ const GeneratePage: React.FC = () => {
}}
/>
)}
{currentStep === 7 && generated && generatedVideos.length > 0 && (
{currentStep >= 5 && generated && finalVideo && (
<div className="xx-inline-video-player">
<video
src={generatedVideos[0].download_url || generatedVideos[0].file_url}
src={finalVideo.download_url || finalVideo.file_url}
controls
autoPlay
autoPlay={currentStep === 5}
style={{ width: "100%", maxHeight: "70vh", objectFit: "contain", borderRadius: 12 }}
poster={generatedVideos[0].thumbnail_url || undefined}
poster={finalVideo.thumbnail_url || undefined}
/>
<div style={{ display: "flex", gap: 8, marginTop: 12, justifyContent: "center" }}>
<button className="xx-btn xx-btn-ghost xx-btn-sm" onClick={handleDownload}>
@@ -16,6 +16,7 @@ import {
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
import { useCanvasPlayer } from "../hooks/useCanvasPlayer"
@@ -24,6 +25,7 @@ interface FrontendPreviewPlayerProps {
template: EditingTemplate | null
videoRatio: string
ready: boolean
serverClips?: EditPlanClip[]
voiceAudioUrl?: string
titleSettings?: {
title: string
@@ -50,9 +52,31 @@ function formatTime(seconds: number): string {
function buildPlaybackSegments(
assets: AssetItem[],
template: EditingTemplate | null,
serverClips?: EditPlanClip[],
): PlaybackSegment[] {
if (!assets.length) return []
// Build asset lookup map
const assetMap = new Map(assets.map((a) => [a.id, a]))
// 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致
if (serverClips && serverClips.length > 0) {
const segments: PlaybackSegment[] = []
for (const clip of serverClips) {
const asset = assetMap.get(clip.asset_id)
if (!asset) continue
const assetDuration = asset.duration || asset.metadata?.duration || 30
const startTime = clip.start_time || 0
const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration)
const videoUrl = asset.file_url || asset.storage_key
segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order })
}
if (segments.length > 0) {
return segments.sort((a, b) => a.order - b.order)
}
}
// Fallback: 本地构建片段(与旧行为一致)
const templateSegments = template?.segments || []
const segments: PlaybackSegment[] = []
@@ -78,10 +102,14 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
template,
videoRatio,
ready,
serverClips,
voiceAudioUrl,
titleSettings,
}) => {
const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template])
const segments = useMemo(
() => buildPlaybackSegments(assets, template, serverClips),
[assets, template, serverClips],
)
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
const TITLE_MARGIN_TOP = 120
@@ -143,7 +171,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
fontSize: titleSettings.size,
fontFamily: titleSettings.font || "思源黑体",
color: titleSettings.color || "#ffffff",
position: titleSettings.position || "bottom",
position: titleSettings.position || "top",
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
@@ -1,14 +1,19 @@
/**
* GeneratePage 步骤底部操作按钮
*
* 步骤 1~3:上一步 / 下一步
* 步骤 4(标题+预览):上一步 / 确认生成视频(点击后直接创建最终渲染任务,成功后跳转步骤5)
* 步骤 5(确认生成):上一步 / 下一步(渲染中禁用,渲染完成后可进入封面)
* 步骤 6(选择封面):仅上一步
*/
import React from "react"
import { ThunderboltOutlined } from "@ant-design/icons"
export interface GenerateStepActionsProps {
currentStep: number
onPrev: () => void
onNext: () => void
onGenerate: () => void
/** 步骤4:确认生成视频(校验 + 创建渲染任务 + 成功后进入步骤5) */
onConfirmGenerate: () => void | Promise<void>
generating: boolean
generated: boolean
generateError: string | null
@@ -18,36 +23,74 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
currentStep,
onPrev,
onNext,
onGenerate,
onConfirmGenerate,
generating,
generated,
generateError,
}) => {
const renderPrimaryButton = () => {
/* 步骤 1~3:上一步 / 下一步(必填校验由 useStepNavigation.goNext 统一处理) */
if (currentStep < 4) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
/* 步骤 4:确认生成视频(触发按钮在标题页) */
if (currentStep === 4) {
if (generating) {
return (
<button className="xx-btn xx-btn-primary" disabled>
</button>
)
}
if (generateError) {
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
🔄
</button>
)
}
if (generated) {
return (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
)
}
return (
<button className="xx-btn xx-btn-primary" onClick={onConfirmGenerate}>
</button>
)
}
/* 步骤 5:渲染中禁用,完成后下一步进入封面 */
if (currentStep === 5) {
return (
<button
className="xx-btn xx-btn-primary"
onClick={onNext}
disabled={generating || !generated}
>
{generating ? "视频生成中…" : "下一步 →"}
</button>
)
}
/* 步骤 6(最后一步):无主按钮 */
return null
}
return (
<div className="xx-step-actions">
<button className="xx-btn xx-btn-ghost" onClick={onPrev} disabled={currentStep === 1}>
</button>
{currentStep < 7 ? (
<button className="xx-btn xx-btn-primary" onClick={onNext}>
</button>
) : (
<button
className="xx-btn xx-btn-primary"
onClick={onGenerate}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
{renderPrimaryButton()}
</div>
)
}
@@ -1,12 +1,10 @@
/**
* GeneratePage 步骤内容渲染
* 根据当前步骤渲染对应的 Step 组件
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* V24: 移除 Step5 预览生成相关 props,改为纯标题样式编辑
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
*/
import React from "react"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import type { PresetVoiceItem } from "@/api/voices"
import type { VoiceClone } from "@/api/voice-clone"
import type { CoverConfig } from "../types/cover"
@@ -14,10 +12,9 @@ import type { TitleSettings } from "../types"
import Step1TemplateSelect from "../components/Step1TemplateSelect"
import Step2MaterialSelect from "../components/Step2MaterialSelect"
import Step3VoiceSelect from "../components/Step5VoiceSelect"
import Step5GeneratePreview from "../components/Step5GeneratePreview"
import Step4TitleSettings from "../components/Step4TitleSettings"
import Step5ConfirmGenerate from "../components/Step7ConfirmGenerate"
import Step6CoverSettings from "../components/Step6CoverSettings"
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
import type { GeneratedVideo } from "@/api/template-editor"
export interface GenerateStepContentProps {
@@ -36,7 +33,6 @@ export interface GenerateStepContentProps {
/* 标题 */
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/* 标题样式回调 — Step5 样式面板使用 */
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
@@ -50,11 +46,11 @@ export interface GenerateStepContentProps {
/* 封面 */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
/* 配音 */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
totalVideoDuration?: number
onServerClipsChange: (clips: EditPlanClip[]) => void
voiceMode: "preset" | "custom" | "clone"
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
selectedClonedVoice: string
@@ -74,10 +70,6 @@ export interface GenerateStepContentProps {
onDismissError: () => void
/* 其他 */
presetVoices: PresetVoiceItem[]
/** 预览任务创建回调——传递给 Step6CoverSettings */
onPreviewTaskCreated?: (taskId: string) => void
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
onSourceEditPlanIdExtracted?: (planId: string) => void
/** BGM 开关 */
bgm: boolean
/** BGM 配置(来自模板) */
@@ -110,10 +102,10 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
titlePresets,
coverSettings,
onCoverSettingsChange,
duration,
selectedVoice,
onSelectedVoiceChange,
totalVideoDuration,
onServerClipsChange,
voiceMode,
selectedClonedVoice,
clonedVoices,
@@ -125,10 +117,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onRetry,
onDismissError,
presetVoices,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
bgm,
bgmConfig,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
@@ -155,6 +143,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onSmartSelectedIdsChange={onSmartSelectedIdsChange}
selectedTemplate={selectedTemplate}
templateSegments={templateSegments}
onServerClipsChange={onServerClipsChange}
/>
)
case 3:
@@ -171,12 +160,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
titleSettings={titleSettings}
onTitleSettingsChange={onTitleSettingsChange}
selectedTemplate={selectedTemplate}
/>
)
case 5:
return (
<Step5GeneratePreview
titleSettings={titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
@@ -189,27 +172,9 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
titlePresets={titlePresets}
/>
)
case 6:
case 5:
return (
<Step6CoverSettings
coverSettings={coverSettings}
onCoverSettingsChange={onCoverSettingsChange}
duration={duration}
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
onPreviewTaskCreated={onPreviewTaskCreated}
onSourceEditPlanIdExtracted={onSourceEditPlanIdExtracted}
voiceMode={voiceMode}
selectedVoice={selectedVoice}
selectedClonedVoice={selectedClonedVoice}
bgm={bgm}
bgmConfig={bgmConfig}
/>
)
case 7:
return (
<Step7ConfirmGenerate
<Step5ConfirmGenerate
templates={userTemplates}
selectedTemplate={selectedTemplate}
materialMode={materialMode}
@@ -231,6 +196,16 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onDismissError={onDismissError}
/>
)
case 6:
return (
<Step6CoverSettings
coverSettings={coverSettings}
onCoverSettingsChange={onCoverSettingsChange}
selectedTemplate={selectedTemplate}
titleSettings={titleSettings}
generatedVideos={generatedVideos}
/>
)
default:
return null
}
@@ -3,6 +3,7 @@
*/
import React from "react"
import type { TemplateSegment } from "@/api/templates/types"
import type { EditPlanClip } from "@/api/template-editor"
import { useStep2Materials } from "../hooks/useStep2Materials"
import MaterialModeTabs from "./material/MaterialModeTabs"
import ManualMaterialList from "./material/ManualMaterialList"
@@ -20,6 +21,8 @@ interface Step2MaterialSelectProps {
selectedTemplate?: string
/** 当前模板的 segments(用于构建 clips duration */
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
}
const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
@@ -1,23 +1,50 @@
/**
* Step 4 标题设置组件
* 仅包含标题文字输入 + AI 标题生成
* 标题样式面板已迁移到 Step5(生成预览页面
* Step 4 选择标题(合并原 Step4 标题输入 + Step5 标题样式面板)
*
* 左侧:标题文字输入 + AI生成标题 + 样式设置(位置/字号/字体/颜色/样式/预设
* 右侧:FrontendPreviewPlayer 实时预览(由 GeneratePage 统一渲染)
*/
import React from "react"
import { AutoComplete } from "antd"
import { PlayCircleOutlined } from "@ant-design/icons"
import type { TitleSettings } from "../types"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import { useStep4Title } from "../hooks/useStep4Title"
import AiTitleGenerator from "./title/AiTitleGenerator"
import TitleStylePanel from "./title/TitleStylePanel"
interface Step4TitleSettingsProps {
titleSettings: TitleSettings
onTitleSettingsChange: (settings: TitleSettings) => void
/** 当前选中的模板/草稿 ID,用于自动保存 */
selectedTemplate?: string
/* 标题样式回调 */
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
}
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
const t = useStep4Title(props)
const {
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets,
} = props
return (
<div className="xx-form-section">
@@ -112,6 +139,42 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
</div>
</>
)}
{/* 标题样式面板(原 Step5) */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "10px 14px",
background: "rgba(59, 130, 246, 0.08)",
borderRadius: 8,
marginTop: 16,
marginBottom: 12,
border: "1px solid rgba(59, 130, 246, 0.15)",
}}
>
<PlayCircleOutlined style={{ fontSize: 16, color: "#3b82f6" }} />
<span style={{ fontSize: 12, color: "var(--text-secondary, #666)" }}>
</span>
</div>
<TitleStylePanel
settings={t.titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
</div>
)
}
@@ -1,83 +0,0 @@
/**
* Step 5 预览设置组件
*
* 前端实时预览架构:
* - 右侧面板使用 FrontendPreviewPlayer 实时播放素材片段
* - 标题样式可实时调整,CSS 层即时叠加预览
* - 点"确认生成"时触发一次服务器渲染
*/
import React from "react"
import { PlayCircleOutlined } from "@ant-design/icons"
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
import type { TitleSettings } from "../types"
import TitleStylePanel from "./title/TitleStylePanel"
interface Step5GeneratePreviewProps {
titleSettings: TitleSettings
onUpdatePosition: (position: string) => void
onUpdateFont: (font: string) => void
onUpdateSize: (size: number) => void
onToggleBold: () => void
onToggleItalic: () => void
onToggleStroke: () => void
onToggleShadow: () => void
onApplyPreset: (presetKey: string) => void
activePreset: string | null
titlePresets: { key: string; label: string; previewStyle: React.CSSProperties }[]
}
const Step5GeneratePreview: React.FC<Step5GeneratePreviewProps> = ({
titleSettings,
onUpdatePosition,
onUpdateFont,
onUpdateSize,
onToggleBold,
onToggleItalic,
onToggleStroke,
onToggleShadow,
onApplyPreset,
activePreset,
titlePresets,
}) => {
return (
<div className="xx-form-section">
<h3>🎬 </h3>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "12px 16px",
background: "rgba(59, 130, 246, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(59, 130, 246, 0.15)",
}}
>
<PlayCircleOutlined style={{ fontSize: 18, color: "#3b82f6" }} />
<span style={{ fontSize: 13, color: "var(--text-secondary, #666)" }}>
</span>
</div>
<TitleStylePanel
settings={titleSettings}
onUpdatePosition={onUpdatePosition}
onUpdateFont={onUpdateFont}
onUpdateSize={onUpdateSize}
onToggleBold={onToggleBold}
onToggleItalic={onToggleItalic}
onToggleStroke={onToggleStroke}
onToggleShadow={onToggleShadow}
onApplyPreset={onApplyPreset}
activePreset={activePreset}
titlePresets={titlePresets}
POSITION_OPTIONS={POSITION_OPTIONS}
FONT_OPTIONS={FONT_OPTIONS}
/>
</div>
)
}
export default Step5GeneratePreview
@@ -1,6 +1,8 @@
import React from "react"
import { Modal, Spin } from "antd"
import type { CoverConfig } from "../types/cover"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import { useStep6Cover } from "../hooks/useStep6Cover"
import Button from "@/components/ui/Button"
import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
@@ -9,27 +11,12 @@ import CoverEditorModal from "./cover-settings/CoverEditorModal"
interface Step6CoverSettingsProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
/** 当前素材 ID 列表,用于智能封面生成 */
assetIds?: string[]
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
titleSettings?: import("../types").TitleSettings
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
onPreviewTaskCreated?: (taskId: string) => void
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
onSourceEditPlanIdExtracted?: (planId: string) => void
/** 配音模式 */
voiceMode?: "preset" | "custom" | "clone"
/** 选中的配音素材 ID */
selectedVoice?: string
/** 选中的克隆音色 ID */
selectedClonedVoice?: string
/** BGM 开关 */
bgm?: boolean
/** BGM 配置 */
bgmConfig?: { enabled: boolean; music_id?: string }
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
}
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
@@ -37,6 +24,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
coverSettings,
generating,
generateAutoCover,
finalVideo,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
@@ -53,17 +41,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
} = useStep6Cover({
coverSettings: props.coverSettings,
onCoverSettingsChange: props.onCoverSettingsChange,
duration: props.duration,
assetIds: props.assetIds,
selectedTemplate: props.selectedTemplate,
titleSettings: props.titleSettings,
onPreviewTaskCreated: props.onPreviewTaskCreated,
onSourceEditPlanIdExtracted: props.onSourceEditPlanIdExtracted,
voiceMode: props.voiceMode,
selectedVoice: props.selectedVoice,
selectedClonedVoice: props.selectedClonedVoice,
bgm: props.bgm,
bgmConfig: props.bgmConfig,
generatedVideos: props.generatedVideos,
})
const handleAutoGenerate = () => {
@@ -77,8 +57,25 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<div className="xx-form-section">
<h3>🖼 </h3>
{/* 最终成片信息 */}
{finalVideo && (
<div
style={{
padding: "10px 14px",
background: "rgba(16, 185, 129, 0.08)",
borderRadius: 8,
marginBottom: 16,
border: "1px solid rgba(16, 185, 129, 0.15)",
fontSize: 13,
color: "var(--text-secondary, #666)",
}}
>
🎬 {finalVideo.name}
</div>
)}
<div className="xx-cover-actions">
<Button buttonType="primary" onClick={handleAutoGenerate}>
<Button buttonType="primary" onClick={handleAutoGenerate} disabled={!finalVideo}>
</Button>
<Button buttonType="ghost" onClick={() => setShowCoverSettings(true)}>
@@ -126,7 +123,9 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
<Modal open={generating} closable={false} footer={null} centered>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin size="large" />
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>AI ...</p>
<p style={{ marginTop: 16, fontSize: 14, color: "#666" }}>
AI ...
</p>
</div>
</Modal>
</div>
@@ -1,7 +1,8 @@
/**
* 手动选择素材列表
* 手动选择素材列表 — 竖屏 9:16 卡片网格
* 交互:默认显示封面,点击播放按钮播放,播放中隐藏按钮,点击视频区域暂停
*/
import React, { useRef, useCallback } from "react"
import React, { useRef, useState, useCallback } from "react"
import { Typography } from "antd"
import type { AssetItem } from "@/api/assets"
@@ -14,39 +15,241 @@ interface ManualMaterialListProps {
onToggle: (materialId: string) => void
}
/** 秒数格式化为 mm:ss */
const fmtDuration = (seconds?: number): string => {
if (!seconds && seconds !== 0) return "--:--"
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
}
/** 单个素材卡片 */
const MaterialCard: React.FC<{
asset: AssetItem
checked: boolean
onToggle: () => void
}> = ({ asset, checked, onToggle }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const isVideo = asset.mime_type?.startsWith("video/") ?? false
const thumbSrc = asset.thumbnail_url || undefined
const handlePlayToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
const video = videoRef.current
if (!video || !isVideo) return
if (isPlaying) {
video.pause()
setIsPlaying(false)
} else {
video.play().catch(() => {})
setIsPlaying(true)
}
},
[isPlaying, isVideo],
)
const handleVideoEnded = useCallback(() => {
setIsPlaying(false)
}, [])
const handleCardClick = useCallback(() => {
// 如果视频正在播放,点击卡片空白区域暂停视频
if (isPlaying) {
const video = videoRef.current
if (video) {
video.pause()
setIsPlaying(false)
}
return
}
onToggle()
}, [isPlaying, onToggle])
return (
<div
data-testid="material-card"
onClick={handleCardClick}
style={{
position: "relative",
aspectRatio: "9 / 16",
borderRadius: 10,
overflow: "hidden",
cursor: "pointer",
border: checked ? "2px solid var(--primary-color, #4f46e5)" : "2px solid transparent",
boxShadow: checked ? "0 0 0 2px rgba(79, 70, 229, 0.2)" : "0 1px 3px rgba(0, 0, 0, 0.1)",
background: "#1e293b",
transition: "all 0.15s ease",
}}
>
{/* 视频元素 */}
{isVideo && asset.file_url ? (
<video
ref={videoRef}
src={asset.file_url}
poster={thumbSrc}
muted
loop
playsInline
preload="metadata"
onEnded={handleVideoEnded}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={asset.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
}}
/>
) : (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #334155, #1e293b)",
color: "rgba(255,255,255,0.5)",
fontSize: 28,
}}
>
{isVideo ? "🎬" : "🎵"}
</div>
)}
{/* 底部渐变遮罩 */}
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: "50%",
background: "linear-gradient(0deg, rgba(0,0,0,0.6) 0%, transparent 100%)",
pointerEvents: "none",
}}
/>
{/* 播放按钮 — 播放中隐藏 */}
{!isPlaying && (
<div
onClick={handlePlayToggle}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 36,
height: 36,
borderRadius: "50%",
background: "rgba(99, 102, 241, 0.85)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 3,
transition: "opacity 0.2s ease",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
<path d="M8 5v14l11-7z" />
</svg>
</div>
)}
{/* 文件名(左下角) */}
<div
style={{
position: "absolute",
bottom: 6,
left: 6,
right: 50,
color: "white",
fontSize: 11,
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
textShadow: "0 1px 2px rgba(0,0,0,0.5)",
pointerEvents: "none",
zIndex: 1,
}}
>
{asset.name}
</div>
{/* 时长(右下角) */}
<div
style={{
position: "absolute",
bottom: 6,
right: 6,
background: "rgba(0, 0, 0, 0.7)",
color: "white",
padding: "1px 5px",
borderRadius: 3,
fontSize: 10,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
pointerEvents: "none",
zIndex: 1,
}}
>
{fmtDuration(asset.duration)}
</div>
{/* 选中勾选标记(左上角) */}
{checked && (
<div
data-testid="material-card-check"
aria-label="已选中"
style={{
position: "absolute",
top: 6,
left: 6,
width: 20,
height: 20,
borderRadius: "50%",
background: "var(--primary-color, #4f46e5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 12,
fontWeight: 700,
zIndex: 2,
pointerEvents: "none",
}}
>
</div>
)}
</div>
)
}
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
materials,
materialsLoading,
selectedMaterials,
onToggle,
}) => {
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
if (
activeVideoRef.current &&
activeVideoRef.current !== video &&
document.body.contains(activeVideoRef.current)
) {
activeVideoRef.current.pause()
activeVideoRef.current.currentTime = 0
}
activeVideoRef.current = video
video.play().catch(() => {})
}, [])
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
const video = e.currentTarget
video.pause()
video.currentTime = 0
if (activeVideoRef.current === video) {
activeVideoRef.current = null
}
}, [])
return (
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
@@ -56,145 +259,21 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.items.map((m) => {
const checked = selectedMaterials.includes(m.id)
const isVideo = m.mime_type?.startsWith("video/") ?? false
const thumbSrc = m.thumbnail_url || undefined
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked ? "var(--primary-soft, #eef2ff)" : "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(m.id)}
style={{
accentColor: "var(--primary-color, #4f46e5)",
flexShrink: 0,
}}
/>
{/* 缩略图预览 48×48 */}
<div
style={{
width: 48,
height: 48,
borderRadius: 6,
overflow: "hidden",
background: "#e2e8f0",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{isVideo && m.file_url ? (
<video
src={m.file_url}
poster={m.thumbnail_url || undefined}
muted
loop
playsInline
preload="none"
onMouseEnter={handleVideoMouseEnter}
onMouseLeave={handleVideoMouseLeave}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
cursor: "pointer",
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
alt={m.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
const fallback = target.nextElementSibling as HTMLElement | null
if (fallback) fallback.style.display = "flex"
}}
/>
) : null}
{!thumbSrc && !isVideo && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎵
</span>
)}
{!thumbSrc && isVideo && !m.file_url && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "flex",
}}
>
🎬
</span>
)}
{/* img onError 时显示的 fallback(初始隐藏) */}
{thumbSrc && !(isVideo && m.file_url) && (
<span
style={{
fontSize: 20,
opacity: 0.5,
display: "none",
}}
>
{isVideo ? "🎬" : "🎵"}
</span>
)}
</div>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
flexShrink: 0,
}}
>
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
</span>
</label>
)
})}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(110px, 1fr))",
gap: 10,
}}
>
{materials.items.map((asset) => (
<MaterialCard
key={asset.id}
asset={asset}
checked={selectedMaterials.includes(asset.id)}
onToggle={() => onToggle(asset.id)}
/>
))}
</div>
)}
</div>
@@ -25,10 +25,21 @@ const GenerationStatus: React.FC<GenerationStatusProps> = ({
onRetry,
onDismissError,
}) => {
if (!generating && !generated && !generateError) return null
return (
<div style={{ marginTop: 16 }}>
{!generating && !generated && !generateError && (
<div className="xx-gen-progress-card" style={{ opacity: 0.85 }}>
<div className="xx-gen-progress-header">
<div className="xx-gen-progress-icon">🎬</div>
<div className="xx-gen-progress-info">
<div className="xx-gen-progress-phase"></div>
<div className="xx-gen-progress-sub">
</div>
</div>
</div>
</div>
)}
{generating && (
<div className="xx-gen-progress-card">
<div className="xx-gen-progress-header">
+1 -2
View File
@@ -33,9 +33,8 @@ export const STEPS = [
{ key: 2, label: "选择素材" },
{ key: 3, label: "选择配音" },
{ key: 4, label: "选择标题" },
{ key: 5, label: "生成预览" },
{ key: 5, label: "确认生成" },
{ key: 6, label: "选择封面" },
{ key: 7, label: "确认生成" },
]
/* ── 标题位置选项 ── */
+12 -10
View File
@@ -194,16 +194,16 @@
============================================================ */
.xx-choice-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
.xx-choice-item {
position: relative;
background: var(--bg-primary);
border: 2px solid var(--border-color);
border-radius: var(--radius-md);
padding: 14px;
border-radius: var(--radius-sm);
padding: 6px;
cursor: pointer;
transition: 0.18s ease;
text-align: center;
@@ -219,18 +219,20 @@
}
.xx-choice-thumb {
height: 60px;
width: 33%;
max-width: 52px;
height: 24px;
border-radius: var(--radius-sm);
display: grid;
place-items: center;
color: var(--text-inverse);
font-size: 24px;
font-size: 12px;
font-weight: 700;
margin-bottom: 10px;
margin: 0 auto 4px;
}
.xx-choice-item h4 {
margin: 0 0 4px;
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
@@ -238,7 +240,7 @@
.xx-choice-item p {
margin: 0;
font-size: 11px;
font-size: 12px;
color: var(--text-tertiary);
}
@@ -1063,7 +1065,7 @@
}
.xx-choice-list {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(4, 1fr);
}
.xx-voice-choice-list {
@@ -5,6 +5,7 @@
import { useState } from "react"
import { useSearchParams } from "react-router-dom"
import type { EditingTemplate } from "@/api/editing-planner"
import type { EditPlanClip } from "@/api/template-editor"
import type { CoverConfig } from "../../types/cover"
import type { PresetVoiceItem } from "@/api/voices"
import { DEFAULT_COVER_SETTINGS } from "../../constants"
@@ -18,7 +19,7 @@ import { usePersistedState } from "../usePersistedState"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
position: "top",
font: "思源黑体",
size: 28,
bold: true,
@@ -46,6 +47,10 @@ export interface GenerateFormState {
smartSelectedIds: string[]
setSmartSelectedIds: (ids: string[]) => void
/* 服务端片段(/clips/from-assets 创建后获取) */
serverClips: EditPlanClip[]
setServerClips: (clips: EditPlanClip[]) => void
/* 标题 */
titleSettings: TitleSettings
setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void
@@ -116,6 +121,9 @@ export const useGenerateFormState = (): GenerateFormState => {
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
const [smartSelectedIds, setSmartSelectedIds] = useState<string[]>([])
/* ── 服务端片段(供预览播放器使用)── */
const [serverClips, setServerClips] = useState<EditPlanClip[]>([])
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
@@ -196,6 +204,8 @@ export const useGenerateFormState = (): GenerateFormState => {
setMaterialMode,
smartSelectedIds,
setSmartSelectedIds,
serverClips,
setServerClips,
titleSettings,
setTitleSettings,
coverSettings,
@@ -48,7 +48,7 @@ export function usePlanConfigLoader({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
position: prev.position, // 强制保留默认/用户选择,不从草稿配置同步位置
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
@@ -80,7 +80,7 @@ export function usePlanConfigLoader({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
position: prev.position, // 强制保留默认/用户选择,不从远程草稿同步位置
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
@@ -26,7 +26,7 @@ export function useTitleCoverSync({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
position: prev.position, // 强制保留默认/用户选择,不从模板同步位置
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
@@ -4,7 +4,7 @@
*/
import { useState, useCallback } from "react"
import { message } from "antd"
import type { GeneratedVideo } from "@/api/template-editor"
import { type GeneratedVideo, getEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
import { createGenerationTask } from "@/api/tasks/tasks"
import type { UseGenerateVideoProps } from "./generate-video/types"
import { getGenerationPhase } from "./generate-video/phase"
@@ -29,7 +29,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
setGenerating(false)
setGenerated(true)
setGeneratedVideos(videos as GeneratedVideo[])
// 生成成功后清除持久化的预览状态,避免下次进入复用旧任务
onGenerationSuccess?.()
},
[onGenerationSuccess],
@@ -45,12 +44,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
onFailed: handleFailed,
})
/* ── 生成视频 ── */
const generate = useCallback(async () => {
/* ── 生成视频 ──
返回 true 表示任务创建成功并已开始轮询;false 表示校验未通过或创建失败 */
const generate = useCallback(async (): Promise<boolean> => {
const errorMsg = validateGenerateInputs(props)
if (errorMsg) {
message.warning(errorMsg)
return
return false
}
setGenerating(true)
@@ -60,7 +60,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
clearTimer()
try {
// 解析分辨率(共享工具函数)
const { width: outputWidth, height: outputHeight } = calculateResolution(
props.videoRatio || "9:16",
)
@@ -68,57 +67,74 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用,
// 后端已改为异步秒级返回,这里做一次轻量兜底:
// 单次查 clips,已有则直接放行;没有则再调一次 from-assets。
if (assetIds.length > 0 && selectedTemplate) {
try {
const clipList = await getEditPlanClips(selectedTemplate, { limit: 500 })
if (clipList.items.length === 0) {
// 片段不存在(极端情况:useStep2Materials 的 debounce 还没触发)
// 手动补一次 from-assets(后端秒级返回)
await createClipsFromAssets(selectedTemplate, assetIds, "main")
}
} catch {
// 查询失败不阻塞,继续生成
}
}
const hide = message.loading("正在生成预览视频...", 0)
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
// 解析配音参数:voice_library_id 是配音素材库 asset ID
// 优先用当前 voiceMode 对应的选择,兜底用 selectedVoice(防止 voiceMode 切换后丢失)
const voiceLibraryId =
props.voiceMode === "clone"
? props.selectedClonedVoice || props.selectedVoice || ""
: props.selectedVoice || ""
// 创建生成任务(服务器渲染)
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
// 配音:始终传递 voice_library_id,确保后端能正确接收
voice_library_id: voiceLibraryId,
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
})
const taskId = taskResp.items?.[0]?.id
try {
const taskResp = await createGenerationTask({
template_id: selectedTemplate,
asset_ids: assetIds,
output_width: outputWidth,
output_height: outputHeight,
cover_url: coverUrl,
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
voice_library_id: voiceLibraryId,
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
bgm_config: {
enabled: props.bgm !== false,
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
},
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
text: props.titleSettings.title,
font: props.titleSettings.font,
font_size: props.titleSettings.size,
font_color: props.titleSettings.color,
position: props.titleSettings.position,
bold: props.titleSettings.bold,
stroke: props.titleSettings.stroke,
shadow: props.titleSettings.shadow,
},
}
: {}),
})
hide()
const taskId = taskResp.items?.[0]?.id
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
if (!taskId) {
throw new Error("创建任务成功但未返回任务 ID,请稍后在任务列表查看")
}
startPolling(taskId)
} catch (err) {
hide()
throw err
}
startPolling(taskId)
} catch (err: unknown) {
console.error("[handleGenerate] 生成失败:", err)
setGenerating(false)
@@ -127,21 +143,20 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const finalMsg = translateError(backendMsg)
setGenerateError(finalMsg)
message.error(finalMsg)
return false
}
return true
}, [props, clearTimer, startPolling, selectedTemplate])
/* 重新生成(失败后重试) */
const retry = useCallback(() => {
setGenerateError(null)
generate()
}, [generate])
/* 清除错误 */
const dismissError = useCallback(() => {
setGenerateError(null)
}, [])
/* ── 下载视频 ── */
const download = useCallback(async () => {
if (!generatedVideos.length) return
const video = generatedVideos[0]
@@ -162,7 +177,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}
}, [generatedVideos])
/* ── 分享视频 ── */
const share = useCallback(async () => {
if (!generatedVideos.length) return
const video = generatedVideos[0]
@@ -176,19 +190,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
}, [generatedVideos])
return {
// 状态
generating,
progress,
generated,
generateError,
generatedVideos,
// 操作
generate,
retry,
dismissError,
download,
share,
// 工具
getGenerationPhase,
}
}
@@ -236,6 +236,12 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
await waitForReady(video)
}
// 播放前 seek 到片段起始时间,确保 progress 计算正确
const seg = segmentsRef.current[idx]
if (seg && Math.abs(video.currentTime - seg.startTime) > 0.1) {
video.currentTime = seg.startTime
}
try {
await video.play()
setIsPlaying(true)
+31 -6
View File
@@ -3,8 +3,10 @@
* 组合素材库加载 + 智能匹配两个子 Hook
*/
import { useCallback, useEffect, useRef } from "react"
import { message } from "antd"
import type { TemplateSegment } from "@/api/templates/types"
import { updateEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
import type { EditPlanClip } from "@/api/template-editor"
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
import { formatDuration } from "../utils/formatDuration"
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
import { useSmartMatch } from "./step2-materials/useSmartMatch"
@@ -21,6 +23,8 @@ interface UseStep2MaterialsProps {
selectedTemplate?: string
/** 当前模板的 segments(用于构建 clips duration */
templateSegments?: TemplateSegment[]
/** 服务端 clips 创建成功后的回调,用于通知预览播放器 */
onServerClipsChange?: (clips: EditPlanClip[]) => void
}
export function useStep2Materials({
@@ -32,6 +36,7 @@ export function useStep2Materials({
onSmartSelectedIdsChange,
selectedTemplate,
templateSegments,
onServerClipsChange,
}: UseStep2MaterialsProps) {
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
useMaterialLibrary()
@@ -83,12 +88,17 @@ export function useStep2Materials({
templateSegmentsRef.current = templateSegments
const selectedTemplateRef = useRef(selectedTemplate)
selectedTemplateRef.current = selectedTemplate
const onServerClipsChangeRef = useRef(onServerClipsChange)
onServerClipsChangeRef.current = onServerClipsChange
useEffect(() => {
const tid = selectedTemplateRef.current
if (!tid) return
const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials
if (!ids.length) return
if (!ids.length) {
onServerClipsChangeRef.current?.([])
return
}
if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current)
clipsTimerRef.current = setTimeout(async () => {
@@ -103,13 +113,28 @@ export function useStep2Materials({
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 2. 调用后端 from-assets 接口创建片段
await createClipsFromAssets(tid, ids, "main", requiredClipsCount)
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底)
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
})
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
const clipList = await getEditPlanClips(tid, { limit: 500 })
const readyClips = clipList.items
.filter((c) => c.status === "ready")
.sort((a, b) => a.order - b.order)
onServerClipsChangeRef.current?.(readyClips)
} catch (err) {
const name = (err as { name?: string })?.name
if (name !== "CanceledError" && name !== "AbortError") {
console.warn("[useStep2Materials] 写入 clips 失败:", err)
// 用户切换素材导致的主动取消,静默
if (name === "CanceledError" || name === "AbortError") return
// from-assets 60s 超时(MediaKit 智能选片未完成)
const code = (err as { code?: string })?.code
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
console.warn("[useStep2Materials] 智能选片超时:", err)
message.error("智能选片失败,请重试")
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
}
}, 800)
@@ -1,13 +1,13 @@
/**
* Step 6 封面设置 Hook
* 封装封面设置的交互逻辑,对接后端封面模板 CRUD API
* 封面候选帧从确认生成的最终视频中获取(MediaKit 选帧)
* 不再从预览片段创建预览视频
*/
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import { message } from "antd"
import type { CoverConfig, CoverTemplate } from "../types/cover"
import { generateCover } from "@/api/generation"
import { createPreview, getPreviewStatus } from "@/api/generation/preview"
import { updateEditPlan } from "@/api/template-editor"
import type { GeneratedVideo } from "@/api/template-editor"
import type { TitleSettings } from "../types"
import {
fetchCoverTemplates,
@@ -19,47 +19,22 @@ import {
interface UseStep6CoverProps {
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
/** 当前素材 ID 列表,用于智能封面生成 */
assetIds?: string[]
/** 当前选中的模板 ID */
selectedTemplate?: string
/** Step4 标题设置,用于预览视频烧录标题 & 封面叠加标题 */
/** Step4 标题设置,用于封面叠加标题 */
titleSettings?: TitleSettings
/** 预览任务创建回调——将 task_id 暴露给父组件供 confirmGeneration 复用 */
onPreviewTaskCreated?: (taskId: string) => void
/** 从预览响应中提取到 source_edit_plan_id 时的回调 */
onSourceEditPlanIdExtracted?: (planId: string) => void
/** 配音模式 */
voiceMode?: "preset" | "custom" | "clone"
/** 选中的配音素材 ID(配音素材库 asset ID */
selectedVoice?: string
/** 选中的克隆音色 ID */
selectedClonedVoice?: string
/** BGM 开关 */
bgm?: boolean
/** BGM 配置(来自模板) */
bgmConfig?: { enabled: boolean; music_id?: string }
/** 确认生成步骤产出的最终视频列表 */
generatedVideos: GeneratedVideo[]
}
export function useStep6Cover({
coverSettings,
onCoverSettingsChange,
duration,
assetIds = [],
selectedTemplate = "",
titleSettings,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
voiceMode,
selectedVoice,
selectedClonedVoice,
bgm,
bgmConfig,
generatedVideos,
}: UseStep6CoverProps) {
const [generating, setGenerating] = useState(false)
// 防竞态:记录当前预览生成的参数指纹,任务完成时校验一致性
const previewParamsRef = useRef<string>("")
// ── 封面设置弹窗状态 ──
const [showCoverSettings, setShowCoverSettings] = useState(false)
@@ -72,6 +47,9 @@ export function useStep6Cover({
const [templatesLoading, setTemplatesLoading] = useState(false)
const [templatesError, setTemplatesError] = useState<string | null>(null)
/** 最终成片:取第一个已完成视频 */
const finalVideo = generatedVideos.find((v) => v.status === "completed") || generatedVideos[0]
/** 从后端加载封面模板列表 */
const loadTemplates = useCallback(async () => {
setTemplatesLoading(true)
@@ -94,7 +72,7 @@ export function useStep6Cover({
}
}, [showCoverSettings, loadTemplates])
/** 调用后端智能封面 API生成封面并更新预览 */
/** 调用后端智能封面 API从最终成片中抽帧 */
const generateAutoCover = useCallback(async () => {
if (generating) {
message.warning("封面正在生成中,请稍候...")
@@ -106,19 +84,20 @@ export function useStep6Cover({
return
}
if (assetIds.length === 0) {
message.error("请先选择素材")
if (!finalVideo) {
message.error("请先生成视频再选择封面")
return
}
setGenerating(true)
// 超时保护:300 秒后强制重置,防止 state 卡死导致按钮永久失效
const timeoutId = setTimeout(() => {
setGenerating(false)
}, 300000)
try {
const response = await generateCover(selectedTemplate, {
asset_ids: assetIds,
generated_video_id: finalVideo.id,
video_url: finalVideo.file_url || finalVideo.download_url || "",
cover_type: "ai_frame",
...(titleSettings?.title
? {
@@ -151,151 +130,9 @@ export function useStep6Cover({
clearTimeout(timeoutId)
console.error("[Step6] 智能封面生成失败:", err)
// 提取详细错误信息
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyErr = err as any
const statusCode = anyErr?.response?.status
// 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errCode = anyErr?.response?.data?.code as string | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errMsg = (anyErr?.response?.data?.message ||
anyErr?.response?.data?.detail ||
"") as string
const isPreviewMissing =
statusCode === 400 &&
(errCode?.includes("PREVIEW") ||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
errMsg,
))
if (isPreviewMissing) {
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
message.info("正在准备预览视频,请稍候...")
try {
// 记录当前参数指纹,用于任务完成时校验一致性(防竞态)
previewParamsRef.current = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
// 解析配音参数:voiceMode=clone 时用 selectedClonedVoice,否则用 selectedVoice
const previewVoiceLibraryId =
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
const previewResp = await createPreview({
template_id: selectedTemplate,
asset_ids: assetIds,
duration: duration || 30,
// 配音:始终传递 voice_library_id,确保后端能正确接收
voice_library_id: previewVoiceLibraryId,
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
...(selectedVoice && !previewVoiceLibraryId ? { voice_ids: [selectedVoice] } : {}),
// BGM 配置:受 bgm 开关控制
bgm_config: {
enabled: bgm !== false,
...(bgmConfig?.music_id ? { preset_id: bgmConfig.music_id } : {}),
},
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
// 将预览任务 ID 暴露给父组件,供 Step7 确认生成时复用(confirmGeneration
const currentFingerprint = JSON.stringify({ selectedTemplate, assetIds, titleSettings })
if (previewResp.task_id && previewParamsRef.current === currentFingerprint) {
onPreviewTaskCreated?.(previewResp.task_id)
// 提取后端自动关联的 source_edit_plan_id,供 fallback 路径使用
if (previewResp.source_edit_plan_id) {
onSourceEditPlanIdExtracted?.(previewResp.source_edit_plan_id)
}
}
// 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底
await new Promise<void>((resolve, reject) => {
let finished = false
const done = (fn: () => void) => {
if (finished) return
finished = true
clearTimeout(timeoutId)
fn()
}
const timeoutId = setTimeout(() => {
done(() => reject(new Error("预览生成超时,请稍后重试")))
}, 120_000)
const poll = async () => {
if (finished) return
try {
const status = await getPreviewStatus(previewResp.task_id)
if (status.status === "completed") {
// 保存预览视频地址到 plan.config.rendered_storage_key
// 供封面 API 的 E1 兜底路径定位渲染后的视频(含标题烧录)。
// video_url 可能是完整 http(s) URL 或 OSS storage_key,两种格式后端都能处理。
if (status.video_url) {
try {
await updateEditPlan(selectedTemplate, {
config: { rendered_storage_key: status.video_url },
})
} catch (saveErr) {
console.warn(
"[Step6] 保存 rendered_storage_key 失败(不阻塞封面重试):",
saveErr,
)
}
}
done(() => resolve())
} else if (status.status === "failed") {
done(() => reject(new Error(status.error_message || "预览渲染失败")))
} else {
setTimeout(poll, 2000)
}
} catch (e) {
done(() => reject(e))
}
}
poll()
})
message.success("预览视频就绪,重新生成封面...")
// 重试封面生成
const retryResp = await generateCover(selectedTemplate, {
asset_ids: assetIds,
cover_type: "ai_frame",
...(titleSettings?.title
? {
title_config: {
text: titleSettings.title,
font: titleSettings.font,
font_size: titleSettings.size,
font_color: titleSettings.color,
position: titleSettings.position,
bold: titleSettings.bold,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
}
: {}),
})
const retryUrl = retryResp.cover?.image_url || ""
if (retryUrl) {
onCoverSettingsChange({
...coverSettings,
thumbnail_url: retryUrl,
ai_suggested_time: retryResp.cover?.frame_time ?? null,
})
message.success("封面生成成功")
} else {
message.warning("封面生成未返回图片,请重试")
}
} catch (retryErr) {
console.error("[Step6] 自动创建预览后重试失败:", retryErr)
message.error("预览视频创建失败,请稍后重试")
}
} else if (anyErr?.__msgShown) {
if (anyErr?.__msgShown) {
// 拦截器已处理,不再重复弹出
} else {
let errorMsg = "封面生成失败"
@@ -310,31 +147,21 @@ export function useStep6Cover({
console.error("[Step6] 后端返回:", e.response.data)
} else if (e.request) {
errorMsg = "服务器无响应,请检查网络连接"
console.error("[Step6] 请求无响应:", e.request)
} else if (e.message) {
errorMsg = e.message
}
message.error(errorMsg)
}
} finally {
clearTimeout(timeoutId)
setGenerating(false)
}
}, [
selectedTemplate,
assetIds,
finalVideo,
coverSettings,
onCoverSettingsChange,
generating,
duration,
titleSettings,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
voiceMode,
selectedVoice,
selectedClonedVoice,
bgm,
bgmConfig,
])
// ── 模板操作方法 ──
@@ -391,13 +218,11 @@ export function useStep6Cover({
const selectedTemplateName =
coverTemplates.find((t) => t.id === selectedTemplateId)?.name || "默认"
const totalDuration = duration || 30
return {
coverSettings,
generating,
generateAutoCover,
totalDuration,
finalVideo,
showCoverSettings,
setShowCoverSettings,
showCoverEditor,
@@ -87,7 +87,9 @@ export function useStep7Generate({
}
const handleScrollToPreview = () => {
const el = document.querySelector(".xx-preview-section")
const el =
document.querySelector(".xx-inline-video-player") ||
document.querySelector(".xx-preview-section")
el?.scrollIntoView({ behavior: "smooth", block: "start" })
}
@@ -1,9 +1,6 @@
/**
* GeneratePage 步骤导航
* 管理步骤切换与各步骤的前置校验
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 标题(4) → 预览(5) → 封面(6) → 确认(7)
*
* 前端实时预览架构:Step5 无需等待服务器渲染
* 步骤顺序(6步):模板(1) → 素材(2) → 配音(3) → 标题(4) → 确认生成(5) → 封面(6)
*/
import { message } from "antd"
import type { TitleSettings } from "../types"
@@ -16,6 +13,10 @@ export interface UseStepNavigationOptions {
selectedMaterials: string[]
smartSelectedIds: string[]
titleSettings: TitleSettings
/** 预览是否已就绪(素材已加载,可播放) */
previewReady: boolean
/** 是否已完成视频生成(步骤5确认生成后才能进入封面) */
generated: boolean
}
export interface UseStepNavigationReturn {
@@ -32,6 +33,8 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
selectedMaterials,
smartSelectedIds,
titleSettings,
previewReady,
generated,
} = options
const goNext = () => {
@@ -47,11 +50,23 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
message.warning("请先进行智能匹配并选择素材")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
message.warning("请选择或输入标题")
// Step4(标题+预览):标题必填 + 预览必须已加载
if (currentStep === 4) {
if (!titleSettings.title.trim()) {
message.warning("请选择或输入标题")
return
}
if (!previewReady) {
message.warning("预览视频正在加载,请稍候")
return
}
}
// Step5(确认生成):必须已完成生成才能进入封面
if (currentStep === 5 && !generated) {
message.warning("请先生成视频")
return
}
if (currentStep < 7) {
if (currentStep < 6) {
setCurrentStep((s) => s + 1)
}
}
+47 -5
View File
@@ -32,6 +32,8 @@ import { useAudioPlayer } from "./hooks/useAudioPlayer"
import { useCloneOperations } from "./hooks/useCloneOperations"
import { useTtsSynthesize } from "./hooks/useTtsSynthesize"
import { useVoiceUpload } from "./hooks/useVoiceUpload"
import { useMaterialDelete } from "./hooks/useMaterialDelete"
import { useMaterialBatchDelete } from "./hooks/useMaterialBatchDelete"
import "./voices.css"
let toastIdSeq = 0
@@ -70,17 +72,39 @@ const VoiceLibrary: React.FC = () => {
materialCount,
} = useVoicesData()
// ── 播放控制 ──────────────────────────────────────────
// ── 播放控制(三 tab 共用:真实 Audio 播放 + TTS 试听) ──
const {
playingId,
loadingId,
currentTime,
handlePlay,
duration: playDuration,
handlePause,
handleToggleVoice,
handleToggleMaterial,
handleSeek,
handleTogglePlay,
stopPlayback,
} = useAudioPlayer()
// ── 配音素材删除 / 批量删除(删除正在播放的素材时停止播放) ──
const { handleMaterialDelete } = useMaterialDelete({
materials: materials as AssetItem[],
stopPlayback,
showToast,
})
const {
selectedIds: materialSelectedIds,
selectedCount: materialSelectedCount,
allSelected: materialAllSelected,
batchDeleting: materialBatchDeleting,
toggleSelect: toggleMaterialSelect,
toggleSelectAll: toggleMaterialSelectAll,
handleBatchDelete: handleMaterialBatchDelete,
} = useMaterialBatchDelete({
materials: materials as AssetItem[],
stopPlayback,
showToast,
})
// ── 克隆音色操作 ──────────────────────────────────────
const {
detailVoice,
@@ -203,8 +227,10 @@ const VoiceLibrary: React.FC = () => {
loading={presetLoading}
voices={filteredPreset}
playingId={playingId}
loadingId={loadingId}
currentTime={currentTime}
onPlay={handlePlay}
playDuration={playDuration}
onToggle={handleToggleVoice}
onPause={handlePause}
onSeek={handleSeek}
onClearFilters={handleClearFilters}
@@ -217,9 +243,12 @@ const VoiceLibrary: React.FC = () => {
loading={cloneLoading}
voices={clonedVoices}
playingId={playingId}
loadingId={loadingId}
currentTime={currentTime}
onPlay={handleTogglePlay}
playDuration={playDuration}
onToggle={handleToggleVoice}
onPause={handlePause}
onSeek={handleSeek}
onUse={handleCloneUse}
onDelete={handleCloneDelete}
onRetry={handleCloneRetry}
@@ -234,6 +263,19 @@ const VoiceLibrary: React.FC = () => {
loading={materialLoading}
materials={materials as AssetItem[]}
onOpenUpload={() => setUploadOpen(true)}
onDelete={handleMaterialDelete}
selectedIds={materialSelectedIds}
selectedCount={materialSelectedCount}
allSelected={materialAllSelected}
batchDeleting={materialBatchDeleting}
onToggleSelect={toggleMaterialSelect}
onToggleSelectAll={toggleMaterialSelectAll}
onBatchDelete={handleMaterialBatchDelete}
playingId={playingId}
currentTime={currentTime}
playDuration={playDuration}
onTogglePlay={handleToggleMaterial}
onSeek={handleSeek}
/>
)}
@@ -8,9 +8,12 @@ import CardFooter from "./clone-voice-card/CardFooter"
export interface CloneVoiceCardProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
isLoading?: boolean
currentTime: number
playDuration?: number
onPlay: () => void
onPause: () => void
onSeek?: (time: number) => void
onUse: () => void
onDelete: () => void
onRetry: () => void
@@ -12,9 +12,12 @@ export interface ClonedVoiceTabProps {
loading: boolean
voices: ClonedVoiceDisplay[]
playingId: string | null
loadingId: string | null
currentTime: number
onPlay: (voiceId: string, duration: number) => void
playDuration: number
onToggle: (voiceId: string) => void
onPause: () => void
onSeek: (time: number) => void
onUse: (voice: ClonedVoiceDisplay) => void
onDelete: (voice: ClonedVoiceDisplay) => void
onRetry: (voice: ClonedVoiceDisplay) => void
@@ -26,9 +29,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
loading,
voices,
playingId,
loadingId,
currentTime,
onPlay,
playDuration,
onToggle,
onPause,
onSeek,
onUse,
onDelete,
onRetry,
@@ -54,9 +60,12 @@ export const ClonedVoiceTab: React.FC<ClonedVoiceTabProps> = ({
key={voice.id}
voice={voice}
isPlaying={playingId === voice.id}
isLoading={loadingId === voice.id}
currentTime={playingId === voice.id ? currentTime : 0}
onPlay={() => onPlay(voice.id, voice.duration)}
playDuration={playingId === voice.id ? playDuration : voice.duration}
onPlay={() => onToggle(voice.voiceId)}
onPause={onPause}
onSeek={onSeek}
onUse={() => onUse(voice)}
onDelete={() => onDelete(voice)}
onRetry={() => onRetry(voice)}
@@ -1,37 +0,0 @@
import React from "react"
import { AudioOutlined } from "@ant-design/icons"
import { type AssetItem } from "@/api/assets"
import { formatFileSize } from "@/pages/voices/utils/format"
export interface MaterialVoiceCardProps {
asset: AssetItem
onClick?: () => void
}
/** 配音素材卡片 */
const MaterialVoiceCard: React.FC<MaterialVoiceCardProps> = ({ asset, onClick }) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div className="vmat-card" onClick={onClick}>
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>{asset.file_size ? formatFileSize(asset.file_size) : "--"}</span>
</div>
</div>
</div>
)
}
export default MaterialVoiceCard
@@ -1,31 +1,126 @@
/**
* VoiceLibrary 配音素材 Tab 内容
*
* 卡片视觉与预置音色卡片(xx-voice-card)一致:白底圆角横向布局、
* 左侧圆形图标、名称/副标题、波形条、底部播放控件。
* 删除按钮常驻右上角;支持全选/多选 + 批量删除。
* 播放状态由页面级统一 hookVoiceLibrary/useAudioPlayer)下发。
*/
import React from "react"
import { SoundOutlined, UploadOutlined, AudioOutlined } from "@ant-design/icons"
import React, { useRef } from "react"
import {
SoundOutlined,
UploadOutlined,
AudioOutlined,
DeleteOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CheckOutlined,
} from "@ant-design/icons"
import { Button } from "@/components/ui"
import type { AssetItem } from "@/api/assets"
import { mapAssetToMaterial, type VoiceMaterial } from "@/pages/voice-materials/types"
import { formatTime, formatFileSize } from "../utils/format"
export interface MaterialVoiceTabProps {
loading: boolean
materials: AssetItem[]
onOpenUpload: () => void
onDelete: (asset: AssetItem) => void
// 批量删除
selectedIds: Set<string>
selectedCount: number
allSelected: boolean
batchDeleting: boolean
onToggleSelect: (id: string) => void
onToggleSelectAll: () => void
onBatchDelete: () => void
// 播放控制(页面级统一 hook)
playingId: string | null
currentTime: number
playDuration: number
onTogglePlay: (material: VoiceMaterial) => void
onSeek: (time: number) => void
}
export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
loading,
materials,
onOpenUpload,
onDelete,
selectedIds,
selectedCount,
allSelected,
batchDeleting,
onToggleSelect,
onToggleSelectAll,
onBatchDelete,
playingId,
currentTime,
playDuration,
onTogglePlay,
onSeek,
}) => {
const progressRefs = useRef<Record<string, HTMLDivElement | null>>({})
/** 播放中点击进度条 seek;非播放态点击则开始播放 */
const handleProgressClick =
(asset: AssetItem, material: VoiceMaterial, dur: number) =>
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (!asset.file_url) return
if (playingId !== asset.id) {
onTogglePlay(material)
return
}
const el = progressRefs.current[asset.id]
if (!el || dur <= 0) return
const rect = el.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
onSeek(percent * dur)
}
return (
<div className="xx-voices-tab-content">
{/* 批量操作栏 */}
{!loading && materials.length > 0 && (
<div className="vmat-batch-bar">
<div className="vmat-batch-bar-left">
<button
type="button"
className={`vmat-checkbox${allSelected ? " checked" : ""}`}
onClick={onToggleSelectAll}
aria-label={allSelected ? "取消全选" : "全选"}
>
{allSelected && <CheckOutlined />}
</button>
<button type="button" className="vmat-select-all" onClick={onToggleSelectAll}>
{allSelected ? "取消全选" : "全选"}
</button>
{selectedCount > 0 && (
<span className="vmat-batch-count"> {selectedCount} </span>
)}
</div>
<div className="vmat-batch-bar-right">
<Button
buttonType="danger"
buttonSize="sm"
icon={<DeleteOutlined />}
disabled={selectedCount === 0 || batchDeleting}
onClick={onBatchDelete}
>
{batchDeleting ? "删除中..." : "批量删除"}
</Button>
</div>
</div>
)}
{/* 骨架屏加载 */}
{loading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="vmat-card vmat-card--skeleton">
<div className="vmat-thumb" />
<div className="vmat-info">
<div key={i} className="xx-voice-card vmat-card--skeleton">
<div className="vmat-skeleton-avatar" />
<div className="xx-voice-info">
<div className="vmat-skeleton-line vmat-skeleton-title" />
<div className="vmat-skeleton-line" />
</div>
@@ -38,27 +133,94 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
{!loading && materials.length > 0 && (
<div className="xx-voice-grid">
{materials.map((asset: AssetItem) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
const material = mapAssetToMaterial(asset)
// duration 优先取顶层(后端从 metadata 提取),兜底 metadata
const cardDuration = asset.duration || material.duration || 0
const isPlaying = playingId === asset.id
const isSelected = selectedIds.has(asset.id)
// 播放中以 audio 真实时长为准,未播放显示卡片时长
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
const progress = effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
return (
<div key={asset.id} className="vmat-card">
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
<div
key={asset.id}
className={`xx-voice-card vmat-card${isSelected ? " selected" : ""}${
isPlaying ? " playing" : ""
}`}
>
{/* 左上角批量选择 checkbox */}
<button
type="button"
className={`vmat-card-checkbox${isSelected ? " checked" : ""}`}
onClick={(e) => {
e.stopPropagation()
onToggleSelect(asset.id)
}}
aria-label={isSelected ? "取消选择" : "选择素材"}
>
{isSelected && <CheckOutlined />}
</button>
{/* 右上角删除按钮(常驻可见) */}
<button
type="button"
className="vmat-card-delete"
onClick={(e) => {
e.stopPropagation()
onDelete(asset)
}}
title="删除"
aria-label="删除配音素材"
>
<DeleteOutlined />
</button>
<div className="xx-voice-avatar vmat-avatar">
<AudioOutlined />
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
<div className="xx-voice-info vmat-info">
<div className="xx-voice-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>
{asset.file_size ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` : "--"}
</span>
<div className="xx-voice-subtitle">
{asset.file_size ? `${formatFileSize(asset.file_size)}` : "--"}
{" · "}
{cardDuration > 0 ? formatTime(cardDuration) : "--:--"}
</div>
</div>
{/* 波形装饰条(与预置音色卡片一致) */}
<div className="xx-voice-wave" />
{/* 播放控制区:真实音频播放 */}
<div className="xx-voice-controls">
<button
type="button"
className="xx-voice-play-btn"
disabled={!asset.file_url}
onClick={(e) => {
e.stopPropagation()
onTogglePlay(material)
}}
title={asset.file_url ? (isPlaying ? "暂停" : "试听") : "暂无可播放音频"}
aria-label={isPlaying ? "暂停播放" : "播放音频"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
<div
ref={(el) => {
progressRefs.current[asset.id] = el
}}
className="xx-voice-progress"
onClick={handleProgressClick(asset, material, effectiveDuration)}
>
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(cardDuration)}
</span>
</div>
</div>
)
})}
@@ -19,10 +19,12 @@ export interface PresetVoiceTabProps {
loading: boolean
voices: PresetVoiceDisplay[]
playingId: string | null
loadingId: string | null
currentTime: number
onPlay: (id: string, duration: number) => void
playDuration: number
onToggle: (voiceId: string) => void
onPause: () => void
onSeek: (id: string, time: number, duration: number) => void
onSeek: (time: number) => void
onClearFilters: () => void
}
@@ -36,8 +38,10 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
loading,
voices,
playingId,
loadingId,
currentTime,
onPlay,
playDuration,
onToggle,
onPause,
onSeek,
onClearFilters,
@@ -71,15 +75,16 @@ export const PresetVoiceTab: React.FC<PresetVoiceTabProps> = ({
name={voice.name}
subtitle={`${genderLabel(voice.gender)} · ${languageLabel(voice.language)} · ${voice.description}`}
tags={voice.tags}
duration={voice.duration}
duration={playingId === voice.id ? playDuration : voice.duration}
gender={voice.gender}
isPlaying={playingId === voice.id}
isLoading={loadingId === voice.id}
isSelected={false}
currentTime={playingId === voice.id ? currentTime : 0}
starred={voice.starred}
onPlay={() => onPlay(voice.id, voice.duration)}
onPlay={() => onToggle(voice.voiceId)}
onPause={onPause}
onSeek={(time) => onSeek(voice.id, time, voice.duration)}
onSeek={(time) => onSeek(time)}
onToggleStar={() => {}}
/>
))}
@@ -4,6 +4,7 @@ import {
PlayCircleOutlined,
PauseCircleOutlined,
HeartOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { type VoiceGender } from "@/pages/voices/types"
import { genderClass, formatTime } from "@/pages/voices/utils/format"
@@ -16,6 +17,7 @@ export interface VoiceCardProps {
duration: number
gender: VoiceGender
isPlaying: boolean
isLoading?: boolean
isSelected: boolean
currentTime: number
starred?: boolean
@@ -36,6 +38,7 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
duration,
gender,
isPlaying,
isLoading = false,
isSelected,
currentTime,
starred,
@@ -51,8 +54,12 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!progressRef.current || status !== "ready") return
const rect = progressRef.current.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
onSeek(percent * duration)
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
if (isPlaying && duration > 0) {
onSeek(percent * duration)
} else if (!isPlaying && !isLoading) {
onPlay()
}
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
@@ -110,19 +117,27 @@ const VoiceCard: React.FC<VoiceCardProps> = ({
<div className="xx-voice-controls">
<button
className="xx-voice-play-btn"
disabled={isLoading}
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
aria-label={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isLoading ? (
<LoadingOutlined spin />
) : isPlaying ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)}
</button>
<div ref={progressRef} className="xx-voice-progress" onClick={handleProgressClick}>
<div className="xx-voice-progress-bar" style={{ width: `${progress}%` }} />
</div>
<span className="xx-voice-time">
{isPlaying ? formatTime(currentTime) : formatTime(duration)}
{isPlaying ? formatTime(currentTime) : duration > 0 ? formatTime(duration) : "试听"}
</span>
</div>
)}
@@ -1,13 +1,21 @@
import React from "react"
import { PlayCircleOutlined, PauseCircleOutlined, ReloadOutlined } from "@ant-design/icons"
import {
PlayCircleOutlined,
PauseCircleOutlined,
ReloadOutlined,
LoadingOutlined,
} from "@ant-design/icons"
import { type ClonedVoiceDisplay } from "@/pages/voices/types"
interface CardFooterProps {
voice: ClonedVoiceDisplay
isPlaying: boolean
isLoading?: boolean
currentTime: number
playDuration?: number
onPlay: () => void
onPause: () => void
onSeek?: (time: number) => void
onUse: () => void
onRetry: () => void
}
@@ -15,14 +23,30 @@ interface CardFooterProps {
const CardFooter: React.FC<CardFooterProps> = ({
voice,
isPlaying,
isLoading = false,
currentTime,
playDuration,
onPlay,
onPause,
onSeek,
onUse,
onRetry,
}) => {
const isFailed = voice.status === "failed"
const isProcessing = voice.status === "processing"
// 播放中以 audio 真实时长为准
const effectiveDuration = isPlaying ? playDuration || voice.duration : voice.duration
/** 播放中点击进度条 seek;非播放态点击触发播放 */
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
if (isPlaying && effectiveDuration > 0) {
onSeek?.(percent * effectiveDuration)
} else if (!isLoading) {
onPlay()
}
}
return (
<div className="xx-clone-footer">
@@ -31,21 +55,30 @@ const CardFooter: React.FC<CardFooterProps> = ({
<button
type="button"
className="xx-clone-play-btn"
disabled={isLoading}
onClick={(e) => {
e.stopPropagation()
isPlaying ? onPause() : onPlay()
}}
title={isPlaying ? "暂停" : "试听"}
aria-label={isPlaying ? "暂停" : "试听"}
>
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
{isLoading ? (
<LoadingOutlined spin />
) : isPlaying ? (
<PauseCircleOutlined />
) : (
<PlayCircleOutlined />
)}
</button>
<div className="xx-clone-progress">
<div className="xx-clone-progress" onClick={handleProgressClick}>
<div
className="xx-clone-progress-bar"
style={{
width: isPlaying
? `${Math.min((currentTime / Math.max(voice.duration, 1)) * 100, 100)}%`
: "0%",
width:
isPlaying && effectiveDuration > 0
? `${Math.min((currentTime / effectiveDuration) * 100, 100)}%`
: "0%",
}}
/>
</div>
+162 -64
View File
@@ -1,102 +1,200 @@
import { useState, useRef, useCallback, useEffect } from "react"
/**
* 音频播放控制 Hook
* 封装当前播放状态、播放/暂停/跳转控制,使用 setInterval 模拟进度更新
* (适用于预置音色/克隆音色卡片的播放按钮交互
* 配音库真实音频播放控制 Hook(预置音色 / 克隆音色 / 配音素材三个 tab 共用)
*
* - 配音素材:直接播放 asset.file_url(用户上传的真实音频
* - 预置/克隆音色:调 POST /tts/preview 合成本示例文案,拿到 audio_url 后真实播放,
* 合成结果按 voiceId 内存缓存,同一音色二次试听不重复合成
* - 全库同一时刻只有一个 Audio 在响:切卡片 / 切 tab / 离开页面自动停止
* - timeupdate 驱动进度条,loadedmetadata 取真实时长,ended 自动复位
*
* 注意:合成失败的错误提示由 apiClient 拦截器统一 toast(含后端
* 「音色克隆尚未完成,请稍后再试」文案),hook 内不重复提示。
*/
import { useState, useRef, useCallback, useEffect } from "react"
import { previewTts } from "@/api/tts"
import type { VoiceMaterial } from "@/pages/voice-materials/types"
/** 卡片试听统一示例文案 */
export const VOICE_PREVIEW_TEXT = "你好呀,欢迎使用小虾智剪,这是我的配音效果,希望你喜欢。"
interface PreviewCacheEntry {
url: string
duration?: number
}
export function useAudioPlayer() {
const [playingId, setPlayingId] = useState<string | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const intervalRef = useRef<number | null>(null)
const [duration, setDuration] = useState(0)
const [loadingId, setLoadingId] = useState<string | null>(null)
/** 开始播放指定音色(从 startTime 开始,默认从 0 开始) */
const handlePlay = useCallback(
(voiceId: string, duration: number, startTime: number = 0) => {
if (playingId === voiceId) return
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
setPlayingId(voiceId)
setCurrentTime(startTime)
intervalRef.current = window.setInterval(() => {
setCurrentTime((prev) => {
if (prev >= duration) {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
return 0
}
return prev + 0.1
})
}, 100)
},
[playingId],
)
const audioRef = useRef<HTMLAudioElement | null>(null)
const pausedRef = useRef<{ id: string; url: string; duration: number } | null>(null)
const previewCacheRef = useRef<Map<string, PreviewCacheEntry>>(new Map())
/** 试听合成请求序号:旧请求返回时丢弃,防止竞态 */
const reqSeqRef = useRef(0)
/** 暂停播放 */
const handlePause = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
/** 停止当前播放并复位状态 */
const stopPlayback = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
pausedRef.current = null
setPlayingId(null)
setLoadingId(null)
setCurrentTime(0)
setDuration(0)
}, [])
/** 跳转到指定时间 */
const handleSeek = useCallback(
(voiceId: string, time: number, duration: number) => {
if (playingId !== voiceId) {
// 不同音色:从指定时间开始播放
handlePlay(voiceId, duration, time)
} else {
// 同一音色:直接跳转
setCurrentTime(time)
/** 用指定 URL 创建 Audio 并播放 */
const startAudio = useCallback((id: string, url: string, knownDuration?: number) => {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
const audio = new Audio(url)
audioRef.current = audio
audio.addEventListener("timeupdate", () => {
setCurrentTime(audio.currentTime)
})
audio.addEventListener("loadedmetadata", () => {
if (Number.isFinite(audio.duration) && audio.duration > 0) {
setDuration(audio.duration)
}
})
audio.addEventListener("ended", () => {
if (audioRef.current === audio) audioRef.current = null
pausedRef.current = null
setPlayingId(null)
setCurrentTime(0)
})
if (knownDuration && knownDuration > 0) setDuration(knownDuration)
setCurrentTime(0)
setPlayingId(id)
pausedRef.current = null
audio.play().catch(() => {
// 自动播放被拦截或 URL 失效:复位按钮,错误提示由拦截器/环境处理
if (audioRef.current === audio) audioRef.current = null
setPlayingId(null)
setLoadingId((cur) => (cur === id ? null : cur))
})
}, [])
/** 播放配音素材(file_url 直链) */
const playMaterial = useCallback(
(material: VoiceMaterial) => {
if (!material.fileUrl) return
startAudio(material.id, material.fileUrl, material.duration)
},
[playingId, handlePlay],
[startAudio],
)
/** 切换播放/暂停 */
const handleTogglePlay = useCallback(
(voiceId: string, duration: number) => {
/** 预置/克隆音色试听:先 TTS 合成(带缓存),再真实播放 */
const playVoice = useCallback(
async (voiceId: string) => {
// 暂停中恢复
if (pausedRef.current?.id === voiceId && audioRef.current) {
try {
await audioRef.current.play()
} catch {
return
}
setPlayingId(voiceId)
pausedRef.current = null
return
}
const cached = previewCacheRef.current.get(voiceId)
if (cached) {
startAudio(voiceId, cached.url, cached.duration)
return
}
const seq = ++reqSeqRef.current
setLoadingId(voiceId)
try {
const res = await previewTts({ text: VOICE_PREVIEW_TEXT, voice_id: voiceId, speed: 1.0 })
if (seq !== reqSeqRef.current) return // 已被更新的请求取代
previewCacheRef.current.set(voiceId, { url: res.audio_url, duration: res.duration })
setLoadingId(null)
startAudio(voiceId, res.audio_url, res.duration)
} catch {
if (seq !== reqSeqRef.current) return
// 错误文案(含「克隆尚未完成」)由 apiClient 拦截器统一 toast
setLoadingId(null)
}
},
[startAudio],
)
/** 暂停(记录暂停对象,供再次点击恢复) */
const handlePause = useCallback(() => {
const audio = audioRef.current
if (!audio) return
audio.pause()
pausedRef.current = { id: playingId ?? "", url: audio.src, duration }
setPlayingId(null)
}, [playingId, duration])
/** 音色卡片播放/暂停切换 */
const handleToggleVoice = useCallback(
(voiceId: string) => {
if (playingId === voiceId) {
handlePause()
} else {
handlePlay(voiceId, duration)
stopPlayback()
void playVoice(voiceId)
}
},
[playingId, handlePlay, handlePause],
[playingId, handlePause, stopPlayback, playVoice],
)
/** 停止所有播放(切换 Tab 时调用) */
const stopPlayback = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
setPlayingId(null)
setCurrentTime(0)
/** 素材卡片播放/暂停切换 */
const handleToggleMaterial = useCallback(
(material: VoiceMaterial) => {
if (playingId === material.id) {
handlePause()
} else {
stopPlayback()
playMaterial(material)
}
},
[playingId, handlePause, stopPlayback, playMaterial],
)
/** 进度条 seek(仅播放中有效;非播放态点击进度条由卡片改为触发播放) */
const handleSeek = useCallback((time: number) => {
const audio = audioRef.current
if (!audio) return
audio.currentTime = time
setCurrentTime(time)
}, [])
// 组件卸载时清理
// 组件卸载时清理音频
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
}
}, [])
return {
playingId,
loadingId,
currentTime,
handlePlay,
duration,
playMaterial,
playVoice,
handlePause,
handleToggleVoice,
handleToggleMaterial,
handleSeek,
handleTogglePlay,
stopPlayback,
}
}
@@ -0,0 +1,97 @@
/**
* 配音库「配音素材」Tab 批量删除逻辑
*
* 选择状态本地维护;批量删除循环调 deleteAsset(无批量接口),
* 单个失败不中断;删除项含正在播放的素材时停止播放。
*/
import { useState, useCallback, useMemo } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Modal } from "@/components/ui"
import { deleteAsset, type AssetItem } from "@/api/assets"
import type { Toast } from "../components/VoiceToasts"
interface UseMaterialBatchDeleteOptions {
materials: AssetItem[]
/** 停止播放回调(删除正在播放的素材时调用) */
stopPlayback: () => void
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialBatchDelete({
materials,
stopPlayback,
showToast,
}: UseMaterialBatchDeleteOptions) {
const queryClient = useQueryClient()
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [batchDeleting, setBatchDeleting] = useState(false)
/** 只统计当前列表中仍然存在的选中项(删除后自动收敛) */
const validSelected = useMemo(
() => materials.filter((m) => selectedIds.has(m.id)),
[materials, selectedIds],
)
const selectedCount = validSelected.length
const allSelected = materials.length > 0 && selectedCount === materials.length
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const toggleSelectAll = useCallback(() => {
setSelectedIds(allSelected ? new Set() : new Set(materials.map((m) => m.id)))
}, [allSelected, materials])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const handleBatchDelete = useCallback(() => {
const targets = validSelected
if (targets.length === 0) return
Modal.confirm({
title: "确认批量删除",
content: `确定删除选中的 ${targets.length} 个素材?删除后不可恢复。`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: async () => {
setBatchDeleting(true)
let successCount = 0
for (const asset of targets) {
try {
await deleteAsset(asset.id)
successCount++
} catch {
/* 单个失败不中断 */
}
}
// 删除项含正在播放的素材(播放 id 与素材 id 一致)→ 停止播放
stopPlayback()
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setSelectedIds(new Set())
setBatchDeleting(false)
if (successCount === targets.length) {
showToast(`已批量删除 ${successCount} 个素材`, "success")
} else {
showToast(`已批量删除 ${successCount}/${targets.length} 个素材,部分失败`, "error")
}
},
})
}, [validSelected, stopPlayback, queryClient, showToast])
return {
selectedIds,
selectedCount,
allSelected,
batchDeleting,
toggleSelect,
toggleSelectAll,
clearSelection,
handleBatchDelete,
}
}
@@ -0,0 +1,56 @@
/**
* 配音库「配音素材」Tab 单个删除逻辑
*/
import { useCallback } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Modal } from "@/components/ui"
import { deleteAsset, type AssetItem } from "@/api/assets"
import type { Toast } from "../components/VoiceToasts"
interface UseMaterialDeleteOptions {
materials: AssetItem[]
/** 停止播放回调(删除正在播放的素材时调用) */
stopPlayback: () => void
showToast: (message: string, type: Toast["type"]) => void
}
export function useMaterialDelete({
materials,
stopPlayback,
showToast,
}: UseMaterialDeleteOptions) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: (assetId: string) => deleteAsset(assetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["voice-materials"] })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
// 删除成功后停止播放(若删的是正在播放的素材,播放 id 与素材 id 一致)
stopPlayback()
showToast("素材已删除", "success")
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : "未知错误"
showToast(`删除失败:${msg}`, "error")
},
})
const handleMaterialDelete = useCallback(
(asset: AssetItem) => {
const material = materials.find((m) => m.id === asset.id)
if (!material) return
Modal.confirm({
title: "确认删除",
content: `确定删除配音素材「${material.name}」吗?删除后不可恢复。`,
okText: "删除",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => deleteMutation.mutate(material.id),
})
},
[materials, deleteMutation],
)
return { handleMaterialDelete, isDeleting: deleteMutation.isPending }
}
+151 -54
View File
@@ -972,76 +972,173 @@
}
/* ================================================================
配音素材卡片(与配音库Tab集成
配音素材卡片(复用预置音色卡片 .xx-voice-card 布局与播放控件
================================================================ */
.vmat-card {
/* 圆形图标:紫色系(素材统一配色,不用预置音色的性别色) */
.vmat-card .vmat-avatar {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
/* 左上角批量选择 checkbox */
.vmat-card-checkbox {
position: absolute;
top: 10px;
left: 10px;
z-index: 3;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
color: #fff;
display: grid;
place-items: center;
font-size: 11px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: var(--transition-fast);
}
.vmat-card:hover .vmat-card-checkbox,
.vmat-card-checkbox.checked {
opacity: 1;
}
.vmat-card-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
/* 触摸屏无 hovercheckbox 常驻显示 */
@media (hover: none) {
.vmat-card-checkbox {
opacity: 1;
}
}
/* 右上角删除按钮:常驻可见,hover 加深为 danger 色 */
.vmat-card-delete {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
font-size: 14px;
display: grid;
place-items: center;
transition: var(--transition-fast);
}
.vmat-card-delete:hover {
background: var(--error-50);
color: var(--error-600);
}
/* 选中态高亮(覆盖预置卡片的选中边框) */
.vmat-card.selected {
border-color: var(--primary-color);
box-shadow: 0 0 0 1px var(--primary-color);
}
/* 信息区给 checkbox / 删除按钮留位 */
.vmat-card .vmat-info {
padding-right: 24px;
}
/* 播放波形/控件继承 .xx-voice-card 网格,无需额外样式 */
/* 无可播放音频时播放按钮置灰 */
.vmat-card .xx-voice-play-btn:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* ── 批量操作栏 ─────────────────────────────────── */
.vmat-batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: var(--space-md);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
transition: all 0.2s;
border-radius: var(--radius-md);
}
.vmat-batch-bar-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vmat-batch-bar-right {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.vmat-checkbox {
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border-color);
background: var(--bg-primary);
color: #fff;
display: grid;
place-items: center;
font-size: 10px;
line-height: 1;
cursor: pointer;
padding: 0;
transition: var(--transition-fast);
}
.vmat-checkbox.checked {
background: var(--primary-color);
border-color: var(--primary-color);
}
.vmat-select-all {
background: none;
border: none;
padding: 0;
font-size: var(--font-size-sm);
color: var(--text-secondary);
cursor: pointer;
}
.vmat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: var(--primary-300);
.vmat-select-all:hover {
color: var(--primary-color);
}
.vmat-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
.vmat-batch-count {
font-size: var(--font-size-sm);
color: var(--primary-color);
font-weight: var(--font-weight-medium);
}
.vmat-thumb-icon {
font-size: 32px;
color: rgba(255, 255, 255, 0.9);
}
.vmat-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
border-radius: 4px;
font-family: monospace;
}
.vmat-info {
padding: 12px 14px;
}
.vmat-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.vmat-meta {
font-size: 12px;
color: var(--text-tertiary);
}
/* 骨架屏 */
/* ── 骨架屏 ─────────────────────────────────────── */
.vmat-card--skeleton {
pointer-events: none;
}
.vmat-card--skeleton .vmat-thumb {
.vmat-skeleton-avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-full);
background: var(--bg-tertiary);
grid-row: 1 / 3;
}
.vmat-skeleton-line {
@@ -17,7 +17,6 @@ import "@/api/generation/types"
// 直接引入所有 Step 组件,建立完整依赖链
import "@/pages/generate/GeneratePage"
import "@/pages/generate/components/Step2MaterialSelect"
import "@/pages/generate/components/Step5GeneratePreview"
import "@/pages/generate/components/Step4TitleSettings"
import "@/pages/generate/components/Step5VoiceSelect"
import "@/pages/generate/components/PreviewVideoPanel"
@@ -1,10 +1,9 @@
/**
* Step5GeneratePreview smoke test
* Step4+5 merged preview smoke test
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
*/
import { describe, it, expect } from "vitest"
import "@/pages/generate/components/Step5GeneratePreview"
import "@/pages/generate/hooks/usePreviewAssets"
import "@/pages/generate/hooks/useSegmentScheduler"
import "@/pages/generate/components/FrontendPreviewPlayer"
@@ -12,8 +11,8 @@ import "@/pages/generate/hooks/useStepNavigation"
import "@/pages/generate/components/GenerateStepContent"
import "@/pages/generate/GeneratePage"
describe("Step5GeneratePreview module smoke test", () => {
it("should load all step5 preview modules", () => {
describe("Merged Step4+5 preview module smoke test", () => {
it("should load all merged step4+5 preview modules", () => {
expect(true).toBe(true)
})
})
@@ -1,232 +1,242 @@
/**
* useAudioPlayer hook 测试 — VoiceLibrary 版本
* useAudioPlayer hook 测试 — VoiceLibrary 版本(真实 Audio + TTS 试听)
*
* 该 Hook 使用 setInterval 模拟音频播放进度,纯逻辑可测
* 参考 voice-materials/hooks/useAudioPlayer.test.ts 的测试结构。
* mock HTMLAudioElement 与 previewTts,验证三 tab 共用的播放状态逻辑
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
import { useAudioPlayer } from "@/pages/voices/hooks/useAudioPlayer"
import type { VoiceMaterial } from "@/pages/voice-materials/types"
describe("useAudioPlayer (voices)", () => {
beforeEach(() => {
vi.useFakeTimers()
const mockAudioPlay = vi.fn()
const mockAudioPause = vi.fn()
const listeners: Record<string, (() => void) | ((ev: unknown) => void)> = {}
let mockAudioInstance: {
play: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
addEventListener: (ev: string, cb: () => void) => void
currentTime: number
duration: number
src: string
volume: number
paused: boolean
}
const previewTtsMock = vi.fn()
vi.mock("@/api/tts", () => ({
previewTts: (...args: unknown[]) => previewTtsMock(...args),
}))
const mockMaterial: VoiceMaterial = {
id: "asset-1",
name: "测试素材",
description: "",
gender: "neutral",
tagIds: [],
fileName: "test.mp3",
fileSize: 1024,
duration: 30,
mimeType: "audio/mpeg",
createdAt: "2024-01-01T00:00:00Z",
fileUrl: "https://example.com/test.mp3",
}
beforeEach(() => {
vi.clearAllMocks()
for (const k of Object.keys(listeners)) delete listeners[k]
mockAudioInstance = {
play: mockAudioPlay.mockResolvedValue(undefined),
pause: mockAudioPause,
addEventListener: (ev: string, cb: () => void) => {
listeners[ev] = cb
},
currentTime: 0,
duration: 30,
src: "",
volume: 1,
paused: true,
}
global.Audio = vi.fn().mockImplementation((url: string) => {
mockAudioInstance.src = url
return mockAudioInstance
}) as unknown as typeof Audio
})
afterEach(() => {
vi.clearAllMocks()
})
describe("useAudioPlayer (voices, 真实播放)", () => {
it("初始状态为空", () => {
const { result } = renderHook(() => useAudioPlayer())
expect(result.current.playingId).toBeNull()
expect(result.current.loadingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it("应该使用初始状态初始化", () => {
it("playMaterial 用 file_url 真实播放素材", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
expect(result.current.playingId).toBe("asset-1")
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
expect(mockAudioInstance.src).toBe("https://example.com/test.mp3")
})
it("file_url 缺失时不播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial({ ...mockMaterial, fileUrl: undefined })
})
expect(result.current.playingId).toBeNull()
expect(mockAudioPlay).not.toHaveBeenCalled()
})
it("handleToggleMaterial 播放中再点为暂停", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handleToggleMaterial(mockMaterial)
})
expect(result.current.playingId).toBe("asset-1")
act(() => {
result.current.handleToggleMaterial(mockMaterial)
})
expect(result.current.playingId).toBeNull()
expect(mockAudioPause).toHaveBeenCalled()
})
it("暂停后再次点击恢复播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handleToggleMaterial(mockMaterial)
})
act(() => {
result.current.handleToggleMaterial(mockMaterial) // 暂停
})
mockAudioPlay.mockClear()
act(() => {
result.current.handleToggleMaterial(mockMaterial) // 恢复
})
expect(result.current.playingId).toBe("asset-1")
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
})
it("playVoice 合成中显示 loading,成功后播放且二次点用缓存不重复合成", async () => {
previewTtsMock.mockResolvedValue({ audio_url: "https://example.com/tts.mp3", duration: 5 })
const { result } = renderHook(() => useAudioPlayer())
await act(async () => {
await result.current.playVoice("voice-1")
})
expect(previewTtsMock).toHaveBeenCalledWith(
expect.objectContaining({ voice_id: "voice-1", speed: 1.0 }),
)
expect(result.current.playingId).toBe("voice-1")
expect(mockAudioInstance.src).toBe("https://example.com/tts.mp3")
// 停止后再次试听同一音色 → 走缓存
act(() => {
result.current.stopPlayback()
})
mockAudioPlay.mockClear()
previewTtsMock.mockClear()
await act(async () => {
await result.current.playVoice("voice-1")
})
expect(previewTtsMock).not.toHaveBeenCalled()
expect(result.current.playingId).toBe("voice-1")
expect(mockAudioPlay).toHaveBeenCalledTimes(1)
})
it("合成失败时复位 loading 且不播放", async () => {
previewTtsMock.mockRejectedValue(new Error("克隆尚未完成"))
const { result } = renderHook(() => useAudioPlayer())
await act(async () => {
await result.current.playVoice("voice-x")
})
expect(result.current.loadingId).toBeNull()
expect(result.current.playingId).toBeNull()
expect(mockAudioPlay).not.toHaveBeenCalled()
})
it("互斥:播放新素材时停止上一个", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
act(() => {
result.current.playMaterial({
...mockMaterial,
id: "asset-2",
fileUrl: "https://e.com/2.mp3",
})
})
expect(result.current.playingId).toBe("asset-2")
expect(mockAudioPause).toHaveBeenCalledTimes(1)
})
it("handleSeek 播放中调整 currentTime", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
act(() => {
result.current.handleSeek(12)
})
expect(mockAudioInstance.currentTime).toBe(12)
})
it("timeupdate 更新进度、ended 复位", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.playMaterial(mockMaterial)
})
act(() => {
mockAudioInstance.currentTime = 7
;(listeners["timeupdate"] as () => void)()
})
expect(result.current.currentTime).toBe(7)
act(() => {
;(listeners["ended"] as () => void)()
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
it("handlePlay 应该开始播放指定音色", () => {
it("stopPlayback 复位全部状态", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
result.current.playMaterial(mockMaterial)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBe(0)
})
it("handlePlay 对同一个音色不应重复启动播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
const initialTime = result.current.currentTime
// 推进一些时间让进度走动
act(() => {
vi.advanceTimersByTime(200)
})
const timeAfterAdvance = result.current.currentTime
expect(timeAfterAdvance).toBeGreaterThan(initialTime)
// 对同一个音色再次调用 handlePlay 不应重置
act(() => {
result.current.handlePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBe(timeAfterAdvance)
})
it("handlePlay 切换音色时应停止上一个并从头开始", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBeGreaterThan(0)
act(() => {
result.current.handlePlay("voice-2", 15)
})
expect(result.current.playingId).toBe("voice-2")
expect(result.current.currentTime).toBe(0)
})
it("播放进度应该随时间递增", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
// 每 100ms 增加 0.1
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.currentTime).toBeCloseTo(0.3, 1)
expect(result.current.playingId).toBe("voice-1")
})
it("播放到结尾应自动停止并重置", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 0.5) // 0.5 秒的短音频
})
act(() => {
vi.advanceTimersByTime(600) // 超过 0.5 秒
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
})
it("handlePause 应该暂停播放", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(200)
})
const timeBeforePause = result.current.currentTime
act(() => {
result.current.handlePause()
})
expect(result.current.playingId).toBeNull()
// 暂停后时间不应再变化
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.currentTime).toBe(timeBeforePause)
})
it("handleSeek 应该跳转到指定时间", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
result.current.handleSeek("voice-1", 5, 10)
})
expect(result.current.currentTime).toBe(5)
})
it("handleSeek 对不同音色应该开始播放该音色", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
result.current.handleSeek("voice-2", 3, 15)
})
expect(result.current.playingId).toBe("voice-2")
expect(result.current.currentTime).toBe(3)
})
it("handleTogglePlay 应该在播放和暂停之间切换", () => {
const { result } = renderHook(() => useAudioPlayer())
// 初始为暂停,调用应开始播放
act(() => {
result.current.handleTogglePlay("voice-1", 10)
})
expect(result.current.playingId).toBe("voice-1")
// 再次调用应暂停
act(() => {
result.current.handleTogglePlay("voice-1", 10)
})
expect(result.current.playingId).toBeNull()
})
it("stopPlayback 应该重置所有播放状态", () => {
const { result } = renderHook(() => useAudioPlayer())
act(() => {
result.current.handlePlay("voice-1", 10)
})
act(() => {
vi.advanceTimersByTime(300)
})
expect(result.current.playingId).toBe("voice-1")
expect(result.current.currentTime).toBeGreaterThan(0)
act(() => {
result.current.stopPlayback()
})
expect(result.current.playingId).toBeNull()
expect(result.current.currentTime).toBe(0)
// 停止后定时器不应再触发
const timeAfterStop = result.current.currentTime
act(() => {
vi.advanceTimersByTime(500)
})
expect(result.current.currentTime).toBe(timeAfterStop)
})
it("返回值应该包含所有必要的方法和状态", () => {
const { result } = renderHook(() => useAudioPlayer())
expect(typeof result.current.handlePlay).toBe("function")
expect(typeof result.current.handlePause).toBe("function")
expect(typeof result.current.handleSeek).toBe("function")
expect(typeof result.current.handleTogglePlay).toBe("function")
expect(typeof result.current.stopPlayback).toBe("function")
expect(typeof result.current.playingId).toBe("object") // string | null
expect(typeof result.current.currentTime).toBe("number")
expect(mockAudioPause).toHaveBeenCalled()
})
})
@@ -29,7 +29,7 @@ import "@/pages/voices/components/tts-modal/ErrorAlert"
import "@/pages/voices/components/tts-modal/ResultPanel"
import "@/pages/voices/components/tts-modal/types"
import "@/pages/voices/components/VoiceFilterBar"
import "@/pages/voices/components/MaterialVoiceCard"
import "@/pages/voices/components/MaterialVoiceTab"
// 类型与常量
import "@/pages/voices/types"
@@ -575,12 +575,13 @@ class RenderAdapter:
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
# 6. 生成封面缩略图
# 6. 生成封面缩略图(结果通过 RenderAdapterResult.thumbnail_url 返回给调用方)
thumbnail_url = ""
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
# job_id 是 _do_render 方法的参数(参见方法签名)
thumb_storage_key = f"rendered/{plan_id}/thumbnails/{job_id}.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
except Exception as thumb_err:
logger.warning(
@@ -598,7 +599,7 @@ class RenderAdapter:
# 抽帧天然带标题,因此这里传空字符串,避免 Pillow 二次叠加导致重影。
# Pillow 叠加仅用于 API 从源素材抽帧(源素材本身无标题)的兜底场景。
cover_candidates = extract_and_upload_cover_frames(
str(result.output_path), plan_id, num_frames=3, title_text=""
str(result.output_path), plan_id, task_id=job_id, num_frames=3, title_text=""
)
if cover_candidates:
logger.info(
@@ -278,6 +278,7 @@ def extract_and_upload_cover_frames(
video_path: str,
plan_id: str,
*,
task_id: str = "",
num_frames: int = 3,
title_text: str = "",
title_color: str = "#ffffff",
@@ -291,6 +292,7 @@ def extract_and_upload_cover_frames(
Args:
video_path: 视频文件路径
plan_id: 编辑计划 ID(用于生成 storage key
task_id: 任务 ID(用于生成独立的 storage key,避免标题变更时封面冲突)
num_frames: 抽取帧数(默认 3
title_text: 标题文字;非空时用 Pillow 叠加到每帧。
从已渲染视频抽帧时通常传空(标题已烧录);从源素材抽帧时传标题。
@@ -338,7 +340,7 @@ def extract_and_upload_cover_frames(
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/mediakit_frame_{i}.jpg"
storage_key = f"covers/{plan_id}/{task_id}/mediakit_frame_{i}.jpg"
url = upload_to_oss(tmp.name, storage_key)
if url:
seek_time = frame.get("timestamp", 0.0)
@@ -377,7 +379,7 @@ def extract_and_upload_cover_frames(
position=title_position,
font_size=title_font_size,
)
storage_key = f"covers/{plan_id}/frame_{i}.jpg"
storage_key = f"covers/{plan_id}/{task_id}/frame_{i}.jpg"
url = upload_to_oss(frame_path, storage_key)
if url:
seek_time = max(0.5, duration * ratio) if duration > 0 else 0.0
@@ -1,7 +1,30 @@
def infer_mime_type_from_storage_key(storage_key: str) -> str:
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
# Video
if lower_filename.endswith(".mov"):
return "video/quicktime"
if lower_filename.endswith((".mp4", ".m4v", ".avi", ".mkv", ".webm")):
return "video/mp4"
# Audio
if lower_filename.endswith(".m4a"):
return "audio/mp4"
if lower_filename.endswith(".mp3"):
return "audio/mpeg"
if lower_filename.endswith(".wav"):
return "audio/wav"
if lower_filename.endswith(".flac"):
return "audio/flac"
if lower_filename.endswith(".ogg"):
return "audio/ogg"
if lower_filename.endswith(".aac"):
return "audio/aac"
if lower_filename.endswith(".wma"):
return "audio/x-ms-wma"
if lower_filename.endswith(".amr"):
return "audio/amr"
if lower_filename.endswith(".opus"):
return "audio/opus"
if lower_filename.endswith(".weba"):
return "audio/webm"
# Default fallback
return "image/jpeg"
+5
View File
@@ -49,10 +49,15 @@ def __getattr__(name: str):
from .ai_tasks import run_generate_cover
return run_generate_cover
elif name == "batch_generate_thumbnails":
from .batch_thumbnail import batch_generate_thumbnails
return batch_generate_thumbnails
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
"batch_generate_thumbnails",
"classify_asset",
"generate_video",
"healthcheck",
@@ -0,0 +1,165 @@
"""批量修复素材缩略图 — 为历史视频素材生成缩略图。
使用方式:
从管理接口或 shell 触发:
celery_app.send_task("worker.batch_generate_thumbnails")
逻辑:
1. 查询所有 file_type=video 且 thumbnail_url 为空或为旧格式公开 URL 的素材
2. 逐个:下载视频 → 抽第一帧 → 上传 OSS → 更新 thumbnail_url 为 storage_key
3. 每处理 50 条 commit 一次,失败单条跳过不阻塞
"""
from __future__ import annotations
import tempfile
from pathlib import Path
from celery.utils.log import get_task_logger
from worker_app.celery_app import celery_app
from worker_app.db import SessionLocal
logger = get_task_logger(__name__)
# 旧格式 URL 前缀(ingest 旧代码生成的公开 URL),需替换为 storage_key
_OLD_URL_PREFIX = "https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/"
# 从公开 URL 中提取 storage_key 时,去掉域名前缀即可
_DOMAIN_PREFIXES = [
"https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/",
"http://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/",
]
def _url_to_storage_key(url: str) -> str | None:
"""尝试将旧格式公开 URL 转回 storage_key。"""
for prefix in _DOMAIN_PREFIXES:
if url.startswith(prefix):
return url[len(prefix) :]
return None
@celery_app.task(name="worker.batch_generate_thumbnails")
def batch_generate_thumbnails() -> dict:
"""为所有缺少缩略图的视频素材批量生成缩略图。
Returns:
dict: {total, success, skipped, failed, converted_legacy}
"""
from video_processing.oss_helpers import download_asset, upload_to_oss
from video_processing.thumbnail_generator import extract_first_frame
from packages.adapters.sqlalchemy_impl.models import AssetModel
db = SessionLocal()
stats = {"total": 0, "success": 0, "skipped": 0, "failed": 0, "converted_legacy": 0}
try:
# 查询所有视频素材中缩略图缺失的
assets = (
db.query(AssetModel)
.filter(
AssetModel.file_type == "video",
AssetModel.status != "deleted",
)
.all()
)
# 筛选需要处理的:thumbnail_url 为空 或 旧格式公开 URL
to_process = []
for asset in assets:
thumb = asset.thumbnail_url or ""
if not thumb:
to_process.append((asset, None)) # (asset, None=需要新生成)
elif thumb.startswith("http"):
# 旧格式公开 URL → 尝试转为 storage_key
sk = _url_to_storage_key(thumb)
if sk:
to_process.append((asset, sk)) # 已有文件,只需改 DB
else:
# 非预期 URL 格式,跳过
stats["skipped"] += 1
# else: 已经是 storage_key 格式,跳过
stats["total"] = len(to_process)
logger.info(
"批量缩略图修复启动: total=%d (new=%d, legacy_convert=%d)",
stats["total"],
sum(1 for _, sk in to_process if sk is None),
sum(1 for _, sk in to_process if sk is not None),
)
batch_count = 0
for asset, existing_key in to_process:
try:
if existing_key is not None:
# 旧 URL → storage_key,只需更新 DB
asset.thumbnail_url = existing_key
stats["converted_legacy"] += 1
stats["success"] += 1
else:
# 需要新生成缩略图
if not asset.storage_key:
stats["skipped"] += 1
continue
suffix = Path(asset.storage_key).suffix or ".mp4"
local_file = None
frame_path = None
try:
# 下载视频
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
local_file = Path(tmp.name)
if not download_asset(asset.storage_key, local_file):
logger.warning("下载失败: asset_id=%s key=%s", asset.id, asset.storage_key[:60])
stats["failed"] += 1
continue
# 抽帧
frame_path = extract_first_frame(str(local_file), width=640)
# 上传
thumb_key = f"assets/{asset.project_id}/thumbnails/{asset.id}.jpg"
upload_ok = upload_to_oss(frame_path, thumb_key)
if upload_ok:
asset.thumbnail_url = thumb_key
stats["success"] += 1
else:
logger.warning("上传失败: asset_id=%s", asset.id)
stats["failed"] += 1
finally:
if local_file and local_file.exists():
try:
local_file.unlink(missing_ok=True)
except OSError:
pass
if frame_path and Path(frame_path).exists():
try:
Path(frame_path).unlink(missing_ok=True)
except OSError:
pass
batch_count += 1
if batch_count % 50 == 0:
db.commit()
logger.info("批量缩略图进度: %d/%d", batch_count, stats["total"])
except Exception as e:
logger.warning("单条处理失败: asset_id=%s error=%s", asset.id, e)
stats["failed"] += 1
try:
db.rollback()
except Exception:
pass
# 最后提交
db.commit()
logger.info("批量缩略图修复完成: %s", stats)
return stats
except Exception as e:
logger.error("批量缩略图修复异常: %s", e)
db.rollback()
return {**stats, "error": str(e)}
finally:
db.close()
+14 -4
View File
@@ -389,6 +389,7 @@ def _upload_and_record(
editing_mode,
user_id: str = "",
video_name: str = "",
thumbnail_url: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
@@ -447,6 +448,7 @@ def _upload_and_record(
mode=editing_mode.value,
session=dedup_session,
name=video_name,
thumbnail_url=thumbnail_url,
)
finally:
dedup_session.close()
@@ -535,11 +537,11 @@ def _render_from_edit_plan(
task_id: str,
source_edit_plan_id: str,
task_info: dict,
) -> tuple[Path, float, list[dict] | None, str | None, str | None]:
) -> tuple[Path, float, list[dict] | None, str | None, str | None, str]:
"""从 EditPlan 数据库记录直接渲染(不再内存重建clips)。
Returns:
(output_path, render_duration, cover_candidates, voiceover_path, temp_dir)
(output_path, render_duration, cover_candidates, voiceover_path, temp_dir, thumbnail_url)
"""
from video_processing.render_adapter import RenderAdapter
from worker_app.db import SessionLocal
@@ -580,7 +582,14 @@ def _render_from_edit_plan(
cover_candidates = getattr(result, "cover_candidates", None)
render_temp_dir = getattr(result, "temp_dir", None)
return output_path, result.duration, cover_candidates, voiceover_path, render_temp_dir
return (
output_path,
result.duration,
cover_candidates,
voiceover_path,
render_temp_dir,
result.thumbnail_url or "",
)
finally:
db.close()
@@ -692,7 +701,7 @@ def generate_video(self, task_id: str) -> dict:
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
_flush_logs(task_id, gen_task)
output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir = (
output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir, thumbnail_url = (
_render_from_edit_plan(
task_id=task_id,
source_edit_plan_id=source_edit_plan_id,
@@ -717,6 +726,7 @@ def generate_video(self, task_id: str) -> dict:
editing_mode=editing_mode,
user_id=user_id,
video_name=task_info.get("video_title", ""),
thumbnail_url=thumbnail_url,
)
if gen_task:
+17 -11
View File
@@ -214,20 +214,26 @@ def ingest_asset(job_id: str) -> dict:
frame_path = extract_first_frame(str(local_file), width=640)
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
try:
thumbnail_url = upload_to_oss(frame_path, thumb_storage_key)
finally:
if frame_path:
try:
Path(frame_path).unlink(missing_ok=True)
except Exception:
pass
if thumbnail_url:
upload_ok = upload_to_oss(frame_path, thumb_storage_key)
if upload_ok:
# 存储 storage_key(非公开 URL),API 层通过 get_download_url 生成签名 URL
thumbnail_url = thumb_storage_key
logger.info(
"素材缩略图生成成功: job_id=%s url=%s",
"素材缩略图生成成功: job_id=%s key=%s",
job_id,
thumbnail_url[:80],
thumb_storage_key[:80],
)
else:
logger.warning(
"素材缩略图上传 OSS 失败: job_id=%s",
job_id,
)
# frame_path 是临时文件,及时清理
if frame_path:
try:
Path(frame_path).unlink(missing_ok=True)
except Exception:
pass
except Exception as thumb_err:
logger.warning(
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
+39 -4
View File
@@ -11,8 +11,11 @@
from __future__ import annotations
import logging
import random
from typing import List
from typing import Callable, List
logger = logging.getLogger(__name__)
from packages.domain.edit_plan_clip import EditPlanClip
from packages.domain.editing_mode import EditingMode
@@ -204,6 +207,7 @@ def _calc_random_start_time(
clip_duration: float,
asset_durations: dict[str, float] | None,
used_segments: dict[str, list[tuple[float, float]]] | None = None,
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
) -> float | None:
"""计算随机 start_time,避开已使用的时间段.
@@ -216,6 +220,10 @@ def _calc_random_start_time(
clip_duration: 片段时长(秒)
asset_durations: 素材 ID -> 时长映射
used_segments: {asset_id: [(start1, end1), (start2, end2), ...]} 已使用的时间段
on_exhausted: 100 次随机都找不到空闲区间时的受控复用回调,入参为
(asset_id, clip_duration),返回 (start, end) 复用区间或 None。
历史记录永不自动清空;回调返回 None(全部达上限/复用占比超闸门)时
本函数返回 None,由调用方轮询下一个素材或报错,不做重叠降级。
Returns:
随机 start_time 或 None
@@ -255,8 +263,36 @@ def _calc_random_start_time(
if not overlap:
return candidate
# 如果尝试多次仍找不到,缩短时长使用素材末尾
# 找到最后一个已使用段之后的可用空间
# 100 次都找不到空闲区间:进入受控复用,回调从历史区间中选最久未用且
# 使用次数未达上限的区间返回(历史记录永不自动清空)
if on_exhausted is not None:
try:
reused = on_exhausted(asset_id, clip_duration)
except Exception:
logger.warning(
"on_exhausted 受控复用回调异常: asset_id=%s",
asset_id,
exc_info=True,
)
reused = None
if reused is not None:
reuse_start, reuse_end = reused
# 边界保护:不越素材末尾、不为负
reuse_start = max(0.0, min(float(reuse_start), max_start))
logger.info(
"素材可用区间耗尽,受控复用历史区间: asset_id=%s start=%.2f end=%.2f",
asset_id,
reuse_start,
reuse_end,
)
return reuse_start
# 回调存在但拒绝复用(区间全部达 use_count 上限,或复用占比将超 15% 闸门):
# 返回 None,由调用方轮询下一个素材;绝不能末尾/0.0 降级——那会把片段
# 放回到已用过的画面,违反区间避让与重复率控制原则
return None
# 未提供 on_exhausted 回调(向后兼容):降级使用素材末尾空闲位置;
# 末尾也已占满时返回 0.0(旧行为,仅无持久化追踪的调用方会走到这里)
last_used_end = 0.0
for _seg_start, seg_end in used:
last_used_end = max(last_used_end, seg_end)
@@ -265,7 +301,6 @@ def _calc_random_start_time(
# 返回从最后使用点开始的位置
return min(last_used_end, max_start)
# 实在没有空间,返回0(可能会重叠,但至少能执行)
return 0.0
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# CI 公共步骤:检测 push(develop/main) 事件的改动范围
# 输出 skip_backend / skip_frontend(复用 PR check 的语义)
# - 纯前端改动(仅 apps/web/): skip_backend=true
# - 纯后端改动(不含 apps/web/): skip_frontend=true
# - 全栈 / 无法判断: 两者都 false(走全量,安全兜底)
# 需要环境变量: GITHUB_TOKEN, GITHUB_API_URL, GITHUB_REPOSITORY, GITHUB_SHA
set -eu
OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}"
before="${GITHUB_EVENT_BEFORE:-}"
after="${GITHUB_SHA:-}"
repo="${GITHUB_REPOSITORY:-}"
base="${GITHUB_API_URL:-}"
# Gitea Actions 中 push 事件的前一个 SHA 在 event payload 的 before 字段
if [ -z "$before" ] && [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
before=$(python3 -c "
import json,sys
try:
d=json.load(open('${GITHUB_EVENT_PATH}'))
print(d.get('before','') or '')
except Exception:
print('')
")
fi
echo "改动范围检测: before=${before:-<empty>} after=${after}"
FILES=""
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
# Gitea 1.26.x compare API 的顶层 files 字段不填充(始终为空),
# 但响应里每个 commit 条目自带的 files 完整可用;聚合区间内所有提交的 files 即可。
API_URL="${base}/repos/${repo}/compare/${before}...${after}?per_page=300"
for attempt in 1 2 3; do
FILES=$(curl -s --max-time 30 -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" \
| python3 -c "
import json,sys
try:
d=json.load(sys.stdin)
files=set()
for c in d.get('commits', []) or []:
for f in (c.get('files') or []):
n = f.get('filename') or ''
if n:
files.add(n)
print('\n'.join(sorted(files)))
except Exception:
pass
")
[ -n "$FILES" ] && break
echo "compare API 无返回,重试 $attempt/3..."
sleep 3
done
fi
if [ -z "$FILES" ]; then
echo "⚠️ 无法获取改动文件列表(新分支/API异常),保守起见走全量构建"
echo "skip_backend=false" >> "$OUTPUT"
echo "skip_frontend=false" >> "$OUTPUT"
exit 0
fi
TOTAL=$(printf '%s\n' "$FILES" | grep -c . || true)
FRONTEND_COUNT=$(printf '%s\n' "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(python3 -c "print($TOTAL - $FRONTEND_COUNT)")
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt 0 ]; then
echo "skip_backend=true" >> "$OUTPUT"
echo "skip_frontend=false" >> "$OUTPUT"
echo "✅ 纯前端改动,跳过后端镜像构建"
elif [ "$FRONTEND_COUNT" = "0" ] && [ "$BACKEND_COUNT" -gt 0 ]; then
echo "skip_backend=false" >> "$OUTPUT"
echo "skip_frontend=true" >> "$OUTPUT"
echo "🔧 纯后端改动,跳过 Web 镜像构建"
else
echo "skip_backend=false" >> "$OUTPUT"
echo "skip_frontend=false" >> "$OUTPUT"
echo "🔧 包含全栈/公共变更,三个镜像全部构建"
fi
+51 -15
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# PR构建专用:只构建不输出,验证Dockerfile能否正常构建
# 无本地缓存(12个runner不共享,反而添乱),只用ACR远程缓存
# 缓存:复用宿主机持久 builder (ci-builder-persist) 的层缓存 + ACR registry 缓存兜底
# 无状态:build-only 不推送,job 结束无需清理(builder 为共享持久资源)
set -eu
NO_CACHE_FLAG=""
@@ -18,26 +19,61 @@ for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
BUILDER_NAME="ci-builder-persist"
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
else
docker buildx use "$BUILDER_NAME"
echo "持久 builder 不存在,创建中..."
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
--driver-opt network=host \
--buildkitd-flags "--allow-insecure-entitlement network.host" \
--platform linux/amd64
fi
docker buildx inspect --bootstrap
docker buildx use "$BUILDER_NAME"
docker buildx inspect "$BUILDER_NAME" --bootstrap
echo "=== PR Build: build only, no output, remote cache only ==="
echo "=== PR Build: build only, no push (persistent builder cache) ==="
echo "Dockerfile: ${DOCKERFILE}"
echo "Image tag: ${IMAGE_TAG}"
echo "Image tag: ${IMAGE_TAG}"
echo "Builder: ${BUILDER_NAME}"
echo ""
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
.
run_build() {
docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
.
}
# PR 构建同样容错:检测到 builder 缓存损坏时,用 flock 串行重建共享 builder 后重试一次
if ! build_output=$(run_build 2>&1); then
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key|no such file or directory.*cache"; then
echo "$build_output"
echo "⚠️ builder 缓存异常,串行重建持久 builder 后重试..."
LOCK_FILE="/tmp/ci-builder-persist-rebuild.lock"
exec 9>"$LOCK_FILE"
flock -w 120 9 || echo "⚠️ 等待重建锁超时,直接重试 build"
if ! docker buildx inspect "$BUILDER_NAME" --bootstrap >/dev/null 2>&1; then
echo "🔨 锁内重建持久 builder..."
docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
--driver-opt network=host \
--buildkitd-flags "--allow-insecure-entitlement network.host" \
--platform linux/amd64
docker buildx use "$BUILDER_NAME"
docker buildx inspect "$BUILDER_NAME" --bootstrap
else
echo "✅ builder 已被其他并发 job 重建/恢复,直接复用"
fi
run_build
else
echo "$build_output"
exit 1
fi
else
echo "$build_output"
fi
echo ""
echo "PR build OK (build only, no output): ${IMAGE_TAG}"
+63 -45
View File
@@ -1,10 +1,19 @@
#!/bin/bash
# 通用Docker镜像构建+推送脚本local cache为主 + registry cache共享)
# 通用Docker镜像构建+推送脚本
# 缓存策略(2026-08 起):
# - buildx 使用宿主机持久 builder (ci-builder-persist),层缓存保存在
# buildkit 容器/命名卷中,跨 job 共享、job 结束不清理
# - registry cache 仅作为冷启动兜底读取
# - 额外 tag(如分支 tag :develop)通过 EXTRA_TAGS 环境变量传入,随构建一并推送
# 用法: docker_build_push.sh [--no-cache] <Dockerfile> <image_tag> <cache_ref> [build_arg...]
# 环境变量:
# EXTRA_TAGS 空格分隔的额外 tag(完整 image:tag 引用),可选
set -eu
# 单次 build 超时时间(秒),防止 docker buildx build 无限挂起
BUILD_TIMEOUT=1500
# 持久 builder 名(宿主机级,所有 CI job 共享;由 ensure_persistent_builder.sh 维护)
BUILDER_NAME="ci-builder-persist"
NO_CACHE_FLAG=""
if [ "$1" = "--no-cache" ]; then
@@ -22,23 +31,27 @@ for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
docker buildx create --use --name ci-builder --driver docker-container
echo "Created ci-builder"
else
docker buildx use ci-builder
echo "Using existing ci-builder"
# 确保持久 builder 存在并使用(幂等)
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
echo "持久 builder 不存在,创建中..."
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
--driver-opt network=host \
--buildkitd-flags "--allow-insecure-entitlement network.host" \
--platform linux/amd64
fi
docker buildx inspect --bootstrap
docker buildx use "$BUILDER_NAME"
docker buildx inspect "$BUILDER_NAME" --bootstrap
# 从cache_ref中提取缓存名称(如 api-cache:develop -> api-cache-develop
CACHE_NAME=$(echo "$CACHE_REF" | tr '/' '_' | tr ':' '-')
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
# 组装额外 tag 参数
EXTRA_TAG_FLAGS=""
EXTRA_TAG_LIST=""
if [ -n "${EXTRA_TAGS:-}" ]; then
for t in $EXTRA_TAGS; do
EXTRA_TAG_FLAGS="$EXTRA_TAG_FLAGS -t $t"
EXTRA_TAG_LIST="$EXTRA_TAG_LIST $t"
done
fi
mkdir -p "$LOCAL_CACHE_DIR"
# 缓存源:local优先(带自动修复),registry兜底读写
# 本地缓存损坏时自动清理后重试,避免snapshot not found导致构建全挂
build_with_cache_retry() {
local attempt=1
local max_attempts=2
@@ -49,12 +62,10 @@ build_with_cache_retry() {
build_output=$(timeout ${BUILD_TIMEOUT} docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=local,src=${LOCAL_CACHE_DIR}" \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
$EXTRA_TAG_FLAGS \
--push \
. 2>&1)
exit_code=$?
@@ -69,48 +80,55 @@ build_with_cache_retry() {
echo "$build_output" | tail -20
return $exit_code
fi
# 检测到缓存损坏类错误,清掉本地缓存重试
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key"; then
# 检测到缓存/快照损坏类错误,重建 builder 后重试
if echo "$build_output" | grep -qE "parent snapshot.*not found|snapshot.*does not exist|cache.*corrupt|failed to compute cache key|no such file or directory.*cache"; then
echo "$build_output"
echo ""
echo "⚠️ Local cache appears corrupted, cleaning up and retrying (attempt $attempt/$max_attempts)..."
rm -rf "${LOCAL_CACHE_DIR}"
mkdir -p "${LOCAL_CACHE_DIR}"
# 清理buildx builder的内部snapshot状态
docker buildx prune -f -a > /dev/null 2>&1 || true
echo "⚠️ builder 缓存异常,重建持久 builder 后重试 (attempt $attempt/$max_attempts)..."
# 共享 builder 的重建必须串行:ci-builder-persist 被所有 build job 共用,
# 若 job A 正在构建、job B 检测到损坏直接 rm,会把 A 正在用的 buildkit 杀掉。
# 用 flock 串行化重建;拿到锁后再次检查 builder 健康度,已被别的 job 重建则直接复用。
LOCK_FILE="/tmp/ci-builder-persist-rebuild.lock"
exec 9>"$LOCK_FILE"
echo "🔒 等待重建锁(最多 120s..."
if flock -w 120 9; then
if docker buildx inspect "$BUILDER_NAME" --bootstrap >/dev/null 2>&1; then
echo "✅ builder 已被其他并发 job 重建/恢复,直接复用"
else
echo "🔨 锁内重建持久 builder..."
docker buildx rm "$BUILDER_NAME" >/dev/null 2>&1 || true
docker buildx create --name "$BUILDER_NAME" --driver docker-container \
--driver-opt network=host \
--buildkitd-flags "--allow-insecure-entitlement network.host" \
--platform linux/amd64
docker buildx use "$BUILDER_NAME"
docker buildx inspect "$BUILDER_NAME" --bootstrap
fi
else
echo "⚠️ 等待重建锁超时,直接重试 build(失败将重试/--no-cache"
docker buildx use "$BUILDER_NAME" 2>/dev/null || true
fi
attempt=$((attempt + 1))
else
# 非缓存类错误,直接输出并返回
echo "$build_output"
return $exit_code
fi
done
# 重试完还是失败,不用本地缓存最后试一次(只从registry读)
echo "⚠️ All cached attempts failed, building without local cache..."
timeout ${BUILD_TIMEOUT} docker buildx build \
$NO_CACHE_FLAG \
$BUILD_ARGS \
--cache-from "type=registry,ref=${CACHE_REF}" \
--cache-to "type=local,dest=${LOCAL_CACHE_DIR},mode=max" \
--cache-to "type=registry,ref=${CACHE_REF},mode=max,ignore-error=true" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.
return 1
}
echo "=== Step 1: Build & push image (local cache + registry cache, with auto-repair) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo "Build timeout: ${BUILD_TIMEOUT}s"
echo "=== Build & push image (persistent builder cache) ==="
echo "Builder: ${BUILDER_NAME} (persistent)"
echo "Registry cache(from): ${CACHE_REF}"
echo "Image tag: ${IMAGE_TAG}"
[ -n "$EXTRA_TAG_LIST" ] && echo "Extra tags: ${EXTRA_TAG_LIST}"
echo "Timeout: ${BUILD_TIMEOUT}s"
echo ""
build_with_cache_retry
echo ""
echo "Image pushed: ${IMAGE_TAG}"
echo "Local cache updated"
echo "Registry cache updated (if supported)"
[ -n "$EXTRA_TAG_LIST" ] && echo "Also pushed: ${EXTRA_TAG_LIST}"
echo ""
echo "Build completed: ${IMAGE_TAG}"
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# CI 公共步骤:确保宿主机持久 buildx builder 存在(DooD 模式下所有 job 共享)
# - builder 名固定: ci-builder-persist
# - docker-container driver, host 网络
# - 层缓存保存在 buildkit 容器及其 _state 命名卷中,job 结束不清理
# - 宿主机 ci-docker-cleanup.sh 已豁免该 builder
# 用法: bash scripts/ci/ensure_persistent_builder.sh
set -eu
BUILDER="ci-builder-persist"
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
echo "=== 创建持久 buildx builder: $BUILDER ==="
# 并发安全:matrix 多个 job 可能同时检测到 builder 不存在,只有一个 create 成功;
# 其余 job 的 create 会因 "builder already exists" 失败(set -e 下会退出)。
# 用 create || inspect 兜底:create 失败时若 builder 实际已被别的 job 创建,直接复用。
if ! docker buildx create --name "$BUILDER" --driver docker-container \
--driver-opt network=host \
--buildkitd-flags "--allow-insecure-entitlement network.host" \
--platform linux/amd64 2>/tmp/_buildx_create.err; then
if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
echo "=== builder 已被并发任务创建,复用: $BUILDER ==="
else
echo "❌ builder 创建失败且不存在:"
cat /tmp/_buildx_create.err
exit 1
fi
fi
else
echo "=== 复用持久 buildx builder: $BUILDER ==="
fi
docker buildx use "$BUILDER"
docker buildx inspect "$BUILDER" --bootstrap
echo "✅ builder ready"
docker buildx ls | head -5
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# CI 步骤:未重建的镜像,把 registry 上一个分支 tag 复制为新 SHA tag
# 保证 deploy-staging 的 Watchtower 链路三个镜像都有新 SHA 可拉
# 用法: bash scripts/ci/retag_skipped_image.sh <image_full_name> <new_sha> <branch>
# 例: bash scripts/ci/retag_skipped_image.sh xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/xiaoxia-saas-web <sha> develop
set -eu
IMAGE="$1"
NEW_TAG="$2"
BRANCH="${3:-develop}"
NEW_REF="${IMAGE}:${NEW_TAG}"
echo "=== 复用已有镜像(本次未重建): $IMAGE ==="
echo "目标 tag: $NEW_TAG"
# 源 tag 候选(按优先级)
CANDIDATES=()
# 1. 分支 tag(构建 job 每次成功都会推)
CANDIDATES+=("$BRANCH")
# 2. 本 push 的前一个 commit SHAcompare 事件)
if [ -n "${GITHUB_EVENT_BEFORE:-}" ] && [ "${GITHUB_EVENT_BEFORE}" != "0000000000000000000000000000000000000000" ]; then
CANDIDATES+=("${GITHUB_EVENT_BEFORE}")
fi
# 3. registry 上最新的 sha 形式 tag(通过 ACR tags API 兜底,不需要额外认证则跳过)
SRC_TAG=""
for cand in "${CANDIDATES[@]}"; do
echo "尝试拉取 ${IMAGE}:${cand} ..."
# 网络抖动容错:每个候选源最多重试 3 次
pull_ok=""
for try in 1 2 3; do
if docker pull "${IMAGE}:${cand}" >/dev/null 2>&1; then
pull_ok="yes"
break
fi
echo " 拉取失败(第 $try/3 次),2s 后重试..."
sleep 2
done
if [ -n "$pull_ok" ]; then
SRC_TAG="$cand"
break
fi
done
if [ -z "$SRC_TAG" ]; then
echo "❌ 找不到可复用的源镜像(已尝试: ${CANDIDATES[*]}"
echo " 请检查该镜像是否曾成功构建推送,或临时改用全量构建。"
exit 1
fi
echo "✅ 源镜像: ${IMAGE}:${SRC_TAG}"
docker tag "${IMAGE}:${SRC_TAG}" "${NEW_REF}"
# 推新 SHA tag;分支 tag 若指向的就是源 digest 则无需重复,失败可忽略
docker push "${NEW_REF}"
echo "✅ retag 推送完成: ${NEW_REF} (from ${SRC_TAG})"
+2 -2
View File
@@ -63,7 +63,7 @@ class TestGenerationTaskCreate:
voice_ids=["v1"],
created_by_user_id=" user1 ",
source_edit_plan_id=" plan1 ",
asset_select_mode="random",
asset_select_mode="smart",
batch_id="batch1",
)
assert task.project_id == "proj1"
@@ -76,7 +76,7 @@ class TestGenerationTaskCreate:
assert task.voice_ids == ["v1"]
assert task.created_by_user_id == "user1"
assert task.source_edit_plan_id == "plan1"
assert task.asset_select_mode == "random"
assert task.asset_select_mode == "smart"
assert task.batch_id == "batch1"
def test_create_with_template_instead_of_project(self):
+413
View File
@@ -0,0 +1,413 @@
"""Task H 单测:素材余量四字段(used_duration/available_duration/used_ratio/usable)。
覆盖
1. compute_asset_availability 纯函数各分支无区间/未满/可复用/全达上限/非视频/无时长/区间合并/扩边判定
2. _asset_availability_fields 路由辅助视频有值非视频 None+usable=True异常零影响
3. _to_asset_response 四字段注入
4. smart_match_assets 结果层过滤 usable=false
"""
import json
import sys
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from app.api.routes.assets import ( # noqa: E402
_asset_availability_fields,
_to_asset_response,
smart_match_assets,
)
from app.schemas.asset import SmartMatchRequest # noqa: E402
from app.services.asset_segment_tracker import ( # noqa: E402
MAX_RANGE_USE_COUNT,
SEGMENT_EDGE_GAP,
compute_asset_availability,
)
VIDEO_DURATION = 60.0
def _make_asset(duration=VIDEO_DURATION, ranges=None, file_type="video", classification_result=None):
"""构造测试用 Asset-like 对象(领域实体形态:metadata 为 dict)。
ranges: list of dictsused_time_ranges 条目同时写入 metadata dict
Asset 实体形态repository 返回 classification_result JSON 字符串
ORM AssetModel 形态_read_meta 两种形态都必须能读到
"""
meta_dict = {"used_time_ranges": ranges} if ranges is not None else {}
if classification_result is None and ranges is not None:
classification_result = json.dumps(meta_dict)
return SimpleNamespace(
id="asset-test",
project_id="proj-1",
library_id="lib-1",
name="测试素材",
storage_key="key/test-asset.mp4",
thumbnail_url=None,
mime_type="video/mp4" if file_type == "video" else "audio/mpeg",
file_size=1000,
duration=duration,
width=1080,
height=1920,
fps=30,
codec="h264",
status=SimpleNamespace(value="ready"),
classification_status=SimpleNamespace(value="completed"),
quality_score=90.0,
created_at=__import__("datetime").datetime(2026, 8, 1, 12, 0, 0),
uploaded_by_user_id="user-1",
tag_ids=[],
file_type=file_type,
# ORM 形态
classification_result=classification_result,
# 领域实体形态(真实路由 repository 返回的 Asset
metadata=meta_dict,
)
def _make_orm_style_asset(duration=VIDEO_DURATION, ranges=None):
"""ORM AssetModel 形态:只有 classification_result JSON 字符串,无 metadata 属性。"""
a = _make_asset(duration=duration, ranges=ranges)
del a.metadata
return a
def _range(start, end, use_count=1):
return {
"start": start,
"end": end,
"plan_id": "plan-1",
"created_at": "2026-08-29T10:00:00",
"use_count": use_count,
"last_used_at": "2026-08-29T10:00:00",
}
# ── compute_asset_availability 纯函数 ─────────────────────────────────────────
class TestComputeAssetAvailability:
def test_no_ranges_fully_usable(self):
"""无历史区间:used=0, ratio=0, usable=True。"""
info = compute_asset_availability(_make_asset(ranges=[]))
assert info is not None
assert info["used_duration"] == 0.0
assert info["available_duration"] == VIDEO_DURATION
assert info["used_ratio"] == 0.0
assert info["usable"] is True
def test_none_model_returns_none(self):
assert compute_asset_availability(None) is None
def test_non_video_returns_none(self):
"""非视频(音频)返回 None,路由层按可用处理。"""
info = compute_asset_availability(_make_asset(file_type="audio"))
assert info is None
def test_zero_duration_returns_none(self):
info = compute_asset_availability(_make_asset(duration=0.0))
assert info is None
def test_partial_usage_usable(self):
"""使用 10s,剩余 50s 空闲(≥3s),usable=True。"""
info = compute_asset_availability(_make_asset(ranges=[_range(5.0, 15.0)]))
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
assert info["available_duration"] == pytest.approx(50.0, abs=0.01)
assert info["used_ratio"] == pytest.approx(10.0 / 60.0, abs=0.001)
assert info["usable"] is True
def test_overlapping_ranges_merged(self):
"""重叠区间合并后计算 used_duration,不重复计时。"""
info = compute_asset_availability(
_make_asset(ranges=[_range(0.0, 10.0), _range(5.0, 20.0)])
)
# 合并后 [0,20] → 20s
assert info["used_duration"] == pytest.approx(20.0, abs=0.01)
assert info["used_ratio"] == pytest.approx(20.0 / 60.0, abs=0.001)
def test_full_coverage_but_reusable(self):
"""区间铺满全片(无空闲段),但 use_count 未达上限 → usable=True(受控复用)。"""
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=1)],
)
)
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
assert info["available_duration"] == 0.0
assert info["usable"] is True
def test_exhausted_not_usable(self):
"""无空闲段 且 所有区间 use_count 达上限 → usable=False。"""
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
)
)
assert info["usable"] is False
assert info["available_duration"] == 0.0
assert info["used_ratio"] == pytest.approx(1.0, abs=0.001)
def test_exhausted_multiple_ranges_all_capped(self):
"""多个区间铺满、全部达上限 → usable=False;任一未满即 usable=True。"""
info_capped = compute_asset_availability(
_make_asset(
duration=20.0,
ranges=[
_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
_range(10.0, 20.0, use_count=MAX_RANGE_USE_COUNT),
],
)
)
assert info_capped["usable"] is False
info_partial = compute_asset_availability(
_make_asset(
duration=20.0,
ranges=[
_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT),
_range(10.0, 20.0, use_count=MAX_RANGE_USE_COUNT - 1),
],
)
)
assert info_partial["usable"] is True
def test_edge_gap_consumed_not_usable(self):
"""区间未物理铺满,但扩边(+0.3s)后空闲段 <3s → 视为无空闲段;
区间 use_count 均达上限 usable=False"""
# 10s 素材:[0, 4.0] 与 [4.6, 10],物理空闲 [4.0,4.6] 仅 0.6s
# 扩边后左区间延至 4.3、右区间起于 4.3,空闲被吃掉
info = compute_asset_availability(
_make_asset(
duration=10.0,
ranges=[
_range(0.0, 4.0, use_count=MAX_RANGE_USE_COUNT),
_range(4.6, 10.0, use_count=MAX_RANGE_USE_COUNT),
],
)
)
assert info["usable"] is False
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
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),
],
)
)
assert info["usable"] is True
def test_invalid_ranges_skipped(self):
"""脏数据(缺 start/end、end<=start、use_count 非法)不崩溃,合法区间照常计算。"""
info = compute_asset_availability(
_make_asset(
duration=30.0,
ranges=[
{"start": "bad"},
{"start": 5.0, "end": 3.0},
"junk",
_range(0.0, 10.0, use_count="not-a-number"),
],
)
)
assert info is not None
assert info["used_duration"] == pytest.approx(10.0, abs=0.01)
# use_count 非法按 1 处理 → 未达上限,且空闲段充足
assert info["usable"] is True
def test_broken_classification_json_treated_as_unused(self):
"""classification_result 是非法 JSON 时按无历史区间处理。"""
info = compute_asset_availability(
_make_asset(classification_result="not-json{{{")
)
assert info is not None
assert info["used_duration"] == 0.0
assert info["usable"] is True
def test_segment_edge_gap_constant(self):
"""边缘间隙常量为 0.3s(与 MediaKit 冲突检测同口径)。"""
assert SEGMENT_EDGE_GAP == 0.3
def test_domain_entity_metadata_dict_form(self):
"""领域实体形态(metadata 为 dict,无 classification_result)也能读到区间。
真实路由 repository 返回 Asset 实体区间记录在 metadata dict
repository ORM classification_result JSON 互转
"""
a = _make_asset(duration=30.0, ranges=[_range(0.0, 12.0)])
del a.classification_result # 实体没有该列
info = compute_asset_availability(a)
assert info is not None
assert info["used_duration"] == pytest.approx(12.0, abs=0.01)
assert info["usable"] is True
def test_orm_model_classification_result_form(self):
"""ORM AssetModel 形态(只有 classification_result JSON 字符串)正常。"""
a = _make_orm_style_asset(duration=30.0, ranges=[_range(0.0, 12.0)])
assert not hasattr(a, "metadata")
info = compute_asset_availability(a)
assert info is not None
assert info["used_duration"] == pytest.approx(12.0, abs=0.01)
# ── 路由层辅助:_asset_availability_fields / _to_asset_response ──────────────
class TestAssetAvailabilityFields:
def test_video_asset_returns_values(self):
fields = _asset_availability_fields(_make_asset(ranges=[_range(0.0, 10.0)]))
assert fields["usable"] is True
assert fields["used_duration"] == pytest.approx(10.0, abs=0.01)
assert fields["available_duration"] == pytest.approx(50.0, abs=0.01)
assert fields["used_ratio"] is not None
def test_non_video_returns_none_fields_usable_true(self):
fields = _asset_availability_fields(_make_asset(file_type="audio"))
assert fields["used_duration"] is None
assert fields["available_duration"] is None
assert fields["used_ratio"] is None
assert fields["usable"] is True
def test_exception_falls_back_to_zero_impact(self, monkeypatch):
"""compute 抛异常时路由层兜底:None 字段 + usable=True,不影响响应。"""
import app.api.routes.assets as assets_module
def _boom(_model):
raise RuntimeError("unexpected")
monkeypatch.setattr(assets_module, "compute_asset_availability", _boom)
fields = _asset_availability_fields(_make_asset())
assert fields["used_duration"] is None
assert fields["usable"] is True
class TestToAssetResponseInjectsFields:
def _storage_stub(self):
svc = MagicMock()
svc.get_download_url.return_value = "https://example.com/signed"
return svc
def test_video_response_carries_availability_fields(self):
asset = _make_asset(ranges=[_range(0.0, 12.0)])
resp = _to_asset_response(asset, storage_service=self._storage_stub())
assert resp.usable is True
assert resp.used_duration == pytest.approx(12.0, abs=0.01)
assert resp.available_duration == pytest.approx(48.0, abs=0.01)
assert resp.used_ratio == pytest.approx(0.2, abs=0.01)
def test_exhausted_asset_response_usable_false(self):
asset = _make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
)
resp = _to_asset_response(asset, storage_service=self._storage_stub())
assert resp.usable is False
assert resp.used_ratio == pytest.approx(1.0, abs=0.001)
def test_non_video_response_fields_none_usable_true(self):
asset = _make_asset(file_type="audio")
resp = _to_asset_response(asset, storage_service=self._storage_stub())
assert resp.used_duration is None
assert resp.available_duration is None
assert resp.used_ratio is None
assert resp.usable is True
# ── smart_match_assets 结果层过滤 ────────────────────────────────────────────
def _exhausted_asset(asset_id):
"""构造一个 usable=false 的视频素材:10s 铺满、区间 use_count 均达上限。"""
a = _make_asset(
duration=10.0,
ranges=[_range(0.0, 10.0, use_count=MAX_RANGE_USE_COUNT)],
)
a.id = asset_id
a.name = f"exhausted-{asset_id}"
return a
def _fresh_asset(asset_id, duration=60.0):
a = _make_asset(duration=duration, ranges=[])
a.id = asset_id
a.name = f"fresh-{asset_id}"
return a
class TestSmartMatchFiltersExhausted:
def _call(self, assets):
lib_repo = MagicMock()
lib_repo.get.return_value = SimpleNamespace(project_id="proj-1")
asset_repo = MagicMock()
asset_repo.find_by_library_and_file_type.return_value = assets
project_repo = MagicMock()
project = MagicMock()
project.can_access.return_value = True
project_repo.find_by_id.return_value = project
user = SimpleNamespace(id="user-1")
auth_user = SimpleNamespace(user=user)
# storage_service 在 _to_asset_response 内 get_storage_service()patch 掉
import app.api.routes.assets as assets_module
svc = MagicMock()
svc.get_download_url.return_value = "https://example.com/signed"
original_get_storage = assets_module.get_storage_service
assets_module.get_storage_service = lambda: svc
try:
resp = smart_match_assets(
SmartMatchRequest(library_id="lib-1", kind="video"),
authenticated_user=auth_user,
asset_repository=asset_repo,
asset_library_repository=lib_repo,
project_repository=project_repo,
)
finally:
assets_module.get_storage_service = original_get_storage
return resp
def test_exhausted_assets_excluded(self):
"""smart-match 结果中 usable=false 的素材被剔除,新鲜素材保留。"""
assets = [
_exhausted_asset("a-exhausted-1"),
_exhausted_asset("a-exhausted-2"),
_fresh_asset("a-fresh-1"),
]
resp = self._call(assets)
returned_ids = {item.asset.id for item in resp.items}
assert "a-fresh-1" in returned_ids
assert "a-exhausted-1" not in returned_ids
assert "a-exhausted-2" not in returned_ids
# total_candidates 是过滤前的候选总数
assert resp.total_candidates == 3
# 返回的素材全部 usable=True
assert all(item.asset.usable for item in resp.items)
def test_all_exhausted_returns_empty(self):
"""全部素材已用尽时返回空列表(不报错,前端显示空结果)。"""
assets = [_exhausted_asset("a-ex-1"), _exhausted_asset("a-ex-2")]
resp = self._call(assets)
assert resp.items == []
assert resp.total_candidates == 2
def test_fresh_assets_all_returned(self):
assets = [_fresh_asset("a-1"), _fresh_asset("a-2")]
resp = self._call(assets)
assert len(resp.items) == 2
assert all(item.asset.usable for item in resp.items)
+461
View File
@@ -0,0 +1,461 @@
"""素材片段使用记录追踪 + 受控复用机制测试(asset_segment_tracker.
覆盖
- get_used_segments 聚合 metadata 中持久化的区间
- record_used_segments 追加新记录use_count=1保留原有 metadata 字段
- record_used_segments 复用同一区间时累加 use_count / 刷新 last_used_at
- remove_used_segment 匹配删除tolerance + plan_id旧数据按时间匹配
- reset_used_segments 清空其他字段不动
- find_reusable_range选最久未用且 use_count<3 的区间全部达上限返回 None
- make_reuse_callback返回复用区间累加 reused_trackerDB 异常返回 None
- _calc_random_start_time100 次避不开时调用复用回调返回历史区间不再清空历史
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
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 app.services import asset_segment_tracker as ast
from app.services.asset_segment_tracker import (
MAX_RANGE_USE_COUNT,
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
find_reusable_range,
get_used_segments,
make_reuse_callback,
record_used_segments,
remove_used_segment,
reset_used_segments,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
class FakeModel:
"""模拟 AssetModelid + classification_resultJSON Text+ updated_at。"""
def __init__(self, asset_id: str, meta: dict | None = None):
self.id = asset_id
self.classification_result = json.dumps(meta, ensure_ascii=False) if meta else None
self.updated_at = None
def meta(self) -> dict:
return json.loads(self.classification_result) if self.classification_result else {}
class _InExpr:
def __init__(self, ids, models):
self._ids = ids
self._models = models
def all(self):
return [self._models[i] for i in self._ids if i in self._models]
class _EqExpr:
def __init__(self, target_id, models):
self._target_id = target_id
self._models = models
def with_for_update(self):
# 模拟 SQLAlchemy Query.with_for_update() 链式返回自身
return self
def first(self):
return self._models.get(self._target_id)
class FakeSession:
"""模拟 dbdb.query(Model).filter(Model.id.in_(ids)).all() / .filter(Model.id == id).first()。"""
class _Col:
def __init__(self, models):
self._models = models
def in_(self, ids):
return _InExpr(list(ids), self._models)
def __eq__(self, other):
return _EqExpr(other, self._models)
def __init__(self, models: dict[str, FakeModel]):
self._models = models
self.commits = 0
def query(self, _model):
col = self._Col(self._models)
class _Q:
def filter(self_inner, expr):
return expr
q = _Q()
_model.id = col
return q
def commit(self):
self.commits += 1
@pytest.fixture
def patched_model(monkeypatch):
monkeypatch.setattr(ast, "AssetModel", FakeModel)
def _db(models):
return FakeSession(models)
def _ranges(db, aid="a1"):
model = db._models[aid]
return json.loads(model.classification_result)["used_time_ranges"]
# ── 配置常量 ──────────────────────────────────────────────────────────────────
def test_config_constants():
assert MAX_RANGE_USE_COUNT == 3
assert REUSE_RATIO_LIMIT == 0.15
assert SEGMENT_EDGE_GAP == 0.3
# ── get_used_segments ─────────────────────────────────────────────────────────
def test_get_used_segments_aggregates_ranges(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 1.0, "end": 5.0, "plan_id": "p1", "use_count": 2},
{"start": 9.0, "end": 12.0, "plan_id": "p2"},
]
},
),
"a2": FakeModel("a2", {"other": 1}),
"a3": FakeModel("a3"),
}
db = _db(models)
assert get_used_segments(db, ["a1", "a2", "a3", "missing"]) == {"a1": [(1.0, 5.0), (9.0, 12.0)]}
def test_get_used_segments_empty(patched_model):
assert get_used_segments(_db({}), []) == {}
# ── record_used_segments ──────────────────────────────────────────────────────
def test_record_appends_new_range_with_use_count_one(patched_model):
models = {"a1": FakeModel("a1", {"generation_use_count": 48, "review_status": "pending_review"})}
db = _db(models)
record_used_segments(db, "a1", 12.5, 20.3, "plan-x")
meta = json.loads(models["a1"].classification_result)
assert meta["generation_use_count"] == 48
assert meta["review_status"] == "pending_review"
ranges = meta["used_time_ranges"]
assert len(ranges) == 1
assert ranges[0]["start"] == 12.5 and ranges[0]["end"] == 20.3
assert ranges[0]["plan_id"] == "plan-x"
assert ranges[0]["use_count"] == 1
assert "created_at" in ranges[0] and "last_used_at" in ranges[0]
assert db.commits == 0 # 不自行 commit
def test_record_reuse_same_range_increments_use_count(patched_model):
"""新片段与历史区间高度重叠(复用)→ 累加 use_count,不新增记录。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{
"start": 10.0,
"end": 20.0,
"plan_id": "p1",
"use_count": 1,
"created_at": "2026-01-01T00:00:00+00:00",
"last_used_at": "2026-01-01T00:00:00+00:00",
},
]
},
)
}
db = _db(models)
# 同一起点复用(find_reusable_range 返回的就是历史区间起点)
record_used_segments(db, "a1", 10.0, 20.0, "p2")
ranges = _ranges(db)
assert len(ranges) == 1
assert ranges[0]["use_count"] == 2
assert ranges[0]["last_used_at"] != "2026-01-01T00:00:00+00:00"
def test_record_distinct_range_appends(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 10.0, "end": 20.0, "plan_id": "p1", "use_count": 1},
]
},
)
}
db = _db(models)
record_used_segments(db, "a1", 25.0, 35.0, "p2")
ranges = _ranges(db)
assert len(ranges) == 2
assert ranges[1]["use_count"] == 1
def test_record_missing_asset_no_raise(patched_model):
db = _db({})
record_used_segments(db, "ghost", 1.0, 2.0, "p") # 不抛异常
# ── remove_used_segment ───────────────────────────────────────────────────────
def test_remove_matching_range(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 1.0, "end": 5.0, "plan_id": "p1"},
{"start": 9.0, "end": 12.0, "plan_id": "p2"},
]
},
)
}
db = _db(models)
assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="p1") is True
assert len(_ranges(db)) == 1
assert _ranges(db)[0]["start"] == 9.0
def test_remove_plan_mismatch_keeps_range(patched_model):
models = {"a1": FakeModel("a1", {"used_time_ranges": [{"start": 1.0, "end": 5.0, "plan_id": "p1"}]})}
db = _db(models)
assert remove_used_segment(db, "a1", 1.0, 5.0, plan_id="other") is False
assert len(_ranges(db)) == 1
def test_remove_legacy_range_without_plan_id(patched_model):
"""旧数据记录缺 plan_id → 按时间匹配可删除。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 2.0, "end": 12.0, "created_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
assert remove_used_segment(db, "a1", 2.0, 12.0, plan_id="plan-new") is True
assert _ranges(db) == []
# ── reset_used_segments(仅运维/测试)─────────────────────────────────────────
def test_reset_clears_ranges_keeps_other_fields(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"generation_use_count": 3,
"used_time_ranges": [
{"start": 1.0, "end": 5.0},
],
},
)
}
db = _db(models)
reset_used_segments(db, "a1")
meta = json.loads(models["a1"].classification_result)
assert meta["used_time_ranges"] == []
assert meta["generation_use_count"] == 3
# ── find_reusable_range:受控复用选择 ─────────────────────────────────────────
def test_find_reusable_prefers_oldest_unused(patched_model):
"""选 last_used_at 最老、use_count 未达上限的区间;能容纳 clip_duration。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 0.0, "end": 8.0, "use_count": 1, "last_used_at": "2026-08-01T00:00:00+00:00"},
{
"start": 10.0,
"end": 20.0,
"use_count": 1,
"last_used_at": "2026-01-01T00:00:00+00:00",
}, # 最久未用
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", clip_duration=5.0, asset_total=30.0)
assert result is not None
start, end = result
assert start == 10.0 and end == 15.0
def test_find_reusable_excludes_max_use_count(patched_model):
"""use_count 达到上限(3)的区间不再参与复用;全部达上限返回 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"},
]
},
)
}
db = _db(models)
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 的不可复用。"""
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"},
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", 5.0, 30.0)
# 只能选 use_count=2 的区间(start=0),不能选 use_count=3 的(虽然它更老)
assert result is not None and result[0] == 0.0
def test_find_reusable_clamps_to_asset_bounds(patched_model):
"""历史区间起点 + clip_duration 会越素材末尾时,起点钳制到 max_start。"""
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 25.0, "end": 30.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
result = find_reusable_range(db, "a1", clip_duration=10.0, asset_total=30.0)
assert result is not None
start, end = result
assert end <= 30.0 + 1e-6 and start >= 0.0
def test_find_reusable_no_ranges_returns_none(patched_model):
models = {"a1": FakeModel("a1", {"other": 1})}
db = _db(models)
assert find_reusable_range(db, "a1", 5.0, 30.0) is None
# ── make_reuse_callback ───────────────────────────────────────────────────────
def test_reuse_callback_returns_range_and_tracks_duration(patched_model):
models = {
"a1": FakeModel(
"a1",
{
"used_time_ranges": [
{"start": 10.0, "end": 20.0, "use_count": 1, "last_used_at": "2026-01-01T00:00:00"},
]
},
)
}
db = _db(models)
reused: dict[str, float] = {}
cb = make_reuse_callback(db, {"a1": 30.0}, reused)
result = cb("a1", 8.0)
assert result is not None and result[0] == 10.0
assert reused["a1"] == 8.0 # 复用时长累加
def test_reuse_callback_db_error_returns_none(patched_model):
class BoomSession:
def query(self, _m):
raise RuntimeError("db down")
reused: dict[str, float] = {}
cb = make_reuse_callback(BoomSession(), {"a1": 30.0}, reused)
assert cb("a1", 8.0) is None # 异常被吞,返回 None
assert reused == {}
# ── _calc_random_start_time 与受控回调集成 ────────────────────────────────────
def test_calc_random_start_uses_reuse_callback_when_exhausted(monkeypatch):
"""素材区间被占满、100 次随机找不到空位时,调用复用回调返回历史区间。"""
import packages.domain.plan_generator_utils as pgu
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5) # 固定候选点必撞区间
durations = {"a1": 30.0}
used = {"a1": [(0.0, 30.0)]} # 全占满
calls = []
def reuse_cb(asset_id, clip_duration):
calls.append((asset_id, clip_duration))
return (10.0, 18.0)
result = _calc_random_start_time("a1", 8.0, durations, used, on_exhausted=reuse_cb)
assert calls == [("a1", 8.0)]
assert result == 10.0
def test_calc_random_start_reuse_callback_none_returns_none(monkeypatch):
"""复用回调返回 None(区间全部达上限/复用占比超闸门)→ calc 返回 None。
新机制下不做末尾/0.0 重叠降级那会把片段放回已用过的画面
由调用方轮询下一个素材或报 400历史记录不被清空
"""
import packages.domain.plan_generator_utils as pgu
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5)
durations = {"a1": 30.0}
used = {"a1": [(0.0, 30.0)]}
used_before = list(used["a1"])
result = _calc_random_start_time("a1", 8.0, durations, used, on_exhausted=lambda aid, d: None)
assert result is None
assert used["a1"] == used_before # 历史记录未被清空
def test_calc_random_start_no_callback_backward_compatible(monkeypatch):
"""不传 on_exhausted 时行为与旧版兼容(100 次失败走降级)。"""
import packages.domain.plan_generator_utils as pgu
monkeypatch.setattr(pgu.random, "uniform", lambda a, b: 0.5)
result = _calc_random_start_time("a1", 8.0, {"a1": 30.0}, {"a1": [(0.0, 30.0)]})
assert result is not None
-20
View File
@@ -97,26 +97,6 @@ class TestSelectAssetsAllMode:
assert result == []
class TestSelectAssetsRandomMode:
"""random 模式:随机选取 N 个。"""
def test_random_selects_exact_count(self):
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(10)]
result = _select_assets_from_library(assets, mode="random", count=3)
assert len(result) == 3
assert all(rid in [a.id for a in assets] for rid in result)
def test_random_count_zero_returns_all(self):
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(5)]
result = _select_assets_from_library(assets, mode="random", count=0)
assert len(result) == 5
def test_random_count_exceeds_total_returns_all(self):
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(3)]
result = _select_assets_from_library(assets, mode="random", count=100)
assert len(result) == 3
class TestSelectAssetsSmartMode:
"""smart 模式:使用 smart_match 多维评分(质量40%+时长30%+新鲜度20%+未使用10%)。"""
+95
View File
@@ -0,0 +1,95 @@
"""AI Review 回归:批量生成 count>1 但 source_edit_plan_id 为空时不应 IndexError。
变体 plan 预克隆仅在 source_edit_plan_id 非空时执行无源 plan
variant_plan_ids 为空循环中禁止索引访问各任务走自身随机选片流程
"""
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
def _make_user():
return SimpleNamespace(user=SimpleNamespace(id="user-1"))
def _make_request(count):
from app.schemas.generation_task import CreateGenerationTaskRequest
return CreateGenerationTaskRequest(
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="one_take",
asset_ids=["a1"],
count=count,
source_edit_plan_id="", # 关键:无源 plan(空字符串为假值)
)
class TestBatchNoSourcePlanNoIndexError:
def test_count3_without_source_plan_creates_three_tasks(self):
"""count=3 且无 source_edit_plan_id:不克隆、不 IndexError、创建 3 个任务。"""
from app.api.routes.generation_tasks import create_generation_task
repo = MagicMock()
repo.count_pending_by_user.return_value = 0
repo.count_pending_total.return_value = 0
repo.create.side_effect = lambda t: t
repo.update.side_effect = lambda t: t
created = []
def _fake_execute(cmd):
task = MagicMock()
task.id = f"task-{len(created) + 1}"
task.source_edit_plan_id = cmd.source_edit_plan_id
task.status = "pending"
task.progress = 0.0
task.strategy_id = "one_take"
task.error_message = ""
task.cover_url = None
task.title_config = {}
task.created_at = None
task.batch_id = "batch-1"
created.append(task)
return task
with patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase") as MockUC:
MockUC.return_value.execute.side_effect = _fake_execute
with patch(
"app.api.routes.generation_tasks.safe_enqueue_generation_task",
return_value=True,
):
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
with patch(
"app.api.routes.generation_tasks._resolve_project_and_library",
return_value=("proj-1", ""),
):
# 核心断言:不得抛 IndexError(变体 plan 索引守卫)。
# 响应序列化字段与本回归无关,ValidationError 可接受,
# 但 IndexError 必须不出现。
try:
create_generation_task(
_make_request(3),
authenticated_user=_make_user(),
generation_task_repository=repo,
project_repository=MagicMock(),
asset_repository=MagicMock(),
asset_library_repository=MagicMock(),
db=MagicMock(),
)
except IndexError as exc: # pragma: no cover - 不应发生
pytest.fail(f"无源 plan 批量生成触发 IndexError: {exc}")
except Exception:
# 响应序列化等其他异常与本次守卫无关,忽略
pass
# 3 个任务全部创建(未因 IndexError 中断)
assert len(created) == 3
# 无源 plan 时所有任务 source_edit_plan_id 均为空
assert all(not t.source_edit_plan_id for t in created)
+201
View File
@@ -0,0 +1,201 @@
"""clone_plan_for_variant 单元测试(Task G 验收项:批量 N 条视频片段独立)。
验证
- 同一源 plan 克隆 3 次产出 3 个不同 plan_id各自片段起点不同
- plan 的片段不被修改
- 模板/config/时长结构被复制
- 复用占比闸门触发时保留原起点不重复抽取
- plan 无片段时抛出 ValueError
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
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")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit,便于复用同目录 stub
# 复用 test_edit_plan_service 里的内存 stub 仓储
from test_edit_plan_service import ( # noqa: E402
StubEditPlanClipRepository,
StubEditPlanRepository,
_make_service,
)
from packages.domain.edit_plan_clip import EditPlanClip
@pytest.fixture
def svc_with_source():
"""构造带源 plan + 3 个片段的 servicestub 仓储)。"""
svc = _make_service()
# clone 用 self._clip_repo.session 拿 dbstub 无 session,补一个 MagicMock
svc._clip_repo.session = MagicMock()
source = svc.create_plan(template_id="tpl-001", name="源计划", total_duration=15.0)
for i in range(3):
clip = EditPlanClip.create(
plan_id=source.id,
clip_type="main",
order=i,
asset_id=f"a{i % 2 + 1}", # a1, a2, a1
start_time=float(i * 5),
duration=5.0,
)
svc._clip_repo.create(clip)
return svc, source
def _clone_with_fake_calc(svc, source, starts, *, used=None):
"""用受控的 calc 起点列表执行一次克隆。
starts: 每次 _calc_random_start_time 返回的起点按片段顺序
返回 (new_plan, replace_all 调用的 clips_data, calc 调用记录)
"""
calc_calls: list[dict] = []
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
idx = len(calc_calls)
calc_calls.append({"asset_id": asset_id, "clip_duration": clip_duration, "on_exhausted": on_exhausted})
return starts[idx]
with (
patch(
"app.services.edit_plan_service.get_used_segments",
return_value=used or {},
),
patch(
"app.services.edit_plan_service.make_reuse_callback",
return_value=lambda aid, d: None,
),
patch(
"app.services.edit_plan_service.record_used_segments",
return_value=None,
),
patch(
"packages.domain.plan_generator_utils._calc_random_start_time",
side_effect=fake_calc,
),
patch.object(svc, "replace_all_clips_transactional", return_value=3) as mock_replace,
patch(
"packages.adapters.sqlalchemy_impl.models.AssetModel",
create=True,
) as mock_asset_model,
):
# db.query(AssetModel).filter(...).all() → 返回带 duration 的 mock 素材
m1 = MagicMock(id="a1")
m1.duration = 60.0
m2 = MagicMock(id="a2")
m2.duration = 60.0
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = [m1, m2]
new_plan = svc.clone_plan_for_variant(source.id, created_by_user_id="u1", name_suffix="变体")
clips_data = mock_replace.call_args.args[1]
return new_plan, clips_data, calc_calls
class TestClonePlanForVariant:
def test_three_clones_produce_distinct_plans_and_starts(self, svc_with_source):
"""克隆 3 次:3 个不同 plan_id,片段起点互不相同(Task G 验收)。"""
svc, source = svc_with_source
start_sets = [
[10.0, 20.0, 30.0],
[11.0, 21.0, 31.0],
[12.0, 22.0, 32.0],
]
plans = []
all_clips = []
for starts in start_sets:
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, starts)
plans.append(new_plan)
all_clips.append(clips_data)
# 3 个不同 plan_id,且都不等于源 plan
plan_ids = {p.id for p in plans}
assert len(plan_ids) == 3
assert source.id not in plan_ids
# 每次克隆的起点各自不同
for clips_data, starts in zip(all_clips, start_sets, strict=True):
assert [c["start_time"] for c in clips_data] == starts
# 三次克隆的起点集合互不相同
assert {tuple(c["start_time"] for c in clips) for clips in all_clips} == {
(10.0, 20.0, 30.0),
(11.0, 21.0, 31.0),
(12.0, 22.0, 32.0),
}
def test_source_plan_not_modified(self, svc_with_source):
"""克隆不修改源 plan 及其片段(保留用户手动编辑)。"""
svc, source = svc_with_source
source_clips_before = sorted(
[(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)]
)
source_name_before = source.name
_clone_with_fake_calc(svc, source, [9.0, 19.0, 29.0])
_clone_with_fake_calc(svc, source, [8.0, 18.0, 28.0])
source_clips_after = sorted(
[(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)]
)
assert source_clips_after == source_clips_before
assert svc._plan_repo.get(source.id).name == source_name_before
def test_clone_copies_structure(self, svc_with_source):
"""克隆复制 template_id / config / total_duration / 片段素材与时长。"""
svc, source = svc_with_source
source.config = {"mode": "ONE_TAKE"}
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0])
assert new_plan.template_id == source.template_id
assert new_plan.total_duration == source.total_duration
assert new_plan.config == {"mode": "ONE_TAKE"}
assert "变体" in new_plan.name
# 片段素材与时长结构保持
assert [c["asset_id"] for c in clips_data] == ["a1", "a2", "a1"]
assert all(c["duration"] == 5.0 for c in clips_data)
assert [c["order"] for c in clips_data] == [0, 1, 2]
def test_clone_uses_reuse_callback(self, svc_with_source):
"""克隆时 calc 传入了 on_exhausted 受控复用回调(耗尽时复用而非清空历史)。"""
svc, source = svc_with_source
_, _, calc_calls = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0])
assert len(calc_calls) == 3
for call in calc_calls:
assert call["on_exhausted"] is not None
def test_clone_ratio_blocked_keeps_original_start(self, svc_with_source):
"""复用占比闸门触发(calc 返回 None)时保留源片段原起点。"""
svc, source = svc_with_source
# 第 3 个片段 calc 返回 None(模拟复用占比超 15% 拒绝复用)
new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, None]) # type: ignore[list-item]
starts = [c["start_time"] for c in clips_data]
assert starts[0] == 10.0
assert starts[1] == 20.0
# 第 3 片段保留源起点(源 order=2 → start_time=10.0
assert starts[2] == 10.0
def test_clone_empty_source_raises(self):
"""源 plan 无片段时抛出 ValueError。"""
svc = _make_service()
svc._clip_repo.session = MagicMock()
empty = svc.create_plan(template_id="tpl-x", name="空计划")
with pytest.raises(ValueError, match="无片段"):
svc.clone_plan_for_variant(empty.id, name_suffix="变体")
def test_clone_nonexistent_source_raises(self):
"""源 plan 不存在时抛出 ValueError。"""
svc = _make_service()
svc._clip_repo.session = MagicMock()
with pytest.raises(ValueError, match="不存在"):
svc.clone_plan_for_variant("no-such-plan", name_suffix="变体")
+523 -197
View File
@@ -1,14 +1,15 @@
"""测试编辑器 from-assets 端点:同一素材切多个片段 + 随机起始 + 去重.
"""测试编辑器 from-assets 端点:按模板segment创建片段 + 事务性替换 + 随机起始.
覆盖
- required_clips_count 精确控制片段数量
- 片段数量 = segment 数量required_clips_count 被忽略
- 素材不足时同一素材轮询切多个片段
- 随机 start_time + used_segments 去重
- 素材时长不足时 clip duration 缩短
- 向后兼容不传 required_clips_count 时等于素材数量
- order 追加到时间线末尾
- 素材时长全部为 0/缺失时抛 400素材可切区间不足混合池中零时长素材被跳过
- 使用 replace_all_clips_transactional 原子性替换
- order 0 开始
- start_time=None 时抛出 400
- create_clip 失败时抛出 400 并记录日志
- mark_clips_ready 在事务方法内部完成
"""
from __future__ import annotations
@@ -29,6 +30,20 @@ from fastapi import HTTPException
TEST_PLAN_ID = "plan-draft-001"
TEST_USER_ID = "user-001"
# 默认测试用 segments:4 个片段,每个 3~5 秒
DEFAULT_SEGMENTS = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
return [(i, dur_min, dur_max) for i in range(count)]
def _patch_segments(segments=None):
return patch(
"app.api.routes.templates_editor.clips._get_template_segments",
return_value=segments if segments is not None else DEFAULT_SEGMENTS,
)
def _make_auth_user():
auth = MagicMock()
@@ -39,27 +54,6 @@ def _make_auth_user():
return auth
def _make_mock_clip(clip_id, order, duration, start_time=0.0, asset_id=""):
clip = MagicMock()
clip.id = clip_id
clip.plan_id = TEST_PLAN_ID
clip.clip_type = "main"
clip.order = order
clip.duration = duration
clip.start_time = start_time
clip.text_content = ""
clip.transition_effect = "cut"
clip.transition_duration = 0.0
clip.playback_speed = 1.0
clip.config = {}
clip.asset_id = asset_id
clip.status = "pending"
clip.template_clip_config_id = ""
clip.created_at = None
clip.updated_at = None
return clip
def _make_mock_asset(asset_id, duration):
asset = MagicMock()
asset.id = asset_id
@@ -67,123 +61,158 @@ def _make_mock_asset(asset_id, duration):
return asset
def _create_clips(plan_id, clip_type, order, duration=0.0, start_time=0.0, asset_id="", **kw):
return _make_mock_clip(
clip_id=f"clip-{order}",
order=order,
duration=duration,
start_time=start_time,
asset_id=asset_id,
)
def _make_plan_svc(existing_clips=None):
def _make_plan_svc(replace_return_count=None):
svc = MagicMock()
svc.get_plan_or_raise = MagicMock()
svc.create_clip = MagicMock(side_effect=_create_clips)
svc.list_clips = MagicMock(return_value=existing_clips or [])
# replace_all_clips_transactional 返回创建的片段数量
if replace_return_count is not None:
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
else:
svc.replace_all_clips_transactional = MagicMock(return_value=0)
return svc
class TestEditorClipsRequiredCount:
"""测试 required_clips_count 控制片段数量 + 同素材多片段."""
def _get_clips_data_from_call(mock_plan_svc):
""" replace_all_clips_transactional 的调用中获取 clips_data。"""
assert mock_plan_svc.replace_all_clips_transactional.called, "replace_all_clips_transactional 未被调用"
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
# call_args = ((plan_id, clips_data), kwargs)
if len(call_args.args) >= 2:
return call_args.args[1]
return call_args.kwargs.get("clips_data", [])
@pytest.fixture(autouse=True)
def _mock_segment_tracker():
"""from-assets 现在会读/写素材 metadata 的片段区间记录,测试中 mock 掉避免依赖真实 DB。
get_used_segments 返回空 dict等价历史行为无历史区间
record/remove/reset 回调均无副作用
"""
with (
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,
),
patch(
"app.api.routes.templates_editor.clips.make_reuse_callback",
return_value=lambda asset_id, clip_duration: None,
),
patch(
"app.api.routes.templates_editor.clips.remove_used_segment",
return_value=False,
),
):
yield
class TestEditorClipsBySegments:
"""测试按 segment 数量创建片段 + 素材轮询。"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_creates_exactly_required_clips_count(self, mock_storage):
"""required_clips_count=4 时,即使只有2个素材也创建4个片段."""
def test_creates_clips_matching_segment_count(self, mock_storage):
"""4 个 segment 即使只有2个素材也创建4个片段,required_clips_count 被忽略。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc = _make_plan_svc(replace_return_count=4)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0, "a2": 20.0}[aid]))
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=4)
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with _patch_segments(DEFAULT_SEGMENTS):
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert result.created_count == 4
assert mock_plan_svc.create_clip.call_count == 4
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 4
# 验证轮询分配:a1, a2, a1, a2
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["asset_id"] == "a1"
assert calls[1].kwargs["asset_id"] == "a2"
assert calls[2].kwargs["asset_id"] == "a1"
assert calls[3].kwargs["asset_id"] == "a2"
assert clips_data[0]["asset_id"] == "a1"
assert clips_data[1]["asset_id"] == "a2"
assert clips_data[2]["asset_id"] == "a1"
assert clips_data[3]["asset_id"] == "a2"
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_append_to_existing_timeline(self, mock_storage):
"""时间线已有2个片段时,新片段 order 2 开始连续递增."""
def test_orders_start_at_zero(self, mock_storage):
"""片段 order 从 0 开始递增"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 模拟已有 order=0, order=1 的片段
existing = [_make_mock_clip("old-1", 0, 5.0), _make_mock_clip("old-2", 1, 5.0)]
mock_plan_svc = _make_plan_svc(existing_clips=existing)
mock_plan_svc = _make_plan_svc(replace_return_count=3)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with _patch_segments(_segments(3)):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["order"] == 2
assert calls[1].kwargs["order"] == 3
assert calls[2].kwargs["order"] == 4
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert clips_data[0]["order"] == 0
assert clips_data[1]["order"] == 1
assert clips_data[2]["order"] == 2
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_orders_start_at_zero_when_empty(self, mock_storage):
"""空时间线时 order 从 0 开始."""
def test_uses_transactional_replace(self, mock_storage):
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(existing_clips=[])
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=3)
body = ClipsFromAssetsRequest(asset_ids=["a1"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with _patch_segments(_segments(2)):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["order"] == 0
assert calls[1].kwargs["order"] == 1
assert calls[2].kwargs["order"] == 2
# 必须调用事务方法
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
# 不应调用单独的 delete 或 create
assert not hasattr(mock_plan_svc, "create_clip") or not mock_plan_svc.create_clip.called
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_backward_compatible_default_count(self, mock_storage):
"""不传 required_clips_count 时,片段数等于素材数(向后兼容)."""
def test_no_segments_raises_400(self, mock_storage):
"""模板没有 segment 配置时返回 400。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
@@ -191,56 +220,67 @@ class TestEditorClipsRequiredCount:
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2", "a3"])
body = ClipsFromAssetsRequest(asset_ids=["a1"])
result = create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with _patch_segments([]):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert result.created_count == 3
assert mock_plan_svc.create_clip.call_count == 3
assert exc_info.value.status_code == 400
assert "片段配置" in exc_info.value.detail
# 不应调用替换方法
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
class TestEditorClipsDurationAndStartTime:
"""测试素材时长获取、clip duration 缩短、start_time 传入."""
"""测试素材时长获取、clip duration 缩短、start_time 传入"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_clip_duration_shortened_for_short_assets(self, mock_storage):
"""素材只有 3s 时 clip duration 缩短到 3.0."""
"""素材只有 3s 时 clip duration 缩短到不超过 3.0"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc = _make_plan_svc(replace_return_count=1)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("short", 3.0))
body = ClipsFromAssetsRequest(asset_ids=["short"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with _patch_segments(_segments(1, dur_min=5.0, dur_max=10.0)):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
calls = mock_plan_svc.create_clip.call_args_list
assert len(calls) == 1
assert calls[0].kwargs["duration"] == 3.0
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert clips_data[0]["duration"] <= 3.0
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_start_time_passed_to_create_clip(self, mock_storage):
"""验证 _calc_random_start_time 返回值被传入 create_clip."""
def test_zero_duration_asset_raises_400(self, mock_storage):
"""所有素材时长均为 0 时轮询无可用素材,抛出 400「素材可切区间不足」。
新轮询逻辑下零时长素材被跳过而非立即报错全部素材都被跳过时
返回 400不创建无效片段
"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
@@ -248,64 +288,172 @@ class TestEditorClipsDurationAndStartTime:
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("bad", 0.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
body = ClipsFromAssetsRequest(asset_ids=["bad"])
with patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=[12.5, 18.0],
) as mock_calc:
with _patch_segments(_segments(1)):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "素材可切区间不足" in exc_info.value.detail
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_zero_duration_asset_skipped_in_mixed_pool(self, mock_storage):
"""素材池混合零时长与正常素材时,零时长素材被跳过、正常素材承担片段。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(
side_effect=lambda aid: {
"zero": _make_mock_asset("zero", 0.0),
"good": _make_mock_asset("good", 30.0),
}[aid]
)
body = ClipsFromAssetsRequest(asset_ids=["zero", "good"], required_clips_count=2)
with (
_patch_segments(_segments(2)),
patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=[5.0, 12.0],
),
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 2
# 所有片段都分配给正常素材,零时长素材被跳过
assert all(c["asset_id"] == "good" for c in clips_data)
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_missing_duration_asset_raises_400(self, mock_storage):
"""素材时长缺失(asset_repo.get 返回 None)时抛出 400。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=None)
body = ClipsFromAssetsRequest(asset_ids=["missing"])
with _patch_segments(_segments(1)):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_start_time_passed_to_create(self, mock_storage):
"""_calc_random_start_time 返回值被传入 clips_data。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
with (
_patch_segments(_segments(2)),
patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=[12.5, 18.0],
) as mock_calc,
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert mock_calc.call_count == 2
calls = mock_plan_svc.create_clip.call_args_list
assert calls[0].kwargs["start_time"] == 12.5
assert calls[1].kwargs["start_time"] == 18.0
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
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_asset_durations_deduped(self, mock_storage):
"""asset_ids 有重复时只查询一次素材时长."""
"""asset_ids 有重复时只查询一次素材时长"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc = _make_plan_svc(replace_return_count=3)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
# a1 出现 3 次,但时长只应查一次
body = ClipsFromAssetsRequest(asset_ids=["a1", "a1", "a1"])
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with _patch_segments(_segments(3)):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
# 去重后只调用 1 次
# 去重后只调用 1 次获取素材时长
assert mock_asset_repo.get.call_count == 1
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_used_segments_passed_to_calc(self, mock_storage):
"""同一素材切多个片段时,used_segments 应被维护并传入."""
def test_used_segments_maintained_across_clips(self, mock_storage):
"""同一素材切多个片段时,used_segments 应被维护"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc = _make_plan_svc(replace_return_count=3)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
@@ -313,111 +461,289 @@ class TestEditorClipsDurationAndStartTime:
captured_used_segments = []
def fake_calc(asset_id, clip_duration, asset_durations, used_segments):
def fake_calc(asset_id, clip_duration, asset_durations, used_segments, on_exhausted=None):
captured_used_segments.append({aid: list(segs) for aid, segs in (used_segments or {}).items()})
return (len(captured_used_segments) - 1) * 5.0
with patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=fake_calc,
with (
_patch_segments(_segments(3)),
patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
side_effect=fake_calc,
),
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
# 第一次没有已使用时间段
assert captured_used_segments[0] == {}
assert captured_used_segments[1] == {"a1": [(0.0, 5.0)]}
assert captured_used_segments[2] == {"a1": [(0.0, 5.0), (5.0, 10.0)]}
# 第二次有第一次的记录
assert len(captured_used_segments[1]["a1"]) == 1
# 第三次有前两次的记录
assert len(captured_used_segments[2]["a1"]) == 2
class TestEditorClipsErrorHandling:
"""测试异常处理."""
"""测试异常处理"""
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_none_start_time_raises_400(self, mock_storage):
"""_calc_random_start_time 返回 None 时应抛出 HTTPException 400."""
"""所有素材 calc 均返回 None(区间耗尽且复用被拒)→ 轮询失败抛 400"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
# asset_repo.get 返回 None → asset_durations 为空 → _calc_random_start_time 返回 None
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=None)
# 素材有 duration 但 calc 返回 None(模拟可用区间耗尽、复用被闸门拒绝)
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["missing-asset"])
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
)
with (
_patch_segments(_segments(1)),
patch(
"app.api.routes.templates_editor.clips._calc_random_start_time",
return_value=None,
),
):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "时长信息缺失" in exc_info.value.detail
assert "素材可切区间不足" in exc_info.value.detail
# 复用被拒导致无起点时,不应创建任何片段
assert not mock_plan_svc.replace_all_clips_transactional.called
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_create_clip_value_error_raises_400(self, mock_storage):
"""create_clip 抛出 ValueError 时应转为 HTTPException 400."""
def test_transactional_replace_exception_propagates(self, mock_storage):
"""replace_all_clips_transactional 抛异常时应向上传播(事务已回滚)。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc.create_clip = MagicMock(side_effect=ValueError("计划不存在"))
mock_plan_svc.replace_all_clips_transactional = MagicMock(side_effect=RuntimeError("DB connection lost"))
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 30.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
with _patch_segments(_segments(1)):
with pytest.raises(RuntimeError, match="DB connection lost"):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
class TestReuseRatioGate:
"""素材区间耗尽后的受控复用与 15% 占比闸门(路由级)。"""
@staticmethod
def _make_calc_with_reuse(normal_starts, reused_durations):
"""构造模拟「区间耗尽后受控复用」的 _calc_random_start_time。
normal_starts: list[float | None] N 次调用返回的空闲起点
返回 None 表示随机找不到空闲 触发 on_exhausted 复用回调
回调被调用时返回复用区间固定 0.0 起点复用片段时长由路由累加到
reused_durations回调内部占比预判超 15% 时返回 Nonecalc 随之 None
"""
calls = {"i": 0}
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
i = calls["i"]
calls["i"] += 1
if i < len(normal_starts) and normal_starts[i] is not None:
return normal_starts[i]
# 空闲耗尽 → 走受控复用回调(回调返回 (start, end) 元组,calc 取起点)
if on_exhausted is not None:
result = on_exhausted(asset_id, clip_duration)
return result[0] if result else None
return None
return fake_calc, calls
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_reused_clip_ratio_within_threshold(self, mock_storage):
"""素材 60s、片段 5s:前 12 个用空闲区间,第 13 个复用,
复用占比 5/(12*5+5)=7.7% 15%正常创建 13 个片段"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc(replace_return_count=13)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=13)
reused: dict = {}
def reuse_cb(aid, dur):
# 模拟真实回调:返回复用区间前记录复用时长
reused[aid] = reused.get(aid, 0.0) + dur
return (0.0, dur)
# 前 12 次分配空闲起点;第 13 次 calc 直接走回调(normal_starts 越界 → None → 回调)
normal_starts = [float(i * 5) for i in range(12)]
fake_calc, _ = self._make_calc_with_reuse(normal_starts, reused)
with (
_patch_segments(_segments(13, dur_min=5.0, dur_max=5.0)),
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=reuse_cb),
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "创建片段失败" in exc_info.value.detail
class TestMarkClipsReadyAfterCreation:
"""验证 from-assets 创建片段后立即调用 mark_clips_ready,确保渲染管线能找到就绪片段。"""
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
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_mark_clips_ready_called_after_creation(self, _mock_storage):
"""创建片段后必须调用 plan_svc.mark_clips_ready(plan_id)。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
def test_reuse_ratio_exceeded_returns_400(self, mock_storage):
"""复用占比将超 15% 时回调拒绝复用 → 无可用素材 → 400「素材可切区间不足」。
60s 素材5s 片段 12 个空闲随后复用占比累计 (reused+d)/(assigned+d)
超过 15% 时回调返回 Nonecalc 返回 None轮询无素材 400
"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_plan_svc = _make_plan_svc()
mock_plan_svc = _make_plan_svc(replace_return_count=0)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, {"a1": 30.0}[aid]))
mock_asset_repo.get = MagicMock(return_value=_make_mock_asset("a1", 60.0))
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=20)
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
plan_id="plan-xyz",
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
current_user=_make_auth_user(),
# 模拟真实回调:累计复用时长,预判超 15% 拒绝
reused: dict = {}
assigned: dict = {}
def fake_reuse_cb(aid, clip_duration):
a = assigned.get(aid, 0.0)
r = reused.get(aid, 0.0)
if a > 0 and (r + clip_duration) / (a + clip_duration) > 0.15:
return None # 占比闸门拒绝
reused[aid] = r + clip_duration
return (0.0, clip_duration)
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
a = assigned.get(asset_id, 0.0)
# 前 12 个片段(60s/5s)有空闲区间
if a < 60.0:
start = a
assigned[asset_id] = a + clip_duration
return start
# 之后空闲耗尽 → 复用
if on_exhausted is not None:
result = on_exhausted(asset_id, clip_duration)
if result is not None:
assigned[asset_id] = assigned.get(asset_id, 0.0) + clip_duration
return result[0] if result else None
return None
with (
_patch_segments(_segments(20, dur_min=5.0, dur_max=5.0)),
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
patch("app.api.routes.templates_editor.clips.make_reuse_callback", return_value=fake_reuse_cb),
):
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "素材可切区间不足" in exc_info.value.detail
# 闸门在复用占比达上限时拒绝:60s 空闲 + 至多 ~15% 复用
assert reused.get("a1", 0.0) <= 12.0 # 10.0 或 15.0 以内,不会无限复用
# 未创建任何片段(整批失败)
assert not mock_plan_svc.replace_all_clips_transactional.called
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_calc_none_falls_through_to_next_asset(self, mock_storage):
"""一个素材区间耗尽且复用被拒(calc 返回 None)时,轮询到下一个可用素材。"""
from app.api.routes.templates_editor.clips import (
create_clips_from_assets_editor,
)
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
# 关键断言:mark_clips_ready 必须被调用,且传入正确的 plan_id
mock_plan_svc.mark_clips_ready.assert_called_once_with("plan-xyz")
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get = MagicMock(
side_effect=lambda aid: {
"exhausted": _make_mock_asset("exhausted", 60.0),
"fresh": _make_mock_asset("fresh", 60.0),
}[aid]
)
body = ClipsFromAssetsRequest(asset_ids=["exhausted", "fresh"], required_clips_count=2)
def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None):
if asset_id == "exhausted":
# 空闲耗尽 + 回调拒绝 → None
return on_exhausted(asset_id, clip_duration) if on_exhausted else None
return 8.0 # 新鲜素材正常返回
with (
_patch_segments(_segments(2, dur_min=5.0, dur_max=5.0)),
patch("app.api.routes.templates_editor.clips._calc_random_start_time", side_effect=fake_calc),
patch(
"app.api.routes.templates_editor.clips.make_reuse_callback",
return_value=lambda aid, d: None, # 复用始终被拒
),
):
create_clips_from_assets_editor(
template_id="tpl-001",
body=body,
background_tasks=MagicMock(),
plan_id=TEST_PLAN_ID,
services=(MagicMock(), mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data_from_call(mock_plan_svc)
assert len(clips_data) == 2
# 耗尽素材被跳过,两个片段都分配给新鲜素材
assert all(c["asset_id"] == "fresh" for c in clips_data)
+513 -5
View File
@@ -181,7 +181,7 @@ class TestUnifiedCoverPipelineEndpoint:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
):
mock_repo = MagicMock()
@@ -449,7 +449,7 @@ class TestUnifiedCoverPipelineEndpoint:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -590,7 +590,7 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -657,7 +657,7 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -1177,7 +1177,7 @@ class TestUploadCoverType:
return_value=mock_asset_repo,
),
patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -1200,3 +1200,511 @@ class TestUploadCoverType:
assert exc_info.value.status_code == 400
mock_mk.extract_frames.assert_not_called()
class TestCoverFromFinalVideo:
"""测试封面从最终成片任务(is_preview=False)获取视频源。"""
def test_generated_video_fields_in_schema(self):
"""请求体支持 generated_video_id 和 video_url 字段。"""
from app.api.routes.generation_cover import GenerateCoverRequest
req = GenerateCoverRequest(
generated_video_id="gv-001",
video_url="https://example.com/final.mp4",
)
assert req.generated_video_id == "gv-001"
assert req.video_url == "https://example.com/final.mp4"
# 默认 None
req_default = GenerateCoverRequest()
assert req_default.generated_video_id is None
assert req_default.video_url is None
def test_cover_uses_final_video_when_generated_video_id_provided(self):
"""传 generated_video_id 时,从该最终成片视频抽帧。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# Generated video
mock_gv = MagicMock()
mock_gv.file_url = "rendered/final/video.mp4"
mock_gv.generation_task_id = "task-final-001"
mock_gv.user_id = "user-1"
# 最终成片任务
mock_final_task = MagicMock()
mock_final_task.id = "task-final-001"
mock_final_task.created_by_user_id = "user-1"
mock_final_task.cover_url = ""
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-final-001",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
return_value="https://oss.example.com/covers/final-cover.jpg",
) as mock_persist,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_final_task
mock_repo.list_by_source_edit_plan.return_value = []
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/final/video.mp4"
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/frame.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/final-cover.jpg"}
}
from app.api.routes.generation_cover import generate_cover
result = generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-final",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert result.cover["image_url"] == "https://oss.example.com/covers/final-cover.jpg"
call_kwargs = mock_mk.extract_frames.call_args.kwargs
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
assert call_kwargs["strategy"] == "SpecifiedFrames"
assert call_kwargs["max_frames"] == 1
assert call_kwargs["max_retries"] == 0
mock_persist.assert_called_once()
def test_cover_uses_video_url_directly(self):
"""传 video_url 时,直接从该 URL 抽帧。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
video_url="https://oss.example.com/rendered/final/video.mp4",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
return_value="https://oss.example.com/covers/c.jpg",
),
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
mock_repo.list_by_source_edit_plan.return_value = []
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
mock_storage_svc = MagicMock()
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
}
from app.api.routes.generation_cover import generate_cover
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-url",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
call_kwargs = mock_mk.extract_frames.call_args.kwargs
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
def test_cover_prefers_final_task_over_preview_in_source_plan(self):
"""步骤3source_edit_plan 关联任务中,优先使用 is_preview=False 的最终成片。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {} # 无 rendered_storage_key / generation_task_id
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
# 一个预览任务 + 一个最终成片任务
mock_preview = MagicMock()
mock_preview.id = "task-preview"
mock_preview.status = "completed"
mock_preview.is_preview = True
mock_preview.cover_url = ""
mock_final = MagicMock()
mock_final.id = "task-final"
mock_final.status = "completed"
mock_final.is_preview = False
mock_final.cover_url = ""
mock_video_preview = MagicMock()
mock_video_preview.file_url = "rendered/preview/video.mp4"
mock_video_final = MagicMock()
mock_video_final.file_url = "rendered/final/video.mp4"
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(cover_type="ai_frame")
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_list_videos,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
return_value="https://oss.example.com/covers/c.jpg",
),
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
# list_by_source_edit_plan 返回 [preview, final],最终成片排在后面
mock_repo.list_by_source_edit_plan.return_value = [mock_preview, mock_final]
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
# 根据 task id 返回不同 video
def get_videos(task_id):
if task_id == "task-final":
return [mock_video_final]
return [mock_video_preview]
mock_use_case = MagicMock()
mock_use_case.execute.side_effect = get_videos
mock_list_videos.return_value = mock_use_case
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.side_effect = lambda key: f"https://oss.example.com/{key}"
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {
"cover": {"type": "ai_frame", "image_url": "https://oss.example.com/covers/c.jpg"}
}
from app.api.routes.generation_cover import generate_cover
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-priority",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
# 应该使用 final video URL 抽帧,而非 preview
call_kwargs = mock_mk.extract_frames.call_args.kwargs
assert "rendered/final/video.mp4" in call_kwargs["video_url"]
assert "rendered/preview" not in call_kwargs["video_url"]
def test_cover_generated_video_permission_denied(self):
"""generated_video_id 关联任务属于其他用户时,返回 403。"""
from unittest.mock import MagicMock, patch
import pytest
from app.api.routes.generation_cover import GenerateCoverRequest
from fastapi import HTTPException
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_gv = MagicMock()
mock_gv.file_url = "rendered/other/video.mp4"
mock_gv.generation_task_id = "task-other"
mock_gv.user_id = "" # 老数据无 user_id,走关联任务归属校验
mock_other_task = MagicMock()
mock_other_task.created_by_user_id = "other-user"
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-other",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_other_task
mock_repo_cls.return_value = mock_repo
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-perm",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 403
def test_cover_video_url_ssrf_blocked(self):
"""video_url 指向内网/非白名单域名时被忽略,不向其发起抽帧请求。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
# SSRF 攻击载荷:内网元数据地址
body = GenerateCoverRequest(
cover_type="ai_frame",
video_url="http://100.100.100.200/latest/meta-data/",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch("app.api.routes.generation_cover._persist_cover_frame") as mock_persist,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
mock_repo.list_by_source_edit_plan.return_value = []
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
mock_storage_svc = MagicMock()
mock_storage_svc.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
mock_storage_svc.endpoint = "oss-cn-hangzhou.aliyuncs.com"
mock_storage_svc.get_url.side_effect = lambda k: f"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/{k}"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": "https://mk/f.jpg"}}
from app.api.routes.generation_cover import generate_cover
from fastapi import HTTPException
# 内网 URL 被白名单拦截后,无任何可用视频源 → 400(而不是向内网发请求)
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-ssrf",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 400
# MediaKit 从未被要求抽帧该内网地址
if mock_mk.extract_frames.called:
called_url = mock_mk.extract_frames.call_args.kwargs.get("video_url", "")
assert "100.100.100.200" not in called_url
assert "meta-data" not in called_url
def test_cover_generated_video_ownership_unverifiable_denied(self):
"""video 无 user_id 且关联任务不存在时,归属无法确认 → 403(防权限绕过)。"""
from unittest.mock import MagicMock, patch
import pytest
from app.api.routes.generation_cover import GenerateCoverRequest
from fastapi import HTTPException
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_gv = MagicMock()
mock_gv.file_url = "rendered/mystery/video.mp4"
mock_gv.generation_task_id = "task-gone" # 关联任务已删除
mock_gv.user_id = "" # 老数据无 owner
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-mystery",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
):
mock_repo = MagicMock()
mock_repo.get.return_value = None # 关联任务查不到
mock_repo_cls.return_value = mock_repo
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-orphan",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 403
def test_is_trusted_media_url_blocks_internal_and_ipv6(self):
"""白名单函数:内网 IPv4/IPv6/元数据地址一律拒绝,自家 OSS 域名放行。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import _is_trusted_media_url
mock_storage = MagicMock()
mock_storage.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
mock_storage.endpoint = "oss-cn-hangzhou.aliyuncs.com"
with patch(
"app.api.routes.generation_cover.get_shared_storage_service",
return_value=mock_storage,
):
# 内网 / 元数据 / IPv6 本地地址全部拒绝
for bad in [
"http://127.0.0.1/admin",
"http://10.0.0.5/video.mp4",
"http://192.168.1.1/video.mp4",
"http://172.16.0.1/video.mp4",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]:8080/video.mp4",
"http://[fe80::1]/video.mp4",
"http://[fc00::1]/video.mp4",
"http://localhost/x",
"ftp://oss-cn-hangzhou.aliyuncs.com/a.mp4",
"",
]:
assert _is_trusted_media_url(bad) is False, f"应拒绝: {bad}"
# 自家 OSS 域名(含签名 URL 子路径、bucket 域名)放行
for good in [
"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/final/v.mp4",
"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/rendered/v.mp4?Expires=123&Signature=abc",
]:
assert _is_trusted_media_url(good) is True, f"应放行: {good}"
def test_is_trusted_media_url_endpoint_with_scheme_parsed(self):
"""endpoint 配置带 http:// 前缀时也能正确提取主机名,不出现 .http 后缀绕过。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import _is_trusted_media_url
mock_storage = MagicMock()
mock_storage.public_url = "http://oss.internal.example.com:9000"
mock_storage.endpoint = "http://oss.internal.example.com:9000"
with patch(
"app.api.routes.generation_cover.get_shared_storage_service",
return_value=mock_storage,
):
# 正确域名放行
assert _is_trusted_media_url("http://oss.internal.example.com:9000/a/b.mp4") is True
# 伪造后缀域名必须拒绝(修复前 split(':')[0] 会取到 'http' 导致绕过)
assert _is_trusted_media_url("http://evil-http.com/x.mp4") is False
assert _is_trusted_media_url("http://evil.http/x.mp4") is False
+2
View File
@@ -612,6 +612,8 @@ def _make_task(
task.extra_meta = extra_meta or {}
task.asset_ids = asset_ids or []
task.created_by_user_id = "test_user_001"
# 默认无关联编辑计划:涉及克隆变体的测试自行设置并 mock EditPlanService
task.source_edit_plan_id = None
return task
+68
View File
@@ -0,0 +1,68 @@
"""MediaKit 智能选片挪点冲突检测测试(Task G 验收项 1)。
覆盖 _recommended_time_conflicts
- 区间重叠判定 0.3s 边缘间隙扩边
- 不冲突场景间隔大于边缘间隙
- 边缘间隙可配置
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
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 (
SEGMENT_EDGE_GAP,
_recommended_time_conflicts,
)
class TestRecommendedTimeConflicts:
def test_overlapping_range_conflicts(self):
"""推荐区间与已用区间直接重叠 → 冲突。"""
assert _recommended_time_conflicts(10.0, 5.0, [(12.0, 17.0)]) is True
def test_identical_range_conflicts(self):
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 扩边内 → 冲突。"""
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 边缘间隙)→ 冲突。"""
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
def test_far_apart_no_conflict(self):
"""相隔很远 → 不冲突。"""
assert _recommended_time_conflicts(20.0, 5.0, [(0.0, 5.0)]) is False
def test_empty_used_no_conflict(self):
assert _recommended_time_conflicts(10.0, 5.0, []) is False
def test_any_one_range_conflicts(self):
"""多个已用区间,任一冲突即返回 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
def test_custom_edge_gap(self):
"""edge_gap 可配置:gap=0 时紧贴不冲突(端点相接不算重叠)。"""
# edge_gap=015.0 开始与已用 [10,15] 端点相接,区间判定 start<end_gap(15) → False
assert _recommended_time_conflicts(15.0, 5.0, [(10.0, 15.0)], edge_gap=0.0) is False
# edge_gap=1.00.5 间隔也算冲突
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
+664
View File
@@ -0,0 +1,664 @@
"""测试 MediaKit 智能选片 + from-assets 按模板 segment 创建片段。
覆盖
- _recommended_time_conflicts 冲突检测
- _get_mediakit_recommendations 解析与降级
- _get_template_segments 查询模板片段配置
- from-assets 端点按模板 segment 数量和时长创建片段
- from-assets 端点事务性原子替换
- from-assets 端点素材轮询分配
- from-assets 端点MediaKit 推荐时间首片段使用
- from-assets 端点同一素材多片段时后续用随机
- from-assets 端点 segment 配置时报错
- from-assets 端点素材时长为0时报400
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
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
# ── _recommended_time_conflicts 单元测试 ─────────────────────────────────────
class TestRecommendedTimeConflicts:
"""测试推荐时间与已使用时间段的冲突检测。"""
def test_no_conflict_when_empty(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
assert _recommended_time_conflicts(5.0, 5.0, []) is False
def test_no_conflict_when_before(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
assert _recommended_time_conflicts(5.0, 5.0, [(15.0, 20.0)]) is False
def test_no_conflict_when_after(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
assert _recommended_time_conflicts(20.0, 5.0, [(0.0, 10.0)]) is False
def test_conflict_overlap_start(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
# 推荐 [5, 10],已用 [0, 7]
assert _recommended_time_conflicts(5.0, 5.0, [(0.0, 7.0)]) is True
def test_conflict_overlap_end(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
# 推荐 [5, 10],已用 [8, 15]
assert _recommended_time_conflicts(5.0, 5.0, [(8.0, 15.0)]) is True
def test_conflict_contained(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
# 推荐 [5, 10],已用 [0, 20]
assert _recommended_time_conflicts(5.0, 5.0, [(0.0, 20.0)]) is True
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] 首尾相接
# 落在扩边范围内 → 判为冲突(避免观感重复)
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
def test_conflict_multiple_used(self):
from app.api.routes.templates_editor.clips import _recommended_time_conflicts
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 扩边内 → 冲突
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
# ── _get_mediakit_recommendations 单元测试 ──────────────────────────────────
class TestGetMediakitRecommendations:
"""测试 MediaKit 推荐结果解析和降级。"""
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_returns_parsed_recommendations(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client = MagicMock()
mock_client.is_available = True
mock_client.analyze_videos.return_value = [
'[{"asset_id": "a1", "recommended_start_time": 12.5, "reason": "动作场景"}]'
]
mock_client_fn.return_value = mock_client
mock_storage_svc = MagicMock()
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
mock_storage.return_value = mock_storage_svc
asset = MagicMock()
asset.storage_key = "v.mp4"
asset.mime_type = "video/mp4"
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = asset
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
assert result == {"a1": 12.5}
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_client_not_available_returns_empty(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client = MagicMock()
mock_client.is_available = False
mock_client_fn.return_value = mock_client
result = _get_mediakit_recommendations(["a1"], MagicMock())
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_empty_contents_returns_empty(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client = MagicMock()
mock_client.is_available = True
mock_client.analyze_videos.return_value = []
mock_client_fn.return_value = mock_client
mock_storage_svc = MagicMock()
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
mock_storage.return_value = mock_storage_svc
asset = MagicMock()
asset.storage_key = "v.mp4"
asset.mime_type = "video/mp4"
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = asset
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_unparseable_response_returns_empty(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client = MagicMock()
mock_client.is_available = True
mock_client.analyze_videos.return_value = ["这是一段自然语言描述,没有JSON"]
mock_client_fn.return_value = mock_client
mock_storage_svc = MagicMock()
mock_storage_svc.get_download_url.return_value = "https://example.com/v.mp4"
mock_storage.return_value = mock_storage_svc
asset = MagicMock()
asset.storage_key = "v.mp4"
asset.mime_type = "video/mp4"
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = asset
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_storage_failure_returns_empty(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client = MagicMock()
mock_client.is_available = True
mock_client_fn.return_value = mock_client
mock_storage.side_effect = RuntimeError("storage unavailable")
result = _get_mediakit_recommendations(["a1"], MagicMock())
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_exception_returns_empty(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client_fn.side_effect = RuntimeError("unexpected error")
result = _get_mediakit_recommendations(["a1"], MagicMock())
assert result == {}
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_skips_non_video_assets(self, mock_storage, mock_client_fn):
from app.api.routes.templates_editor.clips import _get_mediakit_recommendations
mock_client = MagicMock()
mock_client.is_available = True
mock_client_fn.return_value = mock_client
mock_asset_repo = MagicMock()
mock_asset = MagicMock()
mock_asset.storage_key = "images/test.jpg"
mock_asset.mime_type = "image/jpeg"
mock_asset_repo.get.return_value = mock_asset
result = _get_mediakit_recommendations(["a1"], mock_asset_repo)
assert result == {}
mock_client.analyze_videos.assert_not_called()
# ── _get_template_segments 单元测试 ─────────────────────────────────────────
class TestGetTemplateSegments:
"""测试模板片段配置查询。"""
def test_returns_segments_from_new_template_system(self):
"""新模板系统(clip_configs)有数据时优先使用。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
cc1 = MagicMock()
cc1.order = 0
cc1.min_duration = 3.0
cc1.max_duration = 5.0
cc2 = MagicMock()
cc2.order = 1
cc2.min_duration = 4.0
cc2.max_duration = 8.0
mock_tpl_svc.list_clip_configs.return_value = [cc2, cc1] # 乱序返回
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 2
assert result[0] == (0, 3.0, 5.0)
assert result[1] == (1, 4.0, 8.0)
def test_falls_back_to_old_template_segments(self):
"""新模板系统无数据时回退到旧系统。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
seg1 = MagicMock()
seg1.segment_order = 0
seg1.duration_min = 2.0
seg1.duration_max = 4.0
mock_repo.list_segments.return_value = [seg1]
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 1
assert result[0] == (0, 2.0, 4.0)
def test_returns_empty_when_no_segments(self):
"""两套系统都没有片段配置时返回空列表。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
mock_repo.list_segments.return_value = []
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert result == []
def test_new_system_exception_falls_back(self):
"""新模板系统异常时回退到旧系统。"""
from app.api.routes.templates_editor.clips import _get_template_segments
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.side_effect = RuntimeError("db error")
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
seg = MagicMock()
seg.segment_order = 0
seg.duration_min = 1.0
seg.duration_max = 3.0
mock_repo.list_segments.return_value = [seg]
MockRepo.return_value = mock_repo
result = _get_template_segments("tmpl-1", mock_tpl_svc, MagicMock())
assert len(result) == 1
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
def _make_auth_user():
auth = MagicMock()
auth.user.id = "user-001"
auth.user.email = "test@example.com"
auth.user.display_name = "test"
auth.user_id = "user-001"
return auth
def _make_clip_config(order, min_dur, max_dur):
cc = MagicMock()
cc.order = order
cc.min_duration = min_dur
cc.max_duration = max_dur
return cc
def _make_plan_svc(replace_return_count=None):
svc = MagicMock()
svc.get_plan_or_raise = MagicMock()
if replace_return_count is not None:
svc.replace_all_clips_transactional = MagicMock(return_value=replace_return_count)
else:
svc.replace_all_clips_transactional = MagicMock(return_value=0)
return svc
def _make_tpl_svc_with_segments(segments):
"""segments: list of (order, min_dur, max_dur)"""
svc = MagicMock()
clip_configs = [_make_clip_config(o, mn, mx) for o, mn, mx in segments]
svc.list_clip_configs.return_value = clip_configs
return svc
def _make_rich_asset(asset_id, duration, storage_key="v.mp4", mime="video/mp4"):
asset = MagicMock()
asset.id = asset_id
asset.duration = duration
asset.storage_key = storage_key
asset.mime_type = mime
return asset
def _get_clips_data(mock_plan_svc):
"""从 replace_all_clips_transactional 调用中提取 clips_data。"""
call_args = mock_plan_svc.replace_all_clips_transactional.call_args
if len(call_args.args) >= 2:
return call_args.args[1]
return call_args.kwargs.get("clips_data", [])
class TestFromAssetsByTemplateSegments:
"""测试 from-assets 按模板 segment 创建片段(V2 事务性替换)。"""
def test_creates_clips_matching_segment_count(self):
"""片段数量 = segment 数量,忽略 required_clips_count。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0), (2, 2.0, 6.0), (3, 5.0, 10.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc(replace_return_count=4)
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
body = ClipsFromAssetsRequest(asset_ids=["a1"], required_clips_count=2)
result = create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert result.created_count == 4
clips_data = _get_clips_data(mock_plan_svc)
assert len(clips_data) == 4
def test_uses_transactional_replace(self):
"""使用 replace_all_clips_transactional 而不是分别 delete + create。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
body = ClipsFromAssetsRequest(asset_ids=["a1"])
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
mock_plan_svc.replace_all_clips_transactional.assert_called_once()
assert not mock_plan_svc.delete_all_clips.called
assert not mock_plan_svc.create_clip.called
def test_duration_within_segment_range(self):
"""每个片段时长在 segment 的 min~max 范围内。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
segments = [(0, 3.0, 5.0), (1, 4.0, 8.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc(replace_return_count=2)
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
body = ClipsFromAssetsRequest(asset_ids=["a1"])
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data(mock_plan_svc)
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):
"""素材按片段顺序轮询分配。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0), (3, 3.0, 5.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc(replace_return_count=4)
mock_asset_repo = MagicMock()
def get_asset(aid):
return _make_rich_asset(aid, 60.0)
mock_asset_repo.get.side_effect = get_asset
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
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"]
def test_orders_start_from_zero(self):
"""片段 order 从 0 开始递增。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0), (2, 3.0, 5.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc(replace_return_count=3)
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = _make_rich_asset("a1", 60.0)
body = ClipsFromAssetsRequest(asset_ids=["a1"])
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data(mock_plan_svc)
orders = [c["order"] for c in clips_data]
assert orders == [0, 1, 2]
def test_no_segments_raises_400(self):
"""模板没有 segment 配置时返回 400。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from fastapi import HTTPException
mock_tpl_svc = MagicMock()
mock_tpl_svc.list_clip_configs.return_value = []
mock_plan_svc = _make_plan_svc()
with patch("app.api.routes.templates_editor.clips.SQLAlchemyTemplateRepository") as MockRepo:
mock_repo = MagicMock()
mock_repo.list_segments.return_value = []
MockRepo.return_value = mock_repo
body = ClipsFromAssetsRequest(asset_ids=["a1"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
def test_duration_capped_by_asset_duration(self):
"""素材时长不足时 clip duration 被缩短。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
segments = [(0, 10.0, 20.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc(replace_return_count=1)
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = _make_rich_asset("a1", 5.0)
body = ClipsFromAssetsRequest(asset_ids=["a1"])
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
clips_data = _get_clips_data(mock_plan_svc)
assert clips_data[0]["duration"] <= 5.0
def test_zero_duration_asset_raises_400(self):
"""素材时长为 0 时抛出 400。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
from fastapi import HTTPException
segments = [(0, 3.0, 5.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc()
mock_asset_repo = MagicMock()
mock_asset_repo.get.return_value = _make_rich_asset("bad", 0.0)
body = ClipsFromAssetsRequest(asset_ids=["bad"])
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-1",
body=body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=mock_asset_repo,
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
def test_empty_asset_ids_raises_400(self):
"""asset_ids 为空列表时返回 400defense-in-depthschema 层也有 min_length=1)。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from fastapi import HTTPException
segments = [(0, 3.0, 5.0)]
mock_tpl_svc = _make_tpl_svc_with_segments(segments)
mock_plan_svc = _make_plan_svc()
# 用 MagicMock 模拟 body,绕过 Pydantic schema 的 min_length 校验
mock_body = MagicMock()
mock_body.asset_ids = []
mock_body.required_clips_count = None
with pytest.raises(HTTPException) as exc_info:
create_clips_from_assets_editor(
template_id="tmpl-1",
body=mock_body,
background_tasks=MagicMock(),
plan_id="plan-1",
services=(mock_tpl_svc, mock_plan_svc),
asset_repo=MagicMock(),
db=MagicMock(),
current_user=_make_auth_user(),
)
assert exc_info.value.status_code == 400
assert "素材" in exc_info.value.detail
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
# ── _safe_segment_duration 单元测试 ─────────────────────────────────────────
class TestSafeSegmentDuration:
"""测试片段时长安全转换。"""
def test_normal_float(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration(3.5, 1.0) == 3.5
def test_none_returns_default(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration(None, 5.0) == 5.0
def test_string_number(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration("4.2", 1.0) == 4.2
def test_invalid_string_returns_default(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration("abc", 5.0) == 5.0
def test_negative_returns_default(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration(-1.0, 5.0) == 5.0
def test_zero_returns_default(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration(0, 5.0) == 5.0
def test_integer_value(self):
from app.api.routes.templates_editor.clips import _safe_segment_duration
assert _safe_segment_duration(10, 1.0) == 10.0
@@ -13,6 +13,7 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Any, Optional
from unittest.mock import MagicMock, patch
@@ -180,17 +181,19 @@ class TestPreviewEditPlanAutoAssociation:
"packages.adapters.sqlalchemy_impl.edit_plan_repository.SQLAlchemyEditPlanRepository",
return_value=fake_plan_repo,
):
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
MockPlanSvc.return_value.clone_plan_for_variant.return_value = SimpleNamespace(id="plan-clone-001")
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id=""),
)
assert resp.status_code == 201
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置
# 找到 store 中的 task 并验证 source_edit_plan_id 被设置(自动关联后再克隆为独立 plan
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
task = tasks[0]
assert task.source_edit_plan_id == "plan-auto-001"
assert task.source_edit_plan_id == "plan-clone-001"
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
@@ -209,16 +212,18 @@ class TestPreviewEditPlanAutoAssociation:
client: TestClient,
gen_task_repo: StubGenerationTaskRepository,
):
"""前端已传 source_edit_plan_id 时,不触发自动关联"""
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
)
"""前端已传 source_edit_plan_id 时,不触发自动关联,但仍克隆独立变体 plan"""
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
MockPlanSvc.return_value.clone_plan_for_variant.return_value = SimpleNamespace(id="plan-clone-explicit")
resp = client.post(
"/api/v1/generation/preview",
json=_make_request_body(source_edit_plan_id="plan-explicit-001"),
)
assert resp.status_code == 201
tasks = list(gen_task_repo._store.values())
assert len(tasks) == 1
assert tasks[0].source_edit_plan_id == "plan-explicit-001"
assert tasks[0].source_edit_plan_id == "plan-clone-explicit"
@patch(
"app.api.routes.generation_preview._resolve_strategy_id_from_template",
+2 -2
View File
@@ -116,7 +116,7 @@ class TestGenerationTaskCreate:
voice_ids=["v1"],
created_by_user_id="u1",
source_edit_plan_id="ep1",
asset_select_mode="random",
asset_select_mode="smart",
batch_id="batch_001",
video_title="测试视频",
resolution="1080p",
@@ -132,7 +132,7 @@ class TestGenerationTaskCreate:
assert task.voice_ids == ["v1"]
assert task.created_by_user_id == "u1"
assert task.source_edit_plan_id == "ep1"
assert task.asset_select_mode == "random"
assert task.asset_select_mode == "smart"
assert task.batch_id == "batch_001"
assert task.video_title == "测试视频"
assert task.resolution == "1080p"
+45
View File
@@ -17,3 +17,48 @@ def test_infer_mime_type_treats_common_video_extensions_as_video():
def test_infer_mime_type_keeps_unknown_files_as_image_placeholder():
assert infer_mime_type_from_storage_key("uploads/abc/photo.jpg") == "image/jpeg"
# ─── Audio extensions ───────────────────────────────────────────────
def test_infer_mime_type_audio_m4a():
assert infer_mime_type_from_storage_key("uploads/abc/voice.m4a") == "audio/mp4"
# case-insensitive
assert infer_mime_type_from_storage_key("uploads/abc/VOICE.M4A") == "audio/mp4"
def test_infer_mime_type_audio_mp3():
assert infer_mime_type_from_storage_key("uploads/abc/song.mp3") == "audio/mpeg"
def test_infer_mime_type_audio_wav():
assert infer_mime_type_from_storage_key("uploads/abc/recording.wav") == "audio/wav"
def test_infer_mime_type_audio_flac():
assert infer_mime_type_from_storage_key("uploads/abc/hifi.flac") == "audio/flac"
def test_infer_mime_type_audio_ogg():
assert infer_mime_type_from_storage_key("uploads/abc/podcast.ogg") == "audio/ogg"
def test_infer_mime_type_audio_aac():
assert infer_mime_type_from_storage_key("uploads/abc/stream.aac") == "audio/aac"
def test_infer_mime_type_audio_wma():
assert infer_mime_type_from_storage_key("uploads/abc/old.wma") == "audio/x-ms-wma"
def test_infer_mime_type_audio_amr():
assert infer_mime_type_from_storage_key("uploads/abc/voice_msg.amr") == "audio/amr"
def test_infer_mime_type_audio_opus():
assert infer_mime_type_from_storage_key("uploads/abc/voip.opus") == "audio/opus"
def test_infer_mime_type_audio_weba():
assert infer_mime_type_from_storage_key("uploads/abc/web_audio.weba") == "audio/webm"