Compare commits

...

26 Commits

Author SHA1 Message Date
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
31 changed files with 1638 additions and 701 deletions
+10 -4
View File
@@ -46,10 +46,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,
+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",
+184 -51
View File
@@ -25,7 +25,7 @@ from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session
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
@@ -557,21 +557,23 @@ def _get_mediakit_recommendations(
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配置创建,事务性替换.
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新.
逻辑:
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的
5. MediaKit 智能选片:第一个使用某素材的片段用推荐起始时间,后续用随机
6. 素材时长为 0 或缺失时报 400,不创建无效片段
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间)
5. 立即返回响应(目标 <1秒)
6. 后台异步任务:调用 MediaKit 智能选片并更新片段的 start_time
7. 素材时长为 0 或缺失时报 400,不创建无效片段
"""
tpl_svc, plan_svc = services
@@ -597,13 +599,7 @@ def create_clips_from_assets_editor(
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 3. 获取 MediaKit 智能选片推荐(保持60s timeout + poll 2s + 15次
mediakit_recommendations = _get_mediakit_recommendations(
unique_asset_ids, asset_repo
)
# 4. 在内存中计算所有片段数据
asset_first_used: set[str] = set()
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit
used_segments: dict[str, list[tuple[float, float]]] = {}
clips_data: list[dict] = []
@@ -632,40 +628,10 @@ def create_clips_from_assets_editor(
detail=f"素材 {asset_id} 时长不足,无法创建有效片段",
)
# 确定起始时间
is_first_use = asset_id not in asset_first_used
recommended_start = mediakit_recommendations.get(asset_id)
if (
is_first_use
and recommended_start is not None
and recommended_start + clip_duration <= asset_total
and not _recommended_time_conflicts(
recommended_start, clip_duration, used_segments.get(asset_id, [])
)
):
start_time = recommended_start
logger.info(
"使用MediaKit推荐起始时间: asset_id=%s start_time=%.2f duration=%.1f",
asset_id,
start_time,
clip_duration,
)
else:
if is_first_use and recommended_start is not None:
logger.info(
"MediaKit推荐时间冲突或越界,降级为随机: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
elif not is_first_use:
logger.info(
"素材%s非首次使用,使用随机起始时间",
asset_id,
)
start_time = _calc_random_start_time(
asset_id, clip_duration, asset_durations, used_segments
)
# 使用随机起始时间(不调用MediaKit,保证接口快速返回)
start_time = _calc_random_start_time(
asset_id, clip_duration, asset_durations, used_segments
)
if start_time is None:
raise HTTPException(
@@ -677,7 +643,6 @@ def create_clips_from_assets_editor(
used_segments.setdefault(asset_id, []).append(
(start_time, start_time + clip_duration)
)
asset_first_used.add(asset_id)
clips_data.append(
{
@@ -689,11 +654,11 @@ def create_clips_from_assets_editor(
}
)
# 5. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
logger.info(
"from-assets按模板创建片段: template_id=%s plan_id=%s segments=%d created=%d by user=%s",
"from-assets按模板创建片段(异步): template_id=%s plan_id=%s segments=%d created=%d by user=%s",
template_id,
plan_id,
len(segments),
@@ -701,9 +666,177 @@ def create_clips_from_assets_editor(
current_user.user.id,
)
# 返回事务后查询到的 clip IDs(replace 方法不返回 ID 列表,用 created_count 构造响应)
# 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=[], # 事务方法不返回 ID;前端不需要逐个 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)
)
# 已更新的片段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, []))
# 检查是否与同素材其他片段时间段冲突
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
logger.info(
"后台任务: 推荐时间冲突,跳过: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
continue
# 逐个更新并捕获异常(单点失败不影响其他片段)
try:
plan_svc.update_clip(clip.id, start_time=recommended_start)
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
+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,
@@ -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>
)}
+54 -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"
@@ -163,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(() => {
@@ -172,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,
@@ -219,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]
/* ================================================================
渲染
================================================================ */
@@ -255,13 +285,10 @@ 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}
@@ -289,16 +316,16 @@ 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}
@@ -319,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}>
@@ -171,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,65 @@ export const GenerateStepActions: React.FC<GenerateStepActionsProps> = ({
currentStep,
onPrev,
onNext,
onGenerate,
onConfirmGenerate,
generating,
generated,
generateError,
}) => {
const renderPrimaryButton = () => {
/* 步骤 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,9 +1,6 @@
/**
* 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"
@@ -15,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 {
@@ -37,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
@@ -51,7 +46,6 @@ export interface GenerateStepContentProps {
/* 封面 */
coverSettings: CoverConfig
onCoverSettingsChange: (settings: CoverConfig) => void
duration: number
/* 配音 */
selectedVoice: string
onSelectedVoiceChange: (id: string) => void
@@ -76,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 配置(来自模板) */
@@ -112,7 +102,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
titlePresets,
coverSettings,
onCoverSettingsChange,
duration,
selectedVoice,
onSelectedVoiceChange,
totalVideoDuration,
@@ -128,10 +117,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
onRetry,
onDismissError,
presetVoices,
onPreviewTaskCreated,
onSourceEditPlanIdExtracted,
bgm,
bgmConfig,
} = props
/* 当前模板的 segments,传给 Step2 构建 clips */
@@ -175,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}
@@ -193,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}
@@ -235,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
}
@@ -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>
@@ -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: "确认生成" },
]
/* ── 标题位置选项 ── */
@@ -19,7 +19,7 @@ import { usePersistedState } from "../usePersistedState"
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
position: "top",
font: "思源黑体",
size: 28,
bold: true,
@@ -4,7 +4,7 @@
*/
import { useState, useCallback } from "react"
import { message } from "antd"
import { type GeneratedVideo, getEditPlanClips } 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"
@@ -13,14 +13,6 @@ import { validateGenerateInputs } from "./generate-video/buildPayload"
import { calculateResolution } from "../utils/calculateResolution"
import { extractBackendError, translateError } from "./generate-video/errorUtils"
/**
* 片段创建轮询:最多等 30 秒。
* useStep2Materials 在用户选素材时(debounce 800ms)已调用 from-assets
* 这里只做轻量校验,确认片段已落库就放行,不死等 ready。
*/
const CLIPS_CREATED_MAX_WAIT_MS = 30_000
const CLIPS_CREATED_POLL_INTERVAL_MS = 1_500
export function useGenerateVideo(props: UseGenerateVideoProps) {
const { selectedTemplate, onGenerationSuccess } = props
@@ -52,34 +44,13 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
onFailed: handleFailed,
})
/**
* 轮询确认片段已被创建。
* useStep2Materials 在用户选素材时已调用 from-assets 创建片段,
* 这里只要该 plan 下存在任意 clips(无论 ready/pending),就立即放行 generate。
* 片段是否 ready 由后端生成流程自行等待/兜底,前端不死等 ready,避免:
* 1. MediaKit 失败时前端卡死无法生成
* 2. E2E/弱网环境下 generate 请求迟迟不发出
* 30s 仍查不到片段也放行,由后端返回明确错误。
*/
const waitForClipsCreated = useCallback(async (templateId: string): Promise<void> => {
const start = Date.now()
while (Date.now() - start < CLIPS_CREATED_MAX_WAIT_MS) {
try {
const clipList = await getEditPlanClips(templateId, { limit: 500 })
if (clipList.items.length > 0) return
} catch {
// 单次查询失败不终止,继续轮询
}
await new Promise((resolve) => setTimeout(resolve, CLIPS_CREATED_POLL_INTERVAL_MS))
}
}, [])
/* ── 生成视频 ── */
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)
@@ -96,18 +67,24 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const assetIds =
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用
// 这里轻量确认片段已落库,再发 generate;最多等 30s,超时也放行。
// 片段 ready 状态由后端生成流程兜底,前端不死等
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用
// 后端已改为异步秒级返回,这里做一次轻量兜底:
// 单次查 clips,已有则直接放行;没有则再调一次 from-assets
if (assetIds.length > 0 && selectedTemplate) {
const hide = message.loading("正在准备素材片段...", 0)
try {
await waitForClipsCreated(selectedTemplate)
} finally {
hide()
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 || ""
const voiceLibraryId =
@@ -115,43 +92,49 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
? 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: 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,
},
}
: {}),
})
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)
@@ -160,8 +143,10 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
const finalMsg = translateError(backendMsg)
setGenerateError(finalMsg)
message.error(finalMsg)
return false
}
}, [props, clearTimer, startPolling, selectedTemplate, waitForClipsCreated])
return true
}, [props, clearTimer, startPolling, selectedTemplate])
const retry = useCallback(() => {
setGenerateError(null)
@@ -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)
+2 -2
View File
@@ -113,7 +113,7 @@ export function useStep2Materials({
try {
// 1. 清空旧片段
await updateEditPlanClips(tid, [], controller.signal)
// 2. 调用后端 from-assets 接口创建片段(60s 超时,与后端 MediaKit 一致
// 2. 调用后端 from-assets 接口创建片段(异步秒级返回,60s 超时仅为兜底
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
signal: controller.signal,
})
@@ -131,7 +131,7 @@ export function useStep2Materials({
const code = (err as { code?: string })?.code
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
console.warn("[useStep2Materials] 智能选片超时:", err)
message.error("智能选片超时,请重试")
message.error("智能选片失败,请重试")
return
}
console.warn("[useStep2Materials] 写入 clips 失败:", err)
@@ -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)
}
}
@@ -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)
})
})
@@ -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(
+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",
@@ -102,6 +102,7 @@ class TestEditorClipsBySegments:
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,
@@ -137,6 +138,7 @@ class TestEditorClipsBySegments:
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,
@@ -167,6 +169,7 @@ class TestEditorClipsBySegments:
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,
@@ -197,6 +200,7 @@ class TestEditorClipsBySegments:
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,
@@ -231,6 +235,7 @@ class TestEditorClipsDurationAndStartTime:
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,
@@ -260,6 +265,7 @@ class TestEditorClipsDurationAndStartTime:
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,
@@ -289,6 +295,7 @@ class TestEditorClipsDurationAndStartTime:
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,
@@ -322,6 +329,7 @@ class TestEditorClipsDurationAndStartTime:
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,
@@ -352,6 +360,7 @@ class TestEditorClipsDurationAndStartTime:
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,
@@ -392,6 +401,7 @@ class TestEditorClipsDurationAndStartTime:
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,
@@ -436,6 +446,7 @@ class TestEditorClipsErrorHandling:
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,
@@ -466,6 +477,7 @@ class TestEditorClipsErrorHandling:
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,
+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
+9 -42
View File
@@ -370,6 +370,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -397,6 +398,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -424,6 +426,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -455,6 +458,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -482,6 +486,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -513,6 +518,7 @@ class TestFromAssetsByTemplateSegments:
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(),
@@ -539,6 +545,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -567,6 +574,7 @@ class TestFromAssetsByTemplateSegments:
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,
@@ -595,6 +603,7 @@ class TestFromAssetsByTemplateSegments:
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(),
@@ -605,48 +614,6 @@ class TestFromAssetsByTemplateSegments:
assert "素材" in exc_info.value.detail
mock_plan_svc.replace_all_clips_transactional.assert_not_called()
@patch("app.api.routes.templates_editor.clips.get_mediakit_client")
@patch("app.api.routes.templates_editor.clips.get_storage_service")
def test_mediakit_first_clip_uses_recommendation(self, mock_storage, mock_client_fn):
"""MediaKit 推荐时间用于每个素材的第一个片段。"""
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
mock_client = MagicMock()
mock_client.is_available = True
mock_client.analyze_videos.return_value = [
'[{"asset_id": "a1", "recommended_start_time": 15.0, "reason": "test"}]'
]
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
segments = [(0, 3.0, 5.0), (1, 3.0, 5.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,
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)
# 第一个片段应使用推荐时间 15.0
assert clips_data[0]["start_time"] == 15.0
# 第二个片段(同一素材)不应使用推荐时间
assert clips_data[1]["start_time"] != 15.0
# ── _safe_segment_duration 单元测试 ─────────────────────────────────────────