Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca2ffe272 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 5bc3440370 | |||
| 7dc92191e0 | |||
| 5028956cea | |||
| d72450f42d | |||
| 1ed0d5aa75 | |||
| aef4febd1c | |||
| d99ee6fc84 | |||
| 9b034764ad | |||
| bfb11c3526 | |||
| 9e37c7b73d | |||
| f7a945d417 | |||
| 8748b43070 | |||
| a28395c318 | |||
| 9f0c064f2a | |||
| 1ea8fd3989 | |||
| 4fee87c5e8 | |||
| a74d25e414 |
@@ -0,0 +1,165 @@
|
||||
name: Auto Approve CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
# 根据目标分支决定检查哪些门禁
|
||||
TARGET_BRANCH="${GITHUB_BASE_REF}"
|
||||
echo "目标分支: ${TARGET_BRANCH}"
|
||||
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
elif [ "$TARGET_BRANCH" = "main" ]; then
|
||||
# main分支只检查required statuses: Validate + Frontend Lint
|
||||
# 不检查Tests/test(不是required门禁)
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
)
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批(任何用户的APPROVED都算,避免重复审批)
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第一步:创建PENDING review(Gitea API需要先创建再提交)
|
||||
echo "创建review..."
|
||||
REVIEW_CREATE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "PENDING", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
REVIEW_ID=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
|
||||
REVIEW_STATE=$(echo "$REVIEW_CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
echo "创建结果: id=$REVIEW_ID state=$REVIEW_STATE"
|
||||
|
||||
if [ -z "$REVIEW_ID" ]; then
|
||||
echo "❌ 创建review失败"
|
||||
echo "$REVIEW_CREATE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 如果已经是APPROVED就不用再submit了(兼容不同Gitea版本)
|
||||
if [ "$REVIEW_STATE" = "APPROVED" ]; then
|
||||
echo "✅ 自动审批成功(直接创建为APPROVED)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 第二步:submit review为APPROVED
|
||||
echo "提交review审批..."
|
||||
SUBMIT_CODE=$(curl -s -o /tmp/submit_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVED", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews/${REVIEW_ID}")
|
||||
|
||||
echo "提交API HTTP状态: $SUBMIT_CODE"
|
||||
cat /tmp/submit_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$SUBMIT_CODE" = "200" ] || [ "$SUBMIT_CODE" = "201" ]; then
|
||||
FINAL_STATE=$(python3 -c "import json; print(json.load(open('/tmp/submit_resp.json')).get('state',''))" 2>/dev/null || echo "?")
|
||||
echo "✅ 自动审批成功 (state: $FINAL_STATE)"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 提交审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
+122
-12
@@ -1,21 +1,131 @@
|
||||
name: Auto Merge PRs
|
||||
name: Auto Merge PRs (main)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved (main)
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'main'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto merge develop PRs
|
||||
|
||||
- name: Auto merge when CI passes and approved
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh develop
|
||||
|
||||
- name: Auto merge main PRs (release only)
|
||||
run: |
|
||||
bash scripts/auto_merge_prs.sh main
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合main分支
|
||||
if [ "$BASE_REF" != "main" ]; then
|
||||
echo "Skip: 目标分支不是main"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# main分支门禁:Validate + Frontend Lint
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"Tests / test (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
echo "检查门禁: ${#CONTEXTS[@]} 项"
|
||||
echo
|
||||
|
||||
# 轮询等待,最多30分钟(180次x10秒)
|
||||
for attempt in $(seq 1 180); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 检查CI状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " CI: ${ctx##*/}: $STATE"
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查审批状态
|
||||
APPROVAL_RESULT=$(python3 scripts/check_pr_approval.py "$MERGE_TOKEN" "$GITHUB_REPOSITORY" "$PR_NUMBER" 1)
|
||||
echo " 审批: $APPROVAL_RESULT"
|
||||
HAS_APPROVAL=false
|
||||
if echo "$APPROVAL_RESULT" | grep -q '^approved'; then
|
||||
HAS_APPROVAL=true
|
||||
fi
|
||||
|
||||
# 全部满足 → 合并
|
||||
if [ "$ALL_SUCCESS" = "true" ] && [ "$HAS_APPROVAL" = "true" ]; then
|
||||
echo
|
||||
echo "CI全绿 + 审批通过,执行自动合并"
|
||||
|
||||
# 幂等检查:PR是否还是open
|
||||
PR_STATE=$(curl -s -H "Authorization: token ${MERGE_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))")
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "PR状态为 ${PR_STATE},无需合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 执行merge(main分支用merge,保留历史)
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"merge","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
echo "合并失败(405),可能有冲突或门禁未通过"
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Auto merge failed: PR may have conflicts or unresolved checks. Please review manually."}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 0
|
||||
else
|
||||
echo "自动合并失败 (HTTP $HTTP_CODE)"
|
||||
cat /tmp/merge_resp.json 2>/dev/null || true
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"body\": \"Auto merge failed (HTTP ${HTTP_CODE}), please check manually.\"}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "CI有失败项,不自动合并"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "等待超时(30分钟)"
|
||||
exit 0
|
||||
|
||||
+81
-232
File diff suppressed because one or more lines are too long
@@ -1,69 +0,0 @@
|
||||
name: Test SSH Secret
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- '.gitea/workflows/test-ssh-secret.yml'
|
||||
|
||||
jobs:
|
||||
test-ssh:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
which ssh || (apt-get update && apt-get install -y openssh-client)
|
||||
ssh -V
|
||||
|
||||
- name: Debug environment
|
||||
run: |
|
||||
echo "=== Environment ==="
|
||||
echo "Runner hostname: $(hostname)"
|
||||
echo "Runner IP: $(hostname -i || echo 'unknown')"
|
||||
echo "Current user: $(whoami)"
|
||||
echo "=== Secrets check ==="
|
||||
if [ -n "$STAGING_SSH_HOST" ]; then
|
||||
echo "STAGING_SSH_HOST: [SET] value_length=${#STAGING_SSH_HOST}"
|
||||
else
|
||||
echo "STAGING_SSH_HOST: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_USER" ]; then
|
||||
echo "STAGING_SSH_USER: [SET] value_length=${#STAGING_SSH_USER}"
|
||||
else
|
||||
echo "STAGING_SSH_USER: [EMPTY]"
|
||||
fi
|
||||
if [ -n "$STAGING_SSH_KEY" ]; then
|
||||
echo "STAGING_SSH_KEY: [SET] value_length=${#STAGING_SSH_KEY}"
|
||||
else
|
||||
echo "STAGING_SSH_KEY: [EMPTY]"
|
||||
fi
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keygen -y -f ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.pub 2>/dev/null || echo "No public key generated"
|
||||
echo "=== SSH Key fingerprint ==="
|
||||
ssh-keygen -lf ~/.ssh/id_ed25519 || echo "Key fingerprint failed"
|
||||
env:
|
||||
STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }}
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
echo "Attempting SSH connection to $STAGING_SSH_HOST..."
|
||||
ssh -i ~/.ssh/id_ed25519 \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=10 \
|
||||
-o BatchMode=yes \
|
||||
-v \
|
||||
$STAGING_SSH_USER@$STAGING_SSH_HOST "echo 'SSH_CONNECTION_SUCCESS' && hostname && whoami"
|
||||
echo "=== SSH Test Complete ==="
|
||||
env:
|
||||
STAGING_SSH_HOST: ${{ secrets.STAGING_SSH_HOST }}
|
||||
STAGING_SSH_USER: ${{ secrets.STAGING_SSH_USER }}
|
||||
@@ -11,9 +11,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
@@ -93,9 +95,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
python - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Add file_hash to assets and ingest_jobs
|
||||
|
||||
Revision ID: 031
|
||||
Revises: 030
|
||||
Create Date: 2026-07-07
|
||||
|
||||
为素材去重检测功能添加 file_hash 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "031"
|
||||
down_revision = "030"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assets", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_assets_file_hash"), "assets", ["file_hash"])
|
||||
|
||||
op.add_column("ingest_jobs", sa.Column("file_hash", sa.String(64), nullable=True))
|
||||
op.create_index(op.f("ix_ingest_jobs_file_hash"), "ingest_jobs", ["file_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_ingest_jobs_file_hash"), table_name="ingest_jobs")
|
||||
op.drop_column("ingest_jobs", "file_hash")
|
||||
|
||||
op.drop_index(op.f("ix_assets_file_hash"), table_name="assets")
|
||||
op.drop_column("assets", "file_hash")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add asset_select_mode to generation_tasks
|
||||
|
||||
Revision ID: 032
|
||||
Revises: 031
|
||||
Create Date: 2026-07-07
|
||||
|
||||
素材库自动匹配功能:为 generation_tasks 表添加 asset_select_mode 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "032"
|
||||
down_revision = "031"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("asset_select_mode", sa.String(20), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generation_tasks", "asset_select_mode")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add batch_id to generation_tasks
|
||||
|
||||
Revision ID: 033
|
||||
Revises: 032
|
||||
Create Date: 2026-07-07
|
||||
|
||||
视频查重功能:为 generation_tasks 表添加 batch_id 字段,
|
||||
用于关联同一次批量生成请求中的多个任务。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "033"
|
||||
down_revision = "032"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("batch_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(op.f("ix_generation_tasks_batch_id"), "generation_tasks", ["batch_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generation_tasks_batch_id"), table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "batch_id")
|
||||
@@ -19,6 +19,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
@@ -42,20 +43,33 @@ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2GB
|
||||
CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
# Allowed file types — must stay in sync with upload.py ALLOWED_MIME_TYPES
|
||||
ALLOWED_MIME_TYPES = {
|
||||
# Images
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
"image/svg+xml",
|
||||
# Video
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/mpeg",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
# Audio
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mp3",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
@@ -360,6 +374,7 @@ async def complete_chunked_upload(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> ChunkedUploadCompleteResponse:
|
||||
@@ -423,6 +438,29 @@ async def complete_chunked_upload(
|
||||
content_type=meta["content_type"],
|
||||
)
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(chunked): library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
meta["status"] = "completed"
|
||||
_save_upload_meta(upload_id, meta)
|
||||
return ChunkedUploadCompleteResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id="",
|
||||
url=file_url,
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# Create ingest job
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -430,6 +468,7 @@ async def complete_chunked_upload(
|
||||
project_id=meta["project_id"],
|
||||
library_id=meta["library_id"],
|
||||
storage_key=storage_key,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,6 +16,7 @@ from app.schemas.generated_video import (
|
||||
ListGeneratedVideosResponse,
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -51,6 +54,8 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
batch_id=getattr(task, "batch_id", ""),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
@@ -85,6 +90,49 @@ def _ensure_library_has_ready_video_assets(assets) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, random=随机, smart=按质量评分
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
"""
|
||||
ready_video_assets = [a for a in assets if a.status.value == "ready" and a.mime_type.startswith("video")]
|
||||
|
||||
if not ready_video_assets:
|
||||
return []
|
||||
|
||||
if mode == "random":
|
||||
selected = (
|
||||
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
|
||||
)
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 按质量分降序排列(质量分高的优先),质量分相同时按时长降序
|
||||
sorted_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
a.quality_score if a.quality_score is not None else 0.0,
|
||||
a.duration if a.duration is not None else 0.0,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
selected = sorted_assets if count <= 0 else sorted_assets[:count]
|
||||
return [a.id for a in selected]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
|
||||
|
||||
def _resolve_project_and_library(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
@@ -122,7 +170,7 @@ def _resolve_project_and_library(
|
||||
return project_id, asset_library_id
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=GenerationTaskResponse)
|
||||
@router.post("/tasks", response_model=BatchGenerationTaskResponse)
|
||||
def create_generation_task(
|
||||
request: CreateGenerationTaskRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -130,12 +178,13 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
) -> BatchGenerationTaskResponse:
|
||||
project_id, asset_library_id = _resolve_project_and_library(
|
||||
request, project_repository, asset_library_repository, asset_repository, authenticated_user
|
||||
)
|
||||
|
||||
# asset_library 存在性校验(仅在提供了 asset_library_id 时)
|
||||
resolved_asset_ids: list[str] = list(request.asset_ids)
|
||||
if asset_library_id:
|
||||
library = asset_library_repository.get(asset_library_id)
|
||||
if library is None or (project_id and library.project_id != project_id):
|
||||
@@ -144,23 +193,42 @@ def create_generation_task(
|
||||
assets = asset_repository.find_by_library(asset_library_id)
|
||||
_ensure_library_has_ready_video_assets(assets)
|
||||
|
||||
# 素材库自动匹配:当未显式指定 asset_ids 时,按模式自动选取
|
||||
if not resolved_asset_ids:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
assets,
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=request.asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
count = request.count
|
||||
created_tasks = []
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
batch_id = uuid.uuid4().hex if count > 1 else ""
|
||||
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
return _to_generation_task_response(task)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
created_tasks.append(task)
|
||||
|
||||
items = [_to_generation_task_response(t) for t in created_tasks]
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
@@ -237,6 +305,7 @@ def retry_generation_task(
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[retried.id])
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.storage import OSSStorageService, get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
@@ -99,6 +100,7 @@ def _submit_ingest_job(
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
file_hash: str = "",
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -106,6 +108,7 @@ def _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
)
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
@@ -176,6 +179,7 @@ async def complete_direct_upload(
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
@@ -199,11 +203,32 @@ async def complete_direct_upload(
|
||||
if not file_exists:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中: library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
@@ -218,15 +243,38 @@ async def upload_asset(
|
||||
project_id: str = Form(..., min_length=1, description="项目 ID"),
|
||||
library_id: str = Form(..., min_length=1, description="素材库 ID"),
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> UploadAssetResponse:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
|
||||
@@ -255,6 +303,7 @@ async def upload_asset(
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
|
||||
@@ -36,9 +36,12 @@ class ChunkedUploadStatusResponse(BaseModel):
|
||||
class ChunkedUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class ChunkedUploadCompleteResponse(BaseModel):
|
||||
storage_key: str = Field(..., description="Storage key")
|
||||
ingest_job_id: str = Field(..., description="Ingest job ID")
|
||||
url: str = Field(..., description="File URL")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -21,6 +21,16 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── 批量生成 ──
|
||||
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
|
||||
# ── 素材库自动匹配 ──
|
||||
asset_select_mode: str = Field(
|
||||
default="all",
|
||||
description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
|
||||
)
|
||||
asset_select_count: int = Field(
|
||||
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -46,12 +56,21 @@ class GenerationTaskResponse(BaseModel):
|
||||
title_ids: list[str] = Field(default_factory=list)
|
||||
voice_ids: list[str] = Field(default_factory=list)
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
|
||||
|
||||
class BatchGenerationTaskResponse(BaseModel):
|
||||
"""批量生成任务响应。"""
|
||||
|
||||
items: list[GenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ListGenerationTasksResponse(BaseModel):
|
||||
"""用户级生成任务列表响应(跨 project)。"""
|
||||
|
||||
|
||||
@@ -6,12 +6,7 @@ class UploadAssetRequest(BaseModel):
|
||||
|
||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadPrepareRequest(BaseModel):
|
||||
@@ -20,6 +15,7 @@ class DirectUploadPrepareRequest(BaseModel):
|
||||
filename: str = Field(..., min_length=1, max_length=255)
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
@@ -35,8 +31,19 @@ class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SearchOutlined,
|
||||
InboxOutlined,
|
||||
VideoCameraOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
CheckOutlined,
|
||||
@@ -37,7 +36,7 @@ import "./assets.css";
|
||||
/* ============================================================
|
||||
* 类型
|
||||
* ============================================================ */
|
||||
type AssetKind = "video" | "voice" | "image";
|
||||
type AssetKind = "video" | "image";
|
||||
type StatusType = "ok" | "warn" | "bad" | "info";
|
||||
|
||||
interface LibraryItem {
|
||||
@@ -67,7 +66,6 @@ interface AssetItem {
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
const inferKind = (mimeType: string): AssetKind => {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "voice";
|
||||
return "image";
|
||||
};
|
||||
|
||||
@@ -99,7 +97,7 @@ const formatDuration = (seconds: number): string => {
|
||||
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind || inferKind("video"),
|
||||
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
|
||||
count: item.asset_count ?? 0,
|
||||
});
|
||||
|
||||
@@ -110,14 +108,16 @@ const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
item.classification_status ?? undefined,
|
||||
);
|
||||
const metadata = item.metadata || {};
|
||||
const kind = inferKind(item.mime_type || "");
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: inferKind(item.mime_type || ""),
|
||||
kind,
|
||||
// 视频类型不能用 file_url 做缩略图(是视频文件,<img> 无法渲染)
|
||||
thumbUrl:
|
||||
(item.thumbnail_url as string | undefined) ||
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.thumbnail_url as string | undefined),
|
||||
(metadata.thumbnail_url as string | undefined) ||
|
||||
(kind !== "video" ? (item.file_url as string | undefined) : undefined),
|
||||
fileUrl:
|
||||
(item.file_url as string | undefined) ||
|
||||
(metadata.file_url as string | undefined),
|
||||
@@ -147,8 +147,6 @@ const kindIcon = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return <VideoCameraOutlined />;
|
||||
case "voice":
|
||||
return <SoundOutlined />;
|
||||
case "image":
|
||||
return <PictureOutlined />;
|
||||
}
|
||||
@@ -158,8 +156,6 @@ const kindLabel = (kind: AssetKind) => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "视频";
|
||||
case "voice":
|
||||
return "配音";
|
||||
case "image":
|
||||
return "图片";
|
||||
}
|
||||
@@ -170,8 +166,6 @@ const thumbGradient = (kind: AssetKind): string => {
|
||||
switch (kind) {
|
||||
case "video":
|
||||
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)";
|
||||
case "voice":
|
||||
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)";
|
||||
case "image":
|
||||
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)";
|
||||
}
|
||||
@@ -244,7 +238,7 @@ const AssetCard: React.FC<{
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮 */}
|
||||
{(asset.kind === "video" || asset.kind === "voice") && (
|
||||
{asset.kind === "video" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
@@ -361,6 +355,7 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
/* 上传 */
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
/* 新建素材库 */
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
@@ -434,18 +429,25 @@ const AssetLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`);
|
||||
}
|
||||
await uploadAssetDirect({ file, library_id: effectiveLibId });
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
});
|
||||
message.success(`"${file.name}" 上传成功`);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
} catch {
|
||||
message.error(`"${file.name}" 上传失败`);
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "";
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -532,6 +534,54 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="xx-assets-page">
|
||||
{/* ─── 上传进度弹窗(圆形动画 + 百分比) ─── */}
|
||||
<AntModal
|
||||
open={uploading}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg
|
||||
className="xx-upload-progress-ring"
|
||||
viewBox="0 0 120 120"
|
||||
width={120}
|
||||
height={120}
|
||||
>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - uploadProgress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{uploadProgress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* 两栏布局 */}
|
||||
<div className="xx-assets-layout">
|
||||
{/* ─── 左侧:素材库列表 ─── */}
|
||||
@@ -588,7 +638,7 @@ const AssetLibrary: React.FC = () => {
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,audio/*,image/*"
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
@@ -598,7 +648,7 @@ const AssetLibrary: React.FC = () => {
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">
|
||||
支持视频、音频、图片,单文件不超过 2GB
|
||||
支持视频、图片,单文件不超过 2GB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
@@ -621,7 +671,6 @@ const AssetLibrary: React.FC = () => {
|
||||
options={[
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
@@ -752,7 +801,6 @@ const AssetLibrary: React.FC = () => {
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "voice", label: "配音" },
|
||||
{ value: "image", label: "图片" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -587,3 +587,40 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 上传进度弹窗 ─── */
|
||||
.xx-upload-progress-modal .ant-modal-content {
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.xx-upload-progress-ring {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-upload-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-upload-progress-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
@@ -6,24 +6,27 @@
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { Typography, message } from "antd";
|
||||
import { Typography, message, Select } from "antd";
|
||||
import {
|
||||
AudioOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleFilled,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
SaveOutlined,
|
||||
PlusOutlined,
|
||||
MinusOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets";
|
||||
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
import { fetchPresetVoices } from "@/api/voices";
|
||||
@@ -54,13 +57,6 @@ const MODE_GRADIENTS: Record<string, string> = {
|
||||
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
|
||||
};
|
||||
const MODE_ABBRS: Record<string, string> = {
|
||||
pip: "PIP",
|
||||
one_take: "ONE",
|
||||
voice_over: "VOI",
|
||||
voice_pip: "VP",
|
||||
};
|
||||
|
||||
/* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */
|
||||
const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
female: "🎀",
|
||||
@@ -122,6 +118,8 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
|
||||
/* 素材选择模式:手动选择 / 自动匹配 */
|
||||
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual");
|
||||
|
||||
/* ── 标题 ── */
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -146,6 +144,9 @@ const GeneratePage: React.FC = () => {
|
||||
);
|
||||
const [customVoiceText, setCustomVoiceText] = useState("");
|
||||
|
||||
/* ── 生成数量 ── */
|
||||
const [generateCount, setGenerateCount] = useState(1);
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const [selectedClonedVoice, setSelectedClonedVoice] = useState<string>("");
|
||||
const [cloneModalOpen, setCloneModalOpen] = useState(false);
|
||||
@@ -162,6 +163,7 @@ const GeneratePage: React.FC = () => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -456,11 +458,17 @@ const GeneratePage: React.FC = () => {
|
||||
}, [navigate]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
console.log("[handleGenerate] 开始生成, 参数:", {
|
||||
title,
|
||||
selectedTemplate,
|
||||
selectedMaterials,
|
||||
voiceMode,
|
||||
});
|
||||
if (!title.trim()) {
|
||||
message.warning("请先选择或输入标题");
|
||||
return;
|
||||
}
|
||||
if (selectedMaterials.length === 0) {
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
message.warning("请至少选择一个素材");
|
||||
return;
|
||||
}
|
||||
@@ -473,6 +481,7 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerating(true);
|
||||
setProgress(0);
|
||||
setGenerated(false);
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const voiceConfig: Record<string, unknown> = {};
|
||||
@@ -498,6 +507,8 @@ const GeneratePage: React.FC = () => {
|
||||
duration,
|
||||
auto_subtitles: autoSubtitles,
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
total_duration: duration,
|
||||
source_edit_plan_id: editPlanId || undefined,
|
||||
@@ -531,6 +542,7 @@ const GeneratePage: React.FC = () => {
|
||||
)?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试";
|
||||
console.error("[生成失败] planId:", plan.id, "响应:", data);
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
@@ -560,20 +572,36 @@ const GeneratePage: React.FC = () => {
|
||||
typeof setInterval
|
||||
>;
|
||||
} catch (err: unknown) {
|
||||
console.error("生成失败:", err);
|
||||
console.error("[handleGenerate] 生成失败:", err);
|
||||
setGenerating(false);
|
||||
// 提取 axios 响应中的后端错误信息
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: { message?: string; error?: string; detail?: string };
|
||||
data?: {
|
||||
message?: string;
|
||||
error?: string;
|
||||
detail?: string;
|
||||
msg?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
const backendMsg =
|
||||
axiosErr.response?.data?.message ||
|
||||
axiosErr.response?.data?.error ||
|
||||
axiosErr.response?.data?.detail ||
|
||||
axiosErr.response?.data?.msg ||
|
||||
axiosErr.message ||
|
||||
"";
|
||||
message.error(backendMsg || "生成失败,请重试");
|
||||
console.error(
|
||||
"[handleGenerate] 错误信息:",
|
||||
backendMsg,
|
||||
"完整错误:",
|
||||
axiosErr,
|
||||
);
|
||||
const errorMsg = backendMsg || "生成失败,请检查网络后重试或联系管理员";
|
||||
setGenerateError(errorMsg);
|
||||
message.error(errorMsg);
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
@@ -590,6 +618,8 @@ const GeneratePage: React.FC = () => {
|
||||
bgm,
|
||||
editPlanId,
|
||||
selectedTemplate,
|
||||
generateCount,
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -598,7 +628,11 @@ const GeneratePage: React.FC = () => {
|
||||
message.warning("请先选择一个模板");
|
||||
return;
|
||||
}
|
||||
if (currentStep === 2 && selectedMaterials.length === 0) {
|
||||
if (
|
||||
currentStep === 2 &&
|
||||
materialMode === "manual" &&
|
||||
selectedMaterials.length === 0
|
||||
) {
|
||||
message.warning("请至少选择一个素材");
|
||||
return;
|
||||
}
|
||||
@@ -609,7 +643,13 @@ const GeneratePage: React.FC = () => {
|
||||
if (currentStep < 5) {
|
||||
setCurrentStep((s) => s + 1);
|
||||
}
|
||||
}, [currentStep, selectedTemplate, selectedMaterials.length, title]);
|
||||
}, [
|
||||
currentStep,
|
||||
selectedTemplate,
|
||||
selectedMaterials.length,
|
||||
title,
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
@@ -669,11 +709,10 @@ const GeneratePage: React.FC = () => {
|
||||
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
||||
}}
|
||||
>
|
||||
{MODE_ABBRS[tpl.mode] || "TPL"}
|
||||
🎬
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
@@ -708,11 +747,31 @@ const GeneratePage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 步骤 2:选择素材 */
|
||||
/** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */
|
||||
const renderStep2 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>📦 选择素材</h3>
|
||||
<div className="xx-form-field">
|
||||
|
||||
{/* ── 模式切换 Tab ── */}
|
||||
<div className="xx-material-mode-tabs">
|
||||
<button
|
||||
className={`xx-material-mode-tab ${materialMode === "manual" ? "active" : ""}`}
|
||||
onClick={() => setMaterialMode("manual")}
|
||||
type="button"
|
||||
>
|
||||
手动选择素材
|
||||
</button>
|
||||
<button
|
||||
className={`xx-material-mode-tab ${materialMode === "auto" ? "active" : ""}`}
|
||||
onClick={() => setMaterialMode("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择素材库自动匹配
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 素材库选择(两种模式共用) ── */}
|
||||
<div className="xx-form-field" style={{ marginTop: 12 }}>
|
||||
<label>选择素材库</label>
|
||||
<select
|
||||
value={selectedLibraryId}
|
||||
@@ -725,90 +784,138 @@ const GeneratePage: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">
|
||||
已选 {selectedMaterials.length} 个素材
|
||||
</span>
|
||||
<Text style={{ color: "var(--text-tertiary, #94a3b8)", fontSize: 13 }}>
|
||||
系统将自动选择最合适的素材
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
加载素材中…
|
||||
</Text>
|
||||
) : materials.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在素材库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked
|
||||
? "var(--primary-soft, #eef2ff)"
|
||||
: "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedMaterials((prev) =>
|
||||
prev.includes(m.id)
|
||||
? prev.filter((id) => id !== m.id)
|
||||
: [...prev, m.id],
|
||||
);
|
||||
}}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{/* ── 手动选择模式 ── */}
|
||||
{materialMode === "manual" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span className="xx-pill xx-pill-ok">
|
||||
已选 {selectedMaterials.length} 个素材
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
<Text
|
||||
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
|
||||
>
|
||||
加载素材中…
|
||||
</Text>
|
||||
) : materials.length === 0 ? (
|
||||
<Text
|
||||
style={{ color: "var(--text-secondary)", padding: "16px 0" }}
|
||||
>
|
||||
暂无素材,请先在素材库中上传
|
||||
</Text>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id);
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: checked
|
||||
? "var(--primary-soft, #eef2ff)"
|
||||
: "#f8fafc",
|
||||
borderRadius: 10,
|
||||
cursor: "pointer",
|
||||
border: checked
|
||||
? "1px solid var(--primary-color, #4f46e5)"
|
||||
: "1px solid transparent",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedMaterials((prev) =>
|
||||
prev.includes(m.id)
|
||||
? prev.filter((id) => id !== m.id)
|
||||
: [...prev, m.id],
|
||||
);
|
||||
}}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 自动匹配模式 ── */}
|
||||
{materialMode === "auto" && (
|
||||
<div className="xx-auto-match-card">
|
||||
<div className="xx-auto-match-icon">🤖</div>
|
||||
<div className="xx-auto-match-body">
|
||||
<h4 className="xx-auto-match-title">智能素材匹配</h4>
|
||||
<p className="xx-auto-match-desc">
|
||||
系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。
|
||||
无需手动挑选,AI
|
||||
会综合素材质量、时长、内容相关性等维度进行智能筛选。
|
||||
</p>
|
||||
<div className="xx-auto-match-features">
|
||||
<span className="xx-auto-match-feature">📊 质量评分筛选</span>
|
||||
<span className="xx-auto-match-feature">🎯 内容相关性匹配</span>
|
||||
<span className="xx-auto-match-feature">⏱️ 时长智能分配</span>
|
||||
</div>
|
||||
</div>
|
||||
{materialsLoading ? (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
扫描素材库中…
|
||||
</Text>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
fontSize: 12,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
当前素材库共 {materials.length} 个素材可供匹配
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -818,14 +925,30 @@ const GeneratePage: React.FC = () => {
|
||||
<h3>📝 选择标题</h3>
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<select value={title} onChange={(e) => setTitle(e.target.value)}>
|
||||
<option value="">请选择标题…</option>
|
||||
{userTitles.map((t) => (
|
||||
<option key={t.id} value={t.content}>
|
||||
{t.content}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
allowClear
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
value={title || undefined}
|
||||
onChange={(val) => setTitle(val || "")}
|
||||
options={userTitles.map((t) => ({
|
||||
label: t.content,
|
||||
value: t.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
标题库为空,请前往「标题管理」添加
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
@@ -1270,7 +1393,9 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">素材</span>
|
||||
<span className="xx-summary-value">
|
||||
{selectedMaterials.length} 个素材
|
||||
{materialMode === "auto"
|
||||
? "自动匹配"
|
||||
: `${selectedMaterials.length} 个素材`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
@@ -1281,20 +1406,107 @@ const GeneratePage: React.FC = () => {
|
||||
<span className="xx-summary-label">配音</span>
|
||||
<span className="xx-summary-value">{getVoiceName()}</span>
|
||||
</div>
|
||||
<div className="xx-summary-row">
|
||||
<span className="xx-summary-label">生成数量</span>
|
||||
<span className="xx-summary-value">
|
||||
<div className="xx-count-stepper">
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount <= 1 || generating}
|
||||
onClick={() => setGenerateCount((c) => Math.max(1, c - 1))}
|
||||
>
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-value">{generateCount}</span>
|
||||
<button
|
||||
className="xx-count-stepper-btn"
|
||||
disabled={generateCount >= 10 || generating}
|
||||
onClick={() => setGenerateCount((c) => Math.min(10, c + 1))}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</button>
|
||||
<span className="xx-count-stepper-hint">条视频</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生成进度 */}
|
||||
{generating && (
|
||||
{/* 生成进度 / 结果反馈 */}
|
||||
{(generating || generated || generateError) && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div className="xx-progress-bar">
|
||||
{generating && (
|
||||
<>
|
||||
<div className="xx-progress-bar">
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{generated && !generating && (
|
||||
<div
|
||||
className="xx-progress-bar-fill"
|
||||
style={{ width: `${Math.min(Math.round(progress), 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
正在生成视频,请稍候… {Math.round(progress)}%
|
||||
</Text>
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(82, 196, 26, 0.08)",
|
||||
border: "1px solid rgba(82, 196, 26, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutlined style={{ color: "#52c41a", fontSize: 18 }} />
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ color: "#52c41a", display: "block", fontSize: 14 }}
|
||||
>
|
||||
视频生成完成!
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
可在右侧预览或前往成片库查看
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{generateError && !generating && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: 8,
|
||||
background: "rgba(255, 77, 79, 0.08)",
|
||||
border: "1px solid rgba(255, 77, 79, 0.3)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<CloseCircleOutlined
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 18,
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ color: "#ff4d4f", display: "block", fontSize: 14 }}
|
||||
>
|
||||
生成失败
|
||||
</Text>
|
||||
<Text style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{generateError}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1405,10 +1617,16 @@ const GeneratePage: React.FC = () => {
|
||||
<button
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || generated}
|
||||
disabled={generating || (generated && !generateError)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
{generating ? "生成中…" : generated ? "已生成" : "✨ 确认生成"}
|
||||
{generating
|
||||
? "生成中…"
|
||||
: generated && !generateError
|
||||
? "已生成"
|
||||
: generateError
|
||||
? "🔄 重新生成"
|
||||
: "✨ 确认生成"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1074,3 +1074,141 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 生成数量步进器 ── */
|
||||
.xx-count-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-400, #818cf8);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: var(--primary-50, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-count-stepper-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.xx-count-stepper-value {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-count-stepper-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* ── 素材选择模式切换 Tab ── */
|
||||
.xx-material-mode-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-material-mode-tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--bg-surface, #fff);
|
||||
color: var(--text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-material-mode-tab:first-child {
|
||||
border-right: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-material-mode-tab:hover {
|
||||
background: var(--primary-50, #eef2ff);
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-material-mode-tab.active {
|
||||
background: var(--primary-500, #6366f1);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 自动匹配卡片 ── */
|
||||
.xx-auto-match-card {
|
||||
margin-top: 14px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #faf5ff 100%);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-auto-match-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-auto-match-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-auto-match-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-auto-match-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.xx-auto-match-features {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-auto-match-feature {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary-600, #4f46e5);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid var(--border-light, #f1f5f9);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
@@ -737,11 +737,39 @@ const ProductLibrary: React.FC = () => {
|
||||
|
||||
// ── Error 状态 ──
|
||||
if (isError) {
|
||||
console.error("[ProductLibrary] 加载失败:", error);
|
||||
const errorMsg = error?.message || "加载失败";
|
||||
// 404 视为空数据(API 尚未就绪或无数据)
|
||||
const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found");
|
||||
if (is404) {
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-header">
|
||||
<h2>
|
||||
<VideoCameraOutlined /> 成片库
|
||||
</h2>
|
||||
</div>
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">🎬</div>
|
||||
<p>暂无成片数据</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-tertiary)",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
完成视频生成后,成片将自动保存到这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="xx-products-page">
|
||||
<div className="xx-products-empty">
|
||||
<div className="xx-products-empty-icon">❌</div>
|
||||
<p>{error?.message || "加载失败"}</p>
|
||||
<p>{errorMsg || "加载失败,请稍后重试"}</p>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
|
||||
@@ -91,7 +91,7 @@ const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description,
|
||||
description: item.description ?? "",
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
@@ -370,9 +370,10 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{template.description.slice(0, 80)}...
|
||||
{(template.description ?? "").slice(0, 80)}...
|
||||
</div>
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
@@ -386,7 +387,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
{/* 信息区 */}
|
||||
<div className="xx-template-info">
|
||||
<div className="xx-template-info-top">
|
||||
<h4 className="xx-template-name">{template.name}</h4>
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
@@ -397,7 +397,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
{template.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description}</p>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">
|
||||
已使用 {template.usageCount} 次
|
||||
@@ -483,7 +483,9 @@ const TemplateLibrary: React.FC = () => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()) ||
|
||||
t.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
@@ -233,6 +233,24 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 缩略图底部名称 */
|
||||
.xx-template-thumb-name {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24px 14px 10px;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.55) 0%, transparent 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 预览提示(hover 显示) */
|
||||
.xx-template-preview-hint {
|
||||
position: absolute;
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
CheckOutlined,
|
||||
TagsOutlined,
|
||||
MutedOutlined,
|
||||
RobotOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select, Modal, Tag } from "@/components/ui";
|
||||
import { message, Popover, Popconfirm, Tooltip } from "antd";
|
||||
@@ -56,6 +58,8 @@ import {
|
||||
tagAsset,
|
||||
untagAsset,
|
||||
} from "@/api/tags";
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts";
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices";
|
||||
import "./voice-materials.css";
|
||||
|
||||
/* ============================================================
|
||||
@@ -1086,6 +1090,14 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
[assets],
|
||||
);
|
||||
|
||||
// ── 获取预设音色列表(AI 配音用) ─────────────────────────
|
||||
const { data: presetVoicesData } = useQuery({
|
||||
queryKey: ["preset-voices"],
|
||||
queryFn: fetchPresetVoices,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [];
|
||||
|
||||
// ── 上传 mutation ─────────────────────────────────────────
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
@@ -1218,6 +1230,19 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
);
|
||||
const [batchCustomTag, setBatchCustomTag] = useState("");
|
||||
|
||||
// ── AI 配音(TTS 合成)状态 ────────────────────────────────
|
||||
const [ttsOpen, setTtsOpen] = useState(false);
|
||||
const [ttsText, setTtsText] = useState("");
|
||||
const [ttsVoiceId, setTtsVoiceId] = useState<string>("");
|
||||
const [ttsSpeed, setTtsSpeed] = useState(1.0);
|
||||
const [ttsJobId, setTtsJobId] = useState<string | null>(null);
|
||||
const [ttsStatus, setTtsStatus] = useState<
|
||||
"idle" | "synthesizing" | "done" | "error"
|
||||
>("idle");
|
||||
const [ttsAudioUrl, setTtsAudioUrl] = useState<string | null>(null);
|
||||
const [ttsError, setTtsError] = useState<string | null>(null);
|
||||
const ttsTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── 播放控制 ──────────────────────────────────────────────
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
@@ -1505,6 +1530,77 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
[tags, createTagMutation, handleBatchTag],
|
||||
);
|
||||
|
||||
// ── TTS 合成处理 ─────────────────────────────────────────
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
message.warning("请输入要合成的文本");
|
||||
return;
|
||||
}
|
||||
setTtsError(null);
|
||||
setTtsStatus("synthesizing");
|
||||
setTtsAudioUrl(null);
|
||||
setTtsJobId(null);
|
||||
|
||||
try {
|
||||
const resp = await synthesizeSpeech({
|
||||
text: ttsText.trim(),
|
||||
voice_id: ttsVoiceId || undefined,
|
||||
speed: ttsSpeed,
|
||||
});
|
||||
setTtsJobId(resp.job_id);
|
||||
|
||||
// 轮询任务状态
|
||||
ttsTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const job = await getTTSJobStatus(resp.job_id);
|
||||
if (job.status === "completed") {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("done");
|
||||
setTtsAudioUrl(job.output_audio_url);
|
||||
} else if (job.status === "failed") {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("error");
|
||||
setTtsError(job.error_message || "合成失败");
|
||||
}
|
||||
} catch {
|
||||
clearInterval(ttsTimerRef.current!);
|
||||
ttsTimerRef.current = null;
|
||||
setTtsStatus("error");
|
||||
setTtsError("查询合成状态失败");
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "合成请求失败";
|
||||
setTtsStatus("error");
|
||||
setTtsError(msg);
|
||||
}
|
||||
}, [ttsText, ttsVoiceId, ttsSpeed]);
|
||||
|
||||
/** 保存 TTS 结果到素材库 */
|
||||
const handleTtsSave = useCallback(async () => {
|
||||
if (!ttsJobId) return;
|
||||
try {
|
||||
await saveTtsToLibrary(ttsJobId, {
|
||||
name: ttsText.slice(0, 20) || "AI配音",
|
||||
});
|
||||
message.success("已保存到配音素材库");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] });
|
||||
setTtsOpen(false);
|
||||
} catch {
|
||||
message.error("保存失败");
|
||||
}
|
||||
}, [ttsJobId, ttsText, queryClient]);
|
||||
|
||||
// TTS 定时器清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (ttsTimerRef.current) clearInterval(ttsTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* ── 渲染 ─────────────────────────────────────────────── */
|
||||
|
||||
const isUploading = uploadMutation.isPending;
|
||||
@@ -1512,6 +1608,13 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
|
||||
const pageActions = (
|
||||
<div className="vmat-page-actions">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setTtsOpen(true)}
|
||||
>
|
||||
AI配音
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
@@ -1818,6 +1921,182 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* AI 配音(TTS 合成)弹窗 */}
|
||||
<Modal
|
||||
title="AI 配音"
|
||||
open={ttsOpen}
|
||||
onCancel={() => {
|
||||
setTtsOpen(false);
|
||||
if (ttsTimerRef.current) {
|
||||
clearInterval(ttsTimerRef.current);
|
||||
ttsTimerRef.current = null;
|
||||
}
|
||||
setTtsStatus("idle");
|
||||
setTtsAudioUrl(null);
|
||||
setTtsError(null);
|
||||
setTtsJobId(null);
|
||||
}}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{/* 文本输入 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
输入文本
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="请输入需要转换为语音的文本内容…"
|
||||
value={ttsText}
|
||||
onChange={(e) => setTtsText(e.target.value)}
|
||||
maxLength={2000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #999)",
|
||||
marginTop: 4,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{ttsText.length}/2000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
选择音色
|
||||
</label>
|
||||
<select
|
||||
value={ttsVoiceId}
|
||||
onChange={(e) => setTtsVoiceId(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 10px",
|
||||
border: "1px solid var(--border-color, #d9d9d9)",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary, #fff)",
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语速调节 */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginBottom: 6,
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
语速:{ttsSpeed.toFixed(1)}x
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={ttsSpeed}
|
||||
onChange={(e) => setTtsSpeed(parseFloat(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 合成按钮 */}
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
icon={
|
||||
ttsStatus === "synthesizing" ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<RobotOutlined />
|
||||
)
|
||||
}
|
||||
onClick={handleTtsSynthesize}
|
||||
disabled={ttsStatus === "synthesizing" || !ttsText.trim()}
|
||||
>
|
||||
{ttsStatus === "synthesizing" ? "合成中…" : "开始合成"}
|
||||
</Button>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{ttsStatus === "error" && ttsError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
background: "#fff2f0",
|
||||
borderRadius: 6,
|
||||
color: "#ff4d4f",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{ttsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合成结果 */}
|
||||
{ttsStatus === "done" && ttsAudioUrl && (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: "var(--bg-surface, #f5f5f5)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<audio
|
||||
controls
|
||||
src={ttsAudioUrl}
|
||||
style={{ width: "100%", marginBottom: 12 }}
|
||||
/>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="sm"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleTtsSave}
|
||||
>
|
||||
保存到素材库
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -206,6 +206,68 @@ class VideoDeduplicator:
|
||||
|
||||
return None
|
||||
|
||||
def check_batch_duplicate(
|
||||
self,
|
||||
fingerprint: VideoFingerprint,
|
||||
batch_id: str,
|
||||
current_video_id: str,
|
||||
session: Session,
|
||||
) -> Optional[dict]:
|
||||
"""检查视频是否与同批次内其他视频重复。
|
||||
|
||||
逻辑与 check_duplicate 一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
|
||||
|
||||
Args:
|
||||
fingerprint: 待检测视频的指纹
|
||||
batch_id: 批次 ID
|
||||
current_video_id: 当前视频 ID(排除自身)
|
||||
session: 数据库会话
|
||||
|
||||
Returns:
|
||||
重复信息字典,或 None 表示未找到重复
|
||||
"""
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
batch_videos = video_repo.list_by_batch(batch_id)
|
||||
|
||||
for existing in batch_videos:
|
||||
if existing.id == current_video_id:
|
||||
continue
|
||||
if not existing.video_fingerprint:
|
||||
continue
|
||||
|
||||
ef = existing.video_fingerprint
|
||||
|
||||
if fingerprint.md5 == ef.get("md5"):
|
||||
return {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "batch_exact_md5_match",
|
||||
"similarity": 1.0,
|
||||
}
|
||||
|
||||
existing_phashes = ef.get("keyframe_phashes", [])
|
||||
if not existing_phashes:
|
||||
continue
|
||||
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100
|
||||
|
||||
if avg_distance >= self.PHASH_THRESHOLD:
|
||||
continue
|
||||
|
||||
phash_similarity = 1.0 - (avg_distance / 64)
|
||||
return {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "batch_phash_similar",
|
||||
"similarity": phash_similarity,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float:
|
||||
"""
|
||||
|
||||
@@ -139,14 +139,16 @@ def _download_library_assets(
|
||||
asset_library_id: str,
|
||||
temp_path: Path,
|
||||
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
|
||||
asset_ids: list[str] | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
从素材库下载所有视频素材
|
||||
从素材库下载视频素材
|
||||
|
||||
Args:
|
||||
asset_library_id: 素材库 ID
|
||||
temp_path: 临时目录路径
|
||||
video_extensions: 支持的视频扩展名
|
||||
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
|
||||
|
||||
Returns:
|
||||
下载成功的视频文件路径列表
|
||||
@@ -161,16 +163,15 @@ def _download_library_assets(
|
||||
|
||||
try:
|
||||
# 查询素材库中的视频素材
|
||||
assets = (
|
||||
session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.asset_library_id == asset_library_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
|
||||
)
|
||||
.order_by(AssetModel.created_at)
|
||||
.all()
|
||||
query = session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == asset_library_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
|
||||
)
|
||||
# 如果指定了 asset_ids,则只下载这些素材
|
||||
if asset_ids:
|
||||
query = query.filter(AssetModel.id.in_(asset_ids))
|
||||
assets = query.order_by(AssetModel.created_at).all()
|
||||
|
||||
if not assets:
|
||||
logger.info(f"No video assets found in library {asset_library_id}")
|
||||
@@ -259,6 +260,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
asset_library_id = gen_task.asset_library_id
|
||||
voice_library_id = gen_task.voice_library_id or ""
|
||||
mode = gen_task.strategy_id or "one_take"
|
||||
task_asset_ids = list(gen_task.asset_ids or [])
|
||||
batch_id = getattr(gen_task, "batch_id", "") or ""
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -275,8 +278,8 @@ def generate_video(self, task_id: str) -> dict:
|
||||
temp_path = Path(temp_dir)
|
||||
output_path = temp_path / output_name
|
||||
|
||||
# 从素材库下载视频素材
|
||||
downloaded_videos = _download_library_assets(asset_library_id, temp_path)
|
||||
# 从素材库下载视频素材(如果任务指定了 asset_ids 则只下载这些)
|
||||
downloaded_videos = _download_library_assets(asset_library_id, temp_path, asset_ids=task_asset_ids or None)
|
||||
|
||||
audio_path = None
|
||||
if voice_library_id:
|
||||
@@ -297,6 +300,32 @@ def generate_video(self, task_id: str) -> dict:
|
||||
file_size = output_path.stat().st_size
|
||||
duration = _probe_duration(output_path)
|
||||
|
||||
# 上传到 OSS
|
||||
bucket = _oss_bucket()
|
||||
if bucket:
|
||||
try:
|
||||
bucket.put_object_from_file(storage_key, str(output_path))
|
||||
except Exception as oss_err:
|
||||
logger.warning(f"OSS upload failed: {oss_err}")
|
||||
|
||||
# 构建视频 URL
|
||||
if bucket:
|
||||
file_url = f"{PUBLIC_API_BASE_URL}/{storage_key}"
|
||||
else:
|
||||
file_url = f"{GENERATED_FILES_URL_PREFIX}/{task_id}/{output_name}"
|
||||
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
_create_video_record_and_dedup(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=str(output_path),
|
||||
mode=editing_mode.value,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"task_id": task_id,
|
||||
@@ -314,3 +343,85 @@ def generate_video(self, task_id: str) -> dict:
|
||||
"task_id": task_id,
|
||||
"error": str(error),
|
||||
}
|
||||
|
||||
|
||||
def _create_video_record_and_dedup(
|
||||
*,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
duration: float,
|
||||
video_path: str,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。"""
|
||||
from uuid import uuid4
|
||||
|
||||
from video_processing.dedup import VideoDeduplicator
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
video_id = uuid4().hex
|
||||
generated_video = GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
generation_task_id=task_id,
|
||||
name=f"generated-{task_id[:8]}.mp4",
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
width=OUTPUT_WIDTH,
|
||||
height=OUTPUT_HEIGHT,
|
||||
fps=OUTPUT_FPS,
|
||||
status="completed",
|
||||
generation_params={"mode": mode},
|
||||
)
|
||||
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
|
||||
# 计算视频指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
try:
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
except Exception as fp_err:
|
||||
logger.warning(f"Fingerprint computation failed for {video_id}: {fp_err}")
|
||||
session.commit()
|
||||
return
|
||||
|
||||
generated_video.video_fingerprint = fingerprint.to_dict()
|
||||
|
||||
# (a) 历史成片查重
|
||||
duplicate_result = deduplicator.check_duplicate(fingerprint, project_id, session)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
|
||||
|
||||
if duplicate_result:
|
||||
generated_video.is_duplicate = True
|
||||
generated_video.duplicate_of = duplicate_result["duplicate_of"]
|
||||
logger.info(
|
||||
f"Duplicate detected: {video_id} -> {duplicate_result['duplicate_of']} "
|
||||
f"(reason={duplicate_result['reason']}, similarity={duplicate_result['similarity']:.3f})"
|
||||
)
|
||||
else:
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
video_repo.update(generated_video)
|
||||
session.commit()
|
||||
logger.info(f"GeneratedVideo record created: {video_id} (task={task_id}, dup={generated_video.is_duplicate})")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create video record / dedup for task {task_id}: {e}")
|
||||
session.rollback()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
@@ -179,6 +179,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
width=int(metadata.get("width", 0)),
|
||||
height=int(metadata.get("height", 0)),
|
||||
status=AssetStatus.READY,
|
||||
file_hash=job.file_hash,
|
||||
)
|
||||
asset_repo.create(asset)
|
||||
|
||||
|
||||
@@ -274,6 +274,14 @@
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "file_hash",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(64)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "metadata",
|
||||
@@ -321,6 +329,13 @@
|
||||
"name": "ix_assets_created_at",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"file_hash"
|
||||
],
|
||||
"name": "ix_assets_file_hash",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"file_type"
|
||||
@@ -1502,6 +1517,22 @@
|
||||
"type": "VARCHAR(32)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "asset_select_mode",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "batch_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "metadata",
|
||||
@@ -1527,6 +1558,13 @@
|
||||
"name": "ix_generation_tasks_asset_library_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"batch_id"
|
||||
],
|
||||
"name": "ix_generation_tasks_batch_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"created_by_user_id"
|
||||
@@ -1632,6 +1670,14 @@
|
||||
"type": "VARCHAR(32)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "file_hash",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(64)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "created_at",
|
||||
@@ -1650,6 +1696,13 @@
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"columns": [
|
||||
"file_hash"
|
||||
],
|
||||
"name": "ix_ingest_jobs_file_hash",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"library_id"
|
||||
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# ============================================
|
||||
# Production 部署脚本 - Registry 方式
|
||||
# 用法:IMAGE_TAG=<version> REGISTRY_TOKEN=<token> sh deploy-production-registry.sh
|
||||
# ============================================
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 三镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# ---- Re-tag 成本地名 ----
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 备份旧版 assets(部署期间缓存用户不 404) ----
|
||||
echo "Backing up legacy assets from current web container..."
|
||||
if docker inspect xiaoxia-web-production >/dev/null 2>&1; then
|
||||
_tmpdir="/tmp/legacy-assets-$$"
|
||||
rm -rf "$_tmpdir"
|
||||
mkdir -p "$_tmpdir"
|
||||
docker cp xiaoxia-web-production:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
|
||||
# 合并到 LEGACY_ASSETS_DIR(保留所有历史版本的 assets)
|
||||
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
|
||||
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
|
||||
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
|
||||
fi
|
||||
rm -rf "$_tmpdir"
|
||||
else
|
||||
echo "No existing web container, skipping legacy assets backup"
|
||||
fi
|
||||
|
||||
# 清理超过 7 天的旧 assets 文件(避免无限增长)
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ]; then
|
||||
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
|
||||
echo "Legacy assets cleanup done (retain 7 days)"
|
||||
fi
|
||||
|
||||
# ---- 确保基础设施容器在运行 ----
|
||||
echo "Checking infrastructure containers..."
|
||||
for c in xiaoxia-postgres-production xiaoxia-redis-production; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 确保生产网络存在 ----
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
|
||||
# ---- 执行数据库 Migration ----
|
||||
echo "Running database migrations..."
|
||||
docker run --rm \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
echo "Stopping old containers..."
|
||||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||||
|
||||
# ---- 日志配置(所有容器共用) ----
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:8001:8000 \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-production \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--cpus 2 \
|
||||
--memory 2g \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
# Legacy assets 挂载到 /usr/share/nginx/html/assets-legacy/assets/
|
||||
# nginx 配置中 assets location 有 fallback 逻辑
|
||||
LEGACY_VOLUME=""
|
||||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||||
echo "Web container: legacy assets mounted (fallback)"
|
||||
else
|
||||
echo "Web container: no legacy assets to mount"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-production \
|
||||
--network xiaoxia-net-production \
|
||||
-p 127.0.0.1:3002:80 \
|
||||
--restart unless-stopped \
|
||||
--cpus 0.5 \
|
||||
--memory 512m \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
echo "Waiting for Web to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 15 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||||
echo "Web is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-production
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
echo "Cleaning up old images..."
|
||||
docker image prune -af --filter "until=168h" 2>/dev/null || true
|
||||
docker builder prune -af --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Production deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8001"
|
||||
echo "Web: http://127.0.0.1:3002"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# ============================================
|
||||
# Staging 部署脚本 - Registry 方式
|
||||
# 用法:IMAGE_TAG=<sha|version> REGISTRY_TOKEN=<token> sh deploy-staging.sh
|
||||
# ============================================
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||
|
||||
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/var/lib/xiaoxia-saas-staging/repo/infra/docker}"
|
||||
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
printf %s "$REGISTRY_TOKEN" | docker login "$(echo $REGISTRY | cut -d/ -f1)" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
|
||||
echo "WARN: docker login failed, will try to pull anyway"
|
||||
}
|
||||
fi
|
||||
|
||||
# ---- Pull 三镜像 ----
|
||||
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||||
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||||
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||||
|
||||
LOCAL_API="${REGISTRY}/xiaoxia-saas-api:staging"
|
||||
LOCAL_WORKER="${REGISTRY}/xiaoxia-saas-worker:staging"
|
||||
LOCAL_WEB="${REGISTRY}/xiaoxia-saas-web:staging"
|
||||
|
||||
echo "Pulling API image..."
|
||||
docker pull "$REGISTRY_API"
|
||||
echo "Pulling Worker image..."
|
||||
docker pull "$REGISTRY_WORKER"
|
||||
echo "Pulling Web image..."
|
||||
docker pull "$REGISTRY_WEB"
|
||||
|
||||
# ---- Re-tag 成本地名 ----
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
|
||||
echo "All images pulled and tagged."
|
||||
|
||||
# ---- 确保基础设施容器在运行 ----
|
||||
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
|
||||
if ! docker inspect "$c" >/dev/null 2>&1; then
|
||||
echo "ERROR: Required container not found: $c"
|
||||
exit 1
|
||||
fi
|
||||
state=$(docker inspect -f {{.State.Status}} "$c")
|
||||
if [ "$state" != "running" ]; then
|
||||
echo "ERROR: Container not running: $c ($state)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 确保 staging 网络存在 ----
|
||||
docker network create xiaoxia-net-staging 2>/dev/null || true
|
||||
|
||||
# ---- 执行数据库 Migration ----
|
||||
echo "Running database migrations..."
|
||||
docker run --rm --env-file "$ENV_FILE" --network xiaoxia-net-staging "$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
echo "Migrations completed."
|
||||
|
||||
# ---- 停止旧容器 ----
|
||||
docker rm -f xiaoxia-api-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
|
||||
docker rm -f xiaoxia-web-staging 2>/dev/null || true
|
||||
|
||||
# ---- 启动 API ----
|
||||
echo "Starting API container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-api-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
"$LOCAL_API"
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 10s \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
"$LOCAL_WORKER"
|
||||
|
||||
# ---- 启动 Web ----
|
||||
# Web 镜像默认打包 production nginx.conf,staging 需要挂载 staging 配置
|
||||
NGINX_CONF="${NGINX_CONF:-${COMPOSE_DIR}/nginx-staging.conf}"
|
||||
if [ ! -f "$NGINX_CONF" ]; then
|
||||
echo "WARN: nginx config not found at $NGINX_CONF, using image default"
|
||||
NGINX_VOLUME=""
|
||||
else
|
||||
NGINX_VOLUME="-v ${NGINX_CONF}:/etc/nginx/conf.d/default.conf:ro"
|
||||
fi
|
||||
|
||||
echo "Starting Web container..."
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
--label com.centurylinklabs.watchtower.enable=true \
|
||||
$NGINX_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
"$LOCAL_WEB"
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$i" -ge 30 ]; then
|
||||
echo "ERROR: API did not become healthy within 60s"
|
||||
docker logs --tail 30 xiaoxia-api-staging
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
docker image prune -af --filter "until=72h" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}" | grep staging
|
||||
|
||||
# Watchtower auto-update: 容器加com.centurylinklabs.watchtower.enable=true标签,用:staging tag启动
|
||||
@@ -39,6 +39,15 @@ server {
|
||||
alias /app/generated/;
|
||||
}
|
||||
|
||||
# Assets with legacy fallback (higher priority than generic static regex)
|
||||
# 部署期间,缓存了旧版 index.html 的用户会请求旧版带 hash 的 assets 文件
|
||||
# 先在当前镜像中找,找不到去 legacy-assets 目录找(从旧版本容器中备份的)
|
||||
location ^~ /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri /assets-legacy$uri =404;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -76,3 +76,16 @@ class InMemoryAssetRepository:
|
||||
tag_set = set(tag_ids)
|
||||
items = [a for a in self._assets.values() if tag_set.issubset(set(a.tag_ids))]
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def find_by_library_and_file_hash(
|
||||
self,
|
||||
library_id: str,
|
||||
file_hash: str,
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
|
||||
if not file_hash:
|
||||
return None
|
||||
for asset in self._assets.values():
|
||||
if asset.library_id == library_id and asset.file_hash == file_hash:
|
||||
return asset
|
||||
return None
|
||||
|
||||
@@ -83,6 +83,7 @@ class SQLAlchemyAssetRepository:
|
||||
classification_result=(json.dumps(asset.metadata) if asset.metadata else None),
|
||||
quality_score=asset.quality_score,
|
||||
uploaded_by_user_id=asset.uploaded_by_user_id or "system",
|
||||
file_hash=asset.file_hash or None,
|
||||
created_at=asset.created_at,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -110,6 +111,7 @@ class SQLAlchemyAssetRepository:
|
||||
model.classification_result = json.dumps(asset.metadata) if asset.metadata else None
|
||||
model.quality_score = asset.quality_score
|
||||
model.uploaded_by_user_id = asset.uploaded_by_user_id or model.uploaded_by_user_id
|
||||
model.file_hash = asset.file_hash or model.file_hash
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.flush()
|
||||
self._sync_asset_tags(asset.id, asset.tag_ids)
|
||||
@@ -230,6 +232,7 @@ class SQLAlchemyAssetRepository:
|
||||
classification_status=ClassificationStatus(model.classification_status),
|
||||
quality_score=model.quality_score,
|
||||
uploaded_by_user_id=model.uploaded_by_user_id,
|
||||
file_hash=model.file_hash or "",
|
||||
metadata=metadata,
|
||||
tag_ids=tag_ids,
|
||||
created_at=model.created_at,
|
||||
@@ -267,3 +270,23 @@ class SQLAlchemyAssetRepository:
|
||||
return []
|
||||
models = self.session.query(AssetModel).filter(AssetModel.id.in_(ids)).offset(skip).limit(limit).all()
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def find_by_library_and_file_hash(
|
||||
self,
|
||||
library_id: str,
|
||||
file_hash: str,
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
|
||||
if not file_hash:
|
||||
return None
|
||||
model = (
|
||||
self.session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.file_hash == file_hash,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
|
||||
@@ -78,7 +78,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]:
|
||||
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.project_id == project_id).all()
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]:
|
||||
models = (
|
||||
@@ -86,4 +86,40 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
.filter(GeneratedVideoModel.generation_task_id == generation_task_id)
|
||||
.all()
|
||||
)
|
||||
return [self.get(model.id) for model in models if self.get(model.id) is not None]
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]:
|
||||
"""通过 batch_id 查找同批次生成的所有视频(跨 generation_task 关联查询)。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
|
||||
|
||||
task_ids = (
|
||||
self.session.query(GenerationTaskModel.id).filter(GenerationTaskModel.batch_id == batch_id).subquery()
|
||||
)
|
||||
models = (
|
||||
self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.generation_task_id.in_(task_ids)).all()
|
||||
)
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: GeneratedVideoModel) -> GeneratedVideo:
|
||||
return GeneratedVideo(
|
||||
id=model.id,
|
||||
project_id=model.project_id,
|
||||
generation_task_id=model.generation_task_id,
|
||||
name=model.name,
|
||||
file_url=model.file_url,
|
||||
file_size=int(model.file_size or 0),
|
||||
duration=model.duration,
|
||||
thumbnail_url=model.thumbnail_url,
|
||||
width=int(model.width or 0),
|
||||
height=int(model.height or 0),
|
||||
fps=model.fps,
|
||||
status=getattr(model, "status", "completed"),
|
||||
review_status=getattr(model, "review_status", "pending_review"),
|
||||
generation_params=json.loads(getattr(model, "generation_params", "{}") or "{}"),
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -25,6 +25,8 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
completed_at=model.completed_at,
|
||||
created_by_user_id=model.created_by_user_id,
|
||||
source_edit_plan_id=model.source_edit_plan_id or "",
|
||||
asset_select_mode=model.asset_select_mode or "",
|
||||
batch_id=model.batch_id or "",
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -52,6 +54,8 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
completed_at=task.completed_at,
|
||||
created_by_user_id=task.created_by_user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or None,
|
||||
asset_select_mode=task.asset_select_mode or "",
|
||||
batch_id=task.batch_id or "",
|
||||
created_at=task.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
@@ -123,5 +127,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.started_at = task.started_at
|
||||
model.completed_at = task.completed_at
|
||||
model.source_edit_plan_id = task.source_edit_plan_id or None
|
||||
model.asset_select_mode = task.asset_select_mode or ""
|
||||
model.batch_id = task.batch_id or ""
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -17,6 +17,7 @@ class SQLAlchemyIngestJobRepository:
|
||||
status=job.status.value,
|
||||
error_message=job.error_message,
|
||||
result_asset_id=job.result_asset_id,
|
||||
file_hash=job.file_hash,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
@@ -36,6 +37,7 @@ class SQLAlchemyIngestJobRepository:
|
||||
status=IngestJobStatus(model.status),
|
||||
error_message=model.error_message,
|
||||
result_asset_id=model.result_asset_id,
|
||||
file_hash=model.file_hash or "",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
@@ -51,6 +53,7 @@ class SQLAlchemyIngestJobRepository:
|
||||
model.status = job.status.value
|
||||
model.error_message = job.error_message
|
||||
model.result_asset_id = job.result_asset_id
|
||||
model.file_hash = job.file_hash
|
||||
model.updated_at = job.updated_at
|
||||
self.session.commit()
|
||||
return job
|
||||
|
||||
@@ -85,6 +85,7 @@ class AssetModel(Base):
|
||||
classification_result = Column(Text, nullable=True)
|
||||
quality_score = Column(Float, nullable=True)
|
||||
uploaded_by_user_id = Column(String(36), nullable=False)
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -210,6 +211,7 @@ class IngestJobModel(Base):
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
result_asset_id = Column(String(32), nullable=False, default="")
|
||||
file_hash = Column(String(64), nullable=True, index=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -251,6 +253,8 @@ class GenerationTaskModel(Base):
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
|
||||
source_edit_plan_id = Column(String(32), nullable=True, index=True)
|
||||
asset_select_mode = Column(String(20), nullable=False, default="")
|
||||
batch_id = Column(String(32), nullable=False, default="", index=True)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ class CreateGenerationTaskCommand:
|
||||
voice_ids: list[str] = field(default_factory=list)
|
||||
created_by_user_id: str = ""
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
@@ -44,6 +46,8 @@ class CreateGenerationTaskUseCase:
|
||||
completed_at=None,
|
||||
created_by_user_id=command.created_by_user_id,
|
||||
source_edit_plan_id=command.source_edit_plan_id,
|
||||
asset_select_mode=command.asset_select_mode,
|
||||
batch_id=command.batch_id,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class SubmitIngestJobCommand:
|
||||
project_id: str
|
||||
library_id: str
|
||||
storage_key: str
|
||||
file_hash: str = ""
|
||||
|
||||
|
||||
class SubmitIngestJobUseCase:
|
||||
@@ -22,5 +23,6 @@ class SubmitIngestJobUseCase:
|
||||
project_id=command.project_id,
|
||||
library_id=command.library_id,
|
||||
storage_key=command.storage_key,
|
||||
file_hash=command.file_hash,
|
||||
)
|
||||
return self.ingest_job_repository.create(job)
|
||||
|
||||
@@ -161,6 +161,7 @@ class Asset:
|
||||
classification_status: ClassificationStatus = ClassificationStatus.PENDING
|
||||
quality_score: float | None = None
|
||||
uploaded_by_user_id: str = ""
|
||||
file_hash: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
tag_ids: list[str] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -187,6 +188,7 @@ class Asset:
|
||||
classification_status: ClassificationStatus = ClassificationStatus.PENDING,
|
||||
quality_score: float | None = None,
|
||||
uploaded_by_user_id: str = "",
|
||||
file_hash: str = "",
|
||||
) -> "Asset":
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
@@ -213,6 +215,7 @@ class Asset:
|
||||
classification_status=classification_status,
|
||||
quality_score=quality_score,
|
||||
uploaded_by_user_id=uploaded_by_user_id.strip(),
|
||||
file_hash=file_hash.strip(),
|
||||
metadata=metadata or {},
|
||||
tag_ids=[],
|
||||
)
|
||||
@@ -243,6 +246,7 @@ class IngestJob:
|
||||
status: IngestJobStatus = IngestJobStatus.PENDING
|
||||
error_message: str = ""
|
||||
result_asset_id: str = ""
|
||||
file_hash: str = ""
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -252,6 +256,7 @@ class IngestJob:
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
file_hash: str = "",
|
||||
) -> "IngestJob":
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
@@ -264,4 +269,5 @@ class IngestJob:
|
||||
project_id=project_id.strip(),
|
||||
library_id=library_id.strip(),
|
||||
storage_key=storage_key.strip(),
|
||||
file_hash=file_hash.strip(),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,8 @@ class GenerationTask:
|
||||
completed_at: datetime | None = None
|
||||
source_edit_plan_id: str = ""
|
||||
created_by_user_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@classmethod
|
||||
@@ -59,6 +61,8 @@ class GenerationTask:
|
||||
voice_ids: list[str] | None = None,
|
||||
created_by_user_id: str = "",
|
||||
source_edit_plan_id: str = "",
|
||||
asset_select_mode: str = "",
|
||||
batch_id: str = "",
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
@@ -76,4 +80,6 @@ class GenerationTask:
|
||||
voice_ids=list(voice_ids) if voice_ids else [],
|
||||
created_by_user_id=created_by_user_id.strip(),
|
||||
source_edit_plan_id=source_edit_plan_id.strip(),
|
||||
asset_select_mode=asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
@@ -93,3 +93,12 @@ class AssetRepository(ABC):
|
||||
) -> list[Asset]:
|
||||
"""查找包含所有指定标签的素材。"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def find_by_library_and_file_hash(
|
||||
self,
|
||||
library_id: str,
|
||||
file_hash: str,
|
||||
) -> Asset | None:
|
||||
"""按素材库 + 文件哈希查找已有素材(去重检测)。"""
|
||||
pass
|
||||
|
||||
@@ -13,3 +13,5 @@ class GeneratedVideoRepository(Protocol):
|
||||
def list_by_project(self, project_id: str) -> list[GeneratedVideo]: ...
|
||||
|
||||
def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: ...
|
||||
|
||||
def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]: ...
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查指定commit的CI status状态。
|
||||
|
||||
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
|
||||
返回: 打印状态 (success/failure/pending/error)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
sha = sys.argv[3]
|
||||
target_context = sys.argv[4]
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
statuses = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# API返回按时间倒序,第一个就是最新的
|
||||
for s in statuses:
|
||||
if s.get("context") == target_context:
|
||||
print(s.get("status", "pending"))
|
||||
return
|
||||
|
||||
print("pending")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查PR是否有至少N个APPROVED审批。
|
||||
|
||||
用法: python3 check_pr_approval.py <token> <repo> <pr_number> <min_approval>
|
||||
返回: 打印 "approved" 或 "pending"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
pr_number = sys.argv[3]
|
||||
min_approval = int(sys.argv[4])
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/pulls/{pr_number}/reviews"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
reviews = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# 统计APPROVED的人数(去重,同一人多次审批只算一次)
|
||||
approvers = set()
|
||||
for r in reviews:
|
||||
if r.get("state") == "APPROVED":
|
||||
approvers.add(r.get("user", {}).get("login", ""))
|
||||
|
||||
if len(approvers) >= min_approval:
|
||||
print(f"approved ({len(approvers)})")
|
||||
else:
|
||||
print(f"pending ({len(approvers)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR自动扫描器:扫描所有open PR,对CI全绿的进行自动审批/合并
|
||||
作为短作业模式的兜底机制,每5分钟运行一次
|
||||
|
||||
新增:AI审查联动 - AI代码审查发现严重问题时,不自动审批
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def api_request(token, repo, endpoint, method="GET", data=None):
|
||||
"""Gitea API请求"""
|
||||
url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/{endpoint}"
|
||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
|
||||
# 跳过SSL验证
|
||||
import ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
return json.loads(resp.read().decode()), resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return json.loads(e.read().decode()) if e.read() else {"error": str(e)}, e.code
|
||||
|
||||
|
||||
def get_open_prs(token, repo, base="develop"):
|
||||
"""获取所有open的PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls?state=open&base={base}&sort=recentupdate&per_page=50&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
prs.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(token, repo, sha):
|
||||
"""获取commit的CI状态汇总"""
|
||||
data, code = api_request(token, repo, f"commits/{sha}/status")
|
||||
if code != 200:
|
||||
return {}, "error"
|
||||
return data, data.get("state", "unknown")
|
||||
|
||||
|
||||
def check_required_contexts(token, repo, sha, contexts):
|
||||
"""检查指定的context是否都通过"""
|
||||
data, _ = get_commit_status(token, repo, sha)
|
||||
statuses = {s["context"]: s["status"] for s in data.get("statuses", [])}
|
||||
|
||||
all_success = True
|
||||
any_pending = False
|
||||
any_failed = False
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state != "success":
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
if state in ("failure", "error"):
|
||||
any_failed = True
|
||||
|
||||
return all_success, any_pending, any_failed, statuses
|
||||
|
||||
|
||||
def get_pr_files(token, repo, pr_number):
|
||||
"""获取PR变更文件"""
|
||||
files = []
|
||||
page = 1
|
||||
while True:
|
||||
data, code = api_request(token, repo, f"pulls/{pr_number}/files?per_page=300&page={page}")
|
||||
if code != 200 or not isinstance(data, list) or len(data) == 0:
|
||||
break
|
||||
files.extend(data)
|
||||
if len(data) < 300:
|
||||
break
|
||||
page += 1
|
||||
return [f["filename"] for f in files]
|
||||
|
||||
|
||||
def is_frontend_only(files):
|
||||
"""判断是否纯前端改动"""
|
||||
if not files:
|
||||
return False
|
||||
frontend_count = sum(1 for f in files if f.startswith("apps/web/"))
|
||||
backend_count = len(files) - frontend_count
|
||||
return backend_count == 0 and frontend_count > 0
|
||||
|
||||
|
||||
def has_approval(token, repo, pr_number):
|
||||
"""检查PR是否已有审批"""
|
||||
reviews, code = api_request(token, repo, f"pulls/{pr_number}/reviews")
|
||||
if code != 200:
|
||||
return False
|
||||
return any(r.get("state") == "APPROVED" for r in reviews if isinstance(r, dict))
|
||||
|
||||
|
||||
def get_ai_review_result(token, repo, pr_number):
|
||||
"""
|
||||
检查AI代码审查结果,返回 (has_critical, review_body)
|
||||
has_critical: 是否有严重问题(需修改的问题 > 0)
|
||||
review_body: 最新的AI审查评论文本
|
||||
"""
|
||||
# AI审查评论标记
|
||||
AI_REVIEW_MARKER = "AI_CODE_REVIEW_AUTO_COMMENT"
|
||||
|
||||
comments, code = api_request(token, repo, f"issues/{pr_number}/comments")
|
||||
if code != 200:
|
||||
return False, None
|
||||
|
||||
# 找最新的AI审查评论
|
||||
ai_comments = [c for c in comments if isinstance(c, dict) and AI_REVIEW_MARKER in c.get("body", "")]
|
||||
|
||||
if not ai_comments:
|
||||
return False, None
|
||||
|
||||
# 按时间排序,取最新的
|
||||
latest = max(ai_comments, key=lambda c: c.get("created_at", ""))
|
||||
body = latest.get("body", "")
|
||||
|
||||
# 解析严重问题数量
|
||||
# 匹配 "严重问题数量:X 个" 或 "需修改的问题(严重)" 下的列表
|
||||
critical_count = 0
|
||||
|
||||
# 方式1:直接匹配数字
|
||||
match = re.search(r"严重问题数量[::]\s*(\d+)\s*个", body)
|
||||
if match:
|
||||
critical_count = int(match.group(1))
|
||||
else:
|
||||
# 方式2:数 "需修改的问题" 章节下的条目数
|
||||
critical_section = re.search(
|
||||
r"###\s*[❌⚠️].*?(?:需修改|问题).*?\n(.*?)(?=\n###|\Z)",
|
||||
body,
|
||||
re.DOTALL,
|
||||
)
|
||||
if critical_section:
|
||||
section_text = critical_section.group(1)
|
||||
# 数编号条目 1. 2. 3.
|
||||
items = re.findall(r"^\d+\.\s+\*\*", section_text, re.MULTILINE)
|
||||
critical_count = len(items)
|
||||
|
||||
has_critical = critical_count > 0
|
||||
return has_critical, body
|
||||
|
||||
|
||||
def approve_pr(token, repo, pr_number, reason="CI全绿,自动审批通过。"):
|
||||
"""审批PR"""
|
||||
# 创建review
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews",
|
||||
method="POST",
|
||||
data={"event": "PENDING", "body": reason},
|
||||
)
|
||||
|
||||
if code not in (200, 201):
|
||||
return False, f"创建review失败: HTTP {code}"
|
||||
|
||||
review_id = data.get("id")
|
||||
if data.get("state") == "APPROVED":
|
||||
return True, "直接创建APPROVED成功"
|
||||
|
||||
if not review_id:
|
||||
return False, "未获取到review ID"
|
||||
|
||||
# submit为APPROVED
|
||||
data2, code2 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}/events",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
|
||||
if code2 in (200, 201):
|
||||
return True, "审批提交成功"
|
||||
else:
|
||||
# 尝试另一个端点
|
||||
data3, code3 = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/reviews/{review_id}",
|
||||
method="POST",
|
||||
data={"event": "APPROVED", "body": reason},
|
||||
)
|
||||
if code3 in (200, 201):
|
||||
return True, "审批提交成功(备用端点)"
|
||||
return False, f"审批提交失败: HTTP {code2}/{code3}"
|
||||
|
||||
|
||||
def add_pr_label(token, repo, pr_number, label):
|
||||
"""给PR添加标签"""
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"issues/{pr_number}/labels",
|
||||
method="POST",
|
||||
data={"labels": [label]},
|
||||
)
|
||||
return code in (200, 201)
|
||||
|
||||
|
||||
def merge_pr(token, repo, pr_number):
|
||||
"""合并PR(squash merge)"""
|
||||
# 等待几秒让状态同步
|
||||
time.sleep(30)
|
||||
|
||||
# 检查PR状态
|
||||
pr_data, code = api_request(token, repo, f"pulls/{pr_number}")
|
||||
if code != 200:
|
||||
return False, f"获取PR状态失败: HTTP {code}"
|
||||
if pr_data.get("state") != "open":
|
||||
return False, f"PR状态不是open: {pr_data.get('state')}"
|
||||
|
||||
# 执行squash merge
|
||||
data, code = api_request(
|
||||
token,
|
||||
repo,
|
||||
f"pulls/{pr_number}/merge",
|
||||
method="POST",
|
||||
data={
|
||||
"do": "squash",
|
||||
"merge_title_field": "",
|
||||
"merge_message_field": "",
|
||||
"delete_branch_after_merge": True,
|
||||
"force_merge": False,
|
||||
},
|
||||
)
|
||||
|
||||
if code == 200:
|
||||
return True, "合并成功"
|
||||
elif code == 405:
|
||||
return False, "合并返回405(门禁未满足或冲突)"
|
||||
else:
|
||||
return False, f"合并失败: HTTP {code}"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PR自动扫描器")
|
||||
parser.add_argument("--token", required=True, help="Gitea API token")
|
||||
parser.add_argument("--repo", default="xiaoxia/xiaoxia-saas", help="仓库")
|
||||
parser.add_argument("--base", default="develop", help="目标分支")
|
||||
parser.add_argument("--approve", action="store_true", help="执行自动审批")
|
||||
parser.add_argument("--merge", action="store_true", help="执行自动合并")
|
||||
parser.add_argument("--dry-run", default="false", help="试运行模式")
|
||||
parser.add_argument("--max-prs", type=int, default=20, help="最多处理的PR数")
|
||||
parser.add_argument("--skip-ai-review", action="store_true", help="跳过AI审查检查(强制审批)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
dry_run = args.dry_run.lower() == "true"
|
||||
|
||||
# required contexts(与分支保护一致)
|
||||
REQUIRED_CONTEXTS_FULL = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
"CI/CD Pipeline / PR Build API Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Worker Image (pull_request)",
|
||||
"CI/CD Pipeline / PR Build Web Image (pull_request)",
|
||||
]
|
||||
REQUIRED_CONTEXTS_APPROVE = [
|
||||
"CI/CD Pipeline / Validate - Code Quality (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Type Check (mypy) (pull_request)",
|
||||
"CI/CD Pipeline / Validate - Migration (alembic) (pull_request)",
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
FRONTEND_ONLY_CONTEXT = [
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)",
|
||||
]
|
||||
|
||||
# 获取所有open PR
|
||||
print(f"获取 {args.base} 分支的open PR...")
|
||||
prs = get_open_prs(args.token, args.repo, args.base)
|
||||
print(f"找到 {len(prs)} 个open PR")
|
||||
|
||||
approved_count = 0
|
||||
merged_count = 0
|
||||
skipped_count = 0
|
||||
ai_blocked_count = 0
|
||||
|
||||
for pr in prs[: args.max_prs]:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
base_ref = pr.get("base", {}).get("re", "")
|
||||
|
||||
# 跳过draft
|
||||
if pr.get("draft"):
|
||||
print(f"\n⏭️ #{pr_num} {pr_title[:50]} - draft,跳过")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 跳过目标分支不对的
|
||||
if base_ref != args.base:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
print(f"\n--- #{pr_num} {pr_title[:60]} ---")
|
||||
|
||||
# 判断是否纯前端
|
||||
files = get_pr_files(args.token, args.repo, pr_num)
|
||||
frontend_only = is_frontend_only(files)
|
||||
|
||||
if frontend_only:
|
||||
approve_contexts = FRONTEND_ONLY_CONTEXT
|
||||
merge_contexts = FRONTEND_ONLY_CONTEXT
|
||||
print(f" 类型: 纯前端改动 ({len(files)}个文件)")
|
||||
else:
|
||||
approve_contexts = REQUIRED_CONTEXTS_APPROVE
|
||||
merge_contexts = REQUIRED_CONTEXTS_FULL
|
||||
print(f" 类型: 全栈/后端改动 ({len(files)}个文件)")
|
||||
|
||||
# 检查审批用的CI状态
|
||||
all_ok, pending, failed, _ = check_required_contexts(args.token, args.repo, head_sha, approve_contexts)
|
||||
|
||||
# === AI审查检查 ===
|
||||
ai_has_critical = False
|
||||
if not args.skip_ai_review and all_ok and not failed and args.approve:
|
||||
ai_has_critical, ai_body = get_ai_review_result(args.token, args.repo, pr_num)
|
||||
if ai_has_critical:
|
||||
print(" ⚠️ AI审查发现严重问题,阻止自动审批")
|
||||
ai_blocked_count += 1
|
||||
# 给PR打标签便于人工识别
|
||||
if not dry_run:
|
||||
add_pr_label(args.token, args.repo, pr_num, "ai-review/需修改")
|
||||
|
||||
# === 自动审批 ===
|
||||
if args.approve and all_ok and not failed and not ai_has_critical:
|
||||
if has_approval(args.token, args.repo, pr_num):
|
||||
print(" ✅ 已有审批,跳过")
|
||||
else:
|
||||
if dry_run:
|
||||
print(" 🎯 [DRY-RUN] 将自动审批")
|
||||
else:
|
||||
print(" 🎯 执行自动审批...")
|
||||
ok, msg = approve_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 审批成功: {msg}")
|
||||
approved_count += 1
|
||||
else:
|
||||
print(f" ❌ 审批失败: {msg}")
|
||||
elif ai_has_critical:
|
||||
print(" 🚫 AI审查阻止审批(人工可手动审批覆盖)")
|
||||
elif failed:
|
||||
print(" ❌ CI有失败项,跳过审批")
|
||||
elif pending:
|
||||
print(" ⏳ CI仍在运行,跳过")
|
||||
|
||||
# === 自动合并 ===
|
||||
if args.merge:
|
||||
# 检查合并用的CI状态
|
||||
merge_ok, merge_pending, merge_failed, _ = check_required_contexts(
|
||||
args.token, args.repo, head_sha, merge_contexts
|
||||
)
|
||||
|
||||
# 检查审批
|
||||
approved = has_approval(args.token, args.repo, pr_num)
|
||||
|
||||
if merge_ok and approved and not merge_failed:
|
||||
if dry_run:
|
||||
print(" 🎯 [DRY-RUN] 将自动合并")
|
||||
else:
|
||||
print(" 🎯 执行自动合并...")
|
||||
ok, msg = merge_pr(args.token, args.repo, pr_num)
|
||||
if ok:
|
||||
print(f" ✅ 合并成功: {msg}")
|
||||
merged_count += 1
|
||||
else:
|
||||
print(f" ⚠️ 合并失败: {msg}")
|
||||
elif merge_pending:
|
||||
print(" ⏳ 合并条件未满足: CI运行中")
|
||||
elif merge_failed:
|
||||
print(" ❌ 合并条件未满足: CI有失败")
|
||||
elif not approved:
|
||||
print(" ⏳ 合并条件未满足: 无审批")
|
||||
|
||||
print("\n=== 扫描结果 ===")
|
||||
print(f" 处理PR数: {min(len(prs), args.max_prs)}")
|
||||
print(f" 自动审批: {approved_count} 个")
|
||||
print(f" 自动合并: {merged_count} 个")
|
||||
print(f" AI审查阻止: {ai_blocked_count} 个")
|
||||
print(f" 跳过: {skipped_count} 个")
|
||||
print(" 模式: {'DRY-RUN' if dry_run else '正式执行'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
素材重复上传检测 单元测试
|
||||
|
||||
覆盖:
|
||||
- 表单上传(multipart)命中去重 → 直接返回已有 asset_id,不上传 OSS
|
||||
- 直传 OSS complete 命中去重 → 直接返回已有 asset_id,不创建 ingest job
|
||||
- 未命中去重 → 正常创建 ingest job
|
||||
- file_hash 为空 → 跳过去重检测
|
||||
- IngestJob 透传 file_hash 到 Asset
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, IngestJob, Project
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub repositories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubProjectRepository:
|
||||
def __init__(self, projects: dict | None = None):
|
||||
self._projects = projects or {}
|
||||
|
||||
def get(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
def find_by_id(self, project_id: str):
|
||||
return self._projects.get(project_id)
|
||||
|
||||
|
||||
class StubAssetLibraryRepository:
|
||||
def __init__(self, libraries: dict | None = None):
|
||||
self._libraries = libraries or {}
|
||||
|
||||
def find_by_project(self, project_id: str, kind=None) -> list:
|
||||
items = [lib for lib in self._libraries.values() if lib.project_id == project_id]
|
||||
if kind is not None:
|
||||
items = [lib for lib in items if lib.kind == kind]
|
||||
return items
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
"""支持 find_by_library_and_file_hash 去重检测。"""
|
||||
|
||||
def __init__(self, assets: list[Asset] | None = None):
|
||||
self._assets = assets or []
|
||||
|
||||
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
|
||||
for a in self._assets:
|
||||
if a.library_id == library_id and a.file_hash == file_hash:
|
||||
return a
|
||||
return None
|
||||
|
||||
def create(self, asset: Asset) -> Asset:
|
||||
self._assets.append(asset)
|
||||
return asset
|
||||
|
||||
|
||||
class StubIngestJobRepository:
|
||||
def __init__(self):
|
||||
self._jobs: dict[str, IngestJob] = {}
|
||||
|
||||
def create(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> IngestJob | None:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job: IngestJob) -> IngestJob:
|
||||
self._jobs[job.id] = job
|
||||
return job
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DUPE_HASH = "a" * 32
|
||||
|
||||
|
||||
def _make_project(id: str = "proj-1", owner_user_id: str = "user-1") -> Project:
|
||||
return Project(id=id, name="Test Project", owner_user_id=owner_user_id)
|
||||
|
||||
|
||||
def _make_library(id: str = "lib-1", project_id: str = "proj-1") -> AssetLibrary:
|
||||
return AssetLibrary(id=id, name="Test Library", project_id=project_id, kind=AssetLibraryKind.VIDEO)
|
||||
|
||||
|
||||
def _make_existing_asset(
|
||||
id: str = "existing-asset-1",
|
||||
library_id: str = "lib-1",
|
||||
file_hash: str = DUPE_HASH,
|
||||
) -> Asset:
|
||||
return Asset(
|
||||
id=id,
|
||||
project_id="proj-1",
|
||||
library_id=library_id,
|
||||
name="existing.mp4",
|
||||
storage_key="uploads/existing/existing.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_hash=file_hash,
|
||||
status=AssetStatus.READY,
|
||||
)
|
||||
|
||||
|
||||
def _build_app(
|
||||
project_repo=None,
|
||||
library_repo=None,
|
||||
asset_repo=None,
|
||||
ingest_repo=None,
|
||||
storage=None,
|
||||
):
|
||||
from app.api.routes.upload import router
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_ingest_job_repository,
|
||||
get_project_repository,
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
project_repo = project_repo or StubProjectRepository()
|
||||
library_repo = library_repo or StubAssetLibraryRepository()
|
||||
asset_repo = asset_repo or StubAssetRepository()
|
||||
ingest_repo = ingest_repo or StubIngestJobRepository()
|
||||
storage = storage or MagicMock()
|
||||
storage.is_configured = True
|
||||
storage._normalize_storage_key = lambda key: key
|
||||
storage.file_exists = lambda key: True
|
||||
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
|
||||
|
||||
mock_user = MagicMock(spec=AuthenticatedUser)
|
||||
mock_user.id = "user-1"
|
||||
mock_user.email = "test@example.com"
|
||||
|
||||
app.dependency_overrides[get_current_user] = lambda: mock_user
|
||||
app.dependency_overrides[get_project_repository] = lambda: project_repo
|
||||
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
|
||||
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
|
||||
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
|
||||
app.dependency_overrides[get_storage_service] = lambda: storage
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _client(**kwargs) -> TestClient:
|
||||
return TestClient(_build_app(**kwargs))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试用例
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultipartUploadDedup:
|
||||
"""表单上传(POST /api/v1/assets)去重检测。"""
|
||||
|
||||
def test_dedup_hit_returns_existing_asset(self):
|
||||
"""file_hash 命中已有素材 → 返回 duplicated=true + asset_id,不上传 OSS。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
existing = _make_existing_asset()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([existing]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"file_hash": DUPE_HASH,
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is True
|
||||
assert body["asset_id"] == existing.id
|
||||
assert body["ingest_job_id"] == ""
|
||||
|
||||
def test_dedup_miss_creates_ingest_job(self):
|
||||
"""file_hash 未命中 → 正常上传并创建 ingest job。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([]), # 无已有素材
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"file_hash": "b" * 32, # 新的 hash
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is False
|
||||
assert body["ingest_job_id"] != ""
|
||||
|
||||
def test_empty_hash_skips_dedup(self):
|
||||
"""file_hash 为空 → 跳过去重检测,直接上传。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
existing = _make_existing_asset()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([existing]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
# 不传 file_hash
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is False
|
||||
|
||||
|
||||
class TestDirectUploadDedup:
|
||||
"""直传 OSS complete(POST /api/v1/direct/complete)去重检测。"""
|
||||
|
||||
def test_dedup_hit_returns_existing_asset(self):
|
||||
"""complete 阶段 file_hash 命中 → 返回 duplicated=true。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
existing = _make_existing_asset()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([existing]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"storage_key": "uploads/abc/test.mp4",
|
||||
"file_hash": DUPE_HASH,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is True
|
||||
assert body["asset_id"] == existing.id
|
||||
assert body["ingest_job_id"] == ""
|
||||
|
||||
def test_dedup_miss_creates_ingest_job(self):
|
||||
"""complete 阶段 file_hash 未命中 → 创建 ingest job。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([]),
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"storage_key": "uploads/abc/test.mp4",
|
||||
"file_hash": "c" * 32,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["duplicated"] is False
|
||||
assert body["ingest_job_id"] != ""
|
||||
|
||||
|
||||
class TestIngestJobFileHashPassthrough:
|
||||
"""file_hash 从上传接口透传到 IngestJob。"""
|
||||
|
||||
def test_ingest_job_stores_file_hash(self):
|
||||
"""上传时传入的 file_hash 应保存到 IngestJob 实体。"""
|
||||
project = _make_project()
|
||||
library = _make_library()
|
||||
ingest_repo = StubIngestJobRepository()
|
||||
|
||||
client = _client(
|
||||
project_repo=StubProjectRepository({project.id: project}),
|
||||
library_repo=StubAssetLibraryRepository({library.id: library}),
|
||||
asset_repo=StubAssetRepository([]),
|
||||
ingest_repo=ingest_repo,
|
||||
)
|
||||
|
||||
new_hash = "d" * 32
|
||||
client.post(
|
||||
"/api/v1",
|
||||
data={
|
||||
"project_id": project.id,
|
||||
"library_id": library.id,
|
||||
"file_hash": new_hash,
|
||||
},
|
||||
files={"file": ("test.mp4", b"fake-video-data", "video/mp4")},
|
||||
)
|
||||
|
||||
# 验证 IngestJob 存储了 file_hash
|
||||
assert len(ingest_repo._jobs) == 1
|
||||
job = list(ingest_repo._jobs.values())[0]
|
||||
assert job.file_hash == new_hash
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
素材库自动匹配 单元测试
|
||||
|
||||
覆盖:
|
||||
- all 模式:返回全部 ready 视频素材 ID
|
||||
- random 模式:随机选取 N 个
|
||||
- smart 模式:按质量分/时长评分降序选取
|
||||
- 无 ready 视频素材时返回空列表
|
||||
- count=0 时返回全部(random/smart 模式)
|
||||
- 非视频素材和非 ready 状态素材被过滤
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import _select_assets_from_library
|
||||
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
def _asset(
|
||||
id: str,
|
||||
name: str,
|
||||
mime_type: str = "video/mp4",
|
||||
status: AssetStatus = AssetStatus.READY,
|
||||
quality_score: float | None = None,
|
||||
duration: float | None = None,
|
||||
) -> Asset:
|
||||
a = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"uploads/{name}",
|
||||
mime_type=mime_type,
|
||||
file_size=1024,
|
||||
status=status,
|
||||
quality_score=quality_score,
|
||||
duration=duration,
|
||||
)
|
||||
# create() 会覆盖 id,手动设置
|
||||
a.id = id
|
||||
return a
|
||||
|
||||
|
||||
class TestSelectAssetsAllMode:
|
||||
"""all 模式:返回全部 ready 视频素材。"""
|
||||
|
||||
def test_returns_all_ready_video_assets(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4"),
|
||||
_asset("a2", "v2.mp4"),
|
||||
_asset("a3", "v3.mp4"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert sorted(result) == ["a1", "a2", "a3"]
|
||||
|
||||
def test_ignores_count_in_all_mode(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4"),
|
||||
_asset("a2", "v2.mp4"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=1)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_filters_non_video_assets(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", mime_type="video/mp4"),
|
||||
_asset("a2", "img.jpg", mime_type="image/jpeg"),
|
||||
_asset("a3", "v2.mov", mime_type="video/quicktime"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert sorted(result) == ["a1", "a3"]
|
||||
|
||||
def test_filters_non_ready_assets(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", status=AssetStatus.READY),
|
||||
_asset("a2", "v2.mp4", status=AssetStatus.UPLOADING),
|
||||
_asset("a3", "v3.mp4", status=AssetStatus.PROCESSING),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert result == ["a1"]
|
||||
|
||||
def test_empty_library_returns_empty(self):
|
||||
result = _select_assets_from_library([], mode="all", count=0)
|
||||
assert result == []
|
||||
|
||||
def test_no_ready_video_returns_empty(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", status=AssetStatus.UPLOADING),
|
||||
_asset("a2", "img.jpg", mime_type="image/jpeg", status=AssetStatus.READY),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="all", count=0)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSelectAssetsRandomMode:
|
||||
"""random 模式:随机选取 N 个。"""
|
||||
|
||||
def test_random_selects_exact_count(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(10)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=3)
|
||||
assert len(result) == 3
|
||||
assert all(rid in [a.id for a in assets] for rid in result)
|
||||
|
||||
def test_random_count_zero_returns_all(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(5)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=0)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_random_count_exceeds_total_returns_all(self):
|
||||
assets = [_asset(f"a{i}", f"v{i}.mp4") for i in range(3)]
|
||||
result = _select_assets_from_library(assets, mode="random", count=100)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestSelectAssetsSmartMode:
|
||||
"""smart 模式:按质量分/时长评分降序选取。"""
|
||||
|
||||
def test_smart_sorts_by_quality_score_desc(self):
|
||||
assets = [
|
||||
_asset("low", "low.mp4", quality_score=0.3),
|
||||
_asset("high", "high.mp4", quality_score=0.9),
|
||||
_asset("mid", "mid.mp4", quality_score=0.6),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["high", "mid", "low"]
|
||||
|
||||
def test_smart_tiebreak_by_duration_desc(self):
|
||||
assets = [
|
||||
_asset("short", "short.mp4", quality_score=0.8, duration=10.0),
|
||||
_asset("long", "long.mp4", quality_score=0.8, duration=60.0),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["long", "short"]
|
||||
|
||||
def test_smart_with_count_limits_results(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", quality_score=0.9),
|
||||
_asset("a2", "v2.mp4", quality_score=0.7),
|
||||
_asset("a3", "v3.mp4", quality_score=0.5),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=2)
|
||||
assert result == ["a1", "a2"]
|
||||
|
||||
def test_smart_null_quality_treated_as_zero(self):
|
||||
assets = [
|
||||
_asset("scored", "scored.mp4", quality_score=0.5),
|
||||
_asset("unscored", "unscored.mp4", quality_score=None),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["scored", "unscored"]
|
||||
|
||||
def test_smart_count_zero_returns_all_sorted(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4", quality_score=0.1),
|
||||
_asset("a2", "v2.mp4", quality_score=0.9),
|
||||
_asset("a3", "v3.mp4", quality_score=0.5),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
assert result == ["a2", "a3", "a1"]
|
||||
|
||||
|
||||
class TestSelectAssetsDefaultMode:
|
||||
"""默认模式(未知 mode 字符串)应回退到 all。"""
|
||||
|
||||
def test_unknown_mode_falls_back_to_all(self):
|
||||
assets = [
|
||||
_asset("a1", "v1.mp4"),
|
||||
_asset("a2", "v2.mp4"),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="unknown", count=0)
|
||||
assert len(result) == 2
|
||||
@@ -461,3 +461,171 @@ class TestVideoDeduplicatorCheckDuplicate:
|
||||
assert result["similarity"] == 1.0 # avg_distance = 0
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
|
||||
class TestVideoDeduplicatorCheckBatchDuplicate:
|
||||
"""VideoDeduplicator.check_batch_duplicate() 测试。
|
||||
|
||||
批次内查重逻辑与历史查重一致(MD5 + pHash),但搜索范围限定为同 batch_id 的视频。
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def deduplicator(self):
|
||||
return VideoDeduplicator()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session(self):
|
||||
return MagicMock()
|
||||
|
||||
def _make_batch_video(self, video_id, md5, phashes=None):
|
||||
video = MagicMock()
|
||||
video.id = video_id
|
||||
video.video_fingerprint = {
|
||||
"md5": md5,
|
||||
"keyframe_phashes": phashes or [],
|
||||
"color_histograms": [],
|
||||
}
|
||||
return video
|
||||
|
||||
def _patch_repo(self, mock_repo):
|
||||
import apps.worker.video_processing.dedup as dedup_module
|
||||
|
||||
original = dedup_module.SQLAlchemyGeneratedVideoRepository
|
||||
dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo)
|
||||
return original, dedup_module
|
||||
|
||||
def _restore_repo(self, dedup_module, original):
|
||||
dedup_module.SQLAlchemyGeneratedVideoRepository = original
|
||||
|
||||
def test_batch_exact_md5_match(self, deduplicator, mock_session):
|
||||
"""批次内 MD5 完全匹配应返回 duplicate。"""
|
||||
other = self._make_batch_video("vid-other", "abc123")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc123",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is not None
|
||||
assert result["duplicate"] is True
|
||||
assert result["reason"] == "batch_exact_md5_match"
|
||||
assert result["similarity"] == 1.0
|
||||
assert result["duplicate_of"] == "vid-other"
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_phash_similar(self, deduplicator, mock_session):
|
||||
"""批次内 pHash 距离 < 阈值应判定为重复。"""
|
||||
other = self._make_batch_video("vid-other", "md5_diff", phashes=["abcdef01"])
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="md5_new",
|
||||
keyframe_phashes=["abcdef01"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is not None
|
||||
assert result["duplicate"] is True
|
||||
assert result["reason"] == "batch_phash_similar"
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_excludes_self(self, deduplicator, mock_session):
|
||||
"""批次查重应排除自身视频。"""
|
||||
self_video = self._make_batch_video("vid-self", "abc123")
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [self_video]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc123",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_no_match(self, deduplicator, mock_session):
|
||||
"""批次内无重复时应返回 None。"""
|
||||
other = self._make_batch_video("vid-other", "md5_a", phashes=["0000000000000000"])
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="md5_b",
|
||||
keyframe_phashes=["ffffffffffffffff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_empty_returns_none(self, deduplicator, mock_session):
|
||||
"""空批次应返回 None。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = []
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
def test_batch_skips_no_fingerprint(self, deduplicator, mock_session):
|
||||
"""批次内无指纹的视频应被跳过。"""
|
||||
other = MagicMock()
|
||||
other.id = "vid-other"
|
||||
other.video_fingerprint = None
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.list_by_batch.return_value = [other]
|
||||
|
||||
fingerprint = VideoFingerprint(
|
||||
md5="abc",
|
||||
keyframe_phashes=["ff"],
|
||||
color_histograms=[],
|
||||
duration=10.0,
|
||||
resolution=(1280, 720),
|
||||
)
|
||||
|
||||
orig, mod = self._patch_repo(mock_repo)
|
||||
try:
|
||||
result = deduplicator.check_batch_duplicate(fingerprint, "batch-1", "vid-self", mock_session)
|
||||
assert result is None
|
||||
finally:
|
||||
self._restore_repo(mod, orig)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
测试视频上传失败排查修复:
|
||||
1. chunked_upload ALLOWED_MIME_TYPES 与 upload.py 保持一致
|
||||
2. generated_video_repository list 方法无 N+1 查询
|
||||
3. 成片库 API 端点可正常返回数据
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# ─── 1. chunked_upload ALLOWED_MIME_TYPES 一致性 ───
|
||||
|
||||
|
||||
class TestChunkedUploadMIMEConsistency:
|
||||
"""chunked_upload.py 的 ALLOWED_MIME_TYPES 必须包含 upload.py 的所有类型。"""
|
||||
|
||||
def _get_upload_mime_types(self) -> set:
|
||||
from apps.api.app.api.routes.upload import ALLOWED_MIME_TYPES
|
||||
|
||||
return set(ALLOWED_MIME_TYPES)
|
||||
|
||||
def _get_chunked_mime_types(self) -> set:
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
return set(ALLOWED_MIME_TYPES)
|
||||
|
||||
def test_chunked_upload_has_all_upload_mime_types(self):
|
||||
"""chunked_upload 白名单必须覆盖 upload.py 的全部类型。"""
|
||||
upload_types = self._get_upload_mime_types()
|
||||
chunked_types = self._get_chunked_mime_types()
|
||||
missing = upload_types - chunked_types
|
||||
assert not missing, f"chunked_upload 缺少以下 MIME 类型: {missing}"
|
||||
|
||||
def test_chunked_upload_supports_video_mpeg(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/mpeg" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_video_matroska(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/x-matroska" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_video_3gpp(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "video/3gpp" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_flac(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/flac" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_aac(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/aac" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_m4a(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/x-m4a" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_audio_webm(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "audio/webm" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_chunked_upload_supports_extra_image_types(self):
|
||||
from apps.api.app.api.routes.chunked_upload import ALLOWED_MIME_TYPES
|
||||
|
||||
assert "image/bmp" in ALLOWED_MIME_TYPES
|
||||
assert "image/tiff" in ALLOWED_MIME_TYPES
|
||||
assert "image/svg+xml" in ALLOWED_MIME_TYPES
|
||||
|
||||
def test_both_have_same_core_video_types(self):
|
||||
"""两条路径的核心视频类型必须一致。"""
|
||||
upload_types = self._get_upload_mime_types()
|
||||
chunked_types = self._get_chunked_mime_types()
|
||||
core_video = {"video/mp4", "video/quicktime", "video/webm"}
|
||||
for vt in core_video:
|
||||
assert vt in upload_types, f"upload.py 缺少 {vt}"
|
||||
assert vt in chunked_types, f"chunked_upload.py 缺少 {vt}"
|
||||
|
||||
|
||||
# ─── 2. GeneratedVideo Repository N+1 修复验证 ───
|
||||
|
||||
|
||||
class TestGeneratedVideoRepositoryNoNPlus1:
|
||||
"""list_by_project 和 list_by_generation_task 应使用 _to_domain 而非 self.get。"""
|
||||
|
||||
def _make_model(self, video_id: str, project_id: str = "proj-1", task_id: str = "task-1"):
|
||||
model = MagicMock()
|
||||
model.id = video_id
|
||||
model.project_id = project_id
|
||||
model.generation_task_id = task_id
|
||||
model.name = f"video-{video_id}.mp4"
|
||||
model.file_url = f"https://oss.example.com/{video_id}.mp4"
|
||||
model.file_size = 1024
|
||||
model.duration = 5.0
|
||||
model.thumbnail_url = None
|
||||
model.width = 1280
|
||||
model.height = 720
|
||||
model.fps = 25.0
|
||||
model.status = "completed"
|
||||
model.review_status = "pending_review"
|
||||
model.generation_params = json.dumps({"mode": "one_take"})
|
||||
model.video_fingerprint = None
|
||||
model.is_duplicate = False
|
||||
model.duplicate_of = None
|
||||
model.generated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
model.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
return model
|
||||
|
||||
def test_list_by_project_uses_to_domain(self):
|
||||
"""list_by_project 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
models = [self._make_model(f"v{i}") for i in range(5)]
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = models
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||||
result = repo.list_by_project("proj-1")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
assert len(result) == 5
|
||||
assert all(v.id.startswith("v") for v in result)
|
||||
|
||||
def test_list_by_generation_task_uses_to_domain(self):
|
||||
"""list_by_generation_task 不应调用 self.get(N+1),应使用 _to_domain。"""
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
models = [self._make_model(f"v{i}", task_id="task-42") for i in range(3)]
|
||||
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = models
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
with patch.object(SQLAlchemyGeneratedVideoRepository, "get") as mock_get:
|
||||
result = repo.list_by_generation_task("task-42")
|
||||
mock_get.assert_not_called()
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
def test_list_by_project_returns_empty_when_no_videos(self):
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = MagicMock()
|
||||
query_mock = MagicMock()
|
||||
query_mock.filter.return_value.all.return_value = []
|
||||
session.query.return_value = query_mock
|
||||
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
result = repo.list_by_project("empty-project")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ─── 3. 成片库 API 端点可用性确认 ───
|
||||
|
||||
|
||||
class TestGeneratedVideosAPIAvailability:
|
||||
"""确认成片库 API 路由注册正确,端点可正常返回数据。"""
|
||||
|
||||
def test_generated_videos_routes_registered(self):
|
||||
"""成片库路由已注册到 router。"""
|
||||
from apps.api.app.api.router import api_router
|
||||
|
||||
# 检查 router 包含 generated-videos 路径
|
||||
routes = [r for r in api_router.routes if hasattr(r, "path")]
|
||||
gv_routes = [r for r in routes if "generated-videos" in r.path]
|
||||
assert len(gv_routes) > 0, "generated-videos 路由未注册"
|
||||
|
||||
def test_generated_videos_list_endpoint_exists(self):
|
||||
"""GET /generated-videos 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "" in paths, "GET /generated-videos 列表端点不存在"
|
||||
|
||||
def test_generated_videos_detail_endpoint_exists(self):
|
||||
"""GET /generated-videos/{video_id} 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在"
|
||||
|
||||
def test_generated_videos_review_endpoint_exists(self):
|
||||
"""PATCH /generated-videos/{video_id}/review 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}/review" in paths, "PATCH review 端点不存在"
|
||||
|
||||
def test_generated_videos_download_url_endpoint_exists(self):
|
||||
"""GET /generated-videos/{video_id}/download-url 端点存在。"""
|
||||
from apps.api.app.api.routes.generated_videos import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/{video_id}/download-url" in paths, "download-url 端点不存在"
|
||||
|
||||
def test_generated_video_response_schema_complete(self):
|
||||
"""GeneratedVideoResponse 包含所有必要字段。"""
|
||||
from apps.api.app.schemas.generated_video import GeneratedVideoResponse
|
||||
|
||||
fields = GeneratedVideoResponse.model_fields
|
||||
required_fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"name",
|
||||
"file_url",
|
||||
"status",
|
||||
"review_status",
|
||||
"download_url",
|
||||
]
|
||||
for field in required_fields:
|
||||
assert field in fields, f"GeneratedVideoResponse 缺少字段: {field}"
|
||||
|
||||
def test_list_generated_videos_response_schema(self):
|
||||
"""ListGeneratedVideosResponse 包含 items 列表。"""
|
||||
from apps.api.app.schemas.generated_video import ListGeneratedVideosResponse
|
||||
|
||||
fields = ListGeneratedVideosResponse.model_fields
|
||||
assert "items" in fields, "ListGeneratedVideosResponse 缺少 items 字段"
|
||||
|
||||
def test_generation_task_results_endpoint_exists(self):
|
||||
"""GET /generation/tasks/{task_id}/results 端点存在。"""
|
||||
from apps.api.app.api.routes.generation_tasks import router
|
||||
|
||||
paths = [r.path for r in router.routes if hasattr(r, "path")]
|
||||
assert "/tasks/{task_id}/results" in paths, "generation results 端点不存在"
|
||||
|
||||
|
||||
# ─── 4. GeneratedVideo Use Cases 可用性 ───
|
||||
|
||||
|
||||
class TestGeneratedVideoUseCases:
|
||||
"""确认成片库 Use Case 层可正常工作。"""
|
||||
|
||||
def _make_video(self, video_id: str, project_id: str = "proj-1"):
|
||||
from packages.domain import GeneratedVideo
|
||||
|
||||
return GeneratedVideo(
|
||||
id=video_id,
|
||||
project_id=project_id,
|
||||
generation_task_id="task-1",
|
||||
name=f"video-{video_id}.mp4",
|
||||
file_url=f"https://oss.example.com/{video_id}.mp4",
|
||||
file_size=1024,
|
||||
duration=5.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
status="completed",
|
||||
)
|
||||
|
||||
def test_list_generated_videos_use_case(self):
|
||||
"""ListGeneratedVideosUseCase 可正常列出视频。"""
|
||||
from packages.application import ListGeneratedVideosUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
videos = [self._make_video(f"v{i}") for i in range(3)]
|
||||
repo.list_by_project.return_value = videos
|
||||
|
||||
use_case = ListGeneratedVideosUseCase(repo)
|
||||
result = use_case.execute("proj-1")
|
||||
|
||||
repo.list_by_project.assert_called_once_with("proj-1")
|
||||
assert len(result) == 3
|
||||
|
||||
def test_get_generated_video_use_case(self):
|
||||
"""GetGeneratedVideoUseCase 可正常获取单个视频。"""
|
||||
from packages.application import GetGeneratedVideoUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
video = self._make_video("v1")
|
||||
repo.get.return_value = video
|
||||
|
||||
use_case = GetGeneratedVideoUseCase(repo)
|
||||
result = use_case.execute("v1")
|
||||
|
||||
repo.get.assert_called_once_with("v1")
|
||||
assert result.id == "v1"
|
||||
|
||||
def test_list_by_task_use_case(self):
|
||||
"""ListGeneratedVideosByTaskUseCase 可按任务列出视频。"""
|
||||
from packages.application import ListGeneratedVideosByTaskUseCase
|
||||
|
||||
repo = MagicMock()
|
||||
videos = [self._make_video(f"v{i}") for i in range(2)]
|
||||
repo.list_by_generation_task.return_value = videos
|
||||
|
||||
use_case = ListGeneratedVideosByTaskUseCase(repo)
|
||||
result = use_case.execute("task-1")
|
||||
|
||||
repo.list_by_generation_task.assert_called_once_with("task-1")
|
||||
assert len(result) == 2
|
||||
Reference in New Issue
Block a user