Compare commits

...

80 Commits

Author SHA1 Message Date
xiaoxia 8ca2ffe272 fix(ci): pr_auto_scan.py black 120列格式化
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 15s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 35s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m26s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1317h27m22s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1317h27m26s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1317h27m28s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1317h27m32s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1317h27m34s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Failing after 1317h31m31s
修复Code Quality job因脚本自身格式问题失败的问题
2026-07-23 17:53:30 +08:00
xiaoxia dfb2feef8a fix(ci): main??auto-merge/approve????Tests/test??
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1465h21m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1465h21m22s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1465h21m23s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1465h53m20s
- auto-merge.yml: main????Tests/test,?3???????
- auto-approve.yml: main??????????Tests/test
- ??#464???Tests/test???????????
2026-07-17 14:04:23 +08:00
auto-approve-bot b3ef7bb041 Merge pull request 'feat(ci): main分支auto-merge从定时改为即时合并' (#464) from ci/main-auto-merge-instant into main
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 4s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1466h0m8s
CI/CD Pipeline / Deploy Production (push) Failing after 1466h0m12s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1466h0m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1466h32m10s
2026-07-17 13:27:18 +08:00
xiaoxia a1a272b833 feat(ci): 添加auto-approve到main分支并适配main门禁规则
Tests / lint (pull_request) Successful in 6s
Tests / test (pull_request) Failing after 29s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m54s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 3m0s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h2m30s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h2m34s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h2m34s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1466h2m32s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h34m32s
2026-07-17 13:24:21 +08:00
xiaoxia 728db0faf8 chore: empty commit to re-trigger CI for auto-merge verification
Tests / lint (pull_request) Successful in 9s
Tests / test (pull_request) Failing after 21s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Successful in 20m36s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h21m15s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h21m17s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h21m17s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1466h21m16s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h53m15s
2026-07-17 13:06:40 +08:00
xiaoxia 7e5e412f7f feat(ci): main分支auto-merge从定时改为即时合并
Tests / lint (pull_request) Failing after 9s
Tests / test (pull_request) Failing after 28s
Auto Merge PRs (main) / Auto Merge on CI Green + Approved (main) (pull_request) Failing after 30s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1466h49m39s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1466h49m40s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1466h49m41s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1466h49m41s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1467h21m39s
- 触发方式:pull_request事件(CI状态变更时)
- 合并条件:2门禁全绿 + 至少1个APPROVED + 无冲突 + 非草稿
- 安全措施:幂等保护、合并失败留评论、只合main
- 新增check_ci_status.py和check_pr_approval.py辅助脚本
2026-07-17 12:37:24 +08:00
xiaoxia df08161630 fix(ci): 修复auto-merge缺少GITEA_API_TOKEN环境变量
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1470h25m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1470h25m35s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1470h25m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1470h57m29s
1. 给auto-merge.yml的两个merge step添加GITEA_API_TOKEN环境变量,复用REVIEW_GITEA_TOKEN secret
2. 修复tests.yml的checkout步骤GITHUB_TOKEN和Python命令
3. 给main分支ci-cd.yml的单元测试增加Redis服务,修复Celery相关测试失败
2026-07-17 09:02:33 +08:00
xiaoxia 0c9375ff32 ci: remove temporary debug workflow - test-ssh-secret.yml
Fix Flake8 E741 for PR232 / fix-flake8 (push) Failing after 3h4m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1577h41m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 36s
CI/CD Pipeline / Frontend Lint (push) Failing after 0s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1577h10m16s
CI/CD Pipeline / Deploy Production (push) Failing after 1577h10m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1577h10m11s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1577h10m16s
2026-07-12 10:25:12 +08:00
xiaoxia ef344e9ffc cleanup: remove upgrade test file
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h43m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h45m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h45m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1671h17m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1671h20m0s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1671h20m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1671h51m26s
2026-07-09 00:08:40 +08:00
xiaoxia 0d4904433e test: trigger workflow after gitea upgrade
CI/CD Pipeline / Staging E2E Tests (push) Failing after 12h49m24s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 12h53m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 12h56m8s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 12h56m8s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1671h29m39s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1671h29m43s
CI/CD Pipeline / Deploy Production (push) Failing after 1672h1m9s
2026-07-08 23:58:20 +08:00
CI Test 708662394f Merge develop into main - v0.1.126
Auto Merge PRs / auto-merge (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 29h56m45s
CI/CD Pipeline / Frontend Lint (push) Failing after 29h56m45s
CI/CD Pipeline / Deploy Production (push) Failing after 1688h31m42s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1688h31m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1689h3m9s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1688h31m44s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1688h31m39s
2026-07-08 06:57:42 +08:00
CI Bot 5bc3440370 ci: staging部署改为Watchtower自动更新,砍掉SSH部署步骤
CI/CD Pipeline / Staging E2E Tests (push) Failing after 38h33m15s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 38h35m28s
CI/CD Pipeline / Frontend Lint (push) Failing after 38h36m58s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 38h37m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h11m14s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h11m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1697h42m51s
2026-07-07 22:16:44 +08:00
CI Test 7dc92191e0 test: watchtower自动更新验证(仅加注释)
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Failing after 38h42m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 38h56m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 38h58m34s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h18m26s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h18m31s
CI/CD Pipeline / Deploy Production (push) Failing after 1697h49m56s
2026-07-07 21:55:55 +08:00
CI Test 5028956cea ci: staging部署接入Watchtower自动更新
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Failing after 39h4m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 39h5m1s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 39h5m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h40m7s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h40m11s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h11m37s
- staging构建额外打:staging tag并push到Registry
- staging容器加com.centurylinklabs.watchtower.enable=true标签
- 容器用完整Registry路径+:staging tag启动,供Watchtower监控
- 1分钟轮询,自动检测新镜像并重启
2026-07-07 21:49:28 +08:00
CI Test d72450f42d ci: production deploy改为Registry方式,去掉tar/scp/docker load
CI/CD Pipeline / Staging E2E Tests (push) Failing after 39h16m53s
CI/CD Pipeline / Deploy Staging (push) Failing after 39h18m14s
CI/CD Pipeline / Frontend Lint (push) Failing after 39h19m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 39h19m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1697h54m15s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1697h54m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h25m45s
- 新增 deploy-production-registry.sh(纯Registry pull + docker run)
- 去掉源码tar打包和scp传输步骤
- nginx-production.conf 添加assets fallback(部署期间缓存用户不404)
- legacy assets自动清理(保留7天)
2026-07-07 21:35:15 +08:00
CI Test 1ed0d5aa75 fix: staging deploy script health-check url missing quotes
CI/CD Pipeline / Staging E2E Tests (push) Failing after 39h40m42s
CI/CD Pipeline / Deploy Staging (push) Failing after 39h42m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 39h44m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 39h44m58s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1698h18m7s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1698h18m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h49m40s
2026-07-07 21:09:28 +08:00
CI Test aef4febd1c ci: fix staging build step, use build_release_images.sh (same as production)
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (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 / Validate Code Quality And Tests (push) Failing after 39h49m43s
2026-07-07 21:04:48 +08:00
CI Test d99ee6fc84 ci: staging deploy改为Registry方式,去掉tar/scp/docker load
CI/CD Pipeline / Deploy Staging (push) Failing after 40h1m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h2m31s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h2m31s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1698h36m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1698h37m39s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1698h37m42s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h9m6s
2026-07-07 20:52:01 +08:00
CI Test 9b034764ad Merge develop into main - v0.1.125
Auto Merge PRs / auto-merge (push) Failing after 1m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m5s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h58m8s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h32m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h32m48s
2026-07-07 19:54:54 +08:00
CI Test bfb11c3526 fix: prettier formatting for v0.1.125
CI/CD Pipeline / Deploy Staging (push) Failing after 40h57m0s
CI/CD Pipeline / Frontend Lint (push) Failing after 40h59m41s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 40h59m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h31m0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h31m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1699h32m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h32m51s
2026-07-07 19:54:48 +08:00
灵应 9e37c7b73d fix: 配音库AI配音按钮 + 上传按钮修复 + 多项UI修复
CI/CD Pipeline / Frontend Lint (push) Failing after 41h9m45s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 41h9m45s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1699h44m49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1699h44m51s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1699h44m53s
CI/CD Pipeline / Deploy Staging (push) Failing after 1699h44m53s
CI/CD Pipeline / Deploy Production (push) Failing after 1700h16m19s
- Task #197: 配音库新增AI配音按钮 + TTS合成面板
- Task #198: 上传音频按钮事件修复
- Task #199: 一键生成增加标题库选择
- Task #200: 模板库报错修复
- Task #201: 素材库视频上传失败修复
- Task #202: 素材库去掉配音类型
- Task #203: 模板卡片名称位置调整
- Task #204: 一键生成状态反馈完善
- Task #205: 成片库页面接入真实数据

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:44:42 +08:00
灵应 f7a945d417 fix: 视频上传MIME白名单补全 + 成片库N+1查询修复
CI/CD Pipeline / Deploy Staging (push) Failing after 41h47m31s
CI/CD Pipeline / Frontend Lint (push) Failing after 41h49m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 41h49m12s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1700h21m58s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1700h23m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1700h23m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1700h55m3s
- chunked_upload.py ALLOWED_MIME_TYPES 从12种扩展到22种,与 upload.py 保持一致
  新增: video/mpeg, video/x-matroska, video/3gpp, audio/flac, audio/aac,
        audio/x-m4a, audio/webm, image/bmp, image/tiff, image/svg+xml
- generated_video_repository list_by_project/list_by_generation_task
  改用 _to_domain() 替代 self.get(),消除 N+1 查询
- 新增 24 个单元测试覆盖以上修复及成片库 API 可用性确认

Closes #172 #173

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:05:14 +08:00
CI Test 8748b43070 Merge develop into main - v0.1.124
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h16m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h16m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 1703h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 45h9m27s
CI/CD Pipeline / Deploy Production (push) Failing after 45h8m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1703h44m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1703h44m30s
2026-07-07 15:36:36 +08:00
CI Test a28395c318 fix: update schema snapshot and prettier formatting for v0.1.124
CI/CD Pipeline / Deploy Staging (push) Failing after 45h15m56s
CI/CD Pipeline / Frontend Lint (push) Failing after 45h18m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h18m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1703h44m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1703h44m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1703h45m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1703h51m44s
2026-07-07 15:36:26 +08:00
灵应 9f0c064f2a feat: 任务4 视频查重 — 历史+批次双重去重
CI/CD Pipeline / Frontend Lint (push) Failing after 45h27m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h27m46s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h3m13s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h3m17s
CI/CD Pipeline / Deploy Production (push) Failing after 1704h3m15s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h3m17s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h34m43s
- Alembic 033: generation_tasks 加 batch_id 列+索引
- Route: count>1 时生成共享 batch_id
- Repository: list_by_batch() 批次内查询
- dedup.py: check_batch_duplicate() 批次内查重
- Worker: 生成视频后创建 GeneratedVideo 记录 + 双重查重
- 单元测试: 6 个批次查重测试用例
2026-07-07 15:26:41 +08:00
灵应 1ea8fd3989 feat: P2 素材库自动匹配 - 支持 all/random/smart 三种素材选取模式
CI/CD Pipeline / Frontend Lint (push) Failing after 45h41m20s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h41m20s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h16m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h16m50s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h16m52s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h16m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1704h48m18s
- Schema: 新增 asset_select_mode + asset_select_count 字段
- Domain: GenerationTask 新增 asset_select_mode 字段
- Application: Command/UseCase 透传 asset_select_mode
- Route: _select_assets_from_library() 辅助函数 + 创建任务集成
- SQLAlchemy: Model/Repository 映射 asset_select_mode
- Alembic: 032 号迁移
- Worker: _download_library_assets() 支持 asset_ids 过滤
- 单测: 15 个测试覆盖三种模式 + 边界情况
2026-07-07 15:13:05 +08:00
灵应 4fee87c5e8 feat: 素材重复上传检测 + 批量生成视频
CI/CD Pipeline / Frontend Lint (push) Failing after 45h56m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h56m48s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h31m50s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h32m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h31m51s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h32m6s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h3m19s
任务1: 素材重复上传检测
- 上传接口支持 file_hash 参数,通过 MD5+素材库ID 去重
- 命中去重直接返回已有 asset_id,不重复存 OSS
- file_hash 透传: API → IngestJob → Asset 全链路
- 三条上传路径(表单/直传/分片)均支持去重
- Alembic 031: assets + ingest_jobs 加 file_hash 列+索引
- 6 个单元测试覆盖去重命中/未命中/空hash/透传

任务3: 批量生成视频
- POST /generations 支持 count 参数,一次创建多条生成任务
- 每条任务独立状态跟踪,响应返回 task_ids 列表

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:57:37 +08:00
灵应 a74d25e414 feat: 完成7个前端修复任务 (#190-#196)
CI/CD Pipeline / Frontend Lint (push) Failing after 45h59m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 45h59m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1704h31m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1704h31m2s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1704h31m7s
CI/CD Pipeline / Deploy Staging (push) Failing after 1704h31m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h2m30s
- #190: 素材库视频加缩略图展示
- #191: 视频上传进度提示(圆形动画+百分比)
- #192: 一键生成模板名称显示用户自定义名称
- #193: 确认生成无反应修复(增强错误处理)
- #194: 素材选择双模式(手动选择/自动匹配)
- #195: 多条视频生成数量输入
- #196: 成片库404修复
2026-07-07 14:54:28 +08:00
CI Test d213a055a1 Merge develop into main - v0.1.123
Auto Merge PRs / auto-merge (push) Failing after 3m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h9m34s
CI/CD Pipeline / Frontend Lint (push) Failing after 47h8m47s
CI/CD Pipeline / Deploy Staging (push) Failing after 47h6m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1705h41m20s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h41m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h41m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h41m18s
2026-07-07 13:44:46 +08:00
CI Test 8219d2445d fix: 移除多余的031 display_name migration(baseline已包含此字段)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 47h10m46s
CI/CD Pipeline / Deploy Staging (push) Failing after 47h12m27s
CI/CD Pipeline / Frontend Lint (push) Failing after 47h13m22s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h13m22s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h48m28s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h48m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h19m58s
2026-07-07 13:41:08 +08:00
CI Test 0d871d9734 fix: 更新schema快照 - 新增tags/asset_tags表 + users.display_name字段
CI/CD Pipeline / Frontend Lint (push) Failing after 47h17m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h17m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h51m58s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h52m0s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h52m2s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h52m2s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h23m28s
2026-07-07 13:36:50 +08:00
CI Test 608ddbf9f7 fix: 修复migration 030离线模式下_table_exists崩溃问题
CI/CD Pipeline / Frontend Lint (push) Failing after 47h19m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h19m6s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h54m32s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h54m35s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h54m37s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h54m37s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h26m3s
2026-07-07 13:35:22 +08:00
CI Test 6cb146693e fix: 修复TTS路由WebSocketDisconnect未导入问题
CI/CD Pipeline / Frontend Lint (push) Failing after 47h21m52s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h21m52s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h57m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h57m16s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h57m18s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h57m18s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h28m44s
2026-07-07 13:32:37 +08:00
CI Test 18571451ed fix: 修复前端prettier格式 + 增加users.display_name migration
CI/CD Pipeline / Frontend Lint (push) Failing after 47h23m30s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h23m30s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1705h58m54s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1705h58m56s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1705h58m58s
CI/CD Pipeline / Deploy Staging (push) Failing after 1705h58m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h30m24s
- 修复6个前端文件的Prettier格式问题
- 新增031 migration: users表增加display_name列
2026-07-07 13:31:01 +08:00
灵应 2bcbd54ed7 feat: 最后一公里 — TTS 合成结果保存到配音库接口
CI/CD Pipeline / Frontend Lint (push) Failing after 47h30m9s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h30m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h5m36s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h5m40s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h5m38s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1706h5m38s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h37m8s
- 新增 POST /tts/jobs/{id}/save-to-library 接口
- 自动携带音色名、时长、语速等元信息到 metadata
- 配音库配额检查(429 响应)
- 15 个单元测试全部通过
- 全部 115 个 TTS 测试无回归
2026-07-07 13:24:08 +08:00
灵应 d1c83de698 feat: TTS 生成页加存为素材功能
CI/CD Pipeline / Frontend Lint (push) Failing after 47h32m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h32m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h7m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1706h7m55s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h7m57s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h7m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h39m23s
- 合成完成后显示「存为素材」按钮,点击弹出保存弹窗
- 弹窗支持改名称、选标签(预设标签 + 自定义新增)
- 保存成功后提示「去素材库查看」可跳转配音素材库
- 新增 saveTtsToLibrary API 对接后端 POST /tts/jobs/{id}/save-to-library
2026-07-07 13:21:39 +08:00
灵应 2849123bd7 feat: P2 WebSocket 流式合成
CI/CD Pipeline / Frontend Lint (push) Failing after 47h49m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h49m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h24m55s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h24m57s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h24m59s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h24m59s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1706h56m25s
- 新增 TTSStreamingService 流式合成服务
- 实现 /ws/tts/stream WebSocket 端点
- 短文本(≤500字):合成完整音频后分块推送
- 长文本(>500字):分段并发合成,逐段推送
- 通过 asyncio.to_thread 桥接同步 CosyVoiceService
- 二进制帧 4KB 分块推送音频数据
- 10 个单元测试全部通过
2026-07-07 13:04:59 +08:00
灵应 d6ab413dcd feat: P1 长文本分段合成
CI/CD Pipeline / Frontend Lint (push) Failing after 47h57m37s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 47h57m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1706h31m47s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1706h31m50s
CI/CD Pipeline / Deploy Production (push) Failing after 1706h31m48s
CI/CD Pipeline / Deploy Staging (push) Failing after 1706h31m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h3m16s
- 新增 text_splitter.py: 长文本智能分段(句子边界 + 短段合并)
- 新增 audio_merger.py: FFmpeg concat 音频合并器
- workflow.py: 分段合成完整流程(同步合并 / 异步轮询 / 失败处理)
- tts_synthesis.py: 新增 process_tts_segment_synthesis Celery 任务
- tts.py: 路由层自动识别分段任务并分发到对应 Celery task
- 23 个单元测试全部通过,P0 回归测试无退化
2026-07-07 12:56:52 +08:00
灵应 c47aed95fc feat(tts): P0 音频转存 OSS — 合成完成后下载临时音频上传 OSS 存储永久 URL
CI/CD Pipeline / Frontend Lint (push) Failing after 48h27m21s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h27m21s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h2m49s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h2m51s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h2m53s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h2m53s
CI/CD Pipeline / Deploy Production (push) Failing after 1707h34m19s
- TTSWorkflowService 新增 _transfer_audio_to_oss 方法
- start_synthesis 同步路径和 process_synthesis_result 均集成 OSS 转存
- OSS 转存失败时优雅降级,回退到 CosyVoice 临时 URL
- 支持 mp3/wav/pcm/opus 格式 content-type 映射
- 新增 9 个单元测试覆盖转存成功/失败/降级场景
- 向后兼容:storage_service 为可选参数,现有调用方无需修改

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:27:06 +08:00
灵应 8e2e1e1357 feat(voice-materials): 标签管理 UI 对接后端标签 API
CI/CD Pipeline / Frontend Lint (push) Failing after 48h32m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h32m19s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h7m47s
CI/CD Pipeline / Deploy Production (push) Failing after 1707h7m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h7m48s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h7m51s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h7m51s
- api/tags.ts: 桩实现替换为真实 API(getTags/createTag/deleteTag/tagAsset/untagAsset)
- api/assets.ts: AssetItem 新增 tag_ids 字段,getAssetsByKind 支持 tag_ids 筛选
- VoiceMaterialLibrary.tsx:
  - 移除硬编码 STYLE_TAGS,全面使用 API 返回的标签列表
  - VoiceMaterial 接口 tags: string[] → tagIds: string[]
  - mapAssetToMaterial 读取 asset.tag_ids 替代 metadata.style
  - TagSelector 组件改为 ID 模式(tagId 操作 + tagMap 名称解析)
  - MaterialForm 接收 tags/tagMap/onCreateTag props
  - VoiceMaterialCard/VoiceMaterialRow 接收 tagMap prop 渲染标签名
  - 标签筛选栏使用 API tags + filterTagId 状态
  - 批量打标签改用 tagAsset API + handleBatchCustomTag 自定义标签
  - 上传/编辑流程调用 tagAsset/untagAsset 管理标签关联
  - filtered/tagCountMap 全部基于 tagIds 计算
2026-07-07 12:22:05 +08:00
灵应 b0f2e4712a feat: P3 配音模块标签体系 — 标签 CRUD + 素材打标 + tag_ids 筛选
CI/CD Pipeline / Frontend Lint (push) Failing after 48h55m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h55m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h31m26s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h31m28s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h31m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h2m56s
- 新增 Tag 领域实体 + TagModel/AssetTagModel ORM 模型
- Alembic 迁移 030:tags 表 + asset_tags 关联表
- TagRepository 端口 + SQLAlchemy/InMemory 实现
- Asset.tag_ids 多对多关联替代原 JSON tags
- GET /tags / POST /tags / DELETE /tags/{tag_id} 标签 CRUD
- POST /assets/{asset_id}/tags 打标 / DELETE 取消标签
- GET /assets 新增 tag_ids 筛选参数(逗号分隔,取交集)
- 19 个单元测试全部通过
2026-07-07 11:58:27 +08:00
灵应 d60a963b62 feat(voice-materials): P3 标签体系 UI — 药丸筛选栏 + TagSelector + 溢出处理 + 批量自定义输入
CI/CD Pipeline / Frontend Lint (push) Failing after 48h58m54s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 48h58m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h33m9s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h33m14s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h33m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1707h33m12s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h4m40s
- 标签筛选栏:Select 下拉替换为水平可滚动药丸条,显示使用计数
- 编辑弹窗:TagSelector 组件,支持自定义输入回车添加、已选标签可移除、预设快捷选择、自动补全
- 卡片/行标签溢出:卡片最多3个、行最多2个,超出显示 +N Tooltip
- 空标签态:无标签时显示「添加标签」虚线按钮
- 批量打标签 Popover:顶部新增自定义输入框,回车即可批量添加
- 交互走查:全失败时显示 error 消息、键盘可访问、过渡动画
- 新建 api/tags.ts 桩文件,预留后端标签 API 切换点

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:55:34 +08:00
灵应 d698d78b35 feat(voice-materials): 配音模块体验打磨 — 批量操作/上传进度/播放器优化
CI/CD Pipeline / Frontend Lint (push) Failing after 49h22m24s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h22m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1707h57m28s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1707h57m30s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1707h57m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 1707h57m32s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h28m58s
- 配音素材库批量操作:多选删除、批量打标签(Popover 选标签)
- 上传配音进度条:XMLHttpRequest 替换 fetch,实时百分比进度
- 试听播放器优化:暂停恢复、进度条拖拽 seek、音量控制
- 整体交互走查:错误处理、空状态提示、动画过渡

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:32:03 +08:00
灵应 f8bc252ded feat(assets): 批量删除接口 + 分页性能优化
CI/CD Pipeline / Staging E2E Tests (push) Failing after 49h30m34s
CI/CD Pipeline / Deploy Staging (push) Failing after 49h34m18s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h36m48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h36m54s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h1m5s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h6m40s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h9m27s
- 新增 POST /assets/batch-delete 批量删除素材接口
  - 仓储层: abstract/SQLAlchemy/InMemory 均实现 batch_delete()
  - API层: 逐项校验项目权限后批量删除
  - Schema: BatchDeleteRequest/BatchDeleteResponse
- GET /assets 分页优化: 无 keyword/gender/style 过滤时走 DB 级 skip/limit
- 素材上传 duration 字段已全链路支持(domain/schema/ORM/repository)
- 新增 4 个批量删除单元测试,全量 1030 测试通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:06:24 +08:00
CI Test 2371860f82 Merge develop into main - v0.1.122 (prettier fix)
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h37m40s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h37m23s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 49h35m42s
CI/CD Pipeline / Deploy Production (push) Failing after 49h33m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 49h27m0s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h11m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h9m27s
2026-07-07 11:04:39 +08:00
CI Test d1970bd44b fix: prettier formatting for voice material module files
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Runtime Images (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-07 11:04:30 +08:00
CI Test dbd956fc6e Merge develop into main - v0.1.122
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 49h54m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 49h54m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h14m26s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h14m26s
CI/CD Pipeline / Deploy Production (push) Failing after 1708h12m14s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h10m25s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h12m14s
2026-07-07 10:59:31 +08:00
灵应 734d7eb377 feat: 配音素材库统一入口 + 搜索筛选 + 剪辑编辑器配音体验优化
CI/CD Pipeline / Frontend Lint (push) Failing after 50h14m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h14m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1708h49m15s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1708h49m17s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1708h49m20s
CI/CD Pipeline / Deploy Staging (push) Failing after 1708h49m20s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h20m45s
1. 全局收敛硬编码配音/音色数据到配音素材库 (Task #170)
   - CloneVoiceModal: 替换硬编码音色列表为素材库 API
   - GeneratePage: 配音选择改为从素材库选取
   - MyVoices: 统一展示素材库中的配音

2. 配音素材库增加搜索框和筛选 (Task #171)
   - 关键词搜索、性别筛选、风格标签筛选
   - 对接后端 getAssetsByKind 筛选参数

3. 剪辑编辑器配音选择体验优化 (Task #172)
   - 共享 React Query 缓存 (queryKey: ['assets','voice'])
   - 新增试听播放/暂停按钮
   - 下拉选项显示性别标签
   - 加载状态 + 刷新按钮
   - 空状态提示
2026-07-07 10:40:16 +08:00
灵应 3e1284d130 feat(assets): GET /assets 增加 keyword/gender/style 搜索筛选参数
CI/CD Pipeline / Frontend Lint (push) Failing after 50h33m50s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h33m50s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h8m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h8m52s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h8m58s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h8m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h40m20s
- keyword: 按名称模糊匹配(大小写不敏感)
- gender: 按 metadata.gender 精确筛选
- style: 按 metadata.style 精确筛选
- 支持 library_id / project_id 两种查询模式
2026-07-07 10:20:36 +08:00
灵应 bcda5a2581 feat: 剪辑编辑器集成配音素材库 — 替换硬编码音色为真实素材 API
CI/CD Pipeline / Frontend Lint (push) Failing after 50h37m49s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h37m49s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h12m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h12m59s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h13m1s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h13m1s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h44m27s
- EditingPlanner: useQuery 拉取配音素材列表(ensureDefaultLibrary + getAssetsByKind)
- ClipPropertiesPanel: voice 类型片段新增配音素材下拉选择器 + 上传新配音按钮
- 生成任务 voice_ids 从片段 voice_asset_id 收集
- planConfig segments 携带 voice_asset_id / voice_file_url
- 新增 .ep-clip-detail-select / .ep-voice-upload-btn 样式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:16:38 +08:00
灵应 25a9352794 fix: P1 安全修复 - TTS合成接口增加voice_clone_profile_id归属校验
CI/CD Pipeline / Frontend Lint (push) Failing after 50h43m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h43m5s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h18m14s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h18m16s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h18m19s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h18m19s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h49m44s
POST /tts/synthesize 未校验 voice_clone_profile_id 归属,
任意用户可使用他人克隆音色进行合成。
增加 ownership 校验,非本人 profile 返回 403。
2026-07-07 10:10:57 +08:00
灵应 ae9a79f01d feat: 配音素材库对接真实 API + 导航标签更新
CI/CD Pipeline / Frontend Lint (push) Failing after 50h53m19s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 50h53m20s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h27m34s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h27m38s
CI/CD Pipeline / Deploy Production (push) Failing after 1709h27m36s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h27m36s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h59m5s
- VoiceMaterialLibrary.tsx: 移除 mock 数据,对接真实 Assets API
  - 列表: GET /assets?kind=voice
  - 上传: uploadAssetDirect → createAsset
  - 试听: 使用 file_url 播放
  - 编辑: PUT /assets/{id}
  - 删除: DELETE /assets/{id}
  - metadata 格式: voice_name, gender, speed, style, description
- navigation.tsx: 侧边栏标签改为「配音素材库」
- PageHead.tsx: 页面标题改为「配音素材库」
2026-07-07 10:01:08 +08:00
灵应 30c5cd4bbe feat(assets): 分页查询 + voice 默认素材库自动初始化
CI/CD Pipeline / Frontend Lint (push) Failing after 51h1m53s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 51h1m53s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1709h37m3s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1709h37m5s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1709h37m7s
CI/CD Pipeline / Deploy Staging (push) Failing after 1709h37m7s
CI/CD Pipeline / Deploy Production (push) Failing after 1710h8m32s
- GET /assets 增加 skip/limit 分页参数,响应包含 total/skip/limit
- GET /asset-libraries 增加 kind 过滤参数
- 新增 POST /asset-libraries/ensure-default 自动创建默认素材库
- 前端上传配音音频走通用上传链路(无缺口)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:52:33 +08:00
灵应 32e78f31e2 feat: 新增配音素材管理页面 VoiceMaterialLibrary
CI/CD Pipeline / Frontend Lint (push) Failing after 51h43m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 51h43m2s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1710h18m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1710h18m15s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1710h18m17s
CI/CD Pipeline / Deploy Staging (push) Failing after 1710h18m17s
CI/CD Pipeline / Deploy Production (push) Failing after 1710h49m42s
- 新建 VoiceMaterialLibrary.tsx:卡片/列表双布局、上传、试听、编辑、删除
- 元信息:名称、音色描述、性别(男/女/童声/中性)、风格标签
- V21 设计系统样式(voice-materials.css),含性别色带、响应式适配
- 路由 /app/voice-materials,导航 + PageHead 面包屑配置
- 当前阶段:静态页面 + Mock 数据,等后端 API 就绪后对接

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:11:17 +08:00
灵应 2832109b35 feat: add P0 asset API endpoints for voice material management
CI/CD Pipeline / Staging E2E Tests (push) Failing after 51h49m13s
CI/CD Pipeline / Deploy Staging (push) Failing after 51h50m36s
CI/CD Pipeline / Frontend Lint (push) Failing after 51h52m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 51h52m26s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1710h26m35s
CI/CD Pipeline / Deploy Production (push) Failing after 1710h26m41s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1710h26m43s
- GET /assets/{asset_id}: single asset detail
- PUT /assets/{asset_id}: update name, metadata, tags
- DELETE /assets/{asset_id}: delete asset with project access check
- GET /assets: add kind query param (voice/video/image) for filtering
- Add find_by_library_and_file_type to repository port + implementations

Supports the voice material library MVP: CRUD operations needed by the
frontend VoiceLibrary page.
2026-07-07 09:02:00 +08:00
CI Test 1d59ee5336 Merge develop into main - v0.1.121
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 52h47m33s
CI/CD Pipeline / Frontend Lint (push) Failing after 52h46m49s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 52h46m3s
CI/CD Pipeline / Deploy Production (push) Failing after 52h45m27s
CI/CD Pipeline / Deploy Staging (push) Failing after 1711h22m10s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h19m57s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1711h21m45s
2026-07-07 08:05:42 +08:00
灵应 653be7755b fix: handle Alembic offline mode in migration _column_exists() helper
CI/CD Pipeline / Staging E2E Tests (push) Failing after 52h57m33s
CI/CD Pipeline / Deploy Staging (push) Failing after 52h58m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h0m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h0m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h26m48s
CI/CD Pipeline / Deploy Production (push) Failing after 1711h28m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1711h33m42s
The _column_exists() helper in migrations 026-029 calls conn.execute()
which returns None in Alembic's offline/SQL mode (--sql flag), causing
AttributeError on result.scalar(). Add context.is_offline_mode() guard
to skip the idempotency check in offline mode and unconditionally emit
the DDL statements.

Fixes: Validate CI step 'alembic upgrade head --sql' failure.
2026-07-07 07:53:27 +08:00
灵应 6a853fef3c fix: 补建缺失的 Alembic 迁移文件 026-029
CI/CD Pipeline / Frontend Lint (push) Failing after 53h9m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h9m57s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h45m18s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1711h45m20s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1711h45m22s
CI/CD Pipeline / Deploy Staging (push) Failing after 1711h45m22s
CI/CD Pipeline / Deploy Production (push) Failing after 1712h16m47s
生产 DB 已执行过 026-029 迁移但文件丢失(可能 rebase 导致),
导致 alembic 报 'Can't locate revision identified by 029_user_phone'。

补建内容:
- 026: users 表加 name, avatar, updated_at
- 027: users 表加 ban_reason, ban_at
- 028: users 表加 admin_status, admin_remarks
- 029: users 表加 phone

所有 upgrade 做幂等检查(IF NOT EXISTS),安全用于已有字段的库。
2026-07-07 07:44:30 +08:00
灵应 e33bfe71bf feat: 退出登录后跳转首页(/)而非登录页(/login)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 53h21m49s
CI/CD Pipeline / Deploy Staging (push) Failing after 53h22m44s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h23m14s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h24m0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1711h52m30s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1711h57m58s
CI/CD Pipeline / Deploy Production (push) Failing after 1712h23m59s
- useLogout hook: navigate('/login') → navigate('/')
- client.ts 无refresh_token自动登出: href='/login' → href='/'
- client.ts 刷新失败自动登出: href='/login' → href='/'
- 已排查所有退出入口:仅Header头像菜单有手动退出按钮,行为一致
- ProtectedRoute未登录拦截仍跳/login(正确行为,非退出场景)
2026-07-07 07:30:28 +08:00
xiaoxia 9d2f4ec0f2 Merge pull request 'ci: 优化生产镜像部署 - Registry push/pull 替代 tar+scp' (#186) from feat/ci-registry-push-pull into develop
CI/CD Pipeline / Staging E2E Tests (push) Failing after 53h34m48s
CI/CD Pipeline / Deploy Staging (push) Failing after 53h36m13s
CI/CD Pipeline / Frontend Lint (push) Failing after 53h36m46s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 53h37m32s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1712h5m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1712h5m32s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1712h10m58s
2026-07-07 07:15:15 +08:00
灵应 3242e71ec5 fix: 修复部署脚本健康检查循环变量bug (k=0 → i=0)
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 53h38m46s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 53h39m52s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1712h10m58s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1712h12m20s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1712h12m21s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1712h12m23s
CI/CD Pipeline / Deploy Staging (pull_request) Failing after 1712h12m23s
2026-07-07 07:14:37 +08:00
灵应 1b4532cd9c fix(ci): 修复 PR #185 微信同步登录 Validate 失败
CI/CD Pipeline / Staging E2E Tests (push) Failing after 60h2m5s
CI/CD Pipeline / Deploy Staging (push) Failing after 60h3m49s
CI/CD Pipeline / Frontend Lint (push) Failing after 60h4m26s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h5m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1718h32m10s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1718h38m14s
CI/CD Pipeline / Deploy Production (push) Failing after 1719h5m1s
- black 格式化 wechat_sync_use_case.py
- isort 排序 auth.py imports
- bandit: try_except_pass 改为 logger.debug

根因:PR #185 合并时未通过代码质量检查(black/isort),
导致 CI Validate 步骤在代码检查阶段即失败。
2026-07-07 00:49:14 +08:00
灵应 3620e29f41 style: 收窄时间线轨道高度 — 片段卡片96px→64px,track padding/gap缩减
CI/CD Pipeline / Frontend Lint (push) Failing after 60h16m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h16m12s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1718h51m30s
CI/CD Pipeline / Deploy Production (push) Failing after 1718h51m33s
CI/CD Pipeline / Deploy Staging (push) Failing after 1718h51m35s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1718h51m33s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1719h23m2s
- .ep-clip-track: gap 12px→8px, padding 12px→6px
- .ep-clip-card: height 96px→64px
- .ep-clip-thumbnail: height 52px→34px
- .ep-clip-info: padding 6px 8px→4px 6px
- .ep-track-empty: min-height 96px→64px
- .ep-track-add-card: height 96px→64px(与片段卡片对齐)
2026-07-07 00:38:16 +08:00
xiaoxia ed6150fa52 Merge pull request 'feat(auth): 新增微信同步登录接口 wechat-sync(小程序BFF用)' (#185) from feat/wechat-sync-login into develop
CI/CD Pipeline / Frontend Lint (push) Failing after 60h17m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 60h17m24s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1718h52m40s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1718h52m42s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1718h52m44s
CI/CD Pipeline / Deploy Staging (push) Failing after 1718h52m44s
CI/CD Pipeline / Deploy Production (push) Failing after 1719h24m9s
2026-07-07 00:37:06 +08:00
灵应 dc9d84d015 feat(剪辑编辑器): AI自动选择默认关闭 + 预设样式去背景 + 添加片段面板紧凑化
CI/CD Pipeline / Staging E2E Tests (push) Failing after 61h36m0s
CI/CD Pipeline / Deploy Staging (push) Failing after 61h37m52s
CI/CD Pipeline / Frontend Lint (push) Failing after 61h49m29s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 61h52m11s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1720h7m23s
CI/CD Pipeline / Deploy Production (push) Failing after 1720h7m25s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h12m10s
1. AI自动选择开关默认值 true → false
2. 预设样式卡片去掉深色背景(#1a1a2e),改为白底纯文字效果展示
   - 默认态: 白色背景 + 浅灰边框
   - hover: #f8fafc 浅灰底
   - active: #eef2ff 主色浅底
   - 标签文字颜色 #8888aa → #64748b
3. 添加片段面板宽度 240px → 180px,内边距 14px → 10px,间距 10px → 8px
2026-07-06 23:01:19 +08:00
灵应 67d5f899b3 refactor: 标题预设解耦字体 — 预设只保存样式属性,不绑定字体family
CI/CD Pipeline / Staging E2E Tests (push) Failing after 62h0m32s
CI/CD Pipeline / Deploy Staging (push) Failing after 62h1m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 62h2m15s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 62h3m1s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1720h31m13s
CI/CD Pipeline / Deploy Production (push) Failing after 1720h31m15s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h36m41s
- TITLE_PRESETS 去掉 font 字段、style.font、previewStyle.fontFamily
- getActivePreset 去掉 font 比较,仅匹配样式属性
- 预设点击回调不再传 font,保留用户当前字体不变
- 预览图统一用系统字体展示效果
- 华康俪金黑保留在字体列表供手动选择

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:49:39 +08:00
灵应 1c83122711 feat: 添加华康俪金黑字体支持
CI/CD Pipeline / Staging E2E Tests (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 / Deploy Staging (push) Failing after 62h9m37s
CI/CD Pipeline / Frontend Lint (push) Failing after 62h10m12s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 62h10m58s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h44m38s
- index.css 添加 @font-face 声明加载 DFLiJinHei-W8.ttf
- ClipPropertiesPanel FONT_OPTIONS 增加「华康俪金黑」选项
- 标题预设「黑金质感」默认字体改为华康俪金黑

注:字体文件需手动放置到 apps/web/public/fonts/DFLiJinHei-W8.ttf
2026-07-06 22:43:07 +08:00
CI Test 46d98f369d feat(auth): 新增微信同步登录接口wechat-sync(内部系统级接口,支持openid查找/创建用户并返回SaaS token)
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 62h22m48s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 62h22m56s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1720h48m29s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1720h48m31s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1720h48m31s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1720h53m56s
CI/CD Pipeline / Deploy Staging (pull_request) Failing after 1720h53m56s
2026-07-06 22:10:33 +08:00
CI Test a568c97439 feat(auth): User实体新增wechat_openid/wechat_unionid字段 2026-07-06 22:07:07 +08:00
CI Test f94ffa77bf feat(auth): SQLAlchemy仓储实现微信openid/unionid查询 2026-07-06 22:06:34 +08:00
CI Test b3118b05ab feat(auth): 仓储接口新增find_by_wechat_openid/unionid方法 2026-07-06 22:06:34 +08:00
灵应 a48740195d feat(editing-planner): 标题预设样式改造 — 剪映风格8预设+完整样式预览
CI/CD Pipeline / Staging E2E Tests (push) Failing after 62h17m49s
CI/CD Pipeline / Deploy Staging (push) Failing after 62h22m16s
CI/CD Pipeline / Frontend Lint (push) Failing after 62h30m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 62h50m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1720h48m29s
CI/CD Pipeline / Deploy Production (push) Failing after 1720h48m31s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1720h53m58s
- 8个预设:经典白字黑描边、黑金质感、清新简约、综艺花字、商务极简、复古胶片、霓虹发光、手写字
- 每个预设用示例文字标题展示完整效果(字体+字号+颜色+描边+阴影)
- 4列方形网格布局,深色背景衬托文字效果
- 新增color字段到TitleSettings,保存/加载/生成均使用用户选择的颜色
- 新增楷体到字体选项

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:03:39 +08:00
灵应 7ec8e4355e fix(editing-planner): 修复添加片段面板被裁剪 — 默认向上弹出+useLayoutEffect精确校正
- 面板默认从+按钮上方弹出,上方不够时翻转到下方
- 用useLayoutEffect+实际offsetHeight替代硬编码PICKER_H
- fixed定位脱离父容器overflow裁剪

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:03:33 +08:00
灵应 edf6a5501b fix: 修复剪辑编辑器保存模板和生成视频按钮无响应
CI/CD Pipeline / Staging E2E Tests (push) Failing after 63h6m41s
CI/CD Pipeline / Deploy Staging (push) Failing after 63h9m23s
CI/CD Pipeline / Frontend Lint (push) Failing after 63h9m59s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 63h13m41s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1721h37m25s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1721h42m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1722h8m53s
根因:EditingPlanner.tsx 使用了假的 msg 对象(仅 console.log),
用户看不到任何 toast 反馈。同时两个按钮在 loadedTemplateId 为 null
时直接 return,阻止了新建模板和直接生成的场景。

修复:
1. 替换假 msg 为 antd message,所有 toast 通知可见
2. handleOpenSaveModal 移除 loadedTemplateId 守卫,始终打开弹窗
3. handleSave 支持 createEditingTemplate(新建)和 updateEditingTemplate(更新)
4. handleGoToGenerate 移除 loadedTemplateId 守卫,允许直接跳转生成页
2026-07-06 21:40:45 +08:00
灵应 9005135868 fix: 添加片段面板视口边界检测,防止底部溢出
CI/CD Pipeline / Staging E2E Tests (push) Failing after 64h34m17s
CI/CD Pipeline / Deploy Staging (push) Failing after 64h35m51s
CI/CD Pipeline / Frontend Lint (push) Failing after 64h36m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 64h38m0s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1723h4m57s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1723h10m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1723h36m26s
- updatePickerPosition: 预估面板高度,下方空间不足时向上翻转
- 新增渲染后校正 useEffect: 用实际 getBoundingClientRect 精确调整
- 水平方向:右边界保护 + 左边界保护
- 8px 安全边距,clamp 到视口内

Closes: 面板底部被视口下边缘遮挡的问题
2026-07-06 20:16:27 +08:00
灵应 c454a31e1b fix: 添加片段面板改用 position:fixed 渲染,彻底解决 overflow 裁剪问题
CI/CD Pipeline / Staging E2E Tests (push) Failing after 65h4m19s
CI/CD Pipeline / Deploy Staging (push) Failing after 65h5m20s
CI/CD Pipeline / Frontend Lint (push) Failing after 65h5m51s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 65h6m37s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1723h34m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1723h40m27s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h6m23s
- 移除 createPortal,改为在 ep-timeline-area 内直接渲染 picker
- picker 使用 position:fixed + !important 覆盖基类 absolute
- 通过 getBoundingClientRect 动态计算弹出坐标
- 不受任何父容器 overflow:hidden 裁剪影响

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:47:49 +08:00
DevOps Bot e6dfeafb85 ci: 优化生产镜像部署 - 改用Registry push/pull替代tar+scp\n\n- build_release_images.sh: 增加web镜像buildx缓存构建 + push到Gitea Registry\n- deploy-production.sh: 改用docker pull从Registry拉取镜像,替代docker load\n- ci-cd.yml: 简化build-production和deploy-production job\n - 移除docker save + scp传镜像tar的流程\n - 仅保留源码tar.gz scp(compose/migration脚本需要)\n - web镜像构建合并到build_release_images.sh统一管理
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 60h28m37s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 60h30m7s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1719h1m54s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1719h1m57s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1719h1m57s
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1719h1m59s
CI/CD Pipeline / Deploy Staging (pull_request) Failing after 1719h1m59s
2026-07-06 19:20:03 +08:00
灵应 cd14f0e0ac fix: 添加片段面板被轨道 overflow 裁剪 — 改用 Portal + fixed 定位
CI/CD Pipeline / Staging E2E Tests (push) Failing after 65h32m50s
CI/CD Pipeline / Deploy Staging (push) Failing after 65h34m29s
CI/CD Pipeline / Frontend Lint (push) Failing after 65h35m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 65h35m46s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1724h3m26s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1724h8m57s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h34m59s
- picker 通过 createPortal 渲染到 body,脱离 .ep-clip-track 的 overflow:hidden 裁剪
- 位置根据 + 卡片 getBoundingClientRect 动态计算
- z-index 提升至 1000 确保弹层在最上层
2026-07-06 19:18:42 +08:00
灵应 6e30a96f6d fix: eslint unused-disable + prettier formatting
CI/CD Pipeline / Staging E2E Tests (push) Failing after 65h49m33s
CI/CD Pipeline / Deploy Staging (push) Failing after 65h51m39s
CI/CD Pipeline / Frontend Lint (push) Failing after 65h53m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 65h54m47s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1724h19m54s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h20m12s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1724h25m42s
- Remove unused eslint-disable in MainLayout.tsx (react-refresh/only-export-components)
- Format client.ts, ClipPropertiesPanel.tsx, TimelinePanel.tsx, EditingPlanner.tsx with prettier
2026-07-06 18:59:10 +08:00
灵应 4fe9fc0c95 feat: 登录持久化 + 片段逻辑重构
CI/CD Pipeline / Frontend Lint (push) Failing after 66h12m36s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 66h13m59s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1724h46m52s
CI/CD Pipeline / Deploy Production (push) Failing after 1724h46m54s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1724h46m56s
CI/CD Pipeline / Deploy Staging (push) Failing after 1724h46m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1725h18m21s
任务 1:登录持久化 + Token 刷新
- authStore: partialize 持久化 accessToken/refreshToken 到 localStorage
- auth.ts: 新增 refreshAccessToken API 函数
- client.ts: 401 拦截器实现刷新队列,无感续期

任务 2:片段逻辑重构(片段 = 时间规划 + 类型标记,不绑定素材)
- types.ts: 创建统一 ClipData/ClipType 类型定义
- EditingPlanner.tsx: handleAddClip 改为接收 type+duration,移除素材绑定
- TimelinePanel.tsx: "+" 卡片改为类型+时长选择器,移除素材拖拽
- ClipPropertiesPanel.tsx: 片段详情改为类型选择器+时长+startOffset
- PreviewPlayer.tsx: 适配新 ClipData 类型,用 type 显示图标
- EditingPlanner.css: 新增类型选择器样式,移除旧素材选择器样式

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:40:27 +08:00
110 changed files with 12975 additions and 1228 deletions
+165
View File
@@ -0,0 +1,165 @@
name: Auto Approve CI PRs
on:
pull_request:
types: [synchronize, opened, ready_for_review]
jobs:
auto-approve:
name: Auto Approve on CI Green
runs-on: ci-l1
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Auto approve when CI passes
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
# 检查是否纯前端改动
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
SKIP_BACKEND=true
echo "✅ 纯前端改动,只检查Frontend Lint"
else
SKIP_BACKEND=false
echo "🔧 包含后端/公共变更,检查全部CI"
fi
# 定义需要检查的context
# 根据目标分支决定检查哪些门禁
TARGET_BRANCH="${GITHUB_BASE_REF}"
echo "目标分支: ${TARGET_BRANCH}"
if [ "$SKIP_BACKEND" = "true" ]; then
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
elif [ "$TARGET_BRANCH" = "main" ]; then
# main分支只检查required statuses: Validate + Frontend Lint
# 不检查Tests/test(不是required门禁)
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
"Tests / test (pull_request)"
)
else
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"CI/CD Pipeline / Unit Tests (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
fi
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
for ctx in "${CONTEXTS[@]}"; do
echo " - $ctx"
done
echo
# 轮询等待,最多20分钟(120次x10秒)
for attempt in $(seq 1 120); do
ALL_SUCCESS=true
ANY_FAILED=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 调用辅助脚本检查每个context状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
echo " $ctx: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
done
if [ "$ALL_SUCCESS" = "true" ]; then
echo
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
if [ "$EXISTING" = "yes" ]; then
echo "️ PR #${PR_NUMBER} 已有审批,跳过"
exit 0
fi
# 第一步:创建PENDING reviewGitea API需要先创建再提交)
echo "创建review..."
REVIEW_CREATE=$(curl -s -X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
if [ -z "$REVIEW_ID" ]; then
echo "❌ 创建review失败"
echo "$REVIEW_CREATE"
exit 1
fi
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
if [ "$REVIEW_STATE" = "APPROVED" ]; then
echo "✅ 自动审批成功(直接创建为APPROVED"
exit 0
fi
# 第二步:submit review为APPROVED
echo "提交review审批..."
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${REVIEW_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
echo "提交API HTTP状态: $SUBMIT_CODE"
cat /tmp/submit_resp.json 2>/dev/null || true
echo
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
exit 0
else
echo "❌ 提交审批失败"
exit 1
fi
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "❌ CI检查有失败项,不自动审批"
exit 0
fi
sleep 10
done
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
+122 -12
View File
@@ -1,21 +1,131 @@
name: Auto Merge PRs
name: Auto Merge PRs (main)
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
pull_request:
types: [synchronize, opened, ready_for_review, review_requested]
jobs:
auto-merge:
name: Auto Merge on CI Green + Approved (main)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'main'
timeout-minutes: 30
steps:
- name: Checkout
- name: Checkout code
uses: actions/checkout@v3
- name: Auto merge develop PRs
- name: Auto merge when CI passes and approved
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
bash scripts/auto_merge_prs.sh develop
- name: Auto merge main PRs (release only)
run: |
bash scripts/auto_merge_prs.sh main
set -eu
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
echo
# 只合main分支
if [ "$BASE_REF" != "main" ]; then
echo "Skip: 目标分支不是main"
exit 0
fi
# main分支门禁:Validate + Frontend Lint
CONTEXTS=(
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
"Tests / test (pull_request)"
"CI/CD Pipeline / Frontend Lint (pull_request)"
)
echo "检查门禁: ${#CONTEXTS[@]} 项"
echo
# 轮询等待,最多30分钟(180次x10秒)
for attempt in $(seq 1 180); do
ALL_SUCCESS=true
ANY_FAILED=false
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
# 检查CI状态
for ctx in "${CONTEXTS[@]}"; do
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
echo " CI: ${ctx##*/}: $STATE"
if [ "$STATE" != "success" ]; then
ALL_SUCCESS=false
fi
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
ANY_FAILED=true
fi
done
# 检查审批状态
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
echo " 审批: $APPROVAL_RESULT"
HAS_APPROVAL=false
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
HAS_APPROVAL=true
fi
# 全部满足 → 合并
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
echo
echo "CI全绿 + 审批通过,执行自动合并"
# 幂等检查:PR是否还是open
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
if [ "$PR_STATE" != "open" ]; then
echo "PR状态为 ${PR_STATE},无需合并"
exit 0
fi
# 执行mergemain分支用merge,保留历史)
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
-X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"do":"merge","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
echo "合并API HTTP状态: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
echo "自动合并成功"
exit 0
elif [ "$HTTP_CODE" = "405" ]; then
echo "合并失败(405),可能有冲突或门禁未通过"
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 0
else
echo "自动合并失败 (HTTP $HTTP_CODE)"
cat /tmp/merge_resp.json 2>/dev/null || true
curl -s -X POST \
-H "Authorization: token ${MERGE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
exit 1
fi
fi
if [ "$ANY_FAILED" = "true" ]; then
echo
echo "CI有失败项,不自动合并"
exit 0
fi
sleep 10
done
echo
echo "等待超时(30分钟)"
exit 0
File diff suppressed because one or more lines are too long
-69
View File
@@ -1,69 +0,0 @@
name: Test SSH Secret
on:
push:
branches: [develop]
paths:
- '.gitea/workflows/test-ssh-secret.yml'
jobs:
test-ssh:
runs-on: ubuntu-22.04
steps:
- name: Install SSH client
run: |
which ssh || (apt-get update && apt-get install -y openssh-client)
ssh -V
- name: Debug environment
run: |
echo "=== Environment ==="
echo "Runner hostname: $(hostname)"
echo "Runner IP: $(hostname -i || echo 'unknown')"
echo "Current user: $(whoami)"
echo "=== Secrets check ==="
if [ -n "$STAGING_SSH_HOST" ]; then
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
else
echo "STAGING_SSH_HOST: [EMPTY]"
fi
if [ -n "$STAGING_SSH_USER" ]; then
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
else
echo "STAGING_SSH_USER: [EMPTY]"
fi
if [ -n "$STAGING_SSH_KEY" ]; then
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
else
echo "STAGING_SSH_KEY: [EMPTY]"
fi
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
echo "=== SSH Key fingerprint ==="
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
env:
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
- name: Test SSH connection
run: |
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 \
-o BatchMode=yes \
-v \
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
echo "=== SSH Test Complete ==="
env:
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
+6 -2
View File
@@ -11,9 +11,11 @@ jobs:
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python - <<'PY'
python3 - <<'PY'
import io
import os
import tarfile
@@ -93,9 +95,11 @@ jobs:
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python - <<'PY'
python3 - <<'PY'
import io
import os
import tarfile
@@ -0,0 +1,56 @@
"""Add user profile fields (name, avatar, updated_at)
Revision ID: 026
Revises: 025
Create Date: 2026-07-05
补录用户资料字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查,避免在已有字段的库上执行报错。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "026"
down_revision = "025"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "name"):
op.add_column("users", sa.Column("name", sa.String(100), nullable=True))
if not _column_exists("users", "avatar"):
op.add_column("users", sa.Column("avatar", sa.String(500), nullable=True))
if not _column_exists("users", "updated_at"):
op.add_column(
"users",
sa.Column(
"updated_at",
sa.DateTime(),
nullable=True,
server_default=sa.func.now(),
),
)
def downgrade() -> None:
op.drop_column("users", "updated_at")
op.drop_column("users", "avatar")
op.drop_column("users", "name")
@@ -0,0 +1,44 @@
"""Add user ban fields (ban_reason, ban_at)
Revision ID: 027
Revises: 026
Create Date: 2026-07-05
补录用户封禁字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "027"
down_revision = "026"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "ban_reason"):
op.add_column("users", sa.Column("ban_reason", sa.Text(), nullable=True))
if not _column_exists("users", "ban_at"):
op.add_column("users", sa.Column("ban_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "ban_at")
op.drop_column("users", "ban_reason")
@@ -0,0 +1,44 @@
"""Add user admin fields (admin_status, admin_remarks)
Revision ID: 028
Revises: 027
Create Date: 2026-07-05
补录管理员备注字段。生产数据库已手动添加过这些字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "028"
down_revision = "027"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "admin_status"):
op.add_column("users", sa.Column("admin_status", sa.String(50), nullable=True))
if not _column_exists("users", "admin_remarks"):
op.add_column("users", sa.Column("admin_remarks", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "admin_remarks")
op.drop_column("users", "admin_status")
+40
View File
@@ -0,0 +1,40 @@
"""Add user phone field
Revision ID: 029
Revises: 028
Create Date: 2026-07-05
补录用户手机号字段。生产数据库已手动添加过该字段,
因此 upgrade 做幂等检查。
"""
import sqlalchemy as sa
from alembic import context, op
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def _column_exists(table: str, column: str) -> bool:
if context.is_offline_mode():
return False
conn = op.get_bind()
result = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
return result.scalar() > 0
def upgrade() -> None:
if not _column_exists("users", "phone"):
op.add_column("users", sa.Column("phone", sa.String(20), nullable=True))
def downgrade() -> None:
op.drop_column("users", "phone")
@@ -0,0 +1,68 @@
"""Add tags and asset_tags tables
Revision ID: 030
Revises: 029
Create Date: 2026-07-07
新增标签表和素材-标签关联表,支持规范化多对多标签管理。
"""
import sqlalchemy as sa
from alembic import op
revision = "030"
down_revision = "029"
branch_labels = None
depends_on = None
def _table_exists(table: str) -> bool:
ctx = op.get_context()
if ctx.as_sql:
return False
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT COUNT(*) FROM information_schema.tables WHERE table_name = :table"),
{"table": table},
)
return (result.scalar() or 0) > 0
def upgrade() -> None:
if not _table_exists("tags"):
op.create_table(
"tags",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=False),
sa.Column("name", sa.String(100), nullable=False),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("user_id", "name", name="uq_tags_user_name"),
)
op.create_index("ix_tags_user_id", "tags", ["user_id"])
if not _table_exists("asset_tags"):
op.create_table(
"asset_tags",
sa.Column("asset_id", sa.String(36), primary_key=True),
sa.Column("tag_id", sa.String(36), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_asset_tags_tag_id", "asset_tags", ["tag_id"])
def downgrade() -> None:
op.drop_index("ix_asset_tags_tag_id", table_name="asset_tags")
op.drop_table("asset_tags")
op.drop_index("ix_tags_user_id", table_name="tags")
op.drop_table("tags")
@@ -0,0 +1,33 @@
"""Add file_hash to assets and ingest_jobs
Revision ID: 031
Revises: 030
Create Date: 2026-07-07
为素材去重检测功能添加 file_hash 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "031"
down_revision = "030"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
def downgrade() -> None:
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
op.drop_column("ingest_jobs", "file_hash")
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
op.drop_column("assets", "file_hash")
@@ -0,0 +1,28 @@
"""Add asset_select_mode to generation_tasks
Revision ID: 032
Revises: 031
Create Date: 2026-07-07
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
"""
import sqlalchemy as sa
from alembic import op
revision = "032"
down_revision = "031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "asset_select_mode")
@@ -0,0 +1,31 @@
"""Add batch_id to generation_tasks
Revision ID: 033
Revises: 032
Create Date: 2026-07-07
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
用于关联同一次批量生成请求中的多个任务。
"""
import sqlalchemy as sa
from alembic import op
revision = "033"
down_revision = "032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
)
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
def downgrade() -> None:
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
op.drop_column("generation_tasks", "batch_id")
+6
View File
@@ -16,6 +16,7 @@ from app.api.routes.jobs import router as jobs_router
from app.api.routes.projects import router as projects_router
from app.api.routes.recipes import router as recipes_router
from app.api.routes.subscription import router as subscription_router
from app.api.routes.tags import router as tags_router
from app.api.routes.task_center import router as task_center_router
from app.api.routes.templates import router as templates_router
from app.api.routes.titles import router as titles_router
@@ -38,6 +39,11 @@ api_router.include_router(
prefix="/projects",
tags=["Project"],
)
api_router.include_router(
tags_router,
prefix="/tags",
tags=["Tag"],
)
api_router.include_router(
task_center_router,
tags=["TaskCenter"],
+58 -1
View File
@@ -5,6 +5,7 @@ from app.dependencies import get_asset_library_repository, get_project_repositor
from app.schemas.asset_library import (
AssetLibraryResponse,
CreateAssetLibraryRequest,
EnsureDefaultLibraryRequest,
ListAssetLibrariesResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -15,7 +16,7 @@ from packages.application import (
GetProjectUseCase,
ListAssetLibrariesUseCase,
)
from packages.domain import AssetLibraryKind
from packages.domain import AssetLibrary, AssetLibraryKind
router = APIRouter()
@@ -43,6 +44,7 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
@router.get("", response_model=ListAssetLibrariesResponse)
def list_asset_libraries(
project_id: str | None = Query(None),
kind: str | None = Query(None, pattern="^(video|voice|image)$"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
@@ -66,6 +68,11 @@ def list_asset_libraries(
all_items.extend(use_case.execute(proj.id))
items = all_items
# 按 kind 过滤(可选)
if kind:
kind_enum = AssetLibraryKind(kind)
items = [item for item in items if item.kind == kind_enum]
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
@@ -90,3 +97,53 @@ def create_asset_library(
)
)
return _to_asset_library_response(item)
# 默认素材库名称映射
_DEFAULT_LIBRARY_NAMES = {
"video": "视频素材库",
"voice": "配音素材库",
"image": "图片素材库",
}
@router.post("/ensure-default", response_model=AssetLibraryResponse)
def ensure_default_library(
request: EnsureDefaultLibraryRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repository: Any = Depends(get_asset_library_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetLibraryResponse:
"""确保项目下指定 kind 的默认素材库存在,已存在则直接返回,不存在则自动创建。"""
project = project_repository.find_by_id(request.project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
if not project.can_access(authenticated_user.user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project")
kind = AssetLibraryKind(request.kind)
# 查找该项目下同 kind 的素材库,返回第一个
existing = asset_library_repository.find_by_project(request.project_id)
for lib in existing:
if lib.kind == kind:
return _to_asset_library_response(lib)
# 不存在 → 自动创建
import uuid
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
library = AssetLibrary(
id=str(uuid.uuid4()),
project_id=request.project_id,
name=default_name,
kind=kind,
asset_count=0,
total_size=0,
created_at=now,
updated_at=now,
)
created = asset_library_repository.create(library)
return _to_asset_library_response(created)
+278 -21
View File
@@ -7,14 +7,23 @@ from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_project_repository,
get_tag_repository,
)
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
from app.schemas.asset import (
AssetResponse,
BatchDeleteRequest,
BatchDeleteResponse,
CreateAssetRequest,
ListAssetsResponse,
UpdateAssetRequest,
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from fastapi import APIRouter, Depends, HTTPException, Query
from packages.application import (
CreateAssetCommand,
CreateAssetUseCase,
ListAssetsUseCase,
)
from packages.domain import AssetStatus, ClassificationStatus
@@ -59,6 +68,7 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
classification_status=item.classification_status.value,
quality_score=item.quality_score,
uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []),
)
@@ -75,6 +85,13 @@ def _check_project_access(project_id: str, user_id: str, project_repository) ->
def list_assets(
library_id: Optional[str] = Query(None),
project_id: Optional[str] = Query(None),
kind: Optional[str] = Query(None, pattern="^(video|voice|image)$"),
keyword: Optional[str] = Query(None, description="按名称模糊匹配"),
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
@@ -82,32 +99,148 @@ def list_assets(
) -> ListAssetsResponse:
user_id = authenticated_user.user.id
# 模式1:指定 library_id → 返回该库的素材
# kind → file_type 映射(voice 对应 audio
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
# 解析 tag_ids 参数(逗号分隔)
filter_tag_ids: list[str] | None = None
if tag_ids:
filter_tag_ids = [t.strip() for t in tag_ids.split(",") if t.strip()]
if not filter_tag_ids:
filter_tag_ids = None
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
def _apply_memory_filters(items):
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
result = items
if keyword:
kw = keyword.lower()
result = [i for i in result if kw in (i.name or "").lower()]
if gender:
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
if style:
result = [i for i in result if (i.metadata or {}).get("style") == style]
if filter_tag_ids:
tag_set = set(filter_tag_ids)
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
return result
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
if not needs_memory_filter:
ft = kind_to_file_type.get(kind) if kind else None
# 模式1:指定 library_id
if library_id:
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(library.project_id)
return ListAssetsResponse(
items=[_to_asset_response(item) for item in items],
total=total,
skip=skip,
limit=limit,
)
# 模式2:指定 project_id
if project_id:
_check_project_access(project_id, user_id, project_repository)
if ft:
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
all_items = asset_repository.find_by_project(project_id)
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
total = len(items)
paged = items[skip : skip + limit]
else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
total = asset_repository.count_by_project(project_id)
paged = items
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged],
total=total,
skip=skip,
limit=limit,
)
# 模式3:跨项目(无 library_id/project_id
try:
projects = project_repository.find_accessible_projects(user_id)
except Exception:
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
project_ids = [p.id for p in projects]
if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
total = asset_repository.count_by_project_ids(project_ids)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged_items],
total=total,
skip=skip,
limit=limit,
)
# ── 内存过滤路径:有 keyword/gender/style 时,加载全量后内存过滤 ──
if library_id:
library = asset_library_repository.get(library_id)
if library is None:
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
_check_project_access(library.project_id, user_id, project_repository)
items = asset_repository.find_by_library(library_id)
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
# 模式2:指定 project_id → 返回该项目所有素材
if project_id:
if kind:
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
else:
all_items = asset_repository.find_by_library(library_id)
elif project_id:
_check_project_access(project_id, user_id, project_repository)
items = asset_repository.find_by_project(project_id)
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
all_items = asset_repository.find_by_project(project_id)
else:
try:
projects = project_repository.find_accessible_projects(user_id)
except Exception:
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = []
for proj in projects:
all_items.extend(asset_repository.find_by_project(proj.id))
# 模式3:都不传 → 返回用户可访问的所有项目的所有素材
try:
projects = project_repository.find_accessible_projects(user_id)
except Exception:
logger.exception("查询用户可访问项目失败: user_id=%s", user_id)
return ListAssetsResponse(items=[])
all_items = []
for proj in projects:
all_items.extend(asset_repository.find_by_project(proj.id))
return ListAssetsResponse(items=[_to_asset_response(item) for item in all_items])
# 应用 kind 过滤(如果有)+ keyword/gender/style
if kind:
ft = kind_to_file_type.get(kind)
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
filtered = _apply_memory_filters(all_items)
total = len(filtered)
paged = filtered[skip : skip + limit]
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged],
total=total,
skip=skip,
limit=limit,
)
def _apply_asset_review_status(item, review_status: str):
@@ -135,6 +268,130 @@ def update_asset_review_status(
return _to_asset_response(updated)
@router.post("/batch-delete", response_model=BatchDeleteResponse)
def batch_delete_assets(
request: BatchDeleteRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> BatchDeleteResponse:
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
user_id = authenticated_user.user.id
deleted_ids: list[str] = []
failed_ids: list[str] = []
for asset_id in request.ids:
item = asset_repository.find_by_id(asset_id)
if item is None:
failed_ids.append(asset_id)
continue
try:
_check_project_access(item.project_id, user_id, project_repository)
deleted_ids.append(asset_id)
except HTTPException:
failed_ids.append(asset_id)
if deleted_ids:
asset_repository.batch_delete(deleted_ids)
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
@router.get("/{asset_id}", response_model=AssetResponse)
def get_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetResponse:
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
return _to_asset_response(item)
@router.put("/{asset_id}", response_model=AssetResponse)
def update_asset(
asset_id: str,
request: UpdateAssetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> AssetResponse:
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
# 合并可修改字段
if request.name is not None:
item.name = request.name
if request.metadata is not None:
item.metadata = {**item.metadata, **request.metadata}
if request.tags is not None:
item.metadata = {**item.metadata, "tags": request.tags}
updated = asset_repository.update(item)
return _to_asset_response(updated)
@router.delete("/{asset_id}", status_code=204)
def delete_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> None:
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
asset_repository.delete(asset_id)
@router.post("/{asset_id}/tags", response_model=AssetResponse)
def tag_asset(
asset_id: str,
request: TagAssetsRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
tag_repository: Any = Depends(get_tag_repository),
) -> AssetResponse:
"""给素材打标签。"""
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
for tag_id in request.tag_ids:
tag = tag_repository.get(tag_id)
if tag is None:
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found")
if tag.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail=f"无权使用标签 {tag_id}")
item.add_tag(tag_id)
updated = asset_repository.update(item)
return _to_asset_response(updated)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
def untag_asset(
asset_id: str,
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""取消素材的标签。"""
item = asset_repository.find_by_id(asset_id)
if item is None:
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
item.remove_tag(tag_id)
asset_repository.update(item)
@router.post("", response_model=AssetResponse)
def create_asset(
request: CreateAssetRequest,
+98 -1
View File
@@ -6,13 +6,14 @@ app.dependencies and authentication behavior lives in application use cases.
"""
import logging
import os
from typing import Optional
import jwt
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
from app.config import settings
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, EmailStr
@@ -284,3 +285,99 @@ def _translate_auth_error(error: str | None) -> str:
"Display name is required": "显示名称不能为空",
}
return translations.get(error or "", error or "注册失败")
class WechatSyncRequest(BaseModel):
openid: str
unionid: Optional[str] = None
nickname: Optional[str] = None
avatar_url: Optional[str] = None
source: str = "miniapp"
class WechatSyncResponse(BaseModel):
access_token: str
token: str
refresh_token: str
user_id: str
user: dict
user_info: dict
is_new_user: bool
expires_in: int
def _get_internal_api_keys() -> list[str]:
"""获取内部 API Key 列表
优先级:
1. INTERNAL_API_KEYS 环境变量
2. /app/generated/internal_api_keys.txt 文件 (volume 持久化)
"""
env_keys = os.environ.get("INTERNAL_API_KEYS", "")
if env_keys:
return [k.strip() for k in env_keys.split(",") if k.strip()]
# 从持久化文件读取
try:
with open("/app/generated/internal_api_keys.txt", "r") as f:
content = f.read().strip()
if content:
return [k.strip() for k in content.split(",") if k.strip()]
except Exception:
logger.debug("Failed to read internal API keys from file", exc_info=True)
return []
def _verify_internal_api_key(x_api_key: str | None = Header(None)) -> bool:
"""验证内部 API Key
- 已配置时:必须匹配 INTERNAL_API_KEYS 中的 key
- 未配置且非生产环境:放行(方便开发)
- 未配置且生产环境:拒绝
"""
env = os.environ.get("APP_ENV", os.environ.get("ENV", "development")).lower()
key_list = _get_internal_api_keys()
if not key_list:
if env in ("production", "prod"):
raise HTTPException(status_code=401, detail="内部接口未配置 API Key")
return True
if x_api_key and x_api_key.strip() in key_list:
return True
raise HTTPException(status_code=401, detail="无效的 API Key")
@router.post("/wechat-sync", response_model=WechatSyncResponse, include_in_schema=False)
async def wechat_sync(
request: WechatSyncRequest,
user_repository: UserRepository = Depends(get_user_repository),
_: bool = Depends(_verify_internal_api_key),
):
"""
微信同步登录/注册(系统级内部接口)
由 BFF 层通过 API Key 调用,不直接面向终端用户。
根据 openid 查找或创建用户,返回 SaaS token。
"""
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as UseCaseRequest
from packages.application.auth.wechat_sync_use_case import (
WechatSyncUseCase,
)
use_case = WechatSyncUseCase(user_repository=user_repository)
use_case_request = UseCaseRequest(
openid=request.openid,
unionid=request.unionid,
nickname=request.nickname,
avatar_url=request.avatar_url,
source=request.source,
)
response, error = use_case.execute(use_case_request)
if error:
raise HTTPException(status_code=400, detail=error)
return WechatSyncResponse(**response.to_dict())
+40 -1
View File
@@ -19,6 +19,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -42,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
CHUNK_EXPIRY_HOURS = 24
# Allowed file types (consistent with existing upload.py)
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
ALLOWED_MIME_TYPES = {
# Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
# Video
"video/mp4",
"video/quicktime",
"video/mpeg",
"video/x-msvideo",
"video/webm",
"video/x-matroska",
"video/3gpp",
# Audio
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/mp3",
"audio/flac",
"audio/aac",
"audio/x-m4a",
"audio/webm",
}
# Chunk storage root directory
@@ -360,6 +374,7 @@ async def complete_chunked_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ChunkedUploadCompleteResponse:
@@ -423,6 +438,29 @@ async def complete_chunked_upload(
content_type=meta["content_type"],
)
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
meta["status"] = "completed"
_save_upload_meta(upload_id, meta)
return ChunkedUploadCompleteResponse(
storage_key=storage_key,
ingest_job_id="",
url=file_url,
duplicated=True,
asset_id=existing.id,
)
# Create ingest job
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -430,6 +468,7 @@ async def complete_chunked_upload(
project_id=meta["project_id"],
library_id=meta["library_id"],
storage_key=storage_key,
file_hash=request.file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
+86 -17
View File
@@ -1,3 +1,5 @@
import random
import uuid
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
ListGeneratedVideosResponse,
)
from app.schemas.generation_task import (
BatchGenerationTaskResponse,
CreateGenerationTaskRequest,
GenerationTaskResponse,
ListGenerationTasksResponse,
@@ -51,6 +54,8 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
title_ids=task.title_ids,
voice_ids=task.voice_ids,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
status=task.status,
progress=task.progress,
result_count=task.result_count,
@@ -85,6 +90,49 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
)
def _select_assets_from_library(
assets: list,
mode: str,
count: int,
) -> list[str]:
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
Args:
assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns:
选中的素材 ID 列表
"""
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
if not ready_video_assets:
return []
if mode == "random":
selected = (
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
)
return [a.id for a in selected]
if mode == "smart":
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
sorted_assets = sorted(
ready_video_assets,
key=lambda a: (
a.quality_score if a.quality_score is not None else 0.0,
a.duration if a.duration is not None else 0.0,
),
reverse=True,
)
selected = sorted_assets if count <= 0 else sorted_assets[:count]
return [a.id for a in selected]
# 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets]
def _resolve_project_and_library(
request: CreateGenerationTaskRequest,
project_repository: Any,
@@ -122,7 +170,7 @@ def _resolve_project_and_library(
return project_id, asset_library_id
@router.post("/tasks", response_model=GenerationTaskResponse)
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -130,12 +178,13 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> GenerationTaskResponse:
) -> BatchGenerationTaskResponse:
project_id, asset_library_id = _resolve_project_and_library(
request, project_repository, asset_library_repository, asset_repository, authenticated_user
)
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
resolved_asset_ids: list[str] = list(request.asset_ids)
if asset_library_id:
library = asset_library_repository.get(asset_library_id)
if library is None or (project_id and library.project_id != project_id):
@@ -144,23 +193,42 @@ def create_generation_task(
assets = asset_repository.find_by_library(asset_library_id)
_ensure_library_has_ready_video_assets(assets)
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
if not resolved_asset_ids:
resolved_asset_ids = _select_assets_from_library(
assets,
mode=request.asset_select_mode,
count=request.asset_select_count,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=request.asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
count = request.count
created_tasks = []
# 同批次任务共享 batch_id,用于视频查重时批次内比对
batch_id = uuid.uuid4().hex if count > 1 else ""
for _ in range(count):
task = use_case.execute(
CreateGenerationTaskCommand(
project_id=project_id,
asset_library_id=asset_library_id,
strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=resolved_asset_ids,
title_ids=request.title_ids,
voice_ids=request.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
)
)
)
celery_app.send_task("worker.generate_video", args=[task.id])
return _to_generation_task_response(task)
celery_app.send_task("worker.generate_video", args=[task.id])
created_tasks.append(task)
items = [_to_generation_task_response(t) for t in created_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
@router.get("/tasks", response_model=ListGenerationTasksResponse)
@@ -237,6 +305,7 @@ def retry_generation_task(
voice_ids=task.voice_ids,
created_by_user_id=authenticated_user.user.id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
)
)
celery_app.send_task("worker.generate_video", args=[retried.id])
+67
View File
@@ -0,0 +1,67 @@
"""标签 CRUD 路由。"""
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_tag_repository
from app.schemas.tag import (
CreateTagRequest,
ListTagsResponse,
TagResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from packages.domain import Tag
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=ListTagsResponse)
def list_tags(
skip: int = 0,
limit: int = 100,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tag_repository: Any = Depends(get_tag_repository),
) -> ListTagsResponse:
"""列出当前用户的标签。"""
user_id = authenticated_user.user.id
items = tag_repository.list_by_user(user_id, skip=skip, limit=limit)
total = tag_repository.count_by_user(user_id)
return ListTagsResponse(
items=[TagResponse(id=t.id, name=t.name, created_at=t.created_at) for t in items],
total=total,
)
@router.post("", response_model=TagResponse, status_code=201)
def create_tag(
request: CreateTagRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tag_repository: Any = Depends(get_tag_repository),
) -> TagResponse:
"""创建标签(同用户同名去重,返回 409)。"""
user_id = authenticated_user.user.id
existing = tag_repository.find_by_name(user_id, request.name)
if existing:
raise HTTPException(status_code=409, detail="标签名称已存在")
tag = Tag.create(user_id=user_id, name=request.name)
created = tag_repository.create(tag)
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
@router.delete("/{tag_id}", status_code=204)
def delete_tag(
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tag_repository: Any = Depends(get_tag_repository),
) -> None:
"""删除标签(同时清理素材关联)。"""
tag = tag_repository.get(tag_id)
if tag is None:
raise HTTPException(status_code=404, detail="标签不存在")
if tag.user_id != authenticated_user.user.id:
raise HTTPException(status_code=403, detail="无权删除该标签")
tag_repository.delete(tag_id)
+174 -11
View File
@@ -6,21 +6,31 @@ import logging
from typing import Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_cosyvoice_service, get_db_session
from app.dependencies import (
get_cosyvoice_service,
get_db_session,
get_user_repository,
get_voice_clone_profile_repository,
get_voice_library_repository,
)
from app.schemas.tts import (
ListTTSJobResponse,
SaveToLibraryRequest,
SaveToLibraryResponse,
TTSJobResponse,
TTSStatusResponse,
TTSSynthesizeRequest,
TTSSynthesizeResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository,
)
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.streaming_service import TTSStreamingService
from packages.application.tts_job.use_cases import (
CreateTTSJobUseCase,
DeleteTTSJobUseCase,
@@ -30,6 +40,12 @@ from packages.application.tts_job.use_cases import (
TTSJobNotFoundError,
)
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.ports.user_repository import UserRepository
logger = logging.getLogger(__name__)
@@ -73,6 +89,7 @@ def synthesize(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSSynthesizeResponse:
"""发起 TTS 合成任务。
@@ -80,6 +97,21 @@ def synthesize(
与音色克隆接口保持一致:CosyVoice 失败时不抛 500,而是返回 201 + failed 状态任务记录。
"""
user_id = authenticated_user.user.id
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
if request.voice_clone_profile_id:
profile = voice_clone_repo.get(request.voice_clone_profile_id)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Voice clone profile not found",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to voice clone profile",
)
use_case = CreateTTSJobUseCase(repository)
job = use_case.execute(
user_id=user_id,
@@ -110,18 +142,25 @@ def synthesize(
# 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询
if job.status.value == "processing":
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
if task_id:
try:
# 分段合成任务 vs 普通单段任务
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
is_segment = len(segment_task_ids) > 0
try:
if is_segment:
from worker_app.tasks import process_tts_segment_synthesis
process_tts_segment_synthesis.delay(job.id)
else:
from worker_app.tasks import process_tts_synthesis
process_tts_synthesis.delay(job.id)
except Exception as e:
# Celery 调度失败,标记 job 为 failed
try:
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
except Exception as e:
# Celery 调度失败,标记 job 为 failed
try:
workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}")
except Exception as inner_e:
logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}")
return TTSSynthesizeResponse(
job_id=job.id,
@@ -205,3 +244,127 @@ def delete_tts_job(
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return Response(status_code=204)
@router.post(
"/jobs/{job_id}/save-to-library",
response_model=SaveToLibraryResponse,
status_code=status.HTTP_201_CREATED,
)
def save_tts_job_to_library(
job_id: str,
request: SaveToLibraryRequest = SaveToLibraryRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
user_repository: UserRepository = Depends(get_user_repository),
) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音库。
自动携带音色名、时长、语速等元信息。
"""
user_id = authenticated_user.user.id
# 获取 TTS job
get_use_case = GetTTSJobUseCase(tts_repository)
try:
job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成
if not job.is_completed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TTS job is not completed yet",
)
# 构建配音素材名称
name = request.name or f"TTS-{job.id[:8]}"
# 构建元信息
metadata_ = {
"source": "tts_job",
"tts_job_id": job.id,
"format": job.format,
"sample_rate": job.sample_rate,
}
if job.metadata:
# 保留原始 job 的有用元信息
for key in ("speed", "language"):
if key in job.metadata:
metadata_[key] = job.metadata[key]
# 获取用户套餐(用于配额检查)
user = user_repository.find_by_id(user_id)
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
# 构建命令并执行
command = CreateVoiceLibraryCommand(
user_id=user_id,
name=name,
text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id,
voice_name=job.voice_model or "",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed",
project_id=job.project_id or "",
tags=[],
metadata_=metadata_,
)
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
try:
item = use_case.execute(command, plan_name=plan_name or "free")
except QuotaExceededError as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
return SaveToLibraryResponse(
id=item.id,
name=item.name,
audio_url=item.audio_url,
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
status=item.status,
)
@router.websocket("/ws/tts/stream")
async def tts_websocket_stream(
websocket: WebSocket,
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
) -> None:
"""WebSocket 流式 TTS 合成。
协议:
1. 客户端发送 JSON 文本帧: {"text": "...", "voice_id": "...", ...}
2. 服务端发送 JSON 状态帧 + 二进制音频帧
3. 完成时发送 JSON 结束帧
"""
await websocket.accept()
try:
message = await websocket.receive_json()
params = {
"text": message.get("text", ""),
"voice_id": message.get("voice_id", ""),
"sample_rate": message.get("sample_rate", 0),
"format": message.get("format", "mp3"),
"speed": message.get("speed", 1.0),
}
streaming_service = TTSStreamingService(cosyvoice_service)
await streaming_service.synthesize_and_stream(websocket, params)
except WebSocketDisconnect:
logger.info("WebSocket 客户端断开连接")
except Exception as e:
logger.error(f"WebSocket 流式合成异常: {e}", exc_info=True)
try:
await websocket.send_json({"type": "error", "message": f"服务异常: {e}"})
except Exception:
pass
+49
View File
@@ -8,6 +8,7 @@ from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
@@ -99,6 +100,7 @@ def _submit_ingest_job(
library_id: str,
storage_key: str,
ingest_job_repository: Any,
file_hash: str = "",
) -> Any:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
@@ -106,6 +108,7 @@ def _submit_ingest_job(
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
file_hash=file_hash,
)
)
celery_app.send_task("worker.ingest_asset", args=[job.id])
@@ -176,6 +179,7 @@ async def complete_direct_upload(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
@@ -199,11 +203,32 @@ async def complete_direct_upload(
if not file_exists:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
if request.file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=request.library_id,
file_hash=request.file_hash,
)
if existing is not None:
logger.info(
"素材去重命中: library=%s hash=%s existing_asset=%s",
request.library_id,
request.file_hash,
existing.id,
)
return DirectUploadCompleteResponse(
storage_key=normalized_key,
ingest_job_id="",
duplicated=True,
asset_id=existing.id,
)
job = _submit_ingest_job(
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash,
)
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
@@ -218,15 +243,38 @@ async def upload_asset(
project_id: str = Form(..., min_length=1, description="项目 ID"),
library_id: str = Form(..., min_length=1, description="素材库 ID"),
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
if file_hash:
existing = asset_repository.find_by_library_and_file_hash(
library_id=library_id,
file_hash=file_hash,
)
if existing is not None:
logger.info(
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
library_id,
file_hash,
existing.id,
)
return UploadAssetResponse(
storage_key=existing.storage_key,
ingest_job_id="",
url="",
duplicated=True,
asset_id=existing.id,
)
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
@@ -255,6 +303,7 @@ async def upload_asset(
library_id=library_id,
storage_key=storage_key,
ingest_job_repository=ingest_job_repository,
file_hash=file_hash,
)
return UploadAssetResponse(
+9
View File
@@ -39,6 +39,7 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
SQLAlchemyProjectRepository,
)
from packages.adapters.sqlalchemy_impl.session import build_session_factory
from packages.adapters.sqlalchemy_impl.tag_repository import SQLAlchemyTagRepository
from packages.adapters.sqlalchemy_impl.title_library_repository import (
SQLAlchemyTitleLibraryRepository,
)
@@ -58,6 +59,7 @@ from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.ingest_job_repository import IngestJobRepository
from packages.ports.job_repository import JobRepository
from packages.ports.project_repository import ProjectRepository
from packages.ports.tag_repository import TagRepository
from packages.ports.title_library_repository import TitleLibraryRepository
from packages.ports.user_repository import UserRepository
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
@@ -138,6 +140,13 @@ def get_project_repository(
return SQLAlchemyProjectRepository(session)
def get_tag_repository(
session: Session = Depends(get_db_session),
) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session)
def get_user_repository(
session: Session = Depends(get_db_session),
) -> UserRepository:
+23
View File
@@ -25,6 +25,12 @@ class UpdateAssetReviewRequest(BaseModel):
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
class UpdateAssetRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
metadata: dict[str, object] | None = None
tags: list[str] | None = None
class AssetResponse(BaseModel):
id: str
project_id: str
@@ -45,7 +51,24 @@ class AssetResponse(BaseModel):
classification_status: str
quality_score: float | None = None
uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list)
class BatchDeleteRequest(BaseModel):
"""批量删除请求。"""
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
class BatchDeleteResponse(BaseModel):
"""批量删除响应。"""
deleted_count: int = Field(..., ge=0, description="实际删除数量")
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
class ListAssetsResponse(BaseModel):
items: list[AssetResponse]
total: int = Field(default=0, ge=0)
skip: int = Field(default=0, ge=0)
limit: int = Field(default=100, ge=1)
+5
View File
@@ -18,3 +18,8 @@ class AssetLibraryResponse(BaseModel):
class ListAssetLibrariesResponse(BaseModel):
items: list[AssetLibraryResponse]
class EnsureDefaultLibraryRequest(BaseModel):
project_id: str = Field(..., min_length=1)
kind: str = Field(..., pattern="^(video|voice|image)$")
+3
View File
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
class ChunkedUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="Project ID")
library_id: str = Field(..., min_length=1, description="Asset library ID")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class ChunkedUploadCompleteResponse(BaseModel):
storage_key: str = Field(..., description="Storage key")
ingest_job_id: str = Field(..., description="Ingest job ID")
url: str = Field(..., description="File URL")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
+19
View File
@@ -21,6 +21,16 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ──
asset_select_mode: str = Field(
default="all",
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
)
@model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -46,12 +56,21 @@ class GenerationTaskResponse(BaseModel):
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
status: str
progress: float
result_count: int
error_message: str
class BatchGenerationTaskResponse(BaseModel):
"""批量生成任务响应。"""
items: list[GenerationTaskResponse]
total: int
class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。"""
+24
View File
@@ -0,0 +1,24 @@
"""标签相关 Schema。"""
from datetime import datetime
from pydantic import BaseModel, Field
class CreateTagRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
class TagResponse(BaseModel):
id: str
name: str
created_at: datetime
class ListTagsResponse(BaseModel):
items: list[TagResponse]
total: int = Field(default=0, ge=0)
class TagAssetsRequest(BaseModel):
tag_ids: list[str] = Field(..., min_length=1, max_length=50)
+18
View File
@@ -83,3 +83,21 @@ class ListTTSJobResponse(BaseModel):
total: int
page: int
page_size: int
class SaveToLibraryRequest(BaseModel):
"""保存到配音库请求。"""
name: Optional[str] = Field(None, description="配音素材名称,留空则自动生成")
class SaveToLibraryResponse(BaseModel):
"""保存到配音库响应。"""
id: str
name: str
audio_url: str
duration: float
voice_id: str
voice_name: str
status: str
+13 -6
View File
@@ -6,12 +6,7 @@ class UploadAssetRequest(BaseModel):
project_id: str = Field(..., min_length=1, description="项目 ID")
library_id: str = Field(..., min_length=1, description="素材库 ID")
class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareRequest(BaseModel):
@@ -20,6 +15,7 @@ class DirectUploadPrepareRequest(BaseModel):
filename: str = Field(..., min_length=1, max_length=255)
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
file_size: int = Field(..., gt=0)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadPrepareResponse(BaseModel):
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1)
storage_key: str = Field(..., min_length=1, max_length=255)
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
class DirectUploadCompleteResponse(BaseModel):
storage_key: str
ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
class UploadAssetResponse(BaseModel):
storage_key: str
ingest_job_id: str
url: str = Field(..., description="Public URL of uploaded file")
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
+73 -7
View File
@@ -19,6 +19,7 @@ export interface AssetItem {
status?: string;
classification_status?: string | null;
quality_score?: number | null;
tag_ids?: string[];
created_at?: string;
}
@@ -114,6 +115,18 @@ export const createAssetLibrary = async (data: {
return response.data;
};
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
export const ensureDefaultLibrary = async (data: {
project_id: string;
kind: "video" | "voice" | "image";
}): Promise<AssetLibraryItem> => {
const response = await apiClient.post(
"/asset-libraries/ensure-default",
data,
);
return response.data;
};
/** 删除素材库 */
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
await apiClient.delete(`/asset-libraries/${libraryId}`);
@@ -129,6 +142,46 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
return response.data.items || [];
};
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
export const getAssetsByKind = async (
kind: string,
filters?: {
keyword?: string;
gender?: string;
style?: string;
tag_ids?: string[];
},
): Promise<AssetItem[]> => {
const params: Record<string, string> = { kind };
if (filters?.keyword) params.keyword = filters.keyword;
if (filters?.gender) params.gender = filters.gender;
if (filters?.style) params.style = filters.style;
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",");
const response = await apiClient.get("/assets", { params });
return response.data.items || [];
};
/** 创建素材(上传文件后调用,附带 metadata) */
export const createAsset = async (data: {
library_id: string;
name: string;
storage_key: string;
mime_type: string;
metadata?: Record<string, unknown>;
}): Promise<AssetItem> => {
const response = await apiClient.post("/assets", data);
return response.data;
};
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: Record<string, unknown> },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
};
/** 更新素材审核状态 */
export const updateAssetReviewStatus = async (
assetId: string,
@@ -187,10 +240,11 @@ export const completeDirectUpload = async (data: {
return response.data;
};
/** 直传上传(大文件推荐) */
/** 直传上传(大文件推荐),支持可选进度回调 */
export const uploadAssetDirect = async (data: {
file: File;
library_id: string;
onProgress?: (percent: number) => void;
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
// 后端要求 project_id,前端自动获取默认项目
const project = await getOrCreateDefaultProject();
@@ -209,13 +263,25 @@ export const uploadAssetDirect = async (data: {
);
directForm.append("file", data.file);
const uploadResponse = await fetch(prepared.upload_url, {
method: prepared.method,
body: directForm,
// 使用 XMLHttpRequest 以获取上传进度(fetch 不支持)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(prepared.method, prepared.upload_url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && data.onProgress) {
data.onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`OSS direct upload failed: ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error("OSS direct upload failed"));
xhr.send(directForm);
});
if (!uploadResponse.ok) {
throw new Error(`OSS direct upload failed: ${uploadResponse.status}`);
}
return completeDirectUpload({
project_id: project.id,
+12
View File
@@ -1,6 +1,7 @@
/**
* 认证相关 API
*/
import axios from "axios";
import apiClient from "./client";
// 类型定义
@@ -71,6 +72,17 @@ export const login = async (data: LoginRequest): Promise<LoginResponse> => {
return response.data;
};
// 刷新 access_token(使用裸 axios 避免拦截器递归)
export const refreshAccessToken = async (
refreshToken: string,
): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? "";
const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken,
});
return response.data;
};
// 注册
export const register = async (
data: RegisterRequest,
+85 -4
View File
@@ -5,6 +5,7 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { message } from "antd";
import { useAuthStore } from "@/store/authStore";
import { refreshAccessToken } from "./auth";
// 创建 Axios 实例
const apiClient = axios.create({
@@ -15,6 +16,24 @@ const apiClient = axios.create({
},
});
// ── Token 刷新队列 ─────────────────────────────────────────
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
const processQueue = (error: unknown | null, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// 请求拦截器:添加 Token
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
@@ -29,15 +48,77 @@ apiClient.interceptors.request.use(
},
);
// 响应拦截器:统一错误提示 + 处理未授权状态
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
apiClient.interceptors.response.use(
(response) => response,
async (
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
) => {
// 401 → 清除登录态
if (error.response?.status === 401) {
useAuthStore.getState().clearAuth();
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// 401 → 尝试刷新 Token
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry
) {
const refreshToken = useAuthStore.getState().refreshToken;
// 无 refresh_token → 直接登出
if (!refreshToken) {
useAuthStore.getState().clearAuth();
window.location.href = "/";
return Promise.reject(error);
}
// 已在刷新中 → 排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return apiClient(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const data = await refreshAccessToken(refreshToken);
const newAccessToken = data.access_token;
const newRefreshToken = data.refresh_token ?? refreshToken;
// 更新 Zustand + localStorage
useAuthStore
.getState()
.setAuth(
useAuthStore.getState().user!,
newAccessToken,
newRefreshToken,
);
// 处理排队的请求
processQueue(null, newAccessToken);
// 重试原始请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
return apiClient(originalRequest);
} catch (refreshError) {
// 刷新失败 → 登出
processQueue(refreshError, null);
useAuthStore.getState().clearAuth();
window.location.href = "/";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// 提取后端返回的错误信息(detail / message / msg
+46
View File
@@ -0,0 +1,46 @@
/**
* 标签 CRUD API
* P3 标签体系:对接后端标签表
*/
import apiClient from "./client";
export interface TagItem {
id: string;
name: string;
created_at?: string;
usage_count?: number;
}
/** 获取当前用户所有标签 */
export const getTags = async (): Promise<TagItem[]> => {
const response = await apiClient.get("/tags");
return response.data.items || [];
};
/** 创建标签(同名返回 409) */
export const createTag = async (name: string): Promise<TagItem> => {
const response = await apiClient.post("/tags", { name });
return response.data;
};
/** 删除标签(同时清理素材关联) */
export const deleteTag = async (tagId: string): Promise<void> => {
await apiClient.delete(`/tags/${tagId}`);
};
/** 为素材添加标签(最多 50 个) */
export const tagAsset = async (
assetId: string,
tagIds: string[],
): Promise<void> => {
if (tagIds.length === 0) return;
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
};
/** 移除素材的某个标签 */
export const untagAsset = async (
assetId: string,
tagId: string,
): Promise<void> => {
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
};
+14
View File
@@ -122,6 +122,20 @@ export const getTTSJobs = async (
return response.data;
};
/** 存为素材请求参数 */
export interface SaveTtsToLibraryRequest {
name?: string;
tag_ids?: string[];
}
/** 将 TTS 合成结果保存到配音素材库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
): Promise<void> => {
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {});
};
/** 删除 TTS 任务 */
export const deleteTTSJob = async (jobId: string): Promise<void> => {
await apiClient.delete(`/tts/jobs/${jobId}`);
@@ -16,7 +16,6 @@ import Sidebar from "./Sidebar";
import "./MainLayout.css";
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
// eslint-disable-next-line react-refresh/only-export-components
export interface SidebarContextValue {
collapsed: boolean;
}
@@ -62,6 +62,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/editing-planner": "剪辑规划",
"/app/my-templates": "我的模板",
"/app/voice-clone": "我的音色",
"/app/voice-materials": "配音素材库",
"/app/accounts": "账号管理",
"/app/duplication": "查重",
"/app/duplication/results": "查重结果",
@@ -10,6 +10,7 @@ import React, { useState, useCallback, useRef } from "react";
import { Modal, Button } from "@/components/ui";
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { uploadAsset } from "@/api/assets";
import "./clone-voice-modal.css";
/* ── 类型定义 ───────────────────────────────────────────── */
@@ -121,12 +122,24 @@ const CloneVoiceModal: React.FC<CloneVoiceModalProps> = ({
setStep("uploading");
try {
// Mock:模拟上传 + 克隆过程
// 先上传音频文件获取真实 URL
let audioUrl: string;
if (selectedFile) {
const formData = new FormData();
formData.append("file", selectedFile);
formData.append("kind", "voice");
const uploadResult = await uploadAsset(formData);
audioUrl = uploadResult.url;
} else {
// 录制功能暂未实现,提示用户上传
setStep("input");
return;
}
// 提交克隆请求
const result = await createVoiceClone({
name,
audio_url: selectedFile
? `mock://${selectedFile.name}`
: "mock://recorded-audio",
audio_url: audioUrl,
});
setStep("success");
+12
View File
@@ -67,6 +67,12 @@ export const NAV_ITEMS: NavItem[] = [
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "templates",
label: "模板库",
@@ -153,6 +159,12 @@ export const NAV_GROUPS: NavGroup[] = [
path: "/app/voice-clone",
icon: <AudioOutlined />,
},
{
key: "voice-materials",
label: "配音素材库",
path: "/app/voice-materials",
icon: <AudioOutlined />,
},
{
key: "titles",
label: "标题库",
+1 -1
View File
@@ -75,7 +75,7 @@ export const useLogout = () => {
} finally {
clearAuth();
queryClient.clear();
navigate("/login");
navigate("/");
}
};
+9
View File
@@ -25,6 +25,15 @@ body {
background-color: var(--bg-secondary);
}
/* ── 自定义字体 ── */
@font-face {
font-family: "华康俪金黑";
src: url("/fonts/DFLiJinHei-W8.ttf") format("truetype");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 滚动条 - V21 样式 */
::-webkit-scrollbar {
width: 8px;
+69 -21
View File
@@ -10,7 +10,6 @@ import {
SearchOutlined,
InboxOutlined,
VideoCameraOutlined,
SoundOutlined,
PictureOutlined,
PlayCircleOutlined,
CheckOutlined,
@@ -37,7 +36,7 @@ import "./assets.css";
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "voice" | "image";
type AssetKind = "video" | "image";
type StatusType = "ok" | "warn" | "bad" | "info";
interface LibraryItem {
@@ -67,7 +66,6 @@ interface AssetItem {
/** 根据 mime_type 推断前端 AssetKind */
const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("audio/")) return "voice";
return "image";
};
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
count: item.asset_count ?? 0,
});
@@ -110,14 +108,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
item.classification_status ?? undefined,
);
const metadata = item.metadata || {};
const kind = inferKind(item.mime_type || "");
return {
id: item.id,
name: item.name,
kind: inferKind(item.mime_type || ""),
kind,
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
thumbUrl:
(item.thumbnail_url as string | undefined) ||
(item.file_url as string | undefined) ||
(metadata.thumbnail_url as string | undefined),
(metadata.thumbnail_url as string | undefined) ||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
fileUrl:
(item.file_url as string | undefined) ||
(metadata.file_url as string | undefined),
@@ -147,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
switch (kind) {
case "video":
return <VideoCameraOutlined />;
case "voice":
return <SoundOutlined />;
case "image":
return <PictureOutlined />;
}
@@ -158,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
switch (kind) {
case "video":
return "视频";
case "voice":
return "配音";
case "image":
return "图片";
}
@@ -170,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
switch (kind) {
case "video":
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
}
@@ -244,7 +238,7 @@ const AssetCard: React.FC<{
)}
{/* 视频/配音类显示播放按钮 */}
{(asset.kind === "video" || asset.kind === "voice") && (
{asset.kind === "video" && (
<span
className="xx-asset-play"
onClick={(e) => {
@@ -361,6 +355,7 @@ const AssetLibrary: React.FC = () => {
/* 上传 */
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -434,18 +429,25 @@ const AssetLibrary: React.FC = () => {
}
setUploading(true);
setUploadProgress(0);
try {
if (file.size > LARGE_FILE_THRESHOLD) {
message.info(`大文件 "${file.name}" 将使用直传上传`);
}
await uploadAssetDirect({ file, library_id: effectiveLibId });
await uploadAssetDirect({
file,
library_id: effectiveLibId,
onProgress: (pct) => setUploadProgress(pct),
});
message.success(`"${file.name}" 上传成功`);
queryClient.invalidateQueries({ queryKey: ["assets"] });
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
} catch {
message.error(`"${file.name}" 上传失败`);
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : "";
message.error(`"${file.name}" 上传失败${detail ? `${detail}` : ""}`);
} finally {
setUploading(false);
setUploadProgress(0);
}
return false;
};
@@ -532,6 +534,54 @@ const AssetLibrary: React.FC = () => {
return (
<div className="xx-assets-page">
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
<AntModal
open={uploading}
footer={null}
closable={false}
centered
width={260}
maskClosable={false}
className="xx-upload-progress-modal"
>
<div className="xx-upload-progress-body">
<svg
className="xx-upload-progress-ring"
viewBox="0 0 120 120"
width={120}
height={120}
>
{/* 背景圆环 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--border-primary, #e5e7eb)"
strokeWidth="8"
/>
{/* 进度圆弧 */}
<circle
cx="60"
cy="60"
r="52"
fill="none"
stroke="var(--primary-color, #6366f1)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 52}`}
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
transform="rotate(-90 60 60)"
style={{ transition: "stroke-dashoffset 0.3s ease" }}
/>
</svg>
<div className="xx-upload-progress-text">
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
<span className="xx-upload-progress-label"></span>
</div>
</div>
</AntModal>
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:素材库列表 ─── */}
@@ -588,7 +638,7 @@ const AssetLibrary: React.FC = () => {
beforeUpload={handleUpload}
showUploadList={false}
multiple
accept="video/*,audio/*,image/*"
accept="video/*,image/*"
>
<div className="xx-asset-upload-zone">
<p className="xx-asset-upload-icon">
@@ -598,7 +648,7 @@ const AssetLibrary: React.FC = () => {
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
</p>
<p className="xx-asset-upload-hint">
2GB
2GB
</p>
</div>
</Upload.Dragger>
@@ -621,7 +671,6 @@ const AssetLibrary: React.FC = () => {
options={[
{ value: "all", label: "全部类型" },
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
@@ -752,7 +801,6 @@ const AssetLibrary: React.FC = () => {
style={{ width: "100%" }}
options={[
{ value: "video", label: "视频" },
{ value: "voice", label: "配音" },
{ value: "image", label: "图片" },
]}
/>
+37
View File
@@ -587,3 +587,40 @@
grid-template-columns: 1fr;
}
}
/* ─── 上传进度弹窗 ─── */
.xx-upload-progress-modal .ant-modal-content {
padding: 24px 16px 20px;
border-radius: 16px;
}
.xx-upload-progress-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0;
}
.xx-upload-progress-ring {
display: block;
}
.xx-upload-progress-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.xx-upload-progress-pct {
font-size: 22px;
font-weight: 700;
color: var(--primary-color, #6366f1);
line-height: 1;
}
.xx-upload-progress-label {
font-size: 13px;
color: var(--text-secondary, #6b7280);
}
@@ -847,8 +847,8 @@
flex: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
gap: 8px;
padding: 6px 16px;
overflow-x: auto;
overflow-y: hidden;
background: var(--bg-primary, #ffffff);
@@ -864,11 +864,11 @@
border-radius: 3px;
}
/* 水平片段卡片 — 统一高度96px、圆角、hover高亮 */
/* 水平片段卡片 — 统一高度64px、圆角、hover高亮 */
.ep-clip-card {
width: 100px;
min-width: 100px;
height: 96px;
height: 64px;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e2e2e2);
border-radius: var(--radius-sm, 14px);
@@ -903,7 +903,7 @@
}
.ep-clip-thumbnail {
height: 52px;
height: 34px;
background: linear-gradient(
135deg,
var(--bg-secondary, #f8fafc),
@@ -917,7 +917,7 @@
}
.ep-clip-info {
padding: 6px 8px;
padding: 4px 6px;
flex: 1;
display: flex;
flex-direction: column;
@@ -993,7 +993,7 @@
gap: 8px;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
min-height: 96px;
min-height: 64px;
}
.ep-track-empty-icon {
@@ -1033,7 +1033,7 @@
.ep-track-add-card {
min-width: 60px;
height: 96px;
height: 64px;
border: 2px dashed var(--line, #e2e8f0);
border-radius: 12px;
display: flex;
@@ -1064,10 +1064,7 @@
background: none;
}
.ep-track-add-card-wrapper > .ep-add-clip-picker {
top: calc(100% + 6px);
right: 0;
}
/* picker 已改为 Portal 渲染,不再需要相对定位 */
/* 添加片段按钮 + 选择器 */
.ep-add-clip-wrapper {
@@ -1094,81 +1091,139 @@
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* ── "+" 卡片类型+时长选择器 ── */
.ep-add-clip-picker {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 260px;
max-height: 320px;
width: 180px;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--line, #e2e8f0);
border-radius: var(--radius-md, 18px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
overflow: hidden;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* fixed 定位版本 — 不受任何父容器 overflow 裁剪 */
.ep-add-clip-picker--portal {
position: fixed !important;
z-index: 1000;
}
.ep-add-clip-picker-title {
padding: 10px 14px 6px;
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #64748b);
color: var(--text-primary, #1e293b);
padding-bottom: 6px;
border-bottom: 1px solid var(--line, #e2e8f0);
}
.ep-add-clip-picker-empty {
padding: 20px 14px;
text-align: center;
color: var(--text-tertiary, #94a3b8);
font-size: 12px;
}
.ep-add-clip-picker-list {
overflow-y: auto;
max-height: 260px;
padding: 4px 0;
}
.ep-add-clip-picker-item {
.ep-add-clip-type-row,
.ep-add-clip-duration-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
background: none;
border: none;
cursor: pointer;
text-align: left;
}
.ep-add-clip-type-label {
font-size: 12px;
color: var(--text-primary, #0f172a);
transition: background var(--transition-fast, 0.15s ease);
}
.ep-add-clip-picker-item:hover {
background: var(--bg-hover, #f1f5f9);
}
.ep-add-clip-picker-icon {
font-size: 16px;
color: var(--text-secondary, #64748b);
font-weight: 500;
flex-shrink: 0;
min-width: 36px;
}
.ep-add-clip-picker-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
.ep-add-clip-type-btn {
padding: 5px 10px;
border-radius: var(--radius-xs, 8px);
border: 1px solid var(--line, #e2e8f0);
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #64748b);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.ep-add-clip-picker-type {
flex-shrink: 0;
font-size: 10px;
.ep-add-clip-type-btn:hover {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.ep-add-clip-type-btn.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
color: var(--primary, #6366f1);
font-weight: 500;
}
.ep-add-clip-duration-unit {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
background: var(--bg-secondary, #f8fafc);
padding: 2px 6px;
border-radius: 4px;
}
.ep-add-clip-confirm-btn {
width: 100%;
height: 30px;
background: var(--primary, #6366f1);
color: var(--text-inverse, #ffffff);
border: none;
border-radius: var(--radius-xs, 8px);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 4px;
}
.ep-add-clip-confirm-btn:hover {
background: var(--primary-hover, #4f46e5);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
/* ── 片段详情 — 类型选择器 ── */
.ep-clip-type-selector {
display: flex;
gap: 6px;
}
.ep-clip-type-btn {
padding: 5px 12px;
border-radius: var(--radius-xs, 8px);
border: 1px solid var(--line, #e2e8f0);
background: var(--bg-primary, #ffffff);
color: var(--text-secondary, #64748b);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 4px;
}
.ep-clip-type-btn:hover:not(.disabled) {
border-color: var(--primary, #6366f1);
color: var(--primary, #6366f1);
}
.ep-clip-type-btn.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
color: var(--primary, #6366f1);
font-weight: 500;
}
.ep-clip-type-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 一镜到底提示 */
@@ -1385,53 +1440,57 @@
color: var(--primary, #6366f1);
}
/* 标题预设样式按钮方形 T 字预览 */
.ep-title-presets {
display: flex;
/* ── 标题预设样式 — 剪映风格方形网格,示例文字"标题"预览完整效果 ── */
.ep-title-presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.ep-title-preset-btn {
flex: 1;
min-width: 44px;
height: 48px;
background: var(--bg-primary, #ffffff);
.ep-title-preset-card {
aspect-ratio: 1;
background: #ffffff;
border: 2px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-xs, 8px);
color: var(--text-primary, #1e293b);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px 6px;
gap: 4px;
padding: 6px 4px;
transition: all 0.15s ease;
overflow: hidden;
}
.ep-title-preset-btn:hover {
.ep-title-preset-card:hover {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
background: #f8fafc;
transform: translateY(-1px);
}
.ep-title-preset-btn.active {
.ep-title-preset-card.active {
border-color: var(--primary, #6366f1);
background: var(--primary-soft, #eef2ff);
box-shadow: 0 0 0 1px var(--primary, #6366f1);
background: #eef2ff;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
}
.ep-title-preset-t {
.ep-title-preset-preview-text {
line-height: 1.2;
text-align: center;
word-break: keep-all;
white-space: nowrap;
}
.ep-title-preset-card-label {
font-size: 9px;
color: #64748b;
line-height: 1;
font-family: inherit;
text-align: center;
margin-top: auto;
}
.ep-title-preset-label {
font-size: 10px;
color: var(--text-tertiary, #94a3b8);
line-height: 1;
}
.ep-title-preset-btn.active .ep-title-preset-label {
.ep-title-preset-card.active .ep-title-preset-card-label {
color: var(--primary, #6366f1);
font-weight: 500;
}
@@ -1876,6 +1935,128 @@
flex-shrink: 0;
}
/* ═══ 配音素材选择器 ═══ */
.ep-clip-detail-select {
width: 100%;
padding: 6px 8px;
font-size: 12px;
color: var(--text-primary, #e2e8f0);
background: var(--bg-tertiary, #1e293b);
border: 1px solid var(--border-color, #334155);
border-radius: 6px;
outline: none;
cursor: pointer;
transition: border-color 0.15s;
}
.ep-clip-detail-select:hover {
border-color: var(--primary, #6366f1);
}
.ep-clip-detail-select:focus {
border-color: var(--primary, #6366f1);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15);
}
.ep-voice-upload-btn {
width: 100%;
margin-top: 6px;
padding: 6px 0;
font-size: 12px;
color: var(--primary, #6366f1);
background: transparent;
border: 1px dashed var(--primary, #6366f1);
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.ep-voice-upload-btn:hover {
background: rgba(99, 102, 241, 0.08);
}
/* 配音标签行(含刷新按钮) */
.ep-clip-detail-label {
display: flex;
align-items: center;
gap: 6px;
}
.ep-voice-refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
font-size: 11px;
line-height: 1;
color: var(--text-secondary, #94a3b8);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.ep-voice-refresh-btn:hover {
color: var(--primary, #6366f1);
background: rgba(99, 102, 241, 0.1);
}
.ep-voice-refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 配音选择行(select + 试听按钮) */
.ep-voice-select-row {
display: flex;
align-items: center;
gap: 6px;
}
.ep-voice-select-row .ep-clip-detail-select {
flex: 1;
min-width: 0;
}
.ep-voice-preview-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
font-size: 13px;
line-height: 1;
color: var(--text-primary, #e2e8f0);
background: var(--bg-tertiary, #1e293b);
border: 1px solid var(--border-color, #334155);
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.ep-voice-preview-btn:hover {
background: var(--bg-hover, #2d3a4f);
border-color: var(--primary, #6366f1);
}
.ep-voice-loading,
.ep-voice-empty {
padding: 8px 0;
font-size: 12px;
color: var(--text-secondary, #94a3b8);
text-align: center;
}
/* ═══ 预览区 — 补充样式 ═══ */
.ep-phone-empty-hint {
color: rgba(255, 255, 255, 0.5);
@@ -4,13 +4,8 @@
*/
import React, { useState, useCallback, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
// 简易消息工具(UI 库未导出 message)
const msg = {
success: (s: string) => console.log(`${s}`),
error: (s: string) => console.error(`${s}`),
warning: (s: string) => console.warn(`⚠️ ${s}`),
info: (s: string) => console.info(`${s}`),
};
import { message } from "antd";
import { useQuery } from "@tanstack/react-query";
import type {
EditingTemplate,
TemplateCategory,
@@ -20,26 +15,28 @@ import type {
import {
getEditingTemplates,
getEditingTemplate,
createEditingTemplate,
updateEditingTemplate,
getTemplateCategories,
generateFromTemplate,
MODE_LABELS,
} from "@/api/editingPlanner";
import type {
EditPlanClip,
EditPlanGeneration,
MediaAsset,
} from "@/api/editPlans";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
MATERIAL_TYPE_LABELS,
aiRecommendClips,
generateCover,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type { TaskItem } from "@/api/tasks";
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
import type { ClipData, ClipType } from "./types";
import {
ensureDefaultLibrary,
getAssetsByKind,
type AssetItem,
} from "@/api/assets";
import { getOrCreateDefaultProject } from "@/api/projects";
import MediaPanel from "./components/MediaPanel";
import PreviewPlayer from "./components/PreviewPlayer";
@@ -51,14 +48,6 @@ import type { GenPhase } from "./components/GenerationProgressModal";
import GenerationHistoryModal from "./components/GenerationHistoryModal";
import "./EditingPlanner.css";
/* ──────────── 类型 ──────────── */
interface ClipData extends EditPlanClip {
name: string;
thumbnail?: string;
assetName?: string;
}
/* ──────────── 常量 ──────────── */
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
@@ -106,7 +95,7 @@ const EditingPlanner: React.FC = () => {
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
/* ── AI 操作状态 ── */
const [aiRecommendLoading, setAiRecommendLoading] = useState(false);
const [aiCoverLoading, setAiCoverLoading] = useState(false);
/* ── 封面方案 ── */
@@ -119,7 +108,7 @@ const EditingPlanner: React.FC = () => {
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState({
aiAutoSelect: true,
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
@@ -128,6 +117,7 @@ const EditingPlanner: React.FC = () => {
italic: false,
stroke: true,
shadow: true,
color: "#ffffff",
});
const [subtitleSettings, setSubtitleSettings] = useState({
@@ -174,6 +164,37 @@ const EditingPlanner: React.FC = () => {
/* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false);
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
const voiceMaterialsQuery = useQuery({
queryKey: ["assets", "voice"],
queryFn: async () => {
const project = await getOrCreateDefaultProject();
await ensureDefaultLibrary({ project_id: project.id, kind: "voice" });
const assets = await getAssetsByKind("voice");
return assets;
},
staleTime: 30_000,
});
const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [];
/** 为片段选择配音素材 */
const handleClipVoiceSelect = useCallback(
(clipId: string, asset: AssetItem | null) => {
setClips((prev) =>
prev.map((c) =>
c.id === clipId
? {
...c,
voice_asset_id: asset?.id ?? undefined,
voice_file_url: asset?.file_url ?? undefined,
}
: c,
),
);
},
[setClips],
);
/* ──────────── 加载 ──────────── */
/**
@@ -192,7 +213,7 @@ const EditingPlanner: React.FC = () => {
setCategories(cats);
setMediaAssets(assets);
} catch {
msg.error("加载模板失败");
message.error("加载模板失败");
} finally {
setLoadingTemplates(false);
}
@@ -216,14 +237,13 @@ const EditingPlanner: React.FC = () => {
const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({
id: seg.id || `seg-${idx}`,
template_segment_id: seg.id || `seg-${idx}`,
name: `片段${idx + 1} · ${MATERIAL_TYPE_LABELS[seg.material_type || "video"] || "视频"}`,
material_type:
(seg.material_type as ClipData["material_type"]) || "video",
script_text: "",
type: (seg.material_type === "voiceover"
? "voice"
: "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
resetClips(mapped);
@@ -234,6 +254,7 @@ const EditingPlanner: React.FC = () => {
position: tpl.title_config.position,
font: tpl.title_config.font_preset,
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}));
setSubtitleSettings({
enabled: tpl.subtitle_config.enabled,
@@ -249,7 +270,7 @@ const EditingPlanner: React.FC = () => {
setDraftCategory(tpl.category);
setDraftTags(tpl.tags.join(", "));
})
.catch(() => msg.error("加载模板详情失败"));
.catch(() => message.error("加载模板详情失败"));
}, [loadedTemplateId, resetClips]);
/* ──────────── 计算 ──────────── */
@@ -303,91 +324,32 @@ const EditingPlanner: React.FC = () => {
);
};
const handleAssetDropToClip = (clipId: string, asset: MediaAsset) => {
handleClipUpdate(clipId, {
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
});
};
const handleAssetDropToTimeline = (asset: MediaAsset) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
template_segment_id: "",
name: `片段${clips.length + 1} · ${MATERIAL_TYPE_LABELS[asset.type]}`,
material_type:
asset.type === "audio"
? "voiceover"
: asset.type === "image"
? "image"
: "video",
script_text: "",
duration: asset.duration || 5,
order: clips.length,
media_asset_id: asset.id,
assetName: asset.name,
thumbnail: asset.thumbnail_url,
transition: { type: "none", duration: 0 },
};
setClips((prev) => [...prev, newClip]);
};
/**
* AI 推荐片段方案
* 将当前已绑定素材的片段 ID 列表 + 编辑模式 + 目标时长发送给后端
* 返回的推荐结果直接替换当前片段列表(不保留原手动编排)
* 添加片段(不绑定任何素材)
* 片段 = 时间规划 + 类型标记
*/
const handleAiRecommend = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载模板");
return;
}
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
if (assetIds.length === 0) {
msg.warning("请先添加素材到片段");
return;
}
setAiRecommendLoading(true);
try {
const res = await aiRecommendClips(loadedTemplateId, {
asset_ids: assetIds,
editing_mode: currentMode,
target_duration: totalDuration || 30,
});
const mapped: ClipData[] = res.clips.map((c, i) => ({
id: `clip-ai-${i}-${Date.now()}`,
template_segment_id: "",
name: `片段${i + 1} · ${c.clip_type}`,
material_type: (c.clip_type as ClipData["material_type"]) || "video",
script_text: c.text_content || "",
duration: c.duration || 5,
order: c.order ?? i,
media_asset_id: c.asset_id || undefined,
assetName: undefined,
transition: { type: "none", duration: 0 },
}));
setClips(mapped);
msg.success(`AI 推荐了 ${mapped.length} 个片段`);
} catch {
msg.error("AI 推荐失败");
} finally {
setAiRecommendLoading(false);
}
};
const handleAddClip = useCallback(
(type: ClipType, duration: number) => {
const newClip: ClipData = {
id: `clip-${Date.now()}`,
type,
duration,
startOffset: 0,
order: clips.length,
};
setClips((prev) => [...prev, newClip]);
},
[clips.length, setClips],
);
/* AI 封面生成 */
const handleAiGenerateCover = async (
coverType: "ai_frame" | "ai_regenerate",
) => {
if (!loadedTemplateId) return;
const assetIds = clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v);
const assetIds = selectedAssetIds;
if (assetIds.length === 0) {
msg.warning("请先添加素材");
message.warning("请先在素材库中选择素材");
return;
}
setAiCoverLoading(true);
@@ -399,29 +361,24 @@ const EditingPlanner: React.FC = () => {
setCurrentCoverScheme(
coverType === "ai_frame" ? "ai_frame" : "ai_reselect",
);
msg.success("AI 封面生成成功");
message.success("AI 封面生成成功");
} catch {
msg.error("AI 封面生成失败");
message.error("AI 封面生成失败");
} finally {
setAiCoverLoading(false);
}
};
/* 保存 */
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
setSaveModalOpen(true);
};
const handleSave = async () => {
if (!draftName.trim()) {
msg.warning("请输入模板名称");
message.warning("请输入模板名称");
return;
}
if (!loadedTemplateId) return;
setSaveLoading(true);
try {
const payload: SaveTemplatePayload = {
@@ -436,7 +393,7 @@ const EditingPlanner: React.FC = () => {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
font_preset: titleSettings.font,
font_color: "#ffffff",
font_color: titleSettings.color,
font_size: titleSettings.size,
position: titleSettings.position,
},
@@ -457,15 +414,19 @@ const EditingPlanner: React.FC = () => {
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.material_type,
material_type: c.type === "voice" ? "voiceover" : "video",
})),
};
await updateEditingTemplate(loadedTemplateId, payload);
msg.success("模板保存成功");
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload);
} else {
await createEditingTemplate(payload);
}
message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功");
setSaveModalOpen(false);
loadTemplates();
} catch {
msg.error("保存失败");
message.error("保存失败");
} finally {
setSaveLoading(false);
}
@@ -477,16 +438,13 @@ const EditingPlanner: React.FC = () => {
* 一键生成页面从 params 解析配置,无需重复请求接口
*/
const handleGoToGenerate = () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
return;
}
const planConfig = {
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
bold: titleSettings.bold,
italic: titleSettings.italic,
@@ -508,16 +466,18 @@ const EditingPlanner: React.FC = () => {
total_duration: totalDuration,
segments: clips.map((c, i) => ({
order: i,
name: c.name,
material_type: c.material_type,
material_type: c.type === "voice" ? "voiceover" : "video",
duration: c.duration,
media_asset_id: c.media_asset_id,
template_segment_id: c.template_segment_id,
script_text: c.script_text,
voice_asset_id: c.voice_asset_id,
voice_file_url: c.voice_file_url,
})),
};
const params = new URLSearchParams();
params.set("edit_plan_id", loadedTemplateId);
if (loadedTemplateId) {
params.set("edit_plan_id", loadedTemplateId);
}
params.set("plan_config", JSON.stringify(planConfig));
navigate(`/app/generate?${params.toString()}`);
};
@@ -535,21 +495,23 @@ const EditingPlanner: React.FC = () => {
await generateFromTemplate(loadedTemplateId, {
voiceover_duration: voiceoverDuration || totalDuration,
});
// 收集所有 voice 类型片段的配音素材 ID
const voiceIds = clips
.filter((c) => c.type === "voice" && c.voice_asset_id)
.map((c) => c.voice_asset_id as string);
const res = await createGenerationTask({
template_id: loadedTemplateId,
asset_ids: clips
.map((c) => c.media_asset_id)
.filter((v): v is string => !!v),
asset_ids: [],
title_ids: [],
voice_ids: [],
voice_ids: voiceIds,
});
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
const task = await getTask(res.id);
setGenTask(task);
setGenPhase("progress");
msg.info("生成任务已创建");
message.info("生成任务已创建");
} catch {
msg.error("创建生成任务失败");
message.error("创建生成任务失败");
} finally {
setGenSubmitting(false);
}
@@ -588,7 +550,7 @@ const EditingPlanner: React.FC = () => {
setGenTask(t);
setGenPhase("progress");
} catch {
msg.error("重试失败");
message.error("重试失败");
} finally {
setGenSubmitting(false);
}
@@ -604,7 +566,7 @@ const EditingPlanner: React.FC = () => {
/* 查看生成历史 */
const handleViewGenHistory = async () => {
if (!loadedTemplateId) {
msg.warning("请先加载一个模板");
message.warning("请先加载一个模板");
return;
}
setGenHistoryOpen(true);
@@ -613,7 +575,7 @@ const EditingPlanner: React.FC = () => {
const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items);
} catch {
msg.error("加载生成历史失败");
message.error("加载生成历史失败");
} finally {
setGenHistoryLoading(false);
}
@@ -724,9 +686,7 @@ const EditingPlanner: React.FC = () => {
onClipSelect={handleClipSelect}
onClipReorder={handleClipReorder}
onClipRemove={handleClipRemove}
onAssetDropToTimeline={handleAssetDropToTimeline}
onAssetDropToClip={handleAssetDropToClip}
mediaAssets={mediaAssets}
onAddClip={handleAddClip}
/>
</div>
@@ -739,7 +699,6 @@ const EditingPlanner: React.FC = () => {
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
aiRecommendLoading={aiRecommendLoading}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
@@ -750,7 +709,10 @@ const EditingPlanner: React.FC = () => {
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onAiRecommend={handleAiRecommend}
voiceMaterials={voiceMaterials}
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
onClipVoiceSelect={handleClipVoiceSelect}
/>
</div>
@@ -2,21 +2,11 @@
* 右栏设置面板 — V8 原型 1:1 还原
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
*/
import React from "react";
import React, { useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import type { TemplateMode } from "@/api/editingPlanner";
import { MATERIAL_TYPE_LABELS } from "@/api/editPlans";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: "video" | "image" | "audio" | "voiceover";
thumbnail?: string;
assetName?: string;
media_asset_id?: string;
template_segment_id?: string;
script_text?: string;
}
import type { ClipData, ClipType } from "../types";
import type { AssetItem } from "@/api/assets";
interface TitleSettings {
aiAutoSelect: boolean;
@@ -28,6 +18,7 @@ interface TitleSettings {
italic: boolean;
stroke: boolean;
shadow: boolean;
color: string;
}
interface SubtitleSettings {
@@ -50,12 +41,18 @@ interface ClipPropertiesPanelProps {
clipsCount: number;
totalDuration: number;
currentMode: TemplateMode;
aiRecommendLoading: boolean;
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
onAiRecommend: () => void;
/** 配音素材列表(从配音素材库 API 获取) */
voiceMaterials?: AssetItem[];
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean;
/** 刷新配音素材列表 */
onRefreshVoiceMaterials?: () => void;
/** 为片段选择配音素材 */
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
}
const POSITION_OPTIONS = [
@@ -64,7 +61,15 @@ const POSITION_OPTIONS = [
{ value: "bottom", label: "底部" },
];
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑"];
const FONT_OPTIONS = [
"思源黑体",
"思源宋体",
"苹方",
"PingFang",
"微软雅黑",
"楷体",
"华康俪金黑",
];
const ANIMATION_OPTIONS = [
{ value: "none", label: "无" },
@@ -82,61 +87,163 @@ const BGM_OPTIONS = [
];
/**
* 标题样式预设 — 一键应用一组样式
* 注意:预设只绑定样式属性(粗细、描边、阴影、字号),不绑定字体家族
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
* 不绑定字体,用户可自由搭配任意字体
* 预览统一用系统字体展示效果
*/
const TITLE_PRESETS = [
{
key: "default",
label: "默认",
key: "classic_white",
label: "经典白字",
style: {
size: 24,
bold: false,
italic: false,
stroke: false,
shadow: false,
},
},
{
key: "classic",
label: "经典",
style: {
size: 24,
size: 28,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
shadow: false,
},
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "bold",
label: "醒目",
key: "black_gold",
label: "黑金质感",
style: {
size: 32,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: {
size: 24,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
fontSize: "18px",
},
},
{
key: "variety_show",
label: "综艺花字",
style: {
size: 36,
color: "#ff4081",
bold: true,
italic: false,
stroke: true,
shadow: true,
},
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "elegant",
label: "柔和",
key: "business",
label: "商务极简",
style: {
size: 20,
size: 24,
color: "#1a1a1a",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#1a1a1a",
fontSize: "17px",
},
},
{
key: "retro_film",
label: "复古胶片",
style: {
size: 28,
color: "#e8d5b7",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: {
size: 32,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow:
"0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: {
size: 28,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
];
/** 判断当前设置匹配哪个预设(不含 font 字段 */
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体 */
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
@@ -148,6 +255,13 @@ function getActivePreset(settings: TitleSettings): string | null {
return null;
}
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" };
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
titleSettings,
@@ -156,13 +270,50 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
clipsCount,
totalDuration,
currentMode,
aiRecommendLoading,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onAiRecommend,
voiceMaterials = [],
voiceMaterialsLoading = false,
onRefreshVoiceMaterials,
onClipVoiceSelect,
}) => {
const navigate = useNavigate();
/* ── 配音试听 ── */
const audioRef = useRef<HTMLAudioElement | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
/** 试听配音素材 */
const handlePreviewVoice = useCallback(
(asset: AssetItem) => {
// 点击同一个 → 暂停
if (previewingId === asset.id) {
audioRef.current?.pause();
setPreviewingId(null);
return;
}
// 停止上一个
audioRef.current?.pause();
const url = asset.file_url || (asset.metadata?.preview_url as string);
if (!url) return;
const audio = new Audio(url);
audioRef.current = audio;
audio.play().catch(() => {});
audio.onended = () => setPreviewingId(null);
setPreviewingId(asset.id);
},
[previewingId],
);
/** 从 metadata 取性别标签 */
const getGenderLabel = (m: AssetItem): string => {
const g = (m.metadata?.gender as string) || "";
if (g === "male") return "男";
if (g === "female") return "女";
return "";
};
return (
<div className="ep-right-panel">
{/* ═══ 标题设置 ═══ */}
@@ -241,38 +392,34 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-title-presets">
<div className="ep-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key;
return (
<button
key={p.key}
className={`ep-title-preset-btn${isActive ? " active" : ""}`}
onClick={() => onTitleSettingsChange({ ...p.style })}
className={`ep-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
onTitleSettingsChange({
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
})
}
title={p.label}
>
<span
className="ep-title-preset-t"
style={{
fontWeight: p.style.bold ? 700 : 400,
fontStyle: p.style.italic ? "italic" : "normal",
WebkitTextStroke: p.style.stroke
? "1px currentColor"
: undefined,
textShadow: p.style.shadow
? "2px 2px 4px rgba(0,0,0,0.5)"
: undefined,
fontSize:
p.style.size >= 32
? "22px"
: p.style.size >= 24
? "18px"
: "16px",
}}
className="ep-title-preset-preview-text"
style={p.previewStyle}
>
T
</span>
<span className="ep-title-preset-card-label">
{p.label}
</span>
<span className="ep-title-preset-label">{p.label}</span>
</button>
);
})}
@@ -452,15 +599,36 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
<div className="ep-clip-detail">
<div className="ep-clip-detail-header">
<span className="ep-clip-detail-name">{selectedClip.name}</span>
<span className="ep-clip-detail-type">
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
</span>
{/* 类型选择器 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-type-selector">
{(["voice", "pip"] as ClipType[]).map((t) => {
const disabled =
currentMode === "pip"
? t !== "pip"
: currentMode === "voice_over"
? t !== "voice"
: false; // voice_pip 可切换
return (
<button
key={t}
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
disabled={disabled}
onClick={() =>
!disabled && onClipUpdate(selectedClip.id, { type: t })
}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
);
})}
</div>
</div>
{/* 时长 */}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
@@ -470,7 +638,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
value={selectedClip.duration}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
duration: Number(e.target.value),
duration: Math.max(
1,
Math.min(120, Number(e.target.value) || 1),
),
})
}
/>
@@ -478,31 +649,127 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
</div>
{selectedClip.assetName && (
{/* 素材起始时间 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
<span className="ep-clip-detail-linked">
{selectedClip.assetName}
</span>
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-row">
<input
className="ep-duration-input"
type="number"
min={0}
max={9999}
step={0.1}
value={selectedClip.startOffset}
onChange={(e) =>
onClipUpdate(selectedClip.id, {
startOffset: Math.max(
0,
Math.min(9999, Number(e.target.value) || 0),
),
})
}
/>
<span className="ep-clip-detail-unit"></span>
</div>
</div>
)}
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{MATERIAL_TYPE_LABELS[selectedClip.material_type] || "视频"}
</div>
</div>
{/* 配音素材选择 — 仅 voice 类型显示 */}
{selectedClip.type === "voice" && (
<div className="ep-clip-detail-field">
<div className="ep-clip-detail-label">
{onRefreshVoiceMaterials && (
<button
type="button"
className="ep-voice-refresh-btn"
title="刷新配音列表"
onClick={() => onRefreshVoiceMaterials()}
disabled={voiceMaterialsLoading}
>
{voiceMaterialsLoading ? "⏳" : "🔄"}
</button>
)}
</div>
<button
className="ep-ai-recommend-btn"
onClick={onAiRecommend}
disabled={aiRecommendLoading}
>
{aiRecommendLoading ? "⏳ AI 分析中..." : "✨ AI 推荐素材"}
</button>
{voiceMaterialsLoading && voiceMaterials.length === 0 ? (
<div className="ep-voice-loading">...</div>
) : (
<>
<div className="ep-voice-select-row">
<select
className="ep-clip-detail-select"
value={selectedClip.voice_asset_id ?? ""}
onChange={(e) => {
const assetId = e.target.value;
if (!onClipVoiceSelect) return;
if (!assetId) {
onClipVoiceSelect(selectedClip.id, null);
} else {
const asset = voiceMaterials.find(
(m) => m.id === assetId,
);
if (asset)
onClipVoiceSelect(selectedClip.id, asset);
}
// 切换选择时停止试听
audioRef.current?.pause();
setPreviewingId(null);
}}
>
<option value=""></option>
{voiceMaterials.map((m) => {
const gender = getGenderLabel(m);
const label = gender
? `${m.name}${gender}`
: m.name;
return (
<option key={m.id} value={m.id}>
{label}
</option>
);
})}
</select>
{/* 试听按钮 */}
{selectedClip.voice_asset_id && (
<button
type="button"
className="ep-voice-preview-btn"
title={
previewingId === selectedClip.voice_asset_id
? "暂停"
: "试听"
}
onClick={() => {
const asset = voiceMaterials.find(
(m) => m.id === selectedClip.voice_asset_id,
);
if (asset) handlePreviewVoice(asset);
}}
>
{previewingId === selectedClip.voice_asset_id
? "⏸"
: "▶️"}
</button>
)}
</div>
{voiceMaterials.length === 0 && (
<div className="ep-voice-empty">
</div>
)}
</>
)}
<button
className="ep-voice-upload-btn"
onClick={() => navigate("/app/voice-materials")}
>
+
</button>
</div>
)}
</div>
</div>
)}
@@ -4,15 +4,7 @@
* 封面右侧竖排4个方案按钮
*/
import React from "react";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: string;
thumbnail?: string;
assetName?: string;
}
import type { ClipData, ClipType } from "../types";
interface CoverScheme {
key: string;
@@ -54,11 +46,14 @@ interface PreviewPlayerProps {
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void;
}
const MATERIAL_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙️",
pip: "🖼️",
};
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
@@ -97,7 +92,10 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
style={{ width: isPlaying ? "45%" : "0%" }}
/>
</div>
<div className="ep-phone-clip-label">{displayClip.name}</div>
<div className="ep-phone-clip-label">
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}{" "}
{CLIP_TYPE_LABELS[displayClip.type] || "片段"}
</div>
</>
) : (
<span className="ep-phone-empty-hint"></span>
@@ -168,7 +166,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
<div className="ep-cover-image">
{displayClip ? (
<span className="ep-cover-icon">
{MATERIAL_ICONS[displayClip.material_type] || "🎬"}
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}
</span>
) : (
<span></span>
@@ -1,23 +1,16 @@
/**
* 水平轨道时间线 — V8 原型 1:1 还原
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
* 支持「添加片段」按钮直接选择素材添加,不依赖拖拽
* "+" 卡片 → 类型+时长选择器
*/
import React, { useState, useRef } from "react";
import type { MediaAsset } from "@/api/editPlans";
interface ClipData {
id: string;
name: string;
duration: number;
material_type: string;
thumbnail?: string;
assetName?: string;
media_asset_id?: string;
template_segment_id?: string;
script_text?: string;
order?: number;
}
import React, {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
} from "react";
import type { ClipData, ClipType } from "../types";
interface TimelinePanelProps {
clips: ClipData[];
@@ -26,22 +19,19 @@ interface TimelinePanelProps {
onClipSelect: (clipId: string) => void;
onClipReorder: (fromIdx: number, toIdx: number) => void;
onClipRemove: (clipId: string) => void;
onAssetDropToTimeline: (asset: MediaAsset) => void;
onAssetDropToClip: (clipId: string, asset: MediaAsset) => void;
mediaAssets: MediaAsset[];
onAddClip: (type: ClipType, duration: number) => void;
}
const ASSET_TYPE_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼",
audio: "🎵",
/** 片段类型图标 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
voice: "🎙",
pip: "🖼️",
};
const MATERIAL_ICONS: Record<string, string> = {
video: "🎬",
image: "🖼️",
audio: "🎵",
voiceover: "🎙️",
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "画中画",
};
const TimelinePanel: React.FC<TimelinePanelProps> = ({
@@ -51,18 +41,96 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
onClipSelect,
onClipReorder,
onClipRemove,
onAssetDropToTimeline,
onAssetDropToClip,
mediaAssets,
onAddClip,
}) => {
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const dragRef = useRef<number | null>(null);
const [showAddPicker, setShowAddPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
const addCardRef = useRef<HTMLDivElement>(null);
const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({
top: 0,
right: 0,
});
/* ── 点击外部关闭素材选择器 ── */
React.useEffect(() => {
/* ── "+" 卡片:类型+时长选择状态 ── */
const [addType, setAddType] = useState<ClipType>("voice");
const [addDuration, setAddDuration] = useState<number>(5);
/* ── 根据模式决定可选类型 ── */
const availableTypes: ClipType[] =
currentMode === "voice_over"
? ["voice"]
: currentMode === "pip"
? ["pip"]
: ["voice", "pip"]; // voice_pip 或默认
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
const GAP = 6; // 面板与"+"卡片的间距
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
const updatePickerPosition = useCallback(() => {
if (!addCardRef.current) return;
const rect = addCardRef.current.getBoundingClientRect();
const vw = window.innerWidth;
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
let top = rect.top - GAP - roughHeight;
if (top < 8) top = 8;
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
let right = vw - rect.right;
if (rect.right - PICKER_W < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, []);
const handleTogglePicker = () => {
if (!showAddPicker) {
updatePickerPosition();
}
setShowAddPicker((v) => !v);
};
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
useLayoutEffect(() => {
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
const pickerEl = pickerRef.current;
const addRect = addCardRef.current.getBoundingClientRect();
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
const vh = window.innerHeight;
const vw = window.innerWidth;
/* 默认:面板在"+"按钮上方 */
let top = addRect.top - GAP - pickerH;
/* 上方空间也不够(极端情况)→ 翻转到下方 */
if (top < 8) {
top = addRect.bottom + GAP;
/* 下方也溢出 → clamp */
if (top + pickerH > vh - 8) {
top = vh - 8 - pickerH;
if (top < 8) top = 8;
}
}
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
let right = vw - addRect.right;
const pickerRect = pickerEl.getBoundingClientRect();
if (pickerRect.left < 8) {
right = vw - PICKER_W - 8;
}
setPickerPos({ top, right });
}, [showAddPicker]);
/* ── 点击外部关闭添加面板 ── */
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
setShowAddPicker(false);
@@ -74,9 +142,9 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddPicker]);
/* ── 选择素材添加片段 ── */
const handlePickAsset = (asset: MediaAsset) => {
onAssetDropToTimeline(asset);
/* ── 确认添加片段 ── */
const handleConfirmAdd = () => {
onAddClip(addType, addDuration);
setShowAddPicker(false);
};
@@ -109,37 +177,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
if (fromIdx !== toIdx) {
onClipReorder(fromIdx, toIdx);
}
return;
}
// 素材拖到片段上
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDropToClip(clips[toIdx].id, asset);
} catch {
/* ignore */
}
}
};
/* ── 空轨道区域拖入 ── */
const handleEmptyDrop = (e: React.DragEvent) => {
e.preventDefault();
const assetJson = e.dataTransfer.getData("application/x-media-asset");
if (assetJson) {
try {
const asset: MediaAsset = JSON.parse(assetJson);
onAssetDropToTimeline(asset);
} catch {
/* ignore */
}
}
};
/* ── 空轨道区域不接受素材拖入 ── */
const handleEmptyDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
/* ── 时间标尺 ── */
@@ -187,135 +230,149 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
{/* 一镜到底提示 */}
{currentMode === "one_take" && (
<div className="ep-one-take-hint">
🎥
</div>
<div className="ep-one-take-hint">🎥 </div>
)}
{/* 时间标尺 */}
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
{t}s
</span>
))}
</div>
</div>
{/* 水平片段轨道 */}
<div
className="ep-clip-track"
onDrop={handleEmptyDrop}
onDragOver={handleEmptyDragOver}
>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text">
+
</div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 缩略图区域 */}
<div className="ep-clip-thumbnail">
{MATERIAL_ICONS[clip.material_type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">{clip.name}</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
{currentMode !== "one_take" && (
<div className="ep-time-ruler">
<div
className="ep-time-ruler-inner"
style={{ width: Math.max(clips.length * 108, 300) }}
>
{rulerMarks.map((t) => (
<span
key={t}
className="ep-time-mark"
style={{
left:
totalDuration > 0
? `${(t / totalDuration) * clips.length * 108}px`
: `${t * 20}px`,
}}
>
</button>
</div>
))
)}
{t}s
</span>
))}
</div>
</div>
)}
{/* ── 轨道末尾 "+" 添加卡片(对齐 V21 原型 epAddSegment ── */}
<div className="ep-track-add-card-wrapper" ref={pickerRef}>
<div
className={`ep-track-add-card${currentMode === "one_take" ? " locked" : ""}`}
onClick={() => {
if (currentMode !== "one_take") setShowAddPicker((v) => !v);
}}
title={
currentMode === "one_take" ? "一镜到底模式下不可添加" : "添加片段"
}
>
{currentMode === "one_take" ? "🔒" : "+"}
{/* 水平片段轨道 */}
{currentMode !== "one_take" && (
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
{clips.length === 0 ? (
<div className="ep-track-empty">
<div className="ep-track-empty-icon">🎬</div>
<div className="ep-track-empty-text"> + </div>
</div>
) : (
clips.map((clip, idx) => (
<div
key={clip.id}
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
draggable
onDragStart={(e) => handleDragStart(e, idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragEnd={handleDragEnd}
onDrop={(e) => handleDrop(e, idx)}
onClick={() => onClipSelect(clip.id)}
>
{/* 类型图标 */}
<div className="ep-clip-thumbnail">
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
</div>
{/* 片段信息 */}
<div className="ep-clip-info">
<span className="ep-clip-name">
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
</span>
<span className="ep-clip-duration">{clip.duration}s</span>
</div>
{/* 删除按钮 */}
<button
className="ep-clip-remove"
onClick={(e) => {
e.stopPropagation();
onClipRemove(clip.id);
}}
>
</button>
</div>
))
)}
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
<div className="ep-track-add-card-wrapper">
<div
ref={addCardRef}
className="ep-track-add-card"
onClick={handleTogglePicker}
title="添加片段"
>
+
</div>
</div>
</div>
)}
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
{showAddPicker && (
<div
ref={pickerRef}
className="ep-add-clip-picker ep-add-clip-picker--portal"
style={{
position: "fixed",
top: pickerPos.top,
right: pickerPos.right,
}}
>
<div className="ep-add-clip-picker-title"></div>
{/* 类型选择 */}
<div className="ep-add-clip-type-row">
<span className="ep-add-clip-type-label">:</span>
{availableTypes.map((t) => (
<button
key={t}
className={`ep-add-clip-type-btn${addType === t ? " active" : ""}`}
onClick={() => setAddType(t)}
>
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
</button>
))}
</div>
{/* 素材选择面板 */}
{showAddPicker && (
<div className="ep-add-clip-picker">
<div className="ep-add-clip-picker-title"></div>
{mediaAssets.length === 0 ? (
<div className="ep-add-clip-picker-empty">
</div>
) : (
<div className="ep-add-clip-picker-list">
{mediaAssets.map((asset) => (
<button
key={asset.id}
className="ep-add-clip-picker-item"
onClick={() => handlePickAsset(asset)}
>
<span className="ep-add-clip-picker-icon">
{ASSET_TYPE_ICONS[asset.type] || "📁"}
</span>
<span className="ep-add-clip-picker-name">
{asset.name}
</span>
<span className="ep-add-clip-picker-type">
{asset.type === "image"
? "图片"
: asset.type === "audio"
? "音频"
: "视频"}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* 时长输入 */}
<div className="ep-add-clip-duration-row">
<span className="ep-add-clip-type-label">:</span>
<input
type="number"
className="ep-duration-input"
min={1}
max={120}
value={addDuration}
onChange={(e) =>
setAddDuration(
Math.max(1, Math.min(120, Number(e.target.value) || 1)),
)
}
/>
<span className="ep-add-clip-duration-unit"></span>
</div>
{/* 确认按钮 */}
<button
className="ep-add-clip-confirm-btn"
onClick={handleConfirmAdd}
>
</button>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,21 @@
/**
* 片段(Clip)统一类型定义
* 片段 = 时间规划 + 类型标记,不绑定任何素材
*/
export type ClipType = "voice" | "pip";
export interface ClipData {
id: string;
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
duration: number; // 时长(秒)
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
// 保留兼容字段(后端序列化需要)
template_segment_id?: string;
script_text?: string;
order?: number;
/** 配音素材 ID(voice 类型片段使用) */
voice_asset_id?: string;
/** 配音素材文件 URL(voice 类型片段使用) */
voice_file_url?: string;
}
+600 -152
View File
@@ -6,21 +6,27 @@
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Typography, message } from "antd";
import { Typography, message, Select } from "antd";
import {
AudioOutlined,
ThunderboltOutlined,
CheckCircleFilled,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
DownloadOutlined,
ShareAltOutlined,
SaveOutlined,
PlusOutlined,
MinusOutlined,
CloseOutlined,
} from "@ant-design/icons";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
import { getEditingTemplates } from "@/api/editingPlanner";
import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
import { getTitles } from "@/api/titles";
import apiClient from "@/api/client";
import { fetchPresetVoices } from "@/api/voices";
@@ -28,9 +34,10 @@ import type { PresetVoiceItem } from "@/api/voices";
import { formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import CloneModal from "@/components/voice/CloneModal";
import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts";
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
import { getTags, createTag } from "@/api/tags";
import { useCloneProgress } from "@/hooks/useCloneProgress";
import { useSearchParams } from "react-router-dom";
import { useSearchParams, useNavigate } from "react-router-dom";
import { getEditPlan } from "@/api/editPlans";
import "./generate.css";
@@ -50,27 +57,14 @@ const MODE_GRADIENTS: Record<string, string> = {
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
};
const MODE_ABBRS: Record<string, string> = {
pip: "PIP",
one_take: "ONE",
voice_over: "VOI",
voice_pip: "VP",
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
const VOICE_GENDER_ICON: Record<string, string> = {
female: "🎀",
male: "🎙️",
child: "🧒",
neutral: "✨",
};
/* ── 配音预设卡片 ── */
interface VoicePresetCard {
id: string;
label: string;
desc: string;
icon: string;
}
const VOICE_PRESET_CARDS: VoicePresetCard[] = [
{ id: "gentle-female", label: "温柔女声", desc: "轻柔自然", icon: "🎀" },
{ id: "pro-male", label: "专业男声", desc: "沉稳大气", icon: "🎙️" },
{ id: "energetic-female", label: "活力女声", desc: "活力四射", icon: "✨" },
];
/* ── 时间线 Mock ──
* TODO: 后端暂无时间线场景数据 API,当前使用硬编码预览数据。
* 待后端提供 timeline/scene 接口后替换为真实 API 调用。
@@ -103,6 +97,8 @@ const STEPS = [
================================================================ */
const GeneratePage: React.FC = () => {
const navigate = useNavigate();
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1);
@@ -122,6 +118,8 @@ const GeneratePage: React.FC = () => {
/* ── 素材 ── */
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual");
/* ── 标题 ── */
const [title, setTitle] = useState("");
@@ -146,6 +144,9 @@ const GeneratePage: React.FC = () => {
);
const [customVoiceText, setCustomVoiceText] = useState("");
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1);
/* ── 克隆声音 ── */
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
const [cloneModalOpen, setCloneModalOpen] = useState(false);
@@ -162,6 +163,7 @@ const GeneratePage: React.FC = () => {
const [generating, setGenerating] = useState(false);
const [progress, setProgress] = useState(0);
const [generated, setGenerated] = useState(false);
const [generateError, setGenerateError] = useState<string | null>(null);
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -231,6 +233,23 @@ const GeneratePage: React.FC = () => {
const [customAudioUrl, setCustomAudioUrl] = useState<string | null>(null);
const [ttsError, setTtsError] = useState<string | null>(null);
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
/** 合成完成后保留的 job ID,用于"存为素材" */
const [completedTtsJobId, setCompletedTtsJobId] = useState<string | null>(
null,
);
/* ── 存为素材弹窗状态 ── */
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [saveName, setSaveName] = useState("");
const [saveTagIds, setSaveTagIds] = useState<string[]>([]);
const [saveNewTag, setSaveNewTag] = useState("");
/* ── 标签列表(用于存为素材弹窗) ── */
const { data: allTags = [] } = useQuery({
queryKey: ["generate-save-tags"],
queryFn: getTags,
staleTime: 30_000,
});
/* ── 素材数据 API ── */
const { data: libraries = [] } = useQuery({
@@ -316,6 +335,7 @@ const GeneratePage: React.FC = () => {
if (cancelled) return;
if (status.status === "completed") {
setCustomAudioUrl(status.output_audio_url);
setCompletedTtsJobId(ttsJobId);
setTtsJobId(null);
setTtsError(null);
message.success("语音合成完成!");
@@ -357,12 +377,98 @@ const GeneratePage: React.FC = () => {
});
}, [customVoiceText, selectedVoice, synthesizeMutation]);
/* ── 存为素材 mutation ── */
const saveToLibraryMutation = useMutation({
mutationFn: (params: { name?: string; tag_ids?: string[] }) =>
saveTtsToLibrary(completedTtsJobId!, params),
onSuccess: () => {
message.success({
content: (
<span>
{" "}
<a
onClick={handleGoToLibrary}
style={{
color: "var(--primary-500, #6366f1)",
cursor: "pointer",
}}
>
</a>
</span>
),
duration: 5,
});
setSaveModalOpen(false);
setSaveName("");
setSaveTagIds([]);
setSaveNewTag("");
setCompletedTtsJobId(null);
setCustomAudioUrl(null);
},
onError: (err: Error) => {
message.error(`保存失败:${err.message || "请重试"}`);
},
});
/** 打开存为素材弹窗 */
const handleOpenSaveModal = useCallback(() => {
setSaveName("");
setSaveTagIds([]);
setSaveNewTag("");
setSaveModalOpen(true);
}, []);
/** 确认保存 */
const handleConfirmSave = useCallback(() => {
if (!completedTtsJobId) return;
saveToLibraryMutation.mutate({
name: saveName.trim() || undefined,
tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined,
});
}, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]);
/** 在弹窗中新增标签(先创建再选中) */
const handleAddTagInModal = useCallback(
async (tagName: string) => {
const trimmed = tagName.trim();
if (!trimmed) return;
/* 已在选中列表则跳过 */
const existing = allTags.find((t) => t.name === trimmed);
if (existing) {
if (!saveTagIds.includes(existing.id)) {
setSaveTagIds((prev) => [...prev, existing.id]);
}
return;
}
try {
const created = await createTag(trimmed);
setSaveTagIds((prev) => [...prev, created.id]);
setSaveNewTag("");
} catch {
message.error(`创建标签"${trimmed}"失败`);
}
},
[allTags, saveTagIds],
);
/** 保存成功后跳转到素材库 */
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials");
}, [navigate]);
const handleGenerate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
title,
selectedTemplate,
selectedMaterials,
voiceMode,
});
if (!title.trim()) {
message.warning("请先选择或输入标题");
return;
}
if (selectedMaterials.length === 0) {
if (materialMode === "manual" && selectedMaterials.length === 0) {
message.warning("请至少选择一个素材");
return;
}
@@ -375,6 +481,7 @@ const GeneratePage: React.FC = () => {
setGenerating(true);
setProgress(0);
setGenerated(false);
setGenerateError(null);
try {
const voiceConfig: Record<string, unknown> = {};
@@ -400,6 +507,8 @@ const GeneratePage: React.FC = () => {
duration,
auto_subtitles: autoSubtitles,
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
@@ -433,6 +542,7 @@ const GeneratePage: React.FC = () => {
)?.error_message ||
"视频生成失败,请联系管理员或重试";
console.error("[生成失败] planId:", plan.id, "响应:", data);
setGenerateError(errorMsg);
message.error(errorMsg);
return;
}
@@ -462,20 +572,36 @@ const GeneratePage: React.FC = () => {
typeof setInterval
>;
} catch (err: unknown) {
console.error("生成失败:", err);
console.error("[handleGenerate] 生成失败:", err);
setGenerating(false);
// 提取 axios 响应中的后端错误信息
const axiosErr = err as {
response?: {
data?: { message?: string; error?: string; detail?: string };
data?: {
message?: string;
error?: string;
detail?: string;
msg?: string;
};
};
message?: string;
};
const backendMsg =
axiosErr.response?.data?.message ||
axiosErr.response?.data?.error ||
axiosErr.response?.data?.detail ||
axiosErr.response?.data?.msg ||
axiosErr.message ||
"";
message.error(backendMsg || "生成失败,请重试");
console.error(
"[handleGenerate] 错误信息:",
backendMsg,
"完整错误:",
axiosErr,
);
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
setGenerateError(errorMsg);
message.error(errorMsg);
}
}, [
title,
@@ -492,6 +618,8 @@ const GeneratePage: React.FC = () => {
bgm,
editPlanId,
selectedTemplate,
generateCount,
materialMode,
]);
/* ── 步骤导航 ── */
@@ -500,7 +628,11 @@ const GeneratePage: React.FC = () => {
message.warning("请先选择一个模板");
return;
}
if (currentStep === 2 && selectedMaterials.length === 0) {
if (
currentStep === 2 &&
materialMode === "manual" &&
selectedMaterials.length === 0
) {
message.warning("请至少选择一个素材");
return;
}
@@ -511,7 +643,13 @@ const GeneratePage: React.FC = () => {
if (currentStep < 5) {
setCurrentStep((s) => s + 1);
}
}, [currentStep, selectedTemplate, selectedMaterials.length, title]);
}, [
currentStep,
selectedTemplate,
selectedMaterials.length,
title,
materialMode,
]);
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -571,11 +709,10 @@ const GeneratePage: React.FC = () => {
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
}}
>
{MODE_ABBRS[tpl.mode] || "TPL"}
🎬
</div>
<h4>{tpl.name}</h4>
<p>
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
{tpl.estimated_duration}s · {tpl.segments.length}
</p>
{tpl.tags.length > 0 && (
@@ -610,11 +747,31 @@ const GeneratePage: React.FC = () => {
</div>
);
/** 步骤 2:选择素材 */
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
const renderStep2 = () => (
<div className="xx-form-section">
<h3>📦 </h3>
<div className="xx-form-field">
{/* ── 模式切换 Tab ── */}
<div className="xx-material-mode-tabs">
<button
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
onClick={() => setMaterialMode("manual")}
type="button"
>
</button>
<button
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
onClick={() => setMaterialMode("auto")}
type="button"
>
</button>
</div>
{/* ── 素材库选择(两种模式共用) ── */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<select
value={selectedLibraryId}
@@ -627,90 +784,138 @@ const GeneratePage: React.FC = () => {
))}
</select>
</div>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 13 }}>
</Text>
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : materials.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
{/* ── 手动选择模式 ── */}
{materialMode === "manual" && (
<>
<div
style={{
marginTop: 14,
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span className="xx-pill xx-pill-ok">
{selectedMaterials.length}
</span>
</div>
)}
</div>
{/* 素材列表 */}
<div style={{ marginTop: 14 }}>
{materialsLoading ? (
<Text
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
>
</Text>
) : materials.length === 0 ? (
<Text
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{materials.map((m) => {
const checked = selectedMaterials.includes(m.id);
return (
<label
key={m.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: checked
? "var(--primary-soft, #eef2ff)"
: "#f8fafc",
borderRadius: 10,
cursor: "pointer",
border: checked
? "1px solid var(--primary-color, #4f46e5)"
: "1px solid transparent",
transition: "all 0.15s ease",
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedMaterials((prev) =>
prev.includes(m.id)
? prev.filter((id) => id !== m.id)
: [...prev, m.id],
);
}}
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
/>
<span
style={{
fontSize: 13,
color: "var(--text-primary)",
flex: 1,
}}
>
{m.name}
</span>
<span
style={{
fontSize: 11,
color: "var(--text-tertiary, #94a3b8)",
}}
>
{m.mime_type.split("/")[1].toUpperCase()}
</span>
</label>
);
})}
</div>
)}
</div>
</>
)}
{/* ── 自动匹配模式 ── */}
{materialMode === "auto" && (
<div className="xx-auto-match-card">
<div className="xx-auto-match-icon">🤖</div>
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title"></h4>
<p className="xx-auto-match-desc">
AI
</p>
<div className="xx-auto-match-features">
<span className="xx-auto-match-feature">📊 </span>
<span className="xx-auto-match-feature">🎯 </span>
<span className="xx-auto-match-feature"> </span>
</div>
</div>
{materialsLoading ? (
<Text
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 8,
}}
>
</Text>
) : (
<Text
style={{
color: "var(--text-tertiary, #94a3b8)",
fontSize: 12,
marginTop: 8,
}}
>
{materials.length}
</Text>
)}
</div>
)}
</div>
);
@@ -720,14 +925,30 @@ const GeneratePage: React.FC = () => {
<h3>📝 </h3>
<div className="xx-form-field">
<label></label>
<select value={title} onChange={(e) => setTitle(e.target.value)}>
<option value=""></option>
{userTitles.map((t) => (
<option key={t.id} value={t.content}>
{t.content}
</option>
))}
</select>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={title || undefined}
onChange={(val) => setTitle(val || "")}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "")
.toLowerCase()
.includes(input.toLowerCase())
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label></label>
@@ -753,35 +974,47 @@ const GeneratePage: React.FC = () => {
<div className="xx-form-section">
<h3>🎙 </h3>
{/* 配音方式选择卡片 */}
{/* 配音方式选择卡片 — 从 API 预设音色动态生成 */}
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
{VOICE_PRESET_CARDS.map((card) => (
{presetVoices.slice(0, 3).map((v) => (
<div
key={card.id}
className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === card.id ? "selected" : ""}`}
key={v.voice_id}
className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""}`}
onClick={() => {
setVoiceMode("preset");
setSelectedVoice(card.id);
setSelectedVoice(v.voice_id);
}}
role="button"
tabIndex={0}
aria-pressed={voiceMode === "preset" && selectedVoice === card.id}
aria-pressed={
voiceMode === "preset" && selectedVoice === v.voice_id
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setVoiceMode("preset");
setSelectedVoice(card.id);
setSelectedVoice(v.voice_id);
}
}}
>
<span className="xx-voice-choice-check"></span>
<div className="xx-voice-choice-avatar">{card.icon}</div>
<div className="xx-voice-choice-avatar">
{VOICE_GENDER_ICON[v.gender] ?? "✨"}
</div>
<div className="xx-voice-choice-info">
<h4>{card.label}</h4>
<p>{card.desc}</p>
<h4>{v.name}</h4>
<p>{v.description}</p>
</div>
</div>
))}
{presetVoicesLoading && (
<div className="xx-voice-choice-item" style={{ opacity: 0.5 }}>
<div className="xx-voice-choice-avatar"></div>
<div className="xx-voice-choice-info">
<h4></h4>
</div>
</div>
)}
{/* 克隆我的声音 */}
<div
className={`xx-voice-choice-item ${voiceMode === "clone" ? "selected" : ""}`}
@@ -904,16 +1137,136 @@ const GeneratePage: React.FC = () => {
{ttsError}
</Text>
)}
{customAudioUrl && (
<Text
{customAudioUrl && completedTtsJobId && (
<div
style={{
color: "var(--success, #10b981)",
marginTop: 8,
display: "block",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
</Text>
<Text style={{ color: "var(--success, #10b981)" }}>
</Text>
<button
className="xx-btn xx-btn-primary"
style={{ height: 30, padding: "0 14px", fontSize: 12 }}
onClick={handleOpenSaveModal}
>
<SaveOutlined />
</button>
</div>
)}
{/* ── 存为素材弹窗 ── */}
{saveModalOpen && (
<div
className="xx-save-modal-overlay"
onClick={() => setSaveModalOpen(false)}
>
<div
className="xx-save-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="xx-save-modal-header">
<span></span>
<button
className="xx-save-modal-close"
onClick={() => setSaveModalOpen(false)}
>
<CloseOutlined />
</button>
</div>
<div className="xx-save-modal-body">
<label className="xx-save-modal-label"></label>
<input
className="xx-save-modal-input"
placeholder="留空则自动生成名称"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
maxLength={50}
/>
<label className="xx-save-modal-label">
<span
style={{
fontWeight: 400,
color: "var(--text-tertiary, #94a3b8)",
}}
>
</span>
</label>
<div className="xx-save-modal-tags">
{saveTagIds.map((id) => {
const tag = allTags.find((t) => t.id === id);
return tag ? (
<span key={id} className="xx-save-modal-tag active">
{tag.name}
<CloseOutlined
className="xx-save-modal-tag-remove"
onClick={() =>
setSaveTagIds((prev) =>
prev.filter((x) => x !== id),
)
}
/>
</span>
) : null;
})}
<input
className="xx-save-modal-tag-input"
placeholder="输入标签名回车添加"
value={saveNewTag}
onChange={(e) => setSaveNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTagInModal(saveNewTag);
}
}}
/>
</div>
{allTags.length > 0 && (
<div className="xx-save-modal-tag-presets">
{allTags
.filter((t) => !saveTagIds.includes(t.id))
.slice(0, 12)
.map((t) => (
<button
key={t.id}
className="xx-save-modal-tag-preset"
onClick={() =>
setSaveTagIds((prev) => [...prev, t.id])
}
>
{t.name}
<PlusOutlined
style={{ fontSize: 10, marginLeft: 4 }}
/>
</button>
))}
</div>
)}
</div>
<div className="xx-save-modal-footer">
<button
className="xx-btn xx-btn-ghost"
onClick={() => setSaveModalOpen(false)}
>
</button>
<button
className="xx-btn xx-btn-primary"
disabled={saveToLibraryMutation.isPending}
onClick={handleConfirmSave}
>
{saveToLibraryMutation.isPending ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
</div>
)}
@@ -1040,7 +1393,9 @@ const GeneratePage: React.FC = () => {
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
{selectedMaterials.length}
{materialMode === "auto"
? "自动匹配"
: `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-summary-row">
@@ -1051,20 +1406,107 @@ const GeneratePage: React.FC = () => {
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{getVoiceName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
<div className="xx-count-stepper">
<button
className="xx-count-stepper-btn"
disabled={generateCount <= 1 || generating}
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
>
<MinusOutlined />
</button>
<span className="xx-count-stepper-value">{generateCount}</span>
<button
className="xx-count-stepper-btn"
disabled={generateCount >= 10 || generating}
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
>
<PlusOutlined />
</button>
<span className="xx-count-stepper-hint"></span>
</div>
</span>
</div>
</div>
{/* 生成进度 */}
{generating && (
{/* 生成进度 / 结果反馈 */}
{(generating || generated || generateError) && (
<div style={{ marginTop: 16 }}>
<div className="xx-progress-bar">
{generating && (
<>
<div className="xx-progress-bar">
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
<LoadingOutlined style={{ marginRight: 6 }} />
{Math.round(progress)}%
</Text>
</>
)}
{generated && !generating && (
<div
className="xx-progress-bar-fill"
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
/>
</div>
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
{Math.round(progress)}%
</Text>
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(82, 196, 26, 0.08)",
border: "1px solid rgba(82, 196, 26, 0.3)",
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
<div>
<Text
strong
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
>
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
</Text>
</div>
</div>
)}
{generateError && !generating && (
<div
style={{
padding: "12px 16px",
borderRadius: 8,
background: "rgba(255, 77, 79, 0.08)",
border: "1px solid rgba(255, 77, 79, 0.3)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<CloseCircleOutlined
style={{
color: "#ff4d4f",
fontSize: 18,
marginTop: 2,
flexShrink: 0,
}}
/>
<div>
<Text
strong
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
>
</Text>
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
{generateError}
</Text>
</div>
</div>
)}
</div>
)}
</div>
@@ -1175,10 +1617,16 @@ const GeneratePage: React.FC = () => {
<button
className="xx-btn xx-btn-primary"
onClick={handleGenerate}
disabled={generating || generated}
disabled={generating || (generated && !generateError)}
>
<ThunderboltOutlined />
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
{generating
? "生成中…"
: generated && !generateError
? "已生成"
: generateError
? "🔄 重新生成"
: "✨ 确认生成"}
</button>
)}
</div>
+329
View File
@@ -883,3 +883,332 @@
flex-direction: column;
}
}
/* ── 存为素材弹窗 ── */
.xx-save-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: xxFadeIn 0.15s ease;
}
.xx-save-modal {
background: var(--bg-card, #fff);
border-radius: var(--radius-lg, 16px);
width: 420px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
animation: xxSlideUp 0.2s ease;
}
.xx-save-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light, #f1f5f9);
font-weight: 600;
font-size: 15px;
color: var(--text-primary, #0f172a);
}
.xx-save-modal-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary, #94a3b8);
font-size: 14px;
padding: 4px;
border-radius: var(--radius-sm, 6px);
transition: all 0.15s;
}
.xx-save-modal-close:hover {
background: var(--bg-hover, #f8fafc);
color: var(--text-primary, #0f172a);
}
.xx-save-modal-body {
padding: 20px;
}
.xx-save-modal-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary, #475569);
margin-bottom: 6px;
margin-top: 14px;
}
.xx-save-modal-label:first-child {
margin-top: 0;
}
.xx-save-modal-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-sm, 10px);
font-size: 14px;
outline: none;
background: var(--bg-surface, #fff);
color: var(--text-primary, #0f172a);
transition: border-color 0.15s;
}
.xx-save-modal-input:focus {
border-color: var(--primary-500, #6366f1);
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
}
.xx-save-modal-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: var(--radius-sm, 10px);
min-height: 40px;
align-items: center;
cursor: text;
transition: border-color 0.15s;
}
.xx-save-modal-tags:focus-within {
border-color: var(--primary-500, #6366f1);
box-shadow: 0 0 0 2px var(--primary-100, #e0e7ff);
}
.xx-save-modal-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 12px;
border-radius: 12px;
background: var(--primary-50, #eef2ff);
color: var(--primary-600, #4f46e5);
border: 1px solid var(--primary-200, #c7d2fe);
}
.xx-save-modal-tag-remove {
font-size: 10px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.xx-save-modal-tag-remove:hover {
opacity: 1;
}
.xx-save-modal-tag-input {
border: none;
outline: none;
flex: 1;
min-width: 100px;
font-size: 13px;
background: transparent;
color: var(--text-primary, #0f172a);
}
.xx-save-modal-tag-input::placeholder {
color: var(--text-tertiary, #94a3b8);
}
.xx-save-modal-tag-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-light, #f1f5f9);
}
.xx-save-modal-tag-preset {
padding: 3px 10px;
font-size: 12px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 12px;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #475569);
cursor: pointer;
transition: all 0.15s;
}
.xx-save-modal-tag-preset:hover {
border-color: var(--primary-300, #a5b4fc);
color: var(--primary-600, #4f46e5);
background: var(--primary-50, #eef2ff);
}
.xx-save-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border-light, #f1f5f9);
}
@keyframes xxFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes xxSlideUp {
from {
transform: translateY(12px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ── 生成数量步进器 ── */
.xx-count-stepper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.xx-count-stepper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 8px;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.xx-count-stepper-btn:hover:not(:disabled) {
border-color: var(--primary-400, #818cf8);
color: var(--primary-600, #4f46e5);
background: var(--primary-50, #eef2ff);
}
.xx-count-stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.xx-count-stepper-value {
min-width: 24px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.xx-count-stepper-hint {
font-size: 12px;
color: var(--text-tertiary, #94a3b8);
margin-left: 2px;
}
/* ── 素材选择模式切换 Tab ── */
.xx-material-mode-tabs {
display: flex;
gap: 0;
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 10px;
overflow: hidden;
margin-bottom: 4px;
}
.xx-material-mode-tab {
flex: 1;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
border: none;
background: var(--bg-surface, #fff);
color: var(--text-secondary, #64748b);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.xx-material-mode-tab:first-child {
border-right: 1px solid var(--border-primary, #e2e8f0);
}
.xx-material-mode-tab:hover {
background: var(--primary-50, #eef2ff);
color: var(--primary-600, #4f46e5);
}
.xx-material-mode-tab.active {
background: var(--primary-500, #6366f1);
color: #fff;
font-weight: 600;
}
/* ── 自动匹配卡片 ── */
.xx-auto-match-card {
margin-top: 14px;
padding: 20px;
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
border: 1px solid var(--border-primary, #e2e8f0);
border-radius: 14px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.xx-auto-match-icon {
font-size: 36px;
margin-bottom: 10px;
}
.xx-auto-match-body {
width: 100%;
}
.xx-auto-match-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary, #1e293b);
margin: 0 0 8px;
}
.xx-auto-match-desc {
font-size: 13px;
color: var(--text-secondary, #64748b);
line-height: 1.6;
margin: 0 0 14px;
}
.xx-auto-match-features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.xx-auto-match-feature {
display: inline-block;
padding: 4px 12px;
font-size: 12px;
color: var(--primary-600, #4f46e5);
background: rgba(255, 255, 255, 0.8);
border: 1px solid var(--border-light, #f1f5f9);
border-radius: 20px;
}
+5 -3
View File
@@ -207,9 +207,11 @@ const MyVoices: React.FC = () => {
if (audioRef.current) {
audioRef.current.pause();
}
// Mock: 使用 sample_url 或占位 URL
const url = voice.sample_url || `/mock/audio/clone-${voice.id}.mp3`;
const audio = new Audio(url);
if (!voice.sample_url) {
showToast("暂无试听音频", "error");
return;
}
const audio = new Audio(voice.sample_url);
audioRef.current = audio;
audio.play().catch(() => showToast("播放失败,请检查音频文件", "error"));
audio.onended = () => setPlayingId(null);
+29 -1
View File
@@ -737,11 +737,39 @@ const ProductLibrary: React.FC = () => {
// ── Error 状态 ──
if (isError) {
console.error("[ProductLibrary] 加载失败:", error);
const errorMsg = error?.message || "加载失败";
// 404 视为空数据(API 尚未就绪或无数据)
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
if (is404) {
return (
<div className="xx-products-page">
<div className="xx-products-header">
<h2>
<VideoCameraOutlined />
</h2>
</div>
<div className="xx-products-empty">
<div className="xx-products-empty-icon">🎬</div>
<p></p>
<p
style={{
fontSize: 12,
color: "var(--text-tertiary)",
marginTop: 4,
}}
>
</p>
</div>
</div>
);
}
return (
<div className="xx-products-page">
<div className="xx-products-empty">
<div className="xx-products-empty-icon"></div>
<p>{error?.message || "加载失败"}</p>
<p>{errorMsg || "加载失败,请稍后重试"}</p>
<Button
buttonType="primary"
buttonSize="sm"
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
id: item.id,
name: item.name,
type: inferTemplateType(item.category),
description: item.description,
description: item.description ?? "",
usageCount: 0,
isFavorite: item.is_favorite ?? false,
thumbnailGradient: gradientForCategory(item.category),
@@ -370,9 +370,10 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
className="xx-template-thumb-bg"
style={{ background: template.thumbnailGradient }}
>
{template.description.slice(0, 80)}...
{(template.description ?? "").slice(0, 80)}...
</div>
<div className="xx-template-thumb-overlay" />
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-preview-hint"></div>
<button
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
@@ -386,7 +387,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{/* 信息区 */}
<div className="xx-template-info">
<div className="xx-template-info-top">
<h4 className="xx-template-name">{template.name}</h4>
<span
className="xx-template-category-pill"
style={{
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
{template.type}
</span>
</div>
<p className="xx-template-desc">{template.description}</p>
<p className="xx-template-desc">{template.description ?? ""}</p>
<div className="xx-template-meta">
<span className="xx-template-usage">
使 {template.usageCount}
@@ -483,7 +483,9 @@ const TemplateLibrary: React.FC = () => {
const matchSearch =
!searchText ||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
(t.description ?? "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
t.tags.some((tag) =>
tag.toLowerCase().includes(searchText.toLowerCase()),
);
@@ -233,6 +233,24 @@
pointer-events: none;
}
/* 缩略图底部名称 */
.xx-template-thumb-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 24px 14px 10px;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
z-index: 1;
pointer-events: none;
}
/* 预览提示(hover 显示) */
.xx-template-preview-hint {
position: absolute;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -156,6 +156,13 @@ export const router = createBrowserRouter([
Component: m.default,
})),
},
{
path: "voice-materials",
lazy: () =>
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
Component: m.default,
})),
},
{
path: "my-voices",
lazy: () =>
+2
View File
@@ -75,6 +75,8 @@ export const useAuthStore = create<AuthState>()(
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
}),
},
),
+62
View File
@@ -206,6 +206,68 @@ class VideoDeduplicator:
return None
def check_batch_duplicate(
self,
fingerprint: VideoFingerprint,
batch_id: str,
current_video_id: str,
session: Session,
) -> Optional[dict]:
"""检查视频是否与同批次内其他视频重复。
逻辑与 check_duplicate 一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
Args:
fingerprint: 待检测视频的指纹
batch_id: 批次 ID
current_video_id: 当前视频 ID(排除自身)
session: 数据库会话
Returns:
重复信息字典,或 None 表示未找到重复
"""
video_repo = SQLAlchemyGeneratedVideoRepository(session)
batch_videos = video_repo.list_by_batch(batch_id)
for existing in batch_videos:
if existing.id == current_video_id:
continue
if not existing.video_fingerprint:
continue
ef = existing.video_fingerprint
if fingerprint.md5 == ef.get("md5"):
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_exact_md5_match",
"similarity": 1.0,
}
existing_phashes = ef.get("keyframe_phashes", [])
if not existing_phashes:
continue
min_distances = []
for phash in fingerprint.keyframe_phashes:
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
min_distances.append(min(distances))
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100
if avg_distance >= self.PHASH_THRESHOLD:
continue
phash_similarity = 1.0 - (avg_distance / 64)
return {
"duplicate": True,
"duplicate_of": existing.id,
"reason": "batch_phash_similar",
"similarity": phash_similarity,
}
return None
@staticmethod
def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float:
"""
+5
View File
@@ -41,6 +41,10 @@ def __getattr__(name: str):
from .tts_synthesis import process_tts_synthesis
return process_tts_synthesis
elif name == "process_tts_segment_synthesis":
from .tts_synthesis import process_tts_segment_synthesis
return process_tts_segment_synthesis
elif name == "run_ai_recommend":
from .ai_tasks import run_ai_recommend
@@ -62,6 +66,7 @@ __all__ = [
"extract_background_task",
"process_voice_clone",
"process_tts_synthesis",
"process_tts_segment_synthesis",
"run_ai_recommend",
"run_generate_cover",
]
+123 -12
View File
@@ -139,14 +139,16 @@ def _download_library_assets(
asset_library_id: str,
temp_path: Path,
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
asset_ids: list[str] | None = None,
) -> list[str]:
"""
从素材库下载所有视频素材
从素材库下载视频素材
Args:
asset_library_id: 素材库 ID
temp_path: 临时目录路径
video_extensions: 支持的视频扩展名
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
Returns:
下载成功的视频文件路径列表
@@ -161,16 +163,15 @@ def _download_library_assets(
try:
# 查询素材库中的视频素材
assets = (
session.query(AssetModel)
.filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
.order_by(AssetModel.created_at)
.all()
query = session.query(AssetModel).filter(
AssetModel.asset_library_id == asset_library_id,
AssetModel.status == "ready",
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
)
# 如果指定了 asset_ids,则只下载这些素材
if asset_ids:
query = query.filter(AssetModel.id.in_(asset_ids))
assets = query.order_by(AssetModel.created_at).all()
if not assets:
logger.info(f"No video assets found in library {asset_library_id}")
@@ -259,6 +260,8 @@ def generate_video(self, task_id: str) -> dict:
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
mode = gen_task.strategy_id or "one_take"
task_asset_ids = list(gen_task.asset_ids or [])
batch_id = getattr(gen_task, "batch_id", "") or ""
finally:
session.close()
@@ -275,8 +278,8 @@ def generate_video(self, task_id: str) -> dict:
temp_path = Path(temp_dir)
output_path = temp_path / output_name
# 从素材库下载视频素材
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
# 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些)
downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None)
audio_path = None
if voice_library_id:
@@ -297,6 +300,32 @@ def generate_video(self, task_id: str) -> dict:
file_size = output_path.stat().st_size
duration = _probe_duration(output_path)
# 上传到 OSS
bucket = _oss_bucket()
if bucket:
try:
bucket.put_object_from_file(storage_key, str(output_path))
except Exception as oss_err:
logger.warning(f"OSS upload failed: {oss_err}")
# 构建视频 URL
if bucket:
file_url = f"{PUBLIC_API_BASE_URL}/{storage_key}"
else:
file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}"
# 创建 GeneratedVideo 记录 + 查重
_create_video_record_and_dedup(
task_id=task_id,
project_id=project_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
duration=duration,
video_path=str(output_path),
mode=editing_mode.value,
)
return {
"status": "completed",
"task_id": task_id,
@@ -314,3 +343,85 @@ def generate_video(self, task_id: str) -> dict:
"task_id": task_id,
"error": str(error),
}
def _create_video_record_and_dedup(
*,
task_id: str,
project_id: str,
batch_id: str,
file_url: str,
file_size: int,
duration: float,
video_path: str,
mode: str,
) -> None:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。"""
from uuid import uuid4
from video_processing.dedup import VideoDeduplicator
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.domain import GeneratedVideo
session = SessionLocal()
try:
video_id = uuid4().hex
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
generation_task_id=task_id,
name=f"generated-{task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
width=OUTPUT_WIDTH,
height=OUTPUT_HEIGHT,
fps=OUTPUT_FPS,
status="completed",
generation_params={"mode": mode},
)
video_repo = SQLAlchemyGeneratedVideoRepository(session)
video_repo.create(generated_video)
# 计算视频指纹
deduplicator = VideoDeduplicator()
try:
fingerprint = deduplicator.compute_fingerprint(video_path)
except Exception as fp_err:
logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}")
session.commit()
return
generated_video.video_fingerprint = fingerprint.to_dict()
# (a) 历史成片查重
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
# (b) 批次内查重(仅当有 batch_id 时)
if not duplicate_result and batch_id:
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
if duplicate_result:
generated_video.is_duplicate = True
generated_video.duplicate_of = duplicate_result["duplicate_of"]
logger.info(
f"Duplicate detected: {video_id} -> {duplicate_result['duplicate_of']} "
f"(reason={duplicate_result['reason']}, similarity={duplicate_result['similarity']:.3f})"
)
else:
generated_video.is_duplicate = False
generated_video.duplicate_of = None
video_repo.update(generated_video)
session.commit()
logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})")
except Exception as e:
logger.error(f"Failed to create video record / dedup for task {task_id}: {e}")
session.rollback()
finally:
session.close()
+1
View File
@@ -179,6 +179,7 @@ def ingest_asset(job_id: str) -> dict:
width=int(metadata.get("width", 0)),
height=int(metadata.get("height", 0)),
status=AssetStatus.READY,
file_hash=job.file_hash,
)
asset_repo.create(asset)
@@ -104,3 +104,77 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
finally:
if session is not None:
session.close()
@celery_app.task(bind=True, max_retries=2, name="worker.process_tts_segment_synthesis")
def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
"""分段合成轮询任务 — 轮询多个 CosyVoice 子任务并合并音频。
与 process_tts_synthesis 类似,但超时更长(300s),
因为分段任务需要等待所有子任务完成。
"""
session = None
try:
session = SessionLocal()
repo = SQLAlchemyTTSJobRepository(session)
workflow = TTSWorkflowService(
repository=repo,
cosyvoice_service=CosyVoiceService(),
)
updated_job = workflow.poll_and_process_synthesis(job_id, timeout=300)
session.commit()
logger.info(f"TTS segment synthesis completed: job_id={job_id}, " f"audio_url={updated_job.output_audio_url}")
return {
"ok": True,
"job_id": job_id,
"audio_url": updated_job.output_audio_url,
}
except Retry:
raise
except CosyVoiceTimeoutError as e:
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
if session is not None:
session.rollback()
raise self.retry(exc=e, countdown=60)
except CosyVoiceError as e:
logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
if session is not None:
session.rollback()
try:
if session is not None:
job = repo.get(job_id)
if job is not None:
job.mark_failed(str(e))
repo.update(job)
session.commit()
except Exception as inner_e:
logger.error(f"Failed to mark job as failed: {inner_e}")
if session is not None:
session.rollback()
return {"ok": False, "job_id": job_id, "error": str(e)}
except Exception as e:
logger.error(f"TTS segment synthesis unexpected error for {job_id}: {e}")
if session is not None:
session.rollback()
try:
if session is not None:
job = repo.get(job_id)
if job is not None:
job.mark_failed(str(e))
repo.update(job)
session.commit()
except Exception as inner_e:
logger.error(f"Failed to mark job as failed: {inner_e}")
if session is not None:
session.rollback()
return {"ok": False, "job_id": job_id, "error": str(e)}
finally:
if session is not None:
session.close()
+134
View File
@@ -95,6 +95,39 @@
"id"
]
},
"asset_tags": {
"columns": [
{
"index": false,
"name": "asset_id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "tag_id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "created_at",
"nullable": false,
"primary_key": false,
"type": "DATETIME",
"unique": false
}
],
"indexes": [],
"primary_key": [
"asset_id",
"tag_id"
]
},
"assets": {
"columns": [
{
@@ -241,6 +274,14 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -288,6 +329,13 @@
"name": "ix_assets_created_at",
"unique": false
},
{
"columns": [
"file_hash"
],
"name": "ix_assets_file_hash",
"unique": false
},
{
"columns": [
"file_type"
@@ -1469,6 +1517,22 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "asset_select_mode",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(20)",
"unique": false
},
{
"index": true,
"name": "batch_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(32)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -1494,6 +1558,13 @@
"name": "ix_generation_tasks_asset_library_id",
"unique": false
},
{
"columns": [
"batch_id"
],
"name": "ix_generation_tasks_batch_id",
"unique": false
},
{
"columns": [
"created_by_user_id"
@@ -1599,6 +1670,14 @@
"type": "VARCHAR(32)",
"unique": false
},
{
"index": true,
"name": "file_hash",
"nullable": true,
"primary_key": false,
"type": "VARCHAR(64)",
"unique": false
},
{
"index": false,
"name": "created_at",
@@ -1617,6 +1696,13 @@
}
],
"indexes": [
{
"columns": [
"file_hash"
],
"name": "ix_ingest_jobs_file_hash",
"unique": false
},
{
"columns": [
"library_id"
@@ -2056,6 +2142,54 @@
"id"
]
},
"tags": {
"columns": [
{
"index": false,
"name": "id",
"nullable": false,
"primary_key": true,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "name",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(100)",
"unique": false
},
{
"index": false,
"name": "created_at",
"nullable": false,
"primary_key": false,
"type": "DATETIME",
"unique": false
}
],
"indexes": [
{
"columns": [
"user_id"
],
"name": "ix_tags_user_id",
"unique": false
}
],
"primary_key": [
"id"
]
},
"template_categories": {
"columns": [
{
+240
View File
@@ -0,0 +1,240 @@
#!/bin/sh
set -eu
# ============================================
# Production 部署脚本 - Registry 方式
# 用法:IMAGE_TAG=<version> REGISTRY_TOKEN=<token> sh deploy-production-registry.sh
# ============================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- Pull 三镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# ---- Re-tag 成本地名 ----
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 备份旧版 assets(部署期间缓存用户不 404 ----
echo "Backing up legacy assets from current web container..."
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
_tmpdir="/tmp/legacy-assets-$$"
rm -rf "$_tmpdir"
mkdir -p "$_tmpdir"
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
# 合并到 LEGACY_ASSETS_DIR(保留所有历史版本的 assets)
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
fi
rm -rf "$_tmpdir"
else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理超过 7 天的旧 assets 文件(避免无限增长)
if [ -d "$LEGACY_ASSETS_DIR" ]; then
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
echo "Legacy assets cleanup done (retain 7 days)"
fi
# ---- 确保基础设施容器在运行 ----
echo "Checking infrastructure containers..."
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保生产网络存在 ----
docker network create xiaoxia-net-production 2>/dev/null || true
# ---- 执行数据库 Migration ----
echo "Running database migrations..."
docker run --rm \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
# ---- 停止旧容器 ----
echo "Stopping old containers..."
docker rm -f xiaoxia-api-production 2>/dev/null || true
docker rm -f xiaoxia-worker-production 2>/dev/null || true
docker rm -f xiaoxia-web-production 2>/dev/null || true
# ---- 日志配置(所有容器共用) ----
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
# nginx 配置中 assets location 有 fallback 逻辑
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
echo "Web container: legacy assets mounted (fallback)"
else
echo "Web container: no legacy assets to mount"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
docker logs --tail 50 xiaoxia-api-production
exit 1
fi
# ---- 等待 Web 健康 ----
echo "Waiting for Web to become healthy..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
echo "Web is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/15)"
sleep 2
done
if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
docker logs --tail 30 xiaoxia-web-production
exit 1
fi
# ---- 清理旧镜像 ----
echo "Cleaning up old images..."
docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Production deployment complete ==="
echo "API: http://127.0.0.1:8001"
echo "Web: http://127.0.0.1:3002"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
+35 -33
View File
@@ -6,25 +6,19 @@ ROOT_DIR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/repo"
COMPOSE_DIR="$ROOT_DIR/infra/docker"
ENV_FILE="$HOST_PREFIX/var/lib/xiaoxia-saas-production/.env"
RELEASE_VERSION="${RELEASE_VERSION:-}"
RUNTIME_IMAGE_TAR="${RUNTIME_IMAGE_TAR:-}"
WEB_IMAGE_TAR="${WEB_IMAGE_TAR:-}"
if [ -n "$RELEASE_VERSION" ] && [ -z "$RUNTIME_IMAGE_TAR" ]; then
RUNTIME_IMAGE_TAR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/runtime-images-$RELEASE_VERSION.tar"
fi
if [ -n "$RELEASE_VERSION" ] && [ -z "$WEB_IMAGE_TAR" ]; then
WEB_IMAGE_TAR="$HOST_PREFIX/var/lib/xiaoxia-saas-production/web-$RELEASE_VERSION.tar"
fi
# Registry 配置
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ensure_container_running() {
name="$1"
if ! docker inspect "$name" >/dev/null 2>&1; then
echo "Required infrastructure container not found: $name"
echo "Run infra/docker/infra-production.yml first before deploying production applications."
exit 1
fi
state="$(docker inspect -f '{{.State.Status}}' "$name")"
state="$(docker inspect -f "{{.State.Status}}" "$name")"
if [ "$state" != "running" ]; then
echo "Required infrastructure container is not running: $name ($state)"
exit 1
@@ -39,10 +33,10 @@ fi
cd "$ROOT_DIR"
cp "$ENV_FILE" "$ROOT_DIR/.env"
mkdir -p "$ROOT_DIR/apps/web/public" "$HOST_PREFIX/var/lib/xiaoxia-saas-production/generated"
[ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf 'placeholder' > "$ROOT_DIR/apps/web/public/.keep"
[ -f "$ROOT_DIR/apps/web/public/.keep" ] || printf "placeholder" > "$ROOT_DIR/apps/web/public/.keep"
if [ ! -f "$ROOT_DIR/apps/web/dist/index.html" ]; then
echo "Missing prebuilt web artifact: $ROOT_DIR/apps/web/dist/index.html"
echo "Production deploy must not build frontend assets on the server. Run npm run build before packaging the release artifact."
echo "Production deploy must not build frontend assets on the server."
exit 1
fi
@@ -50,23 +44,33 @@ ensure_container_running xiaoxia-postgres-production
ensure_container_running xiaoxia-redis-production
cd "$COMPOSE_DIR"
if [ -n "$RELEASE_VERSION" ]; then
if [ ! -f "$RUNTIME_IMAGE_TAR" ]; then
echo "Missing production runtime image artifact: $RUNTIME_IMAGE_TAR"
echo "Build it on a dedicated build host with scripts/build_release_images.sh $RELEASE_VERSION, then upload it before production deploy."
exit 1
fi
docker load -i "$RUNTIME_IMAGE_TAR"
if [ ! -f "$WEB_IMAGE_TAR" ]; then
echo "Missing production web image artifact: $WEB_IMAGE_TAR"
echo "Build it on a dedicated build host, then upload it before production deploy."
exit 1
fi
docker load -i "$WEB_IMAGE_TAR"
export API_IMAGE="xiaoxia-saas-api:$RELEASE_VERSION"
export WORKER_IMAGE="xiaoxia-saas-worker:$RELEASE_VERSION"
export WEB_IMAGE="${WEB_IMAGE:-xiaoxia-saas-web:$RELEASE_VERSION}"
export WEB_IMAGE="xiaoxia-saas-web:$RELEASE_VERSION"
export APP_VERSION="$RELEASE_VERSION"
# ---- 从 Registry Pull 镜像 ----
echo "Pulling images from registry: $REGISTRY"
if [ -n "$REGISTRY_TOKEN" ]; then
printf "%s" "$REGISTRY_TOKEN" | docker login git.xiaoxiajianji.com -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
fi
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$RELEASE_VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$RELEASE_VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$RELEASE_VERSION"
docker pull "$REGISTRY_API"
docker pull "$REGISTRY_WORKER"
docker pull "$REGISTRY_WEB"
# Re-tag 为本地镜像名,保持 compose 兼容
docker tag "$REGISTRY_API" "$API_IMAGE"
docker tag "$REGISTRY_WORKER" "$WORKER_IMAGE"
docker tag "$REGISTRY_WEB" "$WEB_IMAGE"
echo "All images pulled and tagged."
fi
export DOCKER_BUILDKIT=0
@@ -76,7 +80,6 @@ export ENV=production
export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile
export WEB_NGINX_CONF=infra/docker/nginx-production.conf
# Ensure isolated production network exists
docker network create xiaoxia-net-production 2>/dev/null || true
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"
export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"
@@ -86,24 +89,23 @@ if [ "${ALLOW_PRODUCTION_BUILDS:-false}" = "true" ]; then
docker compose --env-file "$ENV_FILE" build --pull=false worker
docker compose --env-file "$ENV_FILE" build --pull=false web
else
echo "Skipping production API/worker/web image builds. Set ALLOW_PRODUCTION_BUILDS=true only on a dedicated build host."
echo "Skipping production image builds (using prebuilt images from registry)."
docker image inspect "${API_IMAGE:-xiaoxia-saas-api:dev}" >/dev/null
docker image inspect "${WORKER_IMAGE:-xiaoxia-saas-worker:dev}" >/dev/null
docker image inspect "${WEB_IMAGE:-xiaoxia-saas-web:dev}" >/dev/null
fi
docker compose --env-file "$ENV_FILE" run --rm --no-deps api sh -c '
docker compose --env-file "$ENV_FILE" run --rm --no-deps api sh -c "
cd /app &&
python /app/scripts/validate_release_env.py --from-environ --strict-external &&
alembic upgrade head
'
"
docker compose --env-file "$ENV_FILE" up -d api worker
# Recreate web after API so nginx resolves the current API container IP.
# Docker's embedded DNS is resolved by nginx at startup for this static upstream.
docker compose --env-file "$ENV_FILE" up -d --force-recreate web
docker compose --env-file "$ENV_FILE" ps
if [ -n "$RELEASE_VERSION" ] && [ -d "$HOST_PREFIX/etc/cron.d" ]; then
cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" <<EOF
cat > "$HOST_PREFIX/etc/cron.d/xiaoxia-production-resource-check" << EOF
*/5 * * * * root cd /var/lib/xiaoxia-saas-production/repo && EXPECTED_VERSION=$RELEASE_VERSION sh scripts/production_resource_check.sh >/var/log/xiaoxia-resource-check.log 2>&1
EOF
fi
+178
View File
@@ -0,0 +1,178 @@
#!/bin/sh
set -eu
# ============================================
# Staging 部署脚本 - Registry 方式
# 用法:IMAGE_TAG=<sha|version> REGISTRY_TOKEN=<token> sh deploy-staging.sh
# ============================================
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
COMPOSE_DIR="${COMPOSE_DIR:-/var/lib/xiaoxia-saas-staging/repo/infra/docker}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
printf %s "$REGISTRY_TOKEN" | docker login "$(echo $REGISTRY | cut -d/ -f1)" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- Pull 三镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
LOCAL_API="${REGISTRY}/xiaoxia-saas-api:staging"
LOCAL_WORKER="${REGISTRY}/xiaoxia-saas-worker:staging"
LOCAL_WEB="${REGISTRY}/xiaoxia-saas-web:staging"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# ---- Re-tag 成本地名 ----
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 确保基础设施容器在运行 ----
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f {{.State.Status}} "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 确保 staging 网络存在 ----
docker network create xiaoxia-net-staging 2>/dev/null || true
# ---- 执行数据库 Migration ----
echo "Running database migrations..."
docker run --rm --env-file "$ENV_FILE" --network xiaoxia-net-staging "$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
# ---- 停止旧容器 ----
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
"$LOCAL_WORKER"
# ---- 启动 Web ----
# Web 镜像默认打包 production nginx.confstaging 需要挂载 staging 配置
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
if [ ! -f "$NGINX_CONF" ]; then
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
NGINX_VOLUME=""
else
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
--label com.centurylinklabs.watchtower.enable=true \
$NGINX_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
"$LOCAL_WEB"
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 30 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/30)"
sleep 2
done
if [ "$i" -ge 30 ]; then
echo "ERROR: API did not become healthy within 60s"
docker logs --tail 30 xiaoxia-api-staging
exit 1
fi
# ---- 清理旧镜像 ----
docker image prune -af --filter "until=72h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}" | grep staging
# Watchtower auto-update: 容器加com.centurylinklabs.watchtower.enable=true标签,用:staging tag启动
+9
View File
@@ -39,6 +39,15 @@ server {
alias /app/generated/;
}
# Assets with legacy fallback (higher priority than generic static regex)
# 部署期间,缓存了旧版 index.html 的用户会请求旧版带 hash 的 assets 文件
# 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的)
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri /assets-legacy$uri =404;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
@@ -26,6 +26,13 @@ class InMemoryAssetRepository:
"""Alias for list_by_library to match the port interface."""
return self.list_by_library(library_id)
def find_by_library_and_file_type(self, library_id: str, file_type: str) -> list[Asset]:
return [
asset
for asset in self._assets.values()
if asset.library_id == library_id and asset.mime_type and asset.mime_type.startswith(file_type)
]
def update(self, asset: Asset) -> Asset:
self._assets[asset.id] = asset
return asset
@@ -35,3 +42,50 @@ class InMemoryAssetRepository:
del self._assets[asset_id]
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
count = 0
for aid in asset_ids:
if aid in self._assets:
del self._assets[aid]
count += 1
return count
def find_by_project(
self,
project_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
items = [a for a in self._assets.values() if a.project_id == project_id]
return items[skip : skip + limit]
def find_by_id(self, asset_id: str) -> Asset | None:
return self._assets.get(asset_id)
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
if not tag_ids:
return []
tag_set = set(tag_ids)
items = [a for a in self._assets.values() if tag_set.issubset(set(a.tag_ids))]
return items[skip : skip + limit]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
for asset in self._assets.values():
if asset.library_id == library_id and asset.file_hash == file_hash:
return asset
return None
@@ -0,0 +1,40 @@
"""标签 InMemory 仓储实现。"""
from packages.domain import Tag
class InMemoryTagRepository:
def __init__(self):
self._tags: dict[str, Tag] = {}
def create(self, tag: Tag) -> Tag:
self._tags[tag.id] = tag
return tag
def get(self, tag_id: str) -> Tag | None:
return self._tags.get(tag_id)
def find_by_name(self, user_id: str, name: str) -> Tag | None:
for tag in self._tags.values():
if tag.user_id == user_id and tag.name == name:
return tag
return None
def list_by_user(
self,
user_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Tag]:
tags = [tag for tag in self._tags.values() if tag.user_id == user_id]
tags.sort(key=lambda t: t.created_at, reverse=True)
return tags[skip : skip + limit]
def count_by_user(self, user_id: str) -> int:
return sum(1 for tag in self._tags.values() if tag.user_id == user_id)
def delete(self, tag_id: str) -> bool:
if tag_id in self._tags:
del self._tags[tag_id]
return True
return False
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.adapters.sqlalchemy_impl.models import AssetModel, AssetTagModel
from packages.domain import Asset, AssetStatus, ClassificationStatus
@@ -37,6 +37,22 @@ class SQLAlchemyAssetRepository:
)
return [self._to_domain(model) for model in models]
def find_by_library_and_file_type(
self,
library_id: str,
file_type: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
models = (
self.session.query(AssetModel)
.filter(AssetModel.asset_library_id == library_id, AssetModel.file_type == file_type)
.offset(skip)
.limit(limit)
.all()
)
return [self._to_domain(model) for model in models]
def find_by_id(self, asset_id: str) -> Asset | None:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model is None:
@@ -67,10 +83,13 @@ class SQLAlchemyAssetRepository:
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
quality_score=asset.quality_score,
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
file_hash=asset.file_hash or None,
created_at=asset.created_at,
updated_at=now,
)
self.session.add(model)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
self.session.commit()
return asset
@@ -92,7 +111,10 @@ class SQLAlchemyAssetRepository:
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
model.quality_score = asset.quality_score
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
model.file_hash = asset.file_hash or model.file_hash
model.updated_at = datetime.now(timezone.utc)
self.session.flush()
self._sync_asset_tags(asset.id, asset.tag_ids)
self.session.commit()
return asset
@@ -104,6 +126,14 @@ class SQLAlchemyAssetRepository:
return True
return False
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
if not asset_ids:
return 0
count = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).delete(synchronize_session=False)
self.session.commit()
return count
def count_by_project(self, project_id: str) -> int:
return self.session.query(AssetModel).filter(AssetModel.project_id == project_id).count()
@@ -179,6 +209,11 @@ class SQLAlchemyAssetRepository:
"audio": "audio/mpeg",
"image": "image/jpeg",
}.get(mime_type, mime_type)
# 查询关联的 tag_ids
tag_ids = [
row.tag_id
for row in self.session.query(AssetTagModel.tag_id).filter(AssetTagModel.asset_id == model.id).all()
]
return Asset(
id=model.id,
project_id=model.project_id,
@@ -197,7 +232,61 @@ class SQLAlchemyAssetRepository:
classification_status=ClassificationStatus(model.classification_status),
quality_score=model.quality_score,
uploaded_by_user_id=model.uploaded_by_user_id,
file_hash=model.file_hash or "",
metadata=metadata,
tag_ids=tag_ids,
created_at=model.created_at,
updated_at=model.updated_at,
)
def _sync_asset_tags(self, asset_id: str, tag_ids: list[str]) -> None:
"""同步素材-标签关联表(全量替换)。"""
self.session.query(AssetTagModel).filter(AssetTagModel.asset_id == asset_id).delete(synchronize_session=False)
for tag_id in tag_ids:
self.session.add(AssetTagModel(asset_id=asset_id, tag_id=tag_id))
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
if not tag_ids:
return []
from sqlalchemy import func
# 找出同时拥有所有指定 tag_id 的 asset_id
tag_set = set(tag_ids)
asset_ids = (
self.session.query(AssetTagModel.asset_id)
.filter(AssetTagModel.tag_id.in_(tag_set))
.group_by(AssetTagModel.asset_id)
.having(func.count(AssetTagModel.tag_id) == len(tag_set))
.all()
)
ids = [row[0] for row in asset_ids]
if not ids:
return []
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
return [self._to_domain(m) for m in models]
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
if not file_hash:
return None
model = (
self.session.query(AssetModel)
.filter(
AssetModel.asset_library_id == library_id,
AssetModel.file_hash == file_hash,
)
.first()
)
if model is None:
return None
return self._to_domain(model)
@@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository:
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
return [self.get(model.id) for model in models if self.get(model.id) is not None]
return [self._to_domain(model) for model in models]
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
models = (
@@ -86,4 +86,40 @@ class SQLAlchemyGeneratedVideoRepository:
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
.all()
)
return [self.get(model.id) for model in models if self.get(model.id) is not None]
return [self._to_domain(model) for model in models]
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
task_ids = (
self.session.query(GenerationTaskModel.id).filter(GenerationTaskModel.batch_id == batch_id).subquery()
)
models = (
self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id.in_(task_ids)).all()
)
return [self._to_domain(model) for model in models]
@staticmethod
def _to_domain(model: GeneratedVideoModel) -> GeneratedVideo:
return GeneratedVideo(
id=model.id,
project_id=model.project_id,
generation_task_id=model.generation_task_id,
name=model.name,
file_url=model.file_url,
file_size=int(model.file_size or 0),
duration=model.duration,
thumbnail_url=model.thumbnail_url,
width=int(model.width or 0),
height=int(model.height or 0),
fps=model.fps,
status=getattr(model, "status", "completed"),
review_status=getattr(model, "review_status", "pending_review"),
generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"),
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
is_duplicate=getattr(model, "is_duplicate", False),
duplicate_of=getattr(model, "duplicate_of", None),
generated_at=model.generated_at,
created_at=model.created_at,
)
@@ -25,6 +25,8 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
completed_at=model.completed_at,
created_by_user_id=model.created_by_user_id,
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
created_at=model.created_at,
)
@@ -52,6 +54,8 @@ class SQLAlchemyGenerationTaskRepository:
completed_at=task.completed_at,
created_by_user_id=task.created_by_user_id,
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
created_at=task.created_at,
)
self.session.add(model)
@@ -123,5 +127,7 @@ class SQLAlchemyGenerationTaskRepository:
model.started_at = task.started_at
model.completed_at = task.completed_at
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
self.session.commit()
return task
@@ -17,6 +17,7 @@ class SQLAlchemyIngestJobRepository:
status=job.status.value,
error_message=job.error_message,
result_asset_id=job.result_asset_id,
file_hash=job.file_hash,
created_at=job.created_at,
updated_at=job.updated_at,
)
@@ -36,6 +37,7 @@ class SQLAlchemyIngestJobRepository:
status=IngestJobStatus(model.status),
error_message=model.error_message,
result_asset_id=model.result_asset_id,
file_hash=model.file_hash or "",
created_at=model.created_at,
updated_at=model.updated_at,
)
@@ -51,6 +53,7 @@ class SQLAlchemyIngestJobRepository:
model.status = job.status.value
model.error_message = job.error_message
model.result_asset_id = job.result_asset_id
model.file_hash = job.file_hash
model.updated_at = job.updated_at
self.session.commit()
return job
@@ -85,11 +85,35 @@ class AssetModel(Base):
classification_result = Column(Text, nullable=True)
quality_score = Column(Float, nullable=True)
uploaded_by_user_id = Column(String(36), nullable=False)
file_hash = Column(String(64), nullable=True, index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class TagModel(Base):
"""标签 ORM 模型。"""
__tablename__ = "tags"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_tags_user_name"),)
class AssetTagModel(Base):
"""素材-标签关联表 ORM 模型。"""
__tablename__ = "asset_tags"
asset_id = Column(String(36), primary_key=True)
tag_id = Column(String(36), primary_key=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
class EditTemplateModel(Base):
"""Phase 8 剪辑模板 ORM 模型
@@ -187,6 +211,7 @@ class IngestJobModel(Base):
status = Column(String(20), nullable=False, default="pending")
error_message = Column(Text, nullable=False, default="")
result_asset_id = Column(String(32), nullable=False, default="")
file_hash = Column(String(64), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -228,6 +253,8 @@ class GenerationTaskModel(Base):
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
source_edit_plan_id = Column(String(32), nullable=True, index=True)
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(32), nullable=False, default="", index=True)
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -0,0 +1,73 @@
"""标签 SQLAlchemy 仓储实现。"""
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetTagModel, TagModel
from packages.domain import Tag
class SQLAlchemyTagRepository:
def __init__(self, session: Session):
self.session = session
def create(self, tag: Tag) -> Tag:
model = TagModel(
id=tag.id,
user_id=tag.user_id,
name=tag.name,
created_at=tag.created_at,
)
self.session.add(model)
self.session.commit()
return tag
def get(self, tag_id: str) -> Tag | None:
model = self.session.query(TagModel).filter(TagModel.id == tag_id).first()
if model is None:
return None
return self._to_domain(model)
def find_by_name(self, user_id: str, name: str) -> Tag | None:
model = self.session.query(TagModel).filter(TagModel.user_id == user_id, TagModel.name == name).first()
if model is None:
return None
return self._to_domain(model)
def list_by_user(
self,
user_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Tag]:
models = (
self.session.query(TagModel)
.filter(TagModel.user_id == user_id)
.order_by(TagModel.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [self._to_domain(m) for m in models]
def count_by_user(self, user_id: str) -> int:
return self.session.query(TagModel).filter(TagModel.user_id == user_id).count()
def delete(self, tag_id: str) -> bool:
# 先清理关联表
self.session.query(AssetTagModel).filter(AssetTagModel.tag_id == tag_id).delete(synchronize_session=False)
model = self.session.query(TagModel).filter(TagModel.id == tag_id).first()
if model is None:
self.session.commit()
return False
self.session.delete(model)
self.session.commit()
return True
@staticmethod
def _to_domain(model: TagModel) -> Tag:
return Tag(
id=model.id,
user_id=model.user_id,
name=model.name,
created_at=model.created_at,
)
@@ -33,6 +33,8 @@ class SQLAlchemyUserRepository(UserRepository):
model.max_projects = user.max_projects
model.max_storage_gb = user.max_storage_gb
model.is_admin = user.is_admin
model.wechat_openid = user.wechat_openid
model.wechat_unionid = user.wechat_unionid
model.created_at = user.created_at
self.session.commit()
@@ -49,6 +51,16 @@ class SQLAlchemyUserRepository(UserRepository):
model = self.session.query(UserModel).filter(UserModel.username == username.strip()).first()
return self._to_entity(model)
def find_by_wechat_openid(self, openid: str) -> User | None:
model = self.session.query(UserModel).filter(UserModel.wechat_openid == openid.strip()).first()
return self._to_entity(model)
def find_by_wechat_unionid(self, unionid: str) -> User | None:
if not unionid or not unionid.strip():
return None
model = self.session.query(UserModel).filter(UserModel.wechat_unionid == unionid.strip()).first()
return self._to_entity(model)
def find_by_verification_token(self, token: str) -> User | None:
model = self.session.query(UserModel).filter(UserModel.email_verification_token == token).first()
return self._to_entity(model)
@@ -87,5 +99,7 @@ class SQLAlchemyUserRepository(UserRepository):
max_projects=model.max_projects or 3,
max_storage_gb=model.max_storage_gb or 10,
is_admin=model.is_admin or False,
wechat_openid=model.wechat_openid,
wechat_unionid=model.wechat_unionid,
created_at=model.created_at,
)
@@ -0,0 +1,206 @@
"""
微信同步登录/注册 Use Case
供 BFF 层调用的系统级接口:
- 根据 openid 查找用户,找到则登录返回 token
- 没找到则创建新用户并返回 token
- 支持 unionid 跨应用关联
"""
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import uuid4
import jwt as pyjwt
from packages.adapters.redis import get_session_store
from packages.application.auth.jwt_service import jwt_service
from packages.domain.entities import User
class WechatSyncRequest:
"""微信同步登录请求"""
def __init__(
self,
openid: str,
unionid: str = "",
nickname: str = "",
avatar_url: str = "",
source: str = "miniapp",
):
self.openid = openid.strip()
self.unionid = unionid.strip() if unionid else ""
self.nickname = nickname or "微信用户"
self.avatar_url = avatar_url or ""
self.source = source
class WechatSyncResponse:
"""微信同步登录响应"""
def __init__(
self,
access_token: str,
refresh_token: str,
user_id: str,
nickname: str,
avatar_url: str,
is_new_user: bool,
expires_in: int,
):
self.access_token = access_token
self.refresh_token = refresh_token
self.user_id = user_id
self.nickname = nickname
self.avatar_url = avatar_url
self.is_new_user = is_new_user
self.expires_in = expires_in
def to_dict(self) -> dict:
return {
"access_token": self.access_token,
"token": self.access_token, # 兼容 BFF 层用 token 字段读取
"refresh_token": self.refresh_token,
"user_id": self.user_id,
"user": {
"id": self.user_id,
"nickname": self.nickname,
"avatar_url": self.avatar_url,
"display_name": self.nickname,
},
"user_info": {
"id": self.user_id,
"nickname": self.nickname,
"avatar_url": self.avatar_url,
"display_name": self.nickname,
},
"is_new_user": self.is_new_user,
"expires_in": self.expires_in,
}
class WechatSyncUseCase:
"""微信同步登录/注册用例
系统级接口,由 BFF 通过 API Key 调用。
职责:根据 openid 查找或创建用户,返回 SaaS token。
"""
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
self.user_repository = user_repository
self.session_store = session_store or get_session_store()
self.jwt_secret_key = jwt_secret_key or jwt_service.config.SECRET_KEY
def execute(self, request: WechatSyncRequest) -> tuple[Optional[WechatSyncResponse], Optional[str]]:
"""
执行微信同步登录/注册
Returns:
(响应对象, 错误信息) - 成功则错误信息为 None
"""
try:
if not request.openid:
return None, "openid is required"
is_new_user = False
# 1. 按 openid 查找用户
user = self.user_repository.find_by_wechat_openid(request.openid)
# 2. 如果 openid 没找到,尝试 unionid
if not user and request.unionid:
user = self.user_repository.find_by_wechat_unionid(request.unionid)
if user:
# 找到用户但 openid 为空,绑定一下当前 openid
user.wechat_openid = request.openid
self.user_repository.save(user)
# 3. 都没找到则创建新用户
if not user:
user = self._create_wechat_user(request)
is_new_user = True
# 4. 创建 session 并生成 token
session_id = secrets.token_urlsafe(16)
refresh_token = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
access_token_payload = {
"sub": user.id,
"sid": session_id,
"type": "user_auth",
"iat": now,
"exp": now + timedelta(minutes=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES),
}
access_token = pyjwt.encode(
access_token_payload,
self.jwt_secret_key,
algorithm=jwt_service.config.ALGORITHM,
)
# 保存 session
self.session_store.save_session(
session_id=session_id,
user_id=user.id,
refresh_token=refresh_token,
device_info=f"wechat_{request.source}",
ip_address="bff_gateway",
expires_in_seconds=30 * 24 * 3600, # 30 天
)
# 更新最后登录信息
user.last_login_at = now
user.last_login_ip = "bff_gateway"
self.user_repository.save(user)
response = WechatSyncResponse(
access_token=access_token,
refresh_token=refresh_token,
user_id=user.id,
nickname=user.display_name,
avatar_url="", # SaaS 用户模型暂存头像,后续可扩展
is_new_user=is_new_user,
expires_in=jwt_service.config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
return response, None
except Exception as e:
return None, f"Internal error: {str(e)}"
def _create_wechat_user(self, request: WechatSyncRequest) -> User:
"""创建微信用户"""
user_id = uuid4().hex
# 生成唯一名和邮箱(微信用户无真实邮箱,用 openid 生成占位)
safe_openid = request.openid.replace("-", "_")[:20]
username = f"wx_{safe_openid}"
email = f"{safe_openid}@wechat.local"
# 确保 username 唯一
suffix = 0
while self.user_repository.find_by_username(username):
suffix += 1
username = f"wx_{safe_openid}_{suffix}"
# 随机密码(微信用户不用密码登录)
random_password = secrets.token_urlsafe(32)
from packages.application.auth.password_hasher import password_hasher
password_hash = password_hasher.hash_password(random_password)
user = User(
id=user_id,
email=email,
username=username,
display_name=request.nickname or "微信用户",
password_hash=password_hash,
email_verified=True, # 微信登录视为已验证
wechat_openid=request.openid,
wechat_unionid=request.unionid or None,
)
self.user_repository.save(user)
return user
+4
View File
@@ -19,6 +19,8 @@ class CreateGenerationTaskCommand:
voice_ids: list[str] = field(default_factory=list)
created_by_user_id: str = ""
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
class CreateGenerationTaskUseCase:
@@ -44,6 +46,8 @@ class CreateGenerationTaskUseCase:
completed_at=None,
created_by_user_id=command.created_by_user_id,
source_edit_plan_id=command.source_edit_plan_id,
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
)
return self.generation_task_repository.create(task)
+2
View File
@@ -11,6 +11,7 @@ class SubmitIngestJobCommand:
project_id: str
library_id: str
storage_key: str
file_hash: str = ""
class SubmitIngestJobUseCase:
@@ -22,5 +23,6 @@ class SubmitIngestJobUseCase:
project_id=command.project_id,
library_id=command.library_id,
storage_key=command.storage_key,
file_hash=command.file_hash,
)
return self.ingest_job_repository.create(job)
@@ -0,0 +1,96 @@
"""FFmpeg 音频合并器 — P1 长文本分段合成。
将多个分段音频文件合并为一个完整音频文件。
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
logger = logging.getLogger(__name__)
class AudioMergeError(Exception):
"""音频合并异常。"""
pass
class AudioMerger:
"""使用 FFmpeg 合并多个音频文件。"""
def merge(self, audio_paths: list[str], output_format: str = "mp3") -> bytes:
"""合并多个音频文件,返回合并后的音频数据。
使用 FFmpeg concat demuxer 按顺序拼接音频。
所有输入文件必须为相同格式和采样率。
Args:
audio_paths: 音频文件路径列表(按合成顺序)
output_format: 输出格式(mp3/wav/pcm
Returns:
合并后的音频文件字节数据
Raises:
AudioMergeError: 合并失败
"""
if not audio_paths:
raise AudioMergeError("没有可合并的音频文件")
if len(audio_paths) == 1:
with open(audio_paths[0], "rb") as f:
return f.read()
temp_dir = tempfile.mkdtemp(prefix="tts_merge_")
try:
# 生成 concat demuxer 列表文件
list_path = os.path.join(temp_dir, "concat_list.txt")
with open(list_path, "w") as f:
for path in audio_paths:
# FFmpeg concat 文件需要 file: 前缀,路径中的 ' 和 \n 需转义
escaped = path.replace("'", "'\\''").replace("\n", "\\n")
f.write(f"file '{escaped}'\n")
output_path = os.path.join(temp_dir, f"merged.{output_format}")
cmd = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
list_path,
"-c",
"copy",
output_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error(f"FFmpeg 合并失败: stderr={result.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {result.stderr[:500]}")
with open(output_path, "rb") as f:
return f.read()
except subprocess.TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except AudioMergeError:
raise
except Exception as e:
raise AudioMergeError(f"音频合并失败: {e}")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -0,0 +1,251 @@
"""P2: TTS 流式合成服务 — WebSocket 实时音频推送。
通过 WebSocket 将合成音频以二进制帧实时推送给客户端。
- 短文本(≤500 字):合成完整音频后分块推送
- 长文本(>500 字):分段并发合成,逐段推送音频
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Optional
import httpx
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.tts_job.text_splitter import split_text
logger = logging.getLogger(__name__)
# WebSocket 二进制帧块大小(4KB
_AUDIO_CHUNK_SIZE = 4096
# 分段并发上限
_MAX_STREAMING_SEGMENT_WORKERS = 5
# 长文本分段阈值
_SEGMENT_THRESHOLD = 500
# WebSocket 最大文本长度
_MAX_TEXT_LENGTH = 10000
class TTSStreamingError(Exception):
"""TTS 流式合成异常。"""
pass
class TTSStreamingService:
"""TTS 流式合成服务。
通过 WebSocket 实时推送合成音频。
使用 CosyVoiceService(同步 REST API)合成,
通过 asyncio.to_thread 桥接到异步 WebSocket。
"""
def __init__(self, cosyvoice_service: CosyVoiceService) -> None:
self._cosyvoice = cosyvoice_service
async def synthesize_and_stream(self, websocket: Any, params: dict) -> None:
"""根据文本长度选择流式合成策略。
Args:
websocket: FastAPI WebSocket 连接
params: 合成参数(text, voice_id, sample_rate, format, speed
"""
text = params.get("text", "")
if not text:
await self._send_json(websocket, {"type": "error", "message": "文本不能为空"})
return
if len(text) > _MAX_TEXT_LENGTH:
await self._send_json(
websocket,
{"type": "error", "message": f"文本过长,最大 {_MAX_TEXT_LENGTH}"},
)
return
if len(text) <= _SEGMENT_THRESHOLD:
await self._stream_short_text(websocket, params)
else:
await self._stream_long_text(websocket, params)
# ── 短文本流式合成 ────────────────────────────────────────
async def _stream_short_text(self, websocket: Any, params: dict) -> None:
"""短文本:合成完整音频后分块推送。"""
text = params["text"]
voice_id = params.get("voice_id", "")
sample_rate = params.get("sample_rate", 0)
audio_format = params.get("format", "mp3")
speed = params.get("speed", 1.0)
await self._send_json(
websocket,
{"type": "started", "segment_count": 1, "total_segments": 1},
)
# 在线程池中执行同步合成
try:
result = await asyncio.to_thread(
self._cosyvoice.submit_synthesize_task,
text=text,
voice_id=voice_id,
sample_rate=sample_rate,
format=audio_format,
speed=speed,
)
except CosyVoiceError as e:
logger.error(f"流式合成失败: {e}")
await self._send_json(websocket, {"type": "error", "message": str(e)})
return
except Exception as e:
logger.error(f"流式合成意外错误: {e}")
await self._send_json(websocket, {"type": "error", "message": f"合成失败: {e}"})
return
audio_url = result.get("audio_url", "")
if not audio_url:
await self._send_json(websocket, {"type": "error", "message": "合成未返回音频 URL"})
return
# 下载并流式推送音频
try:
audio_data = await asyncio.to_thread(self._download_audio, audio_url)
total_bytes = await self._stream_audio_chunks(websocket, audio_data)
await self._send_json(
websocket,
{
"type": "done",
"duration": result.get("duration", 0.0),
"file_size": total_bytes,
"format": audio_format,
},
)
except Exception as e:
logger.error(f"音频流式推送失败: {e}")
await self._send_json(websocket, {"type": "error", "message": f"音频推送失败: {e}"})
# ── 长文本分段流式合成 ────────────────────────────────────
async def _stream_long_text(self, websocket: Any, params: dict) -> None:
"""长文本:分段并发合成,逐段推送音频。"""
text = params["text"]
voice_id = params.get("voice_id", "")
sample_rate = params.get("sample_rate", 0)
audio_format = params.get("format", "mp3")
speed = params.get("speed", 1.0)
segments = split_text(text, max_chars=_SEGMENT_THRESHOLD)
segment_count = len(segments)
logger.info(f"流式分段合成: 原文={len(text)}字, 段数={segment_count}")
await self._send_json(
websocket,
{"type": "started", "segment_count": segment_count, "total_segments": segment_count},
)
# 并发合成所有分段,按顺序流式推送
queue: asyncio.Queue[tuple[int, Optional[bytes], Optional[str]]] = asyncio.Queue()
completed_count = 0
async def _synthesize_one(idx: int, seg_text: str) -> None:
"""合成单个分段并放入队列。"""
try:
result = await asyncio.to_thread(
self._cosyvoice.submit_synthesize_task,
text=seg_text,
voice_id=voice_id,
sample_rate=sample_rate,
format=audio_format,
speed=speed,
)
audio_url = result.get("audio_url", "")
if audio_url:
audio_data = await asyncio.to_thread(self._download_audio, audio_url)
await queue.put((idx, audio_data, None))
else:
await queue.put((idx, None, "合成未返回音频 URL"))
except Exception as e:
await queue.put((idx, None, str(e)))
# 启动并发合成任务
workers = [asyncio.create_task(_synthesize_one(idx, seg)) for idx, seg in enumerate(segments)]
# 按顺序消费队列,流式推送
total_bytes = 0
total_duration = 0.0
consumed = 0
try:
while consumed < segment_count:
idx, audio_data, error = await queue.get()
consumed += 1
if error:
logger.error(f"分段 {idx + 1} 合成失败: {error}")
await self._send_json(
websocket,
{"type": "error", "message": f"分段 {idx + 1} 合成失败: {error}"},
)
# 取消剩余 worker
for w in workers:
w.cancel()
return
if audio_data:
seg_bytes = await self._stream_audio_chunks(websocket, audio_data)
total_bytes += seg_bytes
await self._send_json(
websocket,
{"type": "segment_done", "segment": idx + 1, "total": segment_count},
)
# 等待所有 worker 完成
await asyncio.gather(*workers, return_exceptions=True)
await self._send_json(
websocket,
{
"type": "done",
"duration": total_duration,
"file_size": total_bytes,
"format": audio_format,
},
)
except Exception as e:
logger.error(f"流式分段推送失败: {e}")
await self._send_json(websocket, {"type": "error", "message": f"推送失败: {e}"})
for w in workers:
w.cancel()
# ── 工具方法 ────────────────────────────────────────────
def _download_audio(self, url: str) -> bytes:
"""下载音频数据。"""
resp = httpx.get(url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
return resp.content
async def _stream_audio_chunks(self, websocket: Any, audio_data: bytes) -> int:
"""将音频数据分块通过 WebSocket 推送。
Returns:
推送的总字节数
"""
total = 0
for offset in range(0, len(audio_data), _AUDIO_CHUNK_SIZE):
chunk = audio_data[offset : offset + _AUDIO_CHUNK_SIZE]
await websocket.send_bytes(chunk)
total += len(chunk)
return total
async def _send_json(self, websocket: Any, data: dict) -> None:
"""安全发送 JSON 帧。"""
try:
await websocket.send_json(data)
except Exception:
pass
@@ -0,0 +1,70 @@
"""长文本分段工具 — P1 长文本分段合成。
将超过阈值的文本按句子边界分段,供 CosyVoice 并发合成后合并。
"""
from __future__ import annotations
# 中文句子结束符(含全角/半角)
_SENTENCE_ENDS = frozenset("。!?;\n.!?;")
def split_text(text: str, max_chars: int = 500) -> list[str]:
"""将文本分段,每段不超过 max_chars 个字符。
优先在句子边界(句号、问号、感叹号、换行符)处分段。
若单个句子超过 max_chars,则在逗号等次级标点处拆分。
若仍超长,则硬切。
Args:
text: 待分段文本
max_chars: 每段最大字符数
Returns:
分段列表,每段 ≤ max_chars。文本为空时返回空列表。
"""
text = text.strip()
if not text:
return []
if len(text) <= max_chars:
return [text]
segments: list[str] = []
current = ""
for char in text:
current += char
if char in _SENTENCE_ENDS and len(current) >= 50:
# 句子边界且长度合理,切段
segments.append(current.strip())
current = ""
elif len(current) >= max_chars:
# 达到上限,强制切段
segments.append(current.strip())
current = ""
if current.strip():
segments.append(current.strip())
# 合并过短的段(< 50 字符且不是最后一段),减少 API 调用次数
merged: list[str] = []
buffer = ""
for seg in segments:
if buffer:
combined = buffer + seg
if len(combined) <= max_chars:
buffer = combined
continue
merged.append(buffer)
buffer = ""
if len(seg) < 50:
buffer = seg
else:
merged.append(seg)
if buffer:
if merged and len(merged[-1]) + len(buffer) <= max_chars:
merged[-1] = merged[-1] + buffer
else:
merged.append(buffer)
return [s for s in merged if s]
+303 -5
View File
@@ -9,19 +9,35 @@
from __future__ import annotations
import io
import logging
import os
import shutil
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import httpx
from packages.application.cosyvoice_service import (
CosyVoiceAuthError,
CosyVoiceError,
CosyVoiceService,
)
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
from packages.application.tts_job.text_splitter import split_text
from packages.domain.tts_job import TTSJob, TTSJobStatus
from packages.ports.tts_job_repository import TTSJobRepository
from packages.shared.storage import SharedStorageService, get_shared_storage_service
logger = logging.getLogger(__name__)
# 长文本分段阈值:超过此字符数自动分段合成
_SEGMENT_THRESHOLD = 500
# 分段并发上限
_MAX_SEGMENT_WORKERS = 5
class TTSWorkflowError(Exception):
"""TTS 合成工作流异常。"""
@@ -46,9 +62,55 @@ class TTSWorkflowService:
self,
repository: TTSJobRepository,
cosyvoice_service: CosyVoiceService,
storage_service: Optional[SharedStorageService] = None,
) -> None:
self.repository = repository
self.cosyvoice_service = cosyvoice_service
self._storage_service = storage_service
@property
def _storage(self) -> SharedStorageService:
if self._storage_service is None:
self._storage_service = get_shared_storage_service()
return self._storage_service
def _transfer_audio_to_oss(
self,
temp_url: str,
user_id: str,
job_id: str,
audio_format: str = "mp3",
) -> tuple[str, str]:
"""下载 CosyVoice 临时音频并转存到 OSS。
Returns:
(permanent_url, storage_key) 元组。
转存失败时回退到原始临时 URL,storage_key 为空字符串。
"""
storage_key = f"tts-outputs/{user_id}/{job_id}.{audio_format}"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "application/octet-stream")
try:
# 下载临时音频
resp = httpx.get(temp_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
audio_data = resp.content
# 上传到 OSS
file_obj = io.BytesIO(audio_data)
permanent_url = self._storage.upload_file(file_obj, storage_key, content_type=content_type)
logger.info(f"音频转存 OSS 成功: job_id={job_id}, " f"storage_key={storage_key}, size={len(audio_data)}")
return permanent_url, storage_key
except Exception as e:
logger.warning(f"音频转存 OSS 失败,使用临时 URL: " f"job_id={job_id}, error={e}")
return temp_url, ""
def start_synthesis(
self,
@@ -80,6 +142,10 @@ class TTSWorkflowService:
job.mark_processing()
job = self.repository.update(job)
# 长文本自动分段合成
if len(job.input_text) > _SEGMENT_THRESHOLD:
return self._start_segment_synthesis(job)
try:
submit_result = self.cosyvoice_service.submit_synthesize_task(
text=job.input_text,
@@ -93,17 +159,19 @@ class TTSWorkflowService:
job_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "")
job_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "")
# 如果 CosyVoice 同步返回了 audio_url直接标记完成
# 如果 CosyVoice 同步返回了 audio_url转存 OSS 后标记完成
audio_url = submit_result.get("audio_url", "")
if audio_url:
permanent_url, storage_key = self._transfer_audio_to_oss(audio_url, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=audio_url,
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=submit_result.get("duration", 0.0),
file_size=submit_result.get("file_size", 0),
)
job.metadata = job_metadata
job = self.repository.update(job)
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}")
logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={permanent_url}")
return job
job.metadata = job_metadata
@@ -133,6 +201,11 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 检查是否为分段合成任务
segment_task_ids = (job.metadata or {}).get("segment_task_ids", [])
if segment_task_ids:
return self._poll_segment_tasks(job)
task_id = (job.metadata or {}).get("cosyvoice_task_id", "")
if not task_id:
raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata")
@@ -171,13 +244,17 @@ class TTSWorkflowService:
if job is None:
raise TTSJobNotFoundError(f"TTS job {job_id} not found")
# 转存音频到 OSS,获取永久 URL
permanent_url, storage_key = self._transfer_audio_to_oss(audio_url, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=audio_url,
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=duration,
file_size=file_size,
)
job = self.repository.update(job)
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={audio_url}")
logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={permanent_url}")
return job
def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob:
@@ -201,3 +278,224 @@ class TTSWorkflowService:
job = self.repository.update(job)
logger.error(f"TTS 合成失败: job_id={job_id}, error={error_message}")
return job
# ── P1: 长文本分段合成 ─────────────────────────────────────
def _upload_merged_to_oss(
self, merged_data: bytes, user_id: str, job_id: str, audio_format: str
) -> tuple[str, str]:
"""上传合并后的音频数据到 OSS。
Returns:
(permanent_url, storage_key) 元组。
上传失败时返回 ("", "")。
"""
storage_key = f"tts-outputs/{user_id}/{job_id}.{audio_format}"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "application/octet-stream")
try:
file_obj = io.BytesIO(merged_data)
permanent_url = self._storage.upload_file(file_obj, storage_key, content_type=content_type)
return permanent_url, storage_key
except Exception as e:
logger.warning(f"分段合并音频转存 OSS 失败: job_id={job_id}, error={e}")
return "", ""
def _start_segment_synthesis(self, job: TTSJob) -> TTSJob:
"""长文本分段合成入口。
将文本分段后并发提交到 CosyVoice,根据同步/异步结果走不同路径。
"""
segments = split_text(job.input_text, max_chars=_SEGMENT_THRESHOLD)
logger.info(f"长文本分段合成: job_id={job.id}, " f"原文={len(job.input_text)}字, 段数={len(segments)}")
# 记录分段信息到 metadata
job_metadata = dict(job.metadata)
job_metadata["segment_count"] = len(segments)
# 并发提交所有分段
results = self._submit_segments_concurrent(segments, job)
if results is None:
# 提交阶段已失败,_submit_segments_concurrent 内部已标记 failed
return self.repository.get(job.id)
# 判断同步还是异步
has_audio_urls = any(r.get("audio_url", "") for r in results)
has_task_ids = any(r.get("task_id", "") for r in results)
if has_audio_urls and not has_task_ids:
# 所有分段同步返回音频,直接合并
return self._process_segments_sync(job, results)
# 异步路径:保存各分段的 task_id 供后续轮询
segment_task_ids = [r.get("task_id", "") for r in results]
segment_audio_urls = [r.get("audio_url", "") for r in results]
job_metadata["segment_task_ids"] = segment_task_ids
job_metadata["segment_audio_urls"] = segment_audio_urls
job_metadata["segment_format"] = job.format
job.metadata = job_metadata
job = self.repository.update(job)
logger.info(f"分段合成任务已提交(异步): job_id={job.id}, " f"段数={len(segments)}")
return job
def _submit_segments_concurrent(self, segments: list[str], job: TTSJob) -> list[dict] | None:
"""并发提交分段合成任务。
Returns:
各分段的结果列表(保持顺序),提交失败时返回 None。
"""
max_workers = min(len(segments), _MAX_SEGMENT_WORKERS)
results: list[dict | None] = [None] * len(segments)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_idx = {}
for idx, segment_text in enumerate(segments):
future = executor.submit(
self.cosyvoice_service.submit_synthesize_task,
text=segment_text,
voice_id=job.voice_id,
sample_rate=job.sample_rate,
format=job.format,
)
future_to_idx[future] = idx
for future in as_completed(future_to_idx):
idx = future_to_idx[future]
try:
results[idx] = future.result()
except Exception as e:
logger.error(f"分段合成提交失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 合成提交失败: {e}")
return None
return results # type: ignore[return-value]
def _process_segments_sync(self, job: TTSJob, results: list[dict]) -> TTSJob:
"""同步路径:所有分段已返回 audio_url,下载合并后转存 OSS。"""
merged_data, total_duration = self._download_and_merge_segments(results, job)
# 直接上传合并后的音频 bytes 到 OSS
permanent_url, storage_key = self._upload_merged_to_oss(merged_data, job.user_id, job.id, job.format)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成完成: job_id={job.id}, " f"merged_size={len(merged_data)}, duration={total_duration:.1f}")
return job
def _download_and_merge_segments(self, results: list[dict], job: TTSJob) -> tuple[bytes, float]:
"""下载各分段音频并合并。
Returns:
(merged_audio_bytes, total_duration)
"""
temp_dir = tempfile.mkdtemp(prefix="tts_segments_")
try:
audio_paths: list[str] = []
total_duration = 0.0
for idx, result in enumerate(results):
audio_url = result.get("audio_url", "")
if not audio_url:
raise TTSWorkflowError(f"分段 {idx + 1} 没有返回 audio_url")
total_duration += result.get("duration", 0.0)
# 下载分段音频到临时文件
resp = httpx.get(audio_url, timeout=60.0, follow_redirects=True)
resp.raise_for_status()
seg_path = os.path.join(temp_dir, f"seg_{idx:03d}.{job.format}")
with open(seg_path, "wb") as f:
f.write(resp.content)
audio_paths.append(seg_path)
# 合并
merger = AudioMerger()
merged_data = merger.merge(audio_paths, output_format=job.format)
return merged_data, total_duration
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def _poll_segment_tasks(self, job: TTSJob) -> TTSJob:
"""轮询所有分段异步任务,全部完成后合并音频。"""
segment_task_ids: list[str] = (job.metadata or {}).get("segment_task_ids", [])
segment_audio_urls: list[str] = (job.metadata or {}).get("segment_audio_urls", [])
segment_count = len(segment_task_ids)
poll_start = time.monotonic()
poll_timeout = 300.0 # 分段任务超时更长
poll_interval = 2.0
while time.monotonic() - poll_start < poll_timeout:
all_done = True
results: list[dict | None] = [None] * segment_count
for idx, task_id in enumerate(segment_task_ids):
# 已经有音频的分段跳过轮询
if idx < len(segment_audio_urls) and segment_audio_urls[idx]:
results[idx] = {
"audio_url": segment_audio_urls[idx],
"duration": 0.0,
"file_size": 0,
}
continue
try:
result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=poll_timeout)
results[idx] = result
except Exception as e:
logger.error(f"分段任务轮询失败: job_id={job.id}, " f"segment={idx}, error={e}")
self._handle_segment_failure(job, f"分段 {idx + 1} 轮询失败: {e}")
return self.repository.get(job.id)
if results[idx] is None:
all_done = False
if all_done and all(r is not None for r in results):
# 所有分段完成,下载合并
try:
merged_data, total_duration = self._download_and_merge_segments(results, job)
# 转存 OSS
permanent_url, storage_key = self._upload_merged_to_oss(
merged_data, job.user_id, job.id, job.format
)
job.mark_completed(
output_audio_url=permanent_url,
output_audio_key=storage_key,
duration=total_duration,
file_size=len(merged_data),
)
job = self.repository.update(job)
logger.info(f"分段合成轮询完成: job_id={job.id}, " f"merged_size={len(merged_data)}")
return job
except Exception as e:
self._handle_segment_failure(job, f"分段合并失败: {e}")
return self.repository.get(job.id)
# 等待后重试
time.sleep(poll_interval)
# 超时
self._handle_segment_failure(job, "分段合成轮询超时(300 秒)")
return self.repository.get(job.id)
def _handle_segment_failure(self, job: TTSJob, error_message: str) -> None:
"""分段合成失败处理。"""
job.mark_failed(error_message)
self.repository.update(job)
logger.error(f"分段合成失败: job_id={job.id}, error={error_message}")
+2
View File
@@ -24,6 +24,7 @@ from .entities import (
from .generated_video import GeneratedVideo
from .generation_task import GenerationTask, GenerationTaskStatus
from .job import Job, JobStatus, JobType
from .tag import Tag
from .template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
from .title_library import TitleLibraryItem
from .voice_library import VoiceLibraryItem
@@ -56,6 +57,7 @@ __all__ = [
"JobStatus",
"JobType",
"Project",
"Tag",
"TemplateClipConfig",
"TransitionEffect",
"User",
+25 -14
View File
@@ -54,6 +54,11 @@ class User:
used_storage_gb: float = 0.0
# 管理员标识
is_admin: bool = False
# 微信绑定
wechat_openid: str | None = None
wechat_unionid: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -156,8 +161,9 @@ class Asset:
classification_status: ClassificationStatus = ClassificationStatus.PENDING
quality_score: float | None = None
uploaded_by_user_id: str = ""
file_hash: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
tags: list[str] = field(default_factory=list)
tag_ids: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -182,6 +188,7 @@ class Asset:
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
quality_score: float | None = None,
uploaded_by_user_id: str = "",
file_hash: str = "",
) -> "Asset":
clean_name = name.strip()
if not clean_name:
@@ -208,24 +215,25 @@ class Asset:
classification_status=classification_status,
quality_score=quality_score,
uploaded_by_user_id=uploaded_by_user_id.strip(),
file_hash=file_hash.strip(),
metadata=metadata or {},
tags=[],
tag_ids=[],
)
def add_tag(self, tag: str) -> None:
"""添加标签。空标签会被忽略,自动去重。"""
clean_tag = tag.strip()
if not clean_tag:
raise ValueError("标签不能为空")
if clean_tag not in self.tags:
self.tags.append(clean_tag)
def add_tag(self, tag_id: str) -> None:
"""添加标签 ID。空 ID 会被忽略,自动去重。"""
clean_id = tag_id.strip()
if not clean_id:
raise ValueError("标签 ID 不能为空")
if clean_id not in self.tag_ids:
self.tag_ids.append(clean_id)
self.updated_at = datetime.now(timezone.utc)
def remove_tag(self, tag: str) -> None:
"""删除标签。如果标签不存在,不报错(幂等性)。"""
clean_tag = tag.strip()
if clean_tag in self.tags:
self.tags.remove(clean_tag)
def remove_tag(self, tag_id: str) -> None:
"""删除标签 ID。如果标签不存在,不报错(幂等性)。"""
clean_id = tag_id.strip()
if clean_id in self.tag_ids:
self.tag_ids.remove(clean_id)
self.updated_at = datetime.now(timezone.utc)
@@ -238,6 +246,7 @@ class IngestJob:
status: IngestJobStatus = IngestJobStatus.PENDING
error_message: str = ""
result_asset_id: str = ""
file_hash: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -247,6 +256,7 @@ class IngestJob:
project_id: str,
library_id: str,
storage_key: str,
file_hash: str = "",
) -> "IngestJob":
if not project_id.strip():
raise ValueError("project_id 不能为空")
@@ -259,4 +269,5 @@ class IngestJob:
project_id=project_id.strip(),
library_id=library_id.strip(),
storage_key=storage_key.strip(),
file_hash=file_hash.strip(),
)
+6
View File
@@ -43,6 +43,8 @@ class GenerationTask:
completed_at: datetime | None = None
source_edit_plan_id: str = ""
created_by_user_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
@@ -59,6 +61,8 @@ class GenerationTask:
voice_ids: list[str] | None = None,
created_by_user_id: str = "",
source_edit_plan_id: str = "",
asset_select_mode: str = "",
batch_id: str = "",
) -> "GenerationTask":
if not project_id.strip() and not template_id.strip():
raise ValueError("project_id 或 template_id 至少需要提供一个")
@@ -76,4 +80,6 @@ class GenerationTask:
voice_ids=list(voice_ids) if voice_ids else [],
created_by_user_id=created_by_user_id.strip(),
source_edit_plan_id=source_edit_plan_id.strip(),
asset_select_mode=asset_select_mode,
batch_id=batch_id,
)
+24
View File
@@ -0,0 +1,24 @@
"""标签领域实体。"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from uuid import uuid4
@dataclass(slots=True)
class Tag:
id: str
user_id: str
name: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@classmethod
def create(cls, user_id: str, name: str) -> "Tag":
clean_name = name.strip()
if not clean_name:
raise ValueError("标签名称不能为空")
return cls(
id=uuid4().hex,
user_id=user_id,
name=clean_name,
)
+2
View File
@@ -4,6 +4,7 @@ from .asset_library_repository import AssetLibraryRepository
from .asset_repository import AssetRepository
from .ingest_job_repository import IngestJobRepository
from .project_repository import ProjectRepository
from .tag_repository import TagRepository
from .title_library_repository import TitleLibraryRepository
from .voice_library_repository import VoiceLibraryRepository
@@ -12,6 +13,7 @@ __all__ = [
"AssetRepository",
"IngestJobRepository",
"ProjectRepository",
"TagRepository",
"TitleLibraryRepository",
"VoiceLibraryRepository",
]
+34
View File
@@ -32,6 +32,16 @@ class AssetRepository(ABC):
) -> list[Asset]:
pass
@abstractmethod
def find_by_library_and_file_type(
self,
library_id: str,
file_type: str,
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
pass
@abstractmethod
def update(self, asset: Asset) -> Asset:
pass
@@ -40,6 +50,11 @@ class AssetRepository(ABC):
def delete(self, asset_id: str) -> bool:
pass
@abstractmethod
def batch_delete(self, asset_ids: list[str]) -> int:
"""批量删除素材,返回实际删除数量。"""
pass
@abstractmethod
def count_by_project(self, project_id: str) -> int:
pass
@@ -68,3 +83,22 @@ class AssetRepository(ABC):
) -> list[Asset]:
"""按筛选条件搜索候选素材,按质量分降序排列。"""
pass
@abstractmethod
def find_by_tag_ids(
self,
tag_ids: list[str],
skip: int = 0,
limit: int = 100,
) -> list[Asset]:
"""查找包含所有指定标签的素材。"""
pass
@abstractmethod
def find_by_library_and_file_hash(
self,
library_id: str,
file_hash: str,
) -> Asset | None:
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
pass
@@ -13,3 +13,5 @@ class GeneratedVideoRepository(Protocol):
def list_by_project(self, project_id: str) -> list[GeneratedVideo]: ...
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: ...
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]: ...
+36
View File
@@ -0,0 +1,36 @@
"""标签仓储接口定义。"""
from abc import ABC, abstractmethod
from packages.domain import Tag
class TagRepository(ABC):
@abstractmethod
def create(self, tag: Tag) -> Tag:
pass
@abstractmethod
def get(self, tag_id: str) -> Tag | None:
pass
@abstractmethod
def find_by_name(self, user_id: str, name: str) -> Tag | None:
pass
@abstractmethod
def list_by_user(
self,
user_id: str,
skip: int = 0,
limit: int = 100,
) -> list[Tag]:
pass
@abstractmethod
def count_by_user(self, user_id: str) -> int:
pass
@abstractmethod
def delete(self, tag_id: str) -> bool:
pass
+10
View File
@@ -41,6 +41,16 @@ class UserRepository(ABC):
"""根据密码重置令牌查找用户"""
pass
@abstractmethod
def find_by_wechat_openid(self, openid: str) -> Optional[User]:
"""根据微信 openid 查找用户"""
pass
@abstractmethod
def find_by_wechat_unionid(self, unionid: str) -> Optional[User]:
"""根据微信 unionid 查找用户"""
pass
@abstractmethod
def delete(self, user_id: str) -> bool:
"""删除用户"""
+68 -22
View File
@@ -9,42 +9,44 @@ if [ -z "$VERSION" ]; then
fi
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUTPUT_DIR="${OUTPUT_DIR:-$ROOT_DIR/dist/release-images}"
mkdir -p "$OUTPUT_DIR"
API_IMAGE="xiaoxia-saas-api:$VERSION"
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
API_LATEST="xiaoxia-saas-api:dev"
WORKER_LATEST="xiaoxia-saas-worker:dev"
TAR_PATH="$OUTPUT_DIR/xiaoxia-runtime-images-$VERSION.tar"
cd "$ROOT_DIR"
if docker ps --format '{{.Names}}' | grep -Eq '^(xiaoxia-(api|web|worker|postgres|redis)-production|gitea)$'; then
if docker ps --format "{{.Names}}" | grep -Eq "^(xiaoxia-(api|web|worker|postgres|redis)-production|gitea)$"; then
if [ "${ALLOW_SHARED_PRODUCTION_BUILD_HOST:-false}" != "true" ]; then
echo "Refusing to build runtime images on a host that is running production services."
echo "Use a dedicated build host/CI runner, or set ALLOW_SHARED_PRODUCTION_BUILD_HOST=true only for an explicitly approved emergency."
exit 1
fi
fi
# ---- BuildKit 缓存优化 ----
# ---- Registry 配置 ----
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
CACHE_REGISTRY="${CACHE_REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
CACHE_TAG="${CACHE_TAG:-release}"
USE_CACHE=0
# 检查 buildx 和 Gitea Registry 认证是否可用
API_IMAGE="xiaoxia-saas-api:$VERSION"
WORKER_IMAGE="xiaoxia-saas-worker:$VERSION"
WEB_IMAGE="xiaoxia-saas-web:$VERSION"
API_LATEST="xiaoxia-saas-api:dev"
WORKER_LATEST="xiaoxia-saas-worker:dev"
# Registry 上的完整镜像名
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:$VERSION"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:$VERSION"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:$VERSION"
USE_CACHE=0
USE_PUSH=0
# 检查 buildx 和 Registry 认证
if docker buildx version >/dev/null 2>&1; then
# 尝试登录缓存 Registry(有 token 才启用缓存)
if [ -n "${REGISTRY_TOKEN:-}" ]; then
printf '%s' "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null && USE_CACHE=1
printf "%s" "${REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u xiaoxia --password-stdin 2>/dev/null && USE_CACHE=1 && USE_PUSH=1
fi
# 确保使用 docker driver(共享 daemon 凭证)
docker buildx use default 2>/dev/null || true
fi
echo "=== Building API image ==="
if [ "$USE_CACHE" -eq 1 ]; then
echo "Using buildx with distributed cache (${CACHE_REGISTRY})"
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
@@ -52,6 +54,12 @@ if [ "$USE_CACHE" -eq 1 ]; then
-t "$API_IMAGE" -t "$API_LATEST" \
--load \
.
else
docker build --pull=false -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
fi
echo "=== Building Worker image ==="
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
@@ -60,11 +68,49 @@ if [ "$USE_CACHE" -eq 1 ]; then
--load \
.
else
echo "Buildx cache not available, using plain docker build"
docker build --pull=false -f infra/docker/api.Dockerfile -t "$API_IMAGE" -t "$API_LATEST" .
docker build --pull=false -f infra/docker/worker.Dockerfile -t "$WORKER_IMAGE" -t "$WORKER_LATEST" .
fi
docker save "$API_IMAGE" "$API_LATEST" "$WORKER_IMAGE" "$WORKER_LATEST" -o "$TAR_PATH"
echo "=== Building Web image (with buildx cache) ==="
# 先构建前端产物
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace/apps/web \
docker.m.daocloud.io/library/node:20 \
sh -lc "npm ci && npm run build"
printf '%s\n' "$TAR_PATH"
test -f apps/web/dist/index.html
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "$WEB_IMAGE" \
--load \
.
else
docker build --pull=false \
-f infra/docker/web-artifact.Dockerfile \
--build-arg NGINX_CONF=infra/docker/nginx-production.conf \
-t "$WEB_IMAGE" \
.
fi
# Push 到 Registry
if [ "$USE_PUSH" -eq 1 ]; then
echo "=== Pushing images to Registry ==="
docker tag "$API_IMAGE" "$REGISTRY_API"
docker tag "$WORKER_IMAGE" "$REGISTRY_WORKER"
docker tag "$WEB_IMAGE" "$REGISTRY_WEB"
docker push "$REGISTRY_API"
docker push "$REGISTRY_WORKER"
docker push "$REGISTRY_WEB"
echo "All images pushed to $REGISTRY"
else
echo "Registry push skipped (no auth token available)"
fi
echo "=== Build complete ==="
docker images | grep "xiaoxia-saas.*:$VERSION"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""检查指定commit的CI status状态。
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
返回: 打印状态 (success/failure/pending/error)
"""
import json
import sys
import urllib.error
import urllib.request
def main():
if len(sys.argv) != 5:
print("pending")
return
token = sys.argv[1]
repo = sys.argv[2]
sha = sys.argv[3]
target_context = sys.argv[4]
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
statuses = json.loads(resp.read().decode())
except Exception:
print("pending")
return
# API返回按时间倒序,第一个就是最新的
for s in statuses:
if s.get("context") == target_context:
print(s.get("status", "pending"))
return
print("pending")
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""检查PR是否有至少N个APPROVED审批。
用法: python3 check_pr_approval.py <token> <repo> <pr_number> <min_approval>
返回: 打印 "approved""pending"
"""
import json
import sys
import urllib.request
def main():
if len(sys.argv) != 5:
print("pending")
return
token = sys.argv[1]
repo = sys.argv[2]
pr_number = sys.argv[3]
min_approval = int(sys.argv[4])
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews"
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
reviews = json.loads(resp.read().decode())
except Exception:
print("pending")
return
# 统计APPROVED的人数(去重,同一人多次审批只算一次)
approvers = set()
for r in reviews:
if r.get("state") == "APPROVED":
approvers.add(r.get("user", {}).get("login", ""))
if len(approvers) >= min_approval:
print(f"approved ({len(approvers)})")
else:
print(f"pending ({len(approvers)})")
if __name__ == "__main__":
main()
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
作为短作业模式的兜底机制,每5分钟运行一次
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
def api_request(token, repo, endpoint, method="GET", data=None):
"""Gitea API请求"""
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
# 跳过SSL验证
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, context=ctx)
return json.loads(resp.read().decode()), resp.status
except urllib.error.HTTPError as e:
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
def get_open_prs(token, repo, base="develop"):
"""获取所有open的PR"""
prs = []
page = 1
while True:
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
if code != 200 or not isinstance(data, list) or len(data) == 0:
break
prs.extend(data)
if len(data) < 50:
break
page += 1
return prs
def get_commit_status(token, repo, sha):
"""获取commit的CI状态汇总"""
data, code = api_request(token, repo, f"commits/{sha}/status")
if code != 200:
return {}, "error"
return data, data.get("state", "unknown")
def check_required_contexts(token, repo, sha, contexts):
"""检查指定的context是否都通过"""
data, _ = get_commit_status(token, repo, sha)
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
all_success = True
any_pending = False
any_failed = False
for ctx in contexts:
state = statuses.get(ctx, "pending")
if state != "success":
all_success = False
if state == "pending":
any_pending = True
if state in ("failure", "error"):
any_failed = True
return all_success, any_pending, any_failed, statuses
def get_pr_files(token, repo, pr_number):
"""获取PR变更文件"""
files = []
page = 1
while True:
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
if code != 200 or not isinstance(data, list) or len(data) == 0:
break
files.extend(data)
if len(data) < 300:
break
page += 1
return [f["filename"] for f in files]
def is_frontend_only(files):
"""判断是否纯前端改动"""
if not files:
return False
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
backend_count = len(files) - frontend_count
return backend_count == 0 and frontend_count > 0
def has_approval(token, repo, pr_number):
"""检查PR是否已有审批"""
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
if code != 200:
return False
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
def get_ai_review_result(token, repo, pr_number):
"""
检查AI代码审查结果,返回 (has_critical, review_body)
has_critical: 是否有严重问题(需修改的问题 > 0)
review_body: 最新的AI审查评论文本
"""
# AI审查评论标记
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
if code != 200:
return False, None
# 找最新的AI审查评论
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
if not ai_comments:
return False, None
# 按时间排序,取最新的
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
body = latest.get("body", "")
# 解析严重问题数量
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
critical_count = 0
# 方式1:直接匹配数字
match = re.search(r"严重问题数量[:]\s*(\d+)\s*个", body)
if match:
critical_count = int(match.group(1))
else:
# 方式2:数 "需修改的问题" 章节下的条目数
critical_section = re.search(
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
body,
re.DOTALL,
)
if critical_section:
section_text = critical_section.group(1)
# 数编号条目 1. 2. 3.
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
critical_count = len(items)
has_critical = critical_count > 0
return has_critical, body
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
"""审批PR"""
# 创建review
data, code = api_request(
token,
repo,
f"pulls/{pr_number}/reviews",
method="POST",
data={"event": "PENDING", "body": reason},
)
if code not in (200, 201):
return False, f"创建review失败: HTTP {code}"
review_id = data.get("id")
if data.get("state") == "APPROVED":
return True, "直接创建APPROVED成功"
if not review_id:
return False, "未获取到review ID"
# submit为APPROVED
data2, code2 = api_request(
token,
repo,
f"pulls/{pr_number}/reviews/{review_id}/events",
method="POST",
data={"event": "APPROVED", "body": reason},
)
if code2 in (200, 201):
return True, "审批提交成功"
else:
# 尝试另一个端点
data3, code3 = api_request(
token,
repo,
f"pulls/{pr_number}/reviews/{review_id}",
method="POST",
data={"event": "APPROVED", "body": reason},
)
if code3 in (200, 201):
return True, "审批提交成功(备用端点)"
return False, f"审批提交失败: HTTP {code2}/{code3}"
def add_pr_label(token, repo, pr_number, label):
"""给PR添加标签"""
data, code = api_request(
token,
repo,
f"issues/{pr_number}/labels",
method="POST",
data={"labels": [label]},
)
return code in (200, 201)
def merge_pr(token, repo, pr_number):
"""合并PRsquash merge"""
# 等待几秒让状态同步
time.sleep(30)
# 检查PR状态
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
if code != 200:
return False, f"获取PR状态失败: HTTP {code}"
if pr_data.get("state") != "open":
return False, f"PR状态不是open: {pr_data.get('state')}"
# 执行squash merge
data, code = api_request(
token,
repo,
f"pulls/{pr_number}/merge",
method="POST",
data={
"do": "squash",
"merge_title_field": "",
"merge_message_field": "",
"delete_branch_after_merge": True,
"force_merge": False,
},
)
if code == 200:
return True, "合并成功"
elif code == 405:
return False, "合并返回405(门禁未满足或冲突)"
else:
return False, f"合并失败: HTTP {code}"
def main():
parser = argparse.ArgumentParser(description="PR自动扫描器")
parser.add_argument("--token", required=True, help="Gitea API token")
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
parser.add_argument("--base", default="develop", help="目标分支")
parser.add_argument("--approve", action="store_true", help="执行自动审批")
parser.add_argument("--merge", action="store_true", help="执行自动合并")
parser.add_argument("--dry-run", default="false", help="试运行模式")
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
args = parser.parse_args()
dry_run = args.dry_run.lower() == "true"
# required contexts(与分支保护一致)
REQUIRED_CONTEXTS_FULL = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
"CI/CD Pipeline / PR Build API Image (pull_request)",
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
"CI/CD Pipeline / PR Build Web Image (pull_request)",
]
REQUIRED_CONTEXTS_APPROVE = [
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
FRONTEND_ONLY_CONTEXT = [
"CI/CD Pipeline / Frontend Lint (pull_request)",
]
# 获取所有open PR
print(f"获取 {args.base} 分支的open PR...")
prs = get_open_prs(args.token, args.repo, args.base)
print(f"找到 {len(prs)} 个open PR")
approved_count = 0
merged_count = 0
skipped_count = 0
ai_blocked_count = 0
for pr in prs[: args.max_prs]:
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("re", "")
# 跳过draft
if pr.get("draft"):
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
skipped_count += 1
continue
# 跳过目标分支不对的
if base_ref != args.base:
skipped_count += 1
continue
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
# 判断是否纯前端
files = get_pr_files(args.token, args.repo, pr_num)
frontend_only = is_frontend_only(files)
if frontend_only:
approve_contexts = FRONTEND_ONLY_CONTEXT
merge_contexts = FRONTEND_ONLY_CONTEXT
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
else:
approve_contexts = REQUIRED_CONTEXTS_APPROVE
merge_contexts = REQUIRED_CONTEXTS_FULL
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
# 检查审批用的CI状态
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
# === AI审查检查 ===
ai_has_critical = False
if not args.skip_ai_review and all_ok and not failed and args.approve:
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
if ai_has_critical:
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
ai_blocked_count += 1
# 给PR打标签便于人工识别
if not dry_run:
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
# === 自动审批 ===
if args.approve and all_ok and not failed and not ai_has_critical:
if has_approval(args.token, args.repo, pr_num):
print(" ✅ 已有审批,跳过")
else:
if dry_run:
print(" 🎯 [DRY-RUN] 将自动审批")
else:
print(" 🎯 执行自动审批...")
ok, msg = approve_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 审批成功: {msg}")
approved_count += 1
else:
print(f" ❌ 审批失败: {msg}")
elif ai_has_critical:
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
elif failed:
print(" ❌ CI有失败项,跳过审批")
elif pending:
print(" ⏳ CI仍在运行,跳过")
# === 自动合并 ===
if args.merge:
# 检查合并用的CI状态
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
args.token, args.repo, head_sha, merge_contexts
)
# 检查审批
approved = has_approval(args.token, args.repo, pr_num)
if merge_ok and approved and not merge_failed:
if dry_run:
print(" 🎯 [DRY-RUN] 将自动合并")
else:
print(" 🎯 执行自动合并...")
ok, msg = merge_pr(args.token, args.repo, pr_num)
if ok:
print(f" ✅ 合并成功: {msg}")
merged_count += 1
else:
print(f" ⚠️ 合并失败: {msg}")
elif merge_pending:
print(" ⏳ 合并条件未满足: CI运行中")
elif merge_failed:
print(" ❌ 合并条件未满足: CI有失败")
elif not approved:
print(" ⏳ 合并条件未满足: 无审批")
print("\n=== 扫描结果 ===")
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
print(f" 自动审批: {approved_count}")
print(f" 自动合并: {merged_count}")
print(f" AI审查阻止: {ai_blocked_count}")
print(f" 跳过: {skipped_count}")
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
if __name__ == "__main__":
main()
+27 -27
View File
@@ -4,7 +4,7 @@ from packages.domain import Asset
def test_add_tag_to_asset():
"""测试添加标签到 Asset。"""
"""测试添加标签 ID 到 Asset。"""
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
@@ -13,16 +13,16 @@ def test_add_tag_to_asset():
mime_type="video/mp4",
)
asset.add_tag("风景")
asset.add_tag("自然")
asset.add_tag("tag-1")
asset.add_tag("tag-2")
assert len(asset.tags) == 2
assert "风景" in asset.tags
assert "自然" in asset.tags
assert len(asset.tag_ids) == 2
assert "tag-1" in asset.tag_ids
assert "tag-2" in asset.tag_ids
def test_add_duplicate_tag_should_ignore():
"""测试添加重复标签应自动去重。"""
"""测试添加重复标签 ID 应自动去重。"""
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
@@ -31,15 +31,15 @@ def test_add_duplicate_tag_should_ignore():
mime_type="video/mp4",
)
asset.add_tag("风景")
asset.add_tag("风景") # 重复
asset.add_tag("tag-1")
asset.add_tag("tag-1") # 重复
assert len(asset.tags) == 1
assert asset.tags.count("风景") == 1
assert len(asset.tag_ids) == 1
assert asset.tag_ids.count("tag-1") == 1
def test_add_empty_tag_should_fail():
"""测试添加空标签应失败。"""
"""测试添加空标签 ID 应失败。"""
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
@@ -48,15 +48,15 @@ def test_add_empty_tag_should_fail():
mime_type="video/mp4",
)
with pytest.raises(ValueError, match="标签不能为空"):
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag("")
with pytest.raises(ValueError, match="标签不能为空"):
with pytest.raises(ValueError, match="标签 ID 不能为空"):
asset.add_tag(" ") # 仅空格
def test_remove_tag_from_asset():
"""测试从 Asset 删除标签。"""
"""测试从 Asset 删除标签 ID"""
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
@@ -65,18 +65,18 @@ def test_remove_tag_from_asset():
mime_type="video/mp4",
)
asset.add_tag("风景")
asset.add_tag("自然")
asset.add_tag("tag-1")
asset.add_tag("tag-2")
asset.remove_tag("风景")
asset.remove_tag("tag-1")
assert len(asset.tags) == 1
assert "风景" not in asset.tags
assert "自然" in asset.tags
assert len(asset.tag_ids) == 1
assert "tag-1" not in asset.tag_ids
assert "tag-2" in asset.tag_ids
def test_remove_nonexistent_tag_should_be_idempotent():
"""测试删除不存在的标签应幂等(不报错)。"""
"""测试删除不存在的标签 ID 应幂等(不报错)。"""
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
@@ -85,10 +85,10 @@ def test_remove_nonexistent_tag_should_be_idempotent():
mime_type="video/mp4",
)
asset.add_tag("风景")
asset.add_tag("tag-1")
# 删除不存在的标签,不应报错
asset.remove_tag("不存在的标签")
# 删除不存在的标签 ID,不应报错
asset.remove_tag("nonexistent-tag")
assert len(asset.tags) == 1
assert "风景" in asset.tags
assert len(asset.tag_ids) == 1
assert "tag-1" in asset.tag_ids
+77
View File
@@ -0,0 +1,77 @@
"""批量删除素材 + 分页优化 单元测试。"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
from packages.domain import Asset, AssetStatus
class TestBatchDelete:
"""batch_delete 仓储方法测试。"""
def _make_repo_with_assets(self):
repo = InMemoryAssetRepository()
for i in range(5):
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
name=f"voice_{i}.mp3",
storage_key=f"uploads/voice_{i}.mp3",
mime_type="audio/mpeg",
status=AssetStatus.READY,
)
repo.create(asset)
return repo
def test_batch_delete_removes_multiple(self):
repo = InMemoryAssetRepository()
assets = []
for i in range(5):
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
name=f"voice_{i}.mp3",
storage_key=f"uploads/voice_{i}.mp3",
mime_type="audio/mpeg",
)
repo.create(asset)
assets.append(asset)
ids_to_delete = [assets[0].id, assets[2].id, assets[4].id]
deleted_count = repo.batch_delete(ids_to_delete)
assert deleted_count == 3
# 验证确实被删了
assert repo.get(assets[0].id) is None
assert repo.get(assets[2].id) is None
assert repo.get(assets[4].id) is None
# 验证其他还在
assert repo.get(assets[1].id) is not None
assert repo.get(assets[3].id) is not None
def test_batch_delete_empty_list(self):
repo = self._make_repo_with_assets()
assert repo.batch_delete([]) == 0
def test_batch_delete_nonexistent_ids(self):
repo = self._make_repo_with_assets()
deleted = repo.batch_delete(["nonexistent-1", "nonexistent-2"])
assert deleted == 0
def test_batch_delete_mixed_existing_and_nonexistent(self):
repo = InMemoryAssetRepository()
asset = Asset.create(
project_id="proj-1",
library_id="lib-1",
name="voice.mp3",
storage_key="uploads/voice.mp3",
mime_type="audio/mpeg",
)
repo.create(asset)
deleted = repo.batch_delete([asset.id, "nonexistent"])
assert deleted == 1
assert repo.get(asset.id) is None

Some files were not shown because too many files have changed in this diff Show More