Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea65033b01 |
+1
-2
@@ -1,2 +1 @@
|
|||||||
CI trigger file - safe to delete
|
trigger: 1784009947
|
||||||
updated!
|
|
||||||
|
|||||||
+27
-165
@@ -1,198 +1,60 @@
|
|||||||
# ============================================================
|
# 小虾 SaaS 环境变量配置
|
||||||
# 小虾 SaaS 环境变量完整配置
|
|
||||||
# ============================================================
|
|
||||||
# 本文件列出所有可配置的环境变量及默认值。
|
|
||||||
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
|
|
||||||
#
|
|
||||||
# 配置读取规则(pydantic-settings,大小写不敏感):
|
|
||||||
# 1. 系统环境变量(最高优先级)
|
|
||||||
# 2. .env.{APP_ENV} 文件(如 .env.staging)
|
|
||||||
# 3. .env 文件
|
|
||||||
# 4. 代码中的默认值(最低优先级)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
|
# ==================== 应用配置 ====================
|
||||||
# ==================== 应用基本配置 ====================
|
APP_NAME=小虾 SaaS
|
||||||
|
APP_BASE_URL=http://localhost:3000
|
||||||
# 应用名称
|
|
||||||
APP_NAME=xiaoxia-saas
|
|
||||||
|
|
||||||
# 应用版本号(展示用,代码中已内置默认)
|
|
||||||
APP_VERSION=0.1.61
|
|
||||||
|
|
||||||
# 环境标识:development / staging / production
|
|
||||||
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
|
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
|
|
||||||
# 是否开启 Debug 模式(开发环境 true,生产环境 false)
|
|
||||||
DEBUG=true
|
|
||||||
|
|
||||||
# 应用基础 URL,用于生成认证邮件、回调链接等
|
|
||||||
APP_BASE_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# API 服务监听地址(容器内绑定,外部暴露由 Docker/Nginx 控制)
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
|
|
||||||
# API 服务监听端口
|
|
||||||
API_PORT=8000
|
|
||||||
|
|
||||||
# 是否自动创建数据库表结构(开发环境可开启,生产环境用 alembic migration)
|
|
||||||
AUTO_CREATE_SCHEMA=false
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
|
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||||
|
|
||||||
# 数据库连接串(格式:postgresql+psycopg://user:password@host:port/dbname)
|
# 开发环境:使用内存数据库(不需要 PostgreSQL)
|
||||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas
|
USE_IN_MEMORY_DB=true
|
||||||
|
|
||||||
# 连接池大小(常驻连接数)
|
|
||||||
DATABASE_POOL_SIZE=20
|
|
||||||
|
|
||||||
# 连接池最大溢出连接数(pool_size + max_overflow = 最大并发连接数)
|
|
||||||
DATABASE_MAX_OVERFLOW=10
|
|
||||||
|
|
||||||
# 获取连接超时时间(秒)
|
|
||||||
DATABASE_POOL_TIMEOUT=30
|
|
||||||
|
|
||||||
# 连接回收时间(秒),防止数据库端主动断开导致的死连接
|
|
||||||
DATABASE_POOL_RECYCLE=3600
|
|
||||||
|
|
||||||
# 是否使用内存数据库(SQLite,仅开发/测试可用;生产务必 false)
|
|
||||||
USE_IN_MEMORY_DB=false
|
|
||||||
|
|
||||||
|
# 生产环境:使用 PostgreSQL
|
||||||
|
# USE_IN_MEMORY_DB=false
|
||||||
|
|
||||||
# ==================== Redis 配置 ====================
|
# ==================== Redis 配置 ====================
|
||||||
|
|
||||||
# Redis 连接 URL(格式:redis://[:password@]host:port/db)
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储)
|
# ==================== JWT 配置 ====================
|
||||||
ENABLE_REDIS_SESSIONS=false
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Celery 任务队列 ====================
|
|
||||||
|
|
||||||
# Celery Broker(任务分发),默认用 Redis db0
|
|
||||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
|
||||||
|
|
||||||
# Celery Result Backend(任务结果存储),默认用 Redis db1
|
|
||||||
CELERY_RESULT_BACKEND=redis://localhost:6379/1
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== Worker 配置 ====================
|
|
||||||
|
|
||||||
# Worker 进程名称
|
|
||||||
WORKER_NAME=xiaoxia-saas-worker
|
|
||||||
|
|
||||||
# Worker 并发数(同时执行的任务数)
|
|
||||||
WORKER_CONCURRENCY=4
|
|
||||||
|
|
||||||
# 每个子进程最多处理多少任务后重启(防止内存泄漏)
|
|
||||||
WORKER_MAX_TASKS_PER_CHILD=1000
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== JWT 认证配置 ====================
|
|
||||||
|
|
||||||
# JWT 签名密钥 — 生产环境必须设置为强随机字符串(至少32字符)
|
|
||||||
# 内置不安全值会被拒绝:secret / changeme / password / your-secret-key 等
|
|
||||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||||
|
|
||||||
# JWT 签名算法
|
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
# Access Token 过期时间(分钟)
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
|
||||||
# Refresh Token 过期时间(天)
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
|
||||||
|
|
||||||
# ==================== 邮件配置 ====================
|
# ==================== 邮件配置 ====================
|
||||||
|
|
||||||
# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用)
|
|
||||||
ENABLE_EMAIL_DELIVERY=false
|
|
||||||
|
|
||||||
# SMTP 服务器地址
|
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
|
||||||
# SMTP 端口
|
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|
||||||
# SMTP 用户名
|
|
||||||
SMTP_USER=your-email@gmail.com
|
SMTP_USER=your-email@gmail.com
|
||||||
|
|
||||||
# SMTP 密码 / 应用专用密码
|
|
||||||
SMTP_PASSWORD=your-app-specific-password
|
SMTP_PASSWORD=your-app-specific-password
|
||||||
|
|
||||||
# 发件人邮箱
|
|
||||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||||
|
|
||||||
# 发件人显示名称
|
|
||||||
SMTP_FROM_NAME=小虾 SaaS
|
SMTP_FROM_NAME=小虾 SaaS
|
||||||
|
|
||||||
# 是否启用 TLS
|
# ==================== 环境配置 ====================
|
||||||
SMTP_USE_TLS=true
|
ENVIRONMENT=development
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
# ==================== 阿里云 OSS 配置 ====================
|
|
||||||
|
|
||||||
# OSS 区域 endpoint
|
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
|
||||||
|
|
||||||
# OSS Access Key ID — 非开发环境必须设置
|
|
||||||
OSS_ACCESS_KEY_ID=your-access-key-id
|
|
||||||
|
|
||||||
# OSS Access Key Secret — 非开发环境必须设置
|
|
||||||
OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
|
||||||
|
|
||||||
# OSS Bucket 名称
|
|
||||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
|
||||||
|
|
||||||
# 直传最大文件大小(MB)
|
|
||||||
OSS_DIRECT_UPLOAD_MAX_MB=2000
|
|
||||||
|
|
||||||
# 直传签名有效期(秒)
|
|
||||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== CORS 配置 ====================
|
# ==================== CORS 配置 ====================
|
||||||
|
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||||
|
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
# 允许跨域的前端域名列表,逗号分隔
|
# ==================== 阿里云 OSS 配置 ====================
|
||||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||||
|
OSS_ACCESS_KEY_ID=your-access-key-id
|
||||||
|
OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
||||||
# ==================== 渲染引擎配置 ====================
|
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||||
|
|
||||||
# 渲染引擎选择:
|
|
||||||
# legacy — 旧 VideoComposeService(稳定,功能完整)
|
|
||||||
# unified — 新 UnifiedRenderService(新架构,部分场景仍在验证)
|
|
||||||
RENDER_ENGINE=legacy
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== CosyVoice 语音合成 ====================
|
|
||||||
# 阿里云百灵语音合成服务
|
|
||||||
# 模型选择:
|
|
||||||
# cosyvoice-v3-flash — 推荐,系统音色多,性价比高
|
|
||||||
# cosyvoice-v3-plus — 高质量,系统音色少
|
|
||||||
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus — 仅支持克隆/设计音色,无系统音色
|
|
||||||
# 音色:v3 系列系统音色带 _v3 后缀,如 longxiaochun_v3 / longxiaoxia_v3 / longanyang
|
|
||||||
|
|
||||||
|
# ==================== CosyVoice 语音合成配置 ====================
|
||||||
|
# 注意:base_url 只需写到 /api/v1,具体路径由代码拼接
|
||||||
|
# 模型: cosyvoice-v3-flash (推荐,支持系统音色,性价比高)
|
||||||
|
# cosyvoice-v3-plus (高质量,系统音色少)
|
||||||
|
# cosyvoice-v3.5-flash / cosyvoice-v3.5-plus (仅支持克隆/设计音色,无系统音色)
|
||||||
|
# 音色: v3系列系统音色带 _v3 后缀,如 longxiaochun_v3, longxiaoxia_v3, longanyang (无后缀)
|
||||||
|
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
|
||||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||||
COSYVOICE_VOICE=longxiaochun_v3
|
COSYVOICE_VOICE=longxiaochun_v3
|
||||||
COSYVOICE_SAMPLE_RATE=22050
|
COSYVOICE_SAMPLE_RATE=22050
|
||||||
COSYVOICE_FORMAT=mp3
|
COSYVOICE_FORMAT=mp3
|
||||||
|
|
||||||
# 音色克隆模型名(固定为 voice-enrollment,通常不需修改)
|
|
||||||
COSYVOICE_CLONE_MODEL=voice-enrollment
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 豆包大模型(火山引擎方舟) ====================
|
|
||||||
# 用于 AI 文案生成、智能剪辑等需要大模型能力的场景
|
|
||||||
|
|
||||||
DOUBAO_API_KEY=your-doubao-api-key
|
|
||||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
|
||||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
|
||||||
DOUBAO_TIMEOUT=30
|
|
||||||
DOUBAO_MAX_RETRIES=2
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[flake8]
|
||||||
|
max-line-length = 120
|
||||||
|
exclude =
|
||||||
|
.git,
|
||||||
|
.cache,
|
||||||
|
__pycache__,
|
||||||
|
.venv,
|
||||||
|
venv,
|
||||||
|
node_modules,
|
||||||
|
alembic
|
||||||
|
|
||||||
|
per-file-ignores =
|
||||||
|
tests/integration/*:F821
|
||||||
|
tests/unit/*:F821
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
name: ACR Cleanup
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
pr_sha:
|
|
||||||
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
dry_run:
|
|
||||||
description: "预览模式(dry-run),不实际删除"
|
|
||||||
required: false
|
|
||||||
default: "true"
|
|
||||||
pull_request_target:
|
|
||||||
types: [closed]
|
|
||||||
branches: [develop, main]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: acr-cleanup-${{ gitea.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
name: ACR Image Cleanup
|
|
||||||
runs-on: ci-l2
|
|
||||||
timeout-minutes: 20
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
env:
|
|
||||||
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
|
|
||||||
ACR_NAMESPACE: xiaoxiakeji
|
|
||||||
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
|
|
||||||
GITEA_URL: https://git.xiaoxiajianji.com
|
|
||||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
|
||||||
|
|
||||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
|
||||||
- name: Get staging running images (whitelist)
|
|
||||||
id: protected_images
|
|
||||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
|
||||||
env:
|
|
||||||
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
echo "获取staging服务器运行中镜像作为白名单..."
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
|
|
||||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
|
||||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
|
||||||
|
|
||||||
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
# 获取所有运行容器的镜像,提取tag部分
|
|
||||||
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
|
|
||||||
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
|
|
||||||
|
|
||||||
PROTECTED_TAGS=""
|
|
||||||
if [ -n "$IMAGES" ]; then
|
|
||||||
while IFS= read -r img; do
|
|
||||||
# 从完整镜像名中提取tag(最后一个冒号后)
|
|
||||||
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
|
|
||||||
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
|
|
||||||
if [ -z "$PROTECTED_TAGS" ]; then
|
|
||||||
PROTECTED_TAGS="$tag"
|
|
||||||
else
|
|
||||||
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$IMAGES"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
|
|
||||||
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
|
|
||||||
|
|
||||||
# ====== Docker登录 ======
|
|
||||||
- name: Docker login to ACR
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
|
|
||||||
|
|
||||||
# ====== 模式1:PR关闭时清理 ======
|
|
||||||
- name: Cleanup PR images (PR closed)
|
|
||||||
if: gitea.event_name == 'pull_request_target'
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
|
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
|
||||||
run: |
|
|
||||||
echo "============================================"
|
|
||||||
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
|
|
||||||
echo " Head SHA: ${PR_SHA::12}"
|
|
||||||
echo "============================================"
|
|
||||||
echo ""
|
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
|
||||||
--pr-sha "$PR_SHA" \
|
|
||||||
--execute
|
|
||||||
|
|
||||||
# ====== 模式2:Cron全量清理 ======
|
|
||||||
- name: Full cleanup (cron / manual)
|
|
||||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
|
|
||||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
|
||||||
run: |
|
|
||||||
echo "============================================"
|
|
||||||
echo " ACR 全量清理(${{ gitea.event_name }})"
|
|
||||||
echo "============================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 决定是否dry-run
|
|
||||||
DRY_RUN_FLAG=""
|
|
||||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
|
||||||
DRY_RUN_FLAG="--dry-run"
|
|
||||||
echo "模式: 预览模式 (dry-run)"
|
|
||||||
else
|
|
||||||
echo "模式: 执行模式"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
|
||||||
--keep 20 \
|
|
||||||
--protected-tags "$PROTECTED_TAGS" \
|
|
||||||
$DRY_RUN_FLAG
|
|
||||||
|
|
||||||
# ====== 模式3:手动指定PR SHA清理 ======
|
|
||||||
- name: Cleanup specific PR image (manual)
|
|
||||||
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
|
|
||||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
|
||||||
run: |
|
|
||||||
echo "手动清理PR镜像: ${PR_SHA::12}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
DRY_RUN_FLAG=""
|
|
||||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
|
||||||
DRY_RUN_FLAG="--dry-run"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
|
||||||
--pr-sha "$PR_SHA" \
|
|
||||||
$DRY_RUN_FLAG
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,78 +0,0 @@
|
|||||||
name: CI Failure Monitor
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 */6 * * *' # 每6小时检查一次
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
days:
|
|
||||||
description: '统计最近N天的失败'
|
|
||||||
required: false
|
|
||||||
default: '7'
|
|
||||||
fail_threshold:
|
|
||||||
description: '失败次数阈值'
|
|
||||||
required: false
|
|
||||||
default: '3'
|
|
||||||
fail_rate_threshold:
|
|
||||||
description: '失败率阈值(%)'
|
|
||||||
required: false
|
|
||||||
default: '30'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
monitor:
|
|
||||||
name: CI重复失败检测
|
|
||||||
runs-on: ci-l2
|
|
||||||
timeout-minutes: 10
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
|
||||||
| bash
|
|
||||||
|
|
||||||
- name: Record job start time
|
|
||||||
shell: sh
|
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
|
||||||
|
|
||||||
- name: Run failure detection
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
|
||||||
GITEA_URL: https://git.xiaoxiajianji.com
|
|
||||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
FAIL_CHECK_DAYS: ${{ inputs.days || 7 }}
|
|
||||||
FAIL_THRESHOLD: ${{ inputs.fail_threshold || 3 }}
|
|
||||||
FAIL_RATE_THRESHOLD: ${{ inputs.fail_rate_threshold || 30 }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
python3 scripts/ci/ci_repeated_failure_detector.py
|
|
||||||
EXIT_CODE=$?
|
|
||||||
echo "检测完成,退出码: $EXIT_CODE"
|
|
||||||
# 0=无异常, 1=有警告, 2=有严重问题
|
|
||||||
# 监控脚本永远不fail,避免告警风暴
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
- name: Job duration summary
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
run: bash scripts/ci/step_timer_end.sh
|
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
name: CI Health Daily Report
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00
|
|
||||||
workflow_dispatch:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
jobs:
|
|
||||||
ci-health-report:
|
|
||||||
name: CI健康度每日巡检
|
|
||||||
runs-on: ci-l2
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
python3 - <<'PY'
|
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
|
||||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
archive = response.read()
|
|
||||||
break
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
last_err = e
|
|
||||||
if e.code >= 500 and attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
if attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise last_err
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
|
||||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == root_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(root_prefix):
|
|
||||||
member.name = name[len(root_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Generate CI Dashboard HTML
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
echo "=== 生成 CI 健康度 HTML 看板 ==="
|
|
||||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo ""
|
|
||||||
python3 scripts/ci/ci_dashboard.py --days 7 --html --html-output ci_dashboard.html
|
|
||||||
EXIT_CODE=$?
|
|
||||||
if [ $EXIT_CODE -eq 0 ] && [ -f ci_dashboard.html ]; then
|
|
||||||
HTML_SIZE=$(wc -c < ci_dashboard.html)
|
|
||||||
echo ""
|
|
||||||
echo "✅ HTML 看板生成成功 (${HTML_SIZE} bytes)"
|
|
||||||
echo "路径: $(pwd)/ci_dashboard.html"
|
|
||||||
# 输出文件内容前几行,方便在 Actions 日志中确认
|
|
||||||
echo ""
|
|
||||||
echo "--- 看板预览 (前 5 行) ---"
|
|
||||||
head -5 ci_dashboard.html
|
|
||||||
echo "...(完整内容见产物文件)"
|
|
||||||
else
|
|
||||||
echo "❌ HTML 看板生成失败 (exit code: $EXIT_CODE)"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
# 永远成功,看板生成失败不影响主流程
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
- name: Run CI health check and report
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ github.token }}
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
echo "=== CI健康度每日巡检 ==="
|
|
||||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
echo ""
|
|
||||||
python3 scripts/ci/ci_health_report.py --limit 30
|
|
||||||
EXIT_CODE=$?
|
|
||||||
echo ""
|
|
||||||
echo "巡检完成 (exit code: $EXIT_CODE)"
|
|
||||||
# 永远成功,不影响CI状态(通知失败不应该标红)
|
|
||||||
exit 0
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
|||||||
name: CI Trigger Monitor
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '*/5 * * * *' # 每5分钟检查一次
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
stale_threshold:
|
|
||||||
description: 'CI未触发告警阈值(分钟)'
|
|
||||||
required: false
|
|
||||||
default: '5'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
monitor:
|
|
||||||
name: Monitor CI Trigger Reliability
|
|
||||||
runs-on: ci-l2
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
|
||||||
|
|
||||||
- name: Check CI trigger status for all open PRs
|
|
||||||
env:
|
|
||||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
|
||||||
GITEA_URL: https://git.xiaoxiajianji.com
|
|
||||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
python3 scripts/ci_trigger_monitor.py
|
|
||||||
# 监控脚本永远不fail,避免告警风暴
|
|
||||||
exit 0
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
name: AI Code Review
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
|
|
||||||
# 同一个 PR 只跑一个 review,新的取消旧的
|
|
||||||
concurrency:
|
|
||||||
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
code-review:
|
|
||||||
name: AI Code Review
|
|
||||||
runs-on: ci-l2
|
|
||||||
# 跳过草稿 PR
|
|
||||||
if: ${{ !gitea.event.pull_request.draft }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
# 确保 python3-pip 可用(兼容不同基础镜像)
|
|
||||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
|
||||||
apt-get update -qq && apt-get install -y -qq python3-pip python3-venv >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
# 部分镜像 ensurepip 方式兜底
|
|
||||||
if ! python3 -m pip --version >/dev/null 2>&1; then
|
|
||||||
python3 -m ensurepip --upgrade 2>/dev/null || curl -sS https://bootstrap.pypa.io/get-pip.py | python3
|
|
||||||
fi
|
|
||||||
python3 -m pip install --upgrade pip
|
|
||||||
python3 -m pip install requests
|
|
||||||
|
|
||||||
- name: Run AI Code Review
|
|
||||||
env:
|
|
||||||
# Gitea 配置(自动从运行环境获取)
|
|
||||||
GITEA_API_URL: ${{ gitea.server_url }}
|
|
||||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
|
||||||
REPO_NAME: ${{ gitea.repository }}
|
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
|
||||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
|
||||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
|
||||||
LLM_PROVIDER: "coze"
|
|
||||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
|
||||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
|
||||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
|
||||||
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
|
|
||||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
|
||||||
# 可选参数
|
|
||||||
MAX_DIFF_CHARS: "30000"
|
|
||||||
LLM_TIMEOUT: "120"
|
|
||||||
run: |
|
|
||||||
python3 scripts/ci_code_review.py
|
|
||||||
# 注意:脚本退出码决定job状态
|
|
||||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
|
||||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
name: Daily Health Check
|
name: Daily Health Check
|
||||||
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -13,7 +12,7 @@ jobs:
|
|||||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||||
production-smoke:
|
production-smoke:
|
||||||
name: Production Smoke Test
|
name: Production Smoke Test
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 8
|
timeout-minutes: 8
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.smoke.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
@@ -24,9 +23,47 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
set -eu
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
python3 - <<'PY'
|
||||||
| bash
|
import io, os, tarfile, time, urllib.request, urllib.error
|
||||||
|
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||||
|
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
archive = response.read()
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
if e.code >= 500 and attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||||
|
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name
|
||||||
|
if name == root_prefix[:-1]:
|
||||||
|
continue
|
||||||
|
if name.startswith(root_prefix):
|
||||||
|
member.name = name[len(root_prefix):]
|
||||||
|
if member.name:
|
||||||
|
tar.extract(member, '.')
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Production health check & smoke test
|
- name: Production health check & smoke test
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -69,22 +106,10 @@ jobs:
|
|||||||
echo "======================================"
|
echo "======================================"
|
||||||
exit $SMOKE_EXIT
|
exit $SMOKE_EXIT
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||||
staging-api-tests:
|
staging-api-tests:
|
||||||
name: Staging API Integration Tests
|
name: Staging API Integration Tests
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.smoke.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
@@ -95,15 +120,50 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
set -eu
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
python3 - <<'PY'
|
||||||
| bash
|
import io, os, tarfile, time, urllib.request, urllib.error
|
||||||
|
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||||
|
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
archive = response.read()
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
if e.code >= 500 and attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||||
|
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name
|
||||||
|
if name == root_prefix[:-1]:
|
||||||
|
continue
|
||||||
|
if name.startswith(root_prefix):
|
||||||
|
member.name = name[len(root_prefix):]
|
||||||
|
if member.name:
|
||||||
|
tar.extract(member, '.')
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Run API smoke test on staging
|
- name: Run API smoke test on staging
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
|
||||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
|
||||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
START_TIME=$(date +%s)
|
START_TIME=$(date +%s)
|
||||||
@@ -111,8 +171,8 @@ jobs:
|
|||||||
docker run --rm \
|
docker run --rm \
|
||||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||||
-e TEST_USER="$STAGING_TEST_USER" \
|
-e TEST_USER=18314979086@163.com \
|
||||||
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
|
-e TEST_PASSWORD=Ying1234 \
|
||||||
-e CLEANUP_ENABLED=1 \
|
-e CLEANUP_ENABLED=1 \
|
||||||
-e PERF_CHECK_ENABLED=1 \
|
-e PERF_CHECK_ENABLED=1 \
|
||||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||||
@@ -183,22 +243,10 @@ jobs:
|
|||||||
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
echo "report=FAIL" >> "${GITHUB_OUTPUT}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||||
staging-e2e:
|
staging-e2e:
|
||||||
name: Staging Browser E2E
|
name: Staging Browser E2E
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.smoke.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
@@ -209,9 +257,47 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
set -eu
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
python3 - <<'PY'
|
||||||
| bash
|
import io, os, tarfile, time, urllib.request, urllib.error
|
||||||
|
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||||
|
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
archive = response.read()
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
if e.code >= 500 and attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||||
|
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name
|
||||||
|
if name == root_prefix[:-1]:
|
||||||
|
continue
|
||||||
|
if name.startswith(root_prefix):
|
||||||
|
member.name = name[len(root_prefix):]
|
||||||
|
if member.name:
|
||||||
|
tar.extract(member, '.')
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Run Playwright E2E on staging
|
- name: Run Playwright E2E on staging
|
||||||
id: e2e
|
id: e2e
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -246,22 +332,10 @@ jobs:
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
exit $EXIT_CODE
|
exit $EXIT_CODE
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||||
performance-check:
|
performance-check:
|
||||||
name: Performance Baseline Check
|
name: Performance Baseline Check
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 8
|
timeout-minutes: 8
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.report.outputs.report }}
|
report: ${{ steps.report.outputs.report }}
|
||||||
@@ -270,9 +344,6 @@ jobs:
|
|||||||
- name: Run performance baseline checks
|
- name: Run performance baseline checks
|
||||||
id: perf
|
id: perf
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
|
||||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
|
||||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
START_TIME=$(date +%s)
|
START_TIME=$(date +%s)
|
||||||
@@ -308,10 +379,9 @@ jobs:
|
|||||||
|
|
||||||
# 先登录获取 token
|
# 先登录获取 token
|
||||||
echo "--- 准备: 获取测试 Token ---"
|
echo "--- 准备: 获取测试 Token ---"
|
||||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
|
||||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$LOGIN_BODY" \
|
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||||
--max-time 10 2>&1)
|
--max-time 10 2>&1)
|
||||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||||
@@ -341,7 +411,7 @@ jobs:
|
|||||||
# 构建 curl 命令
|
# 构建 curl 命令
|
||||||
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||||
if [ "$method" = "POST" ]; then
|
if [ "$method" = "POST" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||||
fi
|
fi
|
||||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||||
@@ -389,9 +459,6 @@ jobs:
|
|||||||
- name: Generate performance report
|
- name: Generate performance report
|
||||||
id: report
|
id: report
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
|
||||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
|
||||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
echo ""
|
echo ""
|
||||||
@@ -406,11 +473,10 @@ jobs:
|
|||||||
RESULTS=""
|
RESULTS=""
|
||||||
START_TIME=$(date +%s)
|
START_TIME=$(date +%s)
|
||||||
|
|
||||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
|
||||||
# 先登录获取 token
|
# 先登录获取 token
|
||||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$LOGIN_BODY" \
|
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||||
--max-time 10 2>&1)
|
--max-time 10 2>&1)
|
||||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||||
@@ -426,7 +492,7 @@ jobs:
|
|||||||
|
|
||||||
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||||
if [ "$method" = "POST" ]; then
|
if [ "$method" = "POST" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||||
fi
|
fi
|
||||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||||
@@ -514,22 +580,10 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||||
daily-report:
|
daily-report:
|
||||||
name: Daily Check Report
|
name: Daily Check Report
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
if: always()
|
if: always()
|
||||||
needs:
|
needs:
|
||||||
@@ -602,15 +656,3 @@ jobs:
|
|||||||
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
|
||||||
# 但其他失败的 job 已经让整体流水线标记为失败
|
# 但其他失败的 job 已经让整体流水线标记为失败
|
||||||
fi
|
fi
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
name: PR Auto Scan
|
|
||||||
# 定时扫描所有open PR,对CI全绿的触发审批/合并
|
|
||||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "*/5 * * * *" # 每5分钟扫描一次
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
auto-scan:
|
|
||||||
name: Auto Scan Open PRs
|
|
||||||
runs-on: ci-check
|
|
||||||
timeout-minutes: 5
|
|
||||||
if: github.repository == 'xiaoxia/xiaoxia-saas'
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/pr_auto_scan.py?ref=develop" -o /tmp/pr_auto_scan.py
|
|
||||||
python3 /tmp/pr_auto_scan.py --help > /dev/null 2>&1 || {
|
|
||||||
# fallback: checkout
|
|
||||||
echo "使用checkout方式"
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=develop" | bash
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Scan and auto process PRs
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
|
||||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
echo "=== 扫描所有open PR并自动处理 ==="
|
|
||||||
echo "时间: $(date)"
|
|
||||||
echo
|
|
||||||
|
|
||||||
python3 /tmp/pr_auto_scan.py --token "$REVIEW_TOKEN" --repo "$GITHUB_REPOSITORY" --base develop --approve --merge --dry-run false
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ 扫描完成"
|
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "" || true
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
name: PR Automation
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [synchronize, opened, ready_for_review, review_requested]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
auto-approve:
|
|
||||||
name: Auto Approve on CI Green
|
|
||||||
runs-on: ci-check
|
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
|
||||||
timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
|
||||||
|
|
||||||
- name: "🔍 脚本语法自检"
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
ERROR=0
|
|
||||||
for f in scripts/ci/*.sh; do [ -f "$f" ] && bash -n "$f" 2>&1 || ERROR=$((ERROR+1)); done
|
|
||||||
for f in scripts/ci/*.py; do [ -f "$f" ] && python3 -m py_compile "$f" 2>&1 || ERROR=$((ERROR+1)); done
|
|
||||||
if [ "$ERROR" -ne 0 ]; then echo "❌ 语法自检失败 ($ERROR个)"; exit 1; fi
|
|
||||||
echo "✅ 脚本语法自检通过"
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
bash scripts/ci/auto_approve.sh
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
auto-merge:
|
|
||||||
name: Auto Merge on CI Green + Approved
|
|
||||||
runs-on: ci-check
|
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
|
||||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
|
||||||
|
|
||||||
- name: "🔍 脚本语法自检(防止脚本bug导致所有PR挂掉)"
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "=== CI脚本语法自检 ==="
|
|
||||||
ERROR=0
|
|
||||||
for f in scripts/ci/*.sh; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
if ! bash -n "$f" 2>&1; then
|
|
||||||
echo "FAIL: $f"
|
|
||||||
ERROR=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
for f in scripts/ci/*.py; do
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
if ! python3 -m py_compile "$f" 2>&1; then
|
|
||||||
echo "FAIL: $f"
|
|
||||||
ERROR=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "$ERROR" -ne 0 ]; then
|
|
||||||
echo "❌ 脚本语法自检失败"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✅ 所有CI脚本语法自检通过"
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
||||||
run: |
|
|
||||||
bash scripts/ci/auto_merge.sh
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
name: Preview Cleanup
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- closed
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
jobs:
|
|
||||||
cleanup-preview:
|
|
||||||
name: Cleanup Preview Environment
|
|
||||||
runs-on: runtime-builder
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
python3 - <<'PY'
|
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
|
||||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
archive = response.read()
|
|
||||||
break
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
last_err = e
|
|
||||||
if e.code >= 500 and attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
if attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise last_err
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
|
||||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == root_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(root_prefix):
|
|
||||||
member.name = name[len(root_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Extract PR number
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# 优先从event payload中读取(兼容所有PR事件类型)
|
|
||||||
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
|
||||||
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
|
|
||||||
fi
|
|
||||||
# fallback: 从GITHUB_REF中提取
|
|
||||||
if [ -z "${PR_NUMBER:-}" ]; then
|
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
|
|
||||||
fi
|
|
||||||
# 再fallback: 兼容纯数字ref
|
|
||||||
if [ -z "${PR_NUMBER:-}" ] || ! echo "$PR_NUMBER" | grep -qE '^[0-9]+$'; then
|
|
||||||
echo "WARNING: Could not extract PR number cleanly, using raw ref suffix"
|
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
|
||||||
fi
|
|
||||||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
|
||||||
echo "PR number: $PR_NUMBER"
|
|
||||||
echo "Preview dir: /var/www/preview/pr-${PR_NUMBER}"
|
|
||||||
|
|
||||||
- name: Install SSH client
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# 先检查是否已存在ssh
|
|
||||||
if command -v ssh >/dev/null 2>&1 && command -v ssh-keyscan >/dev/null 2>&1; then
|
|
||||||
echo "SSH client already available: $(ssh -V 2>&1)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
# 尝试多种包管理器安装
|
|
||||||
if command -v apk >/dev/null 2>&1; then
|
|
||||||
apk add --no-cache openssh-client >/dev/null 2>&1
|
|
||||||
echo "openssh-client installed via apk"
|
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
|
||||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null 2>&1
|
|
||||||
echo "openssh-client installed via apt-get"
|
|
||||||
elif command -v yum >/dev/null 2>&1; then
|
|
||||||
yum install -y openssh-clients >/dev/null 2>&1
|
|
||||||
echo "openssh-client installed via yum"
|
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
|
||||||
dnf install -y openssh-clients >/dev/null 2>&1
|
|
||||||
echo "openssh-client installed via dnf"
|
|
||||||
else
|
|
||||||
echo "ERROR: No package manager found and ssh not pre-installed"
|
|
||||||
which ssh 2>/dev/null || echo " ssh: not found"
|
|
||||||
which ssh-keyscan 2>/dev/null || echo " ssh-keyscan: not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Remove preview directory from server
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
|
|
||||||
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
|
|
||||||
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
|
|
||||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
|
||||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
|
||||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
|
||||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
|
||||||
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
|
|
||||||
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
|
|
||||||
key_path=""
|
|
||||||
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
|
|
||||||
key_path="$HOME/.ssh/id_ed25519"
|
|
||||||
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
|
|
||||||
chmod 600 "$key_path"
|
|
||||||
echo "Using key from PREVIEW_SSH_KEY secret"
|
|
||||||
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
|
||||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
|
||||||
echo "Using key: $key_path (builder key)"
|
|
||||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
|
||||||
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
|
|
||||||
echo "Using key: $key_path (home key)"
|
|
||||||
else
|
|
||||||
echo "ERROR: No SSH key available"
|
|
||||||
ls -la ~/.ssh/ 2>/dev/null || true
|
|
||||||
ls -la /root/.ssh/ 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
echo "SSH keyscan done"
|
|
||||||
|
|
||||||
# 测试SSH连接
|
|
||||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
|
|
||||||
echo "SSH connection verified"
|
|
||||||
|
|
||||||
# 检查目录是否存在
|
|
||||||
DIR_EXISTS=$(ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
|
||||||
"if [ -d '${preview_dir}' ]; then echo 'yes'; else echo 'no'; fi")
|
|
||||||
|
|
||||||
if [ "$DIR_EXISTS" = "yes" ]; then
|
|
||||||
echo "Removing preview directory: ${preview_dir}"
|
|
||||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
|
||||||
"rm -rf ${preview_dir} && echo 'Preview directory removed successfully'"
|
|
||||||
echo "Cleanup completed: ${preview_dir}"
|
|
||||||
else
|
|
||||||
echo "Preview directory does not exist: ${preview_dir}, nothing to clean up"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Comment cleanup notice on PR
|
|
||||||
if: success()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
# 从event payload读取PR号(最可靠)
|
|
||||||
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "$GITHUB_EVENT_PATH" ]; then
|
|
||||||
PR_NUMBER=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('number',''))" < "$GITHUB_EVENT_PATH")
|
|
||||||
else
|
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -n 's|refs/pull/\([0-9]*\)/.*|\1|p')
|
|
||||||
fi
|
|
||||||
export PR_NUMBER
|
|
||||||
|
|
||||||
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py cleanup)
|
|
||||||
|
|
||||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$COMMENT_BODY" \
|
|
||||||
"$API_URL" \
|
|
||||||
> /dev/null
|
|
||||||
echo "Cleanup comment posted"
|
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
name: Preview Deploy
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- develop
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
reason:
|
|
||||||
description: "触发原因"
|
|
||||||
required: false
|
|
||||||
default: "手动触发 - 预览环境补跑"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
concurrency:
|
|
||||||
group: preview-deploy-${{ gitea.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
jobs:
|
|
||||||
deploy-preview:
|
|
||||||
name: Deploy Preview Environment
|
|
||||||
runs-on: runtime-builder
|
|
||||||
timeout-minutes: 20
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
python3 - <<'PY'
|
|
||||||
import io, os, tarfile, time, urllib.request, urllib.error
|
|
||||||
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
|
||||||
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
|
||||||
last_err = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(request, timeout=120) as response:
|
|
||||||
archive = response.read()
|
|
||||||
break
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
last_err = e
|
|
||||||
if e.code >= 500 and attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
if attempt < 4:
|
|
||||||
wait = 2 ** attempt
|
|
||||||
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
|
||||||
time.sleep(wait)
|
|
||||||
continue
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
raise last_err
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
|
||||||
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
|
||||||
for member in tar.getmembers():
|
|
||||||
name = member.name
|
|
||||||
if name == root_prefix[:-1]:
|
|
||||||
continue
|
|
||||||
if name.startswith(root_prefix):
|
|
||||||
member.name = name[len(root_prefix):]
|
|
||||||
if member.name:
|
|
||||||
tar.extract(member, '.')
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Record job start time
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
echo "JOB_START_TIME=$(date +%s)" >> $GITHUB_ENV
|
|
||||||
echo "Job started at $(date)"
|
|
||||||
|
|
||||||
- name: Extract PR number
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
|
||||||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
|
||||||
echo "PR number: $PR_NUMBER"
|
|
||||||
echo "PREVIEW_URL=https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com" >> $GITHUB_ENV
|
|
||||||
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
cd apps/web
|
|
||||||
|
|
||||||
# Config npm mirror for speed
|
|
||||||
npm config set registry https://registry.npmmirror.com
|
|
||||||
|
|
||||||
# Install dependencies with retry
|
|
||||||
for i in 1 2 3; do
|
|
||||||
npm ci --no-audit --no-fund && break
|
|
||||||
echo "npm ci failed, retry $i/3..."
|
|
||||||
[ $i -eq 3 ] && exit 1
|
|
||||||
rm -rf node_modules
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
# TypeScript check
|
|
||||||
echo "=== TypeScript check ==="
|
|
||||||
npx --no-install tsc --noEmit
|
|
||||||
|
|
||||||
# Vite build
|
|
||||||
echo "=== Vite build ==="
|
|
||||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
|
||||||
npx --no-install vite build
|
|
||||||
|
|
||||||
echo "=== Build completed ==="
|
|
||||||
ls -la dist/
|
|
||||||
|
|
||||||
- name: Install SSH client and rsync
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
if command -v apk >/dev/null 2>&1; then
|
|
||||||
apk add --no-cache openssh-client rsync >/dev/null 2>&1
|
|
||||||
elif command -v apt-get >/dev/null 2>&1; then
|
|
||||||
apt-get update -qq && apt-get install -y -qq openssh-client rsync >/dev/null 2>&1
|
|
||||||
elif command -v yum >/dev/null 2>&1; then
|
|
||||||
yum install -y openssh-clients rsync >/dev/null 2>&1
|
|
||||||
else
|
|
||||||
echo "ERROR: No package manager found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "openssh-client and rsync installed"
|
|
||||||
|
|
||||||
- name: Deploy preview to server
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
PREVIEW_SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}
|
|
||||||
PREVIEW_SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}
|
|
||||||
PREVIEW_SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}
|
|
||||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
|
||||||
preview_user="${PREVIEW_SSH_USER:-root}"
|
|
||||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
|
||||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
|
||||||
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
|
|
||||||
# 查找可用的SSH密钥(优先用 secret 里专门为 preview 配置的 key)
|
|
||||||
key_path=""
|
|
||||||
if [ -n "${PREVIEW_SSH_KEY:-}" ]; then
|
|
||||||
key_path="$HOME/.ssh/id_ed25519"
|
|
||||||
printf '%s\n' "$PREVIEW_SSH_KEY" > "$key_path"
|
|
||||||
chmod 600 "$key_path"
|
|
||||||
echo "Using key from PREVIEW_SSH_KEY secret"
|
|
||||||
elif [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
|
||||||
key_path="/root/.ssh/xiaoxia_runtime_builder"
|
|
||||||
echo "Using key: $key_path (builder key)"
|
|
||||||
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
|
||||||
key_path="$HOME/.ssh/xiaoxia_runtime_builder"
|
|
||||||
echo "Using key: $key_path (home key)"
|
|
||||||
else
|
|
||||||
echo "ERROR: No SSH key available"
|
|
||||||
ls -la ~/.ssh/ 2>/dev/null || true
|
|
||||||
ls -la /root/.ssh/ 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# SSH密钥完整性自检
|
|
||||||
if ! ssh-keygen -y -f "$key_path" > /dev/null 2>&1; then
|
|
||||||
echo "ERROR: SSH密钥损坏(private key contents do not match public)"
|
|
||||||
echo "请检查 PREVIEW_SSH_KEY secret 中的私钥是否完整正确"
|
|
||||||
echo "私钥文件大小: $(wc -c < "$key_path") 字节"
|
|
||||||
head -2 "$key_path"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "SSH key integrity check passed"
|
|
||||||
|
|
||||||
ssh-keyscan -p "$preview_port" -H "$preview_host" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
echo "SSH keyscan done"
|
|
||||||
|
|
||||||
# 测试SSH连接
|
|
||||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" "echo SSH_CONNECTION_OK && hostname"
|
|
||||||
echo "SSH connection verified"
|
|
||||||
|
|
||||||
# 创建预览目录并上传文件
|
|
||||||
ssh -p "$preview_port" -i "$key_path" -o StrictHostKeyChecking=no "${preview_user}@${preview_host}" \
|
|
||||||
"mkdir -p ${preview_dir} && echo 'Preview directory created: ${preview_dir}'"
|
|
||||||
|
|
||||||
# 使用rsync上传dist目录内容
|
|
||||||
rsync -avz --delete -e "ssh -p ${preview_port} -i ${key_path} -o StrictHostKeyChecking=no" \
|
|
||||||
apps/web/dist/ \
|
|
||||||
"${preview_user}@${preview_host}:${preview_dir}/"
|
|
||||||
|
|
||||||
echo "Preview deployed to: ${preview_dir}"
|
|
||||||
echo "Preview URL: https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
|
||||||
|
|
||||||
- name: Comment preview link on PR
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
|
||||||
PREVIEW_URL="https://pr-${PR_NUMBER}.preview.xiaoxiajianji.com"
|
|
||||||
export PR_NUMBER PREVIEW_URL
|
|
||||||
|
|
||||||
COMMENT_BODY=$(python3 scripts/ci/preview_comment.py deploy)
|
|
||||||
|
|
||||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
|
||||||
|
|
||||||
EXISTING_COMMENT_ID=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "
|
|
||||||
import sys, json
|
|
||||||
try:
|
|
||||||
for c in json.load(sys.stdin):
|
|
||||||
if '预览环境已部署' in c.get('body', ''):
|
|
||||||
print(c['id'])
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
")
|
|
||||||
|
|
||||||
if [ -n "$EXISTING_COMMENT_ID" ]; then
|
|
||||||
curl -s -X PATCH \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$COMMENT_BODY" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING_COMMENT_ID}" \
|
|
||||||
> /dev/null
|
|
||||||
echo "Comment updated"
|
|
||||||
else
|
|
||||||
curl -s -X POST \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$COMMENT_BODY" \
|
|
||||||
"$API_URL" \
|
|
||||||
> /dev/null
|
|
||||||
echo "Comment posted"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Job duration summary
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set +eu
|
|
||||||
if [ -n "$JOB_START_TIME" ]; then
|
|
||||||
END_TIME=$(date +%s)
|
|
||||||
DURATION=$((END_TIME - JOB_START_TIME))
|
|
||||||
MINS=$((DURATION / 60))
|
|
||||||
SECS=$((DURATION % 60))
|
|
||||||
echo "JOB_DURATION_SECONDS=$DURATION" >> $GITHUB_ENV
|
|
||||||
echo "=== Job Duration: ${MINS}m${SECS}s ==="
|
|
||||||
else
|
|
||||||
echo "JOB_DURATION_SECONDS=0" >> $GITHUB_ENV
|
|
||||||
echo "=== Job Duration: unknown ==="
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Notify on failure
|
|
||||||
continue-on-error: true
|
|
||||||
if: failure()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
name: Worker Base Image Build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- 'requirements-base.txt'
|
|
||||||
- 'requirements-worker.txt'
|
|
||||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
|
||||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
|
||||||
workflow_dispatch: # 支持手动触发
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-worker-base:
|
|
||||||
name: Build Worker Base Images
|
|
||||||
runs-on: runtime-builder
|
|
||||||
timeout-minutes: 30
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: builder
|
|
||||||
dockerfile: infra/docker/worker-base-builder.Dockerfile
|
|
||||||
image_name: worker-base-builder
|
|
||||||
cache_name: worker-base-builder-cache
|
|
||||||
- name: runtime
|
|
||||||
dockerfile: infra/docker/worker-base-runtime.Dockerfile
|
|
||||||
image_name: worker-base-runtime
|
|
||||||
cache_name: worker-base-runtime-cache
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
|
||||||
|
|
||||||
- name: Docker login to Registry
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
GITEA_REGISTRY_USER: xiaoxia
|
|
||||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
for i in 1 2 3; do
|
|
||||||
echo "=== Docker login 尝试 $i/3 ==="
|
|
||||||
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
|
|
||||||
echo "✅ Docker login successful"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Setup buildx builder
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
|
|
||||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
|
||||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
|
||||||
echo "Created $BUILDER_NAME"
|
|
||||||
else
|
|
||||||
docker buildx use "$BUILDER_NAME"
|
|
||||||
echo "Using existing $BUILDER_NAME"
|
|
||||||
fi
|
|
||||||
docker buildx inspect --bootstrap
|
|
||||||
|
|
||||||
- name: Build and push base image
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
|
||||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
|
|
||||||
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
|
|
||||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
|
|
||||||
|
|
||||||
echo "=== Building ${{ matrix.name }} base image ==="
|
|
||||||
echo "Image: ${IMAGE_TAG}"
|
|
||||||
echo "Cache: ${CACHE_REF}"
|
|
||||||
|
|
||||||
# 用通用构建脚本
|
|
||||||
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
|
|
||||||
|
|
||||||
# 同时推送到 Gitea Packages 作为备份(可选)
|
|
||||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
|
|
||||||
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
|
|
||||||
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ ${{ matrix.name }} base image built and pushed"
|
|
||||||
|
|
||||||
- name: Cleanup buildx builder
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
|
|
||||||
docker buildx prune -f 2>/dev/null || true
|
|
||||||
echo "Builder cleanup done"
|
|
||||||
@@ -49,8 +49,3 @@ build/
|
|||||||
tracker_tasks.json
|
tracker_tasks.json
|
||||||
|
|
||||||
frontend-v21-ui-prototype-final.html
|
frontend-v21-ui-prototype-final.html
|
||||||
|
|
||||||
!.vscode/
|
|
||||||
!.vscode/settings.json
|
|
||||||
.vscode/extensions.json
|
|
||||||
.coverage
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 26.5.1
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
language_version: python3.12
|
|
||||||
|
|
||||||
- repo: https://github.com/pycqa/isort
|
|
||||||
rev: 8.0.1
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
args: ["--profile", "black"]
|
|
||||||
language_version: python3.12
|
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
||||||
rev: v0.14.0
|
|
||||||
hooks:
|
|
||||||
- id: ruff
|
|
||||||
args: [--fix]
|
|
||||||
language_version: python3.12
|
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v4.0.0-alpha.11
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
name: prettier (frontend)
|
|
||||||
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
|
|
||||||
additional_dependencies:
|
|
||||||
- prettier@3.4.2
|
|
||||||
Vendored
-49
@@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"[python]": {
|
|
||||||
"editor.defaultFormatter": "ms-python.black-formatter",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.organizeImports": "explicit"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"[typescript]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[typescriptreact]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[javascript]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[javascriptreact]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[json]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[css]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[scss]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[html]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"[markdown]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true
|
|
||||||
},
|
|
||||||
"prettier.requireConfig": true,
|
|
||||||
"isort.args": ["--profile", "black"],
|
|
||||||
"python.linting.ruffEnabled": true,
|
|
||||||
"python.analysis.typeCheckingMode": "basic"
|
|
||||||
}
|
|
||||||
Executable → Regular
+4
-7
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
@@ -7,17 +8,13 @@ from alembic import context
|
|||||||
# Import your models' Base here
|
# Import your models' Base here
|
||||||
from packages.adapters.sqlalchemy_impl.models import Base
|
from packages.adapters.sqlalchemy_impl.models import Base
|
||||||
|
|
||||||
# 使用统一配置入口获取 database_url,而非直接读环境变量
|
|
||||||
from packages.config import get_shared_settings
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# 从统一配置系统获取 database_url,确保与应用使用同一配置源
|
database_url = os.getenv("DATABASE_URL")
|
||||||
settings = get_shared_settings()
|
if database_url:
|
||||||
if settings.database_url:
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""add result_count to edit_plans
|
|
||||||
|
|
||||||
Revision ID: 041_result_count
|
|
||||||
Revises: 040_playback_speed
|
|
||||||
Create Date: 2026-07-15 14:05:00.000000
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "041_result_count"
|
|
||||||
down_revision = "040_playback_speed"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"edit_plans",
|
|
||||||
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("edit_plans", "result_count")
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""add storage_key to assets
|
|
||||||
|
|
||||||
Revision ID: 042_storage_key
|
|
||||||
Revises: 041_result_count
|
|
||||||
Create Date: 2026-07-17 18:10:00.000000
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "042_storage_key"
|
|
||||||
down_revision = "041_result_count"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"assets",
|
|
||||||
sa.Column("storage_key", sa.String(500), nullable=False, server_default=""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("assets", "storage_key")
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""add updated_at to generation_tasks
|
|
||||||
|
|
||||||
Revision ID: 043_updated_at_generation_tasks
|
|
||||||
Revises: 042_storage_key
|
|
||||||
Create Date: 2026-07-18 19:30:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "043_updated_at_generation_tasks"
|
|
||||||
down_revision = "042_storage_key"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column(
|
|
||||||
"updated_at",
|
|
||||||
sa.DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.func.now(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("generation_tasks", "updated_at")
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""add user_id to generated_videos
|
|
||||||
|
|
||||||
Revision ID: 044_user_id_generated_videos
|
|
||||||
Revises: 043_updated_at_generation_tasks
|
|
||||||
Create Date: 2026-07-19 08:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "044_user_id_generated_videos"
|
|
||||||
down_revision = "043_updated_at_generation_tasks"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"generated_videos",
|
|
||||||
sa.Column(
|
|
||||||
"user_id",
|
|
||||||
sa.String(36),
|
|
||||||
nullable=False,
|
|
||||||
server_default="",
|
|
||||||
index=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("generated_videos", "user_id")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""backfill user_id for generated_videos from generation_tasks
|
|
||||||
|
|
||||||
Revision ID: 045_backfill_user_id_generated_videos
|
|
||||||
Revises: 044_user_id_generated_videos
|
|
||||||
Create Date: 2026-07-19 10:50:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "045_backfill_user_id"
|
|
||||||
down_revision = "044_user_id_generated_videos"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 回填 generated_videos.user_id:通过 generation_task_id 关联 generation_tasks 表
|
|
||||||
# 取 generation_tasks.created_by_user_id 作为 user_id
|
|
||||||
# 回填不到的(无关联task的兜底记录)保持空字符串
|
|
||||||
op.execute("""
|
|
||||||
UPDATE generated_videos gv
|
|
||||||
SET user_id = gt.created_by_user_id
|
|
||||||
FROM generation_tasks gt
|
|
||||||
WHERE gv.generation_task_id = gt.id
|
|
||||||
AND gv.user_id = ''
|
|
||||||
AND gt.created_by_user_id != ''
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 降级不做处理(无法精确区分哪些是回填的)
|
|
||||||
pass
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
"""add video_title to generation_tasks
|
|
||||||
|
|
||||||
Revision ID: 046_add_video_title_to_generation_tasks
|
|
||||||
Revises: 045_backfill_user_id_generated_videos
|
|
||||||
Create Date: 2026-07-19 11:20:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = "046_task_title"
|
|
||||||
down_revision = "045_backfill_user_id"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column(
|
|
||||||
"video_title",
|
|
||||||
sa.String(255),
|
|
||||||
nullable=False,
|
|
||||||
server_default="",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("generation_tasks", "video_title")
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Phase 2 - 模板发布版本化:version字段 + 发布历史表
|
|
||||||
|
|
||||||
Revision ID: 047
|
|
||||||
Revises: 046
|
|
||||||
Create Date: 2026-07-20
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. edit_templates 加 version 字段(INT,默认1,每次发布+1)
|
|
||||||
2. 新建 edit_template_versions 表存发布历史快照,支持回滚
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "047_template_versioning"
|
|
||||||
down_revision = "046_task_title"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# 1. edit_templates 加 version 字段
|
|
||||||
op.add_column(
|
|
||||||
"edit_templates",
|
|
||||||
sa.Column("version", sa.Integer, nullable=False, server_default="1"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 新建 edit_template_versions 发布历史表
|
|
||||||
conn.execute(sa.text("""
|
|
||||||
CREATE TABLE IF NOT EXISTS edit_template_versions (
|
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
|
||||||
template_id VARCHAR(32) NOT NULL,
|
|
||||||
version INTEGER NOT NULL,
|
|
||||||
name VARCHAR(200) NOT NULL DEFAULT '',
|
|
||||||
editing_mode VARCHAR(30) NOT NULL DEFAULT 'one_take',
|
|
||||||
config JSONB NOT NULL DEFAULT '{}',
|
|
||||||
clip_configs JSONB NOT NULL DEFAULT '[]',
|
|
||||||
change_note VARCHAR(500) NOT NULL DEFAULT '',
|
|
||||||
published_by VARCHAR(36) NOT NULL DEFAULT '',
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
"""))
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_edit_template_versions_template_id " "ON edit_template_versions(template_id)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_edit_template_versions_template_version "
|
|
||||||
"ON edit_template_versions(template_id, version)"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
conn.execute(sa.text("DROP TABLE IF EXISTS edit_template_versions"))
|
|
||||||
op.drop_column("edit_templates", "version")
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""Phase 3 - 清理 EditPlan 表冗余字段
|
|
||||||
|
|
||||||
Revision ID: 048
|
|
||||||
Revises: 047
|
|
||||||
Create Date: 2026-07-21
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. 删除 edit_plans.result_count 字段(剪辑计划独立功能遗留,模板草稿不用,
|
|
||||||
生成结果数由 generation_tasks.result_count 承载)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "048_cleanup_result_count"
|
|
||||||
down_revision = "047_template_versioning"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 删除 result_count 字段(剪辑计划独立功能遗留字段)
|
|
||||||
op.drop_column("edit_plans", "result_count")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# 回滚:恢复 result_count 字段,默认值 0
|
|
||||||
op.add_column(
|
|
||||||
"edit_plans",
|
|
||||||
sa.Column(
|
|
||||||
"result_count",
|
|
||||||
sa.Integer,
|
|
||||||
nullable=False,
|
|
||||||
server_default="0",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"""#558 - 微信登录:手机号绑定字段 + 验证码表
|
|
||||||
|
|
||||||
Revision ID: 049
|
|
||||||
Revises: 048
|
|
||||||
Create Date: 2026-07-21
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. users 表新增 phone_verified / binding_completed_at 字段(phone 字段已在 029 中添加)
|
|
||||||
2. users 表 phone 字段添加唯一索引(幂等)
|
|
||||||
3. 新建 verification_codes 表(统一管理邮箱+手机验证码)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import context, op
|
|
||||||
|
|
||||||
revision = "049_wechat_login_phone"
|
|
||||||
down_revision = "048_cleanup_result_count"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def _column_exists(table: str, column: str) -> bool:
|
|
||||||
"""检查列是否已存在。离线模式下返回 False。"""
|
|
||||||
if context.is_offline_mode():
|
|
||||||
return False
|
|
||||||
conn = op.get_bind()
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text("SELECT 1 FROM information_schema.columns " "WHERE table_name = :table AND column_name = :column"),
|
|
||||||
{"table": table, "column": column},
|
|
||||||
)
|
|
||||||
return result.first() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _index_exists(index_name: str) -> bool:
|
|
||||||
"""检查索引是否已存在。离线模式下返回 False。"""
|
|
||||||
if context.is_offline_mode():
|
|
||||||
return False
|
|
||||||
conn = op.get_bind()
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
|
||||||
{"index_name": index_name},
|
|
||||||
)
|
|
||||||
return result.first() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# 1. users 表新增手机号验证状态字段(幂等)
|
|
||||||
if not _column_exists("users", "phone_verified"):
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column(
|
|
||||||
"phone_verified",
|
|
||||||
sa.Boolean,
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("false"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not _column_exists("users", "binding_completed_at"):
|
|
||||||
op.add_column(
|
|
||||||
"users",
|
|
||||||
sa.Column("binding_completed_at", sa.DateTime, nullable=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. phone 字段唯一索引(幂等 - 029 加了字段但没加索引)
|
|
||||||
if not _index_exists("ix_users_phone"):
|
|
||||||
op.create_index("ix_users_phone", "users", ["phone"], unique=True)
|
|
||||||
|
|
||||||
# 3. verification_codes 表
|
|
||||||
op.create_table(
|
|
||||||
"verification_codes",
|
|
||||||
sa.Column("id", sa.String(36), primary_key=True),
|
|
||||||
sa.Column("recipient", sa.String(255), nullable=False, index=True),
|
|
||||||
sa.Column("code", sa.String(10), nullable=False),
|
|
||||||
sa.Column("code_type", sa.String(32), nullable=False, index=True),
|
|
||||||
sa.Column("expires_at", sa.DateTime, nullable=False),
|
|
||||||
sa.Column("used_at", sa.DateTime, nullable=True),
|
|
||||||
sa.Column("attempts", sa.Integer, nullable=False, server_default="0"),
|
|
||||||
sa.Column("created_at", sa.DateTime, nullable=False),
|
|
||||||
sa.Index(
|
|
||||||
"ix_verification_recipient_type",
|
|
||||||
"recipient",
|
|
||||||
"code_type",
|
|
||||||
"created_at",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("verification_codes")
|
|
||||||
if _index_exists("ix_users_phone"):
|
|
||||||
op.drop_index("ix_users_phone", table_name="users")
|
|
||||||
if _column_exists("users", "binding_completed_at"):
|
|
||||||
op.drop_column("users", "binding_completed_at")
|
|
||||||
if _column_exists("users", "phone_verified"):
|
|
||||||
op.drop_column("users", "phone_verified")
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""#P3-2 - 视频分享表 video_shares
|
|
||||||
|
|
||||||
Revision ID: 050
|
|
||||||
Revises: 049
|
|
||||||
Create Date: 2026-07-22
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. 新建 video_shares 表,支持视频匿名分享链接
|
|
||||||
2. share_token 唯一索引,用于公开分享URL
|
|
||||||
3. 支持密码保护、有效期、浏览/下载计数
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import context, op
|
|
||||||
|
|
||||||
revision = "050_video_shares"
|
|
||||||
down_revision = "049_wechat_login_phone"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
# 检查表是否已存在(幂等)
|
|
||||||
result = conn.execute(sa.text("SELECT to_regclass('public.video_shares')"))
|
|
||||||
if result.scalar() is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"video_shares",
|
|
||||||
sa.Column("id", sa.String(32), primary_key=True),
|
|
||||||
sa.Column("video_id", sa.String(32), nullable=False, index=True),
|
|
||||||
sa.Column("user_id", sa.String(32), nullable=False, index=True),
|
|
||||||
sa.Column("share_token", sa.String(16), nullable=False, unique=True),
|
|
||||||
sa.Column("password_hash", sa.String(255), nullable=True),
|
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column("view_count", sa.Integer, nullable=False, server_default="0"),
|
|
||||||
sa.Column("download_count", sa.Integer, nullable=False, server_default="0"),
|
|
||||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("video_shares")
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""#632 - 一键生成输出分辨率可配置
|
|
||||||
|
|
||||||
Revision ID: 051
|
|
||||||
Revises: 050
|
|
||||||
Create Date: 2026-07-23
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. generation_tasks 表新增 resolution 字段,存储用户指定的输出分辨率(如 "1280x720")
|
|
||||||
2. 为空时使用默认值(1280x720)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import context, op
|
|
||||||
|
|
||||||
revision = "051_generation_task_resolution"
|
|
||||||
down_revision = "050_video_shares"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
# 检查列是否已存在(幂等)
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("resolution", sa.String(20), nullable=False, server_default=""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'resolution'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.drop_column("generation_tasks", "resolution")
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""#642 - 生成任务新增 bgm_config 字段
|
|
||||||
|
|
||||||
Revision ID: 052_generation_task_bgm_config
|
|
||||||
Revises: 051_generation_task_resolution
|
|
||||||
Create Date: 2026-07-25
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
|
|
||||||
2. 为空时使用默认空字典
|
|
||||||
|
|
||||||
背景:
|
|
||||||
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
|
|
||||||
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
|
|
||||||
创建生成任务时直接 500。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import context, op
|
|
||||||
|
|
||||||
revision = "052_generation_task_bgm_config"
|
|
||||||
down_revision = "051_generation_task_resolution"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
# 检查列是否已存在(幂等)
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column(
|
|
||||||
"bgm_config",
|
|
||||||
sa.JSON,
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("'{}'::json"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
# 检查列是否存在(幂等)
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'bgm_config'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.drop_column("generation_tasks", "bgm_config")
|
|
||||||
Binary file not shown.
@@ -1,4 +1,3 @@
|
|||||||
from app.api.routes.ai import router as ai_router
|
|
||||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||||
from app.api.routes.assets import router as assets_router
|
from app.api.routes.assets import router as assets_router
|
||||||
@@ -6,18 +5,17 @@ from app.api.routes.auth import router as auth_router
|
|||||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||||
from app.api.routes.duplication import router as duplication_router
|
from app.api.routes.duplication import router as duplication_router
|
||||||
|
from app.api.routes.edit_plans import router as edit_plans_router
|
||||||
from app.api.routes.feature_flags import router as feature_flags_router
|
from app.api.routes.feature_flags import router as feature_flags_router
|
||||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||||
from app.api.routes.health import router as health_check_router
|
from app.api.routes.health import router as health_check_router
|
||||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||||
from app.api.routes.internal_render import router as internal_render_router
|
from app.api.routes.internal_render import router as internal_render_router
|
||||||
from app.api.routes.projects import router as projects_router
|
from app.api.routes.projects import router as projects_router
|
||||||
from app.api.routes.share import router as share_router
|
|
||||||
from app.api.routes.subscription import router as subscription_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.tags import router as tags_router
|
||||||
from app.api.routes.task_center import router as task_center_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.templates import router as templates_router
|
||||||
from app.api.routes.templates_editor import router as templates_editor_router
|
|
||||||
from app.api.routes.titles import router as titles_router
|
from app.api.routes.titles import router as titles_router
|
||||||
from app.api.routes.tts import router as tts_router
|
from app.api.routes.tts import router as tts_router
|
||||||
from app.api.routes.upload import router as upload_router
|
from app.api.routes.upload import router as upload_router
|
||||||
@@ -106,10 +104,6 @@ api_router.include_router(
|
|||||||
videos_router,
|
videos_router,
|
||||||
tags=["VideoCenter"],
|
tags=["VideoCenter"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
share_router,
|
|
||||||
tags=["Share"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
duplication_router,
|
duplication_router,
|
||||||
prefix="/duplication",
|
prefix="/duplication",
|
||||||
@@ -126,20 +120,15 @@ api_router.include_router(
|
|||||||
tags=["Template"],
|
tags=["Template"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
templates_editor_router,
|
edit_plans_router,
|
||||||
prefix="/templates/{template_id}/editor",
|
prefix="/edit-plans",
|
||||||
tags=["TemplateEditor"],
|
tags=["EditPlan"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
tts_router,
|
tts_router,
|
||||||
prefix="/tts",
|
prefix="/tts",
|
||||||
tags=["TTS"],
|
tags=["TTS"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
ai_router,
|
|
||||||
prefix="/ai",
|
|
||||||
tags=["AI"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
feature_flags_router,
|
feature_flags_router,
|
||||||
tags=["Internal"],
|
tags=["Internal"],
|
||||||
|
|||||||
Executable → Regular
-93
@@ -1,6 +1,5 @@
|
|||||||
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -47,95 +46,3 @@ def require_project_and_library(
|
|||||||
libraries = asset_library_repository.find_by_project(project_id)
|
libraries = asset_library_repository.find_by_project(project_id)
|
||||||
if not any(item.id == library_id for item in libraries):
|
if not any(item.id == library_id for item in libraries):
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||||
|
|
||||||
|
|
||||||
def auto_select_video_assets(
|
|
||||||
*,
|
|
||||||
project_id: str,
|
|
||||||
asset_library_repo: Any,
|
|
||||||
asset_repo: Any,
|
|
||||||
logger=None,
|
|
||||||
) -> list[str]:
|
|
||||||
"""从项目视频素材库自动选取 ready 状态的视频素材。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_id: 项目 ID
|
|
||||||
asset_library_repo: 素材库仓储
|
|
||||||
asset_repo: 素材仓储
|
|
||||||
logger: 可选的 logger 实例,用于记录警告
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
选中的素材 ID 列表,无可用素材时返回空列表
|
|
||||||
"""
|
|
||||||
if not project_id:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 找到项目的视频素材库
|
|
||||||
libs = asset_library_repo.find_by_project(project_id)
|
|
||||||
video_lib = None
|
|
||||||
for lib in libs:
|
|
||||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
|
||||||
if lib_kind == "video":
|
|
||||||
video_lib = lib
|
|
||||||
break
|
|
||||||
|
|
||||||
if not video_lib:
|
|
||||||
if logger:
|
|
||||||
logger.warning("自动选素材: 项目 %s 无视频素材库", project_id)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 从素材库中选取 ready 状态的视频素材
|
|
||||||
assets = asset_repo.find_by_library(video_lib.id)
|
|
||||||
ready_videos = [
|
|
||||||
a
|
|
||||||
for a in assets
|
|
||||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
|
||||||
and a.mime_type
|
|
||||||
and a.mime_type.startswith("video")
|
|
||||||
]
|
|
||||||
|
|
||||||
# 过滤横屏素材(只保留竖屏/正方形)
|
|
||||||
# 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用
|
|
||||||
# 注意:这只是选素材优化,渲染引擎本身支持任何分辨率的素材
|
|
||||||
filtered_videos = []
|
|
||||||
skipped_landscape = 0
|
|
||||||
for a in ready_videos:
|
|
||||||
width = a.width if hasattr(a, "width") and a.width else 0
|
|
||||||
height = a.height if hasattr(a, "height") and a.height else 0
|
|
||||||
if not width or not height:
|
|
||||||
# 从 metadata 兜底
|
|
||||||
if a.metadata and isinstance(a.metadata, dict):
|
|
||||||
width = int(a.metadata.get("width", 0) or 0)
|
|
||||||
height = int(a.metadata.get("height", 0) or 0)
|
|
||||||
if width and height and width > height:
|
|
||||||
skipped_landscape += 1
|
|
||||||
continue
|
|
||||||
filtered_videos.append(a)
|
|
||||||
|
|
||||||
if skipped_landscape and logger:
|
|
||||||
logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape)
|
|
||||||
|
|
||||||
if not filtered_videos:
|
|
||||||
if logger:
|
|
||||||
logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# 按创建时间降序(新素材在前)
|
|
||||||
filtered_videos.sort(key=lambda a: a.created_at, reverse=True)
|
|
||||||
return [a.id for a in filtered_videos]
|
|
||||||
|
|
||||||
|
|
||||||
def format_utc_datetime(dt: datetime | None) -> str:
|
|
||||||
"""将数据库读出的 UTC naive datetime 格式化为带时区的 ISO 8601 字符串。
|
|
||||||
|
|
||||||
数据库 DateTime 列不带时区信息,但存的是 UTC 时间。
|
|
||||||
直接 .isoformat() 输出无时区标识,前端会按本地时间解析,导致差 8 小时。
|
|
||||||
输出带 Z 后缀,前端 new Date() 自动转本地时间。
|
|
||||||
"""
|
|
||||||
if dt is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(dt, str):
|
|
||||||
return dt
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.isoformat() + "Z"
|
|
||||||
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
"""AI 相关接口 — 智能标题、智能素材匹配等.
|
|
||||||
|
|
||||||
基于豆包大模型的 AI 能力接口,未配置 API Key 时自动降级为本地模拟。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import List, Literal
|
|
||||||
|
|
||||||
from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ── 请求/响应模型 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateTitlesRequest(BaseModel):
|
|
||||||
"""智能标题生成请求."""
|
|
||||||
|
|
||||||
description: str = Field(..., min_length=1, max_length=500, description="视频内容描述")
|
|
||||||
style: Literal["viral", "emotional", "informative"] = Field(
|
|
||||||
default="viral",
|
|
||||||
description="标题风格:viral爆款 / emotional情感 / informative信息",
|
|
||||||
)
|
|
||||||
count: int = Field(default=5, ge=3, le=10, description="生成数量,3-10个")
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateTitlesResponse(BaseModel):
|
|
||||||
"""智能标题生成响应."""
|
|
||||||
|
|
||||||
titles: List[str] = Field(..., description="生成的标题列表")
|
|
||||||
style: str = Field(..., description="实际使用的风格")
|
|
||||||
source: str = Field(..., description="来源:doubao 或 fallback")
|
|
||||||
description: str = Field(..., description="原始描述")
|
|
||||||
|
|
||||||
|
|
||||||
class TitleStyleInfo(BaseModel):
|
|
||||||
"""标题风格信息."""
|
|
||||||
|
|
||||||
key: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
|
|
||||||
|
|
||||||
# ── 素材语义匹配 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AssetMatchItem(BaseModel):
|
|
||||||
"""待匹配素材项."""
|
|
||||||
|
|
||||||
id: str = Field(..., description="素材ID")
|
|
||||||
name: str = Field(default="", description="素材名称")
|
|
||||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
|
||||||
description: str = Field(default="", description="素材描述")
|
|
||||||
|
|
||||||
|
|
||||||
class SemanticMatchRequest(BaseModel):
|
|
||||||
"""语义匹配请求."""
|
|
||||||
|
|
||||||
description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述")
|
|
||||||
assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表")
|
|
||||||
top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部")
|
|
||||||
|
|
||||||
|
|
||||||
class SemanticMatchResultItem(AssetMatchItem):
|
|
||||||
"""匹配结果项."""
|
|
||||||
|
|
||||||
match_score: float = Field(..., description="匹配度评分 0-1")
|
|
||||||
match_reason: str = Field(..., description="匹配方式:doubao_semantic / fallback_keyword / fallback_default")
|
|
||||||
|
|
||||||
|
|
||||||
class SemanticMatchResponse(BaseModel):
|
|
||||||
"""语义匹配响应."""
|
|
||||||
|
|
||||||
matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表")
|
|
||||||
source: str = Field(..., description="来源:doubao / fallback")
|
|
||||||
description: str = Field(..., description="原始描述")
|
|
||||||
total: int = Field(..., description="输入素材总数")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 路由 ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/titles/generate", response_model=GenerateTitlesResponse)
|
|
||||||
def generate_titles(request: GenerateTitlesRequest):
|
|
||||||
"""生成智能标题.
|
|
||||||
|
|
||||||
根据视频描述生成指定风格的标题,支持爆款、情感、信息三种风格。
|
|
||||||
未配置豆包 API Key 时自动降级为本地规则生成。
|
|
||||||
"""
|
|
||||||
result = generate_smart_titles(
|
|
||||||
description=request.description,
|
|
||||||
style=request.style,
|
|
||||||
count=request.count,
|
|
||||||
)
|
|
||||||
return GenerateTitlesResponse(**result)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/titles/styles", response_model=List[TitleStyleInfo])
|
|
||||||
def list_title_styles():
|
|
||||||
"""获取支持的标题风格列表."""
|
|
||||||
return [
|
|
||||||
TitleStyleInfo(key=key, name=info["name"], description=info["description"])
|
|
||||||
for key, info in TITLE_STYLES.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/assets/match", response_model=SemanticMatchResponse)
|
|
||||||
def match_assets(request: SemanticMatchRequest):
|
|
||||||
"""智能素材语义匹配.
|
|
||||||
|
|
||||||
根据用户描述,对素材列表做语义匹配并按匹配度排序。
|
|
||||||
未配置豆包 API Key 时自动降级为关键词匹配。
|
|
||||||
|
|
||||||
- 支持最多 100 个素材同时匹配
|
|
||||||
- 返回 match_score (0-1),按降序排列
|
|
||||||
- top_k 可限制返回数量
|
|
||||||
"""
|
|
||||||
# 转为 dict 传给服务层
|
|
||||||
assets_dict = [asset.model_dump() for asset in request.assets]
|
|
||||||
|
|
||||||
result = semantic_match_assets(
|
|
||||||
description=request.description,
|
|
||||||
assets=assets_dict,
|
|
||||||
top_k=request.top_k,
|
|
||||||
)
|
|
||||||
|
|
||||||
return SemanticMatchResponse(
|
|
||||||
matches=[SemanticMatchResultItem(**m) for m in result["matches"]],
|
|
||||||
source=result["source"],
|
|
||||||
description=result["description"],
|
|
||||||
total=result["total"],
|
|
||||||
)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from app.api.routes._helpers import check_project_access, format_utc_datetime
|
from app.api.routes._helpers import check_project_access
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.storage import get_storage_service
|
from app.core.storage import get_storage_service
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
@@ -71,7 +71,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
|||||||
status=item.status.value,
|
status=item.status.value,
|
||||||
classification_status=item.classification_status.value,
|
classification_status=item.classification_status.value,
|
||||||
quality_score=item.quality_score,
|
quality_score=item.quality_score,
|
||||||
created_at=format_utc_datetime(item.created_at),
|
|
||||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||||
tag_ids=getattr(item, "tag_ids", []),
|
tag_ids=getattr(item, "tag_ids", []),
|
||||||
)
|
)
|
||||||
@@ -95,12 +94,6 @@ def list_assets(
|
|||||||
None,
|
None,
|
||||||
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
|
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
|
||||||
),
|
),
|
||||||
status: Optional[str] = Query(
|
|
||||||
"default",
|
|
||||||
description="按状态筛选,逗号分隔多值;默认返回除deleted外的所有状态;传deleted查看回收站;传all返回所有状态",
|
|
||||||
),
|
|
||||||
page: Optional[int] = Query(None, ge=1, description="页码,从1开始;与 page_size 配对使用,优先于 skip/limit"),
|
|
||||||
page_size: Optional[int] = Query(None, ge=1, le=500, description="每页数量;与 page 配对使用"),
|
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -110,26 +103,6 @@ def list_assets(
|
|||||||
) -> ListAssetsResponse:
|
) -> ListAssetsResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
|
|
||||||
# ── 分页:page/page_size 优先于 skip/limit
|
|
||||||
if page is not None and page_size is not None:
|
|
||||||
skip = (page - 1) * page_size
|
|
||||||
limit = page_size
|
|
||||||
|
|
||||||
# ── 解析 status 过滤
|
|
||||||
status_list: list[str] | None
|
|
||||||
if status and status.lower() == "all":
|
|
||||||
status_list = None # None = 不过滤,返回所有状态
|
|
||||||
elif status and status.lower() == "deleted":
|
|
||||||
status_list = ["deleted"] # 仅查回收站
|
|
||||||
elif status and status.lower() == "default":
|
|
||||||
status_list = ["ready", "uploading", "processing", "error"] # 默认排除deleted
|
|
||||||
elif status:
|
|
||||||
status_list = [s.strip() for s in status.split(",") if s.strip()]
|
|
||||||
if not status_list:
|
|
||||||
status_list = ["ready", "uploading", "processing", "error"]
|
|
||||||
else:
|
|
||||||
status_list = ["ready", "uploading", "processing", "error"]
|
|
||||||
|
|
||||||
# kind → file_type 映射(voice 对应 audio)
|
# kind → file_type 映射(voice 对应 audio)
|
||||||
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
|
kind_to_file_type = {"video": "video", "voice": "audio", "image": "image"}
|
||||||
|
|
||||||
@@ -198,13 +171,11 @@ def list_assets(
|
|||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||||
check_project_access(library.project_id, user_id, project_repository)
|
check_project_access(library.project_id, user_id, project_repository)
|
||||||
if ft:
|
if ft:
|
||||||
items = asset_repository.find_by_library_and_file_type(
|
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
||||||
library_id, ft, skip=skip, limit=limit, status=status_list
|
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
||||||
)
|
|
||||||
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
|
|
||||||
else:
|
else:
|
||||||
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list)
|
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit)
|
||||||
total = asset_repository.count_by_project(library.project_id, status=status_list)
|
total = asset_repository.count_by_project(library.project_id)
|
||||||
return ListAssetsResponse(
|
return ListAssetsResponse(
|
||||||
items=[_to_asset_response(item) for item in items],
|
items=[_to_asset_response(item) for item in items],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -216,14 +187,14 @@ def list_assets(
|
|||||||
if project_id:
|
if project_id:
|
||||||
check_project_access(project_id, user_id, project_repository)
|
check_project_access(project_id, user_id, project_repository)
|
||||||
if ft:
|
if ft:
|
||||||
items = asset_repository.find_by_project_and_file_type(
|
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||||||
project_id, ft, skip=skip, limit=limit, status=status_list
|
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 = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list)
|
total = len(items)
|
||||||
paged = items
|
paged = items[skip : skip + limit]
|
||||||
else:
|
else:
|
||||||
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list)
|
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit)
|
||||||
total = asset_repository.count_by_project(project_id, status=status_list)
|
total = asset_repository.count_by_project(project_id)
|
||||||
paged = items
|
paged = items
|
||||||
return ListAssetsResponse(
|
return ListAssetsResponse(
|
||||||
items=[_to_asset_response(item) for item in paged],
|
items=[_to_asset_response(item) for item in paged],
|
||||||
@@ -243,43 +214,22 @@ def list_assets(
|
|||||||
if not project_ids:
|
if not project_ids:
|
||||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||||
|
|
||||||
if ft:
|
total = asset_repository.count_by_project_ids(project_ids)
|
||||||
# 有 kind 过滤:逐项目查 file_type,凑够一页
|
# 跨项目分页:逐项目累积直到凑够一页
|
||||||
total = 0
|
paged_items: list = []
|
||||||
paged_items: list = []
|
offset = skip
|
||||||
offset = skip
|
remaining = limit
|
||||||
remaining = limit
|
for pid in project_ids:
|
||||||
for pid in project_ids:
|
proj_total = asset_repository.count_by_project(pid)
|
||||||
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list)
|
if offset >= proj_total:
|
||||||
total += proj_total
|
offset -= proj_total
|
||||||
if offset >= proj_total:
|
continue
|
||||||
offset -= proj_total
|
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining)
|
||||||
continue
|
paged_items.extend(proj_items)
|
||||||
proj_items = asset_repository.find_by_project_and_file_type(
|
remaining -= len(proj_items)
|
||||||
pid, ft, skip=offset, limit=remaining, status=status_list
|
offset = 0
|
||||||
)
|
if remaining <= 0:
|
||||||
paged_items.extend(proj_items)
|
break
|
||||||
remaining -= len(proj_items)
|
|
||||||
offset = 0
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
|
|
||||||
# 跨项目分页:逐项目累积直到凑够一页
|
|
||||||
paged_items: list = []
|
|
||||||
offset = skip
|
|
||||||
remaining = limit
|
|
||||||
for pid in project_ids:
|
|
||||||
proj_total = asset_repository.count_by_project(pid, status=status_list)
|
|
||||||
if offset >= proj_total:
|
|
||||||
offset -= proj_total
|
|
||||||
continue
|
|
||||||
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
|
|
||||||
paged_items.extend(proj_items)
|
|
||||||
remaining -= len(proj_items)
|
|
||||||
offset = 0
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
return ListAssetsResponse(
|
return ListAssetsResponse(
|
||||||
items=[_to_asset_response(item) for item in paged_items],
|
items=[_to_asset_response(item) for item in paged_items],
|
||||||
@@ -295,21 +245,12 @@ def list_assets(
|
|||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||||
check_project_access(library.project_id, user_id, project_repository)
|
check_project_access(library.project_id, user_id, project_repository)
|
||||||
if kind:
|
if kind:
|
||||||
all_items = asset_repository.find_by_library_and_file_type(
|
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
||||||
library_id, kind_to_file_type[kind], status=status_list
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
all_items = asset_repository.find_by_library(library_id, status=status_list)
|
all_items = asset_repository.find_by_library(library_id)
|
||||||
elif project_id:
|
elif project_id:
|
||||||
check_project_access(project_id, user_id, project_repository)
|
check_project_access(project_id, user_id, project_repository)
|
||||||
if kind:
|
all_items = asset_repository.find_by_project(project_id)
|
||||||
ft = kind_to_file_type.get(kind)
|
|
||||||
if ft:
|
|
||||||
all_items = asset_repository.find_by_project_and_file_type(project_id, ft, status=status_list)
|
|
||||||
else:
|
|
||||||
all_items = asset_repository.find_by_project(project_id, status=status_list)
|
|
||||||
else:
|
|
||||||
all_items = asset_repository.find_by_project(project_id, status=status_list)
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
projects = project_repository.find_accessible_projects(user_id)
|
projects = project_repository.find_accessible_projects(user_id)
|
||||||
@@ -318,18 +259,12 @@ def list_assets(
|
|||||||
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
|
||||||
all_items = []
|
all_items = []
|
||||||
for proj in projects:
|
for proj in projects:
|
||||||
if kind and kind_to_file_type.get(kind):
|
all_items.extend(asset_repository.find_by_project(proj.id))
|
||||||
all_items.extend(
|
|
||||||
asset_repository.find_by_project_and_file_type(proj.id, kind_to_file_type[kind], status=status_list)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
|
|
||||||
|
|
||||||
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
# 应用 kind 过滤(如果有)+ keyword/gender/style
|
||||||
if kind:
|
if kind:
|
||||||
ft = kind_to_file_type.get(kind)
|
ft = kind_to_file_type.get(kind)
|
||||||
if ft:
|
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
|
||||||
all_items = [i for i in all_items if i.file_type == ft]
|
|
||||||
filtered = _apply_memory_filters(all_items)
|
filtered = _apply_memory_filters(all_items)
|
||||||
total = len(filtered)
|
total = len(filtered)
|
||||||
paged = filtered[skip : skip + limit]
|
paged = filtered[skip : skip + limit]
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ class CurrentUserResponse(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
display_name: str
|
display_name: str
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
phone: str = ""
|
|
||||||
phone_verified: bool = False
|
|
||||||
binding_complete: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetRequestModel(BaseModel):
|
class PasswordResetRequestModel(BaseModel):
|
||||||
@@ -262,16 +259,12 @@ async def get_current_user_info(
|
|||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
) -> CurrentUserResponse:
|
) -> CurrentUserResponse:
|
||||||
user = authenticated_user.user
|
user = authenticated_user.user
|
||||||
binding_complete = user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
|
||||||
return CurrentUserResponse(
|
return CurrentUserResponse(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
email=user.email,
|
email=user.email,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
display_name=user.display_name,
|
display_name=user.display_name,
|
||||||
email_verified=user.email_verified,
|
email_verified=user.email_verified,
|
||||||
phone=user.phone or "",
|
|
||||||
phone_verified=user.phone_verified,
|
|
||||||
binding_complete=binding_complete,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -387,202 +380,3 @@ async def wechat_sync(
|
|||||||
raise HTTPException(status_code=400, detail=error)
|
raise HTTPException(status_code=400, detail=error)
|
||||||
|
|
||||||
return WechatSyncResponse(**response.to_dict())
|
return WechatSyncResponse(**response.to_dict())
|
||||||
|
|
||||||
|
|
||||||
# ==================== 微信网页登录(OAuth) ====================
|
|
||||||
|
|
||||||
|
|
||||||
class WechatAuthUrlResponse(BaseModel):
|
|
||||||
auth_url: str
|
|
||||||
state: str
|
|
||||||
|
|
||||||
|
|
||||||
class WechatCallbackRequest(BaseModel):
|
|
||||||
code: str
|
|
||||||
state: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class WechatLoginResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
refresh_token: str
|
|
||||||
user_id: str
|
|
||||||
display_name: str
|
|
||||||
avatar_url: str = ""
|
|
||||||
is_new_user: bool
|
|
||||||
binding_complete: bool
|
|
||||||
expires_in: int
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wechat/url", response_model=WechatAuthUrlResponse)
|
|
||||||
async def get_wechat_auth_url() -> WechatAuthUrlResponse:
|
|
||||||
"""获取微信扫码登录授权链接"""
|
|
||||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
|
||||||
|
|
||||||
oauth_service = get_wechat_oauth_service()
|
|
||||||
auth_url, state = oauth_service.generate_auth_url()
|
|
||||||
return WechatAuthUrlResponse(auth_url=auth_url, state=state)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/wechat/callback", response_model=WechatLoginResponse)
|
|
||||||
async def wechat_callback(
|
|
||||||
request: WechatCallbackRequest,
|
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
|
||||||
) -> WechatLoginResponse:
|
|
||||||
"""微信登录回调处理"""
|
|
||||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
|
||||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
|
|
||||||
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
|
|
||||||
|
|
||||||
# 1. 用 code 换微信用户信息
|
|
||||||
oauth_service = get_wechat_oauth_service()
|
|
||||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
|
||||||
if err:
|
|
||||||
raise HTTPException(status_code=400, detail=err)
|
|
||||||
|
|
||||||
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
|
|
||||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
|
||||||
sync_request = SyncRequest(
|
|
||||||
openid=wechat_user.openid,
|
|
||||||
unionid=wechat_user.unionid,
|
|
||||||
nickname=wechat_user.nickname,
|
|
||||||
avatar_url=wechat_user.avatar_url,
|
|
||||||
source="web",
|
|
||||||
)
|
|
||||||
response, err = use_case.execute(sync_request)
|
|
||||||
if err:
|
|
||||||
raise HTTPException(status_code=400, detail=err)
|
|
||||||
|
|
||||||
# 3. 判断绑定状态
|
|
||||||
user = user_repository.find_by_id(response.user_id)
|
|
||||||
binding_complete = False
|
|
||||||
if user:
|
|
||||||
binding_complete = (
|
|
||||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
|
||||||
)
|
|
||||||
|
|
||||||
return WechatLoginResponse(
|
|
||||||
access_token=response.access_token,
|
|
||||||
refresh_token=response.refresh_token,
|
|
||||||
user_id=response.user_id,
|
|
||||||
display_name=response.nickname,
|
|
||||||
avatar_url=response.avatar_url or wechat_user.avatar_url,
|
|
||||||
is_new_user=response.is_new_user,
|
|
||||||
binding_complete=binding_complete,
|
|
||||||
expires_in=response.expires_in,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 验证码 & 绑定 ====================
|
|
||||||
|
|
||||||
|
|
||||||
class SendVerificationCodeRequest(BaseModel):
|
|
||||||
target: str # phone / email
|
|
||||||
value: str
|
|
||||||
purpose: str # bind / login / reset_password
|
|
||||||
|
|
||||||
|
|
||||||
class SendVerificationCodeResponse(BaseModel):
|
|
||||||
expires_in: int
|
|
||||||
resend_after: int
|
|
||||||
|
|
||||||
|
|
||||||
class BindContactRequest(BaseModel):
|
|
||||||
phone: str = ""
|
|
||||||
phone_code: str = ""
|
|
||||||
email: str = ""
|
|
||||||
email_code: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class BindContactResponse(BaseModel):
|
|
||||||
success: bool
|
|
||||||
user: dict
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-verification-code", response_model=SendVerificationCodeResponse)
|
|
||||||
async def send_verification_code(
|
|
||||||
request: SendVerificationCodeRequest,
|
|
||||||
) -> SendVerificationCodeResponse:
|
|
||||||
"""发送验证码(手机或邮箱)"""
|
|
||||||
from app.dependencies import get_db_session
|
|
||||||
|
|
||||||
from packages.adapters.sms.sms_service import get_sms_service
|
|
||||||
from packages.adapters.smtp import get_email_service
|
|
||||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
|
||||||
SQLAlchemyVerificationCodeRepository,
|
|
||||||
)
|
|
||||||
from packages.application.auth.bind_contact_use_case import SendVerificationCodeRequest as UseCaseRequest
|
|
||||||
from packages.application.auth.bind_contact_use_case import (
|
|
||||||
SendVerificationCodeUseCase,
|
|
||||||
)
|
|
||||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
|
||||||
|
|
||||||
db = next(get_db_session())
|
|
||||||
repo = SQLAlchemyVerificationCodeRepository(db)
|
|
||||||
vc_service = VerificationCodeService(repo=repo)
|
|
||||||
sms_service = get_sms_service()
|
|
||||||
email_service = get_email_service()
|
|
||||||
|
|
||||||
use_case = SendVerificationCodeUseCase(
|
|
||||||
verification_code_service=vc_service,
|
|
||||||
sms_service=sms_service,
|
|
||||||
email_service=email_service,
|
|
||||||
)
|
|
||||||
uc_request = UseCaseRequest(
|
|
||||||
target=request.target,
|
|
||||||
value=request.value,
|
|
||||||
purpose=request.purpose,
|
|
||||||
)
|
|
||||||
response, err = use_case.execute(uc_request)
|
|
||||||
if err:
|
|
||||||
raise HTTPException(status_code=400, detail=err)
|
|
||||||
|
|
||||||
return SendVerificationCodeResponse(
|
|
||||||
expires_in=response.expires_in,
|
|
||||||
resend_after=response.resend_after,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bind-contact", response_model=BindContactResponse)
|
|
||||||
async def bind_contact(
|
|
||||||
request: BindContactRequest,
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
user_repository: UserRepository = Depends(get_user_repository),
|
|
||||||
) -> BindContactResponse:
|
|
||||||
"""绑定手机号和/或邮箱(需登录态)"""
|
|
||||||
from app.dependencies import get_db_session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.verification_code_repository import (
|
|
||||||
SQLAlchemyVerificationCodeRepository,
|
|
||||||
)
|
|
||||||
from packages.application.auth.bind_contact_use_case import BindContactRequest as UseCaseRequest
|
|
||||||
from packages.application.auth.bind_contact_use_case import (
|
|
||||||
BindContactUseCase,
|
|
||||||
)
|
|
||||||
from packages.application.auth.verification_code_service import VerificationCodeService
|
|
||||||
|
|
||||||
db = next(get_db_session())
|
|
||||||
vc_repo = SQLAlchemyVerificationCodeRepository(db)
|
|
||||||
vc_service = VerificationCodeService(repo=vc_repo)
|
|
||||||
|
|
||||||
use_case = BindContactUseCase(
|
|
||||||
user_repository=user_repository,
|
|
||||||
verification_code_service=vc_service,
|
|
||||||
)
|
|
||||||
uc_request = UseCaseRequest(
|
|
||||||
user_id=current_user.user.id,
|
|
||||||
phone=request.phone,
|
|
||||||
phone_code=request.phone_code,
|
|
||||||
email=request.email,
|
|
||||||
email_code=request.email_code,
|
|
||||||
)
|
|
||||||
response, err = use_case.execute(uc_request)
|
|
||||||
if err:
|
|
||||||
raise HTTPException(status_code=400, detail=err)
|
|
||||||
|
|
||||||
return BindContactResponse(success=True, user=response.to_dict()["user"])
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 当前用户信息扩展 ====================
|
|
||||||
|
|
||||||
# 扩展 CurrentUserResponse 增加绑定状态字段(在原响应基础上补充)
|
|
||||||
# 通过给 get_current_user_info 返回值补充字段实现
|
|
||||||
|
|||||||
@@ -239,9 +239,7 @@ def get_duplication_detail(
|
|||||||
return _to_detail_response(record)
|
return _to_detail_response(record)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
|
||||||
)
|
|
||||||
def delete_duplication_record(
|
def delete_duplication_record(
|
||||||
record_id: str,
|
record_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -289,7 +287,7 @@ def retry_duplication(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=str(e),
|
detail=str(e),
|
||||||
) from e
|
)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"""剪辑计划管理 API — Phase 8 模板编排引擎.
|
||||||
|
|
||||||
|
RESTful CRUD for EditPlan:
|
||||||
|
- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选)
|
||||||
|
- GET /api/v1/edit-plans/{id} 详情
|
||||||
|
- POST /api/v1/edit-plans 创建
|
||||||
|
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
|
||||||
|
- DELETE /api/v1/edit-plans/{id} 删除
|
||||||
|
|
||||||
|
拆分模块(各自独立 router,由本文件 include_router 聚合):
|
||||||
|
- edit_plans_generation.py 生成相关(generate / generation-status / generations)
|
||||||
|
- edit_plans_ai.py AI 推荐 & 封面(ai-recommend / generate-cover)
|
||||||
|
- edit_plans_timeline.py 时间线 & 模板生成(timeline / generate-from-template)
|
||||||
|
|
||||||
|
业务逻辑委托给 EditPlanService 服务层。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.dependencies import get_db_session, get_project_repository
|
||||||
|
from app.schemas.generation_task import GenerationTaskResponse
|
||||||
|
from app.services import EditPlanService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from packages.domain.config_schemas import normalize_plan_config
|
||||||
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
|
|
||||||
|
from ._helpers import check_project_access
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanCreateRequest(BaseModel):
|
||||||
|
"""创建剪辑计划请求体"""
|
||||||
|
|
||||||
|
template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID")
|
||||||
|
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
|
||||||
|
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
|
||||||
|
project_id: str = Field(default="", description="所属项目 ID")
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanUpdateRequest(BaseModel):
|
||||||
|
"""更新剪辑计划请求体"""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称")
|
||||||
|
config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)")
|
||||||
|
total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)")
|
||||||
|
status: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanResponse(BaseModel):
|
||||||
|
"""剪辑计划响应体"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
template_id: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
total_duration: float
|
||||||
|
project_id: str = ""
|
||||||
|
created_by_user_id: str = ""
|
||||||
|
config: dict[str, Any]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanListResponse(BaseModel):
|
||||||
|
"""剪辑计划列表响应体"""
|
||||||
|
|
||||||
|
items: List[EditPlanResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class ClipStatusItem(BaseModel):
|
||||||
|
"""片段生成状态"""
|
||||||
|
|
||||||
|
clip_id: str
|
||||||
|
clip_type: str
|
||||||
|
order: int
|
||||||
|
status: str
|
||||||
|
asset_id: str
|
||||||
|
text_content: str
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanGenerationStatusResponse(BaseModel):
|
||||||
|
"""剪辑计划生成进度响应体"""
|
||||||
|
|
||||||
|
plan_id: str
|
||||||
|
plan_status: str
|
||||||
|
generation_task_id: Optional[str] = None
|
||||||
|
clips: List[ClipStatusItem]
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanGenerateResponse(BaseModel):
|
||||||
|
"""剪辑计划触发生成响应体"""
|
||||||
|
|
||||||
|
plan_id: str
|
||||||
|
plan_status: str
|
||||||
|
generation_task_id: str
|
||||||
|
clip_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class EditPlanGenerationsResponse(BaseModel):
|
||||||
|
"""剪辑计划关联的生成记录列表响应体"""
|
||||||
|
|
||||||
|
items: List[GenerationTaskResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI 推荐片段方案 Schemas(任务 3.09) ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class AIRecommendRequest(BaseModel):
|
||||||
|
"""AI 推荐片段方案请求体"""
|
||||||
|
|
||||||
|
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||||
|
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip")
|
||||||
|
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||||
|
|
||||||
|
|
||||||
|
class AIRecommendClipItem(BaseModel):
|
||||||
|
"""AI 推荐的单个片段"""
|
||||||
|
|
||||||
|
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
|
||||||
|
order: int = Field(..., ge=0, description="片段顺序")
|
||||||
|
text_content: str = Field(default="", description="文字内容")
|
||||||
|
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
||||||
|
transition_effect: str = Field(default="cut", description="转场效果")
|
||||||
|
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
|
||||||
|
asset_id: str = Field(default="", description="关联素材 ID")
|
||||||
|
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
||||||
|
|
||||||
|
|
||||||
|
class AIRecommendResponse(BaseModel):
|
||||||
|
"""AI 推荐片段方案响应体"""
|
||||||
|
|
||||||
|
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||||
|
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
||||||
|
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
||||||
|
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
||||||
|
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── AI 封面生成 Schemas(任务 3.09) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateCoverRequest(BaseModel):
|
||||||
|
"""AI 封面生成请求体"""
|
||||||
|
|
||||||
|
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||||
|
cover_type: str = Field(
|
||||||
|
default="ai_frame",
|
||||||
|
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||||
|
)
|
||||||
|
frame_time: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
ge=0.0,
|
||||||
|
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateCoverResponse(BaseModel):
|
||||||
|
"""AI 封面生成响应体"""
|
||||||
|
|
||||||
|
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||||
|
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 基于模板生成剪辑计划 Schemas ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateFromTemplateRequest(BaseModel):
|
||||||
|
"""基于模板生成剪辑计划请求体"""
|
||||||
|
|
||||||
|
template_id: str = Field(..., description="剪辑模板 ID")
|
||||||
|
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
||||||
|
project_id: str = Field(default="", description="所属项目 ID")
|
||||||
|
name: str = Field(default="", description="计划名称(为空则自动取模板名)")
|
||||||
|
|
||||||
|
|
||||||
|
class _PlanClipItem(BaseModel):
|
||||||
|
"""片段响应体"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
clip_type: str
|
||||||
|
order: int
|
||||||
|
asset_id: str
|
||||||
|
text_content: str
|
||||||
|
start_time: float
|
||||||
|
duration: float
|
||||||
|
transition_effect: str
|
||||||
|
transition_duration: float
|
||||||
|
playback_speed: float = 1.0
|
||||||
|
status: str
|
||||||
|
config: Optional[dict[str, Any]] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateFromTemplateResponse(BaseModel):
|
||||||
|
"""基于模板生成剪辑计划响应体"""
|
||||||
|
|
||||||
|
plan: EditPlanResponse
|
||||||
|
clips: List[_PlanClipItem]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||||
|
return EditPlanResponse(
|
||||||
|
id=p.id,
|
||||||
|
template_id=p.template_id,
|
||||||
|
name=p.name,
|
||||||
|
status=p.status.value if hasattr(p.status, "value") else p.status,
|
||||||
|
total_duration=p.total_duration,
|
||||||
|
project_id=p.project_id or "",
|
||||||
|
created_by_user_id=p.created_by_user_id or "",
|
||||||
|
config=p.config,
|
||||||
|
created_at=p.created_at,
|
||||||
|
updated_at=p.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD Routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=EditPlanListResponse)
|
||||||
|
def list_plans(
|
||||||
|
page: int = Query(default=1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||||
|
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
|
||||||
|
project_id: Optional[str] = Query(default=None, description="按项目 ID 筛选"),
|
||||||
|
status_filter: Optional[str] = Query(
|
||||||
|
default=None,
|
||||||
|
alias="status",
|
||||||
|
description="按状态筛选: draft / editing / rendering / completed / failed",
|
||||||
|
),
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanListResponse:
|
||||||
|
"""获取剪辑计划列表(支持分页、按模板/状态/项目筛选)"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
|
||||||
|
# 空串 project_id 视为未传(避免 DB 中匹配到空串记录)
|
||||||
|
if project_id is not None and not project_id.strip():
|
||||||
|
project_id = None
|
||||||
|
|
||||||
|
# 解析状态筛选
|
||||||
|
status_enum: Optional[EditPlanStatus] = None
|
||||||
|
if status_filter:
|
||||||
|
try:
|
||||||
|
status_enum = EditPlanStatus(status_filter)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的筛选条件,请选择正确的状态",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
|
||||||
|
if project_id:
|
||||||
|
check_project_access(project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
skip = (page - 1) * page_size
|
||||||
|
plans = svc.list_plans(
|
||||||
|
template_id=template_id,
|
||||||
|
project_id=project_id,
|
||||||
|
status=status_enum,
|
||||||
|
skip=skip,
|
||||||
|
limit=page_size,
|
||||||
|
)
|
||||||
|
total = svc.count_plans(
|
||||||
|
template_id=template_id,
|
||||||
|
project_id=project_id,
|
||||||
|
status=status_enum,
|
||||||
|
)
|
||||||
|
|
||||||
|
return EditPlanListResponse(
|
||||||
|
items=[_to_response(p) for p in plans],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}", response_model=EditPlanResponse)
|
||||||
|
def get_plan(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanResponse:
|
||||||
|
"""获取单个剪辑计划详情"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
try:
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
# 项目鉴权
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
return _to_response(plan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_plan(
|
||||||
|
body: EditPlanCreateRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanResponse:
|
||||||
|
"""创建剪辑计划"""
|
||||||
|
# 空串 project_id 统一为 ""
|
||||||
|
project_id = (body.project_id or "").strip()
|
||||||
|
# 项目鉴权
|
||||||
|
if project_id:
|
||||||
|
check_project_access(project_id, current_user.user.id, project_repository)
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
# 标准化 config,填充 cover/title/subtitle/bgm 默认值
|
||||||
|
normalized_config = normalize_plan_config(body.config)
|
||||||
|
try:
|
||||||
|
created = svc.create_plan(
|
||||||
|
template_id=body.template_id,
|
||||||
|
name=body.name,
|
||||||
|
config=normalized_config,
|
||||||
|
total_duration=body.total_duration,
|
||||||
|
project_id=project_id,
|
||||||
|
created_by_user_id=current_user.user.id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"创建剪辑计划: id=%s name=%s by user=%s",
|
||||||
|
created.id,
|
||||||
|
created.name,
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
return _to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{plan_id}", response_model=EditPlanResponse)
|
||||||
|
def update_plan(
|
||||||
|
plan_id: str,
|
||||||
|
body: EditPlanUpdateRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanResponse:
|
||||||
|
"""更新剪辑计划(支持状态机流转)"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
# 项目鉴权
|
||||||
|
existing = svc.get_plan(plan_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||||
|
if existing.project_id:
|
||||||
|
check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
# 基础字段更新
|
||||||
|
try:
|
||||||
|
if body.name is not None or body.config is not None or body.total_duration is not None:
|
||||||
|
# 标准化 config(如果提供了)
|
||||||
|
config_to_update = normalize_plan_config(body.config) if body.config is not None else None
|
||||||
|
svc.update_plan(
|
||||||
|
plan_id,
|
||||||
|
name=body.name,
|
||||||
|
config=config_to_update,
|
||||||
|
total_duration=body.total_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 状态机流转
|
||||||
|
if body.status is not None:
|
||||||
|
try:
|
||||||
|
target_status = EditPlanStatus(body.status)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="无效的状态值,请选择正确的状态",
|
||||||
|
)
|
||||||
|
svc.transition_status(plan_id, target_status)
|
||||||
|
except ValueError as exc:
|
||||||
|
err_msg = str(exc)
|
||||||
|
if "不存在" in err_msg:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=err_msg,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=err_msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 返回最新状态
|
||||||
|
result = svc.get_plan_or_raise(plan_id)
|
||||||
|
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
|
||||||
|
return _to_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
|
def delete_plan(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> None:
|
||||||
|
"""删除剪辑计划"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
# 项目鉴权
|
||||||
|
existing = svc.get_plan(plan_id)
|
||||||
|
if existing and existing.project_id:
|
||||||
|
check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||||
|
deleted = svc.delete_plan(plan_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"剪辑计划不存在: {plan_id}",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"删除剪辑计划: id=%s by user=%s",
|
||||||
|
plan_id,
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
from .edit_plans_ai import router as ai_router
|
||||||
|
from .edit_plans_generation import router as generation_router
|
||||||
|
from .edit_plans_timeline import router as timeline_router
|
||||||
|
|
||||||
|
router.include_router(generation_router)
|
||||||
|
router.include_router(ai_router)
|
||||||
|
router.include_router(timeline_router)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""剪辑计划 AI 推荐 & 封面生成 API 端点。
|
||||||
|
|
||||||
|
从 edit_plans.py 拆分,包含:
|
||||||
|
- POST /{plan_id}/ai-recommend AI 推荐片段方案
|
||||||
|
- POST /{plan_id}/generate-cover AI 生成封面
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.api.routes.edit_plans import (
|
||||||
|
AIRecommendClipItem,
|
||||||
|
AIRecommendRequest,
|
||||||
|
AIRecommendResponse,
|
||||||
|
GenerateCoverRequest,
|
||||||
|
GenerateCoverResponse,
|
||||||
|
)
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.dependencies import get_db_session, get_project_repository
|
||||||
|
from app.services import EditPlanService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from packages.domain.config_schemas import normalize_plan_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{plan_id}/ai-recommend",
|
||||||
|
response_model=AIRecommendResponse,
|
||||||
|
)
|
||||||
|
def ai_recommend_clips(
|
||||||
|
plan_id: str,
|
||||||
|
body: AIRecommendRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> AIRecommendResponse:
|
||||||
|
"""AI 推荐片段方案
|
||||||
|
|
||||||
|
调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 验证计划存在且状态为 draft/editing
|
||||||
|
2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI)
|
||||||
|
3. 清除计划现有片段,按推荐方案重新创建
|
||||||
|
4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration
|
||||||
|
5. 返回推荐方案详情
|
||||||
|
"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||||
|
if plan_status not in ("draft", "editing"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="当前计划状态不支持AI推荐,请先创建或编辑计划后再试",
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend
|
||||||
|
|
||||||
|
result = run_ai_recommend(
|
||||||
|
plan_id=plan_id,
|
||||||
|
template_id=plan.template_id,
|
||||||
|
asset_ids=body.asset_ids,
|
||||||
|
editing_mode=body.editing_mode,
|
||||||
|
target_duration=body.target_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中
|
||||||
|
try:
|
||||||
|
svc.delete_all_clips(plan_id)
|
||||||
|
|
||||||
|
for clip_data in result["clips"]:
|
||||||
|
svc.create_clip(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=clip_data["clip_type"],
|
||||||
|
order=clip_data["order"],
|
||||||
|
text_content=clip_data.get("text_content", ""),
|
||||||
|
duration=clip_data["duration"],
|
||||||
|
transition_effect=clip_data.get("transition_effect", "cut"),
|
||||||
|
asset_id=clip_data.get("asset_id", ""),
|
||||||
|
start_time=clip_data.get("start_time", 0.0),
|
||||||
|
config=clip_data.get("config", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_config = normalize_plan_config(result.get("config", {}))
|
||||||
|
svc.update_plan(
|
||||||
|
plan_id,
|
||||||
|
config=normalized_config,
|
||||||
|
total_duration=result["total_duration"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception as rollback_err:
|
||||||
|
logger.error(
|
||||||
|
"AI 推荐回滚失败,数据库会话可能处于不一致状态: plan_id=%s error=%s",
|
||||||
|
plan_id,
|
||||||
|
rollback_err,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="AI推荐结果保存失败,请稍后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
|
||||||
|
plan_id,
|
||||||
|
len(result["clips"]),
|
||||||
|
result["total_duration"],
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AIRecommendResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clips=[
|
||||||
|
AIRecommendClipItem(
|
||||||
|
clip_type=c["clip_type"],
|
||||||
|
order=c["order"],
|
||||||
|
text_content=c.get("text_content", ""),
|
||||||
|
duration=c["duration"],
|
||||||
|
transition_effect=c.get("transition_effect", "cut"),
|
||||||
|
asset_id=c.get("asset_id", ""),
|
||||||
|
start_time=c.get("start_time", 0.0),
|
||||||
|
config=c.get("config", {}),
|
||||||
|
)
|
||||||
|
for c in result["clips"]
|
||||||
|
],
|
||||||
|
config=normalized_config,
|
||||||
|
total_duration=result["total_duration"],
|
||||||
|
confidence=result["confidence"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{plan_id}/generate-cover",
|
||||||
|
response_model=GenerateCoverResponse,
|
||||||
|
)
|
||||||
|
def generate_cover(
|
||||||
|
plan_id: str,
|
||||||
|
body: GenerateCoverRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> GenerateCoverResponse:
|
||||||
|
"""AI 生成封面
|
||||||
|
|
||||||
|
调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。
|
||||||
|
"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover
|
||||||
|
|
||||||
|
cover_data = run_generate_cover(
|
||||||
|
plan_id=plan_id,
|
||||||
|
asset_ids=body.asset_ids,
|
||||||
|
cover_type=body.cover_type,
|
||||||
|
frame_time=body.frame_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_config = dict(plan.config)
|
||||||
|
current_config["cover"] = cover_data
|
||||||
|
normalized = normalize_plan_config(current_config)
|
||||||
|
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"AI 封面生成: plan_id=%s type=%s by user=%s",
|
||||||
|
plan_id,
|
||||||
|
body.cover_type,
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GenerateCoverResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
cover=cover_data,
|
||||||
|
)
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""剪辑计划生成相关 API 端点。
|
||||||
|
|
||||||
|
从 edit_plans.py 拆分,包含:
|
||||||
|
- POST /{plan_id}/generate 触发剪辑渲染生成
|
||||||
|
- GET /{plan_id}/generation-status 查询生成进度
|
||||||
|
- GET /{plan_id}/generations 查询关联的生成记录
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.api.routes.edit_plans import (
|
||||||
|
ClipStatusItem,
|
||||||
|
EditPlanGenerateResponse,
|
||||||
|
EditPlanGenerationsResponse,
|
||||||
|
EditPlanGenerationStatusResponse,
|
||||||
|
)
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.core.celery_app import celery_app
|
||||||
|
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
||||||
|
from app.dependencies import get_asset_library_repository, get_asset_repository, get_db_session, get_project_repository
|
||||||
|
from app.services import EditPlanService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||||
|
SQLAlchemyGenerationTaskRepository,
|
||||||
|
)
|
||||||
|
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||||
|
SQLAlchemyTemplateClipConfigRepository,
|
||||||
|
)
|
||||||
|
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||||
|
SQLAlchemyTemplateRepository,
|
||||||
|
)
|
||||||
|
from packages.application.generation_tasks import (
|
||||||
|
CreateGenerationTaskCommand,
|
||||||
|
CreateGenerationTaskUseCase,
|
||||||
|
)
|
||||||
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
||||||
|
"""自动兜底 1: draft → editing"""
|
||||||
|
if plan_check.status == EditPlanStatus.DRAFT:
|
||||||
|
logger.info("自动兜底: plan=%s draft→editing", plan_id)
|
||||||
|
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
||||||
|
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||||
|
existing_clips = svc.count_clips(plan_id)
|
||||||
|
if existing_clips == 0 and plan_check.template_id:
|
||||||
|
logger.info(
|
||||||
|
"自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
||||||
|
plan_id,
|
||||||
|
plan_check.template_id,
|
||||||
|
)
|
||||||
|
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||||
|
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
||||||
|
if configs:
|
||||||
|
for cfg in configs:
|
||||||
|
svc.create_clip(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||||
|
order=cfg.order,
|
||||||
|
template_clip_config_id=cfg.id,
|
||||||
|
duration=cfg.default_duration,
|
||||||
|
transition_effect=(
|
||||||
|
cfg.transition_effect.value
|
||||||
|
if hasattr(cfg.transition_effect, "value")
|
||||||
|
else cfg.transition_effect
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.info("自动兜底: plan=%s 从新模型 template_clip_configs 复制了 %d 个片段", plan_id, len(configs))
|
||||||
|
else:
|
||||||
|
tpl_repo = SQLAlchemyTemplateRepository(db)
|
||||||
|
segments = tpl_repo.list_segments(plan_check.template_id)
|
||||||
|
for seg in segments:
|
||||||
|
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
||||||
|
svc.create_clip(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="main",
|
||||||
|
order=seg.segment_order,
|
||||||
|
duration=avg_duration,
|
||||||
|
config={
|
||||||
|
"material_type": seg.material_type or "",
|
||||||
|
"template_segment_id": seg.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logger.info("自动兜底: plan=%s 从旧模型 template_segments 复制了 %d 个片段", plan_id, len(segments))
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_assign_assets(
|
||||||
|
svc: EditPlanService,
|
||||||
|
plan_id: str,
|
||||||
|
plan_check,
|
||||||
|
) -> list:
|
||||||
|
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||||
|
all_clips = svc.list_clips(plan_id)
|
||||||
|
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||||
|
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||||
|
|
||||||
|
if clips_without_asset and config_asset_ids:
|
||||||
|
logger.info(
|
||||||
|
"自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||||
|
plan_id,
|
||||||
|
len(clips_without_asset),
|
||||||
|
len(config_asset_ids),
|
||||||
|
)
|
||||||
|
for i, clip in enumerate(clips_without_asset):
|
||||||
|
asset_idx = i % len(config_asset_ids)
|
||||||
|
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||||
|
logger.info("自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||||
|
clips_without_asset = []
|
||||||
|
|
||||||
|
return clips_without_asset
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_fallback_auto_material_mode(
|
||||||
|
svc: EditPlanService,
|
||||||
|
plan_id: str,
|
||||||
|
plan_check,
|
||||||
|
clips_without_asset: list,
|
||||||
|
asset_library_repo: Any,
|
||||||
|
asset_repo: Any,
|
||||||
|
) -> None:
|
||||||
|
"""自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取"""
|
||||||
|
if not clips_without_asset:
|
||||||
|
return
|
||||||
|
material_mode = (plan_check.config or {}).get("material_mode", "manual")
|
||||||
|
if material_mode != "auto" or not plan_check.project_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)",
|
||||||
|
plan_id,
|
||||||
|
len(clips_without_asset),
|
||||||
|
)
|
||||||
|
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||||
|
video_lib = None
|
||||||
|
for lib in libs:
|
||||||
|
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||||
|
if lib_kind == "video":
|
||||||
|
video_lib = lib
|
||||||
|
break
|
||||||
|
|
||||||
|
if video_lib:
|
||||||
|
assets = asset_repo.find_by_library(video_lib.id)
|
||||||
|
ready_videos = [
|
||||||
|
a
|
||||||
|
for a in assets
|
||||||
|
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||||
|
and a.mime_type
|
||||||
|
and a.mime_type.startswith("video")
|
||||||
|
]
|
||||||
|
if ready_videos:
|
||||||
|
random.shuffle(ready_videos)
|
||||||
|
for i, clip in enumerate(clips_without_asset):
|
||||||
|
asset = ready_videos[i % len(ready_videos)]
|
||||||
|
svc.assign_asset(clip.id, asset.id)
|
||||||
|
logger.info(
|
||||||
|
"自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材给 %d 个片段",
|
||||||
|
plan_id,
|
||||||
|
video_lib.name,
|
||||||
|
len(ready_videos),
|
||||||
|
len(clips_without_asset),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("自动兜底4: plan=%s 素材库无可用视频素材", plan_id)
|
||||||
|
else:
|
||||||
|
logger.warning("自动兜底4: plan=%s 项目无视频素材库", plan_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
||||||
|
"""队列限流预检查"""
|
||||||
|
try:
|
||||||
|
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
|
||||||
|
gen_task_repo, "count_pending_total"
|
||||||
|
)
|
||||||
|
if has_count:
|
||||||
|
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
||||||
|
global_pending = gen_task_repo.count_pending_total()
|
||||||
|
if user_pending >= USER_PENDING_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||||
|
)
|
||||||
|
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="系统繁忙,请稍后再试",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[队列限流] 剪辑计划限流检查失败,跳过: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse)
|
||||||
|
def generate_plan(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
asset_library_repo: Any = Depends(get_asset_library_repository),
|
||||||
|
asset_repo: Any = Depends(get_asset_repository),
|
||||||
|
) -> EditPlanGenerateResponse:
|
||||||
|
"""触发剪辑计划渲染生成
|
||||||
|
|
||||||
|
前置条件:计划状态必须为 editing,且至少有一个片段。
|
||||||
|
流程:
|
||||||
|
1. 验证计划状态为 editing
|
||||||
|
2. 将 pending 片段标记为 ready
|
||||||
|
3. 创建 GenerationTask
|
||||||
|
4. 调度 Celery 任务 worker.render_edit_plan
|
||||||
|
5. 将计划状态流转为 rendering
|
||||||
|
"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
plan_check = svc.get_plan(plan_id)
|
||||||
|
if plan_check is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||||
|
if plan_check.project_id:
|
||||||
|
check_project_access(plan_check.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
# 自动兜底流程
|
||||||
|
_auto_fallback_draft_to_editing(svc, plan_id, plan_check)
|
||||||
|
_auto_fallback_copy_template_clips(svc, plan_id, plan_check, db)
|
||||||
|
clips_without_asset = _auto_fallback_assign_assets(svc, plan_id, plan_check)
|
||||||
|
_auto_fallback_auto_material_mode(svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo)
|
||||||
|
|
||||||
|
# 检查是否可生成
|
||||||
|
try:
|
||||||
|
can_gen, reason = svc.can_generate(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
if not can_gen:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
||||||
|
|
||||||
|
# 核心生成流程
|
||||||
|
try:
|
||||||
|
clip_count = svc.mark_clips_ready(plan_id)
|
||||||
|
|
||||||
|
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||||
|
user_id = current_user.user.id
|
||||||
|
_check_queue_limits(gen_task_repo, user_id)
|
||||||
|
|
||||||
|
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
gen_task = gen_task_use_case.execute(
|
||||||
|
CreateGenerationTaskCommand(
|
||||||
|
project_id="",
|
||||||
|
template_id=plan.template_id,
|
||||||
|
created_by_user_id=current_user.user.id,
|
||||||
|
source_edit_plan_id=plan_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
||||||
|
svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
||||||
|
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
||||||
|
|
||||||
|
updated_plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
||||||
|
plan_id,
|
||||||
|
gen_task.id,
|
||||||
|
clip_count,
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return EditPlanGenerateResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
||||||
|
generation_task_id=gen_task.id,
|
||||||
|
clip_count=clip_count,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||||
|
try:
|
||||||
|
svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("标记计划失败状态时异常: plan_id=%s", plan_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="生成失败,请稍后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{plan_id}/generation-status",
|
||||||
|
response_model=EditPlanGenerationStatusResponse,
|
||||||
|
)
|
||||||
|
def get_generation_status(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanGenerationStatusResponse:
|
||||||
|
"""查询剪辑计划生成进度"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
try:
|
||||||
|
gen_status = svc.get_generation_status(plan_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
plan = gen_status["plan"]
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
clips = gen_status["clips"]
|
||||||
|
|
||||||
|
clip_items = [
|
||||||
|
ClipStatusItem(
|
||||||
|
clip_id=c.id,
|
||||||
|
clip_type=c.clip_type,
|
||||||
|
order=c.order,
|
||||||
|
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||||
|
asset_id=c.asset_id or "",
|
||||||
|
text_content=c.text_content or "",
|
||||||
|
duration=c.duration,
|
||||||
|
)
|
||||||
|
for c in clips
|
||||||
|
]
|
||||||
|
|
||||||
|
return EditPlanGenerationStatusResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
|
||||||
|
generation_task_id=gen_status["generation_task_id"],
|
||||||
|
clips=clip_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{plan_id}/generations",
|
||||||
|
response_model=EditPlanGenerationsResponse,
|
||||||
|
)
|
||||||
|
def list_plan_generations(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> EditPlanGenerationsResponse:
|
||||||
|
"""查询剪辑计划关联的所有生成记录"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
from app.schemas.generation_task import GenerationTaskResponse
|
||||||
|
|
||||||
|
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||||
|
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
||||||
|
items = [
|
||||||
|
GenerationTaskResponse(
|
||||||
|
id=t.id,
|
||||||
|
project_id=t.project_id,
|
||||||
|
asset_library_id=t.asset_library_id,
|
||||||
|
strategy_id=t.strategy_id,
|
||||||
|
voice_library_id=t.voice_library_id,
|
||||||
|
template_id=t.template_id,
|
||||||
|
asset_ids=t.asset_ids,
|
||||||
|
title_ids=t.title_ids,
|
||||||
|
voice_ids=t.voice_ids,
|
||||||
|
source_edit_plan_id=t.source_edit_plan_id or "",
|
||||||
|
status=t.status.value if hasattr(t.status, "value") else t.status,
|
||||||
|
progress=t.progress,
|
||||||
|
result_count=t.result_count,
|
||||||
|
error_message=t.error_message,
|
||||||
|
)
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
return EditPlanGenerationsResponse(items=items, total=len(items))
|
||||||
+221
@@ -0,0 +1,221 @@
|
|||||||
|
"""剪辑计划时间线 & 模板生成 API 端点。
|
||||||
|
|
||||||
|
从 edit_plans.py 拆分,包含:
|
||||||
|
- GET /{plan_id}/timeline 时间线场景数据
|
||||||
|
- POST /generate-from-template 基于模板+素材自动生成剪辑计划
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
from app.api.routes.edit_plans import (
|
||||||
|
GenerateFromTemplateRequest,
|
||||||
|
GenerateFromTemplateResponse,
|
||||||
|
_PlanClipItem,
|
||||||
|
_to_response,
|
||||||
|
)
|
||||||
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
|
from app.dependencies import get_db_session, get_project_repository
|
||||||
|
from app.services import EditPlanService, PlanGeneratorService
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timeline Schemas ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineSceneResponse(BaseModel):
|
||||||
|
"""时间线场景"""
|
||||||
|
|
||||||
|
scene: str = Field(..., description="场景描述")
|
||||||
|
time: str = Field(..., description='时间范围,如 "0:00 - 0:05"')
|
||||||
|
duration: float = Field(..., ge=0, description="时长(秒)")
|
||||||
|
color: str = Field(..., description="展示颜色")
|
||||||
|
clip_id: str = Field(default="", description="关联的片段 ID")
|
||||||
|
clip_type: str = Field(default="", description="片段类型")
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineResponse(BaseModel):
|
||||||
|
"""时间线响应"""
|
||||||
|
|
||||||
|
plan_id: str
|
||||||
|
total_duration: float
|
||||||
|
scenes: List[TimelineSceneResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# clip_type → 颜色映射
|
||||||
|
_CLIP_TYPE_COLORS = {
|
||||||
|
"intro": "#6366f1",
|
||||||
|
"title": "#6366f1",
|
||||||
|
"product": "#818cf8",
|
||||||
|
"showcase": "#10b981",
|
||||||
|
"scene": "#10b981",
|
||||||
|
"subtitle": "#f59e0b",
|
||||||
|
"text": "#f59e0b",
|
||||||
|
"cta": "#ef4444",
|
||||||
|
"outro": "#ef4444",
|
||||||
|
"voiceover": "#8b5cf6",
|
||||||
|
"transition": "#64748b",
|
||||||
|
}
|
||||||
|
|
||||||
|
_DEFAULT_COLOR = "#6366f1"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time(seconds: float) -> str:
|
||||||
|
"""将秒数格式化为 M:SS"""
|
||||||
|
m = int(seconds) // 60
|
||||||
|
s = int(seconds) % 60
|
||||||
|
return f"{m}:{s:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
||||||
|
"""根据 clip_type 和 text_content 生成场景描述"""
|
||||||
|
type_labels = {
|
||||||
|
"intro": "开场",
|
||||||
|
"title": "标题",
|
||||||
|
"product": "产品展示",
|
||||||
|
"showcase": "场景展示",
|
||||||
|
"scene": "场景",
|
||||||
|
"subtitle": "字幕",
|
||||||
|
"text": "文字",
|
||||||
|
"cta": "结尾 CTA",
|
||||||
|
"outro": "结尾",
|
||||||
|
"voiceover": "配音",
|
||||||
|
"transition": "转场",
|
||||||
|
}
|
||||||
|
label = type_labels.get(clip_type, clip_type or "片段")
|
||||||
|
if text_content:
|
||||||
|
short = text_content[:20].strip()
|
||||||
|
if short:
|
||||||
|
return f"{label} - {short}"
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{plan_id}/timeline",
|
||||||
|
response_model=TimelineResponse,
|
||||||
|
)
|
||||||
|
def get_plan_timeline(
|
||||||
|
plan_id: str,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> TimelineResponse:
|
||||||
|
"""获取剪辑计划的时间线场景数据"""
|
||||||
|
svc = EditPlanService(db)
|
||||||
|
plan = svc.get_plan_or_raise(plan_id)
|
||||||
|
if plan.project_id:
|
||||||
|
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
||||||
|
clips.sort(key=lambda c: c.order)
|
||||||
|
|
||||||
|
scenes: List[TimelineSceneResponse] = []
|
||||||
|
current_time = 0.0
|
||||||
|
|
||||||
|
for clip in clips:
|
||||||
|
start = current_time
|
||||||
|
end = start + clip.duration
|
||||||
|
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
||||||
|
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
||||||
|
|
||||||
|
scenes.append(
|
||||||
|
TimelineSceneResponse(
|
||||||
|
scene=scene_label,
|
||||||
|
time=f"{_format_time(start)} - {_format_time(end)}",
|
||||||
|
duration=clip.duration,
|
||||||
|
color=color,
|
||||||
|
clip_id=clip.id,
|
||||||
|
clip_type=clip.clip_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_time = end
|
||||||
|
|
||||||
|
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
||||||
|
|
||||||
|
return TimelineResponse(
|
||||||
|
plan_id=plan_id,
|
||||||
|
total_duration=total_duration,
|
||||||
|
scenes=scenes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/generate-from-template",
|
||||||
|
response_model=GenerateFromTemplateResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def generate_from_template(
|
||||||
|
body: GenerateFromTemplateRequest,
|
||||||
|
db: Session = Depends(get_db_session),
|
||||||
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
project_repository: Any = Depends(get_project_repository),
|
||||||
|
) -> GenerateFromTemplateResponse:
|
||||||
|
"""基于模板 + 素材自动生成剪辑计划"""
|
||||||
|
from app.services import EditTemplateService
|
||||||
|
|
||||||
|
if body.project_id:
|
||||||
|
check_project_access(body.project_id, current_user.user.id, project_repository)
|
||||||
|
|
||||||
|
template_svc = EditTemplateService(db)
|
||||||
|
|
||||||
|
try:
|
||||||
|
template = template_svc.get_template_or_raise(body.template_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||||
|
|
||||||
|
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
|
||||||
|
|
||||||
|
generator = PlanGeneratorService(db)
|
||||||
|
result = generator.generate_from_template(
|
||||||
|
template=template,
|
||||||
|
clip_configs=clip_configs,
|
||||||
|
asset_ids=body.asset_ids,
|
||||||
|
project_id=body.project_id,
|
||||||
|
created_by_user_id=current_user.user.id,
|
||||||
|
name=body.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = result["plan"]
|
||||||
|
clips = result["clips"]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s",
|
||||||
|
plan.id,
|
||||||
|
body.template_id,
|
||||||
|
len(clips),
|
||||||
|
current_user.user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GenerateFromTemplateResponse(
|
||||||
|
plan=_to_response(plan),
|
||||||
|
clips=[
|
||||||
|
_PlanClipItem(
|
||||||
|
id=c.id,
|
||||||
|
clip_type=c.clip_type,
|
||||||
|
order=c.order,
|
||||||
|
asset_id=c.asset_id,
|
||||||
|
text_content=c.text_content,
|
||||||
|
start_time=c.start_time,
|
||||||
|
duration=c.duration,
|
||||||
|
transition_effect=c.transition_effect,
|
||||||
|
transition_duration=c.transition_duration,
|
||||||
|
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||||
|
config=c.config,
|
||||||
|
created_at=c.created_at,
|
||||||
|
updated_at=c.updated_at,
|
||||||
|
)
|
||||||
|
for c in clips
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -32,7 +32,9 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
router = APIRouter(prefix="/internal/feature-flags", tags=["Internal"])
|
||||||
|
|
||||||
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
# 允许管理的 flag 白名单(防止误操作其他系统 flag)
|
||||||
ALLOWED_FLAGS: set[str] = set()
|
ALLOWED_FLAGS = {
|
||||||
|
"render_engine",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
def _get_feature_flag_store() -> RedisFeatureFlagStore:
|
||||||
@@ -103,7 +105,7 @@ async def list_feature_flags(
|
|||||||
return sorted(result, key=lambda x: x.name)
|
return sorted(result, key=lambda x: x.name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to list feature flags: %s", exc)
|
logger.error("Failed to list feature flags: %s", exc)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{name}", response_model=FeatureFlagResponse)
|
@router.get("/{name}", response_model=FeatureFlagResponse)
|
||||||
@@ -118,7 +120,7 @@ async def get_feature_flag(
|
|||||||
return FeatureFlagResponse.from_config(config)
|
return FeatureFlagResponse.from_config(config)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to get feature flag %s: %s", name, exc)
|
logger.error("Failed to get feature flag %s: %s", name, exc)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
|
||||||
@@ -134,7 +136,7 @@ async def check_feature_flag(
|
|||||||
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to check feature flag %s: %s", name, exc)
|
logger.error("Failed to check feature flag %s: %s", name, exc)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{name}", response_model=FeatureFlagResponse)
|
@router.put("/{name}", response_model=FeatureFlagResponse)
|
||||||
@@ -168,7 +170,7 @@ async def update_feature_flag(
|
|||||||
return FeatureFlagResponse.from_config(config)
|
return FeatureFlagResponse.from_config(config)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to update feature flag %s: %s", name, exc)
|
logger.error("Failed to update feature flag %s: %s", name, exc)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
@@ -176,7 +178,7 @@ async def delete_feature_flag(
|
|||||||
name: str,
|
name: str,
|
||||||
_: bool = Depends(_verify_internal_api_key),
|
_: bool = Depends(_verify_internal_api_key),
|
||||||
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
|
||||||
):
|
) :
|
||||||
"""删除 Feature Flag。
|
"""删除 Feature Flag。
|
||||||
|
|
||||||
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
只允许删除 ALLOWED_FLAGS 列表中的 flag。
|
||||||
@@ -189,4 +191,4 @@ async def delete_feature_flag(
|
|||||||
pass
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
logger.error("Failed to delete feature flag %s: %s", name, exc)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
|
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
|
||||||
|
|||||||
Executable → Regular
+13
-88
@@ -43,7 +43,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||||
return GenerationTaskResponse(
|
return GenerationTaskResponse(
|
||||||
id=task.id,
|
id=task.id,
|
||||||
@@ -58,9 +57,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
|||||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||||
batch_id=getattr(task, "batch_id", ""),
|
batch_id=getattr(task, "batch_id", ""),
|
||||||
video_title=getattr(task, "video_title", ""),
|
|
||||||
resolution=getattr(task, "resolution", ""),
|
|
||||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
|
||||||
logs=getattr(task, "logs", "[]"),
|
logs=getattr(task, "logs", "[]"),
|
||||||
status=task.status,
|
status=task.status,
|
||||||
progress=task.progress,
|
progress=task.progress,
|
||||||
@@ -106,7 +102,7 @@ def _select_assets_from_library(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
assets: 素材库中所有素材(Asset 实体列表)
|
assets: 素材库中所有素材(Asset 实体列表)
|
||||||
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
|
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
|
||||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -124,19 +120,17 @@ def _select_assets_from_library(
|
|||||||
return [a.id for a in selected]
|
return [a.id for a in selected]
|
||||||
|
|
||||||
if mode == "smart":
|
if mode == "smart":
|
||||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
|
||||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
sorted_assets = sorted(
|
||||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
|
||||||
scored_assets = sorted(
|
|
||||||
ready_video_assets,
|
ready_video_assets,
|
||||||
key=lambda a: (
|
key=lambda a: (
|
||||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
a.quality_score if a.quality_score is not None else 0.0,
|
||||||
-(getattr(a, "duration", 0.0) or 0.0),
|
a.duration if a.duration is not None else 0.0,
|
||||||
),
|
),
|
||||||
|
reverse=True,
|
||||||
)
|
)
|
||||||
if count > 0:
|
selected = sorted_assets if count <= 0 else sorted_assets[:count]
|
||||||
scored_assets = scored_assets[:count]
|
return [a.id for a in selected]
|
||||||
return [a.id for a in scored_assets]
|
|
||||||
|
|
||||||
# 默认 all 模式:返回全部 ready 视频素材
|
# 默认 all 模式:返回全部 ready 视频素材
|
||||||
return [a.id for a in ready_video_assets]
|
return [a.id for a in ready_video_assets]
|
||||||
@@ -227,20 +221,6 @@ def create_generation_task(
|
|||||||
mode=request.asset_select_mode,
|
mode=request.asset_select_mode,
|
||||||
count=request.asset_select_count,
|
count=request.asset_select_count,
|
||||||
)
|
)
|
||||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
|
|
||||||
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
|
|
||||||
assets = asset_repository.find_by_project(project_id)
|
|
||||||
if assets:
|
|
||||||
resolved_asset_ids = _select_assets_from_library(
|
|
||||||
assets,
|
|
||||||
mode=request.asset_select_mode,
|
|
||||||
count=request.asset_select_count,
|
|
||||||
)
|
|
||||||
if not resolved_asset_ids:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
|
|
||||||
)
|
|
||||||
|
|
||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||||
count = request.count
|
count = request.count
|
||||||
@@ -287,9 +267,6 @@ def create_generation_task(
|
|||||||
source_edit_plan_id=request.source_edit_plan_id,
|
source_edit_plan_id=request.source_edit_plan_id,
|
||||||
asset_select_mode=request.asset_select_mode,
|
asset_select_mode=request.asset_select_mode,
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
video_title=request.video_title,
|
|
||||||
resolution=request.resolution,
|
|
||||||
bgm_config=request.bgm_config,
|
|
||||||
auto_retry_enabled=request.auto_retry_enabled,
|
auto_retry_enabled=request.auto_retry_enabled,
|
||||||
auto_retry_max=request.auto_retry_max,
|
auto_retry_max=request.auto_retry_max,
|
||||||
)
|
)
|
||||||
@@ -305,28 +282,28 @@ def create_generation_task(
|
|||||||
created_tasks.append(task)
|
created_tasks.append(task)
|
||||||
else:
|
else:
|
||||||
failed_tasks.append(task)
|
failed_tasks.append(task)
|
||||||
except UserPendingLimitExceeded as _e:
|
except UserPendingLimitExceeded:
|
||||||
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
# 兜底:如果预检查后又并发提交了,在这里也拦住
|
||||||
failed_tasks.append(task)
|
failed_tasks.append(task)
|
||||||
if not created_tasks:
|
if not created_tasks:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429,
|
status_code=429,
|
||||||
detail="您的待处理任务过多,请等待完成后再提交",
|
detail="您的待处理任务过多,请等待完成后再提交",
|
||||||
) from _e
|
)
|
||||||
break
|
break
|
||||||
except GlobalQueueFull as _e:
|
except GlobalQueueFull:
|
||||||
failed_tasks.append(task)
|
failed_tasks.append(task)
|
||||||
if not created_tasks:
|
if not created_tasks:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
) from _e
|
)
|
||||||
break
|
break
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e
|
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
|
||||||
|
|
||||||
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
|
||||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||||
@@ -427,8 +404,6 @@ def retry_generation_task(
|
|||||||
created_by_user_id=user_id,
|
created_by_user_id=user_id,
|
||||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||||
video_title=getattr(task, "video_title", ""),
|
|
||||||
resolution=getattr(task, "resolution", ""),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -451,53 +426,3 @@ def retry_generation_task(
|
|||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
) from None
|
) from None
|
||||||
return _to_generation_task_response(retried)
|
return _to_generation_task_response(retried)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
|
|
||||||
def cancel_generation_task(
|
|
||||||
task_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
|
||||||
) -> GenerationTaskResponse:
|
|
||||||
"""取消生成任务。
|
|
||||||
|
|
||||||
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
|
|
||||||
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
|
|
||||||
"""
|
|
||||||
task = generation_task_repository.get(task_id)
|
|
||||||
if task is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
|
||||||
|
|
||||||
# 权限校验
|
|
||||||
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
|
||||||
|
|
||||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
|
||||||
|
|
||||||
# 终态不可取消
|
|
||||||
if status_val in ("completed", "failed", "cancelled"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail=f"Cannot cancel task in {status_val} status",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 执行取消
|
|
||||||
try:
|
|
||||||
task.mark_cancelled()
|
|
||||||
task.append_log(
|
|
||||||
stage="cancelled",
|
|
||||||
message="用户主动取消任务",
|
|
||||||
level="INFO",
|
|
||||||
cancelled_by=authenticated_user.user.id,
|
|
||||||
)
|
|
||||||
generation_task_repository.update(task)
|
|
||||||
logger.info(
|
|
||||||
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
|
|
||||||
task_id,
|
|
||||||
authenticated_user.user.id,
|
|
||||||
status_val,
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
|
|
||||||
return _to_generation_task_response(task)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import redis
|
import redis
|
||||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"])
|
|||||||
async def health_check():
|
async def health_check():
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"version": settings.APP_VERSION,
|
"version": settings.APP_VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ async def startup_check():
|
|||||||
all_ready = all(check["status"] == "healthy" for check in checks.values())
|
all_ready = all(check["status"] == "healthy" for check in checks.values())
|
||||||
response = {
|
response = {
|
||||||
"status": "started" if all_ready else "starting",
|
"status": "started" if all_ready else "starting",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"checks": checks,
|
"checks": checks,
|
||||||
}
|
}
|
||||||
if not all_ready:
|
if not all_ready:
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ def delete_project(
|
|||||||
use_case = DeleteProjectUseCase(project_repository)
|
use_case = DeleteProjectUseCase(project_repository)
|
||||||
try:
|
try:
|
||||||
deleted = use_case.execute(project_id, authenticated_user.user.id)
|
deleted = use_case.execute(project_id, authenticated_user.user.id)
|
||||||
except PermissionError as _e:
|
except PermissionError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only the project owner can delete this project",
|
detail="Only the project owner can delete this project",
|
||||||
) from _e
|
)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
return # type: ignore[return-value]
|
return
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
"""视频分享 API 路由."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.api.routes._helpers import format_utc_datetime
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.core.storage import OSSStorageService, get_storage_service
|
|
||||||
from app.dependencies import get_db_session, get_generated_video_repository
|
|
||||||
from app.schemas.video_share import (
|
|
||||||
CreateShareRequest,
|
|
||||||
ShareAccessResponse,
|
|
||||||
ShareListResponse,
|
|
||||||
ShareMetaResponse,
|
|
||||||
ShareResponse,
|
|
||||||
UpdateShareRequest,
|
|
||||||
VerifySharePasswordRequest,
|
|
||||||
)
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.video_share_repository import (
|
|
||||||
SQLAlchemyVideoShareRepository,
|
|
||||||
)
|
|
||||||
from packages.application.video_share.commands import (
|
|
||||||
CreateShareCommand,
|
|
||||||
UpdateShareCommand,
|
|
||||||
)
|
|
||||||
from packages.application.video_share.use_cases import (
|
|
||||||
AccessShareUseCase,
|
|
||||||
CreateShareUseCase,
|
|
||||||
GetShareByTokenUseCase,
|
|
||||||
InvalidPasswordError,
|
|
||||||
ListSharesByUserUseCase,
|
|
||||||
ListSharesByVideoUseCase,
|
|
||||||
NotFoundError,
|
|
||||||
PasswordRequiredError,
|
|
||||||
RecordShareDownloadUseCase,
|
|
||||||
RevokeShareUseCase,
|
|
||||||
ShareExpiredError,
|
|
||||||
UpdateShareUseCase,
|
|
||||||
VideoNotFoundError,
|
|
||||||
)
|
|
||||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_share_repository(
|
|
||||||
session: Session = Depends(get_db_session),
|
|
||||||
) -> SQLAlchemyVideoShareRepository:
|
|
||||||
return SQLAlchemyVideoShareRepository(session)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_share_response(share) -> ShareResponse:
|
|
||||||
return ShareResponse(
|
|
||||||
id=share.id,
|
|
||||||
video_id=share.video_id,
|
|
||||||
share_token=share.share_token,
|
|
||||||
has_password=share.has_password,
|
|
||||||
expires_at=share.expires_at,
|
|
||||||
view_count=share.view_count,
|
|
||||||
download_count=share.download_count,
|
|
||||||
is_active=share.is_active,
|
|
||||||
created_at=format_utc_datetime(share.created_at),
|
|
||||||
updated_at=format_utc_datetime(share.updated_at),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 用户侧:创建/管理分享 ──────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/{video_id}/share", response_model=ShareResponse)
|
|
||||||
def create_share(
|
|
||||||
video_id: str,
|
|
||||||
request: CreateShareRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
|
||||||
) -> ShareResponse:
|
|
||||||
"""为视频创建分享链接."""
|
|
||||||
use_case = CreateShareUseCase(share_repo, video_repo)
|
|
||||||
try:
|
|
||||||
share = use_case.execute(
|
|
||||||
CreateShareCommand(
|
|
||||||
video_id=video_id,
|
|
||||||
user_id=authenticated_user.user.id,
|
|
||||||
password=request.password,
|
|
||||||
expires_at=request.expires_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except VideoNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Share created: video_id=%s share_id=%s token=%s user=%s",
|
|
||||||
video_id,
|
|
||||||
share.id,
|
|
||||||
share.share_token,
|
|
||||||
authenticated_user.user.id,
|
|
||||||
)
|
|
||||||
return _to_share_response(share)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos/{video_id}/shares", response_model=ShareListResponse)
|
|
||||||
def list_video_shares(
|
|
||||||
video_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
) -> ShareListResponse:
|
|
||||||
"""获取某个视频的所有分享记录."""
|
|
||||||
use_case = ListSharesByVideoUseCase(share_repo)
|
|
||||||
items = use_case.execute(video_id, authenticated_user.user.id)
|
|
||||||
return ShareListResponse(
|
|
||||||
items=[_to_share_response(s) for s in items],
|
|
||||||
total=len(items),
|
|
||||||
skip=0,
|
|
||||||
limit=len(items),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/shares", response_model=ShareListResponse)
|
|
||||||
def list_user_shares(
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
) -> ShareListResponse:
|
|
||||||
"""获取用户创建的所有分享记录."""
|
|
||||||
use_case = ListSharesByUserUseCase(share_repo)
|
|
||||||
items, total = use_case.execute(authenticated_user.user.id, skip=skip, limit=limit)
|
|
||||||
return ShareListResponse(
|
|
||||||
items=[_to_share_response(s) for s in items],
|
|
||||||
total=total,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/shares/{share_id}", response_model=ShareResponse)
|
|
||||||
def update_share(
|
|
||||||
share_id: str,
|
|
||||||
request: UpdateShareRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
) -> ShareResponse:
|
|
||||||
"""更新分享配置(密码、有效期等)."""
|
|
||||||
use_case = UpdateShareUseCase(share_repo)
|
|
||||||
try:
|
|
||||||
share = use_case.execute(
|
|
||||||
UpdateShareCommand(
|
|
||||||
share_id=share_id,
|
|
||||||
user_id=authenticated_user.user.id,
|
|
||||||
password=request.password,
|
|
||||||
expires_at=request.expires_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
return _to_share_response(share)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/shares/{share_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
response_model=None,
|
|
||||||
response_class=Response,
|
|
||||||
)
|
|
||||||
def revoke_share(
|
|
||||||
share_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
) -> Response:
|
|
||||||
"""撤销/删除分享链接."""
|
|
||||||
use_case = RevokeShareUseCase(share_repo)
|
|
||||||
try:
|
|
||||||
use_case.execute(share_id, authenticated_user.user.id)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 公开侧:访问分享内容(无需登录) ──────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/share/{token}/meta", response_model=ShareMetaResponse)
|
|
||||||
def get_share_meta(
|
|
||||||
token: str,
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
|
||||||
storage: OSSStorageService = Depends(get_storage_service),
|
|
||||||
) -> ShareMetaResponse:
|
|
||||||
"""获取分享元信息(不需要密码,用于分享页加载前判断)。"""
|
|
||||||
use_case = GetShareByTokenUseCase(share_repo)
|
|
||||||
try:
|
|
||||||
share = use_case.execute(token)
|
|
||||||
except (NotFoundError, ShareExpiredError) as e:
|
|
||||||
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
|
||||||
|
|
||||||
video = video_repo.get(share.video_id)
|
|
||||||
video_name = video.name if video else ""
|
|
||||||
video_duration = video.duration if video else 0.0
|
|
||||||
thumbnail_url = None
|
|
||||||
if video and video.thumbnail_url:
|
|
||||||
try:
|
|
||||||
thumbnail_url = storage.get_download_url(video.thumbnail_url)
|
|
||||||
except Exception:
|
|
||||||
thumbnail_url = video.thumbnail_url
|
|
||||||
|
|
||||||
return ShareMetaResponse(
|
|
||||||
share_token=share.share_token,
|
|
||||||
has_password=share.has_password,
|
|
||||||
is_expired=share.is_expired,
|
|
||||||
is_active=share.is_active,
|
|
||||||
video_name=video_name,
|
|
||||||
video_duration=video_duration,
|
|
||||||
thumbnail_url=thumbnail_url,
|
|
||||||
created_at=format_utc_datetime(share.created_at),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/share/{token}/access", response_model=ShareAccessResponse)
|
|
||||||
def access_share(
|
|
||||||
token: str,
|
|
||||||
request: Optional[VerifySharePasswordRequest] = None,
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
video_repo: GeneratedVideoRepository = Depends(get_generated_video_repository),
|
|
||||||
storage: OSSStorageService = Depends(get_storage_service),
|
|
||||||
) -> ShareAccessResponse:
|
|
||||||
"""访问分享内容(验证密码后返回视频信息+播放/下载地址)。"""
|
|
||||||
use_case = AccessShareUseCase(share_repo, video_repo)
|
|
||||||
password = request.password if request else None
|
|
||||||
try:
|
|
||||||
result = use_case.execute(token, password=password)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
|
||||||
except ShareExpiredError as e:
|
|
||||||
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
|
|
||||||
except PasswordRequiredError as e:
|
|
||||||
raise HTTPException(status_code=403, detail="需要访问密码") from e
|
|
||||||
except InvalidPasswordError as e:
|
|
||||||
raise HTTPException(status_code=403, detail="密码错误") from e
|
|
||||||
except VideoNotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail="视频不存在") from e
|
|
||||||
|
|
||||||
# 生成下载URL
|
|
||||||
download_url = None
|
|
||||||
if result.video.file_url:
|
|
||||||
try:
|
|
||||||
download_url = storage.get_download_url(result.video.file_url)
|
|
||||||
except Exception:
|
|
||||||
download_url = result.video.file_url
|
|
||||||
|
|
||||||
# 缩略图URL
|
|
||||||
thumbnail_url = None
|
|
||||||
if result.video.thumbnail_url:
|
|
||||||
try:
|
|
||||||
thumbnail_url = storage.get_download_url(result.video.thumbnail_url)
|
|
||||||
except Exception:
|
|
||||||
thumbnail_url = result.video.thumbnail_url
|
|
||||||
|
|
||||||
return ShareAccessResponse(
|
|
||||||
share=_to_share_response(result.share),
|
|
||||||
video_name=result.video.name,
|
|
||||||
video_duration=result.video.duration,
|
|
||||||
video_size=result.video.file_size,
|
|
||||||
thumbnail_url=thumbnail_url,
|
|
||||||
download_url=download_url,
|
|
||||||
password_verified=result.password_verified,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/share/{token}/download")
|
|
||||||
def record_share_download(
|
|
||||||
token: str,
|
|
||||||
request: Optional[VerifySharePasswordRequest] = None,
|
|
||||||
share_repo: SQLAlchemyVideoShareRepository = Depends(_get_share_repository),
|
|
||||||
) -> dict:
|
|
||||||
"""记录分享下载(下载计数+1)。"""
|
|
||||||
use_case = RecordShareDownloadUseCase(share_repo)
|
|
||||||
password = request.password if request else None
|
|
||||||
try:
|
|
||||||
use_case.execute(token, password=password)
|
|
||||||
except NotFoundError as e:
|
|
||||||
raise HTTPException(status_code=404, detail="分享链接不存在或已失效") from e
|
|
||||||
except ShareExpiredError as e:
|
|
||||||
raise HTTPException(status_code=410, detail="分享链接已过期或已撤销") from e
|
|
||||||
except InvalidPasswordError as e:
|
|
||||||
raise HTTPException(status_code=403, detail="密码错误") from e
|
|
||||||
return {"success": True}
|
|
||||||
Executable → Regular
+1
-6
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -21,8 +20,6 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
|
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -257,9 +254,7 @@ async def payment_callback(
|
|||||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
|
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
|
||||||
# 不返回原始异常信息,避免泄漏内部实现细节
|
|
||||||
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
|
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.api.routes._helpers import format_utc_datetime
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.task_enqueue import (
|
from app.core.task_enqueue import (
|
||||||
@@ -25,6 +24,8 @@ from app.schemas.task_center import (
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
|
CreateGenerationTaskCommand,
|
||||||
|
CreateGenerationTaskUseCase,
|
||||||
RetryGenerationTaskUseCase,
|
RetryGenerationTaskUseCase,
|
||||||
SubmitIngestJobCommand,
|
SubmitIngestJobCommand,
|
||||||
SubmitIngestJobUseCase,
|
SubmitIngestJobUseCase,
|
||||||
@@ -100,8 +101,8 @@ def _generation_task_to_user_response(task) -> UserTaskResponse:
|
|||||||
retryable=_status_value(task.status) == "failed",
|
retryable=_status_value(task.status) == "failed",
|
||||||
retry_count=task.retry_count or 0,
|
retry_count=task.retry_count or 0,
|
||||||
source_id=task.id,
|
source_id=task.id,
|
||||||
created_at=format_utc_datetime(task.created_at),
|
created_at=task.created_at,
|
||||||
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at),
|
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -120,8 +121,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
|||||||
retry_count=task.retry_count or 0,
|
retry_count=task.retry_count or 0,
|
||||||
source_id=task.id,
|
source_id=task.id,
|
||||||
template_id=task.template_id,
|
template_id=task.template_id,
|
||||||
created_at=format_utc_datetime(task.created_at),
|
created_at=task.created_at,
|
||||||
updated_at=format_utc_datetime(task.completed_at or task.started_at or task.created_at),
|
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -285,8 +286,8 @@ def list_project_tasks(
|
|||||||
user_message=_humanize_task_error(job.error_message),
|
user_message=_humanize_task_error(job.error_message),
|
||||||
retryable=_status_value(job.status) == "failed",
|
retryable=_status_value(job.status) == "failed",
|
||||||
source_id=job.id,
|
source_id=job.id,
|
||||||
created_at=format_utc_datetime(job.created_at),
|
created_at=job.created_at,
|
||||||
updated_at=format_utc_datetime(job.updated_at),
|
updated_at=job.updated_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -367,9 +368,9 @@ def retry_project_task(
|
|||||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||||
if _status_value(job.status) != "failed":
|
if _status_value(job.status) != "failed":
|
||||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||||
use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment]
|
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||||
retried = use_case.execute(
|
retried = use_case.execute(
|
||||||
SubmitIngestJobCommand( # type: ignore[arg-type]
|
SubmitIngestJobCommand(
|
||||||
project_id=job.project_id,
|
project_id=job.project_id,
|
||||||
library_id=job.library_id,
|
library_id=job.library_id,
|
||||||
storage_key=job.storage_key,
|
storage_key=job.storage_key,
|
||||||
@@ -384,7 +385,7 @@ def retry_project_task(
|
|||||||
progress=0,
|
progress=0,
|
||||||
current_step=_ingest_step(retried),
|
current_step=_ingest_step(retried),
|
||||||
source_id=retried.id,
|
source_id=retried.id,
|
||||||
created_at=format_utc_datetime(retried.created_at),
|
created_at=retried.created_at,
|
||||||
updated_at=format_utc_datetime(retried.updated_at), # type: ignore[attr-defined]
|
updated_at=retried.updated_at,
|
||||||
)
|
)
|
||||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ def get_template(
|
|||||||
use_case = GetTemplateUseCase(template_repository)
|
use_case = GetTemplateUseCase(template_repository)
|
||||||
template = use_case.execute(template_id, user_id)
|
template = use_case.execute(template_id, user_id)
|
||||||
usage = template_repository.get_usage_count(template_id)
|
usage = template_repository.get_usage_count(template_id)
|
||||||
except Exception as _e:
|
except Exception:
|
||||||
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
|
||||||
if template is None:
|
if template is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
return _to_response(template, usage_count=usage)
|
return _to_response(template, usage_count=usage)
|
||||||
@@ -186,7 +186,7 @@ def create_template(
|
|||||||
try:
|
try:
|
||||||
template = use_case.execute(command)
|
template = use_case.execute(command)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
return _to_response(template)
|
return _to_response(template)
|
||||||
|
|
||||||
|
|
||||||
@@ -226,10 +226,10 @@ def update_template(
|
|||||||
use_case = UpdateTemplateUseCase(template_repository)
|
use_case = UpdateTemplateUseCase(template_repository)
|
||||||
try:
|
try:
|
||||||
template = use_case.execute(command)
|
template = use_case.execute(command)
|
||||||
except NotFoundError as _e:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
return _to_response(template)
|
return _to_response(template)
|
||||||
|
|
||||||
|
|
||||||
@@ -264,10 +264,10 @@ def copy_template(
|
|||||||
use_case = CopyTemplateUseCase(template_repository)
|
use_case = CopyTemplateUseCase(template_repository)
|
||||||
try:
|
try:
|
||||||
template = use_case.execute(command)
|
template = use_case.execute(command)
|
||||||
except NotFoundError as _e:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
return _to_response(template)
|
return _to_response(template)
|
||||||
|
|
||||||
|
|
||||||
@@ -299,9 +299,9 @@ def toggle_favorite(
|
|||||||
use_case = GetTemplateUseCase(template_repository)
|
use_case = GetTemplateUseCase(template_repository)
|
||||||
try:
|
try:
|
||||||
template = use_case.execute(template_id, user_id)
|
template = use_case.execute(template_id, user_id)
|
||||||
except Exception as _e:
|
except Exception:
|
||||||
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
|
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
if template is None:
|
if template is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
|
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
|
||||||
@@ -326,10 +326,10 @@ def validate_template(
|
|||||||
use_case = ValidateTemplateUseCase(template_repository)
|
use_case = ValidateTemplateUseCase(template_repository)
|
||||||
try:
|
try:
|
||||||
result = use_case.execute(command)
|
result = use_case.execute(command)
|
||||||
except NotFoundError as _e:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||||
|
|
||||||
return ValidateTemplateResponse(
|
return ValidateTemplateResponse(
|
||||||
template=_to_response(result.template),
|
template=_to_response(result.template),
|
||||||
@@ -375,9 +375,7 @@ def create_category(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||||
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
|
|
||||||
)
|
|
||||||
def delete_category(
|
def delete_category(
|
||||||
category_id: str,
|
category_id: str,
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
"""模板编辑器 API 路由包.
|
|
||||||
|
|
||||||
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
|
|
||||||
- schemas.py: 所有 Pydantic model
|
|
||||||
- dependencies.py: 依赖注入
|
|
||||||
- _utils.py: 工具函数
|
|
||||||
- _fallback.py: 自动兜底逻辑
|
|
||||||
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
|
|
||||||
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
|
|
||||||
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
|
|
||||||
- bgm.py: BGM 管理
|
|
||||||
- effects.py: 转场 + 滤镜
|
|
||||||
- export.py: 导出配置
|
|
||||||
- cover.py: 封面管理 + AI 生成封面
|
|
||||||
- subtitles.py: 字幕管理
|
|
||||||
- ai_features.py: AI 推荐
|
|
||||||
- generation.py: 生成(触发/进度/记录)
|
|
||||||
- timeline.py: 时间线
|
|
||||||
|
|
||||||
挂载路径: /api/v1/templates/{template_id}/editor/
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# 向后兼容:测试和其他模块可能直接从 templates_editor 导入这些符号
|
|
||||||
from app.auth import get_current_user # noqa: F401
|
|
||||||
from app.dependencies import get_db_session # noqa: F401
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from .adjustments import router as adjustments_router
|
|
||||||
from .ai_features import router as ai_features_router
|
|
||||||
from .bgm import router as bgm_router
|
|
||||||
from .clips import router as clips_router
|
|
||||||
from .cover import router as cover_router
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
|
||||||
from .draft import router as draft_router
|
|
||||||
from .effects import router as effects_router
|
|
||||||
from .export import router as export_router
|
|
||||||
from .generation import router as generation_router
|
|
||||||
from .subtitles import router as subtitles_router
|
|
||||||
from .timeline import router as timeline_router
|
|
||||||
|
|
||||||
# 主 router,所有子路由都合并到这里
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
# 合并所有子模块的路由(不用 include_router 是因为子路由有空路径 "")
|
|
||||||
_sub_routers = [
|
|
||||||
draft_router,
|
|
||||||
clips_router,
|
|
||||||
adjustments_router,
|
|
||||||
bgm_router,
|
|
||||||
effects_router,
|
|
||||||
export_router,
|
|
||||||
cover_router,
|
|
||||||
subtitles_router,
|
|
||||||
ai_features_router,
|
|
||||||
generation_router,
|
|
||||||
timeline_router,
|
|
||||||
]
|
|
||||||
|
|
||||||
for sub in _sub_routers:
|
|
||||||
for route in sub.routes:
|
|
||||||
router.routes.append(route)
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
"""模板编辑器自动兜底逻辑.
|
|
||||||
|
|
||||||
generate_editor_draft 触发生成前的自动修复流程:
|
|
||||||
1. draft → editing 状态迁移
|
|
||||||
2. 无片段时从模板复制片段配置
|
|
||||||
3. 为无素材片段分配指定素材
|
|
||||||
4. 项目有素材库时自动选素材
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import random
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
|
||||||
SQLAlchemyTemplateClipConfigRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
|
||||||
SQLAlchemyTemplateRepository,
|
|
||||||
)
|
|
||||||
from packages.domain.edit_plan import EditPlanStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_draft_to_editing(
|
|
||||||
svc: EditPlanService, plan_id: str, plan_check
|
|
||||||
) -> None:
|
|
||||||
"""自动兜底 1: draft → editing"""
|
|
||||||
if plan_check.status == EditPlanStatus.DRAFT:
|
|
||||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
|
||||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_copy_template_clips(
|
|
||||||
svc: EditPlanService, plan_id: str, plan_check, db: Session
|
|
||||||
) -> None:
|
|
||||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
|
||||||
existing_clips = svc.count_clips(plan_id)
|
|
||||||
if existing_clips == 0 and plan_check.template_id:
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
|
|
||||||
plan_id,
|
|
||||||
plan_check.template_id,
|
|
||||||
)
|
|
||||||
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
|
||||||
configs = clip_config_repo.list_by_template(plan_check.template_id)
|
|
||||||
if configs:
|
|
||||||
for cfg in configs:
|
|
||||||
svc.create_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type=cfg.clip_type.value
|
|
||||||
if hasattr(cfg.clip_type, "value")
|
|
||||||
else cfg.clip_type,
|
|
||||||
order=cfg.order,
|
|
||||||
template_clip_config_id=cfg.id,
|
|
||||||
duration=cfg.default_duration,
|
|
||||||
transition_effect=cfg.transition_effect.value
|
|
||||||
if hasattr(cfg.transition_effect, "value")
|
|
||||||
else cfg.transition_effect,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
|
||||||
plan_id,
|
|
||||||
len(configs),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
tpl_repo = SQLAlchemyTemplateRepository(db)
|
|
||||||
segments = tpl_repo.list_segments(plan_check.template_id)
|
|
||||||
for seg in segments:
|
|
||||||
avg_duration = (seg.duration_min + seg.duration_max) / 2
|
|
||||||
svc.create_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type="main",
|
|
||||||
order=seg.segment_order,
|
|
||||||
duration=avg_duration,
|
|
||||||
config={
|
|
||||||
"material_type": seg.material_type or "",
|
|
||||||
"template_segment_id": seg.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
|
|
||||||
plan_id,
|
|
||||||
len(segments),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_assign_assets(
|
|
||||||
svc: EditPlanService, plan_id: str, plan_check
|
|
||||||
) -> list:
|
|
||||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
|
||||||
all_clips = svc.list_clips(plan_id)
|
|
||||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
|
||||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
|
||||||
|
|
||||||
if clips_without_asset and config_asset_ids:
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
|
||||||
plan_id,
|
|
||||||
len(clips_without_asset),
|
|
||||||
len(config_asset_ids),
|
|
||||||
)
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
|
||||||
asset_idx = i % len(config_asset_ids)
|
|
||||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
|
||||||
logger.info("模板编辑器自动兜底3: plan=%s 素材分配完成", plan_id)
|
|
||||||
clips_without_asset = []
|
|
||||||
|
|
||||||
return clips_without_asset
|
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_auto_material_mode(
|
|
||||||
svc: EditPlanService,
|
|
||||||
plan_id: str,
|
|
||||||
plan_check,
|
|
||||||
clips_without_asset: list,
|
|
||||||
asset_library_repo: Any,
|
|
||||||
asset_repo: Any,
|
|
||||||
) -> None:
|
|
||||||
"""自动兜底 4: 项目有视频素材库时自动选素材"""
|
|
||||||
if not clips_without_asset:
|
|
||||||
return
|
|
||||||
if not plan_check.project_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
|
|
||||||
plan_id,
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
|
||||||
video_lib = None
|
|
||||||
for lib in libs:
|
|
||||||
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
|
||||||
if lib_kind == "video":
|
|
||||||
video_lib = lib
|
|
||||||
break
|
|
||||||
|
|
||||||
if video_lib:
|
|
||||||
assets = asset_repo.find_by_library(video_lib.id)
|
|
||||||
ready_videos = [
|
|
||||||
a
|
|
||||||
for a in assets
|
|
||||||
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
|
||||||
and a.mime_type
|
|
||||||
and a.mime_type.startswith("video")
|
|
||||||
]
|
|
||||||
if ready_videos:
|
|
||||||
random.shuffle(ready_videos)
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
|
||||||
asset = ready_videos[i % len(ready_videos)]
|
|
||||||
svc.assign_asset(clip.id, asset.id)
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材",
|
|
||||||
plan_id,
|
|
||||||
video_lib.name,
|
|
||||||
len(ready_videos),
|
|
||||||
)
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"""模板编辑器内部工具函数.
|
|
||||||
|
|
||||||
纯函数,不依赖请求上下文。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .schemas import ClipAdjustResponse
|
|
||||||
|
|
||||||
# 时间线场景颜色映射
|
|
||||||
_CLIP_TYPE_COLORS = {
|
|
||||||
"intro": "#6366f1",
|
|
||||||
"title": "#6366f1",
|
|
||||||
"product": "#818cf8",
|
|
||||||
"showcase": "#10b981",
|
|
||||||
"scene": "#10b981",
|
|
||||||
"subtitle": "#f59e0b",
|
|
||||||
"text": "#f59e0b",
|
|
||||||
"cta": "#ef4444",
|
|
||||||
"outro": "#ef4444",
|
|
||||||
"voiceover": "#8b5cf6",
|
|
||||||
"transition": "#64748b",
|
|
||||||
}
|
|
||||||
_DEFAULT_COLOR = "#6366f1"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_time(seconds: float) -> str:
|
|
||||||
"""秒数格式化为 m:ss"""
|
|
||||||
m = int(seconds) // 60
|
|
||||||
s = int(seconds) % 60
|
|
||||||
return f"{m}:{s:02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _clip_type_to_scene_label(clip_type: str, text_content: str) -> str:
|
|
||||||
"""片段类型转时间线场景标签"""
|
|
||||||
type_labels = {
|
|
||||||
"intro": "开场",
|
|
||||||
"title": "标题",
|
|
||||||
"product": "产品展示",
|
|
||||||
"showcase": "场景展示",
|
|
||||||
"scene": "场景",
|
|
||||||
"subtitle": "字幕",
|
|
||||||
"text": "文字",
|
|
||||||
"cta": "结尾 CTA",
|
|
||||||
"outro": "结尾",
|
|
||||||
"voiceover": "配音",
|
|
||||||
"transition": "转场",
|
|
||||||
}
|
|
||||||
label = type_labels.get(clip_type, clip_type or "片段")
|
|
||||||
if text_content:
|
|
||||||
short = text_content[:20].strip()
|
|
||||||
if short:
|
|
||||||
return f"{label} - {short}"
|
|
||||||
return label
|
|
||||||
|
|
||||||
|
|
||||||
# ── 片段调整相关工具 ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _get_clip_config(clip) -> dict:
|
|
||||||
"""安全获取 clip.config"""
|
|
||||||
config = getattr(clip, "config", {}) or {}
|
|
||||||
if not isinstance(config, dict):
|
|
||||||
config = {}
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _get_adjust_volume(clip) -> float:
|
|
||||||
"""获取片段音量"""
|
|
||||||
config = _get_clip_config(clip)
|
|
||||||
return float(config.get("volume", 1.0))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_adjust_trim(clip) -> tuple[float, float]:
|
|
||||||
"""获取片段裁剪起止"""
|
|
||||||
config = _get_clip_config(clip)
|
|
||||||
trim_start = float(config.get("trim_start", 0.0))
|
|
||||||
trim_end = float(config.get("trim_end", 0.0))
|
|
||||||
return trim_start, trim_end
|
|
||||||
|
|
||||||
|
|
||||||
def _build_adjust_response(clip) -> ClipAdjustResponse:
|
|
||||||
"""构造片段调整响应"""
|
|
||||||
trim_start, trim_end = _get_adjust_trim(clip)
|
|
||||||
return ClipAdjustResponse(
|
|
||||||
clip_id=clip.id,
|
|
||||||
speed=clip.playback_speed,
|
|
||||||
volume=_get_adjust_volume(clip),
|
|
||||||
trim_start=trim_start,
|
|
||||||
trim_end=trim_end,
|
|
||||||
duration=clip.duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
|
|
||||||
"""校验裁剪时长合法性"""
|
|
||||||
if trim_start + trim_end >= total_duration:
|
|
||||||
raise ValueError(
|
|
||||||
f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _clip_value(value: Any) -> str:
|
|
||||||
"""获取枚举/字符串值的统一方法"""
|
|
||||||
if hasattr(value, "value"):
|
|
||||||
return value.value
|
|
||||||
return str(value)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"""片段调整路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- PUT /clips/{clip_id}/speed 调速
|
|
||||||
- PUT /clips/{clip_id}/volume 调音量
|
|
||||||
- PUT /clips/{clip_id}/trim 裁剪
|
|
||||||
- PUT /clips/{clip_id}/adjustments 统一调整
|
|
||||||
- POST /clips/batch-speed 批量调速
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from ._utils import _build_adjust_response, _get_adjust_trim, _get_clip_config, _validate_trim
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import (
|
|
||||||
BatchSpeedRequest,
|
|
||||||
BatchSpeedResponse,
|
|
||||||
ClipAdjustmentsRequest,
|
|
||||||
ClipAdjustResponse,
|
|
||||||
SpeedAdjustRequest,
|
|
||||||
TrimAdjustRequest,
|
|
||||||
VolumeAdjustRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
|
|
||||||
def adjust_editor_clip_speed(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: SpeedAdjustRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipAdjustResponse:
|
|
||||||
"""调整片段播放速度"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
updated = plan_svc.update_clip(clip_id, playback_speed=body.speed)
|
|
||||||
return _build_adjust_response(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
|
|
||||||
def adjust_editor_clip_volume(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: VolumeAdjustRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipAdjustResponse:
|
|
||||||
"""调整片段音量"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
config = dict(_get_clip_config(clip))
|
|
||||||
config["volume"] = body.volume
|
|
||||||
updated = plan_svc.update_clip(clip_id, config=config)
|
|
||||||
return _build_adjust_response(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
|
|
||||||
def adjust_editor_clip_trim(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: TrimAdjustRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipAdjustResponse:
|
|
||||||
"""裁剪片段(trim in/out)"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
try:
|
|
||||||
_validate_trim(body.trim_start, body.trim_end, clip.duration)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
|
|
||||||
config = dict(_get_clip_config(clip))
|
|
||||||
config["trim_start"] = body.trim_start
|
|
||||||
config["trim_end"] = body.trim_end
|
|
||||||
updated = plan_svc.update_clip(clip_id, config=config)
|
|
||||||
return _build_adjust_response(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
|
|
||||||
def adjust_editor_clip_all(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: ClipAdjustmentsRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipAdjustResponse:
|
|
||||||
"""统一调整片段的 speed / volume / trim"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
update_kwargs: dict[str, Any] = {}
|
|
||||||
config_updates: dict[str, Any] = {}
|
|
||||||
|
|
||||||
if body.speed is not None:
|
|
||||||
update_kwargs["playback_speed"] = body.speed
|
|
||||||
if body.volume is not None:
|
|
||||||
config_updates["volume"] = body.volume
|
|
||||||
if body.trim_start is not None:
|
|
||||||
config_updates["trim_start"] = body.trim_start
|
|
||||||
if body.trim_end is not None:
|
|
||||||
config_updates["trim_end"] = body.trim_end
|
|
||||||
|
|
||||||
current_trim_start, current_trim_end = _get_adjust_trim(clip)
|
|
||||||
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
|
|
||||||
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
|
|
||||||
|
|
||||||
if body.trim_start is not None or body.trim_end is not None:
|
|
||||||
try:
|
|
||||||
_validate_trim(new_trim_start, new_trim_end, clip.duration)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
|
|
||||||
if config_updates:
|
|
||||||
config = dict(_get_clip_config(clip))
|
|
||||||
config.update(config_updates)
|
|
||||||
update_kwargs["config"] = config
|
|
||||||
|
|
||||||
if not update_kwargs:
|
|
||||||
return _build_adjust_response(clip)
|
|
||||||
|
|
||||||
updated = plan_svc.update_clip(clip_id, **update_kwargs)
|
|
||||||
return _build_adjust_response(updated)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/batch-speed", response_model=BatchSpeedResponse)
|
|
||||||
def batch_adjust_editor_speed(
|
|
||||||
template_id: str,
|
|
||||||
body: BatchSpeedRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> BatchSpeedResponse:
|
|
||||||
"""批量调整草稿内所有片段的播放速度"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clips = plan_svc.list_clips(plan_id, limit=500, skip=0)
|
|
||||||
count = 0
|
|
||||||
for clip in clips:
|
|
||||||
plan_svc.update_clip(clip.id, playback_speed=body.speed)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""AI 功能路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- POST /ai-recommend AI 推荐片段方案
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.dependencies import get_db_session
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.domain.config_schemas import normalize_plan_config
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import AIRecommendRequest, AIRecommendResponse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ai-recommend", response_model=AIRecommendResponse)
|
|
||||||
def editor_ai_recommend(
|
|
||||||
template_id: str,
|
|
||||||
body: AIRecommendRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> AIRecommendResponse:
|
|
||||||
"""AI 推荐片段方案"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status
|
|
||||||
if plan_status not in ("draft", "editing"):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="当前草稿状态不支持AI推荐,请先编辑后再试",
|
|
||||||
)
|
|
||||||
|
|
||||||
from packages.shared.ai_service import run_ai_recommend
|
|
||||||
|
|
||||||
result = run_ai_recommend(
|
|
||||||
plan_id=plan_id,
|
|
||||||
template_id=plan.template_id,
|
|
||||||
asset_ids=body.asset_ids,
|
|
||||||
editing_mode=body.editing_mode,
|
|
||||||
target_duration=body.target_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan_svc.delete_all_clips(plan_id)
|
|
||||||
|
|
||||||
for clip_data in result["clips"]:
|
|
||||||
plan_svc.create_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type=clip_data["clip_type"],
|
|
||||||
order=clip_data["order"],
|
|
||||||
text_content=clip_data.get("text_content", ""),
|
|
||||||
duration=clip_data["duration"],
|
|
||||||
transition_effect=clip_data.get("transition_effect", "cut"),
|
|
||||||
asset_id=clip_data.get("asset_id", ""),
|
|
||||||
start_time=clip_data.get("start_time", 0.0),
|
|
||||||
config=clip_data.get("config", {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized_config = normalize_plan_config(result.get("config", {}))
|
|
||||||
plan_svc.update_plan(
|
|
||||||
plan_id,
|
|
||||||
config=normalized_config,
|
|
||||||
total_duration=result["total_duration"],
|
|
||||||
)
|
|
||||||
except Exception as _e:
|
|
||||||
logger.exception(
|
|
||||||
"模板编辑器AI推荐写入失败: template_id=%s plan_id=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
db.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="AI推荐结果保存失败,请稍后重试",
|
|
||||||
) from _e
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器AI推荐: template_id=%s plan_id=%s clips=%d duration=%.1f by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
len(result["clips"]),
|
|
||||||
result["total_duration"],
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return AIRecommendResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clips=[
|
|
||||||
{
|
|
||||||
"clip_type": c["clip_type"],
|
|
||||||
"order": c["order"],
|
|
||||||
"text_content": c.get("text_content", ""),
|
|
||||||
"duration": c["duration"],
|
|
||||||
"transition_effect": c.get("transition_effect", "cut"),
|
|
||||||
"asset_id": c.get("asset_id", ""),
|
|
||||||
"start_time": c.get("start_time", 0.0),
|
|
||||||
"config": c.get("config", {}),
|
|
||||||
}
|
|
||||||
for c in result["clips"]
|
|
||||||
],
|
|
||||||
config=normalized_config,
|
|
||||||
total_duration=result["total_duration"],
|
|
||||||
confidence=result["confidence"],
|
|
||||||
)
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
"""BGM 管理路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /bgm 获取 BGM 配置
|
|
||||||
- PUT /bgm 更新 BGM 配置
|
|
||||||
- GET /bgm/presets 预设 BGM 列表
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import BGMConfigUpdateRequest
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bgm", response_model=dict[str, Any])
|
|
||||||
def get_editor_bgm(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""获取草稿的 BGM 配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
config = plan.config or {}
|
|
||||||
return {
|
|
||||||
"plan_id": plan.id,
|
|
||||||
"bgm": config.get("bgm", {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/bgm", response_model=dict[str, Any])
|
|
||||||
def update_editor_bgm(
|
|
||||||
template_id: str,
|
|
||||||
body: BGMConfigUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""更新草稿的 BGM 配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
config = dict(plan.config) if plan.config else {}
|
|
||||||
current_bgm = dict(config.get("bgm", {}))
|
|
||||||
update_data = body.model_dump(exclude_none=True)
|
|
||||||
current_bgm.update(update_data)
|
|
||||||
|
|
||||||
if current_bgm.get("enabled"):
|
|
||||||
has_source = any(
|
|
||||||
current_bgm.get(key)
|
|
||||||
for key in ("asset_id", "preset_id", "audio_url")
|
|
||||||
if current_bgm.get(key)
|
|
||||||
)
|
|
||||||
if not has_source:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url)",
|
|
||||||
)
|
|
||||||
|
|
||||||
config["bgm"] = current_bgm
|
|
||||||
updated_plan = plan_svc.update_plan_config(plan_id, config)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器更新BGM: template_id=%s plan_id=%s enabled=%s by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
current_bgm.get("enabled", False),
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"plan_id": updated_plan.id,
|
|
||||||
"bgm": current_bgm,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bgm/presets", response_model=dict[str, Any])
|
|
||||||
def list_editor_bgm_presets(
|
|
||||||
style: str | None = Query(default=None, description="按风格筛选"),
|
|
||||||
keyword: str | None = Query(default=None, description="关键词搜索"),
|
|
||||||
skip: int = Query(default=0, ge=0, description="分页偏移"),
|
|
||||||
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""获取预设 BGM 列表"""
|
|
||||||
from packages.domain.preset_bgm import (
|
|
||||||
BGM_STYLES,
|
|
||||||
PRESET_BGM_LIBRARY,
|
|
||||||
list_preset_bgm_by_style,
|
|
||||||
search_preset_bgm,
|
|
||||||
)
|
|
||||||
|
|
||||||
bgm_list = PRESET_BGM_LIBRARY
|
|
||||||
if keyword:
|
|
||||||
bgm_list = search_preset_bgm(keyword)
|
|
||||||
elif style:
|
|
||||||
bgm_list = list_preset_bgm_by_style(style)
|
|
||||||
|
|
||||||
total = len(bgm_list)
|
|
||||||
paged = bgm_list[skip : skip + limit]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total": total,
|
|
||||||
"skip": skip,
|
|
||||||
"limit": limit,
|
|
||||||
"styles": BGM_STYLES,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": bgm.id,
|
|
||||||
"name": bgm.name,
|
|
||||||
"style": bgm.style,
|
|
||||||
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
|
|
||||||
"duration": bgm.duration,
|
|
||||||
"artist": bgm.artist,
|
|
||||||
"description": bgm.description,
|
|
||||||
"tags": bgm.tags,
|
|
||||||
"audio_url": bgm.audio_url,
|
|
||||||
}
|
|
||||||
for bgm in paged
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
"""片段管理路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /clips 片段列表
|
|
||||||
- POST /clips 创建片段
|
|
||||||
- GET /clips/{clip_id} 片段详情
|
|
||||||
- PUT /clips/{clip_id} 更新片段
|
|
||||||
- DELETE /clips/{clip_id} 删除片段
|
|
||||||
- POST /clips/{clip_id}/split 分割片段
|
|
||||||
- POST /clips/merge 合并片段
|
|
||||||
- POST /clips/reorder 重排片段
|
|
||||||
- POST /clips/batch-delete 批量删除
|
|
||||||
- POST /clips/from-assets 从素材创建片段
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import (
|
|
||||||
ClipBatchDeleteRequest,
|
|
||||||
ClipBatchDeleteResponse,
|
|
||||||
ClipReorderRequest,
|
|
||||||
ClipReorderResponse,
|
|
||||||
ClipsFromAssetsRequest,
|
|
||||||
ClipsFromAssetsResponse,
|
|
||||||
EditorClipCreateRequest,
|
|
||||||
EditorClipListResponse,
|
|
||||||
EditorClipResponse,
|
|
||||||
EditorClipUpdateRequest,
|
|
||||||
MergeClipsRequest,
|
|
||||||
SplitClipRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
def _clip_to_response(clip) -> EditorClipResponse:
|
|
||||||
"""统一构造片段响应"""
|
|
||||||
return EditorClipResponse(
|
|
||||||
id=clip.id,
|
|
||||||
plan_id=clip.plan_id,
|
|
||||||
clip_type=clip.clip_type.value
|
|
||||||
if hasattr(clip.clip_type, "value")
|
|
||||||
else str(clip.clip_type),
|
|
||||||
order=clip.order,
|
|
||||||
duration=clip.duration,
|
|
||||||
text_content=clip.text_content or "",
|
|
||||||
transition_effect=clip.transition_effect.value
|
|
||||||
if hasattr(clip.transition_effect, "value")
|
|
||||||
else str(clip.transition_effect),
|
|
||||||
playback_speed=clip.playback_speed or 1.0,
|
|
||||||
config=clip.config or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clips", response_model=EditorClipListResponse)
|
|
||||||
def list_draft_clips(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
skip: int = Query(default=0, ge=0),
|
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""获取草稿的片段列表"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
|
|
||||||
total = plan_svc.count_clips(plan_id)
|
|
||||||
return EditorClipListResponse(
|
|
||||||
items=[_clip_to_response(c) for c in clips],
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips", response_model=EditorClipResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
def create_draft_clip(
|
|
||||||
template_id: str,
|
|
||||||
req: EditorClipCreateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""在草稿中创建新片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
try:
|
|
||||||
clip = plan_svc.create_clip(
|
|
||||||
plan_id,
|
|
||||||
clip_type=req.clip_type,
|
|
||||||
order=req.order,
|
|
||||||
duration=req.duration,
|
|
||||||
text_content=req.text_content,
|
|
||||||
transition_effect=req.transition_effect,
|
|
||||||
config=req.config,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
return _clip_to_response(clip)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}", response_model=EditorClipResponse)
|
|
||||||
def update_draft_clip(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
req: EditorClipUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""更新草稿中的片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
try:
|
|
||||||
clip = plan_svc.update_clip(
|
|
||||||
clip_id,
|
|
||||||
order=req.order,
|
|
||||||
duration=req.duration,
|
|
||||||
text_content=req.text_content,
|
|
||||||
transition_effect=req.transition_effect,
|
|
||||||
playback_speed=req.playback_speed,
|
|
||||||
config=req.config,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
return _clip_to_response(clip)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/clips/{clip_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
def delete_draft_clip(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""删除草稿中的片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
success = plan_svc.delete_clip(clip_id)
|
|
||||||
if not success:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clips/{clip_id}", response_model=EditorClipResponse)
|
|
||||||
def get_draft_clip_detail(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""获取草稿中的片段详情"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if clip is None:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
if clip.plan_id != plan_id:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
return _clip_to_response(clip)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
|
||||||
def split_draft_clip(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: SplitClipRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""将一个片段从指定时间点分割为两个片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if clip is None or clip.plan_id != plan_id:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
try:
|
|
||||||
result = plan_svc.split_clip(clip_id, body.split_time)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
left = result["left_clip"]
|
|
||||||
right = result["right_clip"]
|
|
||||||
return {
|
|
||||||
"left_clip": {
|
|
||||||
"id": left.id,
|
|
||||||
"plan_id": left.plan_id,
|
|
||||||
"clip_type": left.clip_type,
|
|
||||||
"order": left.order,
|
|
||||||
"duration": left.duration,
|
|
||||||
"start_time": left.start_time,
|
|
||||||
},
|
|
||||||
"right_clip": {
|
|
||||||
"id": right.id,
|
|
||||||
"plan_id": right.plan_id,
|
|
||||||
"clip_type": right.clip_type,
|
|
||||||
"order": right.order,
|
|
||||||
"duration": right.duration,
|
|
||||||
"start_time": right.start_time,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
|
|
||||||
def merge_draft_clips(
|
|
||||||
template_id: str,
|
|
||||||
body: MergeClipsRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""将多个连续的同类型片段合并为一个片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
for cid in body.clip_ids:
|
|
||||||
clip = plan_svc.get_clip(cid)
|
|
||||||
if clip is None or clip.plan_id != plan_id:
|
|
||||||
raise HTTPException(status_code=404, detail=f"片段不存在: {cid}")
|
|
||||||
try:
|
|
||||||
merged = plan_svc.merge_clips(body.clip_ids)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
return {
|
|
||||||
"id": merged.id,
|
|
||||||
"plan_id": merged.plan_id,
|
|
||||||
"clip_type": merged.clip_type,
|
|
||||||
"order": merged.order,
|
|
||||||
"duration": merged.duration,
|
|
||||||
"text_content": merged.text_content,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/reorder", response_model=ClipReorderResponse)
|
|
||||||
def reorder_editor_clips(
|
|
||||||
template_id: str,
|
|
||||||
body: ClipReorderRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipReorderResponse:
|
|
||||||
"""批量重排片段顺序"""
|
|
||||||
_, plan_svc = services
|
|
||||||
count = 0
|
|
||||||
for item in body.items:
|
|
||||||
try:
|
|
||||||
plan_svc.update_clip(item.clip_id, order=item.new_order)
|
|
||||||
count += 1
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ClipReorderResponse(updated_count=count, plan_id=plan_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/batch-delete", response_model=ClipBatchDeleteResponse)
|
|
||||||
def batch_delete_editor_clips(
|
|
||||||
template_id: str,
|
|
||||||
body: ClipBatchDeleteRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipBatchDeleteResponse:
|
|
||||||
"""批量删除片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
deleted = 0
|
|
||||||
for clip_id in body.clip_ids:
|
|
||||||
if plan_svc.delete_clip(clip_id):
|
|
||||||
deleted += 1
|
|
||||||
|
|
||||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
|
|
||||||
def create_clips_from_assets_editor(
|
|
||||||
template_id: str,
|
|
||||||
body: ClipsFromAssetsRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipsFromAssetsResponse:
|
|
||||||
"""从素材批量创建片段"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clips = []
|
|
||||||
for i, asset_id in enumerate(body.asset_ids):
|
|
||||||
try:
|
|
||||||
clip = plan_svc.create_clip(
|
|
||||||
plan_id,
|
|
||||||
clip_type="main",
|
|
||||||
order=body.start_order + i if hasattr(body, "start_order") else i,
|
|
||||||
duration=5.0,
|
|
||||||
asset_id=asset_id,
|
|
||||||
)
|
|
||||||
clips.append(clip)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
len(clips),
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ClipsFromAssetsResponse(
|
|
||||||
created_count=len(clips),
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_ids=[c.id for c in clips],
|
|
||||||
)
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""封面管理路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /cover 封面配置
|
|
||||||
- PUT /cover 更新封面
|
|
||||||
- POST /cover/extract 抽帧生成封面
|
|
||||||
- POST /cover/smart 智能选帧
|
|
||||||
- POST /generate-cover AI 生成封面
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from packages.domain.config_schemas import normalize_plan_config
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import (
|
|
||||||
CoverConfigResponse,
|
|
||||||
CoverExtractRequest,
|
|
||||||
CoverGenerateResponse,
|
|
||||||
CoverSmartRequest,
|
|
||||||
CoverUpdateRequest,
|
|
||||||
GenerateCoverRequest,
|
|
||||||
GenerateCoverResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cover", response_model=CoverConfigResponse)
|
|
||||||
def get_editor_cover(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> CoverConfigResponse:
|
|
||||||
"""获取草稿封面配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
config = plan.config or {}
|
|
||||||
cover_config = config.get("cover", {})
|
|
||||||
|
|
||||||
return CoverConfigResponse(
|
|
||||||
type=cover_config.get("cover_type", "auto"),
|
|
||||||
image_url=cover_config.get("cover_image_url", ""),
|
|
||||||
frame_time=cover_config.get("frame_time", 0.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/cover", response_model=CoverConfigResponse)
|
|
||||||
def update_editor_cover(
|
|
||||||
template_id: str,
|
|
||||||
body: CoverUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> CoverConfigResponse:
|
|
||||||
"""更新草稿封面配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
config = dict(plan.config) if plan.config else {}
|
|
||||||
current_cover = dict(config.get("cover", {}))
|
|
||||||
update_data = body.model_dump(exclude_none=True)
|
|
||||||
current_cover.update(update_data)
|
|
||||||
|
|
||||||
config["cover"] = current_cover
|
|
||||||
normalized = normalize_plan_config(config)
|
|
||||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
|
||||||
|
|
||||||
return CoverConfigResponse(
|
|
||||||
type=current_cover.get("cover_type", "auto"),
|
|
||||||
image_url=current_cover.get("cover_image_url", ""),
|
|
||||||
frame_time=current_cover.get("frame_time", 0.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cover/extract", response_model=CoverGenerateResponse)
|
|
||||||
def extract_editor_cover(
|
|
||||||
template_id: str,
|
|
||||||
body: CoverExtractRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> CoverGenerateResponse:
|
|
||||||
"""从指定片段抽帧生成封面"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
clip = plan_svc.get_clip(body.clip_id)
|
|
||||||
if not clip or clip.plan_id != plan_id:
|
|
||||||
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
|
|
||||||
|
|
||||||
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
|
|
||||||
|
|
||||||
config = dict(plan.config) if plan.config else {}
|
|
||||||
cover_config = dict(config.get("cover", {}))
|
|
||||||
cover_config.update(
|
|
||||||
{
|
|
||||||
"cover_type": "extract",
|
|
||||||
"cover_image_url": cover_url,
|
|
||||||
"clip_id": body.clip_id,
|
|
||||||
"frame_time": body.frame_time,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
config["cover"] = cover_config
|
|
||||||
normalized = normalize_plan_config(config)
|
|
||||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
body.clip_id,
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return CoverGenerateResponse(
|
|
||||||
type="extract",
|
|
||||||
image_url=cover_url,
|
|
||||||
frame_time=body.frame_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/cover/smart", response_model=CoverGenerateResponse)
|
|
||||||
def smart_editor_cover(
|
|
||||||
template_id: str,
|
|
||||||
body: CoverSmartRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> CoverGenerateResponse:
|
|
||||||
"""智能选帧生成封面"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
cover_url = f"cover/smart/{plan_id}_smart.jpg"
|
|
||||||
strategy = getattr(body, "strategy", "auto")
|
|
||||||
|
|
||||||
config = dict(plan.config) if plan.config else {}
|
|
||||||
cover_config = dict(config.get("cover", {}))
|
|
||||||
cover_config.update(
|
|
||||||
{
|
|
||||||
"cover_type": "smart",
|
|
||||||
"cover_image_url": cover_url,
|
|
||||||
"strategy": strategy,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
config["cover"] = cover_config
|
|
||||||
normalized = normalize_plan_config(config)
|
|
||||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
strategy,
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return CoverGenerateResponse(
|
|
||||||
type="smart",
|
|
||||||
image_url=cover_url,
|
|
||||||
frame_time=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-cover", response_model=GenerateCoverResponse)
|
|
||||||
def editor_generate_cover(
|
|
||||||
template_id: str,
|
|
||||||
body: GenerateCoverRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> GenerateCoverResponse:
|
|
||||||
"""AI 生成封面"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
from packages.shared.ai_service import run_generate_cover
|
|
||||||
|
|
||||||
cover_data = run_generate_cover(
|
|
||||||
plan_id=plan_id,
|
|
||||||
asset_ids=body.asset_ids,
|
|
||||||
cover_type=body.cover_type,
|
|
||||||
frame_time=body.frame_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
current_config = dict(plan.config) if plan.config else {}
|
|
||||||
current_config["cover"] = cover_data
|
|
||||||
normalized = normalize_plan_config(current_config)
|
|
||||||
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
body.cover_type,
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"""模板编辑器依赖注入.
|
|
||||||
|
|
||||||
核心依赖:
|
|
||||||
- get_editor_services: 获取模板+计划服务
|
|
||||||
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
|
||||||
- _check_queue_limits: 生成队列限流检查
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
|
|
||||||
from app.dependencies import get_db_session
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
|
||||||
SQLAlchemyTemplateRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def get_editor_services(
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
) -> tuple[EditTemplateService, EditPlanService]:
|
|
||||||
"""获取模板编辑器所需的两个服务"""
|
|
||||||
return EditTemplateService(db), EditPlanService(db)
|
|
||||||
|
|
||||||
|
|
||||||
def get_draft_plan_id(
|
|
||||||
template_id: str,
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
) -> str:
|
|
||||||
"""路径依赖:根据 template_id 获取或创建草稿,返回 plan_id.
|
|
||||||
|
|
||||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
|
||||||
确保 template_id → plan_id 的映射始终存在。
|
|
||||||
|
|
||||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
|
||||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
|
||||||
"""
|
|
||||||
tpl_svc, plan_svc = services
|
|
||||||
user_id = str(current_user.user.id)
|
|
||||||
|
|
||||||
# 1. 草稿已存在 → 直接返回
|
|
||||||
draft = tpl_svc.get_template_draft(template_id)
|
|
||||||
if draft is not None:
|
|
||||||
return draft.id
|
|
||||||
|
|
||||||
# 2. 新系统有模板 → 用新服务创建草稿
|
|
||||||
if tpl_svc.get_template(template_id) is not None:
|
|
||||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
|
||||||
return draft.id
|
|
||||||
|
|
||||||
# 3. 回退到旧模板系统(templates 表)
|
|
||||||
old_repo = SQLAlchemyTemplateRepository(db)
|
|
||||||
old_template = old_repo.get(template_id, user_id=user_id)
|
|
||||||
if old_template is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
|
||||||
|
|
||||||
# 4. 基于旧模板创建草稿计划
|
|
||||||
from app.services.plan_generator_service import PlanGeneratorService
|
|
||||||
|
|
||||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
|
||||||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
|
||||||
|
|
||||||
# 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段)
|
|
||||||
pseudo_template = EditTemplate(
|
|
||||||
id=old_template.id,
|
|
||||||
name=old_template.name,
|
|
||||||
editing_mode=old_template.mode,
|
|
||||||
status=EditTemplateStatus.ACTIVE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 将旧模板 segments 转换为 clip_configs
|
|
||||||
clip_configs: list[TemplateClipConfig] = []
|
|
||||||
for seg in old_template.segments or []:
|
|
||||||
clip_configs.append(
|
|
||||||
TemplateClipConfig(
|
|
||||||
id=f"seg_{seg.id}",
|
|
||||||
template_id=old_template.id,
|
|
||||||
clip_type=ClipType.MAIN,
|
|
||||||
order=seg.segment_order,
|
|
||||||
min_duration=seg.duration_min,
|
|
||||||
max_duration=seg.duration_max,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
generator = PlanGeneratorService(db)
|
|
||||||
result = generator.generate_from_template(
|
|
||||||
template=pseudo_template,
|
|
||||||
clip_configs=clip_configs,
|
|
||||||
asset_ids=[],
|
|
||||||
created_by_user_id=user_id,
|
|
||||||
name=f"{old_template.name} - 草稿",
|
|
||||||
)
|
|
||||||
plan = result["plan"]
|
|
||||||
|
|
||||||
# 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑)
|
|
||||||
plan_svc.update_plan_config(plan.id, {"is_template_draft": True})
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"旧模板自动创建草稿: template_id=%s draft_plan_id=%s user_id=%s",
|
|
||||||
template_id,
|
|
||||||
plan.id,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
return plan.id
|
|
||||||
|
|
||||||
|
|
||||||
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
|
|
||||||
"""队列限流预检查"""
|
|
||||||
try:
|
|
||||||
has_count = (
|
|
||||||
hasattr(gen_task_repo, "count_pending_by_user")
|
|
||||||
and hasattr(gen_task_repo, "count_pending_total")
|
|
||||||
)
|
|
||||||
if has_count:
|
|
||||||
user_pending = gen_task_repo.count_pending_by_user(user_id)
|
|
||||||
global_pending = gen_task_repo.count_pending_total()
|
|
||||||
if user_pending >= USER_PENDING_LIMIT:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
|
||||||
)
|
|
||||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="系统繁忙,请稍后再试",
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
"""草稿管理路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET / 获取草稿详情
|
|
||||||
- PUT / 更新草稿
|
|
||||||
- POST /publish 发布草稿到模板
|
|
||||||
- GET /versions 模板版本历史
|
|
||||||
- POST /rollback 回滚到指定版本
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import (
|
|
||||||
EditorDraftResponse,
|
|
||||||
EditorPublishResponse,
|
|
||||||
EditorRollbackRequest,
|
|
||||||
EditorRollbackResponse,
|
|
||||||
EditorTemplateVersionItem,
|
|
||||||
EditorUpdateRequest,
|
|
||||||
EditorVersionListResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=EditorDraftResponse)
|
|
||||||
def get_editor_draft(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""获取模板编辑器草稿详情
|
|
||||||
|
|
||||||
首次访问时自动创建草稿。
|
|
||||||
"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
clips = plan_svc.list_clips(plan_id)
|
|
||||||
return EditorDraftResponse(
|
|
||||||
plan_id=plan.id,
|
|
||||||
template_id=plan.template_id,
|
|
||||||
name=plan.name,
|
|
||||||
status=plan.status.value if hasattr(plan.status, "value") else str(plan.status),
|
|
||||||
config=plan.config or {},
|
|
||||||
total_duration=plan.total_duration,
|
|
||||||
clip_count=len(clips),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=EditorDraftResponse)
|
|
||||||
def update_editor_draft(
|
|
||||||
template_id: str,
|
|
||||||
req: EditorUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""更新模板编辑器草稿"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.update_plan(
|
|
||||||
plan_id,
|
|
||||||
name=req.name,
|
|
||||||
config=req.config,
|
|
||||||
total_duration=req.total_duration,
|
|
||||||
)
|
|
||||||
clips = plan_svc.list_clips(plan_id)
|
|
||||||
return EditorDraftResponse(
|
|
||||||
plan_id=plan.id,
|
|
||||||
template_id=plan.template_id,
|
|
||||||
name=plan.name,
|
|
||||||
status=plan.status.value if hasattr(plan.status, "value") else str(plan.status),
|
|
||||||
config=plan.config or {},
|
|
||||||
total_duration=plan.total_duration,
|
|
||||||
clip_count=len(clips),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/publish", response_model=EditorPublishResponse, status_code=status.HTTP_200_OK)
|
|
||||||
def publish_draft_to_template(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""将草稿发布(同步)到正式模板
|
|
||||||
|
|
||||||
草稿的 config 和 clips 会同步覆盖到模板,事务保证一致性。
|
|
||||||
"""
|
|
||||||
tpl_svc, plan_svc = services
|
|
||||||
try:
|
|
||||||
tpl = tpl_svc.publish_template_from_draft(template_id, plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
clips = plan_svc.list_clips(plan_id)
|
|
||||||
return EditorPublishResponse(
|
|
||||||
template_id=tpl.id,
|
|
||||||
status="published",
|
|
||||||
clip_count=len(clips),
|
|
||||||
version=tpl.version,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/versions", response_model=EditorVersionListResponse)
|
|
||||||
def list_template_versions(
|
|
||||||
template_id: str,
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
|
||||||
):
|
|
||||||
"""查询模板发布版本历史"""
|
|
||||||
tpl_svc, _ = services
|
|
||||||
versions = tpl_svc.list_template_versions(template_id, limit=limit)
|
|
||||||
items = [
|
|
||||||
EditorTemplateVersionItem(
|
|
||||||
version=v.version,
|
|
||||||
name=v.name,
|
|
||||||
editing_mode=v.editing_mode,
|
|
||||||
clip_count=len(v.clip_configs),
|
|
||||||
change_note=v.change_note,
|
|
||||||
published_by=v.published_by,
|
|
||||||
created_at=(
|
|
||||||
v.created_at.isoformat()
|
|
||||||
if hasattr(v.created_at, "isoformat")
|
|
||||||
else str(v.created_at)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for v in versions
|
|
||||||
]
|
|
||||||
return EditorVersionListResponse(versions=items, total=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rollback", response_model=EditorRollbackResponse, status_code=status.HTTP_200_OK)
|
|
||||||
def rollback_template(
|
|
||||||
template_id: str,
|
|
||||||
request: EditorRollbackRequest,
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""回滚模板到指定历史版本
|
|
||||||
|
|
||||||
回滚本身也是一次发布,版本号会 +1,可以再次回滚。
|
|
||||||
"""
|
|
||||||
tpl_svc, _ = services
|
|
||||||
try:
|
|
||||||
tpl = tpl_svc.rollback_to_version(template_id, request.version)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
|
||||||
return EditorRollbackResponse(
|
|
||||||
template_id=tpl.id,
|
|
||||||
status="rolled_back",
|
|
||||||
rollback_to_version=request.version,
|
|
||||||
new_version=tpl.version,
|
|
||||||
clip_count=len(clip_configs),
|
|
||||||
)
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
"""转场 & 滤镜路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /transition-presets 转场预设列表
|
|
||||||
- PUT /clips/{clip_id}/transition 单片段转场
|
|
||||||
- POST /transitions/batch 批量转场
|
|
||||||
- GET /filter-presets 滤镜预设列表
|
|
||||||
- GET /filter 滤镜配置
|
|
||||||
- PUT /filter 更新滤镜
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from packages.domain.config_schemas import normalize_plan_config
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import (
|
|
||||||
BatchTransitionRequest,
|
|
||||||
BatchTransitionResponse,
|
|
||||||
ClipTransitionResponse,
|
|
||||||
FilterConfigResponse,
|
|
||||||
FilterPresetListResponse,
|
|
||||||
FilterUpdateRequest,
|
|
||||||
TransitionPresetListResponse,
|
|
||||||
TransitionUpdateRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── 转场 ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
|
|
||||||
def list_editor_transition_presets(
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> TransitionPresetListResponse:
|
|
||||||
"""获取转场预设列表"""
|
|
||||||
from packages.domain.transition_presets import TRANSITION_PRESETS
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"id": p["id"],
|
|
||||||
"name": p["name"],
|
|
||||||
"category": p.get("category", "通用"),
|
|
||||||
"duration": p.get("default_duration", 0.5),
|
|
||||||
"description": p.get("description", ""),
|
|
||||||
}
|
|
||||||
for p in TRANSITION_PRESETS
|
|
||||||
]
|
|
||||||
return TransitionPresetListResponse(items=items, total=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
|
|
||||||
def update_editor_clip_transition(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: TransitionUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ClipTransitionResponse:
|
|
||||||
"""设置单个片段的转场效果"""
|
|
||||||
_, plan_svc = services
|
|
||||||
try:
|
|
||||||
clip = plan_svc.update_clip(
|
|
||||||
clip_id,
|
|
||||||
transition_effect=body.effect,
|
|
||||||
transition_duration=body.duration,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
return ClipTransitionResponse(
|
|
||||||
clip_id=clip.id,
|
|
||||||
effect=clip.transition_effect.value
|
|
||||||
if hasattr(clip.transition_effect, "value")
|
|
||||||
else clip.transition_effect,
|
|
||||||
duration=clip.transition_duration or 0.5,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/transitions/batch", response_model=BatchTransitionResponse)
|
|
||||||
def batch_update_editor_transitions(
|
|
||||||
template_id: str,
|
|
||||||
body: BatchTransitionRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> BatchTransitionResponse:
|
|
||||||
"""批量设置所有片段的转场效果"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clips = plan_svc.list_clips(plan_id, limit=500)
|
|
||||||
updated = 0
|
|
||||||
for clip in clips:
|
|
||||||
if clip.order > 0: # 第一个片段不加转场
|
|
||||||
try:
|
|
||||||
plan_svc.update_clip(
|
|
||||||
clip.id,
|
|
||||||
transition_effect=body.effect,
|
|
||||||
transition_duration=body.duration,
|
|
||||||
)
|
|
||||||
updated += 1
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return BatchTransitionResponse(
|
|
||||||
updated_count=updated,
|
|
||||||
plan_id=plan_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 滤镜 ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/filter-presets", response_model=FilterPresetListResponse)
|
|
||||||
def list_editor_filter_presets(
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> FilterPresetListResponse:
|
|
||||||
"""获取滤镜预设列表"""
|
|
||||||
from packages.domain.filter_presets import FILTER_PRESETS
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"id": p["id"],
|
|
||||||
"name": p["name"],
|
|
||||||
"category": p.get("category", "通用"),
|
|
||||||
"thumbnail": p.get("thumbnail", ""),
|
|
||||||
"description": p.get("description", ""),
|
|
||||||
}
|
|
||||||
for p in FILTER_PRESETS
|
|
||||||
]
|
|
||||||
return FilterPresetListResponse(items=items, total=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/filter", response_model=FilterConfigResponse)
|
|
||||||
def get_editor_filter(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> FilterConfigResponse:
|
|
||||||
"""获取草稿的全局滤镜配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
config = plan.config or {}
|
|
||||||
filter_config = config.get("filter", {})
|
|
||||||
|
|
||||||
return FilterConfigResponse(
|
|
||||||
plan_id=plan.id,
|
|
||||||
enabled=filter_config.get("enabled", False),
|
|
||||||
preset_id=filter_config.get("preset_id", ""),
|
|
||||||
intensity=filter_config.get("intensity", 1.0),
|
|
||||||
brightness=filter_config.get("brightness", 0.0),
|
|
||||||
contrast=filter_config.get("contrast", 1.0),
|
|
||||||
saturation=filter_config.get("saturation", 1.0),
|
|
||||||
warmth=filter_config.get("warmth", 0.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/filter", response_model=FilterConfigResponse)
|
|
||||||
def update_editor_filter(
|
|
||||||
template_id: str,
|
|
||||||
body: FilterUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> FilterConfigResponse:
|
|
||||||
"""更新草稿的全局滤镜配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
config = dict(plan.config) if plan.config else {}
|
|
||||||
current_filter = dict(config.get("filter", {}))
|
|
||||||
update_data = body.model_dump(exclude_none=True)
|
|
||||||
current_filter.update(update_data)
|
|
||||||
|
|
||||||
config["filter"] = current_filter
|
|
||||||
updated_plan = plan_svc.update_plan_config(plan_id, normalize_plan_config(config))
|
|
||||||
|
|
||||||
return FilterConfigResponse(
|
|
||||||
plan_id=updated_plan.id,
|
|
||||||
enabled=current_filter.get("enabled", False),
|
|
||||||
preset_id=current_filter.get("preset_id", ""),
|
|
||||||
intensity=current_filter.get("intensity", 1.0),
|
|
||||||
brightness=current_filter.get("brightness", 0.0),
|
|
||||||
contrast=current_filter.get("contrast", 1.0),
|
|
||||||
saturation=current_filter.get("saturation", 1.0),
|
|
||||||
warmth=current_filter.get("warmth", 0.0),
|
|
||||||
)
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"""导出配置路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /export-presets 导出预设列表
|
|
||||||
- GET /export 导出配置
|
|
||||||
- PUT /export 更新导出配置
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from packages.domain.config_schemas import normalize_plan_config
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import ExportConfigResponse, ExportPresetListResponse, ExportUpdateRequest
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export-presets", response_model=ExportPresetListResponse)
|
|
||||||
def list_editor_export_presets(
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ExportPresetListResponse:
|
|
||||||
"""获取导出预设列表"""
|
|
||||||
from packages.domain.export_presets import EXPORT_PRESETS
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"id": p["id"],
|
|
||||||
"name": p["name"],
|
|
||||||
"resolution": p.get("resolution", "1080p"),
|
|
||||||
"fps": p.get("fps", 30),
|
|
||||||
"video_bitrate": p.get("bitrate", ""),
|
|
||||||
"audio_bitrate": p.get("audio_bitrate", 128),
|
|
||||||
"format": p.get("format", "mp4"),
|
|
||||||
"quality_preset": p.get("quality_preset", "balanced"),
|
|
||||||
"description": p.get("description", ""),
|
|
||||||
"size_hint": p.get("size_hint", ""),
|
|
||||||
}
|
|
||||||
for p in EXPORT_PRESETS
|
|
||||||
]
|
|
||||||
return ExportPresetListResponse(items=items, total=len(items))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export", response_model=ExportConfigResponse)
|
|
||||||
def get_editor_export(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ExportConfigResponse:
|
|
||||||
"""获取草稿的导出配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
config = plan.config or {}
|
|
||||||
export_config = config.get("export", {})
|
|
||||||
|
|
||||||
return ExportConfigResponse(
|
|
||||||
plan_id=plan.id,
|
|
||||||
resolution=export_config.get("resolution", "1080p"),
|
|
||||||
fps=export_config.get("fps", 30),
|
|
||||||
video_bitrate=export_config.get("video_bitrate", 4000),
|
|
||||||
audio_bitrate=export_config.get("audio_bitrate", 128),
|
|
||||||
format=export_config.get("format", "mp4"),
|
|
||||||
quality_preset=export_config.get("quality_preset", "balanced"),
|
|
||||||
watermark_enabled=export_config.get("watermark_enabled", True),
|
|
||||||
watermark_text=export_config.get("watermark_text", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/export", response_model=ExportConfigResponse)
|
|
||||||
def update_editor_export(
|
|
||||||
template_id: str,
|
|
||||||
body: ExportUpdateRequest,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> ExportConfigResponse:
|
|
||||||
"""更新草稿的导出配置"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
config = dict(plan.config) if plan.config else {}
|
|
||||||
current_export = dict(config.get("export", {}))
|
|
||||||
update_data = body.model_dump(exclude_none=True)
|
|
||||||
current_export.update(update_data)
|
|
||||||
|
|
||||||
config["export"] = current_export
|
|
||||||
updated_plan = plan_svc.update_plan_config(plan_id, normalize_plan_config(config))
|
|
||||||
updated_export = (updated_plan.config or {}).get("export", {})
|
|
||||||
|
|
||||||
return ExportConfigResponse(
|
|
||||||
plan_id=updated_plan.id,
|
|
||||||
resolution=updated_export.get("resolution", "1080p"),
|
|
||||||
fps=updated_export.get("fps", 30),
|
|
||||||
video_bitrate=updated_export.get("video_bitrate", 4000),
|
|
||||||
audio_bitrate=updated_export.get("audio_bitrate", 128),
|
|
||||||
format=updated_export.get("format", "mp4"),
|
|
||||||
quality_preset=updated_export.get("quality_preset", "balanced"),
|
|
||||||
watermark_enabled=updated_export.get("watermark_enabled", True),
|
|
||||||
watermark_text=updated_export.get("watermark_text", ""),
|
|
||||||
)
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"""草稿生成路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- POST /generate 触发生成
|
|
||||||
- GET /generation-status 生成进度
|
|
||||||
- GET /generations 生成记录列表
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
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_db_session,
|
|
||||||
)
|
|
||||||
from app.schemas.generation_task import GenerationTaskResponse
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
|
||||||
SQLAlchemyGenerationTaskRepository,
|
|
||||||
)
|
|
||||||
from packages.application.generation_tasks import (
|
|
||||||
CreateGenerationTaskCommand,
|
|
||||||
CreateGenerationTaskUseCase,
|
|
||||||
)
|
|
||||||
from packages.domain.edit_plan import EditPlanStatus
|
|
||||||
|
|
||||||
from ._fallback import (
|
|
||||||
_auto_fallback_assign_assets,
|
|
||||||
_auto_fallback_auto_material_mode,
|
|
||||||
_auto_fallback_copy_template_clips,
|
|
||||||
_auto_fallback_draft_to_editing,
|
|
||||||
)
|
|
||||||
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import (
|
|
||||||
ClipStatusItem,
|
|
||||||
EditPlanGenerateResponse,
|
|
||||||
EditPlanGenerationsResponse,
|
|
||||||
EditPlanGenerationStatusResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate", response_model=EditPlanGenerateResponse)
|
|
||||||
def generate_editor_draft(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
asset_library_repo: Any = Depends(get_asset_library_repository),
|
|
||||||
asset_repo: Any = Depends(get_asset_repository),
|
|
||||||
) -> EditPlanGenerateResponse:
|
|
||||||
"""触发模板草稿渲染生成"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan_check = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
# 自动兜底流程
|
|
||||||
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
|
|
||||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
|
||||||
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
|
||||||
_auto_fallback_auto_material_mode(
|
|
||||||
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查是否可生成
|
|
||||||
try:
|
|
||||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
if not can_gen:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail=reason
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
|
||||||
|
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
user_id = current_user.user.id
|
|
||||||
_check_queue_limits(gen_task_repo, user_id)
|
|
||||||
|
|
||||||
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
|
||||||
gen_task = gen_task_use_case.execute(
|
|
||||||
CreateGenerationTaskCommand(
|
|
||||||
project_id=plan.project_id or "",
|
|
||||||
template_id=plan.template_id,
|
|
||||||
created_by_user_id=current_user.user.id,
|
|
||||||
source_edit_plan_id=plan_id,
|
|
||||||
asset_ids=list(config_asset_ids) if config_asset_ids else [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
|
|
||||||
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
|
|
||||||
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
|
|
||||||
|
|
||||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
gen_task.id,
|
|
||||||
clip_count,
|
|
||||||
current_user.user.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return EditPlanGenerateResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
plan_status=updated_plan.status.value
|
|
||||||
if hasattr(updated_plan.status, "value")
|
|
||||||
else updated_plan.status,
|
|
||||||
generation_task_id=gen_task.id,
|
|
||||||
clip_count=clip_count,
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as _e:
|
|
||||||
logger.exception(
|
|
||||||
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="生成失败,请稍后重试",
|
|
||||||
) from _e
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
|
||||||
def get_editor_generation_status(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditPlanGenerationStatusResponse:
|
|
||||||
"""查询草稿生成进度"""
|
|
||||||
_, plan_svc = services
|
|
||||||
try:
|
|
||||||
gen_status = plan_svc.get_generation_status(plan_id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
plan = gen_status["plan"]
|
|
||||||
clips = gen_status["clips"]
|
|
||||||
|
|
||||||
clip_items = [
|
|
||||||
ClipStatusItem(
|
|
||||||
clip_id=c.id,
|
|
||||||
clip_type=c.clip_type,
|
|
||||||
order=c.order,
|
|
||||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
|
||||||
asset_id=c.asset_id or "",
|
|
||||||
text_content=c.text_content or "",
|
|
||||||
duration=c.duration,
|
|
||||||
)
|
|
||||||
for c in clips
|
|
||||||
]
|
|
||||||
|
|
||||||
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
|
||||||
video_url = ""
|
|
||||||
if raw_video_url:
|
|
||||||
try:
|
|
||||||
video_url = storage_service.get_download_url(
|
|
||||||
raw_video_url, expires_seconds=86400
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
"生成视频签名URL失败: template_id=%s error=%s", template_id, e
|
|
||||||
)
|
|
||||||
video_url = raw_video_url
|
|
||||||
|
|
||||||
progress = gen_status.get("progress", 0.0)
|
|
||||||
error_message = gen_status.get("error_message", "")
|
|
||||||
gen_task_status = gen_status.get("generation_task_status")
|
|
||||||
plan_status_val = (
|
|
||||||
plan.status.value if hasattr(plan.status, "value") else plan.status
|
|
||||||
)
|
|
||||||
if plan_status_val == "completed" and progress < 100:
|
|
||||||
progress = 100.0
|
|
||||||
|
|
||||||
return EditPlanGenerationStatusResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
plan_status=plan_status_val,
|
|
||||||
generation_task_id=gen_status["generation_task_id"],
|
|
||||||
generation_task_status=gen_task_status,
|
|
||||||
progress=progress,
|
|
||||||
video_url=video_url,
|
|
||||||
error_message=error_message,
|
|
||||||
clips=clip_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/generations", response_model=EditPlanGenerationsResponse)
|
|
||||||
def list_editor_generations(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditPlanGenerationsResponse:
|
|
||||||
"""查询草稿关联的生成记录列表"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
|
||||||
items = [
|
|
||||||
GenerationTaskResponse(
|
|
||||||
id=t.id,
|
|
||||||
project_id=t.project_id,
|
|
||||||
asset_library_id=t.asset_library_id,
|
|
||||||
strategy_id=t.strategy_id,
|
|
||||||
voice_library_id=t.voice_library_id,
|
|
||||||
template_id=t.template_id,
|
|
||||||
asset_ids=t.asset_ids,
|
|
||||||
title_ids=t.title_ids,
|
|
||||||
voice_ids=t.voice_ids,
|
|
||||||
source_edit_plan_id=t.source_edit_plan_id or "",
|
|
||||||
status=t.status.value if hasattr(t.status, "value") else t.status,
|
|
||||||
progress=t.progress,
|
|
||||||
result_count=t.result_count,
|
|
||||||
error_message=t.error_message,
|
|
||||||
)
|
|
||||||
for t in tasks
|
|
||||||
]
|
|
||||||
return EditPlanGenerationsResponse(items=items, total=len(items))
|
|
||||||
@@ -1,622 +0,0 @@
|
|||||||
"""模板编辑器所有 Pydantic Schema 定义.
|
|
||||||
|
|
||||||
集中管理,避免在路由文件里散落 40+ 个 model。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re as _re
|
|
||||||
from typing import Any, List, Optional
|
|
||||||
|
|
||||||
from app.schemas.generation_task import GenerationTaskResponse
|
|
||||||
from pydantic import BaseModel, Field, validator
|
|
||||||
|
|
||||||
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
|
|
||||||
_EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
|
||||||
_EXPORT_VALID_FORMATS = {"mp4", "mov"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 生成状态相关 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ClipStatusItem(BaseModel):
|
|
||||||
"""片段生成状态"""
|
|
||||||
|
|
||||||
clip_id: str
|
|
||||||
clip_type: str
|
|
||||||
order: int
|
|
||||||
status: str
|
|
||||||
asset_id: str
|
|
||||||
text_content: str
|
|
||||||
duration: float
|
|
||||||
|
|
||||||
|
|
||||||
class EditPlanGenerationStatusResponse(BaseModel):
|
|
||||||
"""剪辑计划生成进度响应体"""
|
|
||||||
|
|
||||||
plan_id: str
|
|
||||||
plan_status: str
|
|
||||||
generation_task_id: Optional[str] = None
|
|
||||||
generation_task_status: Optional[str] = None
|
|
||||||
progress: float = 0.0
|
|
||||||
video_url: str = ""
|
|
||||||
error_message: str = ""
|
|
||||||
clips: List[ClipStatusItem]
|
|
||||||
|
|
||||||
|
|
||||||
class EditPlanGenerateResponse(BaseModel):
|
|
||||||
"""剪辑计划触发生成响应体"""
|
|
||||||
|
|
||||||
plan_id: str
|
|
||||||
plan_status: str
|
|
||||||
generation_task_id: str
|
|
||||||
clip_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class EditPlanGenerationsResponse(BaseModel):
|
|
||||||
"""剪辑计划关联的生成记录列表响应体"""
|
|
||||||
|
|
||||||
items: List[GenerationTaskResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
# ── AI 推荐 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class AIRecommendRequest(BaseModel):
|
|
||||||
"""AI 推荐片段方案请求体"""
|
|
||||||
|
|
||||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
|
|
||||||
editing_mode: str = Field(
|
|
||||||
default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip"
|
|
||||||
)
|
|
||||||
target_duration: float = Field(
|
|
||||||
default=30.0, ge=1.0, le=600.0, description="目标时长(秒)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AIRecommendClipItem(BaseModel):
|
|
||||||
"""AI 推荐的单个片段"""
|
|
||||||
|
|
||||||
clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro")
|
|
||||||
order: int = Field(..., ge=0, description="片段顺序")
|
|
||||||
text_content: str = Field(default="", description="文字内容")
|
|
||||||
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
|
||||||
transition_effect: str = Field(default="cut", description="转场效果")
|
|
||||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
|
|
||||||
asset_id: str = Field(default="", description="关联素材 ID")
|
|
||||||
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
|
||||||
|
|
||||||
|
|
||||||
class AIRecommendResponse(BaseModel):
|
|
||||||
"""AI 推荐片段方案响应体"""
|
|
||||||
|
|
||||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
|
||||||
clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表")
|
|
||||||
config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)")
|
|
||||||
total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)")
|
|
||||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 封面生成 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateCoverRequest(BaseModel):
|
|
||||||
"""AI 封面生成请求体"""
|
|
||||||
|
|
||||||
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
|
||||||
cover_type: str = Field(
|
|
||||||
default="ai_frame",
|
|
||||||
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
|
||||||
)
|
|
||||||
frame_time: Optional[float] = Field(
|
|
||||||
default=None,
|
|
||||||
ge=0.0,
|
|
||||||
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateCoverResponse(BaseModel):
|
|
||||||
"""AI 封面生成响应体"""
|
|
||||||
|
|
||||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
|
||||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
|
||||||
|
|
||||||
|
|
||||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class BGMConfigUpdateRequest(BaseModel):
|
|
||||||
"""更新BGM配置请求体"""
|
|
||||||
|
|
||||||
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
|
|
||||||
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
|
|
||||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
|
|
||||||
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
|
|
||||||
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
|
|
||||||
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
|
|
||||||
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
|
|
||||||
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
|
|
||||||
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
|
|
||||||
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
|
|
||||||
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 片段调整 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class SpeedAdjustRequest(BaseModel):
|
|
||||||
"""调速请求"""
|
|
||||||
|
|
||||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
|
||||||
|
|
||||||
|
|
||||||
class VolumeAdjustRequest(BaseModel):
|
|
||||||
"""音量调节请求"""
|
|
||||||
|
|
||||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
|
||||||
|
|
||||||
|
|
||||||
class TrimAdjustRequest(BaseModel):
|
|
||||||
"""裁剪请求"""
|
|
||||||
|
|
||||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
|
||||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
|
||||||
|
|
||||||
|
|
||||||
class ClipAdjustmentsRequest(BaseModel):
|
|
||||||
"""统一调整请求"""
|
|
||||||
|
|
||||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
|
||||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
|
||||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
|
||||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
|
||||||
|
|
||||||
|
|
||||||
class BatchSpeedRequest(BaseModel):
|
|
||||||
"""批量调速请求"""
|
|
||||||
|
|
||||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
|
||||||
|
|
||||||
|
|
||||||
class ClipAdjustResponse(BaseModel):
|
|
||||||
"""片段调整响应"""
|
|
||||||
|
|
||||||
clip_id: str
|
|
||||||
speed: float
|
|
||||||
volume: float
|
|
||||||
trim_start: float
|
|
||||||
trim_end: float
|
|
||||||
duration: float
|
|
||||||
|
|
||||||
|
|
||||||
class BatchSpeedResponse(BaseModel):
|
|
||||||
"""批量调速响应"""
|
|
||||||
|
|
||||||
updated_count: int
|
|
||||||
plan_id: str
|
|
||||||
|
|
||||||
|
|
||||||
# ── 片段批量操作 ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ClipReorderItem(BaseModel):
|
|
||||||
"""重排序条目"""
|
|
||||||
|
|
||||||
clip_id: str
|
|
||||||
new_order: int = Field(..., ge=0, description="新的排序序号")
|
|
||||||
|
|
||||||
|
|
||||||
class ClipReorderRequest(BaseModel):
|
|
||||||
"""片段重排序请求"""
|
|
||||||
|
|
||||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
|
||||||
|
|
||||||
|
|
||||||
class ClipReorderResponse(BaseModel):
|
|
||||||
"""片段重排序响应"""
|
|
||||||
|
|
||||||
success: bool = True
|
|
||||||
updated_count: int
|
|
||||||
message: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ClipBatchDeleteRequest(BaseModel):
|
|
||||||
"""批量删除片段请求"""
|
|
||||||
|
|
||||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
|
||||||
|
|
||||||
|
|
||||||
class ClipBatchDeleteResponse(BaseModel):
|
|
||||||
"""批量删除片段响应"""
|
|
||||||
|
|
||||||
success: bool = True
|
|
||||||
deleted_count: int
|
|
||||||
message: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ClipsFromAssetsRequest(BaseModel):
|
|
||||||
"""从素材批量创建片段请求"""
|
|
||||||
|
|
||||||
asset_ids: List[str] = Field(
|
|
||||||
..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
|
|
||||||
)
|
|
||||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
|
||||||
|
|
||||||
|
|
||||||
class ClipsFromAssetsResponse(BaseModel):
|
|
||||||
"""从素材批量创建片段响应"""
|
|
||||||
|
|
||||||
success: bool = True
|
|
||||||
created_count: int
|
|
||||||
message: str = ""
|
|
||||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class CoverConfigResponse(BaseModel):
|
|
||||||
"""封面配置响应"""
|
|
||||||
|
|
||||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
|
||||||
image_url: str = Field(default="", description="封面图片 URL")
|
|
||||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
|
||||||
|
|
||||||
|
|
||||||
class CoverUpdateRequest(BaseModel):
|
|
||||||
"""更新封面配置请求"""
|
|
||||||
|
|
||||||
type: Optional[str] = Field(default=None, description="封面类型")
|
|
||||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
|
||||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
|
||||||
|
|
||||||
|
|
||||||
class CoverExtractRequest(BaseModel):
|
|
||||||
"""从片段抽帧生成封面请求"""
|
|
||||||
|
|
||||||
clip_id: str = Field(..., description="片段 ID")
|
|
||||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
|
||||||
|
|
||||||
|
|
||||||
class CoverSmartRequest(BaseModel):
|
|
||||||
"""智能选帧请求"""
|
|
||||||
|
|
||||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
|
||||||
|
|
||||||
|
|
||||||
class CoverGenerateResponse(BaseModel):
|
|
||||||
"""封面生成响应"""
|
|
||||||
|
|
||||||
type: str = Field(..., description="封面类型")
|
|
||||||
image_url: str = Field(..., description="封面图片 URL")
|
|
||||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class ExportConfigResponse(BaseModel):
|
|
||||||
"""导出配置响应"""
|
|
||||||
|
|
||||||
resolution: str
|
|
||||||
fps: int
|
|
||||||
video_bitrate: int
|
|
||||||
audio_bitrate: int
|
|
||||||
format: str
|
|
||||||
quality_preset: str
|
|
||||||
watermark_enabled: bool
|
|
||||||
watermark_text: str
|
|
||||||
|
|
||||||
|
|
||||||
class ExportUpdateRequest(BaseModel):
|
|
||||||
"""更新导出配置请求"""
|
|
||||||
|
|
||||||
resolution: Optional[str] = None
|
|
||||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
|
||||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
|
||||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
|
||||||
format: Optional[str] = None
|
|
||||||
quality_preset: Optional[str] = None
|
|
||||||
watermark_enabled: Optional[bool] = None
|
|
||||||
watermark_text: Optional[str] = None
|
|
||||||
|
|
||||||
@validator("resolution")
|
|
||||||
def validate_resolution(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if not _EXPORT_RESOLUTION_PATTERN.match(v):
|
|
||||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
|
||||||
w, h = v.split("x")
|
|
||||||
if int(w) < 100 or int(h) < 100:
|
|
||||||
raise ValueError("分辨率数值过小")
|
|
||||||
if int(w) > 4096 or int(h) > 4096:
|
|
||||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("format")
|
|
||||||
def validate_format(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v not in _EXPORT_VALID_FORMATS:
|
|
||||||
raise ValueError(f"无效格式: {v},支持: {_EXPORT_VALID_FORMATS}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator("quality_preset")
|
|
||||||
def validate_quality_preset(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
if v not in _EXPORT_VALID_QUALITY_PRESETS:
|
|
||||||
raise ValueError(f"无效质量预设: {v},支持: {_EXPORT_VALID_QUALITY_PRESETS}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ExportPresetItem(BaseModel):
|
|
||||||
"""导出预设条目"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
resolution: str
|
|
||||||
fps: int
|
|
||||||
video_bitrate: int
|
|
||||||
audio_bitrate: int
|
|
||||||
format: str
|
|
||||||
quality_preset: str
|
|
||||||
description: str
|
|
||||||
size_hint: str
|
|
||||||
|
|
||||||
|
|
||||||
class ExportPresetListResponse(BaseModel):
|
|
||||||
"""导出预设列表响应"""
|
|
||||||
|
|
||||||
items: List[ExportPresetItem]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
# ── 滤镜 ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class FilterPresetResponse(BaseModel):
|
|
||||||
"""滤镜预设响应"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
category: str
|
|
||||||
description: str
|
|
||||||
tags: List[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class FilterConfigResponse(BaseModel):
|
|
||||||
"""滤镜配置响应"""
|
|
||||||
|
|
||||||
enabled: bool
|
|
||||||
preset_id: str
|
|
||||||
intensity: int
|
|
||||||
brightness: float
|
|
||||||
contrast: float
|
|
||||||
saturation: float
|
|
||||||
warmth: float
|
|
||||||
|
|
||||||
|
|
||||||
class FilterUpdateRequest(BaseModel):
|
|
||||||
"""更新滤镜配置请求"""
|
|
||||||
|
|
||||||
enabled: Optional[bool] = None
|
|
||||||
preset_id: Optional[str] = None
|
|
||||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
|
||||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
|
||||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
|
||||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
|
||||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
|
||||||
|
|
||||||
|
|
||||||
class FilterPresetListResponse(BaseModel):
|
|
||||||
"""滤镜预设列表响应"""
|
|
||||||
|
|
||||||
items: List[FilterPresetResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
# ── 转场 ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionPresetResponse(BaseModel):
|
|
||||||
"""转场预设响应"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
category: str
|
|
||||||
description: str
|
|
||||||
tags: List[str] = Field(default_factory=list)
|
|
||||||
default_duration: float
|
|
||||||
min_duration: float
|
|
||||||
max_duration: float
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionUpdateRequest(BaseModel):
|
|
||||||
"""更新转场请求"""
|
|
||||||
|
|
||||||
effect: str = Field(..., description="转场效果 ID")
|
|
||||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
|
||||||
|
|
||||||
|
|
||||||
class BatchTransitionRequest(BaseModel):
|
|
||||||
"""批量设置转场请求"""
|
|
||||||
|
|
||||||
effect: str = Field(..., description="转场效果 ID")
|
|
||||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
|
||||||
apply_to: str = Field(
|
|
||||||
default="all",
|
|
||||||
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ClipTransitionResponse(BaseModel):
|
|
||||||
"""片段转场信息响应"""
|
|
||||||
|
|
||||||
clip_id: str
|
|
||||||
effect: str
|
|
||||||
duration: float
|
|
||||||
|
|
||||||
|
|
||||||
class BatchTransitionResponse(BaseModel):
|
|
||||||
"""批量转场响应"""
|
|
||||||
|
|
||||||
updated_count: int
|
|
||||||
plan_id: str
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionPresetListResponse(BaseModel):
|
|
||||||
"""转场预设列表响应"""
|
|
||||||
|
|
||||||
items: List[TransitionPresetResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
# ── 编辑器草稿 & 片段 ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class EditorDraftResponse(BaseModel):
|
|
||||||
"""模板编辑器草稿详情响应"""
|
|
||||||
|
|
||||||
plan_id: str
|
|
||||||
template_id: str
|
|
||||||
name: str
|
|
||||||
status: str
|
|
||||||
config: dict[str, Any]
|
|
||||||
total_duration: float
|
|
||||||
clip_count: int
|
|
||||||
is_draft: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class EditorUpdateRequest(BaseModel):
|
|
||||||
"""更新草稿请求"""
|
|
||||||
|
|
||||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
|
||||||
config: Optional[dict[str, Any]] = Field(default=None)
|
|
||||||
total_duration: Optional[float] = Field(default=None, ge=0.0)
|
|
||||||
|
|
||||||
|
|
||||||
class EditorClipResponse(BaseModel):
|
|
||||||
"""片段响应"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
plan_id: str
|
|
||||||
clip_type: str
|
|
||||||
order: int
|
|
||||||
duration: float
|
|
||||||
text_content: str = ""
|
|
||||||
transition_effect: str = "cut"
|
|
||||||
playback_speed: float = 1.0
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class EditorClipListResponse(BaseModel):
|
|
||||||
"""片段列表响应"""
|
|
||||||
|
|
||||||
items: List[EditorClipResponse]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
class EditorClipCreateRequest(BaseModel):
|
|
||||||
"""创建片段请求"""
|
|
||||||
|
|
||||||
clip_type: str = Field(..., min_length=1, max_length=32)
|
|
||||||
order: int = Field(..., ge=0)
|
|
||||||
duration: float = Field(..., gt=0.0)
|
|
||||||
text_content: str = Field(default="", max_length=2000)
|
|
||||||
transition_effect: str = Field(default="cut", max_length=32)
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class EditorClipUpdateRequest(BaseModel):
|
|
||||||
"""更新片段请求"""
|
|
||||||
|
|
||||||
order: Optional[int] = Field(default=None, ge=0)
|
|
||||||
duration: Optional[float] = Field(default=None, gt=0.0)
|
|
||||||
text_content: Optional[str] = Field(default=None, max_length=2000)
|
|
||||||
transition_effect: Optional[str] = Field(default=None, max_length=32)
|
|
||||||
playback_speed: Optional[float] = Field(default=None, gt=0.0)
|
|
||||||
config: Optional[dict[str, Any]] = None
|
|
||||||
|
|
||||||
|
|
||||||
class EditorPublishResponse(BaseModel):
|
|
||||||
"""发布草稿响应"""
|
|
||||||
|
|
||||||
template_id: str
|
|
||||||
status: str = "published"
|
|
||||||
clip_count: int
|
|
||||||
version: int = 1
|
|
||||||
|
|
||||||
|
|
||||||
class EditorTemplateVersionItem(BaseModel):
|
|
||||||
"""模板版本历史条目"""
|
|
||||||
|
|
||||||
version: int
|
|
||||||
name: str
|
|
||||||
editing_mode: str
|
|
||||||
clip_count: int
|
|
||||||
change_note: str
|
|
||||||
published_by: str
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class EditorVersionListResponse(BaseModel):
|
|
||||||
"""模板版本列表响应"""
|
|
||||||
|
|
||||||
versions: list[EditorTemplateVersionItem]
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
class EditorRollbackRequest(BaseModel):
|
|
||||||
"""回滚请求体"""
|
|
||||||
|
|
||||||
version: int
|
|
||||||
|
|
||||||
|
|
||||||
class EditorRollbackResponse(BaseModel):
|
|
||||||
"""回滚响应"""
|
|
||||||
|
|
||||||
template_id: str
|
|
||||||
status: str = "rolled_back"
|
|
||||||
rollback_to_version: int
|
|
||||||
new_version: int
|
|
||||||
clip_count: int
|
|
||||||
|
|
||||||
|
|
||||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class SplitClipRequest(BaseModel):
|
|
||||||
"""分割片段请求体"""
|
|
||||||
|
|
||||||
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
|
|
||||||
|
|
||||||
|
|
||||||
class MergeClipsRequest(BaseModel):
|
|
||||||
"""合并片段请求体"""
|
|
||||||
|
|
||||||
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 时间线 ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class EditorTimelineSceneResponse(BaseModel):
|
|
||||||
"""时间线场景"""
|
|
||||||
|
|
||||||
scene: str
|
|
||||||
time: str
|
|
||||||
duration: float
|
|
||||||
color: str
|
|
||||||
clip_id: str = ""
|
|
||||||
clip_type: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class EditorTimelineResponse(BaseModel):
|
|
||||||
"""时间线响应"""
|
|
||||||
|
|
||||||
plan_id: str
|
|
||||||
total_duration: float
|
|
||||||
scenes: List[EditorTimelineSceneResponse]
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
"""字幕管理路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /clips/{clip_id}/subtitles 字幕列表
|
|
||||||
- POST /clips/{clip_id}/subtitles 新增字幕
|
|
||||||
- PUT /clips/{clip_id}/subtitles/{subtitle_id} 更新字幕
|
|
||||||
- DELETE /clips/{clip_id}/subtitles/{subtitle_id} 删除字幕
|
|
||||||
- PUT /clips/{clip_id}/subtitles 批量更新字幕(全量替换)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
def _get_clip_subtitles(plan_svc: EditPlanService, clip_id: str, plan_id: str) -> list[dict[str, Any]]:
|
|
||||||
"""获取片段字幕列表,统一校验"""
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
if clip.plan_id != plan_id:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
config = clip.config or {}
|
|
||||||
subtitles = config.get("subtitles", [])
|
|
||||||
if not isinstance(subtitles, list):
|
|
||||||
subtitles = []
|
|
||||||
return subtitles
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]])
|
|
||||||
def get_editor_clip_subtitles(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""获取片段的字幕列表"""
|
|
||||||
_, plan_svc = services
|
|
||||||
return _get_clip_subtitles(plan_svc, clip_id, plan_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/clips/{clip_id}/subtitles", response_model=dict[str, Any])
|
|
||||||
def create_editor_clip_subtitle(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""新增片段字幕"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
config = dict(clip.config) if clip.config else {}
|
|
||||||
subtitles = config.get("subtitles", [])
|
|
||||||
if not isinstance(subtitles, list):
|
|
||||||
subtitles = []
|
|
||||||
|
|
||||||
new_id = f"sub_{len(subtitles) + 1}"
|
|
||||||
new_subtitle = {
|
|
||||||
"id": body.get("id", new_id),
|
|
||||||
"start_time": body.get("start_time", 0.0),
|
|
||||||
"end_time": body.get("end_time", 0.0),
|
|
||||||
"text": body.get("text", ""),
|
|
||||||
"style": body.get("style", {}),
|
|
||||||
}
|
|
||||||
subtitles.append(new_subtitle)
|
|
||||||
config["subtitles"] = subtitles
|
|
||||||
|
|
||||||
plan_svc.update_clip(clip_id, config=config)
|
|
||||||
return new_subtitle
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/subtitles/{subtitle_id}", response_model=dict[str, Any])
|
|
||||||
def update_editor_clip_subtitle(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
subtitle_id: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""更新片段字幕"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
config = dict(clip.config) if clip.config else {}
|
|
||||||
subtitles = config.get("subtitles", [])
|
|
||||||
if not isinstance(subtitles, list):
|
|
||||||
subtitles = []
|
|
||||||
|
|
||||||
found = False
|
|
||||||
for i, sub in enumerate(subtitles):
|
|
||||||
if sub.get("id") == subtitle_id:
|
|
||||||
subtitles[i].update(body)
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
raise HTTPException(status_code=404, detail="字幕不存在")
|
|
||||||
|
|
||||||
config["subtitles"] = subtitles
|
|
||||||
plan_svc.update_clip(clip_id, config=config)
|
|
||||||
return subtitles[i]
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/clips/{clip_id}/subtitles/{subtitle_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
)
|
|
||||||
def delete_editor_clip_subtitle(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
subtitle_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""删除片段字幕"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
config = dict(clip.config) if clip.config else {}
|
|
||||||
subtitles = config.get("subtitles", [])
|
|
||||||
if not isinstance(subtitles, list):
|
|
||||||
subtitles = []
|
|
||||||
|
|
||||||
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
|
|
||||||
if len(new_subtitles) == len(subtitles):
|
|
||||||
raise HTTPException(status_code=404, detail="字幕不存在")
|
|
||||||
|
|
||||||
config["subtitles"] = new_subtitles
|
|
||||||
plan_svc.update_clip(clip_id, config=config)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/clips/{clip_id}/subtitles", response_model=list[dict[str, Any]])
|
|
||||||
def batch_update_editor_clip_subtitles(
|
|
||||||
template_id: str,
|
|
||||||
clip_id: str,
|
|
||||||
body: list[dict[str, Any]],
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""批量更新片段字幕(全量替换)"""
|
|
||||||
_, plan_svc = services
|
|
||||||
clip = plan_svc.get_clip(clip_id)
|
|
||||||
if not clip:
|
|
||||||
raise HTTPException(status_code=404, detail="片段不存在")
|
|
||||||
|
|
||||||
config = dict(clip.config) if clip.config else {}
|
|
||||||
config["subtitles"] = body
|
|
||||||
plan_svc.update_clip(clip_id, config=config)
|
|
||||||
return body
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""时间线路由.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- GET /timeline 时间线场景数据
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.services.edit_plan_service import EditPlanService
|
|
||||||
from app.services.edit_template_service import EditTemplateService
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from ._utils import _CLIP_TYPE_COLORS, _DEFAULT_COLOR, _clip_type_to_scene_label, _format_time
|
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services
|
|
||||||
from .schemas import EditorTimelineResponse, EditorTimelineSceneResponse
|
|
||||||
|
|
||||||
router = APIRouter(tags=["Template Editor"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/timeline", response_model=EditorTimelineResponse)
|
|
||||||
def get_editor_timeline(
|
|
||||||
template_id: str,
|
|
||||||
plan_id: str = Depends(get_draft_plan_id),
|
|
||||||
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
|
|
||||||
_: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
) -> EditorTimelineResponse:
|
|
||||||
"""获取草稿的时间线场景数据"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
clips = plan_svc.list_clips(plan_id=plan_id, skip=0, limit=200)
|
|
||||||
clips.sort(key=lambda c: c.order)
|
|
||||||
|
|
||||||
scenes = []
|
|
||||||
current_time = 0.0
|
|
||||||
|
|
||||||
for clip in clips:
|
|
||||||
start = current_time
|
|
||||||
end = start + clip.duration
|
|
||||||
color = _CLIP_TYPE_COLORS.get(clip.clip_type, _DEFAULT_COLOR)
|
|
||||||
scene_label = _clip_type_to_scene_label(clip.clip_type, clip.text_content)
|
|
||||||
|
|
||||||
scenes.append(
|
|
||||||
EditorTimelineSceneResponse(
|
|
||||||
scene=scene_label,
|
|
||||||
time=f"{_format_time(start)} - {_format_time(end)}",
|
|
||||||
duration=clip.duration,
|
|
||||||
color=color,
|
|
||||||
clip_id=clip.id,
|
|
||||||
clip_type=clip.clip_type,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
current_time = end
|
|
||||||
|
|
||||||
total_duration = sum(s.duration for s in scenes) or plan.total_duration
|
|
||||||
|
|
||||||
return EditorTimelineResponse(
|
|
||||||
plan_id=plan_id,
|
|
||||||
total_duration=total_duration,
|
|
||||||
scenes=scenes,
|
|
||||||
)
|
|
||||||
@@ -148,7 +148,7 @@ def create_title(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||||
) from exc
|
)
|
||||||
return _to_response(item)
|
return _to_response(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -172,8 +172,8 @@ def update_title(
|
|||||||
use_case = UpdateTitleLibraryUseCase(title_repository)
|
use_case = UpdateTitleLibraryUseCase(title_repository)
|
||||||
try:
|
try:
|
||||||
item = use_case.execute(command)
|
item = use_case.execute(command)
|
||||||
except NotFoundError as _e:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
|
||||||
return _to_response(item)
|
return _to_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
get_audio_url_signer,
|
get_audio_url_signer,
|
||||||
get_cosyvoice_service,
|
get_cosyvoice_service,
|
||||||
@@ -182,9 +181,13 @@ def synthesize(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if is_segment:
|
if is_segment:
|
||||||
celery_app.send_task("worker.process_tts_segment_synthesis", args=[job.id])
|
from worker_app.tasks import process_tts_segment_synthesis
|
||||||
|
|
||||||
|
process_tts_segment_synthesis.delay(job.id)
|
||||||
else:
|
else:
|
||||||
celery_app.send_task("worker.process_tts_synthesis", args=[job.id])
|
from worker_app.tasks import process_tts_synthesis
|
||||||
|
|
||||||
|
process_tts_synthesis.delay(job.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Celery 调度失败,标记 job 为 failed
|
# Celery 调度失败,标记 job 为 failed
|
||||||
try:
|
try:
|
||||||
@@ -233,8 +236,8 @@ def get_tts_job(
|
|||||||
use_case = GetTTSJobUseCase(repository)
|
use_case = GetTTSJobUseCase(repository)
|
||||||
try:
|
try:
|
||||||
job = use_case.execute(job_id, user_id)
|
job = use_case.execute(job_id, user_id)
|
||||||
except TTSJobNotFoundError as _e:
|
except TTSJobNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||||
return _to_response(job, sign_url)
|
return _to_response(job, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -250,8 +253,8 @@ def get_tts_job_status(
|
|||||||
use_case = GetTTSJobStatusUseCase(repository)
|
use_case = GetTTSJobStatusUseCase(repository)
|
||||||
try:
|
try:
|
||||||
job = use_case.execute(job_id, user_id)
|
job = use_case.execute(job_id, user_id)
|
||||||
except TTSJobNotFoundError as _e:
|
except TTSJobNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||||
output_url = job.output_audio_url
|
output_url = job.output_audio_url
|
||||||
if output_url:
|
if output_url:
|
||||||
output_url = sign_url(output_url)
|
output_url = sign_url(output_url)
|
||||||
@@ -306,8 +309,8 @@ def save_tts_job_to_library(
|
|||||||
get_use_case = GetTTSJobUseCase(tts_repository)
|
get_use_case = GetTTSJobUseCase(tts_repository)
|
||||||
try:
|
try:
|
||||||
job = get_use_case.execute(job_id, user_id)
|
job = get_use_case.execute(job_id, user_id)
|
||||||
except TTSJobNotFoundError as _e:
|
except TTSJobNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
|
||||||
|
|
||||||
# 校验已完成
|
# 校验已完成
|
||||||
if not job.is_completed:
|
if not job.is_completed:
|
||||||
@@ -360,7 +363,7 @@ def save_tts_job_to_library(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||||
) from exc
|
)
|
||||||
|
|
||||||
return SaveToLibraryResponse(
|
return SaveToLibraryResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
|
|||||||
Regular → Executable
+10
-69
@@ -1,20 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
from app.api.routes._helpers import format_utc_datetime
|
from app.api.routes._helpers import check_project_access
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.storage import OSSStorageService, get_storage_service
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
from app.dependencies import get_generated_video_repository
|
from app.dependencies import get_generated_video_repository
|
||||||
from app.schemas.video_center import (
|
from app.schemas.video_center import (
|
||||||
BatchDeleteRequest,
|
|
||||||
BatchDownloadRequest,
|
BatchDownloadRequest,
|
||||||
BatchDownloadResponse,
|
BatchDownloadResponse,
|
||||||
BatchOperationResponse,
|
|
||||||
ListVideosResponse,
|
ListVideosResponse,
|
||||||
UpdateVideoReviewRequest,
|
UpdateVideoReviewRequest,
|
||||||
VideoItemResponse,
|
VideoItemResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
GetGeneratedVideoUseCase,
|
GetGeneratedVideoUseCase,
|
||||||
@@ -51,13 +50,13 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
|||||||
review_status=item.review_status,
|
review_status=item.review_status,
|
||||||
generation_params=item.generation_params,
|
generation_params=item.generation_params,
|
||||||
download_url=download_url,
|
download_url=download_url,
|
||||||
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
|
generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/videos", response_model=ListVideosResponse)
|
@router.get("/videos", response_model=ListVideosResponse)
|
||||||
def list_videos(
|
def list_videos(
|
||||||
project_id: str | None = Query(None, description="项目ID,可选过滤"),
|
project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"),
|
||||||
status: str | None = Query(None, description="按状态筛选"),
|
status: str | None = Query(None, description="按状态筛选"),
|
||||||
review_status: str | None = Query(None, description="按复核状态筛选"),
|
review_status: str | None = Query(None, description="按复核状态筛选"),
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
@@ -66,10 +65,9 @@ def list_videos(
|
|||||||
storage: OSSStorageService = Depends(get_storage_service),
|
storage: OSSStorageService = Depends(get_storage_service),
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""成片列表,默认返回当前用户的所有成片,支持按项目/状态/复核状态筛选。"""
|
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
|
||||||
use_case = ListGeneratedVideosPaginatedUseCase(repo)
|
use_case = ListGeneratedVideosPaginatedUseCase(repo)
|
||||||
items, total = use_case.execute(
|
items, total = use_case.execute(
|
||||||
user_id=current_user.user.id,
|
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
status=status,
|
status=status,
|
||||||
review_status=review_status,
|
review_status=review_status,
|
||||||
@@ -112,67 +110,10 @@ def update_video_review_status(
|
|||||||
item = use_case.execute(video_id, request.review_status)
|
item = use_case.execute(video_id, request.review_status)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
logger.info(
|
logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
|
||||||
"Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user.id
|
|
||||||
)
|
|
||||||
return _to_video_response(item, storage)
|
return _to_video_response(item, storage)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/videos/{video_id}", status_code=204, response_class=Response)
|
|
||||||
def delete_video(
|
|
||||||
video_id: str,
|
|
||||||
repo=Depends(get_generated_video_repository),
|
|
||||||
storage: OSSStorageService = Depends(get_storage_service),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""删除单个成片(硬删除)。"""
|
|
||||||
use_case = GetGeneratedVideoUseCase(repo)
|
|
||||||
item = use_case.execute(video_id)
|
|
||||||
if item is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Video not found")
|
|
||||||
|
|
||||||
# 尝试删除 OSS 文件,失败不影响数据库删除
|
|
||||||
if item.file_url:
|
|
||||||
try:
|
|
||||||
key = storage._normalize_storage_key(item.file_url)
|
|
||||||
storage.delete_file(key)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("删除 OSS 视频文件失败,跳过: video_id=%s", video_id)
|
|
||||||
|
|
||||||
repo.delete(video_id)
|
|
||||||
return Response(status_code=204)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/batch-delete", response_model=BatchOperationResponse)
|
|
||||||
def batch_delete_videos(
|
|
||||||
request: BatchDeleteRequest,
|
|
||||||
repo=Depends(get_generated_video_repository),
|
|
||||||
storage: OSSStorageService = Depends(get_storage_service),
|
|
||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""批量删除成片。"""
|
|
||||||
videos = repo.get_by_ids(request.video_ids)
|
|
||||||
existing_ids = {v.id for v in videos}
|
|
||||||
failed_ids = [vid for vid in request.video_ids if vid not in existing_ids]
|
|
||||||
failed_details = {vid: "Video not found" for vid in failed_ids}
|
|
||||||
|
|
||||||
# 尝试删除 OSS 文件
|
|
||||||
for video in videos:
|
|
||||||
if video.file_url:
|
|
||||||
try:
|
|
||||||
key = storage._normalize_storage_key(video.file_url)
|
|
||||||
storage.delete_file(key)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("批量删除 OSS 视频文件失败,跳过: video_id=%s", video.id)
|
|
||||||
|
|
||||||
success_count = repo.batch_delete(list(existing_ids))
|
|
||||||
return BatchOperationResponse(
|
|
||||||
success_count=success_count,
|
|
||||||
failed_ids=failed_ids,
|
|
||||||
failed_details=failed_details,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/videos/batch-download", response_model=BatchDownloadResponse)
|
@router.post("/videos/batch-download", response_model=BatchDownloadResponse)
|
||||||
def batch_download_videos(
|
def batch_download_videos(
|
||||||
request: BatchDownloadRequest,
|
request: BatchDownloadRequest,
|
||||||
@@ -180,7 +121,7 @@ def batch_download_videos(
|
|||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""批量下载成片,异步打包 zip。
|
"""批量下载成片,异步打包 zip。
|
||||||
|
|
||||||
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
|
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
|
||||||
"""
|
"""
|
||||||
if not request.video_ids:
|
if not request.video_ids:
|
||||||
@@ -197,7 +138,7 @@ def batch_download_videos(
|
|||||||
# 发送 celery 任务
|
# 发送 celery 任务
|
||||||
task = celery_app.send_task(
|
task = celery_app.send_task(
|
||||||
"worker.batch_download_videos",
|
"worker.batch_download_videos",
|
||||||
args=[request.video_ids, current_user.user.id],
|
args=[request.video_ids, current_user.user_id],
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
|
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
|
||||||
@@ -213,7 +154,7 @@ def get_batch_download_status(
|
|||||||
from celery.result import AsyncResult
|
from celery.result import AsyncResult
|
||||||
|
|
||||||
task = AsyncResult(job_id, app=celery_app)
|
task = AsyncResult(job_id, app=celery_app)
|
||||||
|
|
||||||
status_map = {
|
status_map = {
|
||||||
"PENDING": "pending",
|
"PENDING": "pending",
|
||||||
"STARTED": "running",
|
"STARTED": "running",
|
||||||
|
|||||||
Executable → Regular
+14
-11
@@ -6,7 +6,6 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
|
||||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||||
from app.schemas.voice_clone import (
|
from app.schemas.voice_clone import (
|
||||||
CreateVoiceCloneRequest,
|
CreateVoiceCloneRequest,
|
||||||
@@ -98,7 +97,9 @@ def create_voice_clone(
|
|||||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||||
if profile.status == "processing" and task_id:
|
if profile.status == "processing" and task_id:
|
||||||
try:
|
try:
|
||||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
from worker_app.tasks import process_voice_clone
|
||||||
|
|
||||||
|
process_voice_clone.delay(profile.id)
|
||||||
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
logger.info(f"Celery task dispatched for voice clone {profile.id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||||
@@ -140,8 +141,8 @@ def get_voice_clone(
|
|||||||
use_case = GetVoiceCloneUseCase(repository)
|
use_case = GetVoiceCloneUseCase(repository)
|
||||||
try:
|
try:
|
||||||
profile = use_case.execute(clone_id, user_id)
|
profile = use_case.execute(clone_id, user_id)
|
||||||
except VoiceCloneNotFoundError as _e:
|
except VoiceCloneNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||||
return _to_response(profile)
|
return _to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,8 +157,8 @@ def get_voice_clone_status(
|
|||||||
use_case = GetVoiceCloneStatusUseCase(repository)
|
use_case = GetVoiceCloneStatusUseCase(repository)
|
||||||
try:
|
try:
|
||||||
profile = use_case.execute(clone_id, user_id)
|
profile = use_case.execute(clone_id, user_id)
|
||||||
except VoiceCloneNotFoundError as _e:
|
except VoiceCloneNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||||
return VoiceCloneStatusResponse(
|
return VoiceCloneStatusResponse(
|
||||||
id=profile.id,
|
id=profile.id,
|
||||||
status=profile.status,
|
status=profile.status,
|
||||||
@@ -200,19 +201,21 @@ def retry_voice_clone(
|
|||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
try:
|
try:
|
||||||
profile = workflow.retry_clone(clone_id, user_id)
|
profile = workflow.retry_clone(clone_id, user_id)
|
||||||
except VoiceCloneNotFoundError as _e:
|
except VoiceCloneNotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
|
||||||
except VoiceCloneNotRetryableError as _e:
|
except VoiceCloneNotRetryableError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Voice clone is not retryable (only failed clones can be retried)",
|
detail="Voice clone is not retryable (only failed clones can be retried)",
|
||||||
) from _e
|
)
|
||||||
|
|
||||||
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
||||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||||
if profile.status == "processing" and task_id:
|
if profile.status == "processing" and task_id:
|
||||||
try:
|
try:
|
||||||
celery_app.send_task("worker.process_voice_clone", args=[profile.id])
|
from worker_app.tasks import process_voice_clone
|
||||||
|
|
||||||
|
process_voice_clone.delay(profile.id)
|
||||||
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
logger.info(f"Celery task dispatched for voice clone retry {profile.id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Literal, Optional
|
|||||||
|
|
||||||
from app.api.routes._helpers import get_user_plan
|
from app.api.routes._helpers import get_user_plan
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository
|
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
|
||||||
from app.schemas.voice import (
|
from app.schemas.voice import (
|
||||||
PresetVoiceItemResponse,
|
PresetVoiceItemResponse,
|
||||||
PresetVoiceListResponse,
|
PresetVoiceListResponse,
|
||||||
@@ -27,7 +27,6 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
||||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
|
||||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
|
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
|
||||||
from packages.application.voice_library.use_cases import (
|
from packages.application.voice_library.use_cases import (
|
||||||
CreateVoiceLibraryUseCase,
|
CreateVoiceLibraryUseCase,
|
||||||
@@ -38,18 +37,11 @@ from packages.application.voice_library.use_cases import (
|
|||||||
QuotaExceededError,
|
QuotaExceededError,
|
||||||
UpdateVoiceLibraryUseCase,
|
UpdateVoiceLibraryUseCase,
|
||||||
)
|
)
|
||||||
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
|
from packages.domain.preset_voices import PRESET_VOICES
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
|
||||||
# key: voice_id, value: (audio_url, timestamp)
|
|
||||||
_preset_preview_cache: dict[str, tuple[str, float]] = {}
|
|
||||||
PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
|
||||||
# 每个预置音色的默认试听文本
|
|
||||||
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
||||||
return SQLAlchemyVoiceLibraryRepository(session)
|
return SQLAlchemyVoiceLibraryRepository(session)
|
||||||
@@ -224,58 +216,6 @@ def list_preset_voices() -> PresetVoiceListResponse:
|
|||||||
return PresetVoiceListResponse(items=items, total=len(items))
|
return PresetVoiceListResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/presets/{voice_id}/preview")
|
|
||||||
def get_preset_voice_preview(
|
|
||||||
voice_id: str,
|
|
||||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
|
||||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
|
||||||
) -> dict:
|
|
||||||
"""获取预置音色试听音频(实时 TTS 合成)。
|
|
||||||
|
|
||||||
- 首次调用会合成并缓存7天
|
|
||||||
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
|
||||||
- 可传入自定义 text 参数试听不同文本
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
preset = get_preset_voice_by_id(voice_id)
|
|
||||||
if preset is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
|
||||||
|
|
||||||
# 有自定义文本时不缓存
|
|
||||||
use_cache = not text.strip()
|
|
||||||
|
|
||||||
if use_cache and voice_id in _preset_preview_cache:
|
|
||||||
audio_url, cached_at = _preset_preview_cache[voice_id]
|
|
||||||
if time.time() - cached_at < PREVIEW_CACHE_TTL:
|
|
||||||
return {"voice_id": voice_id, "audio_url": audio_url, "cached": True}
|
|
||||||
|
|
||||||
# 合成试听音频
|
|
||||||
preview_text = text.strip() or PREVIEW_TEMPLATE.format(name=preset.name)
|
|
||||||
try:
|
|
||||||
result = cosyvoice.synthesize_speech(
|
|
||||||
text=preview_text,
|
|
||||||
voice_id=preset.voice_id,
|
|
||||||
format="mp3",
|
|
||||||
speed=1.0,
|
|
||||||
)
|
|
||||||
except CosyVoiceError as e:
|
|
||||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
|
||||||
|
|
||||||
audio_url = result.audio_url
|
|
||||||
|
|
||||||
# 缓存(仅默认试听文本)
|
|
||||||
if use_cache:
|
|
||||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
|
||||||
|
|
||||||
return {
|
|
||||||
"voice_id": voice_id,
|
|
||||||
"audio_url": audio_url,
|
|
||||||
"text": preview_text,
|
|
||||||
"cached": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 原有 CRUD 端点(保持向后兼容)====================
|
# ==================== 原有 CRUD 端点(保持向后兼容)====================
|
||||||
|
|
||||||
|
|
||||||
@@ -347,7 +287,7 @@ def create_voice(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||||
) from exc
|
)
|
||||||
return _to_response(item, sign_url)
|
return _to_response(item, sign_url)
|
||||||
|
|
||||||
|
|
||||||
@@ -377,8 +317,8 @@ def update_voice(
|
|||||||
use_case = UpdateVoiceLibraryUseCase(voice_repository)
|
use_case = UpdateVoiceLibraryUseCase(voice_repository)
|
||||||
try:
|
try:
|
||||||
item = use_case.execute(command)
|
item = use_case.execute(command)
|
||||||
except NotFoundError as _e:
|
except NotFoundError:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||||
return _to_response(item, sign_url)
|
return _to_response(item, sign_url)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+166
-23
@@ -1,28 +1,171 @@
|
|||||||
"""API 服务配置(向后兼容层)。
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
统一配置已迁移到 packages.config.api_settings。
|
from pydantic import AliasChoices, Field, field_validator
|
||||||
新代码请使用:
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from packages.config import APISettings, get_api_settings
|
|
||||||
|
|
||||||
本文件保留 Settings 类名、get_settings() 函数、settings 模块级单例,
|
|
||||||
确保所有旧的 import 路径仍然有效。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from packages.config import APISettings as Settings
|
class Settings(BaseSettings):
|
||||||
from packages.config import get_api_settings as get_settings
|
APP_NAME: str = "xiaoxia-saas"
|
||||||
from packages.config import reload_settings_cache
|
APP_VERSION: str = "0.1.61"
|
||||||
|
ENVIRONMENT: str = "development"
|
||||||
|
DEBUG: bool = True
|
||||||
|
|
||||||
|
# 应用基础 URL,用于生成认证邮件中的链接
|
||||||
|
# 开发环境默认 http://localhost:3000
|
||||||
|
# 生产环境应通过环境变量 APP_BASE_URL 设置
|
||||||
|
APP_BASE_URL: str = "http://localhost:3000"
|
||||||
|
|
||||||
|
# Container bind address; external expose is controlled by Docker/Nginx.
|
||||||
|
API_HOST: str = "0.0.0.0" # nosec: B104
|
||||||
|
API_PORT: int = 8000
|
||||||
|
|
||||||
|
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||||
|
DATABASE_POOL_SIZE: int = 20
|
||||||
|
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||||
|
DATABASE_POOL_TIMEOUT: int = 30
|
||||||
|
DATABASE_POOL_RECYCLE: int = 3600
|
||||||
|
USE_IN_MEMORY_DB: bool = False
|
||||||
|
AUTO_CREATE_SCHEMA: bool = False
|
||||||
|
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
ENABLE_REDIS_SESSIONS: bool = False
|
||||||
|
|
||||||
|
# JWT secret key - MUST be set via environment variable, no default allowed
|
||||||
|
JWT_SECRET_KEY: Optional[str] = None
|
||||||
|
|
||||||
|
# JWT 算法与过期时间(与 .env.example 对齐)
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
|
|
||||||
|
@field_validator("JWT_SECRET_KEY", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_jwt_secret_key(cls, v):
|
||||||
|
if v is None or v == "":
|
||||||
|
raise ValueError(
|
||||||
|
"JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!"
|
||||||
|
)
|
||||||
|
# Block known insecure default values
|
||||||
|
insecure_defaults = [
|
||||||
|
"your-secret-key-change-in-production",
|
||||||
|
"your-secret-key",
|
||||||
|
"secret",
|
||||||
|
"changeme",
|
||||||
|
"password",
|
||||||
|
]
|
||||||
|
if v.lower() in [d.lower() for d in insecure_defaults]:
|
||||||
|
raise ValueError(
|
||||||
|
f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
ENABLE_EMAIL_DELIVERY: bool = False
|
||||||
|
SMTP_HOST: str = "smtp.gmail.com"
|
||||||
|
SMTP_PORT: int = 587
|
||||||
|
SMTP_USER: str = ""
|
||||||
|
SMTP_PASSWORD: str = ""
|
||||||
|
SMTP_FROM_EMAIL: str = ""
|
||||||
|
SMTP_FROM_NAME: str = "小虾 SaaS"
|
||||||
|
SMTP_USE_TLS: bool = True
|
||||||
|
|
||||||
|
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||||
|
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||||
|
|
||||||
|
# OSS 七牛云相关
|
||||||
|
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
OSS_ACCESS_KEY_ID: str = ""
|
||||||
|
OSS_ACCESS_KEY_SECRET: str = ""
|
||||||
|
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||||
|
|
||||||
|
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_oss_access_key_id(cls, v):
|
||||||
|
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||||
|
raise ValueError(
|
||||||
|
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
|
||||||
|
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||||
|
)
|
||||||
|
return v or ""
|
||||||
|
|
||||||
|
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_oss_access_key_secret(cls, v):
|
||||||
|
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||||
|
raise ValueError(
|
||||||
|
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
|
||||||
|
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||||
|
)
|
||||||
|
return v or ""
|
||||||
|
|
||||||
|
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
|
||||||
|
default=2000,
|
||||||
|
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
|
||||||
|
)
|
||||||
|
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||||
|
|
||||||
|
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||||
|
|
||||||
|
# 渲染引擎选择:legacy=旧VideoComposeService,unified=新UnifiedRenderService
|
||||||
|
RENDER_ENGINE: str = "legacy"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def CORS_ORIGINS(self) -> list[str]:
|
||||||
|
return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
return self.DATABASE_URL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def redis_url(self) -> str:
|
||||||
|
return self.REDIS_URL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def celery_broker_url(self) -> str:
|
||||||
|
return self.CELERY_BROKER_URL
|
||||||
|
|
||||||
|
@property
|
||||||
|
def celery_result_backend(self) -> str:
|
||||||
|
return self.CELERY_RESULT_BACKEND
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oss_endpoint(self) -> str:
|
||||||
|
return self.OSS_ENDPOINT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oss_access_key_id(self) -> str:
|
||||||
|
return self.OSS_ACCESS_KEY_ID
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oss_access_key_secret(self) -> str:
|
||||||
|
return self.OSS_ACCESS_KEY_SECRET
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oss_bucket_name(self) -> str:
|
||||||
|
return self.OSS_BUCKET_NAME
|
||||||
|
|
||||||
|
|
||||||
|
_settings: Optional[Settings] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
global _settings
|
||||||
|
if _settings is None:
|
||||||
|
env = os.getenv("APP_ENV", "development")
|
||||||
|
env_file = f".env.{env}" if env != "development" else ".env"
|
||||||
|
if os.path.exists(env_file):
|
||||||
|
_settings = Settings(_env_file=env_file)
|
||||||
|
else:
|
||||||
|
_settings = Settings()
|
||||||
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
# 模块级单例(向后兼容)
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
# 暴露旧的 reload_settings 函数名
|
|
||||||
def reload_settings():
|
|
||||||
"""重新加载配置(测试用)。"""
|
|
||||||
reload_settings_cache()
|
|
||||||
global settings
|
|
||||||
settings = get_settings()
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["Settings", "get_settings", "settings", "reload_settings"]
|
|
||||||
|
|||||||
Executable → Regular
+9
-10
@@ -1,20 +1,19 @@
|
|||||||
"""向后兼容层 — 配置已统一到 packages.config。
|
"""Compatibility layer for the canonical API settings module.
|
||||||
|
|
||||||
新代码请使用:
|
Use `app.config` as the single source of truth for API configuration.
|
||||||
from packages.config import get_api_settings, APISettings
|
This module remains only for older imports during migration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from packages.config import APISettings as AppSettings
|
from app.config import Settings as AppSettings
|
||||||
from packages.config import get_api_settings as get_settings
|
from app.config import get_settings, settings
|
||||||
from packages.config import reload_settings_cache
|
|
||||||
|
|
||||||
|
|
||||||
def reload_settings() -> AppSettings:
|
def reload_settings() -> AppSettings:
|
||||||
"""重新加载配置(测试用)。"""
|
"""Reload settings for tests and legacy callers."""
|
||||||
reload_settings_cache()
|
import app.config as canonical_config
|
||||||
return get_settings()
|
|
||||||
|
|
||||||
|
canonical_config.settings = canonical_config.get_settings()
|
||||||
|
return canonical_config.settings
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
|
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
|
|||||||
global_pending_limit,
|
global_pending_limit,
|
||||||
user_id or "unknown",
|
user_id or "unknown",
|
||||||
)
|
)
|
||||||
exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||||
raise exc
|
raise exc
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
|
|||||||
if global_over or user_over:
|
if global_over or user_over:
|
||||||
if global_over:
|
if global_over:
|
||||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||||
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||||
else:
|
else:
|
||||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ def get_tag_repository(
|
|||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
) -> TagRepository:
|
) -> TagRepository:
|
||||||
"""Provide the SQLAlchemy tag repository implementation."""
|
"""Provide the SQLAlchemy tag repository implementation."""
|
||||||
return SQLAlchemyTagRepository(session) # type: ignore[return-value]
|
return SQLAlchemyTagRepository(session)
|
||||||
|
|
||||||
|
|
||||||
def get_user_repository(
|
def get_user_repository(
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self.max_requests = max_requests
|
self.max_requests = max_requests
|
||||||
self.window_seconds = window_seconds
|
self.window_seconds = window_seconds
|
||||||
self.paths = set(paths) if paths else None
|
self.paths = set(paths) if paths else None
|
||||||
self.requests: dict[str, list[float]] = {}
|
self.requests = {} # {ip: [timestamps]}
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# 如果配置了路径过滤,只对指定路径限流
|
# 如果配置了路径过滤,只对指定路径限流
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ class AssetResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
classification_status: str
|
classification_status: str
|
||||||
quality_score: float | None = None
|
quality_score: float | None = None
|
||||||
created_at: str
|
|
||||||
uploaded_by_user_id: str
|
uploaded_by_user_id: str
|
||||||
tag_ids: list[str] = Field(default_factory=list)
|
tag_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
|||||||
voice_ids: list[str] = Field(default_factory=list)
|
voice_ids: list[str] = Field(default_factory=list)
|
||||||
# ── 来源剪辑计划 ──
|
# ── 来源剪辑计划 ──
|
||||||
source_edit_plan_id: str = ""
|
source_edit_plan_id: str = ""
|
||||||
# ── 视频标题 ──
|
|
||||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
|
||||||
# ── 批量生成 ──
|
# ── 批量生成 ──
|
||||||
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
||||||
# ── 素材库自动匹配 ──
|
# ── 素材库自动匹配 ──
|
||||||
@@ -46,16 +44,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
|||||||
le=5,
|
le=5,
|
||||||
description="最大自动重试次数,0表示不自动重试,最大5次",
|
description="最大自动重试次数,0表示不自动重试,最大5次",
|
||||||
)
|
)
|
||||||
# ── 输出分辨率 ──
|
|
||||||
resolution: str = Field(
|
|
||||||
default="",
|
|
||||||
description="输出分辨率,格式为 WIDTHxHEIGHT,如 1280x720、1080x1920。为空使用默认 1280x720",
|
|
||||||
)
|
|
||||||
# ── 自定义 BGM ──
|
|
||||||
bgm_config: dict = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
|
||||||
)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||||
@@ -83,9 +71,6 @@ class GenerationTaskResponse(BaseModel):
|
|||||||
source_edit_plan_id: str = ""
|
source_edit_plan_id: str = ""
|
||||||
asset_select_mode: str = ""
|
asset_select_mode: str = ""
|
||||||
batch_id: str = ""
|
batch_id: str = ""
|
||||||
video_title: str = ""
|
|
||||||
resolution: str = ""
|
|
||||||
bgm_config: dict = Field(default_factory=dict)
|
|
||||||
status: str
|
status: str
|
||||||
progress: float
|
progress: float
|
||||||
result_count: int
|
result_count: int
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -15,8 +17,8 @@ class ProjectTaskResponse(BaseModel):
|
|||||||
retry_count: int = 0
|
retry_count: int = 0
|
||||||
source_id: str = ""
|
source_id: str = ""
|
||||||
template_id: str = ""
|
template_id: str = ""
|
||||||
created_at: str = ""
|
created_at: datetime | None = None
|
||||||
updated_at: str = ""
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListProjectTasksResponse(BaseModel):
|
class ListProjectTasksResponse(BaseModel):
|
||||||
@@ -40,8 +42,8 @@ class UserTaskResponse(BaseModel):
|
|||||||
retryable: bool = False
|
retryable: bool = False
|
||||||
retry_count: int = 0
|
retry_count: int = 0
|
||||||
source_id: str = ""
|
source_id: str = ""
|
||||||
created_at: str = ""
|
created_at: datetime | None = None
|
||||||
updated_at: str = ""
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class ListTasksResponse(BaseModel):
|
class ListTasksResponse(BaseModel):
|
||||||
|
|||||||
@@ -35,19 +35,6 @@ class UpdateVideoReviewRequest(BaseModel):
|
|||||||
review_status: VideoReviewStatus
|
review_status: VideoReviewStatus
|
||||||
|
|
||||||
|
|
||||||
MAX_BATCH_DELETE_SIZE = 200
|
|
||||||
|
|
||||||
|
|
||||||
class BatchDeleteRequest(BaseModel):
|
|
||||||
video_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_DELETE_SIZE, description="要删除的成片ID列表")
|
|
||||||
|
|
||||||
|
|
||||||
class BatchOperationResponse(BaseModel):
|
|
||||||
success_count: int = Field(..., ge=0, description="成功删除数量")
|
|
||||||
failed_ids: list[str] = Field(default_factory=list, description="失败的ID列表")
|
|
||||||
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情")
|
|
||||||
|
|
||||||
|
|
||||||
class BatchDownloadRequest(BaseModel):
|
class BatchDownloadRequest(BaseModel):
|
||||||
video_ids: list[str]
|
video_ids: list[str]
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""视频分享相关 schemas."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CreateShareRequest(BaseModel):
|
|
||||||
"""创建分享请求."""
|
|
||||||
|
|
||||||
password: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="访问密码(可选,不设置则无需密码)",
|
|
||||||
min_length=0,
|
|
||||||
max_length=50,
|
|
||||||
)
|
|
||||||
expires_at: Optional[datetime] = Field(
|
|
||||||
None,
|
|
||||||
description="过期时间(可选,不设置则永久有效)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateShareRequest(BaseModel):
|
|
||||||
"""更新分享配置请求."""
|
|
||||||
|
|
||||||
password: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="新密码(传空字符串清除密码,不传则不修改)",
|
|
||||||
max_length=50,
|
|
||||||
)
|
|
||||||
expires_at: Optional[datetime] = Field(
|
|
||||||
None,
|
|
||||||
description="新的过期时间(不传则不修改)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VerifySharePasswordRequest(BaseModel):
|
|
||||||
"""验证分享密码请求."""
|
|
||||||
|
|
||||||
password: str = Field(..., description="访问密码")
|
|
||||||
|
|
||||||
|
|
||||||
class ShareResponse(BaseModel):
|
|
||||||
"""分享记录响应."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
video_id: str
|
|
||||||
share_token: str
|
|
||||||
has_password: bool = False
|
|
||||||
expires_at: Optional[datetime] = None
|
|
||||||
view_count: int = 0
|
|
||||||
download_count: int = 0
|
|
||||||
is_active: bool = True
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ShareListResponse(BaseModel):
|
|
||||||
"""分享列表响应."""
|
|
||||||
|
|
||||||
items: List[ShareResponse]
|
|
||||||
total: int = 0
|
|
||||||
skip: int = 0
|
|
||||||
limit: int = 20
|
|
||||||
|
|
||||||
|
|
||||||
class ShareAccessResponse(BaseModel):
|
|
||||||
"""分享访问成功响应(含视频信息)."""
|
|
||||||
|
|
||||||
share: ShareResponse
|
|
||||||
video_name: str
|
|
||||||
video_duration: float = 0.0
|
|
||||||
video_size: int = 0
|
|
||||||
thumbnail_url: Optional[str] = None
|
|
||||||
download_url: Optional[str] = None
|
|
||||||
password_verified: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ShareMetaResponse(BaseModel):
|
|
||||||
"""分享元信息响应(访问前获取,用于判断是否需要密码)。"""
|
|
||||||
|
|
||||||
share_token: str
|
|
||||||
has_password: bool = False
|
|
||||||
is_expired: bool = False
|
|
||||||
is_active: bool = True
|
|
||||||
video_name: str = ""
|
|
||||||
video_duration: float = 0.0
|
|
||||||
thumbnail_url: Optional[str] = None
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
"""统一 AI 服务层 — 豆包大模型接入.
|
|
||||||
|
|
||||||
提供基于字节跳动豆包大模型的 AI 能力:
|
|
||||||
- 智能标题生成(爆款/情感/信息三种风格)
|
|
||||||
- 后续扩展:智能素材匹配、AI 推荐片段编排等
|
|
||||||
|
|
||||||
设计原则:
|
|
||||||
1. 无 API Key 或调用失败时自动降级为本地模拟,不阻塞主流程
|
|
||||||
2. 统一的客户端封装,新增能力只需加方法
|
|
||||||
3. 所有模型相关配置集中在 Settings
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
|
|
||||||
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
|
|
||||||
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
|
|
||||||
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
|
|
||||||
from packages.shared.ai_client import get_doubao_client
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 智能标题风格定义 ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
TITLE_STYLES = {
|
|
||||||
"viral": {
|
|
||||||
"name": "爆款",
|
|
||||||
"description": "吸引点击、引发好奇的爆款标题,带有数字、疑问或反差感",
|
|
||||||
"examples": [
|
|
||||||
"3个方法让你效率翻倍,第2个最绝",
|
|
||||||
"为什么越努力越穷?真相扎心了",
|
|
||||||
"看完这个,我删掉了手机里一半的APP",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"emotional": {
|
|
||||||
"name": "情感",
|
|
||||||
"description": "触动人心、引发共鸣的情感向标题",
|
|
||||||
"examples": [
|
|
||||||
"那些年我们一起追过的梦想",
|
|
||||||
"生活不易,但请相信光",
|
|
||||||
"致每一个在城市里打拼的你",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"informative": {
|
|
||||||
"name": "信息",
|
|
||||||
"description": "清晰直白、传递核心信息的干货标题",
|
|
||||||
"examples": [
|
|
||||||
"2026年最新个税政策解读,一文讲透",
|
|
||||||
"新手剪辑入门:从0到1完整指南",
|
|
||||||
"产品对比:10款热门手机深度评测",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 智能标题生成 ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_titles_fallback(
|
|
||||||
description: str,
|
|
||||||
style: str = "viral",
|
|
||||||
count: int = 5,
|
|
||||||
) -> List[str]:
|
|
||||||
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
|
||||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
|
||||||
return _generate_titles_fallback_base(description, style_info, count)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_smart_titles(
|
|
||||||
description: str,
|
|
||||||
style: str = "viral",
|
|
||||||
count: int = 5,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""生成智能标题.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: 视频内容描述
|
|
||||||
style: 标题风格 viral/emotional/informative
|
|
||||||
count: 生成数量(5-10)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"titles": [...],
|
|
||||||
"style": "viral",
|
|
||||||
"source": "doubao" | "fallback", # 实际来源
|
|
||||||
"description": "...",
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# 参数校验与边界处理
|
|
||||||
if style not in TITLE_STYLES:
|
|
||||||
style = "viral"
|
|
||||||
count = max(3, min(10, count)) # 3-10 个
|
|
||||||
description = (description or "").strip()
|
|
||||||
|
|
||||||
client = get_doubao_client()
|
|
||||||
if not client.is_available:
|
|
||||||
logger.info("豆包API未配置,使用本地降级生成标题")
|
|
||||||
titles = _generate_titles_fallback(description, style, count)
|
|
||||||
return {
|
|
||||||
"titles": titles,
|
|
||||||
"style": style,
|
|
||||||
"source": "fallback",
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
|
|
||||||
style_info = TITLE_STYLES[style]
|
|
||||||
system_prompt = (
|
|
||||||
f"你是一个专业的短视频标题创作专家,擅长根据视频内容生成吸引人的标题。\n"
|
|
||||||
f"请根据以下视频描述,生成{count}个{style_info['name']}风格的标题。\n"
|
|
||||||
f"风格说明:{style_info['description']}\n"
|
|
||||||
f"要求:\n"
|
|
||||||
f"1. 每个标题控制在8-25字之间\n"
|
|
||||||
f"2. 直接返回JSON数组格式,不要其他文字\n"
|
|
||||||
f"3. 标题要贴合内容,有吸引力"
|
|
||||||
)
|
|
||||||
|
|
||||||
user_prompt = f"视频描述:{description}\n\n请生成标题:"
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": user_prompt},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = client.chat_completion(
|
|
||||||
messages=messages,
|
|
||||||
temperature=0.8,
|
|
||||||
max_tokens=512,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
titles = _parse_titles_from_response(result)
|
|
||||||
if len(titles) >= 2: # 至少解析出2个才算成功
|
|
||||||
titles = titles[:count]
|
|
||||||
logger.info(
|
|
||||||
"豆包智能标题生成成功: style=%s count=%d description=%s...",
|
|
||||||
style,
|
|
||||||
len(titles),
|
|
||||||
description[:20],
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"titles": titles,
|
|
||||||
"style": style,
|
|
||||||
"source": "doubao",
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
logger.warning("豆包返回内容解析失败,降级到本地生成: %s", result[:100])
|
|
||||||
|
|
||||||
# 降级到本地生成
|
|
||||||
titles = _generate_titles_fallback(description, style, count)
|
|
||||||
return {
|
|
||||||
"titles": titles,
|
|
||||||
"style": style,
|
|
||||||
"source": "fallback",
|
|
||||||
"description": description,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 智能素材语义匹配 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _semantic_match_fallback(
|
|
||||||
description: str,
|
|
||||||
assets: List[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
|
||||||
return _semantic_match_fallback_base(description, assets)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_semantic_match_response(
|
|
||||||
content: str,
|
|
||||||
asset_ids: List[str],
|
|
||||||
) -> Optional[Dict[str, float]]:
|
|
||||||
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
|
||||||
result = _parse_semantic_match_base(content, asset_ids)
|
|
||||||
if result is None:
|
|
||||||
return None
|
|
||||||
return dict(result)
|
|
||||||
|
|
||||||
|
|
||||||
def semantic_match_assets(
|
|
||||||
description: str,
|
|
||||||
assets: List[Dict[str, Any]],
|
|
||||||
top_k: int = 0,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""智能素材语义匹配.
|
|
||||||
|
|
||||||
根据用户描述,评估每个素材的语义匹配度并排序。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: 用户描述的目标视频内容
|
|
||||||
assets: 素材列表,每个素材需含 id/name/tags/description 等字段
|
|
||||||
top_k: 返回前K个,0表示返回全部
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"matches": [{"asset_id": ..., "match_score": ..., ...}],
|
|
||||||
"source": "doubao" | "fallback",
|
|
||||||
"description": "...",
|
|
||||||
"total": 总数,
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
description = (description or "").strip()
|
|
||||||
if not assets:
|
|
||||||
return {"matches": [], "source": "fallback", "description": description, "total": 0}
|
|
||||||
|
|
||||||
client = get_doubao_client()
|
|
||||||
if not client.is_available:
|
|
||||||
logger.info("豆包API未配置,使用本地降级做素材语义匹配")
|
|
||||||
matched = _semantic_match_fallback(description, assets)
|
|
||||||
if top_k > 0:
|
|
||||||
matched = matched[:top_k]
|
|
||||||
return {
|
|
||||||
"matches": matched,
|
|
||||||
"source": "fallback",
|
|
||||||
"description": description,
|
|
||||||
"total": len(assets),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 构建素材信息(控制 token 数量)
|
|
||||||
asset_summaries = []
|
|
||||||
for asset in assets[:50]: # 最多传50个素材给模型
|
|
||||||
aid = asset.get("id", "")
|
|
||||||
name = asset.get("name", "")[:50]
|
|
||||||
tags = asset.get("tags", [])
|
|
||||||
tags_str = ",".join(str(t) for t in tags[:5])
|
|
||||||
desc = str(asset.get("description", ""))[:80]
|
|
||||||
asset_summaries.append(f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}")
|
|
||||||
|
|
||||||
asset_ids = [str(a.get("id", "")) for a in assets[:50]]
|
|
||||||
|
|
||||||
system_prompt = (
|
|
||||||
"你是一个专业的视频素材匹配助手。"
|
|
||||||
"根据用户的视频目标描述,评估每个素材的匹配程度。\n"
|
|
||||||
"评分规则:\n"
|
|
||||||
"- 0.0-0.3: 完全不相关\n"
|
|
||||||
"- 0.3-0.6: 有一定关联但不够匹配\n"
|
|
||||||
"- 0.6-0.8: 比较匹配,适合使用\n"
|
|
||||||
"- 0.8-1.0: 高度匹配,非常适合\n"
|
|
||||||
"只返回JSON对象,key为素材ID,value为匹配分数(0-1之间的小数)。"
|
|
||||||
"不要其他文字说明。"
|
|
||||||
)
|
|
||||||
|
|
||||||
user_prompt = (
|
|
||||||
f"目标视频描述:{description}\n\n"
|
|
||||||
f"素材列表:\n" + "\n".join(asset_summaries) + "\n\n请返回每个素材的匹配分数JSON:"
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": system_prompt},
|
|
||||||
{"role": "user", "content": user_prompt},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = client.chat_completion(
|
|
||||||
messages=messages,
|
|
||||||
temperature=0.3,
|
|
||||||
max_tokens=1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result:
|
|
||||||
scores = _parse_semantic_match_response(result, asset_ids)
|
|
||||||
if scores:
|
|
||||||
# 把评分填回素材
|
|
||||||
matched = []
|
|
||||||
for asset in assets:
|
|
||||||
aid = str(asset.get("id", ""))
|
|
||||||
score = scores.get(aid, 0.3) # 没评分的给默认偏低分
|
|
||||||
matched.append(
|
|
||||||
{
|
|
||||||
**asset,
|
|
||||||
"match_score": round(score, 3),
|
|
||||||
"match_reason": "doubao_semantic",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
matched.sort(key=lambda x: x["match_score"], reverse=True)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"豆包语义匹配完成: assets=%d top_score=%.2f description=%s...",
|
|
||||||
len(matched),
|
|
||||||
matched[0]["match_score"] if matched else 0,
|
|
||||||
description[:20],
|
|
||||||
)
|
|
||||||
|
|
||||||
if top_k > 0:
|
|
||||||
matched = matched[:top_k]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"matches": matched,
|
|
||||||
"source": "doubao",
|
|
||||||
"description": description,
|
|
||||||
"total": len(assets),
|
|
||||||
}
|
|
||||||
logger.warning("豆包语义匹配返回解析失败,降级到本地: %s", result[:100])
|
|
||||||
|
|
||||||
# 降级
|
|
||||||
matched = _semantic_match_fallback(description, assets)
|
|
||||||
if top_k > 0:
|
|
||||||
matched = matched[:top_k]
|
|
||||||
return {
|
|
||||||
"matches": matched,
|
|
||||||
"source": "fallback",
|
|
||||||
"description": description,
|
|
||||||
"total": len(assets),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── 单例入口 ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def get_ai_service() -> "AIService":
|
|
||||||
"""获取 AI 服务单例."""
|
|
||||||
global _ai_service
|
|
||||||
if _ai_service is None:
|
|
||||||
_ai_service = AIService()
|
|
||||||
return _ai_service
|
|
||||||
|
|
||||||
|
|
||||||
_ai_service: Optional["AIService"] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AIService:
|
|
||||||
"""AI 服务统一入口,便于后续扩展更多能力."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._client = get_doubao_client()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_available(self) -> bool:
|
|
||||||
return self._client.is_available
|
|
||||||
|
|
||||||
def generate_titles(
|
|
||||||
self,
|
|
||||||
description: str,
|
|
||||||
style: str = "viral",
|
|
||||||
count: int = 5,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
return generate_smart_titles(description, style, count)
|
|
||||||
|
|
||||||
def semantic_match(
|
|
||||||
self,
|
|
||||||
description: str,
|
|
||||||
assets: List[Dict[str, Any]],
|
|
||||||
top_k: int = 0,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
return semantic_match_assets(description, assets, top_k)
|
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -156,7 +155,7 @@ class AutoClipService:
|
|||||||
self,
|
self,
|
||||||
clip: EditPlanClip,
|
clip: EditPlanClip,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
config_map: Mapping[str, object],
|
config_map: dict[str, object],
|
||||||
) -> ClipAssignDetail:
|
) -> ClipAssignDetail:
|
||||||
"""为单个片段分配素材。"""
|
"""为单个片段分配素材。"""
|
||||||
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
|
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
"""封面管理服务.
|
|
||||||
|
|
||||||
提供封面配置管理和从视频抽帧生成封面的能力。
|
|
||||||
抽帧使用 FFmpeg,上传使用共享存储服务。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DEFAULT_COVER_WIDTH = 1080
|
|
||||||
DEFAULT_COVER_HEIGHT = 1920
|
|
||||||
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
|
|
||||||
COVER_STORAGE_PREFIX = "covers"
|
|
||||||
|
|
||||||
|
|
||||||
class CoverService:
|
|
||||||
"""封面管理服务."""
|
|
||||||
|
|
||||||
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
|
|
||||||
self._storage = storage_service
|
|
||||||
self._asset_repo = asset_repository
|
|
||||||
|
|
||||||
# ── 配置读写 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""从 plan.config 中提取封面配置.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plan_config: 剪辑计划的 config 字段
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
封面配置 dict
|
|
||||||
"""
|
|
||||||
cover = plan_config.get("cover", {})
|
|
||||||
if not isinstance(cover, dict):
|
|
||||||
cover = {}
|
|
||||||
# 确保默认字段存在
|
|
||||||
return {
|
|
||||||
"type": cover.get("type", "ai_frame"),
|
|
||||||
"image_url": cover.get("image_url", ""),
|
|
||||||
"frame_time": cover.get("frame_time"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def extract_cover_from_clip(
|
|
||||||
self,
|
|
||||||
plan_id: str,
|
|
||||||
asset_id: str,
|
|
||||||
frame_time: float = 1.0,
|
|
||||||
*,
|
|
||||||
width: int = DEFAULT_COVER_WIDTH,
|
|
||||||
height: int = DEFAULT_COVER_HEIGHT,
|
|
||||||
quality: int = DEFAULT_COVER_QUALITY,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""从指定素材的指定时间点抽取一帧作为封面.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
|
||||||
asset_id: 素材 ID
|
|
||||||
frame_time: 抽帧时间点(秒)
|
|
||||||
width: 输出宽度
|
|
||||||
height: 输出高度
|
|
||||||
quality: JPEG 质量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
封面数据 dict,包含 type / image_url / frame_time
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 素材不存在或不是视频
|
|
||||||
RuntimeError: 抽帧或上传失败
|
|
||||||
"""
|
|
||||||
# 1. 获取素材
|
|
||||||
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
|
|
||||||
if not asset:
|
|
||||||
raise ValueError(f"素材不存在: {asset_id}")
|
|
||||||
|
|
||||||
storage_key = getattr(asset, "storage_key", "")
|
|
||||||
if not storage_key:
|
|
||||||
raise ValueError(f"素材没有文件: {asset_id}")
|
|
||||||
|
|
||||||
mime_type = getattr(asset, "mime_type", "")
|
|
||||||
if mime_type and not mime_type.startswith("video"):
|
|
||||||
raise ValueError(f"素材不是视频类型: {mime_type}")
|
|
||||||
|
|
||||||
# 2. 下载视频到临时目录
|
|
||||||
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
|
|
||||||
tmp_path = Path(tmp_dir)
|
|
||||||
video_path = tmp_path / f"source_{asset_id[:8]}"
|
|
||||||
|
|
||||||
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
|
|
||||||
try:
|
|
||||||
self._storage.download_file(storage_key, str(video_path))
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"下载素材失败: {e}") from e
|
|
||||||
|
|
||||||
if not video_path.exists() or video_path.stat().st_size == 0:
|
|
||||||
raise RuntimeError("下载的素材文件为空")
|
|
||||||
|
|
||||||
# 3. FFmpeg 抽帧
|
|
||||||
output_path = tmp_path / "cover.jpg"
|
|
||||||
self._extract_frame(
|
|
||||||
video_path=video_path,
|
|
||||||
output_path=output_path,
|
|
||||||
time_sec=frame_time,
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
quality=quality,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
|
||||||
raise RuntimeError("封面抽帧失败")
|
|
||||||
|
|
||||||
# 4. 上传到 OSS
|
|
||||||
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
|
|
||||||
logger.info("上传封面到存储: key=%s", cover_key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._storage.upload_file(
|
|
||||||
file_or_path=str(output_path),
|
|
||||||
storage_key=cover_key,
|
|
||||||
content_type="image/jpeg",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"上传封面失败: {e}") from e
|
|
||||||
|
|
||||||
# 5. 获取访问 URL
|
|
||||||
try:
|
|
||||||
image_url = self._storage.get_url(cover_key)
|
|
||||||
except Exception:
|
|
||||||
image_url = cover_key # 降级为 storage_key
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
|
|
||||||
plan_id,
|
|
||||||
asset_id,
|
|
||||||
frame_time,
|
|
||||||
output_path.stat().st_size if output_path.exists() else 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": "manual",
|
|
||||||
"image_url": image_url,
|
|
||||||
"frame_time": frame_time,
|
|
||||||
}
|
|
||||||
|
|
||||||
def generate_smart_cover(
|
|
||||||
self,
|
|
||||||
plan_id: str,
|
|
||||||
asset_id: str,
|
|
||||||
*,
|
|
||||||
width: int = DEFAULT_COVER_WIDTH,
|
|
||||||
height: int = DEFAULT_COVER_HEIGHT,
|
|
||||||
quality: int = DEFAULT_COVER_QUALITY,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plan_id: 剪辑计划 ID
|
|
||||||
asset_id: 素材 ID
|
|
||||||
width: 输出宽度
|
|
||||||
height: 输出高度
|
|
||||||
quality: JPEG 质量
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
封面数据 dict
|
|
||||||
"""
|
|
||||||
# 简单实现:取视频 1/3 处的帧作为智能封面
|
|
||||||
# 更复杂的多帧选清晰帧可以后续优化
|
|
||||||
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
|
|
||||||
|
|
||||||
result = self.extract_cover_from_clip(
|
|
||||||
plan_id=plan_id,
|
|
||||||
asset_id=asset_id,
|
|
||||||
frame_time=frame_time,
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
quality=quality,
|
|
||||||
)
|
|
||||||
result["type"] = "ai_frame"
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_frame(
|
|
||||||
video_path: Path,
|
|
||||||
output_path: Path,
|
|
||||||
*,
|
|
||||||
time_sec: float,
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
quality: int,
|
|
||||||
) -> None:
|
|
||||||
"""使用 FFmpeg 从视频中抽取一帧.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
video_path: 视频文件路径
|
|
||||||
output_path: 输出图片路径
|
|
||||||
time_sec: 抽帧时间点(秒)
|
|
||||||
width: 输出宽度
|
|
||||||
height: 输出高度
|
|
||||||
quality: JPEG 质量
|
|
||||||
"""
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# scale + crop 实现 cover 裁剪
|
|
||||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
|
||||||
|
|
||||||
command = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-ss",
|
|
||||||
f"{time_sec:.3f}",
|
|
||||||
"-i",
|
|
||||||
str(video_path),
|
|
||||||
"-vframes",
|
|
||||||
"1",
|
|
||||||
"-vf",
|
|
||||||
vf,
|
|
||||||
"-q:v",
|
|
||||||
str(quality),
|
|
||||||
"-f",
|
|
||||||
"mjpeg",
|
|
||||||
str(output_path),
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
|
|
||||||
# 尝试不使用 scale+crop 的简化命令
|
|
||||||
simple_command = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-ss",
|
|
||||||
f"{time_sec:.3f}",
|
|
||||||
"-i",
|
|
||||||
str(video_path),
|
|
||||||
"-vframes",
|
|
||||||
"1",
|
|
||||||
"-q:v",
|
|
||||||
str(quality),
|
|
||||||
"-f",
|
|
||||||
"mjpeg",
|
|
||||||
str(output_path),
|
|
||||||
]
|
|
||||||
result2 = subprocess.run(
|
|
||||||
simple_command,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
if result2.returncode != 0:
|
|
||||||
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
|
|
||||||
except subprocess.TimeoutExpired as e:
|
|
||||||
raise RuntimeError("FFmpeg 抽帧超时") from e
|
|
||||||
except FileNotFoundError as e:
|
|
||||||
raise RuntimeError("FFmpeg 不可用") from e
|
|
||||||
@@ -16,11 +16,6 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
SQLAlchemyEditPlanRepository,
|
SQLAlchemyEditPlanRepository,
|
||||||
SQLAlchemyGenerationTaskRepository,
|
SQLAlchemyGenerationTaskRepository,
|
||||||
)
|
)
|
||||||
from packages.domain.clip_operations import calculate_merge as _calc_merge
|
|
||||||
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
|
|
||||||
from packages.domain.clip_operations import calculate_split as _calc_split
|
|
||||||
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
|
|
||||||
from packages.domain.clip_operations import validate_split_time as _validate_split
|
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
|
|
||||||
@@ -44,6 +39,70 @@ class EditPlanService:
|
|||||||
|
|
||||||
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
|
# ── 剪辑计划 CRUD ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def list_plans(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
template_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
status: Optional[EditPlanStatus] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> List[EditPlan]:
|
||||||
|
"""列出剪辑计划
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_id: 按模板 ID 筛选
|
||||||
|
project_id: 按项目 ID 筛选
|
||||||
|
status: 按状态筛选
|
||||||
|
skip: 分页偏移
|
||||||
|
limit: 每页数量
|
||||||
|
"""
|
||||||
|
if project_id:
|
||||||
|
return self._plan_repo.list_by_project(
|
||||||
|
project_id,
|
||||||
|
status=status,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
if template_id:
|
||||||
|
return self._plan_repo.list_by_template(
|
||||||
|
template_id,
|
||||||
|
status=status,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return self._plan_repo.list_all(status=status, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
def count_plans(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
template_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
status: Optional[EditPlanStatus] = None,
|
||||||
|
) -> int:
|
||||||
|
"""统计计划数量
|
||||||
|
|
||||||
|
Note:
|
||||||
|
当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。
|
||||||
|
"""
|
||||||
|
if project_id:
|
||||||
|
all_matching = self._plan_repo.list_by_project(
|
||||||
|
project_id,
|
||||||
|
status=status,
|
||||||
|
skip=0,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
return len(all_matching)
|
||||||
|
if template_id:
|
||||||
|
all_matching = self._plan_repo.list_by_template(
|
||||||
|
template_id,
|
||||||
|
status=status,
|
||||||
|
skip=0,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
return len(all_matching)
|
||||||
|
return self._plan_repo.count(status=status)
|
||||||
|
|
||||||
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
|
def get_plan(self, plan_id: str) -> Optional[EditPlan]:
|
||||||
"""获取计划详情"""
|
"""获取计划详情"""
|
||||||
return self._plan_repo.get(plan_id)
|
return self._plan_repo.get(plan_id)
|
||||||
@@ -65,7 +124,7 @@ class EditPlanService:
|
|||||||
project_id: str = "",
|
project_id: str = "",
|
||||||
created_by_user_id: str = "",
|
created_by_user_id: str = "",
|
||||||
) -> EditPlan:
|
) -> EditPlan:
|
||||||
"""创建剪辑计划(基础 CRUD,供内部测试与脚本使用)
|
"""创建剪辑计划
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: 参数校验失败
|
ValueError: 参数校验失败
|
||||||
@@ -82,19 +141,6 @@ class EditPlanService:
|
|||||||
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
def _auto_resume_editing(self, plan_id: str) -> None:
|
|
||||||
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
|
|
||||||
plan = self._plan_repo.get(plan_id)
|
|
||||||
if plan is None:
|
|
||||||
return
|
|
||||||
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
|
||||||
try:
|
|
||||||
plan.resume_editing()
|
|
||||||
self._plan_repo.update(plan)
|
|
||||||
logger.info("自动重新编辑: plan_id=%s", plan_id)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def update_plan(
|
def update_plan(
|
||||||
self,
|
self,
|
||||||
plan_id: str,
|
plan_id: str,
|
||||||
@@ -110,10 +156,6 @@ class EditPlanService:
|
|||||||
"""
|
"""
|
||||||
existing = self.get_plan_or_raise(plan_id)
|
existing = self.get_plan_or_raise(plan_id)
|
||||||
|
|
||||||
# 自动从 completed/failed 切回 editing
|
|
||||||
self._auto_resume_editing(plan_id)
|
|
||||||
existing = self.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
updated = EditPlan(
|
updated = EditPlan(
|
||||||
id=existing.id,
|
id=existing.id,
|
||||||
template_id=existing.template_id,
|
template_id=existing.template_id,
|
||||||
@@ -131,6 +173,23 @@ class EditPlanService:
|
|||||||
logger.info("更新剪辑计划: id=%s", plan_id)
|
logger.info("更新剪辑计划: id=%s", plan_id)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def delete_plan(self, plan_id: str) -> bool:
|
||||||
|
"""删除剪辑计划及其所有片段
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否删除成功
|
||||||
|
"""
|
||||||
|
existing = self._plan_repo.get(plan_id)
|
||||||
|
if existing is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 先删除所有片段
|
||||||
|
self._clip_repo.delete_by_plan(plan_id)
|
||||||
|
# 再删除计划
|
||||||
|
self._plan_repo.delete(plan_id)
|
||||||
|
logger.info("删除剪辑计划: id=%s", plan_id)
|
||||||
|
return True
|
||||||
|
|
||||||
# ── 状态机流转 ──────────────────────────────────────────────────────────
|
# ── 状态机流转 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
|
def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan:
|
||||||
@@ -153,24 +212,8 @@ class EditPlanService:
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
# 根据目标状态调用对应的状态机方法
|
# 根据目标状态调用对应的状态机方法
|
||||||
# EDITING 支持从 draft / completed / failed 进入
|
|
||||||
if target_status == EditPlanStatus.EDITING:
|
|
||||||
if plan.status == EditPlanStatus.DRAFT:
|
|
||||||
plan.start_editing()
|
|
||||||
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
|
||||||
plan.resume_editing()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
|
|
||||||
result = self._plan_repo.update(plan)
|
|
||||||
logger.info(
|
|
||||||
"状态流转: plan_id=%s %s → %s",
|
|
||||||
plan_id,
|
|
||||||
plan.status,
|
|
||||||
target_status,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
transition_map = {
|
transition_map = {
|
||||||
|
EditPlanStatus.EDITING: plan.start_editing,
|
||||||
EditPlanStatus.RENDERING: plan.start_rendering,
|
EditPlanStatus.RENDERING: plan.start_rendering,
|
||||||
EditPlanStatus.COMPLETED: plan.mark_completed,
|
EditPlanStatus.COMPLETED: plan.mark_completed,
|
||||||
EditPlanStatus.FAILED: plan.mark_failed,
|
EditPlanStatus.FAILED: plan.mark_failed,
|
||||||
@@ -249,8 +292,6 @@ class EditPlanService:
|
|||||||
"""
|
"""
|
||||||
# 确保计划存在
|
# 确保计划存在
|
||||||
self.get_plan_or_raise(plan_id)
|
self.get_plan_or_raise(plan_id)
|
||||||
# 自动从 completed/failed 切回 editing
|
|
||||||
self._auto_resume_editing(plan_id)
|
|
||||||
|
|
||||||
clip = EditPlanClip.create(
|
clip = EditPlanClip.create(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
@@ -298,9 +339,6 @@ class EditPlanService:
|
|||||||
"""
|
"""
|
||||||
existing = self.get_clip_or_raise(clip_id)
|
existing = self.get_clip_or_raise(clip_id)
|
||||||
|
|
||||||
# 自动从 completed/failed 切回 editing
|
|
||||||
self._auto_resume_editing(existing.plan_id)
|
|
||||||
|
|
||||||
# 速度边界钳制
|
# 速度边界钳制
|
||||||
if playback_speed is not None:
|
if playback_speed is not None:
|
||||||
if playback_speed <= 0:
|
if playback_speed <= 0:
|
||||||
@@ -343,8 +381,6 @@ class EditPlanService:
|
|||||||
ValueError: 片段不存在或 asset_id 为空
|
ValueError: 片段不存在或 asset_id 为空
|
||||||
"""
|
"""
|
||||||
clip = self.get_clip_or_raise(clip_id)
|
clip = self.get_clip_or_raise(clip_id)
|
||||||
# 自动从 completed/failed 切回 editing
|
|
||||||
self._auto_resume_editing(clip.plan_id)
|
|
||||||
clip.assign_asset(asset_id)
|
clip.assign_asset(asset_id)
|
||||||
result = self._clip_repo.update(clip)
|
result = self._clip_repo.update(clip)
|
||||||
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
|
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
|
||||||
@@ -371,162 +407,21 @@ class EditPlanService:
|
|||||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
|
||||||
"""将一个片段从指定位置分割为两个片段
|
|
||||||
|
|
||||||
Args:
|
|
||||||
clip_id: 要分割的片段 ID
|
|
||||||
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 片段不存在、分割时间越界
|
|
||||||
"""
|
|
||||||
clip = self.get_clip_or_raise(clip_id)
|
|
||||||
plan_id = clip.plan_id
|
|
||||||
|
|
||||||
# 纯逻辑:校验 + 计算
|
|
||||||
_validate_split(split_time, clip.duration)
|
|
||||||
split = _calc_split(
|
|
||||||
duration=clip.duration,
|
|
||||||
split_time=split_time,
|
|
||||||
start_time=clip.start_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
|
||||||
|
|
||||||
original_order = clip.order
|
|
||||||
|
|
||||||
# 更新左半部分(原片段)
|
|
||||||
clip.duration = split.left_duration
|
|
||||||
left_clip = self._clip_repo.update(clip)
|
|
||||||
|
|
||||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
|
||||||
shifts = _calc_shift_orders(
|
|
||||||
all_clips,
|
|
||||||
threshold_order=original_order,
|
|
||||||
shift=1,
|
|
||||||
excluded_ids={clip_id},
|
|
||||||
id_attr="id",
|
|
||||||
order_attr="order",
|
|
||||||
)
|
|
||||||
for c, new_order in shifts:
|
|
||||||
c.order = new_order
|
|
||||||
self._clip_repo.update(c)
|
|
||||||
|
|
||||||
# 创建右半部分新片段(继承原片段的大部分属性)
|
|
||||||
right_config = dict(clip.config) if clip.config else {}
|
|
||||||
# 素材裁剪信息
|
|
||||||
if clip.asset_id:
|
|
||||||
# 右半部分从 split_time 开始播放
|
|
||||||
right_config["trim_start"] = split.right_trim_start
|
|
||||||
# 左半部分在 split_time 处结束
|
|
||||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
|
||||||
left_config["trim_end"] = split.left_trim_end
|
|
||||||
left_clip.config = left_config
|
|
||||||
left_clip = self._clip_repo.update(left_clip)
|
|
||||||
|
|
||||||
right_clip = EditPlanClip.create(
|
|
||||||
plan_id=plan_id,
|
|
||||||
clip_type=clip.clip_type,
|
|
||||||
order=original_order + 1,
|
|
||||||
template_clip_config_id=clip.template_clip_config_id,
|
|
||||||
asset_id=clip.asset_id,
|
|
||||||
text_content=clip.text_content,
|
|
||||||
start_time=split.right_start_time,
|
|
||||||
duration=split.right_duration,
|
|
||||||
transition_effect=clip.transition_effect,
|
|
||||||
transition_duration=clip.transition_duration,
|
|
||||||
playback_speed=clip.playback_speed,
|
|
||||||
config=right_config,
|
|
||||||
)
|
|
||||||
created_right = self._clip_repo.create(right_clip)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
|
|
||||||
clip_id,
|
|
||||||
plan_id,
|
|
||||||
split_time,
|
|
||||||
split.left_duration,
|
|
||||||
split.right_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"left_clip": left_clip,
|
|
||||||
"right_clip": created_right,
|
|
||||||
}
|
|
||||||
|
|
||||||
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
|
|
||||||
"""合并多个连续片段为一个片段
|
|
||||||
|
|
||||||
Args:
|
|
||||||
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EditPlanClip: 合并后的新片段
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
|
|
||||||
"""
|
|
||||||
if len(clip_ids) < 2:
|
|
||||||
raise ValueError("至少需要 2 个片段才能合并")
|
|
||||||
|
|
||||||
# 读取所有片段
|
|
||||||
clips = []
|
|
||||||
for cid in clip_ids:
|
|
||||||
clip = self.get_clip_or_raise(cid)
|
|
||||||
clips.append(clip)
|
|
||||||
|
|
||||||
# 纯逻辑:校验 + 计算
|
|
||||||
plan_id, first_order = _validate_merge(clips)
|
|
||||||
merge = _calc_merge(clips)
|
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
|
||||||
|
|
||||||
# 更新第一个片段(保留它作为合并结果)
|
|
||||||
first_clip = sorted(clips, key=lambda c: c.order)[0]
|
|
||||||
first_clip.duration = merge.total_duration
|
|
||||||
first_clip.text_content = merge.merged_text
|
|
||||||
first_clip.config = merge.merged_config
|
|
||||||
# 转场保留第一个的(合并后的入点转场)
|
|
||||||
# playback_speed 取第一个的
|
|
||||||
merged_clip = self._clip_repo.update(first_clip)
|
|
||||||
|
|
||||||
# 删除其余片段
|
|
||||||
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
|
|
||||||
for cid in rest_ids:
|
|
||||||
self._clip_repo.delete(cid)
|
|
||||||
|
|
||||||
# 后面的片段 order 前移 (len - 1) 位
|
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
|
||||||
shifts = _calc_shift_orders(
|
|
||||||
all_clips,
|
|
||||||
threshold_order=first_order,
|
|
||||||
shift=-merge.shift_amount,
|
|
||||||
excluded_ids={merged_clip.id},
|
|
||||||
id_attr="id",
|
|
||||||
order_attr="order",
|
|
||||||
)
|
|
||||||
for c, new_order in shifts:
|
|
||||||
c.order = new_order
|
|
||||||
self._clip_repo.update(c)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
|
||||||
plan_id,
|
|
||||||
len(clips),
|
|
||||||
merge.total_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
return merged_clip
|
|
||||||
|
|
||||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
|
||||||
|
"""获取计划及其所有片段
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||||
|
"""
|
||||||
|
plan = self.get_plan_or_raise(plan_id)
|
||||||
|
clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
|
return {
|
||||||
|
"plan": plan,
|
||||||
|
"clips": clips,
|
||||||
|
}
|
||||||
|
|
||||||
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
def get_generation_status(self, plan_id: str) -> Dict[str, Any]:
|
||||||
"""获取渲染进度状态
|
"""获取渲染进度状态
|
||||||
|
|
||||||
@@ -536,8 +431,6 @@ class EditPlanService:
|
|||||||
"clips": List[EditPlanClip],
|
"clips": List[EditPlanClip],
|
||||||
"generation_task_id": Optional[str],
|
"generation_task_id": Optional[str],
|
||||||
"generation_task_status": Optional[str],
|
"generation_task_status": Optional[str],
|
||||||
"progress": float,
|
|
||||||
"error_message": str,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -549,23 +442,17 @@ class EditPlanService:
|
|||||||
# 从 plan.config 中获取 generation_task_id
|
# 从 plan.config 中获取 generation_task_id
|
||||||
generation_task_id = plan.config.get("generation_task_id")
|
generation_task_id = plan.config.get("generation_task_id")
|
||||||
generation_task_status = None
|
generation_task_status = None
|
||||||
progress = 0.0
|
|
||||||
error_message = ""
|
|
||||||
|
|
||||||
if generation_task_id:
|
if generation_task_id:
|
||||||
task = self._generation_task_repo.get(generation_task_id)
|
task = self._generation_task_repo.get(generation_task_id)
|
||||||
if task:
|
if task:
|
||||||
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
|
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
|
||||||
progress = getattr(task, "progress", 0.0) or 0.0
|
|
||||||
error_message = getattr(task, "error_message", "") or ""
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"plan": plan,
|
"plan": plan,
|
||||||
"clips": clips,
|
"clips": clips,
|
||||||
"generation_task_id": generation_task_id,
|
"generation_task_id": generation_task_id,
|
||||||
"generation_task_status": generation_task_status,
|
"generation_task_status": generation_task_status,
|
||||||
"progress": progress,
|
|
||||||
"error_message": error_message,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||||
@@ -616,9 +503,6 @@ class EditPlanService:
|
|||||||
更新后的计划
|
更新后的计划
|
||||||
"""
|
"""
|
||||||
plan = self.get_plan_or_raise(plan_id)
|
plan = self.get_plan_or_raise(plan_id)
|
||||||
# 自动从 completed/failed 切回 editing
|
|
||||||
self._auto_resume_editing(plan_id)
|
|
||||||
plan = self.get_plan_or_raise(plan_id)
|
|
||||||
new_config = {**plan.config, **config_updates}
|
new_config = {**plan.config, **config_updates}
|
||||||
|
|
||||||
updated = EditPlan(
|
updated = EditPlan(
|
||||||
|
|||||||
Executable → Regular
+3
-420
@@ -12,8 +12,6 @@ from typing import Any, List, Optional
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl import (
|
from packages.adapters.sqlalchemy_impl import (
|
||||||
SQLAlchemyEditPlanClipRepository,
|
|
||||||
SQLAlchemyEditPlanRepository,
|
|
||||||
SQLAlchemyEditTemplateRepository,
|
SQLAlchemyEditTemplateRepository,
|
||||||
SQLAlchemyTemplateClipConfigRepository,
|
SQLAlchemyTemplateClipConfigRepository,
|
||||||
)
|
)
|
||||||
@@ -23,13 +21,6 @@ from packages.domain.template_clip_config import (
|
|||||||
TemplateClipConfig,
|
TemplateClipConfig,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
)
|
)
|
||||||
from packages.domain.template_clip_converter import (
|
|
||||||
clip_configs_to_snapshots,
|
|
||||||
clips_to_template_clip_configs,
|
|
||||||
filter_plan_config_to_template,
|
|
||||||
snapshots_to_template_clip_configs,
|
|
||||||
validate_template_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,14 +37,6 @@ class EditTemplateService:
|
|||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self._template_repo = SQLAlchemyEditTemplateRepository(db)
|
self._template_repo = SQLAlchemyEditTemplateRepository(db)
|
||||||
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
|
||||||
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_version_repository import (
|
|
||||||
SQLAlchemyTemplateVersionRepository,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._version_repo = SQLAlchemyTemplateVersionRepository(db)
|
|
||||||
self._db = db
|
|
||||||
|
|
||||||
# ── 模板 CRUD ──────────────────────────────────────────────────────────
|
# ── 模板 CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -128,7 +111,9 @@ class EditTemplateService:
|
|||||||
ValueError: 名称为空或重复
|
ValueError: 名称为空或重复
|
||||||
"""
|
"""
|
||||||
# 名称校验
|
# 名称校验
|
||||||
clean_name = validate_template_name(name)
|
clean_name = name.strip()
|
||||||
|
if not clean_name:
|
||||||
|
raise ValueError("模板名称不能为空")
|
||||||
|
|
||||||
# 名称重复检查
|
# 名称重复检查
|
||||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||||
@@ -409,405 +394,3 @@ class EditTemplateService:
|
|||||||
"template": template,
|
"template": template,
|
||||||
"clip_configs": clip_configs,
|
"clip_configs": clip_configs,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def save_plan_as_template(
|
|
||||||
self,
|
|
||||||
plan_id: str,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
description: str = "",
|
|
||||||
template_type: str = "custom",
|
|
||||||
preview_url: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""将剪辑计划保存为模板
|
|
||||||
|
|
||||||
将指定剪辑计划的配置和片段结构另存为一个新模板,
|
|
||||||
方便后续基于该模板快速创建新的剪辑计划。
|
|
||||||
|
|
||||||
转换规则:
|
|
||||||
- 计划名称 → 模板名称(调用方传入,支持自定义)
|
|
||||||
- 计划 config → 模板 config(整体迁移)
|
|
||||||
- 计划 editing_mode 从 config 中提取,默认 one_take
|
|
||||||
- 每个片段转换为模板片段配置:
|
|
||||||
- clip_type 直接映射
|
|
||||||
- order 保持不变
|
|
||||||
- duration → min_duration = max_duration = duration(固定时长)
|
|
||||||
- text_content → text_template
|
|
||||||
- transition_effect 直接映射
|
|
||||||
- playback_speed 等播放参数存入 config
|
|
||||||
- 不保留 asset_id(模板不绑定具体素材)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plan_id: 源剪辑计划 ID
|
|
||||||
name: 新模板名称
|
|
||||||
description: 模板描述
|
|
||||||
template_type: 模板类型,默认 custom(用户自定义)
|
|
||||||
preview_url: 预览图 URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 计划不存在或名称为空/重复
|
|
||||||
"""
|
|
||||||
# 1. 读取源计划
|
|
||||||
plan = self._plan_repo.get(plan_id)
|
|
||||||
if plan is None:
|
|
||||||
raise ValueError(f"剪辑计划不存在: {plan_id}")
|
|
||||||
|
|
||||||
# 2. 读取所有片段(按 order 排序)
|
|
||||||
clips = self._plan_clip_repo.list_by_plan(plan_id)
|
|
||||||
clips.sort(key=lambda c: c.order)
|
|
||||||
|
|
||||||
# 3. 提取 editing_mode
|
|
||||||
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
|
|
||||||
|
|
||||||
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
|
|
||||||
clean_name = name.strip()
|
|
||||||
if not clean_name:
|
|
||||||
raise ValueError("模板名称不能为空")
|
|
||||||
|
|
||||||
# 名称重复检查
|
|
||||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
|
||||||
for t in existing:
|
|
||||||
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
|
|
||||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
|
||||||
|
|
||||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
|
||||||
template_config = filter_plan_config_to_template(plan.config)
|
|
||||||
|
|
||||||
template = EditTemplate.create(
|
|
||||||
name=clean_name,
|
|
||||||
description=description,
|
|
||||||
template_type=template_type,
|
|
||||||
editing_mode=editing_mode,
|
|
||||||
config=template_config,
|
|
||||||
preview_url=preview_url,
|
|
||||||
)
|
|
||||||
created_template = self._template_repo.create(template)
|
|
||||||
logger.info(
|
|
||||||
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
|
|
||||||
plan_id,
|
|
||||||
created_template.id,
|
|
||||||
clean_name,
|
|
||||||
len(clips),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 转换每个片段为模板片段配置
|
|
||||||
created_configs: List[TemplateClipConfig] = []
|
|
||||||
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
|
|
||||||
created = self._clip_config_repo.create(clip_config_obj)
|
|
||||||
created_configs.append(created)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"template": created_template,
|
|
||||||
"clip_configs": created_configs,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 模板草稿(编辑器)相关 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_template_draft(self, template_id: str) -> Optional[Any]:
|
|
||||||
"""获取模板的草稿剪辑计划
|
|
||||||
|
|
||||||
通过 template_id + config.is_template_draft=True 标记查找。
|
|
||||||
每个模板有且仅有一个草稿计划。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_id: 模板 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EditPlan | None: 草稿剪辑计划,不存在则返回 None
|
|
||||||
"""
|
|
||||||
from packages.domain.edit_plan import EditPlan # noqa: F401
|
|
||||||
|
|
||||||
plans = self._plan_repo.list_by_template(template_id, limit=50)
|
|
||||||
for plan in plans:
|
|
||||||
config = plan.config or {}
|
|
||||||
if config.get("is_template_draft") is True:
|
|
||||||
return plan
|
|
||||||
return None
|
|
||||||
|
|
||||||
def create_template_draft(
|
|
||||||
self,
|
|
||||||
template_id: str,
|
|
||||||
user_id: str,
|
|
||||||
*,
|
|
||||||
project_id: str = "",
|
|
||||||
) -> Any:
|
|
||||||
"""基于模板创建草稿剪辑计划
|
|
||||||
|
|
||||||
草稿与普通剪辑计划的区别:
|
|
||||||
- config.is_template_draft = True
|
|
||||||
- 不绑定具体素材(空素材列表)
|
|
||||||
- 用于模板编辑器的编辑上下文
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_id: 模板 ID
|
|
||||||
user_id: 创建者用户 ID
|
|
||||||
project_id: 所属项目 ID(可选)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EditPlan: 创建的草稿剪辑计划
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 模板不存在,或草稿已存在
|
|
||||||
"""
|
|
||||||
from .plan_generator_service import PlanGeneratorService
|
|
||||||
|
|
||||||
# 检查模板是否存在
|
|
||||||
template = self.get_template_or_raise(template_id)
|
|
||||||
|
|
||||||
# 检查草稿是否已存在
|
|
||||||
existing = self.get_template_draft(template_id)
|
|
||||||
if existing is not None:
|
|
||||||
raise ValueError(f"模板草稿已存在: {template_id}")
|
|
||||||
|
|
||||||
# 读取模板片段配置
|
|
||||||
clip_configs = self.list_clip_configs(template_id)
|
|
||||||
|
|
||||||
# 基于模板生成计划(空素材)
|
|
||||||
generator = PlanGeneratorService(self._db)
|
|
||||||
result = generator.generate_from_template(
|
|
||||||
template=template,
|
|
||||||
clip_configs=clip_configs,
|
|
||||||
asset_ids=[],
|
|
||||||
project_id=project_id,
|
|
||||||
created_by_user_id=user_id,
|
|
||||||
name=f"{template.name} - 草稿",
|
|
||||||
)
|
|
||||||
plan = result["plan"]
|
|
||||||
|
|
||||||
# 标记为模板草稿
|
|
||||||
plan_config = plan.config or {}
|
|
||||||
plan_config["is_template_draft"] = True
|
|
||||||
plan.config = plan_config
|
|
||||||
plan = self._plan_repo.update(plan)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"创建模板草稿: template_id=%s draft_plan_id=%s user_id=%s",
|
|
||||||
template_id,
|
|
||||||
plan.id,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
def get_or_create_draft(
|
|
||||||
self,
|
|
||||||
template_id: str,
|
|
||||||
user_id: str,
|
|
||||||
*,
|
|
||||||
project_id: str = "",
|
|
||||||
) -> Any:
|
|
||||||
"""获取或创建模板草稿
|
|
||||||
|
|
||||||
首次访问模板编辑器时自动创建草稿。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_id: 模板 ID
|
|
||||||
user_id: 操作用户 ID
|
|
||||||
project_id: 所属项目 ID(可选)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EditPlan: 草稿剪辑计划
|
|
||||||
"""
|
|
||||||
draft = self.get_template_draft(template_id)
|
|
||||||
if draft is not None:
|
|
||||||
return draft
|
|
||||||
return self.create_template_draft(template_id, user_id, project_id=project_id)
|
|
||||||
|
|
||||||
def publish_template_from_draft(
|
|
||||||
self,
|
|
||||||
template_id: str,
|
|
||||||
draft_plan_id: str,
|
|
||||||
*,
|
|
||||||
change_note: str = "",
|
|
||||||
published_by: str = "",
|
|
||||||
) -> Any:
|
|
||||||
"""将草稿剪辑计划的内容发布(同步)到模板
|
|
||||||
|
|
||||||
将草稿的配置和片段结构同步到模板,相当于"保存"编辑结果。
|
|
||||||
使用事务保证一致性,失败则回滚。
|
|
||||||
|
|
||||||
同步规则:
|
|
||||||
- 草稿 plan.config → template.config(过滤掉草稿特有字段)
|
|
||||||
- 草稿 clips → template_clip_configs(先删后插)
|
|
||||||
- 草稿 editing_mode → template.editing_mode
|
|
||||||
- 不更新模板名称、描述等元信息(由专门的接口处理)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
template_id: 模板 ID
|
|
||||||
draft_plan_id: 草稿剪辑计划 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EditTemplate: 更新后的模板
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
|
||||||
"""
|
|
||||||
# 1. 校验模板和草稿
|
|
||||||
template = self.get_template_or_raise(template_id)
|
|
||||||
draft = self._plan_repo.get(draft_plan_id)
|
|
||||||
if draft is None:
|
|
||||||
raise ValueError(f"草稿计划不存在: {draft_plan_id}")
|
|
||||||
if draft.template_id != template_id:
|
|
||||||
raise ValueError(f"草稿不属于该模板: plan_template_id={draft.template_id}")
|
|
||||||
config = draft.config or {}
|
|
||||||
if config.get("is_template_draft") is not True:
|
|
||||||
raise ValueError("指定的计划不是模板草稿")
|
|
||||||
|
|
||||||
# 2. 读取草稿片段
|
|
||||||
draft_clips = self._plan_clip_repo.list_by_plan(draft_plan_id)
|
|
||||||
draft_clips.sort(key=lambda c: c.order)
|
|
||||||
|
|
||||||
# 3. 提取 editing_mode
|
|
||||||
editing_mode = config.get("editing_mode", "one_take")
|
|
||||||
|
|
||||||
# 4. 提取模板配置(去掉草稿/运行时字段)
|
|
||||||
template_config = filter_plan_config_to_template(draft.config)
|
|
||||||
|
|
||||||
# 5. 事务更新
|
|
||||||
try:
|
|
||||||
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
|
||||||
old_version = template.version or 1
|
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
|
||||||
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
|
||||||
|
|
||||||
old_snapshot = EditTemplateVersion.create(
|
|
||||||
template_id=template_id,
|
|
||||||
version=old_version,
|
|
||||||
name=template.name,
|
|
||||||
editing_mode=template.editing_mode,
|
|
||||||
config=dict(template.config) if template.config else {},
|
|
||||||
clip_configs=old_clip_snapshots,
|
|
||||||
change_note=f"v{old_version} 快照(发布前)",
|
|
||||||
published_by=published_by,
|
|
||||||
)
|
|
||||||
self._version_repo.create(old_snapshot)
|
|
||||||
|
|
||||||
# 更新模板元信息
|
|
||||||
template.config = template_config
|
|
||||||
template.editing_mode = editing_mode
|
|
||||||
template.bump_version() # 版本号 +1
|
|
||||||
updated_template = self._template_repo.update(template)
|
|
||||||
|
|
||||||
# 批量删除旧的片段配置(外层事务统一提交)
|
|
||||||
self._clip_config_repo.delete_by_template(template_id, commit=False)
|
|
||||||
|
|
||||||
# 创建新的片段配置
|
|
||||||
created_configs: list[TemplateClipConfig] = []
|
|
||||||
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
|
|
||||||
created = self._clip_config_repo.create(config_obj)
|
|
||||||
created_configs.append(created)
|
|
||||||
|
|
||||||
self._db.commit()
|
|
||||||
logger.info(
|
|
||||||
"发布模板草稿: template_id=%s draft_plan_id=%s clip_count=%d",
|
|
||||||
template_id,
|
|
||||||
draft_plan_id,
|
|
||||||
len(created_configs),
|
|
||||||
)
|
|
||||||
return updated_template
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
self._db.rollback()
|
|
||||||
logger.error(
|
|
||||||
"发布模板草稿失败: template_id=%s draft_plan_id=%s error=%s",
|
|
||||||
template_id,
|
|
||||||
draft_plan_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
# ── 版本历史与回滚 ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def list_template_versions(self, template_id: str, limit: int = 50) -> list[Any]:
|
|
||||||
"""列出模板的发布版本历史(按版本号倒序)"""
|
|
||||||
self.get_template_or_raise(template_id) # 校验存在性
|
|
||||||
return self._version_repo.list_by_template(template_id, limit=limit)
|
|
||||||
|
|
||||||
def rollback_to_version(self, template_id: str, version: int) -> Any:
|
|
||||||
"""回滚模板到指定历史版本
|
|
||||||
|
|
||||||
流程:
|
|
||||||
1. 校验目标版本存在
|
|
||||||
2. 保存当前状态为新版本快照(当前版本号)
|
|
||||||
3. 用目标版本的快照覆盖模板 config + clip_configs
|
|
||||||
4. 版本号 +1(回滚本身也是一次发布)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
EditTemplate: 回滚后的模板
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 模板/版本不存在
|
|
||||||
"""
|
|
||||||
template = self.get_template_or_raise(template_id)
|
|
||||||
|
|
||||||
# 1. 读取目标版本快照
|
|
||||||
target_version = self._version_repo.get_by_version(template_id, version)
|
|
||||||
if target_version is None:
|
|
||||||
raise ValueError(f"模板 {template_id} 不存在版本 {version}")
|
|
||||||
|
|
||||||
current_version = template.version or 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
|
||||||
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
|
||||||
|
|
||||||
current_snapshot = EditTemplateVersion.create(
|
|
||||||
template_id=template_id,
|
|
||||||
version=current_version,
|
|
||||||
name=template.name,
|
|
||||||
editing_mode=template.editing_mode,
|
|
||||||
config=dict(template.config) if template.config else {},
|
|
||||||
clip_configs=old_clip_snapshots,
|
|
||||||
change_note=f"v{current_version} 快照(回滚到 v{version} 前)",
|
|
||||||
published_by="rollback",
|
|
||||||
)
|
|
||||||
self._version_repo.create(current_snapshot)
|
|
||||||
|
|
||||||
# 3. 覆盖模板配置 + editing_mode + name + preview_url
|
|
||||||
template.config = dict(target_version.config)
|
|
||||||
template.editing_mode = target_version.editing_mode
|
|
||||||
if target_version.name:
|
|
||||||
template.name = target_version.name
|
|
||||||
template.bump_version() # 版本号 +1
|
|
||||||
updated_template = self._template_repo.update(template)
|
|
||||||
|
|
||||||
# 4. 先删后插 clip_configs(批量删除避免N+1)
|
|
||||||
from packages.adapters.sqlalchemy_impl.models import (
|
|
||||||
TemplateClipConfigModel,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._db.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.template_id == template_id).delete(
|
|
||||||
synchronize_session=False
|
|
||||||
)
|
|
||||||
|
|
||||||
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
|
|
||||||
self._clip_config_repo.create(config_obj)
|
|
||||||
|
|
||||||
self._db.commit()
|
|
||||||
logger.info(
|
|
||||||
"模板回滚成功: template_id=%s from_v=%d to_v=%d new_v=%d",
|
|
||||||
template_id,
|
|
||||||
current_version,
|
|
||||||
version,
|
|
||||||
updated_template.version,
|
|
||||||
)
|
|
||||||
return updated_template
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
self._db.rollback()
|
|
||||||
logger.error(
|
|
||||||
"模板回滚失败: template_id=%s target_version=%d error=%s",
|
|
||||||
template_id,
|
|
||||||
version,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|||||||
Executable → Regular
+213
-29
@@ -26,13 +26,7 @@ from packages.domain.edit_plan import EditPlan
|
|||||||
from packages.domain.edit_plan_clip import EditPlanClip
|
from packages.domain.edit_plan_clip import EditPlanClip
|
||||||
from packages.domain.edit_template import EditTemplate
|
from packages.domain.edit_template import EditTemplate
|
||||||
from packages.domain.editing_mode import EditingMode
|
from packages.domain.editing_mode import EditingMode
|
||||||
from packages.domain.plan_generator_utils import (
|
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||||
create_clips_from_configs,
|
|
||||||
distribute_assets,
|
|
||||||
generate_default_clips,
|
|
||||||
map_clip_types_for_mode,
|
|
||||||
)
|
|
||||||
from packages.domain.template_clip_config import TemplateClipConfig
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -105,11 +99,6 @@ class PlanGeneratorService:
|
|||||||
# 3. 生成片段列表
|
# 3. 生成片段列表
|
||||||
if clip_configs:
|
if clip_configs:
|
||||||
clips = self._create_clips_from_configs(plan.id, clip_configs)
|
clips = self._create_clips_from_configs(plan.id, clip_configs)
|
||||||
# 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等),
|
|
||||||
# 但 PIP / VOICE_PIP 模式需要特定的 clip_type(overlay/background/corner_voice/b_roll)
|
|
||||||
# 才能让素材分配和渲染分层正确工作。
|
|
||||||
# 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。
|
|
||||||
self._map_clip_types_for_mode(clips, editing_mode)
|
|
||||||
else:
|
else:
|
||||||
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
|
clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids))
|
||||||
|
|
||||||
@@ -157,8 +146,8 @@ class PlanGeneratorService:
|
|||||||
plan_config: dict[str, Any] = {
|
plan_config: dict[str, Any] = {
|
||||||
"editing_mode": editing_mode,
|
"editing_mode": editing_mode,
|
||||||
}
|
}
|
||||||
# 继承模板的 cover/title/subtitle/bgm/export/filter 配置
|
# 继承模板的 cover/title/subtitle/bgm 配置
|
||||||
for key in ("cover", "title", "subtitle", "bgm", "export", "filter"):
|
for key in ("cover", "title", "subtitle", "bgm"):
|
||||||
if key in template_config:
|
if key in template_config:
|
||||||
plan_config[key] = template_config[key]
|
plan_config[key] = template_config[key]
|
||||||
|
|
||||||
@@ -169,18 +158,42 @@ class PlanGeneratorService:
|
|||||||
plan_id: str,
|
plan_id: str,
|
||||||
clip_configs: List[TemplateClipConfig],
|
clip_configs: List[TemplateClipConfig],
|
||||||
) -> List[EditPlanClip]:
|
) -> List[EditPlanClip]:
|
||||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
|
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
|
||||||
|
clips: List[EditPlanClip] = []
|
||||||
|
# 按 order 排序
|
||||||
|
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||||
|
|
||||||
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
|
for cfg in sorted_configs:
|
||||||
"""
|
# 计算时长:取 min_duration 和 max_duration 的中间值
|
||||||
return create_clips_from_configs(plan_id, clip_configs)
|
if cfg.min_duration > 0 and cfg.max_duration > 0:
|
||||||
|
duration = (cfg.min_duration + cfg.max_duration) / 2
|
||||||
|
elif cfg.min_duration > 0:
|
||||||
|
duration = cfg.min_duration
|
||||||
|
elif cfg.max_duration > 0:
|
||||||
|
duration = cfg.max_duration
|
||||||
|
else:
|
||||||
|
duration = _DEFAULT_CLIP_DURATION
|
||||||
|
|
||||||
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
# clip_type 可能是枚举或字符串
|
||||||
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
|
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||||||
|
|
||||||
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
|
# transition_effect 可能是枚举或字符串
|
||||||
"""
|
transition = (
|
||||||
map_clip_types_for_mode(clips, editing_mode)
|
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||||||
|
)
|
||||||
|
|
||||||
|
clip = EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=clip_type,
|
||||||
|
order=cfg.order,
|
||||||
|
template_clip_config_id=cfg.id,
|
||||||
|
text_content=getattr(cfg, "text_template", "") or "",
|
||||||
|
duration=duration,
|
||||||
|
transition_effect=transition or "cut",
|
||||||
|
)
|
||||||
|
clips.append(clip)
|
||||||
|
|
||||||
|
return clips
|
||||||
|
|
||||||
def _generate_default_clips(
|
def _generate_default_clips(
|
||||||
self,
|
self,
|
||||||
@@ -188,11 +201,101 @@ class PlanGeneratorService:
|
|||||||
editing_mode: str,
|
editing_mode: str,
|
||||||
asset_count: int,
|
asset_count: int,
|
||||||
) -> List[EditPlanClip]:
|
) -> List[EditPlanClip]:
|
||||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
|
||||||
|
|
||||||
委托给 plan_generator_utils.generate_default_clips 纯函数。
|
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||||
|
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||||
|
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||||
|
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||||
"""
|
"""
|
||||||
return generate_default_clips(plan_id, editing_mode, asset_count)
|
n = max(asset_count, 1)
|
||||||
|
clips: List[EditPlanClip] = []
|
||||||
|
order = 0
|
||||||
|
|
||||||
|
if editing_mode == EditingMode.PIP.value:
|
||||||
|
# 1 个 main(全屏背景)
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=ClipType.MAIN.value,
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
# 剩余为 overlay
|
||||||
|
for i in range(1, n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="overlay",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||||
|
# N 个 main clips(B-roll)
|
||||||
|
for i in range(n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=ClipType.MAIN.value,
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
config={"role": "b_roll"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||||
|
# 1 个 background
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="background",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
# 1 个 corner_voice
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="corner_voice",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
# 剩余为 b_roll
|
||||||
|
for i in range(2, n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="b_roll",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ONE_TAKE: N 个 main clips
|
||||||
|
for i in range(n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=ClipType.MAIN.value,
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
return clips
|
||||||
|
|
||||||
def _distribute_assets(
|
def _distribute_assets(
|
||||||
self,
|
self,
|
||||||
@@ -200,8 +303,89 @@ class PlanGeneratorService:
|
|||||||
asset_ids: List[str],
|
asset_ids: List[str],
|
||||||
editing_mode: str,
|
editing_mode: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
|
||||||
|
|
||||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
分配策略:
|
||||||
|
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||||
|
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
|
||||||
|
- VOICE_OVER: 素材→main clips (B-roll)
|
||||||
|
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||||
"""
|
"""
|
||||||
distribute_assets(clips, asset_ids, editing_mode)
|
if not asset_ids or not clips:
|
||||||
|
return
|
||||||
|
|
||||||
|
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||||
|
self._distribute_one_take(clips, asset_ids)
|
||||||
|
elif editing_mode == EditingMode.PIP.value:
|
||||||
|
self._distribute_pip(clips, asset_ids)
|
||||||
|
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||||
|
self._distribute_voice_over(clips, asset_ids)
|
||||||
|
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||||
|
self._distribute_voice_pip(clips, asset_ids)
|
||||||
|
else:
|
||||||
|
# 未知模式,退化为 one_take
|
||||||
|
self._distribute_one_take(clips, asset_ids)
|
||||||
|
|
||||||
|
def _distribute_one_take(
|
||||||
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
|
||||||
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
|
for i, clip in enumerate(main_clips):
|
||||||
|
if i < len(asset_ids):
|
||||||
|
clip.assign_asset(asset_ids[i])
|
||||||
|
|
||||||
|
def _distribute_pip(
|
||||||
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
|
||||||
|
# 第1个素材 → main clip
|
||||||
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
|
if main_clips and asset_ids:
|
||||||
|
main_clips[0].assign_asset(asset_ids[0])
|
||||||
|
|
||||||
|
# 其余素材 → overlay clips
|
||||||
|
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||||||
|
remaining = asset_ids[1:]
|
||||||
|
for i, clip in enumerate(overlay_clips):
|
||||||
|
if i < len(remaining):
|
||||||
|
clip.assign_asset(remaining[i])
|
||||||
|
|
||||||
|
def _distribute_voice_over(
|
||||||
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""VOICE_OVER: 素材→main clips (B-roll)"""
|
||||||
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
|
for i, clip in enumerate(main_clips):
|
||||||
|
if i < len(asset_ids):
|
||||||
|
clip.assign_asset(asset_ids[i])
|
||||||
|
|
||||||
|
def _distribute_voice_pip(
|
||||||
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
|
||||||
|
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||||||
|
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
|
||||||
|
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
|
||||||
|
|
||||||
|
# 第1个素材 → background
|
||||||
|
if bg_clips and len(asset_ids) > 0:
|
||||||
|
bg_clips[0].assign_asset(asset_ids[0])
|
||||||
|
|
||||||
|
# 第2个素材 → corner_voice
|
||||||
|
if corner_clips and len(asset_ids) > 1:
|
||||||
|
corner_clips[0].assign_asset(asset_ids[1])
|
||||||
|
|
||||||
|
# 其余素材 → b_roll
|
||||||
|
remaining = asset_ids[2:]
|
||||||
|
for i, clip in enumerate(broll_clips):
|
||||||
|
if i < len(remaining):
|
||||||
|
clip.assign_asset(remaining[i])
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"""SmartAssetSelector — 智能素材选择服务.
|
|
||||||
|
|
||||||
根据多维度评分从素材库中自动选择最优视频素材,
|
|
||||||
用于一键生成等需要自动选取素材的场景。
|
|
||||||
|
|
||||||
评分维度(加权求和,总分 0-1):
|
|
||||||
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
|
|
||||||
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
|
|
||||||
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
|
|
||||||
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
|
|
||||||
|
|
||||||
特性:
|
|
||||||
- 最低质量分门槛:自动过滤低质量素材
|
|
||||||
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
|
||||||
- 兼容全部模式:素材库模式和项目模式都可用
|
|
||||||
|
|
||||||
纯逻辑部分已抽离到 packages.domain.asset_scoring。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
|
|
||||||
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
|
|
||||||
from packages.domain.asset_scoring import (
|
|
||||||
AssetScoreDetail,
|
|
||||||
SmartSelectResult,
|
|
||||||
diverse_selection,
|
|
||||||
filter_candidates,
|
|
||||||
score_asset_detail,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SmartAssetSelector:
|
|
||||||
"""智能素材选择器.
|
|
||||||
|
|
||||||
从一组素材中按综合评分选择最优的 N 个,
|
|
||||||
同时保证时长分布的多样性。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
min_quality_score: float = 30.0,
|
|
||||||
target_width: int = 1920,
|
|
||||||
target_height: int = 1080,
|
|
||||||
):
|
|
||||||
self.min_quality_score = min_quality_score
|
|
||||||
self.target_width = target_width
|
|
||||||
self.target_height = target_height
|
|
||||||
|
|
||||||
# ── 公开方法 ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def select(
|
|
||||||
self,
|
|
||||||
assets: list,
|
|
||||||
count: int = 0,
|
|
||||||
*,
|
|
||||||
ensure_diversity: bool = True,
|
|
||||||
) -> SmartSelectResult:
|
|
||||||
"""从素材列表中智能选择最优素材.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
assets: Asset 实体列表(需要有 id/quality_score/width/height/duration/file_size 属性)
|
|
||||||
count: 选取数量,0 表示全部符合条件的
|
|
||||||
ensure_diversity: 是否保证时长多样性(默认开启)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SmartSelectResult 选择结果
|
|
||||||
"""
|
|
||||||
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
|
|
||||||
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
return SmartSelectResult(
|
|
||||||
selected_ids=[],
|
|
||||||
total_candidates=0,
|
|
||||||
filtered_out=filtered_out,
|
|
||||||
avg_score=0.0,
|
|
||||||
details=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 对每个候选素材评分
|
|
||||||
scored: list[AssetScoreDetail] = []
|
|
||||||
for asset in candidates:
|
|
||||||
detail = score_asset_detail(
|
|
||||||
asset_id=asset.id,
|
|
||||||
quality=getattr(asset, "quality_score", None),
|
|
||||||
width=getattr(asset, "width", None),
|
|
||||||
height=getattr(asset, "height", None),
|
|
||||||
duration=getattr(asset, "duration", None),
|
|
||||||
file_size=getattr(asset, "file_size", 0) or 0,
|
|
||||||
target_width=self.target_width,
|
|
||||||
target_height=self.target_height,
|
|
||||||
)
|
|
||||||
scored.append(detail)
|
|
||||||
|
|
||||||
# 3. 按总分降序排列
|
|
||||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
|
||||||
|
|
||||||
# 4. 多样性选择(如果需要且数量有限制)
|
|
||||||
if ensure_diversity and count > 0 and len(scored) > count:
|
|
||||||
selected = diverse_selection(scored, count)
|
|
||||||
else:
|
|
||||||
# 无数量限制或不要求多样性,直接按排名取
|
|
||||||
selected = scored if count <= 0 else scored[:count]
|
|
||||||
|
|
||||||
avg_score = sum(d.total_score for d in selected) / len(selected) if selected else 0.0
|
|
||||||
|
|
||||||
result = SmartSelectResult(
|
|
||||||
selected_ids=[d.asset_id for d in selected],
|
|
||||||
total_candidates=len(candidates),
|
|
||||||
filtered_out=filtered_out,
|
|
||||||
avg_score=avg_score,
|
|
||||||
details=selected,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"智能素材选择完成: 候选=%d, 过滤=%d, 选中=%d, 平均分=%.3f",
|
|
||||||
result.total_candidates,
|
|
||||||
result.filtered_out,
|
|
||||||
len(result.selected_ids),
|
|
||||||
result.avg_score,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
|
|
||||||
|
|
||||||
def _score_asset(self, asset) -> AssetScoreDetail:
|
|
||||||
"""对单个素材进行多维度评分(向后兼容)."""
|
|
||||||
return score_asset_detail(
|
|
||||||
asset_id=asset.id,
|
|
||||||
quality=getattr(asset, "quality_score", None),
|
|
||||||
width=getattr(asset, "width", None),
|
|
||||||
height=getattr(asset, "height", None),
|
|
||||||
duration=getattr(asset, "duration", None),
|
|
||||||
file_size=getattr(asset, "file_size", 0) or 0,
|
|
||||||
target_width=self.target_width,
|
|
||||||
target_height=self.target_height,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
|
||||||
"""分辨率评分(向后兼容)."""
|
|
||||||
from packages.domain.asset_scoring import score_resolution
|
|
||||||
|
|
||||||
return score_resolution(width, height, self.target_width, self.target_height)
|
|
||||||
|
|
||||||
def _score_duration(self, duration: float | None) -> float:
|
|
||||||
"""时长评分(向后兼容)."""
|
|
||||||
from packages.domain.asset_scoring import score_duration
|
|
||||||
|
|
||||||
return score_duration(duration)
|
|
||||||
|
|
||||||
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
|
||||||
"""码率评分(向后兼容)."""
|
|
||||||
from packages.domain.asset_scoring import score_bitrate
|
|
||||||
|
|
||||||
return score_bitrate(file_size, duration)
|
|
||||||
|
|
||||||
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
|
||||||
"""多样性选择(向后兼容)."""
|
|
||||||
return diverse_selection(scored, count)
|
|
||||||
Executable → Regular
+221
-31
@@ -29,33 +29,47 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
|||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlanStatus
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.video_filter_builder import (
|
from packages.domain.template_clip_config import TransitionEffect
|
||||||
DEFAULT_FPS,
|
|
||||||
DEFAULT_OUTPUT_HEIGHT,
|
|
||||||
DEFAULT_OUTPUT_WIDTH,
|
|
||||||
DEFAULT_TRANSITION_DURATION,
|
|
||||||
ClipFilterChain,
|
|
||||||
build_clip_filter,
|
|
||||||
)
|
|
||||||
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
|
|
||||||
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
|
|
||||||
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
|
|
||||||
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
|
|
||||||
from packages.domain.video_filter_builder import has_audio as _has_audio_func
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
# 实际定义已迁移至 packages/domain/video_filter_builder.py
|
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT_WIDTH = 1280
|
||||||
|
DEFAULT_OUTPUT_HEIGHT = 720
|
||||||
|
DEFAULT_FPS = 25
|
||||||
DEFAULT_CODEC = "libx264"
|
DEFAULT_CODEC = "libx264"
|
||||||
DEFAULT_CRF = 23
|
DEFAULT_CRF = 23
|
||||||
DEFAULT_PRESET = "medium"
|
DEFAULT_PRESET = "medium"
|
||||||
|
|
||||||
|
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
||||||
|
_XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||||
|
TransitionEffect.FADE: "fade",
|
||||||
|
TransitionEffect.SLIDE_LEFT: "slideleft",
|
||||||
|
TransitionEffect.SLIDE_RIGHT: "slideright",
|
||||||
|
TransitionEffect.DISSOLVE: "dissolve",
|
||||||
|
TransitionEffect.WIPE: "wipeleft",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 转场默认时长(秒)
|
||||||
|
DEFAULT_TRANSITION_DURATION = 0.5
|
||||||
|
|
||||||
|
|
||||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClipFilterChain:
|
||||||
|
"""单个片段的滤镜链描述。"""
|
||||||
|
|
||||||
|
clip_id: str
|
||||||
|
input_index: int
|
||||||
|
video_label: str
|
||||||
|
audio_label: str | None
|
||||||
|
filters: list[str]
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ComposeCommand:
|
class ComposeCommand:
|
||||||
"""完整的 FFmpeg 合成命令描述。"""
|
"""完整的 FFmpeg 合成命令描述。"""
|
||||||
@@ -205,7 +219,7 @@ class VideoComposeService:
|
|||||||
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
|
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
|
||||||
|
|
||||||
滤镜链逻辑:
|
滤镜链逻辑:
|
||||||
- 每个片段:scale → crop → fps → setpts → trim → atrim
|
- 每个片段:scale → crop → setpts → trim → atrim
|
||||||
- 多片段之间:concat 滤镜 或 xfade 转场
|
- 多片段之间:concat 滤镜 或 xfade 转场
|
||||||
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
|
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
|
||||||
"""
|
"""
|
||||||
@@ -387,8 +401,52 @@ class VideoComposeService:
|
|||||||
output_height: int,
|
output_height: int,
|
||||||
fps: int,
|
fps: int,
|
||||||
) -> ClipFilterChain:
|
) -> ClipFilterChain:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
|
"""为单个片段构建滤镜链。
|
||||||
return build_clip_filter(clip, input_index, output_width, output_height, fps)
|
|
||||||
|
滤镜顺序:
|
||||||
|
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
||||||
|
2. crop — 居中裁剪到目标分辨率
|
||||||
|
3. setpts — 重置时间戳 + 偏移
|
||||||
|
4. trim — 视频时长裁剪
|
||||||
|
5. atrim — 音频时长裁剪(如有音频流)
|
||||||
|
"""
|
||||||
|
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
||||||
|
start = clip.start_time
|
||||||
|
|
||||||
|
filters: list[str] = []
|
||||||
|
|
||||||
|
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop)
|
||||||
|
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
|
||||||
|
|
||||||
|
# 2. crop: 居中裁剪
|
||||||
|
filters.append(f"crop={output_width}:{output_height}")
|
||||||
|
|
||||||
|
# 3. setpts: 重置时间戳
|
||||||
|
if start > 0:
|
||||||
|
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
||||||
|
else:
|
||||||
|
filters.append("setpts=PTS-STARTPTS")
|
||||||
|
|
||||||
|
# 4. trim: 视频时长
|
||||||
|
filters.append(f"trim=0:{duration}")
|
||||||
|
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
|
||||||
|
|
||||||
|
video_label = f"v{input_index}"
|
||||||
|
|
||||||
|
# 5. 音频标签:仅当片段类型可能有音频时才设置
|
||||||
|
# title/subtitle 是纯文字/图片卡片,没有音频流
|
||||||
|
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
||||||
|
has_audio_stream = clip_type not in ("title", "subtitle")
|
||||||
|
audio_label = f"a{input_index}" if has_audio_stream else None
|
||||||
|
|
||||||
|
return ClipFilterChain(
|
||||||
|
clip_id=clip.id,
|
||||||
|
input_index=input_index,
|
||||||
|
video_label=video_label,
|
||||||
|
audio_label=audio_label,
|
||||||
|
filters=filters,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_filter_complex(
|
def _build_filter_complex(
|
||||||
@@ -398,30 +456,96 @@ class VideoComposeService:
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_filter_complex。"""
|
"""构建完整的 filter_complex 字符串。
|
||||||
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
|
|
||||||
|
策略:
|
||||||
|
- 单片段:直接输出
|
||||||
|
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
||||||
|
- 多片段 + 有转场:使用 xfade 滤镜链
|
||||||
|
|
||||||
|
返回 (filter_complex_string, estimated_total_duration)。
|
||||||
|
"""
|
||||||
|
n = len(clip_chains)
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
return "", 0.0
|
||||||
|
|
||||||
|
# ── 单片段 ─────────────────────────────────────────────────────
|
||||||
|
if n == 1:
|
||||||
|
chain = clip_chains[0]
|
||||||
|
filter_str = _chain_filters(chain.filters, chain.video_label)
|
||||||
|
# 音频
|
||||||
|
if chain.audio_label:
|
||||||
|
filter_str += f";[0:a]{chain.audio_label}"
|
||||||
|
total_duration = chain.duration
|
||||||
|
return filter_str, total_duration
|
||||||
|
|
||||||
|
# ── 检查是否有转场 ─────────────────────────────────────────────
|
||||||
|
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||||||
|
|
||||||
|
if not has_transitions:
|
||||||
|
return _build_concat_filter(clip_chains)
|
||||||
|
|
||||||
|
# ── 有转场:使用 xfade ─────────────────────────────────────────
|
||||||
|
return _build_xfade_filter(
|
||||||
|
clip_chains=clip_chains,
|
||||||
|
transition_duration=transition_duration,
|
||||||
|
transitions=transitions,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||||
"""向后兼容:委托给 video_filter_builder.has_audio。"""
|
"""是否有任何片段包含音频流。"""
|
||||||
return _has_audio_func(clip_chains)
|
return any(c.audio_label is not None for c in clip_chains)
|
||||||
|
|
||||||
|
|
||||||
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
|
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
||||||
# 实际实现已迁移至 packages/domain/video_filter_builder.py
|
|
||||||
# 保留此处别名以兼容现有测试与调用方
|
|
||||||
|
|
||||||
|
|
||||||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||||||
"""向后兼容:委托给 video_filter_builder.chain_filters。"""
|
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
||||||
return _chain_filters_func(filters, output_label)
|
filter_body = ",".join(filters)
|
||||||
|
return f"[0:v]{filter_body}[{output_label}]"
|
||||||
|
|
||||||
|
|
||||||
def _build_concat_filter(
|
def _build_concat_filter(
|
||||||
clip_chains: list[ClipFilterChain],
|
clip_chains: list[ClipFilterChain],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
|
"""构建 concat 滤镜(无转场,高效拼接)。
|
||||||
return _build_concat_filter_func(clip_chains)
|
|
||||||
|
格式:
|
||||||
|
[0:v]filters[v0]; [1:v]filters[v1]; ...
|
||||||
|
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
|
||||||
|
"""
|
||||||
|
n = len(clip_chains)
|
||||||
|
parts: list[str] = []
|
||||||
|
total_duration = 0.0
|
||||||
|
|
||||||
|
# 每个片段的滤镜链
|
||||||
|
for idx, chain in enumerate(clip_chains):
|
||||||
|
filter_body = ",".join(chain.filters)
|
||||||
|
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||||
|
total_duration += chain.duration
|
||||||
|
|
||||||
|
# concat 滤镜
|
||||||
|
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
||||||
|
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
||||||
|
parts.append(concat_filter)
|
||||||
|
|
||||||
|
# 音频 concat(如果有)
|
||||||
|
audio_parts: list[str] = []
|
||||||
|
for idx, chain in enumerate(clip_chains):
|
||||||
|
if chain.audio_label:
|
||||||
|
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
|
||||||
|
|
||||||
|
if audio_parts:
|
||||||
|
parts.extend(audio_parts)
|
||||||
|
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
||||||
|
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
||||||
|
if audio_count > 0:
|
||||||
|
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
||||||
|
|
||||||
|
return ";".join(parts), total_duration
|
||||||
|
|
||||||
|
|
||||||
def _build_xfade_filter(
|
def _build_xfade_filter(
|
||||||
@@ -429,5 +553,71 @@ def _build_xfade_filter(
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
|
"""构建 xfade 转场滤镜链。
|
||||||
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
|
|
||||||
|
每两个相邻片段之间插入 xfade 转场。
|
||||||
|
offset = 前一个片段的累积时长 - 转场时长。
|
||||||
|
|
||||||
|
格式(2 片段):
|
||||||
|
[0:v]filters[v0]; [1:v]filters[v1];
|
||||||
|
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
|
||||||
|
|
||||||
|
格式(3+ 片段):
|
||||||
|
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
|
||||||
|
"""
|
||||||
|
n = len(clip_chains)
|
||||||
|
parts: list[str] = []
|
||||||
|
total_duration = 0.0
|
||||||
|
|
||||||
|
# 每个片段的滤镜链
|
||||||
|
for idx, chain in enumerate(clip_chains):
|
||||||
|
filter_body = ",".join(chain.filters)
|
||||||
|
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||||
|
total_duration += chain.duration
|
||||||
|
|
||||||
|
# xfade 链
|
||||||
|
if n == 1:
|
||||||
|
# 单片段不需要 xfade
|
||||||
|
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
||||||
|
return ";".join(parts), total_duration
|
||||||
|
|
||||||
|
# 计算每个转场的 offset
|
||||||
|
cumulative = 0.0
|
||||||
|
prev_label = clip_chains[0].video_label
|
||||||
|
|
||||||
|
for i in range(1, n):
|
||||||
|
cumulative += clip_chains[i - 1].duration
|
||||||
|
offset = max(0.0, cumulative - transition_duration * i)
|
||||||
|
|
||||||
|
# 获取转场类型
|
||||||
|
transition = transitions[i] if i < len(transitions) else "cut"
|
||||||
|
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
|
||||||
|
|
||||||
|
if i == n - 1:
|
||||||
|
# 最后一个转场,输出到 [outv]
|
||||||
|
out_label = "outv"
|
||||||
|
else:
|
||||||
|
out_label = f"xf{i}"
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f"[{prev_label}][{clip_chains[i].video_label}]"
|
||||||
|
f"xfade=transition={xfade_transition}"
|
||||||
|
f":duration={transition_duration}"
|
||||||
|
f":offset={offset:.3f}"
|
||||||
|
f"[{out_label}]"
|
||||||
|
)
|
||||||
|
prev_label = out_label
|
||||||
|
|
||||||
|
# 总时长需要减去转场重叠部分
|
||||||
|
total_duration -= transition_duration * (n - 1)
|
||||||
|
|
||||||
|
# 音频 crossfade(简化处理:使用 adelay + amix)
|
||||||
|
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
|
||||||
|
if len(audio_labels) >= 2:
|
||||||
|
# 简单拼接音频(不做 crossfade)
|
||||||
|
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
|
||||||
|
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
|
||||||
|
elif len(audio_labels) == 1:
|
||||||
|
parts.append(f"[{audio_labels[0]}]acopy[outa]")
|
||||||
|
|
||||||
|
return ";".join(parts), max(0.0, total_duration)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "service": "api"}
|
||||||
Executable → Regular
+6
-4
@@ -8,14 +8,16 @@ module.exports = {
|
|||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"prettier", // 关掉与 Prettier 冲突的 ESLint 规则
|
|
||||||
],
|
],
|
||||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
plugins: ["react-refresh"],
|
plugins: ["react-refresh"],
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": false,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"useTabs": false,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"bracketSameLine": false
|
|
||||||
}
|
|
||||||
+4
-4
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
|
|||||||
使用 Zustand 创建 Store:
|
使用 Zustand 创建 Store:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { create } from "zustand"
|
import { create } from "zustand";
|
||||||
|
|
||||||
interface MyStore {
|
interface MyStore {
|
||||||
data: any
|
data: any;
|
||||||
setData: (data: any) => void
|
setData: (data: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMyStore = create<MyStore>((set) => ({
|
export const useMyStore = create<MyStore>((set) => ({
|
||||||
data: null,
|
data: null,
|
||||||
setData: (data) => set({ data }),
|
setData: (data) => set({ data }),
|
||||||
}))
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|||||||
+399
-240
@@ -5,29 +5,33 @@
|
|||||||
* 删除素材、批量删除、空状态
|
* 删除素材、批量删除、空状态
|
||||||
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
|
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
|
||||||
*/
|
*/
|
||||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
const PASSWORD = "SmokePass123!"
|
const PASSWORD = "SmokePass123!";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||||
|
? apiBase.slice(0, -"/api/v1".length)
|
||||||
|
: "";
|
||||||
|
|
||||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
const routeBrowserApiToTestApi = async (
|
||||||
if (!apiOrigin) return
|
page: import("@playwright/test").Page,
|
||||||
|
) => {
|
||||||
|
if (!apiOrigin) return;
|
||||||
await page.route("**/api/v1/**", async (route) => {
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
const sourceUrl = new URL(route.request().url())
|
const sourceUrl = new URL(route.request().url());
|
||||||
const response = await route.fetch({
|
const response = await route.fetch({
|
||||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||||
})
|
});
|
||||||
await route.fulfill({ response })
|
await route.fulfill({ response });
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
function uniqueEmail(prefix: string): string {
|
function uniqueEmail(prefix: string): string {
|
||||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueUsername(prefix: string): string {
|
function uniqueUsername(prefix: string): string {
|
||||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||||
@@ -40,30 +44,30 @@ async function loginWithRetry(
|
|||||||
for (let i = 0; i <= maxRetries; i++) {
|
for (let i = 0; i <= maxRetries; i++) {
|
||||||
const response = await request.post(`${apiBase}/auth/login`, {
|
const response = await request.post(`${apiBase}/auth/login`, {
|
||||||
data: { email, password },
|
data: { email, password },
|
||||||
})
|
});
|
||||||
if (response.status() !== 429) return response
|
if (response.status() !== 429) return response;
|
||||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||||
await new Promise((r) => setTimeout(r, 65000))
|
await new Promise((r) => setTimeout(r, 65000));
|
||||||
}
|
}
|
||||||
return request.post(`${apiBase}/auth/login`, {
|
return request.post(`${apiBase}/auth/login`, {
|
||||||
data: { email, password },
|
data: { email, password },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||||
const email = uniqueEmail(label)
|
const email = uniqueEmail(label);
|
||||||
const username = uniqueUsername(label)
|
const username = uniqueUsername(label);
|
||||||
|
|
||||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||||
})
|
});
|
||||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||||
const regData = await reg.json()
|
const regData = await reg.json();
|
||||||
|
|
||||||
const login = await loginWithRetry(request, email, PASSWORD)
|
const login = await loginWithRetry(request, email, PASSWORD);
|
||||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||||
const loginData = await login.json()
|
const loginData = await login.json();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||||
@@ -71,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
|||||||
username,
|
username,
|
||||||
userId: regData.user_id,
|
userId: regData.user_id,
|
||||||
accessToken: loginData.access_token,
|
accessToken: loginData.access_token,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建项目 */
|
/** 创建项目 */
|
||||||
@@ -86,10 +90,10 @@ async function createProject(
|
|||||||
name: `Assets Test Proj ${suffix}`,
|
name: `Assets Test Proj ${suffix}`,
|
||||||
description: "E2E assets test",
|
description: "E2E assets test",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
|
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||||
const data = await resp.json()
|
const data = await resp.json();
|
||||||
return data.id
|
return data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建素材库 */
|
/** 创建素材库 */
|
||||||
@@ -103,10 +107,10 @@ async function createLibrary(
|
|||||||
const resp = await request.post(`${apiBase}/asset-libraries`, {
|
const resp = await request.post(`${apiBase}/asset-libraries`, {
|
||||||
headers,
|
headers,
|
||||||
data: { project_id: projectId, name, kind },
|
data: { project_id: projectId, name, kind },
|
||||||
})
|
});
|
||||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy()
|
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
|
||||||
const data = await resp.json()
|
const data = await resp.json();
|
||||||
return data.id
|
return data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建素材记录 */
|
/** 创建素材记录 */
|
||||||
@@ -132,10 +136,10 @@ async function createAsset(
|
|||||||
uploaded_by_user_id: userId,
|
uploaded_by_user_id: userId,
|
||||||
metadata: { duration: 15.5, resolution: "1080p" },
|
metadata: { duration: 15.5, resolution: "1080p" },
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy()
|
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
|
||||||
const data = await resp.json()
|
const data = await resp.json();
|
||||||
return data.id
|
return data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在浏览器中设置登录态 */
|
/** 在浏览器中设置登录态 */
|
||||||
@@ -146,14 +150,14 @@ async function setupAuthInBrowser(
|
|||||||
) {
|
) {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ token, user }) => {
|
({ token, user }) => {
|
||||||
localStorage.setItem("access_token", token)
|
localStorage.setItem("access_token", token);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"auth-storage",
|
"auth-storage",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
state: { user, isAuthenticated: true },
|
state: { user, isAuthenticated: true },
|
||||||
version: 0,
|
version: 0,
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token,
|
token,
|
||||||
@@ -167,400 +171,551 @@ async function setupAuthInBrowser(
|
|||||||
email_verified: true,
|
email_verified: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("素材库页面 - 完整交互测试", () => {
|
test.describe("素材库页面 - 完整交互测试", () => {
|
||||||
test.describe.configure({ timeout: 180_000 })
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
// ─── 页面加载 ──────────────────────────────────────
|
// ─── 页面加载 ──────────────────────────────────────
|
||||||
|
|
||||||
test("素材库列表页面加载", async ({ page, request }) => {
|
test("素材库列表页面加载", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-load");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-load",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
await createLibrary(request, headers, projectId, "默认视频库", "video")
|
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
|
|
||||||
// 页面布局容器
|
// 页面布局容器
|
||||||
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 左侧素材库列表
|
// 左侧素材库列表
|
||||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible()
|
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||||
|
|
||||||
// 右侧内容区(上传区 + 筛选 + 素材网格)
|
// 右侧内容区(上传区 + 筛选 + 素材网格)
|
||||||
await expect(page.locator(".xx-assets-content")).toBeVisible()
|
await expect(page.locator(".xx-assets-content")).toBeVisible();
|
||||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible()
|
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
|
||||||
await expect(page.locator(".xx-assets-filters")).toBeVisible()
|
await expect(page.locator(".xx-assets-filters")).toBeVisible();
|
||||||
|
|
||||||
// 无错误提示
|
// 无错误提示
|
||||||
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
|
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 创建素材库 ────────────────────────────────────
|
// ─── 创建素材库 ────────────────────────────────────
|
||||||
|
|
||||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-create");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-create",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
await createLibrary(request, headers, projectId, "初始库", "video")
|
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 点击新建素材库
|
// 点击新建素材库
|
||||||
await page.locator(".xx-asset-library-add").click()
|
await page.locator(".xx-asset-library-add").click();
|
||||||
|
|
||||||
// 弹窗出现
|
// 弹窗出现
|
||||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" })
|
const modal = page
|
||||||
await expect(modal).toBeVisible()
|
.locator(".ant-modal-content")
|
||||||
|
.filter({ hasText: "新建素材库" });
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
// 填写表单
|
// 填写表单
|
||||||
const newLibName = `E2E 新建库 ${Date.now()}`
|
const newLibName = `E2E 新建库 ${Date.now()}`;
|
||||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName)
|
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
|
||||||
// 类型选择默认是 video,保持即可
|
// 类型选择默认是 video,保持即可
|
||||||
|
|
||||||
// 监听创建请求
|
// 监听创建请求
|
||||||
const createPromise = page.waitForResponse(
|
const createPromise = page.waitForResponse(
|
||||||
(resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST",
|
(resp) =>
|
||||||
|
resp.url().includes("/asset-libraries") &&
|
||||||
|
resp.request().method() === "POST",
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
)
|
);
|
||||||
|
|
||||||
// 点击创建
|
// 点击创建
|
||||||
await modal.getByRole("button", { name: "创建" }).click()
|
await modal.getByRole("button", { name: "创建" }).click();
|
||||||
|
|
||||||
const resp = await createPromise
|
const resp = await createPromise;
|
||||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy()
|
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
|
||||||
|
|
||||||
// 新素材库应出现在列表中
|
// 新素材库应出现在列表中
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
|
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
|
||||||
).toBeVisible({ timeout: 10_000 })
|
).toBeVisible({ timeout: 10_000 });
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 切换素材库 ────────────────────────────────────
|
// ─── 切换素材库 ────────────────────────────────────
|
||||||
|
|
||||||
test("切换不同素材库", async ({ page, request }) => {
|
test("切换不同素材库", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-switch");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-switch",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
|
|
||||||
const videoLibName = "视频素材库 A"
|
const videoLibName = "视频素材库 A";
|
||||||
const imageLibName = "图片素材库 B"
|
const imageLibName = "图片素材库 B";
|
||||||
const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video")
|
const videoLibId = await createLibrary(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
videoLibName,
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
const imageLibId = await createLibrary(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
imageLibName,
|
||||||
|
"image",
|
||||||
|
);
|
||||||
|
|
||||||
// 在视频库里创建一个素材
|
// 在视频库里创建一个素材
|
||||||
await createAsset(request, headers, projectId, videoLibId, userId, "demo_video.mp4")
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
videoLibId,
|
||||||
|
userId,
|
||||||
|
"demo_video.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 点击视频库,应显示素材
|
// 点击视频库,应显示素材
|
||||||
const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName })
|
const videoLibItem = page
|
||||||
await videoLibItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
await expect(videoLibItem).toHaveClass(/active/)
|
.filter({ hasText: videoLibName });
|
||||||
|
await videoLibItem.click({ force: true });
|
||||||
|
await expect(videoLibItem).toHaveClass(/active/);
|
||||||
|
|
||||||
// 验证视频素材出现
|
// 验证视频素材出现
|
||||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 点击图片库,应切换且不显示视频
|
// 点击图片库,应切换且不显示视频
|
||||||
const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName })
|
const imageLibItem = page
|
||||||
await imageLibItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
await expect(imageLibItem).toHaveClass(/active/)
|
.filter({ hasText: imageLibName });
|
||||||
|
await imageLibItem.click({ force: true });
|
||||||
|
await expect(imageLibItem).toHaveClass(/active/);
|
||||||
|
|
||||||
// 空状态或图片库内容
|
// 空状态或图片库内容
|
||||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 素材搜索 ──────────────────────────────────────
|
// ─── 素材搜索 ──────────────────────────────────────
|
||||||
|
|
||||||
test("素材搜索功能", async ({ page, request }) => {
|
test("素材搜索功能", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-search");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-search",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video")
|
const libraryId = await createLibrary(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
"搜索测试库",
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
|
||||||
// 创建两个不同名称的素材
|
// 创建两个不同名称的素材
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4")
|
await createAsset(
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4")
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"apple_clip.mp4",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"banana_clip.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 确保在测试库中
|
// 确保在测试库中
|
||||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" })
|
const libItem = page
|
||||||
await libItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
|
.filter({ hasText: "搜索测试库" });
|
||||||
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 两个素材都应可见
|
// 两个素材都应可见
|
||||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
|
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||||
|
|
||||||
// 搜索 apple,只显示 apple
|
// 搜索 apple,只显示 apple
|
||||||
await page.getByPlaceholder("搜索素材名称...").fill("apple")
|
await page.getByPlaceholder("搜索素材名称...").fill("apple");
|
||||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible()
|
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
|
||||||
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0)
|
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
|
||||||
|
|
||||||
// 清空搜索,两个都显示
|
// 清空搜索,两个都显示
|
||||||
await page.getByPlaceholder("搜索素材名称...").fill("")
|
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
})
|
});
|
||||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
|
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 筛选类型 ──────────────────────────────────────
|
// ─── 筛选类型 ──────────────────────────────────────
|
||||||
|
|
||||||
test("素材类型筛选", async ({ page, request }) => {
|
test("素材类型筛选", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-filter");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-filter",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video")
|
const libraryId = await createLibrary(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
"筛选测试库",
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
|
||||||
// 创建视频素材
|
// 创建视频素材
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4")
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"video_clip.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" })
|
const libItem = page
|
||||||
await libItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
|
.filter({ hasText: "筛选测试库" });
|
||||||
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 素材应可见
|
// 素材应可见
|
||||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 筛选类型下拉存在
|
// 筛选类型下拉存在
|
||||||
const filterSelect = page.locator(".xx-assets-filters-left select").first()
|
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||||
await expect(filterSelect).toBeVisible()
|
await expect(filterSelect).toBeVisible();
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 素材详情/播放 ────────────────────────────────
|
// ─── 素材详情/播放 ────────────────────────────────
|
||||||
|
|
||||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-detail");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-detail",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video")
|
const libraryId = await createLibrary(
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4")
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
"详情测试库",
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"play_test.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" })
|
const libItem = page
|
||||||
await libItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
|
.filter({ hasText: "详情测试库" });
|
||||||
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 找到素材卡片并点击播放按钮
|
// 找到素材卡片并点击播放按钮
|
||||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" })
|
const assetCard = page
|
||||||
await expect(assetCard).toBeVisible({ timeout: 10_000 })
|
.locator(".xx-asset-card")
|
||||||
|
.filter({ hasText: "play_test.mp4" });
|
||||||
|
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// 点击播放按钮
|
// 点击播放按钮
|
||||||
await assetCard.locator(".xx-asset-play").click({ force: true })
|
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||||
|
|
||||||
// 播放弹窗出现
|
// 播放弹窗出现
|
||||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" })
|
const modal = page
|
||||||
await expect(modal).toBeVisible()
|
.locator(".ant-modal-content")
|
||||||
|
.filter({ hasText: "播放" });
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
// 关闭弹窗
|
// 关闭弹窗
|
||||||
await modal.locator(".ant-modal-close").click()
|
await modal.locator(".ant-modal-close").click();
|
||||||
await expect(modal).not.toBeVisible({ timeout: 5_000 })
|
await expect(modal).not.toBeVisible({ timeout: 5_000 });
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 删除素材 ──────────────────────────────────────
|
// ─── 删除素材 ──────────────────────────────────────
|
||||||
|
|
||||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-delete");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-delete",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video")
|
const libraryId = await createLibrary(
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4")
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
"删除测试库",
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"to_delete.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" })
|
const libItem = page
|
||||||
await libItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
|
.filter({ hasText: "删除测试库" });
|
||||||
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" })
|
const assetCard = page
|
||||||
await expect(assetCard).toBeVisible({ timeout: 10_000 })
|
.locator(".xx-asset-card")
|
||||||
|
.filter({ hasText: "to_delete.mp4" });
|
||||||
|
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// 悬停显示删除按钮
|
// 悬停显示删除按钮
|
||||||
await assetCard.hover()
|
await assetCard.hover();
|
||||||
|
|
||||||
// 点击删除
|
// 点击删除
|
||||||
const deleteBtn = assetCard.locator(".xx-asset-delete")
|
const deleteBtn = assetCard.locator(".xx-asset-delete");
|
||||||
await expect(deleteBtn).toBeVisible()
|
await expect(deleteBtn).toBeVisible();
|
||||||
await deleteBtn.click({ force: true })
|
await deleteBtn.click({ force: true });
|
||||||
|
|
||||||
// 确认对话框出现
|
// 确认对话框出现
|
||||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" })
|
const confirmModal = page
|
||||||
await expect(confirmModal).toBeVisible()
|
.locator(".ant-popover")
|
||||||
|
.filter({ hasText: "确认删除" });
|
||||||
|
await expect(confirmModal).toBeVisible();
|
||||||
|
|
||||||
// 监听删除请求
|
// 监听删除请求
|
||||||
const deletePromise = page.waitForResponse(
|
const deletePromise = page.waitForResponse(
|
||||||
(resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
(resp) =>
|
||||||
|
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||||
{ timeout: 10_000 },
|
{ timeout: 10_000 },
|
||||||
)
|
);
|
||||||
|
|
||||||
// 点击确认删除
|
// 点击确认删除
|
||||||
await confirmModal.getByRole("button", { name: "删除" }).click()
|
await confirmModal.getByRole("button", { name: "删除" }).click();
|
||||||
|
|
||||||
const resp = await deletePromise
|
const resp = await deletePromise;
|
||||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy()
|
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
|
||||||
|
|
||||||
// 素材应从列表中消失
|
// 素材应从列表中消失
|
||||||
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
|
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 批量删除素材 ──────────────────────────────────
|
// ─── 批量删除素材 ──────────────────────────────────
|
||||||
|
|
||||||
test("批量删除素材", async ({ page, request }) => {
|
test("批量删除素材", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-batch");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-batch",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video")
|
const libraryId = await createLibrary(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
"批量删除库",
|
||||||
|
"video",
|
||||||
|
);
|
||||||
|
|
||||||
// 创建多个素材
|
// 创建多个素材
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4")
|
await createAsset(
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4")
|
request,
|
||||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4")
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"batch_1.mp4",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"batch_2.mp4",
|
||||||
|
);
|
||||||
|
await createAsset(
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
projectId,
|
||||||
|
libraryId,
|
||||||
|
userId,
|
||||||
|
"batch_3.mp4",
|
||||||
|
);
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" })
|
const libItem = page
|
||||||
await libItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
|
.filter({ hasText: "批量删除库" });
|
||||||
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 所有素材应可见
|
// 所有素材应可见
|
||||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
await expect(page.getByText("batch_2.mp4")).toBeVisible()
|
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||||
await expect(page.getByText("batch_3.mp4")).toBeVisible()
|
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||||
|
|
||||||
// 点击全选
|
// 点击全选
|
||||||
const selectAllBtn = page.getByRole("button", { name: "全选" })
|
const selectAllBtn = page.getByRole("button", { name: "全选" });
|
||||||
await expect(selectAllBtn).toBeVisible()
|
await expect(selectAllBtn).toBeVisible();
|
||||||
await selectAllBtn.click()
|
await selectAllBtn.click();
|
||||||
|
|
||||||
// 批量操作栏出现
|
// 批量操作栏出现
|
||||||
const batchBar = page.locator(".xx-assets-batch-bar")
|
const batchBar = page.locator(".xx-assets-batch-bar");
|
||||||
await expect(batchBar).toBeVisible()
|
await expect(batchBar).toBeVisible();
|
||||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible()
|
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
|
||||||
|
|
||||||
// 点击批量删除
|
// 点击批量删除
|
||||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" })
|
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
|
||||||
await expect(batchDeleteBtn).toBeVisible()
|
await expect(batchDeleteBtn).toBeVisible();
|
||||||
await batchDeleteBtn.click()
|
await batchDeleteBtn.click();
|
||||||
|
|
||||||
// 确认对话框
|
// 确认对话框
|
||||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" })
|
const confirmPop = page
|
||||||
await expect(confirmPop).toBeVisible()
|
.locator(".ant-popover")
|
||||||
|
.filter({ hasText: "确定删除" });
|
||||||
|
await expect(confirmPop).toBeVisible();
|
||||||
|
|
||||||
// 确认删除
|
// 确认删除
|
||||||
await confirmPop.getByRole("button", { name: "删除" }).click()
|
await confirmPop.getByRole("button", { name: "删除" }).click();
|
||||||
|
|
||||||
// 验证素材已删除(通过 API 确认)
|
// 验证素材已删除(通过 API 确认)
|
||||||
await expect
|
await expect
|
||||||
@@ -569,53 +724,57 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
|||||||
const resp = await request.get(`${apiBase}/assets`, {
|
const resp = await request.get(`${apiBase}/assets`, {
|
||||||
headers,
|
headers,
|
||||||
params: { library_id: libraryId },
|
params: { library_id: libraryId },
|
||||||
})
|
});
|
||||||
if (!resp.ok()) return "error"
|
if (!resp.ok()) return "error";
|
||||||
const data = await resp.json()
|
const data = await resp.json();
|
||||||
const items = data.items || []
|
const items = data.items || [];
|
||||||
return items.length
|
return items.length;
|
||||||
},
|
},
|
||||||
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
|
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
|
||||||
)
|
)
|
||||||
.toBe(0)
|
.toBe(0);
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 空状态 ────────────────────────────────────────
|
// ─── 空状态 ────────────────────────────────────────
|
||||||
|
|
||||||
test("空素材库展示空状态", async ({ page, request }) => {
|
test("空素材库展示空状态", async ({ page, request }) => {
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
const { headers, userId, accessToken, email, username } =
|
||||||
|
await createAuthedUser(request, "assets-empty");
|
||||||
|
const projectId = await createProject(
|
||||||
request,
|
request,
|
||||||
"assets-empty",
|
headers,
|
||||||
)
|
Date.now().toString(),
|
||||||
const projectId = await createProject(request, headers, Date.now().toString())
|
);
|
||||||
await createLibrary(request, headers, projectId, "空素材库", "video")
|
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||||
|
|
||||||
await setupAuthInBrowser(page, accessToken, {
|
await setupAuthInBrowser(page, accessToken, {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
})
|
});
|
||||||
|
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" })
|
const libItem = page
|
||||||
await libItem.click({ force: true })
|
.locator(".xx-asset-library-item")
|
||||||
|
.filter({ hasText: "空素材库" });
|
||||||
|
await libItem.click({ force: true });
|
||||||
|
|
||||||
// 空状态应显示
|
// 空状态应显示
|
||||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible()
|
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||||
})
|
});
|
||||||
|
|
||||||
// ─── 未登录访问 ────────────────────────────────────
|
// ─── 未登录访问 ────────────────────────────────────
|
||||||
|
|
||||||
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
|
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
|
||||||
await page.goto("/app/assets")
|
await page.goto("/app/assets");
|
||||||
await expect(page).toHaveURL(/\/login/)
|
await expect(page).toHaveURL(/\/login/);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("App route guard", () => {
|
test.describe("App route guard", () => {
|
||||||
test("redirects anonymous users to login", async ({ page }) => {
|
test("redirects anonymous users to login", async ({ page }) => {
|
||||||
await page.goto("/app/dashboard")
|
await page.goto("/app/dashboard");
|
||||||
await expect(page).toHaveURL(/\/login/)
|
await expect(page).toHaveURL(/\/login/);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe("Authentication page", () => {
|
test.describe("Authentication page", () => {
|
||||||
test("renders login form", async ({ page }) => {
|
test("renders login form", async ({ page }) => {
|
||||||
await page.goto("/login")
|
await page.goto("/login");
|
||||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||||
await expect(page.getByLabel("密码")).toBeVisible()
|
await expect(page.getByLabel("密码")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
import * as fs from "node:fs"
|
|
||||||
import * as path from "node:path"
|
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const PASSWORD = "SmokePass123!";
|
||||||
const PASSWORD = "SmokePass123!"
|
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
? apiBase.slice(0, -"/api/v1".length)
|
||||||
|
: "";
|
||||||
|
|
||||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
const routeBrowserApiToTestApi = async (
|
||||||
if (!apiOrigin) return
|
page: import("@playwright/test").Page,
|
||||||
|
) => {
|
||||||
|
if (!apiOrigin) return;
|
||||||
await page.route("**/api/v1/**", async (route) => {
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
const sourceUrl = new URL(route.request().url())
|
const sourceUrl = new URL(route.request().url());
|
||||||
const response = await route.fetch({
|
const response = await route.fetch({
|
||||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||||
})
|
});
|
||||||
await route.fulfill({ response })
|
await route.fulfill({ response });
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
async function loginWithRetry(
|
async function loginWithRetry(
|
||||||
request: APIRequestContext,
|
request: APIRequestContext,
|
||||||
@@ -28,72 +28,73 @@ async function loginWithRetry(
|
|||||||
for (let i = 0; i <= maxRetries; i++) {
|
for (let i = 0; i <= maxRetries; i++) {
|
||||||
const response = await request.post(`${apiBase}/auth/login`, {
|
const response = await request.post(`${apiBase}/auth/login`, {
|
||||||
data: { email, password },
|
data: { email, password },
|
||||||
})
|
});
|
||||||
if (response.status() !== 429) return response
|
if (response.status() !== 429) return response;
|
||||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||||
await new Promise((r) => setTimeout(r, 65000))
|
await new Promise((r) => setTimeout(r, 65000));
|
||||||
}
|
}
|
||||||
return request.post(`${apiBase}/auth/login`, {
|
return request.post(`${apiBase}/auth/login`, {
|
||||||
data: { email, password },
|
data: { email, password },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectResponse = { id: string }
|
type ProjectResponse = { id: string };
|
||||||
type LibraryResponse = { id: string }
|
type LibraryResponse = { id: string };
|
||||||
type TemplateResponse = { id: string }
|
type TemplateResponse = { id: string };
|
||||||
type AssetListResponse = {
|
type AssetListResponse = {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
status: string
|
status: string;
|
||||||
}>
|
}>;
|
||||||
}
|
};
|
||||||
|
|
||||||
test.describe("Core generation flow", () => {
|
test.describe("Core generation flow", () => {
|
||||||
test.describe.configure({ timeout: 180_000 })
|
test.describe.configure({ timeout: 180_000 });
|
||||||
|
|
||||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
test("walks through 5-step wizard and starts generation", async ({
|
||||||
test.setTimeout(180_000)
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page);
|
||||||
const suffix = Date.now().toString(36)
|
const suffix = Date.now().toString(36);
|
||||||
const email = `e2e-gen-${suffix}@example.com`
|
const email = `e2e-gen-${suffix}@example.com`;
|
||||||
const username = `e2e_gen_${suffix}`
|
const username = `e2e_gen_${suffix}`;
|
||||||
const libraryName = `E2E Gen Lib ${suffix}`
|
const libraryName = `E2E Gen Lib ${suffix}`;
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
const register = await request.post(`${apiBase}/auth/register`, {
|
const register = await request.post(`${apiBase}/auth/register`, {
|
||||||
data: { email, username, password: PASSWORD, display_name: username },
|
data: { email, username, password: PASSWORD, display_name: username },
|
||||||
})
|
});
|
||||||
expect(register.status()).toBe(201)
|
expect(register.status()).toBe(201);
|
||||||
const registerData = (await register.json()) as { user_id: string }
|
const registerData = (await register.json()) as { user_id: string };
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
const login = await loginWithRetry(request, email, PASSWORD)
|
const login = await loginWithRetry(request, email, PASSWORD);
|
||||||
expect(login.status()).toBe(200)
|
expect(login.status()).toBe(200);
|
||||||
const loginData = (await login.json()) as { access_token: string }
|
const loginData = (await login.json()) as { access_token: string };
|
||||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||||
|
|
||||||
// Create project
|
// Create project
|
||||||
const project = await request.post(`${apiBase}/projects`, {
|
const project = await request.post(`${apiBase}/projects`, {
|
||||||
headers,
|
headers,
|
||||||
data: { name: `E2E Gen Proj ${suffix}` },
|
data: { name: `E2E Gen Proj ${suffix}` },
|
||||||
})
|
});
|
||||||
expect(project.status()).toBe(200)
|
expect(project.status()).toBe(200);
|
||||||
const projectData = (await project.json()) as ProjectResponse
|
const projectData = (await project.json()) as ProjectResponse;
|
||||||
|
|
||||||
// Create asset library
|
// Create asset library
|
||||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||||
headers,
|
headers,
|
||||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||||
})
|
});
|
||||||
expect(library.status()).toBe(200)
|
expect(library.status()).toBe(200);
|
||||||
const libraryData = (await library.json()) as LibraryResponse
|
const libraryData = (await library.json()) as LibraryResponse;
|
||||||
|
|
||||||
// Upload source video
|
// Upload source video
|
||||||
const sourceFileName = "e2e-gen-source.mp4"
|
const sourceFileName = "e2e-gen-source.mp4";
|
||||||
const sampleVideoPath = path.join(__dirname, "fixtures", "sample.mp4")
|
|
||||||
const sampleVideoBuffer = fs.readFileSync(sampleVideoPath)
|
|
||||||
const upload = await request.post(`${apiBase}/upload`, {
|
const upload = await request.post(`${apiBase}/upload`, {
|
||||||
headers,
|
headers,
|
||||||
multipart: {
|
multipart: {
|
||||||
@@ -102,11 +103,11 @@ test.describe("Core generation flow", () => {
|
|||||||
file: {
|
file: {
|
||||||
name: sourceFileName,
|
name: sourceFileName,
|
||||||
mimeType: "video/mp4",
|
mimeType: "video/mp4",
|
||||||
buffer: sampleVideoBuffer,
|
buffer: Buffer.from("e2e source data"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
expect(upload.status()).toBe(200)
|
expect(upload.status()).toBe(200);
|
||||||
|
|
||||||
// Wait for asset to be ready
|
// Wait for asset to be ready
|
||||||
await expect
|
await expect
|
||||||
@@ -115,16 +116,16 @@ test.describe("Core generation flow", () => {
|
|||||||
const assets = await request.get(`${apiBase}/assets`, {
|
const assets = await request.get(`${apiBase}/assets`, {
|
||||||
headers,
|
headers,
|
||||||
params: { library_id: libraryData.id },
|
params: { library_id: libraryData.id },
|
||||||
})
|
});
|
||||||
if (!assets.ok()) return `http_${assets.status()}`
|
if (!assets.ok()) return `http_${assets.status()}`;
|
||||||
const data = (await assets.json()) as AssetListResponse
|
const data = (await assets.json()) as AssetListResponse;
|
||||||
const asset = data.items.find((a) => a.name === sourceFileName)
|
const asset = data.items.find((a) => a.name === sourceFileName);
|
||||||
if (!asset) return "missing"
|
if (!asset) return "missing";
|
||||||
return asset.status
|
return asset.status;
|
||||||
},
|
},
|
||||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||||
)
|
)
|
||||||
.toBe("ready")
|
.toBe("ready");
|
||||||
|
|
||||||
// Create an editing template so the generate page has at least one template
|
// Create an editing template so the generate page has at least one template
|
||||||
// (templates are now loaded from API; new users have none by default)
|
// (templates are now loaded from API; new users have none by default)
|
||||||
@@ -144,22 +145,22 @@ test.describe("Core generation flow", () => {
|
|||||||
],
|
],
|
||||||
tags: ["e2e"],
|
tags: ["e2e"],
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
expect(template.status(), await template.text()).toBe(201)
|
expect(template.status(), await template.text()).toBe(201);
|
||||||
const templateData = (await template.json()) as TemplateResponse
|
const templateData = (await template.json()) as TemplateResponse;
|
||||||
expect(templateData.id).toBeTruthy()
|
expect(templateData.id).toBeTruthy();
|
||||||
|
|
||||||
// Set auth in localStorage
|
// Set auth in localStorage
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ token, user }) => {
|
({ token, user }) => {
|
||||||
localStorage.setItem("access_token", token)
|
localStorage.setItem("access_token", token);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"auth-storage",
|
"auth-storage",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
state: { user, isAuthenticated: true },
|
state: { user, isAuthenticated: true },
|
||||||
version: 0,
|
version: 0,
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
token: loginData.access_token,
|
token: loginData.access_token,
|
||||||
@@ -173,127 +174,113 @@ test.describe("Core generation flow", () => {
|
|||||||
email_verified: true,
|
email_verified: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
|
|
||||||
// Navigate to generate page
|
// Navigate to generate page
|
||||||
await page.goto("/app/generate")
|
await page.goto("/app/generate");
|
||||||
await expect(page.getByRole("heading", { name: "智能剪辑" })).toBeVisible({
|
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
|
||||||
timeout: 20_000,
|
timeout: 20_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// Step 1: template - default selected, click next
|
// Step 1: template - default selected, click next
|
||||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click();
|
||||||
|
|
||||||
// Step 2: select material
|
// Step 2: select material
|
||||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
|
||||||
const librarySelect = page.locator("select").first()
|
const librarySelect = page.locator("select").first();
|
||||||
await librarySelect.selectOption({ label: libraryName })
|
await librarySelect.selectOption({ label: libraryName });
|
||||||
const materialLabel = page.getByText(sourceFileName).locator("..")
|
const materialLabel = page.getByText(sourceFileName).locator("..");
|
||||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
})
|
});
|
||||||
await materialLabel.locator("input[type='checkbox']").check()
|
await materialLabel.locator("input[type='checkbox']").check();
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click();
|
||||||
|
|
||||||
// Step 3: preview (纯展示页,AI 智能匹配预览)
|
// Step 3: title
|
||||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
const titleText = `E2E Test ${suffix}`;
|
||||||
|
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
|
||||||
|
await page.getByRole("button", { name: "下一步" }).click();
|
||||||
|
|
||||||
// Step 4: title
|
// Step 4: voice
|
||||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
|
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
|
||||||
const titleText = `E2E Test ${suffix}`
|
const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
|
||||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
await firstVoiceCard.click();
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click();
|
||||||
|
|
||||||
// Step 5: voice
|
// Step 5: confirm and generate
|
||||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
|
||||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
|
||||||
await firstVoiceCard.click()
|
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
|
||||||
|
|
||||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
// Wait for plan creation API to be called
|
||||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
const createPlanPromise = page.waitForResponse(
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
(response) =>
|
||||||
|
response.url().includes("/edit-plans") &&
|
||||||
// Step 7: confirm and generate
|
response.request().method() === "POST" &&
|
||||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
!response.url().includes("/generate"),
|
||||||
|
|
||||||
// Wait for generation API to be called
|
|
||||||
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
|
|
||||||
// 等 generate 接口返回,确认生成流程启动
|
|
||||||
const generatePromise = page.waitForResponse(
|
|
||||||
(response) => {
|
|
||||||
const url = response.url()
|
|
||||||
const path = new URL(url).pathname
|
|
||||||
return response.request().method() === "POST" && path.endsWith("/editor/generate")
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
);
|
||||||
|
|
||||||
// Click generate button
|
// Click generate button
|
||||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
|
await page
|
||||||
|
.locator(".xx-btn-primary")
|
||||||
|
.filter({ hasText: "确认生成" })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
// Verify generation was triggered successfully
|
// Verify plan was created successfully
|
||||||
const genResp = await generatePromise
|
const planResp = await createPlanPromise;
|
||||||
if (!genResp.ok()) {
|
expect(planResp.ok()).toBeTruthy();
|
||||||
const body = await genResp.text()
|
const planData = (await planResp.json()) as { id: string };
|
||||||
console.error(
|
expect(planData.id).toBeTruthy();
|
||||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
expect(genResp.ok()).toBeTruthy()
|
|
||||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
|
||||||
expect(genData.plan_id).toBeTruthy()
|
|
||||||
expect(genData.generation_task_id).toBeTruthy()
|
|
||||||
|
|
||||||
// Generation may fail in test env (no worker), that's OK
|
// Generation may fail in test env (no worker), that's OK
|
||||||
// Just verify the flow started - check page shows generation-related UI
|
// Just verify the flow started - check page shows generation-related UI
|
||||||
await page
|
await page
|
||||||
.getByText(/生成中|生成完成|生成失败/)
|
.getByText(/生成中|生成完成|生成失败/)
|
||||||
.isVisible({ timeout: 15_000 })
|
.isVisible({ timeout: 15_000 })
|
||||||
.catch(() => false)
|
.catch(() => false);
|
||||||
// If we see progress or result, great; if not, flow still reached the end
|
// If we see progress or result, great; if not, flow still reached the end
|
||||||
// which is sufficient for an E2E smoke test
|
// which is sufficient for an E2E smoke test
|
||||||
|
|
||||||
// Verify product library page loads (smoke: just verify page renders)
|
// Verify product library page loads (smoke: just verify page renders)
|
||||||
await page.goto("/app/products")
|
await page.goto("/app/products");
|
||||||
await expect(page).toHaveURL(/\/app\/products/)
|
await expect(page).toHaveURL(/\/app\/products/);
|
||||||
// Verify page container exists = page rendered correctly
|
// Verify page container exists = page rendered correctly
|
||||||
// (works in all states: loading/error/success - more reliable than checking search input)
|
// (works in all states: loading/error/success - more reliable than checking search input)
|
||||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||||
})
|
});
|
||||||
|
|
||||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||||
const suffix = Date.now().toString(36)
|
const suffix = Date.now().toString(36);
|
||||||
const email = `e2e-gen-api-${suffix}@example.com`
|
const email = `e2e-gen-api-${suffix}@example.com`;
|
||||||
const username = `e2e_gen_api_${suffix}`
|
const username = `e2e_gen_api_${suffix}`;
|
||||||
|
|
||||||
const register = await request.post(`${apiBase}/auth/register`, {
|
const register = await request.post(`${apiBase}/auth/register`, {
|
||||||
data: { email, username, password: PASSWORD, display_name: username },
|
data: { email, username, password: PASSWORD, display_name: username },
|
||||||
})
|
});
|
||||||
expect(register.status()).toBe(201)
|
expect(register.status()).toBe(201);
|
||||||
|
|
||||||
const login = await loginWithRetry(request, email, PASSWORD)
|
const login = await loginWithRetry(request, email, PASSWORD);
|
||||||
expect(login.status()).toBe(200)
|
expect(login.status()).toBe(200);
|
||||||
const loginData = (await login.json()) as { access_token: string }
|
const loginData = (await login.json()) as { access_token: string };
|
||||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||||
|
|
||||||
const project = await request.post(`${apiBase}/projects`, {
|
const project = await request.post(`${apiBase}/projects`, {
|
||||||
headers,
|
headers,
|
||||||
data: { name: `E2E API Proj ${suffix}` },
|
data: { name: `E2E API Proj ${suffix}` },
|
||||||
})
|
});
|
||||||
expect(project.status()).toBe(200)
|
expect(project.status()).toBe(200);
|
||||||
|
|
||||||
// List generation tasks via task center API
|
// List generation tasks via task center API
|
||||||
const tasks = await request.get(`${apiBase}/tasks`, { headers })
|
const tasks = await request.get(`${apiBase}/tasks`, { headers });
|
||||||
expect(tasks.status()).toBe(200)
|
expect(tasks.status()).toBe(200);
|
||||||
const tasksData = await tasks.json()
|
const tasksData = await tasks.json();
|
||||||
expect(Array.isArray(tasksData.items)).toBe(true)
|
expect(Array.isArray(tasksData.items)).toBe(true);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user