Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fccfcd181 | |||
| 8ea3263a12 |
+27
-165
@@ -1,198 +1,60 @@
|
||||
# ============================================================
|
||||
# 小虾 SaaS 环境变量完整配置
|
||||
# ============================================================
|
||||
# 本文件列出所有可配置的环境变量及默认值。
|
||||
# 复制为 .env 后按需修改;生产环境务必覆盖所有密钥类配置。
|
||||
#
|
||||
# 配置读取规则(pydantic-settings,大小写不敏感):
|
||||
# 1. 系统环境变量(最高优先级)
|
||||
# 2. .env.{APP_ENV} 文件(如 .env.staging)
|
||||
# 3. .env 文件
|
||||
# 4. 代码中的默认值(最低优先级)
|
||||
# ============================================================
|
||||
# 小虾 SaaS 环境变量配置
|
||||
|
||||
|
||||
# ==================== 应用基本配置 ====================
|
||||
|
||||
# 应用名称
|
||||
APP_NAME=xiaoxia-saas
|
||||
|
||||
# 应用版本号(展示用,代码中已内置默认)
|
||||
APP_VERSION=0.1.61
|
||||
|
||||
# 环境标识:development / staging / production
|
||||
# 决定读取 .env.{APP_ENV} 还是 .env,也影响部分配置的严格校验
|
||||
# ==================== 应用配置 ====================
|
||||
APP_NAME=小虾 SaaS
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
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)
|
||||
DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas
|
||||
|
||||
# 连接池大小(常驻连接数)
|
||||
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=true
|
||||
|
||||
# 生产环境:使用 PostgreSQL
|
||||
# USE_IN_MEMORY_DB=false
|
||||
|
||||
# ==================== Redis 配置 ====================
|
||||
|
||||
# Redis 连接 URL(格式:redis://[:password@]host:port/db)
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# 是否使用 Redis 存储 Session(多实例部署时必须开启;开发可用内存存储)
|
||||
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 配置 ====================
|
||||
JWT_SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-chars
|
||||
|
||||
# JWT 签名算法
|
||||
JWT_ALGORITHM=HS256
|
||||
|
||||
# Access Token 过期时间(分钟)
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Refresh Token 过期时间(天)
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
|
||||
|
||||
# ==================== 邮件配置 ====================
|
||||
|
||||
# 是否启用邮件投递(关闭时邮件内容打印到日志,开发调试用)
|
||||
ENABLE_EMAIL_DELIVERY=false
|
||||
|
||||
# SMTP 服务器地址
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
|
||||
# SMTP 端口
|
||||
SMTP_PORT=587
|
||||
|
||||
# SMTP 用户名
|
||||
SMTP_USER=your-email@gmail.com
|
||||
|
||||
# SMTP 密码 / 应用专用密码
|
||||
SMTP_PASSWORD=your-app-specific-password
|
||||
|
||||
# 发件人邮箱
|
||||
SMTP_FROM_EMAIL=noreply@xiaoxia-saas.com
|
||||
|
||||
# 发件人显示名称
|
||||
SMTP_FROM_NAME=小虾 SaaS
|
||||
|
||||
# 是否启用 TLS
|
||||
SMTP_USE_TLS=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
|
||||
|
||||
# ==================== 环境配置 ====================
|
||||
ENVIRONMENT=development
|
||||
DEBUG=true
|
||||
|
||||
# ==================== CORS 配置 ====================
|
||||
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||
|
||||
# 允许跨域的前端域名列表,逗号分隔
|
||||
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173,http://localhost:8000
|
||||
|
||||
|
||||
# ==================== 渲染引擎配置 ====================
|
||||
|
||||
# 渲染引擎选择:
|
||||
# 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
|
||||
# ==================== 阿里云 OSS 配置 ====================
|
||||
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
|
||||
|
||||
# ==================== 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_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
COSYVOICE_MODEL=cosyvoice-v3-flash
|
||||
COSYVOICE_VOICE=longxiaochun_v3
|
||||
COSYVOICE_SAMPLE_RATE=22050
|
||||
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
|
||||
|
||||
@@ -1,162 +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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# ====== 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
|
||||
@@ -10,8 +10,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
schedule:
|
||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00,每日全量CI回归
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
@@ -22,19 +20,7 @@ permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||
env:
|
||||
CI_PG_HOST: host.docker.internal
|
||||
CI_LOCAL_PG_PORT: "5432"
|
||||
CI_PG_USER: postgres
|
||||
CI_PG_PASSWORD: postgres
|
||||
CI_PG_DB: xiaoxia_saas
|
||||
CI_SHARED_PG_PORT: "5433"
|
||||
CI_SHARED_PG_USER: postgres
|
||||
CI_SHARED_PG_PASSWORD: ci_pg_2026!
|
||||
CI_DEFAULT_DB: xiaoxia_saas
|
||||
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
@@ -262,7 +248,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
steps:
|
||||
@@ -346,7 +332,6 @@ jobs:
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -407,18 +392,15 @@ jobs:
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- validate
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
OSS_ACCESS_KEY_ID: placeholder
|
||||
OSS_ACCESS_KEY_SECRET: placeholder
|
||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -640,83 +622,65 @@ jobs:
|
||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
- name: Pre-build worker base images (3-level cache)
|
||||
- name: Pre-build worker base images (fallback if not exist)
|
||||
if: matrix.service == 'worker'
|
||||
id: prebuild
|
||||
shell: bash
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest"
|
||||
GITEA_RUNTIME="${GITEA_REGISTRY}/worker-base-runtime:latest"
|
||||
ACR_BUILDER="${ACR_REGISTRY}/worker-base-builder:latest"
|
||||
ACR_RUNTIME="${ACR_REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon)
|
||||
echo "=== L1 本地缓存 ==="
|
||||
if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \
|
||||
&& docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then
|
||||
echo "本地缓存命中"
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "本地无缓存"
|
||||
|
||||
# L2: Gitea registry缓存(内网快)
|
||||
echo "=== L2 Registry拉取 ==="
|
||||
if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then
|
||||
echo "Registry拉取成功,重tag供Dockerfile使用"
|
||||
docker tag "$GITEA_BUILDER" "$ACR_BUILDER"
|
||||
docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME"
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "Registry无缓存,需本地构建"
|
||||
|
||||
# L3: 本地构建
|
||||
echo "=== L3 本地构建 ==="
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
||||
|
||||
# 尝试拉取基础镜像
|
||||
echo "检查基础镜像..."
|
||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
||||
echo "基础镜像已存在,使用远程镜像"
|
||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
||||
|
||||
# 构建builder基础镜像
|
||||
echo "构建 worker-base-builder..."
|
||||
# 用buildx docker-container驱动构建(兼容DooD模式:普通docker build看不到容器内文件)
|
||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
# 构建builder基础镜像(带重试,buildx容器偶发不稳定)
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$BASE_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 构建runtime基础镜像
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$BASE_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 构建失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像本地构建完成"
|
||||
fi
|
||||
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||
|
||||
echo "构建 worker-base-builder..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$ACR_BUILDER" .; then
|
||||
echo "worker-base-builder 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-builder 失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "构建 worker-base-runtime..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$ACR_RUNTIME" .; then
|
||||
echo "worker-base-runtime 构建成功"
|
||||
break
|
||||
fi
|
||||
echo "worker-base-runtime 失败,重试 $attempt/3..."
|
||||
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# 推送到Gitea registry供后续复用
|
||||
echo "=== 推送缓存到Registry ==="
|
||||
docker tag "$ACR_BUILDER" "$GITEA_BUILDER"
|
||||
docker tag "$ACR_RUNTIME" "$GITEA_RUNTIME"
|
||||
docker push "$GITEA_BUILDER" 2>/dev/null || echo "push builder失败(不影响)"
|
||||
docker push "$GITEA_RUNTIME" 2>/dev/null || echo "push runtime失败(不影响)"
|
||||
|
||||
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||
echo "基础镜像构建完成"
|
||||
- name: Build PR image (verify only, no push)
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -730,15 +694,15 @@ jobs:
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||
fi
|
||||
|
||||
# Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器)
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then
|
||||
echo "本地base镜像已就绪,BuildKit快速构建"
|
||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
||||
BUILD_ARG_STR=""
|
||||
for arg in $EXTRA_BUILD_ARGS; do
|
||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||
done
|
||||
DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "快速构建成功"
|
||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||
echo "Fallback PR Build successful"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1114,27 +1078,18 @@ jobs:
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Run Playwright E2E on staging
|
||||
shell: bash
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-${GITHUB_SHA::8}"
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1179,25 +1134,16 @@ jobs:
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Run API integration tests on staging
|
||||
shell: bash
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-api-tests-${GITHUB_SHA::8}"
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
docker run --rm \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1230,7 +1176,7 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1307,16 +1253,10 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
|
||||
fi
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
|
||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
|
||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
|
||||
if [ "${{ matrix.service }}" = "web" ]; then
|
||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
||||
fi
|
||||
@@ -1614,89 +1554,4 @@ jobs:
|
||||
[ ${{ 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
|
||||
|
||||
canary-release:
|
||||
name: Canary Release to Production
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: canary-release-production
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'push' && github.ref_name == 'main'
|
||||
needs:
|
||||
- build-production
|
||||
- staging-api-tests
|
||||
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: Notify canary release start
|
||||
continue-on-error: true
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- name: Install SSH client
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
|
||||
echo "openssh-client installed"
|
||||
- name: Run canary release
|
||||
shell: bash
|
||||
env:
|
||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
||||
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
|
||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE_TAG="main-${GITHUB_SHA::8}"
|
||||
export IMAGE_TAG
|
||||
echo "Canary release version: $IMAGE_TAG"
|
||||
bash scripts/ci/canary_release.sh
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on success
|
||||
continue-on-error: true
|
||||
if: success()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
||||
- 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="Canary Release" 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
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
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
|
||||
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -22,15 +22,6 @@ jobs:
|
||||
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:
|
||||
@@ -39,7 +30,153 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
bash scripts/ci/auto_approve.sh
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项(与分支保护required门禁一致)"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 12); do # 短作业模式:最多等2分钟(12次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ] || [ "$STATE" = "null" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 还有CI在跑 → 继续等
|
||||
if [ "$ANY_PENDING" = "true" ]; then
|
||||
echo "⏳ CI仍在运行中(第${attempt}/12次),超时后将退出等待下次触发..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
# 所有CI都跑完了但有失败 → 退出
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 快速检查超时(2分钟),CI尚未完成,退出等待下次触发(workflow_run事件或5分钟定时扫描)"
|
||||
exit 0
|
||||
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -56,7 +193,7 @@ jobs:
|
||||
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
|
||||
timeout-minutes: 3 # 短作业模式:最多3分钟,不占runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -65,31 +202,6 @@ jobs:
|
||||
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:
|
||||
@@ -99,7 +211,148 @@ jobs:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
bash scripts/ci/auto_merge.sh
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
echo "Skip: 目标分支不是develop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判断是否纯前端改动
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)"
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)"
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)"
|
||||
)
|
||||
echo "检查required门禁(与分支保护一致)"
|
||||
fi
|
||||
echo
|
||||
|
||||
# 初始等待30秒,给CI启动写status的时间
|
||||
echo "等待30秒让CI启动..."
|
||||
sleep 30
|
||||
|
||||
# 405连续计数器
|
||||
MERGE_405_COUNT=0
|
||||
MAX_405_RETRIES=10
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 18); do # 短作业模式:最多等3分钟(18次x10秒),不满足就退出等下次触发
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
ANY_PENDING=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$PR_HEAD_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
if [ "$STATE" = "pending" ]; then
|
||||
ANY_PENDING=true
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# CI全绿 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿,执行自动合并"
|
||||
echo "等待60秒冷却,给Gitea内部状态同步时间..."
|
||||
sleep 60
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
MERGE_405_COUNT=$((MERGE_405_COUNT + 1))
|
||||
echo "⚠️ 合并返回405(第${MERGE_405_COUNT}次),可能CI状态尚未同步或有未解决的门禁,继续等待重试..."
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
echo
|
||||
if [ "$MERGE_405_COUNT" -ge "$MAX_405_RETRIES" ]; then
|
||||
echo "⚠️ 连续${MAX_405_RETRIES}次合并返回405,放弃自动合并(需人工确认,非代码问题)"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge skipped after multiple 405 errors: PR may have conflicts or unresolved checks. Please review manually. This is not a CI failure."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
continue
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# 本轮不满足合并条件,重置405计数器
|
||||
MERGE_405_COUNT=0
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "快速检查超时(3分钟),CI尚未全绿或无审批,退出等待下次触发"
|
||||
exit 0
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
|
||||
@@ -115,11 +115,11 @@ jobs:
|
||||
fi
|
||||
if [ "$CACHE_VALID" = "false" ]; then
|
||||
echo "Cache miss or invalid: running npm ci..."
|
||||
if ! npm ci; then
|
||||
if ! npm ci --include=dev; then
|
||||
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||
rm -rf node_modules
|
||||
mkdir -p node_modules
|
||||
npm ci
|
||||
npm ci --include=dev
|
||||
fi
|
||||
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||
echo "Dependencies installed, cache updated"
|
||||
|
||||
Executable → Regular
+4
-7
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
@@ -7,17 +8,13 @@ from alembic import context
|
||||
# Import your models' Base here
|
||||
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
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# 从统一配置系统获取 database_url,确保与应用使用同一配置源
|
||||
settings = get_shared_settings()
|
||||
if settings.database_url:
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
|
||||
@@ -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_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_router
|
||||
@@ -12,7 +11,6 @@ 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.internal_render import router as internal_render_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.tags import router as tags_router
|
||||
from app.api.routes.task_center import router as task_center_router
|
||||
@@ -106,10 +104,6 @@ api_router.include_router(
|
||||
videos_router,
|
||||
tags=["VideoCenter"],
|
||||
)
|
||||
api_router.include_router(
|
||||
share_router,
|
||||
tags=["Share"],
|
||||
)
|
||||
api_router.include_router(
|
||||
duplication_router,
|
||||
prefix="/duplication",
|
||||
@@ -135,11 +129,6 @@ api_router.include_router(
|
||||
prefix="/tts",
|
||||
tags=["TTS"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ai_router,
|
||||
prefix="/ai",
|
||||
tags=["AI"],
|
||||
)
|
||||
api_router.include_router(
|
||||
feature_flags_router,
|
||||
tags=["Internal"],
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -59,8 +59,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
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", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -106,7 +104,7 @@ def _select_assets_from_library(
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
@@ -124,19 +122,17 @@ def _select_assets_from_library(
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||
scored_assets = sorted(
|
||||
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
|
||||
sorted_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||
-(getattr(a, "duration", 0.0) or 0.0),
|
||||
a.quality_score if a.quality_score is not None else 0.0,
|
||||
a.duration if a.duration is not None else 0.0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
if count > 0:
|
||||
scored_assets = scored_assets[:count]
|
||||
return [a.id for a in scored_assets]
|
||||
selected = sorted_assets if count <= 0 else sorted_assets[:count]
|
||||
return [a.id for a in selected]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
@@ -227,20 +223,6 @@ def create_generation_task(
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
|
||||
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)
|
||||
count = request.count
|
||||
@@ -288,8 +270,6 @@ def create_generation_task(
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
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_max=request.auto_retry_max,
|
||||
)
|
||||
@@ -428,7 +408,6 @@ def retry_generation_task(
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
import psycopg2
|
||||
import redis
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"])
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ async def startup_check():
|
||||
all_ready = all(check["status"] == "healthy" for check in checks.values())
|
||||
response = {
|
||||
"status": "started" if all_ready else "starting",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"checks": checks,
|
||||
}
|
||||
if not all_ready:
|
||||
|
||||
@@ -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}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import (
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
@@ -182,9 +181,13 @@ def synthesize(
|
||||
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
# Celery 调度失败,标记 job 为 failed
|
||||
try:
|
||||
|
||||
Executable → Regular
+6
-3
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
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.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
@@ -98,7 +97,9 @@ def create_voice_clone(
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if profile.status == "processing" and task_id:
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
@@ -212,7 +213,9 @@ def retry_voice_clone(
|
||||
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
|
||||
if profile.status == "processing" and task_id:
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to dispatch Celery task: {e}")
|
||||
|
||||
+166
-23
@@ -1,28 +1,171 @@
|
||||
"""API 服务配置(向后兼容层)。
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
统一配置已迁移到 packages.config.api_settings。
|
||||
新代码请使用:
|
||||
from packages.config import APISettings, get_api_settings
|
||||
from pydantic import AliasChoices, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
本文件保留 Settings 类名、get_settings() 函数、settings 模块级单例,
|
||||
确保所有旧的 import 路径仍然有效。
|
||||
"""
|
||||
|
||||
from packages.config import APISettings as Settings
|
||||
from packages.config import get_api_settings as get_settings
|
||||
from packages.config import reload_settings_cache
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "xiaoxia-saas"
|
||||
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()
|
||||
|
||||
|
||||
# 暴露旧的 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.
|
||||
|
||||
新代码请使用:
|
||||
from packages.config import get_api_settings, APISettings
|
||||
Use `app.config` as the single source of truth for API configuration.
|
||||
This module remains only for older imports during migration.
|
||||
"""
|
||||
|
||||
from packages.config import APISettings as AppSettings
|
||||
from packages.config import get_api_settings as get_settings
|
||||
from packages.config import reload_settings_cache
|
||||
from app.config import Settings as AppSettings
|
||||
from app.config import get_settings, settings
|
||||
|
||||
|
||||
def reload_settings() -> AppSettings:
|
||||
"""重新加载配置(测试用)。"""
|
||||
reload_settings_cache()
|
||||
return get_settings()
|
||||
"""Reload settings for tests and legacy callers."""
|
||||
import app.config as canonical_config
|
||||
|
||||
canonical_config.settings = canonical_config.get_settings()
|
||||
return canonical_config.settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
__all__ = ["AppSettings", "get_settings", "reload_settings", "settings"]
|
||||
|
||||
@@ -46,16 +46,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
le=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")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -84,8 +74,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
|
||||
@@ -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,536 +0,0 @@
|
||||
"""统一 AI 服务层 — 豆包大模型接入.
|
||||
|
||||
提供基于字节跳动豆包大模型的 AI 能力:
|
||||
- 智能标题生成(爆款/情感/信息三种风格)
|
||||
- 后续扩展:智能素材匹配、AI 推荐片段编排等
|
||||
|
||||
设计原则:
|
||||
1. 无 API Key 或调用失败时自动降级为本地模拟,不阻塞主流程
|
||||
2. 统一的客户端封装,新增能力只需加方法
|
||||
3. 所有模型相关配置集中在 Settings
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
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]:
|
||||
"""本地降级:基于模板规则生成标题.
|
||||
|
||||
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
||||
"""
|
||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||
examples = style_info["examples"]
|
||||
|
||||
# 从描述中提取关键词(取前几个词)
|
||||
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||||
keyword = keywords[0] if keywords else "精彩内容"
|
||||
|
||||
# 基于模板生成
|
||||
templates = [
|
||||
f"「{keyword}」{examples[0][:10]}...",
|
||||
f"{keyword}:{examples[1]}",
|
||||
f"关于{keyword},你不知道的3件事",
|
||||
f"{keyword}入门指南,新手必看",
|
||||
f"深度解析:{keyword}背后的秘密",
|
||||
f"{keyword}怎么做?手把手教你",
|
||||
f"干货分享 | {keyword}全攻略",
|
||||
f"建议收藏:{keyword}实用技巧",
|
||||
f"{keyword}避坑指南,别再踩雷了",
|
||||
f"一分钟搞懂{keyword}",
|
||||
]
|
||||
|
||||
random.shuffle(templates)
|
||||
return templates[: min(count, len(templates))]
|
||||
|
||||
|
||||
def _parse_titles_from_response(content: str) -> List[str]:
|
||||
"""从模型返回中解析标题列表.
|
||||
|
||||
支持多种返回格式:
|
||||
- JSON 数组: ["标题1", "标题2"]
|
||||
- 编号列表: 1. 标题1 / 2. 标题2
|
||||
- 换行分隔: 标题1\n标题2
|
||||
- 带破折号: - 标题1
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
# 清理可能的 markdown 代码块标记
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
if isinstance(data, list):
|
||||
return [str(item).strip() for item in data if str(item).strip()]
|
||||
if isinstance(data, dict) and "titles" in data:
|
||||
titles = data["titles"]
|
||||
if isinstance(titles, list):
|
||||
return [str(t).strip() for t in titles if str(t).strip()]
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# 尝试按行解析
|
||||
titles: List[str] = []
|
||||
for line in content.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 去掉编号前缀 "1. " "1、" "(1)"
|
||||
import re
|
||||
|
||||
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||||
# 去掉破折号前缀 "- " "• "
|
||||
line = re.sub(r"^[-•·]\s*", "", line)
|
||||
# 去掉引号
|
||||
line = line.strip('"').strip("'").strip("「」")
|
||||
if line and len(line) < 100: # 过滤过长的行
|
||||
titles.append(line)
|
||||
|
||||
return titles
|
||||
|
||||
|
||||
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]]:
|
||||
"""本地降级:基于关键词的简单匹配.
|
||||
|
||||
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||||
作为匹配度评分。0-1分。
|
||||
"""
|
||||
import re
|
||||
|
||||
# 提取关键词(中文按2字以上片段,英文按单词)
|
||||
desc = description.lower()
|
||||
# 简单分词:提取2字以上的中文字符串和英文单词
|
||||
keywords = set()
|
||||
# 英文单词
|
||||
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||||
keywords.add(word)
|
||||
# 中文2-4字片段
|
||||
for i in range(len(desc)):
|
||||
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||||
fragment = desc[i:j]
|
||||
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||||
keywords.add(fragment)
|
||||
|
||||
if not keywords:
|
||||
# 没有关键词时给所有素材中等分数
|
||||
for asset in assets:
|
||||
asset["match_score"] = 0.5
|
||||
asset["match_reason"] = "fallback_default"
|
||||
return assets
|
||||
|
||||
results = []
|
||||
for asset in assets:
|
||||
# 组合素材的文本信息:名称 + 标签 + 描述
|
||||
asset_text_parts = [
|
||||
str(asset.get("name", "")).lower(),
|
||||
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||||
str(asset.get("description", "")).lower(),
|
||||
]
|
||||
asset_text = " | ".join(asset_text_parts)
|
||||
|
||||
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||||
hit_count = 0
|
||||
hit_keywords = []
|
||||
for kw in keywords:
|
||||
if kw in asset_text:
|
||||
hit_count += 1
|
||||
hit_keywords.append(kw)
|
||||
|
||||
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||||
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||||
|
||||
# 名称命中加分(名称匹配更重要)
|
||||
name = str(asset.get("name", "")).lower()
|
||||
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||||
name_bonus = min(0.2, name_hits * 0.05)
|
||||
|
||||
score = min(1.0, base_score * 0.8 + name_bonus)
|
||||
score = round(score, 3)
|
||||
|
||||
results.append(
|
||||
{
|
||||
**asset,
|
||||
"match_score": score,
|
||||
"match_reason": "fallback_keyword",
|
||||
}
|
||||
)
|
||||
|
||||
# 按匹配度降序
|
||||
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def _parse_semantic_match_response(
|
||||
content: str,
|
||||
asset_ids: List[str],
|
||||
) -> Optional[Dict[str, float]]:
|
||||
"""从模型返回中解析素材匹配度.
|
||||
|
||||
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||||
score 范围 0-1。
|
||||
"""
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# 尝试解析 JSON
|
||||
try:
|
||||
cleaned = content.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.lower().startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
|
||||
data = json.loads(cleaned)
|
||||
|
||||
result: Dict[str, float] = {}
|
||||
|
||||
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||||
if isinstance(data, dict):
|
||||
if "matches" in data and isinstance(data["matches"], list):
|
||||
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||||
for item in data["matches"]:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (int, float)):
|
||||
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||||
|
||||
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
aid = item.get("asset_id") or item.get("id")
|
||||
score = item.get("score", 0)
|
||||
if aid and isinstance(score, (int, float)):
|
||||
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||
|
||||
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
|
||||
return result
|
||||
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
@@ -1,326 +0,0 @@
|
||||
"""SmartAssetSelector — 智能素材选择服务.
|
||||
|
||||
根据多维度评分从素材库中自动选择最优视频素材,
|
||||
用于一键生成等需要自动选取素材的场景。
|
||||
|
||||
评分维度(加权求和,总分 0-1):
|
||||
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
|
||||
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
|
||||
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
|
||||
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
|
||||
|
||||
特性:
|
||||
- 最低质量分门槛:自动过滤低质量素材
|
||||
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||
- 兼容全部模式:素材库模式和项目模式都可用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 评分权重 ──────────────────────────────────────────────────────────────────
|
||||
_WEIGHT_QUALITY = 0.5
|
||||
_WEIGHT_RESOLUTION = 0.2
|
||||
_WEIGHT_DURATION = 0.2
|
||||
_WEIGHT_BITRATE = 0.1
|
||||
|
||||
# ── 评分参数 ──────────────────────────────────────────────────────────────────
|
||||
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
|
||||
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
|
||||
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
|
||||
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
|
||||
_OPTIMAL_DURATION_MAX = 30.0
|
||||
|
||||
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
|
||||
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
|
||||
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
|
||||
# 长素材:> 15s
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartSelectResult:
|
||||
"""智能选择结果."""
|
||||
|
||||
selected_ids: list[str]
|
||||
total_candidates: int
|
||||
filtered_out: int # 被质量门槛过滤的数量
|
||||
avg_score: float
|
||||
details: list[AssetScoreDetail]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetScoreDetail:
|
||||
"""单个素材的评分详情."""
|
||||
|
||||
asset_id: str
|
||||
total_score: float
|
||||
quality_score: float
|
||||
resolution_score: float
|
||||
duration_score: float
|
||||
bitrate_score: float
|
||||
duration: float | None
|
||||
|
||||
|
||||
class SmartAssetSelector:
|
||||
"""智能素材选择器.
|
||||
|
||||
从一组素材中按综合评分选择最优的 N 个,
|
||||
同时保证时长分布的多样性。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_quality_score: float = _MIN_QUALITY_SCORE,
|
||||
target_width: int = _TARGET_WIDTH,
|
||||
target_height: int = _TARGET_HEIGHT,
|
||||
):
|
||||
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 = 0
|
||||
for asset in assets:
|
||||
status = getattr(asset, "status", None)
|
||||
status_val = status.value if hasattr(status, "value") else str(status)
|
||||
if status_val != "ready":
|
||||
continue
|
||||
mime_type = getattr(asset, "mime_type", "") or ""
|
||||
if not mime_type.startswith("video"):
|
||||
continue
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
if quality is not None and quality < self.min_quality_score:
|
||||
filtered_out += 1
|
||||
continue
|
||||
candidates.append(asset)
|
||||
|
||||
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 = self._score_asset(asset)
|
||||
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 = self._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
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分."""
|
||||
# 质量分
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
quality_score = (quality / 100.0) if quality is not None else 0.5
|
||||
|
||||
# 分辨率评分:越接近目标分辨率得分越高
|
||||
width = getattr(asset, "width", None)
|
||||
height = getattr(asset, "height", None)
|
||||
resolution_score = self._score_resolution(width, height)
|
||||
|
||||
# 时长评分:在最佳区间内得分高,过短过长扣分
|
||||
duration = getattr(asset, "duration", None)
|
||||
duration_score = self._score_duration(duration)
|
||||
|
||||
# 码率评分:用 file_size/duration 估算,适中得分高
|
||||
file_size = getattr(asset, "file_size", 0) or 0
|
||||
bitrate_score = self._score_bitrate(file_size, duration)
|
||||
|
||||
# 加权总分
|
||||
total = (
|
||||
_WEIGHT_QUALITY * quality_score
|
||||
+ _WEIGHT_RESOLUTION * resolution_score
|
||||
+ _WEIGHT_DURATION * duration_score
|
||||
+ _WEIGHT_BITRATE * bitrate_score
|
||||
)
|
||||
|
||||
return AssetScoreDetail(
|
||||
asset_id=asset.id,
|
||||
total_score=round(total, 4),
|
||||
quality_score=round(quality_score, 4),
|
||||
resolution_score=round(resolution_score, 4),
|
||||
duration_score=round(duration_score, 4),
|
||||
bitrate_score=round(bitrate_score, 4),
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
return 0.5 # 未知分辨率给中评分
|
||||
|
||||
target_pixels = self.target_width * self.target_height
|
||||
actual_pixels = width * height
|
||||
|
||||
# 计算像素数比例
|
||||
ratio = actual_pixels / target_pixels
|
||||
|
||||
if ratio >= 1.0:
|
||||
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
|
||||
return 1.0
|
||||
else:
|
||||
# 低于目标分辨率:线性衰减,但最低不低于 0.1
|
||||
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
|
||||
score = 0.3 + 0.7 * ratio
|
||||
return max(0.1, min(1.0, score))
|
||||
|
||||
def _score_duration(self, duration: float | None) -> float:
|
||||
"""时长评分:3-30秒最佳,过短或过长都扣分."""
|
||||
if duration is None or duration <= 0:
|
||||
return 0.5 # 未知时长给中评分
|
||||
|
||||
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
|
||||
# 最佳区间:满分
|
||||
return 1.0
|
||||
|
||||
if duration < _OPTIMAL_DURATION_MIN:
|
||||
# 太短:线性衰减,1秒以下给 0.3
|
||||
ratio = duration / _OPTIMAL_DURATION_MIN
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
|
||||
excess = duration - _OPTIMAL_DURATION_MAX
|
||||
penalty = min(0.8, excess / 10.0 * 0.1)
|
||||
return max(0.2, 1.0 - penalty)
|
||||
|
||||
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
|
||||
if not file_size or not duration or duration <= 0:
|
||||
return 0.5 # 未知给中评分
|
||||
|
||||
# 估算码率(bps)
|
||||
bitrate = (file_size * 8) / duration
|
||||
|
||||
# 最佳码率范围:2-8 Mbps
|
||||
optimal_low = 2_000_000 # 2 Mbps
|
||||
optimal_high = 8_000_000 # 8 Mbps
|
||||
|
||||
if optimal_low <= bitrate <= optimal_high:
|
||||
return 1.0
|
||||
|
||||
if bitrate < optimal_low:
|
||||
# 码率太低:线性衰减
|
||||
ratio = bitrate / optimal_low
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 码率太高(文件太大):适度扣分
|
||||
excess = bitrate / optimal_high - 1.0
|
||||
penalty = min(0.5, excess * 0.2)
|
||||
return max(0.5, 1.0 - penalty)
|
||||
|
||||
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||
|
||||
策略:
|
||||
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
|
||||
2. 每个桶配额 = max(1, count / 3)
|
||||
3. 先从每桶按配额取最高分的
|
||||
4. 剩余名额从全局最高分中取(不重复)
|
||||
"""
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
|
||||
medium_bucket = [
|
||||
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||
]
|
||||
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
|
||||
unknown_bucket = [d for d in scored if d.duration is None]
|
||||
|
||||
buckets = [short_bucket, medium_bucket, long_bucket]
|
||||
bucket_names = ["short", "medium", "long"]
|
||||
|
||||
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
|
||||
base_quota = max(1, count // 3)
|
||||
|
||||
selected: list[AssetScoreDetail] = []
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket, _name in zip(buckets, bucket_names, strict=False):
|
||||
quota = min(base_quota, len(bucket))
|
||||
if quota <= 0:
|
||||
continue
|
||||
# 桶内已经按分数排好序了,直接取前 quota 个
|
||||
for item in bucket[:quota]:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
return selected
|
||||
|
||||
# 剩余名额:从全局(未被选中的)中按分数高低取
|
||||
remaining_needed = count - len(selected)
|
||||
if remaining_needed > 0:
|
||||
for item in scored:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
# 如果还不够(不应该发生),加上未知时长的
|
||||
if len(selected) < count and unknown_bucket:
|
||||
for item in unknown_bucket:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
return selected[:count]
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "service": "api"}
|
||||
Generated
-6117
File diff suppressed because it is too large
Load Diff
Regular → Executable
Regular → Executable
@@ -8,8 +8,8 @@
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editing-planner"
|
||||
import type { EditPlanConfig } from "./template-editor"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
|
||||
import type { EditPlanConfig } from "./templateEditor"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/template-editor"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/templateEditor"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
Regular → Executable
Regular → Executable
@@ -12,8 +12,8 @@
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import "./clone-modal.css"
|
||||
|
||||
|
||||
Regular → Executable
@@ -20,14 +20,7 @@ export const useLogin = () => {
|
||||
const data = await mutation.mutateAsync(credentials)
|
||||
const refreshToken = data.refresh_token ?? null
|
||||
|
||||
// 先存 token 到 localStorage,确保后续请求拦截器能取到
|
||||
// (apiClient 拦截器从 localStorage 读 access_token)
|
||||
localStorage.setItem("access_token", data.access_token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
}
|
||||
|
||||
// 再获取用户信息(这时候请求带 Authorization header)
|
||||
// 获取用户信息
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
|
||||
@@ -61,15 +54,6 @@ export const useWechatCallback = () => {
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await mutation.mutateAsync({ code, state })
|
||||
|
||||
// 先存 token 到 localStorage,确保后续请求拦截器能取到
|
||||
// (apiClient 拦截器从 localStorage 读 access_token)
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
}
|
||||
|
||||
// 再获取用户信息(这时候请求带 Authorization header)
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* 全部完成(ready / failed)后停止轮询。
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { getVoiceClones } from "@/api/voice-clone"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { getVoiceClones } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
|
||||
const POLL_INTERVAL = 3000 // 3 秒
|
||||
|
||||
|
||||
Regular → Executable
Regular → Executable
+1128
-156
File diff suppressed because it is too large
Load Diff
@@ -1,128 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import { thumbGradient } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
import { StatusPill } from "./AssetSkeleton"
|
||||
|
||||
/* ============================================================
|
||||
* AssetCard — 素材卡片(网格视图)
|
||||
* ============================================================ */
|
||||
export interface AssetCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
diagnosing?: boolean
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default AssetCard
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from "react"
|
||||
import { SearchOutlined } from "@ant-design/icons"
|
||||
import { Button, Input, Select } from "@/components/ui"
|
||||
import { TYPE_FILTER_OPTIONS, TIME_FILTER_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* AssetFilterBar — 筛选栏(搜索/类型/时间 + 全选/计数)
|
||||
* ============================================================ */
|
||||
export interface AssetFilterBarProps {
|
||||
searchText: string
|
||||
onSearchChange: (value: string) => void
|
||||
filterType: string
|
||||
onFilterTypeChange: (value: string) => void
|
||||
filterTime: string
|
||||
onFilterTimeChange: (value: string) => void
|
||||
onSelectAll: () => void
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const AssetFilterBar: React.FC<AssetFilterBarProps> = ({
|
||||
searchText,
|
||||
onSearchChange,
|
||||
filterType,
|
||||
onFilterTypeChange,
|
||||
filterTime,
|
||||
onFilterTimeChange,
|
||||
onSelectAll,
|
||||
totalCount,
|
||||
}) => (
|
||||
<div className="xx-assets-filters">
|
||||
<div className="xx-assets-filters-left">
|
||||
<Input
|
||||
placeholder="搜索素材名称..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
value={filterType}
|
||||
onChange={onFilterTypeChange}
|
||||
style={{ width: 120 }}
|
||||
options={TYPE_FILTER_OPTIONS}
|
||||
/>
|
||||
<Select
|
||||
value={filterTime}
|
||||
onChange={onFilterTimeChange}
|
||||
style={{ width: 120 }}
|
||||
options={TIME_FILTER_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-assets-filters-right">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<span className="xx-assets-filter-count">共 {totalCount} 个素材</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default AssetFilterBar
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from "react"
|
||||
import type { StatusType } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
* StatusPill — 状态标签
|
||||
* ============================================================ */
|
||||
export interface StatusPillProps {
|
||||
status: StatusType
|
||||
label: string
|
||||
}
|
||||
|
||||
export const StatusPill: React.FC<StatusPillProps> = ({ status, label }) => (
|
||||
<span className={`xx-status-pill xx-status-pill-${status}`}>{label}</span>
|
||||
)
|
||||
|
||||
/* ============================================================
|
||||
* SkeletonCard — 骨架屏卡片(素材列表加载时占位)
|
||||
* ============================================================ */
|
||||
export const SkeletonCard: React.FC = () => (
|
||||
<div className="xx-asset-card xx-asset-skeleton">
|
||||
<div className="xx-asset-thumb xx-skeleton-pulse" />
|
||||
<div className="xx-asset-info">
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "70%" }} />
|
||||
<div className="xx-skeleton-line xx-skeleton-pulse" style={{ width: "40%", marginTop: 8 }} />
|
||||
<div
|
||||
className="xx-skeleton-line xx-skeleton-pulse"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 28,
|
||||
marginTop: 8,
|
||||
borderRadius: "var(--radius-xs)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Select as AntSelect } from "antd"
|
||||
import { CATEGORY_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* BatchClassifyModal — 批量改分类弹窗
|
||||
* ============================================================ */
|
||||
export interface BatchClassifyModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
category: string
|
||||
onCategoryChange: (value: string) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchClassifyModal: React.FC<BatchClassifyModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
category,
|
||||
onCategoryChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">将选中的 {selectedCount} 个素材统一修改为以下分类:</p>
|
||||
<AntSelect
|
||||
value={category || undefined}
|
||||
onChange={(v) => onCategoryChange(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={CATEGORY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchClassifyModal
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Tag, Radio } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* BatchMarkModal — 批量智能标记弹窗
|
||||
* ============================================================ */
|
||||
export type SmartViewType = "recommended" | "caution" | "high_risk"
|
||||
|
||||
export interface BatchMarkModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
smartView: SmartViewType
|
||||
onSmartViewChange: (value: SmartViewType) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchMarkModal: React.FC<BatchMarkModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
smartView,
|
||||
onSmartViewChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">将选中的 {selectedCount} 个素材标记为:</p>
|
||||
<Radio.Group
|
||||
value={smartView}
|
||||
onChange={(e) => onSmartViewChange(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">质量优良,可直接用于生产</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">存在一定问题,需人工审核后再使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">存在严重问题,不建议使用</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchMarkModal
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
DeleteOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import { Button } from "@/components/ui"
|
||||
|
||||
/* ============================================================
|
||||
* BatchOperationBar — 批量操作栏
|
||||
* ============================================================ */
|
||||
export interface BatchOperationBarProps {
|
||||
selectedCount: number
|
||||
onDeselectAll: () => void
|
||||
onTagClick: () => void
|
||||
onClassifyClick: () => void
|
||||
onMarkClick: () => void
|
||||
onBatchDelete: () => void
|
||||
}
|
||||
|
||||
const BatchOperationBar: React.FC<BatchOperationBarProps> = ({
|
||||
selectedCount,
|
||||
onDeselectAll,
|
||||
onTagClick,
|
||||
onClassifyClick,
|
||||
onMarkClick,
|
||||
onBatchDelete,
|
||||
}) => (
|
||||
<div className="xx-assets-batch-bar">
|
||||
<span className="xx-assets-batch-count">已选 {selectedCount} 项</span>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={onDeselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<TagsOutlined />} onClick={onTagClick}>
|
||||
打标签
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<FolderOutlined />} onClick={onClassifyClick}>
|
||||
改分类
|
||||
</Button>
|
||||
<Button buttonType="ghost" buttonSize="sm" icon={<ThunderboltOutlined />} onClick={onMarkClick}>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedCount} 个素材?`}
|
||||
onConfirm={onBatchDelete}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button buttonType="danger" buttonSize="sm" icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default BatchOperationBar
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal, Tag, Radio, Input as AntInput } from "antd"
|
||||
import { ExclamationCircleOutlined } from "@ant-design/icons"
|
||||
|
||||
/* ============================================================
|
||||
* BatchTagModal — 批量打标签弹窗
|
||||
* ============================================================ */
|
||||
export interface BatchTagModalProps {
|
||||
open: boolean
|
||||
selectedCount: number
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
tags: string[]
|
||||
onTagInputChange: (value: string) => void
|
||||
onTagInputKeyDown: (e: React.KeyboardEvent) => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
tagInput: string
|
||||
tagMode: "add" | "replace"
|
||||
onTagModeChange: (mode: "add" | "replace") => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const BatchTagModal: React.FC<BatchTagModalProps> = ({
|
||||
open,
|
||||
selectedCount,
|
||||
onCancel,
|
||||
onOk,
|
||||
tags,
|
||||
onTagInputChange,
|
||||
onTagInputKeyDown,
|
||||
onRemoveTag,
|
||||
tagInput,
|
||||
tagMode,
|
||||
onTagModeChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedCount} 个素材)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={confirmLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group value={tagMode} onChange={(e) => onTagModeChange(e.target.value)}>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={tagInput}
|
||||
onChange={(e) => onTagInputChange(e.target.value)}
|
||||
onKeyDown={onTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} closable onClose={() => onRemoveTag(tag)} color="blue">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && tags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default BatchTagModal
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import { Input, Select } from "@/components/ui"
|
||||
import type { AssetKind } from "@/pages/assets/types"
|
||||
import { LIBRARY_KIND_OPTIONS } from "@/pages/assets/constants"
|
||||
|
||||
/* ============================================================
|
||||
* CreateLibraryModal — 新建视频库弹窗
|
||||
* ============================================================ */
|
||||
export interface CreateLibraryModalProps {
|
||||
open: boolean
|
||||
onCancel: () => void
|
||||
onOk: () => void
|
||||
name: string
|
||||
onNameChange: (value: string) => void
|
||||
kind: AssetKind
|
||||
onKindChange: (value: AssetKind) => void
|
||||
confirmLoading?: boolean
|
||||
}
|
||||
|
||||
const CreateLibraryModal: React.FC<CreateLibraryModalProps> = ({
|
||||
open,
|
||||
onCancel,
|
||||
onOk,
|
||||
name,
|
||||
onNameChange,
|
||||
kind,
|
||||
onKindChange,
|
||||
confirmLoading,
|
||||
}) => (
|
||||
<AntModal
|
||||
title="新建视频库"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
confirmLoading={confirmLoading}
|
||||
>
|
||||
<div className="xx-asset-form-body">
|
||||
<div>
|
||||
<div className="xx-asset-form-label">名称</div>
|
||||
<Input
|
||||
placeholder="请输入视频库名称"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="xx-asset-form-label">类型</div>
|
||||
<Select
|
||||
value={kind}
|
||||
onChange={(v) => onKindChange(v)}
|
||||
style={{ width: "100%" }}
|
||||
options={LIBRARY_KIND_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default CreateLibraryModal
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from "react"
|
||||
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { LibraryItem } from "@/pages/assets/types"
|
||||
import { kindLabel } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
|
||||
/* ============================================================
|
||||
* LibrarySidebar — 左侧素材库列表
|
||||
* ============================================================ */
|
||||
export interface LibrarySidebarProps {
|
||||
libraries: LibraryItem[]
|
||||
activeLibId: string
|
||||
onSelect: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
onCreateClick: () => void
|
||||
}
|
||||
|
||||
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
libraries,
|
||||
activeLibId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onCreateClick,
|
||||
}) => (
|
||||
<div className="xx-asset-library-list">
|
||||
{libraries.map((lib) => (
|
||||
<div
|
||||
key={lib.id}
|
||||
className={`xx-asset-library-item${lib.id === activeLibId ? " active" : ""}`}
|
||||
onClick={() => onSelect(lib.id)}
|
||||
>
|
||||
<div className="xx-asset-library-header">
|
||||
<h4>
|
||||
{kindIcon(lib.kind)} {lib.name}
|
||||
</h4>
|
||||
<Popconfirm
|
||||
title={`确定删除视频库 "${lib.name}"?`}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete(lib.id)
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<button
|
||||
className="xx-asset-library-delete"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="删除视频库"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<span>
|
||||
{kindLabel(lib.kind)} · {lib.count} 个素材
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 新建视频库 */}
|
||||
<div className="xx-asset-library-add" onClick={onCreateClick}>
|
||||
<PlusOutlined />
|
||||
新建视频库
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default LibrarySidebar
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
|
||||
/* ============================================================
|
||||
* PlayModal — 视频/音频播放弹窗
|
||||
* ============================================================ */
|
||||
export interface PlayModalProps {
|
||||
open: boolean
|
||||
asset: AssetItem | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const PlayModal: React.FC<PlayModalProps> = ({ open, asset, onClose }) => (
|
||||
<AntModal
|
||||
title={asset?.name ?? "播放"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{asset?.fileUrl ? (
|
||||
<video src={asset.fileUrl} controls autoPlay className="xx-asset-video-player" />
|
||||
) : (
|
||||
<div className="xx-asset-empty-fallback">
|
||||
<p>暂无可播放的文件地址</p>
|
||||
<p className="xx-asset-empty-fallback-id">素材 ID: {asset?.id}</p>
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default PlayModal
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from "react"
|
||||
import { Drawer } from "antd"
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from "@ant-design/icons"
|
||||
import type { BatchOperationResult } from "@/api/assets"
|
||||
|
||||
/* ============================================================
|
||||
* ResultDrawer — 操作结果抽屉
|
||||
* ============================================================ */
|
||||
export interface ResultDrawerProps {
|
||||
open: boolean
|
||||
title: string
|
||||
result: BatchOperationResult | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ResultDrawer: React.FC<ResultDrawerProps> = ({ open, title, result, onClose }) => (
|
||||
<Drawer title={`${title} — 操作结果`} open={open} onClose={onClose} width={420}>
|
||||
{result && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">总计 {result.total} 个</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {result.success_count} 个</span>
|
||||
</div>
|
||||
{result.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {result.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(result?.succeeded?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{result?.succeeded?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result?.failed?.length ?? 0) > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{result?.failed?.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
export default ResultDrawer
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
|
||||
* ============================================================ */
|
||||
export interface UploadProgressModalProps {
|
||||
open: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
|
||||
<AntModal
|
||||
open={open}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{progress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default UploadProgressModal
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 素材库常量
|
||||
*/
|
||||
import type { AssetKind } from "./types"
|
||||
|
||||
/** 文件大小限制 */
|
||||
export const MAX_FILE_SIZE = 2048 * 1024 * 1024
|
||||
export const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
|
||||
|
||||
/** 素材类型标签 */
|
||||
export const KIND_LABELS: Record<AssetKind, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
}
|
||||
|
||||
/** 类型筛选选项 */
|
||||
export const TYPE_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
/** 时间筛选选项 */
|
||||
export const TIME_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "all", label: "全部时间" },
|
||||
{ value: "today", label: "今天" },
|
||||
{ value: "week", label: "近一周" },
|
||||
{ value: "month", label: "近一月" },
|
||||
]
|
||||
|
||||
/** 新建库类型选项(当前仅支持视频和图片) */
|
||||
export const LIBRARY_KIND_OPTIONS: { value: AssetKind; label: string }[] = [
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "image", label: "图片" },
|
||||
]
|
||||
|
||||
/** 分类选项 */
|
||||
export const CATEGORY_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "person", label: "人物" },
|
||||
{ value: "scenic", label: "风景" },
|
||||
{ value: "product", label: "产品" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "animal", label: "动物" },
|
||||
{ value: "architecture", label: "建筑" },
|
||||
{ value: "other", label: "其他" },
|
||||
]
|
||||
|
||||
/** 智能标记视图 */
|
||||
export const SMART_VIEW_OPTIONS: {
|
||||
value: "recommended" | "caution" | "high_risk"
|
||||
label: string
|
||||
color: string
|
||||
}[] = [
|
||||
{ value: "recommended", label: "推荐", color: "success" },
|
||||
{ value: "caution", label: "慎用", color: "warning" },
|
||||
{ value: "high_risk", label: "高风险", color: "error" },
|
||||
]
|
||||
|
||||
/** 状态配置 */
|
||||
export const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
ok: { label: "合格", className: "xx-status-pill xx-status-pill-ok" },
|
||||
warn: { label: "待优化", className: "xx-status-pill xx-status-pill-warn" },
|
||||
bad: { label: "不合格", className: "xx-status-pill xx-status-pill-bad" },
|
||||
info: { label: "待诊断", className: "xx-status-pill xx-status-pill-info" },
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import {
|
||||
deleteAsset,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets"
|
||||
import type { AssetItem } from "../types"
|
||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||
|
||||
/**
|
||||
* 素材操作 Hook
|
||||
* 封装素材的诊断、删除、批量打标签、批量改分类、批量智能标记等操作,
|
||||
* 以及相关弹窗和结果展示的状态管理
|
||||
*/
|
||||
interface UseAssetOperationsProps {
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: (ids: Set<string>) => void
|
||||
}
|
||||
|
||||
export function useAssetOperations({ selectedIds, setSelectedIds }: UseAssetOperationsProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 诊断状态 ── */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null)
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false)
|
||||
|
||||
/* ── 批量打标签表单 ── */
|
||||
const [batchTagInput, setBatchTagInput] = useState("")
|
||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||
|
||||
/* ── 批量改分类表单 ── */
|
||||
const [batchCategory, setBatchCategory] = useState("")
|
||||
|
||||
/* ── 批量智能标记表单 ── */
|
||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||
|
||||
/* ── 操作结果 ── */
|
||||
const [operationResult, setOperationResult] = useState<BatchOperationResult | null>(null)
|
||||
const [operationTitle, setOperationTitle] = useState("")
|
||||
|
||||
/* ── 批量操作 loading ── */
|
||||
const [batchLoading, setBatchLoading] = useState(false)
|
||||
|
||||
/* ── 刷新数据辅助函数 ── */
|
||||
const invalidateAssets = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient])
|
||||
|
||||
/* ── 诊断 ── */
|
||||
const handleDiagnose = useCallback(
|
||||
async (asset: AssetItem) => {
|
||||
setDiagnosingId(asset.id)
|
||||
try {
|
||||
const result = await getAssetDiagnosis(asset.id)
|
||||
const score = result.readiness_score ?? "-"
|
||||
message.success(`"${asset.name}" 诊断完成,就绪分:${score}`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
} catch {
|
||||
message.error(`"${asset.name}" 诊断失败`)
|
||||
} finally {
|
||||
setDiagnosingId(null)
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
/* ── 单个素材删除 ── */
|
||||
const handleSingleDelete = useCallback(
|
||||
async (assetId: string) => {
|
||||
try {
|
||||
await deleteAsset(assetId)
|
||||
invalidateAssets()
|
||||
// 从选中集合中移除
|
||||
setSelectedIds(
|
||||
(() => {
|
||||
const next = new Set(selectedIds)
|
||||
next.delete(assetId)
|
||||
return next
|
||||
})(),
|
||||
)
|
||||
message.success("素材已删除")
|
||||
} catch {
|
||||
message.error("删除失败,请重试")
|
||||
}
|
||||
},
|
||||
[invalidateAssets, selectedIds, setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 显示操作结果 ── */
|
||||
const showOperationResult = useCallback(
|
||||
(result: BatchOperationResult, title: string, clearSelection = true) => {
|
||||
setOperationResult(result)
|
||||
setOperationTitle(title)
|
||||
setResultDrawerOpen(true)
|
||||
if (clearSelection) setSelectedIds(new Set())
|
||||
},
|
||||
[setSelectedIds],
|
||||
)
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids)
|
||||
invalidateAssets()
|
||||
showOperationResult(result, "批量删除")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [selectedIds, invalidateAssets, showOperationResult])
|
||||
|
||||
/* ── 批量打标签 ── */
|
||||
const handleBatchTag = useCallback(async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量打标签")
|
||||
setTagModalOpen(false)
|
||||
setBatchTags([])
|
||||
setBatchTagInput("")
|
||||
setTagMode("add")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchTags, selectedIds, tagMode, queryClient, showOperationResult])
|
||||
|
||||
/* ── 标签输入处理 ── */
|
||||
const handleTagInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault()
|
||||
const tag = batchTagInput.trim()
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag])
|
||||
}
|
||||
setBatchTagInput("")
|
||||
}
|
||||
},
|
||||
[batchTagInput, batchTags],
|
||||
)
|
||||
|
||||
const removeBatchTag = useCallback(
|
||||
(tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||
},
|
||||
[batchTags],
|
||||
)
|
||||
|
||||
/* ── 批量改分类 ── */
|
||||
const handleBatchClassify = useCallback(async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类")
|
||||
return
|
||||
}
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量改分类")
|
||||
setClassifyModalOpen(false)
|
||||
setBatchCategory("")
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchCategory, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 批量智能标记 ── */
|
||||
const handleBatchMark = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchLoading(true)
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
showOperationResult(result, "批量智能标记")
|
||||
setMarkModalOpen(false)
|
||||
const labelMap: Record<SmartViewType, string> = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
}
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
)
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试")
|
||||
} finally {
|
||||
setBatchLoading(false)
|
||||
}
|
||||
}, [batchSmartView, selectedIds, queryClient, showOperationResult])
|
||||
|
||||
/* ── 关闭结果 Drawer ── */
|
||||
const handleResultDrawerClose = useCallback(() => {
|
||||
setResultDrawerOpen(false)
|
||||
setOperationResult(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// 诊断
|
||||
diagnosingId,
|
||||
handleDiagnose,
|
||||
// 单个操作
|
||||
handleSingleDelete,
|
||||
// 批量操作 loading
|
||||
batchLoading,
|
||||
// 批量打标签
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
batchTagInput,
|
||||
setBatchTagInput,
|
||||
batchTags,
|
||||
setBatchTags,
|
||||
tagMode,
|
||||
setTagMode,
|
||||
handleBatchTag,
|
||||
handleTagInputKeyDown,
|
||||
removeBatchTag,
|
||||
// 批量改分类
|
||||
classifyModalOpen,
|
||||
setClassifyModalOpen,
|
||||
batchCategory,
|
||||
setBatchCategory,
|
||||
handleBatchClassify,
|
||||
// 批量智能标记
|
||||
markModalOpen,
|
||||
setMarkModalOpen,
|
||||
batchSmartView,
|
||||
setBatchSmartView,
|
||||
handleBatchMark,
|
||||
// 批量删除
|
||||
handleBatchDelete,
|
||||
// 操作结果
|
||||
resultDrawerOpen,
|
||||
operationResult,
|
||||
operationTitle,
|
||||
handleResultDrawerClose,
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import type { AssetItem } from "../types"
|
||||
|
||||
/**
|
||||
* 素材选中态管理 Hook
|
||||
* 封装单选、全选、取消全选等选中逻辑
|
||||
*/
|
||||
interface UseAssetSelectionProps {
|
||||
filteredAssets: AssetItem[]
|
||||
}
|
||||
|
||||
export function useAssetSelection({ filteredAssets }: UseAssetSelectionProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const toggleSelect = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedIds(new Set(filteredAssets.map((a) => a.id)))
|
||||
}, [filteredAssets])
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [])
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
toggleSelect,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
selectedCount: selectedIds.size,
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { uploadAssetDirect } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
|
||||
|
||||
/**
|
||||
* 素材上传 Hook
|
||||
* 封装上传状态、进度管理和上传逻辑
|
||||
*/
|
||||
interface UseAssetUploadProps {
|
||||
effectiveLibId: string
|
||||
}
|
||||
|
||||
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
return
|
||||
}
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`)
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
})
|
||||
message.success(`"${file.name}" 上传成功`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : ""
|
||||
console.error("[handleUpload] 上传失败:", err)
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
}
|
||||
},
|
||||
[effectiveLibId, queryClient],
|
||||
)
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadProgress,
|
||||
handleUpload,
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
getAssetLibraries,
|
||||
getAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
} from "@/api/assets"
|
||||
import { mapLibrary, mapAsset, type AssetItem, type LibraryItem } from "../types"
|
||||
|
||||
/**
|
||||
* 素材库数据 Hook
|
||||
* 封装视频库列表、素材列表的数据查询,以及筛选、搜索状态管理
|
||||
*/
|
||||
export function useAssetsData() {
|
||||
/* ── 视频库列表查询 ── */
|
||||
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
|
||||
queryKey: ["asset-libraries"],
|
||||
queryFn: getAssetLibraries,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
const libraries: LibraryItem[] = useMemo(
|
||||
() =>
|
||||
(Array.isArray(apiLibraries) ? apiLibraries : [])
|
||||
.map(mapLibrary)
|
||||
.filter((lib) => lib.kind === "video"),
|
||||
[apiLibraries],
|
||||
)
|
||||
|
||||
/* ── 当前选中的视频库 ── */
|
||||
const [activeLibId, setActiveLibId] = useState<string>("")
|
||||
|
||||
// 当库列表加载完成后,自动选中第一个
|
||||
const effectiveLibId = activeLibId || libraries[0]?.id || ""
|
||||
|
||||
/* ── 当前库的素材列表查询 ── */
|
||||
const {
|
||||
data: apiAssets = { items: [], total: 0 },
|
||||
isLoading: assetsLoading,
|
||||
isError: assetsError,
|
||||
error: assetsErrorObj,
|
||||
refetch: refetchAssets,
|
||||
} = useQuery<{ items: ApiAssetItem[]; total: number }, Error>({
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const assets: AssetItem[] = useMemo(
|
||||
() => (Array.isArray(apiAssets?.items) ? apiAssets.items : []).map(mapAsset),
|
||||
[apiAssets],
|
||||
)
|
||||
|
||||
/* ── 筛选状态 ── */
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState<string>("all")
|
||||
const [filterTime, setFilterTime] = useState<string>("all")
|
||||
|
||||
/* ── 筛选后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets
|
||||
|
||||
/* 按素材类型过滤 */
|
||||
if (filterType !== "all") {
|
||||
list = list.filter((a) => a.kind === filterType)
|
||||
}
|
||||
|
||||
/* 按时间筛选 */
|
||||
if (filterTime !== "all") {
|
||||
const now = new Date()
|
||||
list = list.filter((a) => {
|
||||
const d = new Date(a.createdAt)
|
||||
const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (filterTime === "today") return diffDays < 1
|
||||
if (filterTime === "week") return diffDays < 7
|
||||
if (filterTime === "month") return diffDays < 30
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase()
|
||||
list = list.filter((a) => a.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return list
|
||||
}, [assets, filterType, filterTime, searchText])
|
||||
|
||||
return {
|
||||
// 视频库
|
||||
libraries,
|
||||
libLoading,
|
||||
activeLibId,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
// 素材列表
|
||||
assets,
|
||||
assetsLoading,
|
||||
assetsError,
|
||||
assetsErrorObj,
|
||||
refetchAssets,
|
||||
// 筛选
|
||||
searchText,
|
||||
setSearchText,
|
||||
filterType,
|
||||
setFilterType,
|
||||
filterTime,
|
||||
setFilterTime,
|
||||
filteredAssets,
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { createAssetLibrary, deleteAssetLibrary } from "@/api/assets"
|
||||
import type { AssetKind, LibraryItem } from "../types"
|
||||
|
||||
/**
|
||||
* 视频库管理 Hook
|
||||
* 封装视频库的创建、删除操作,以及新建弹窗的表单状态
|
||||
*/
|
||||
interface UseLibraryManagementProps {
|
||||
libraries: LibraryItem[]
|
||||
activeLibId: string
|
||||
setActiveLibId: (id: string) => void
|
||||
effectiveLibId: string
|
||||
}
|
||||
|
||||
export function useLibraryManagement({
|
||||
libraries,
|
||||
setActiveLibId,
|
||||
effectiveLibId,
|
||||
}: UseLibraryManagementProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 状态 ── */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
const [newLibName, setNewLibName] = useState("")
|
||||
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
|
||||
|
||||
/* ── Mutations ── */
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库创建成功")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("创建视频库失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteLibMutation = useMutation({
|
||||
mutationFn: deleteAssetLibrary,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
message.success("视频库已删除")
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除视频库失败")
|
||||
},
|
||||
})
|
||||
|
||||
/* ── 新建视频库 ── */
|
||||
const handleCreateLibrary = useCallback(async () => {
|
||||
if (!newLibName.trim()) {
|
||||
message.warning("请输入视频库名称")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const newLib = await createLibMutation.mutateAsync({
|
||||
name: newLibName.trim(),
|
||||
kind: newLibKind,
|
||||
})
|
||||
setActiveLibId(newLib.id)
|
||||
setCreateModalOpen(false)
|
||||
setNewLibName("")
|
||||
setNewLibKind("video")
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
}, [newLibName, newLibKind, createLibMutation, setActiveLibId])
|
||||
|
||||
/* ── 删除视频库 ── */
|
||||
const handleDeleteLibrary = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await deleteLibMutation.mutateAsync(id)
|
||||
if (effectiveLibId === id) {
|
||||
const remaining = libraries.filter((l) => l.id !== id)
|
||||
if (remaining.length > 0) setActiveLibId(remaining[0].id)
|
||||
else setActiveLibId("")
|
||||
}
|
||||
} catch {
|
||||
// error handled in mutation
|
||||
}
|
||||
},
|
||||
[deleteLibMutation, effectiveLibId, libraries, setActiveLibId],
|
||||
)
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
createModalOpen,
|
||||
setCreateModalOpen,
|
||||
// 表单状态
|
||||
newLibName,
|
||||
setNewLibName,
|
||||
newLibKind,
|
||||
setNewLibKind,
|
||||
// Mutations
|
||||
isCreating: createLibMutation.isPending,
|
||||
isDeleting: deleteLibMutation.isPending,
|
||||
// Handlers
|
||||
handleCreateLibrary,
|
||||
handleDeleteLibrary,
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* 素材库类型定义
|
||||
*/
|
||||
import type { AssetLibraryItem, AssetItem as ApiAssetItem } from "@/api/assets"
|
||||
import { formatDuration } from "./utils/format"
|
||||
|
||||
export type AssetKind = "video" | "image" | "voice"
|
||||
export type StatusType = "ok" | "warn" | "bad" | "info"
|
||||
|
||||
export interface LibraryItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: AssetKind
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AssetItem {
|
||||
id: string
|
||||
name: string
|
||||
kind: AssetKind
|
||||
thumbUrl?: string
|
||||
fileUrl?: string
|
||||
status: StatusType
|
||||
statusLabel: string
|
||||
/** 是否处于处理中状态(上传中/入库中/诊断中) */
|
||||
loading?: boolean
|
||||
duration?: string
|
||||
size: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
export const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("audio/")) return "voice"
|
||||
return "image"
|
||||
}
|
||||
|
||||
/** 根据 quality_score / classification_status / asset status 推断前端状态 */
|
||||
export const inferStatus = (
|
||||
score?: number,
|
||||
classificationStatus?: string,
|
||||
assetStatus?: string,
|
||||
): { status: StatusType; label: string; loading?: boolean } => {
|
||||
// 已删除素材(正常情况列表已过滤,这里是防御性处理)
|
||||
if (assetStatus === "deleted") {
|
||||
return { status: "bad", label: "已删除" }
|
||||
}
|
||||
// 处理中状态:上传中 / 入库中 / 处理中
|
||||
if (
|
||||
assetStatus === "uploading" ||
|
||||
assetStatus === "ingesting" ||
|
||||
assetStatus === "processing" ||
|
||||
assetStatus === "pending"
|
||||
) {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
// 失败状态
|
||||
if (assetStatus === "error" || assetStatus === "failed") {
|
||||
return { status: "bad", label: "处理失败" }
|
||||
}
|
||||
// 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中"
|
||||
if (assetStatus === "ready") {
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
if (score >= 40) return { status: "warn", label: "待优化" }
|
||||
return { status: "bad", label: "不合格" }
|
||||
}
|
||||
// 素材未就绪:classification 正在处理中
|
||||
if (classificationStatus === "processing" || classificationStatus === "pending") {
|
||||
return { status: "info", label: "处理中", loading: true }
|
||||
}
|
||||
if (score == null) return { status: "info", label: "待诊断" }
|
||||
if (score >= 70) return { status: "ok", label: "合格" }
|
||||
if (score >= 40) return { status: "warn", label: "待优化" }
|
||||
return { status: "bad", label: "不合格" }
|
||||
}
|
||||
|
||||
/** 将后端 AssetLibraryItem 映射为前端 LibraryItem */
|
||||
export const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
})
|
||||
|
||||
/** 将后端 ApiAssetItem 映射为前端 AssetItem */
|
||||
export const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
const { status, label, loading } = inferStatus(
|
||||
item.quality_score ?? undefined,
|
||||
item.classification_status ?? undefined,
|
||||
item.status ?? undefined,
|
||||
)
|
||||
const metadata = item.metadata || {}
|
||||
const kind = inferKind(item.mime_type || "")
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
// 处理中的素材没有缩略图,显示占位符
|
||||
thumbUrl: loading
|
||||
? undefined
|
||||
: (item.thumbnail_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined),
|
||||
status,
|
||||
statusLabel: label,
|
||||
loading,
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* 素材相关工具函数
|
||||
*/
|
||||
import type { AssetKind } from "../types"
|
||||
import { KIND_LABELS } from "../constants"
|
||||
|
||||
export const kindLabel = (kind: AssetKind): string => KIND_LABELS[kind] ?? kind
|
||||
|
||||
/** 根据素材类型返回渐变背景 */
|
||||
export const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 格式化工具函数
|
||||
*/
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export const formatDate = (iso: string): string =>
|
||||
new Date(iso).toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, PictureOutlined, AudioOutlined } from "@ant-design/icons"
|
||||
import type { AssetKind } from "../types"
|
||||
|
||||
/** 根据素材类型返回对应图标 */
|
||||
export const kindIcon = (kind: AssetKind): React.ReactNode => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />
|
||||
case "image":
|
||||
return <PictureOutlined />
|
||||
case "voice":
|
||||
return <AudioOutlined />
|
||||
}
|
||||
}
|
||||
Regular → Executable
@@ -39,13 +39,7 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
|
||||
// 先把 token 存到 localStorage,让请求拦截器能拿到(getCurrentUser 需要带 token)
|
||||
localStorage.setItem("access_token", result.access_token)
|
||||
if (result.refresh_token) {
|
||||
localStorage.setItem("refresh_token", result.refresh_token)
|
||||
}
|
||||
|
||||
// 获取用户信息(这时候请求拦截器能拿到 token 了)
|
||||
// 获取用户信息
|
||||
const userData = await getCurrentUser()
|
||||
const user: User = normalizeUser(userData)
|
||||
setAuth(user, result.access_token, result.refresh_token)
|
||||
|
||||
Regular → Executable
@@ -11,7 +11,7 @@ import type {
|
||||
TemplateCategory,
|
||||
TemplateMode,
|
||||
SaveTemplatePayload,
|
||||
} from "@/api/editing-planner"
|
||||
} from "@/api/editingPlanner"
|
||||
import {
|
||||
getEditingTemplates,
|
||||
getEditingTemplate,
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editing-planner"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/template-editor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/template-editor"
|
||||
} from "@/api/editingPlanner"
|
||||
import type { MediaAsset, TransitionEffect, TitleConfig } from "@/api/templateEditor"
|
||||
import { getMediaAssets, getEditPlan, getEditPlanClips } from "@/api/templateEditor"
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo"
|
||||
import type {
|
||||
ClipData,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { TemplateMode } from "@/api/editingPlanner"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SubtitleSettings {
|
||||
|
||||
Regular → Executable
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/template-editor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/template-editor"
|
||||
import type { EditPlanGeneration } from "@/api/templateEditor"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/templateEditor"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useCallback } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import AssetSelector from "@/components/asset-selector/AssetSelector"
|
||||
import type { EditingTemplate } from "@/api/editingPlanner"
|
||||
import { MODE_LABELS } from "@/api/editingPlanner"
|
||||
import type { MediaAsset } from "@/api/templateEditor"
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[]
|
||||
|
||||
Regular → Executable
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type { TitleConfig } from "@/api/templateEditor"
|
||||
import type { CoverConfig } from "../types"
|
||||
|
||||
interface SubtitleSettings {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import React from "react"
|
||||
import { Modal, Input, Select } from "@/components/ui"
|
||||
import type { TemplateCategory } from "@/api/editing-planner"
|
||||
import type { TemplateCategory } from "@/api/editingPlanner"
|
||||
|
||||
interface SaveModalProps {
|
||||
open: boolean
|
||||
|
||||
Regular → Executable
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react"
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Slider } from "antd"
|
||||
import { TRANSITION_OPTIONS } from "@/api/template-editor"
|
||||
import { TRANSITION_OPTIONS } from "@/api/templateEditor"
|
||||
import type { TransitionConfig, TransitionType } from "../types"
|
||||
import { DEFAULT_TRANSITION } from "../types"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/template-editor"
|
||||
} from "@/api/templateEditor"
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/template-editor"
|
||||
} from "@/api/templateEditor"
|
||||
import { useUndoRedo } from "./useUndoRedo"
|
||||
|
||||
const QUERY_KEY = "editPlanClips"
|
||||
|
||||
Regular → Executable
Regular → Executable
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user