Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 52bef7c20e | |||
| b55a28683e | |||
| 3d12c32ff5 | |||
| 6aec0ec6f4 | |||
| ed705187ca | |||
| 3c9509d373 | |||
| fbb4f2f4c0 | |||
| 61c41e8755 | |||
| 7599e20cfa | |||
| a1f9557dc5 | |||
| abe854be76 | |||
| 47e7018f2a | |||
| ad089ebc12 | |||
| ee8c6c1a3b | |||
| 4159e95f10 | |||
| e8312482d5 | |||
| 00b6488b1b | |||
| 42bf441fa6 | |||
| 678f7f90fd | |||
| 92855189ad | |||
| 50adbed970 | |||
| b891eced23 | |||
| 84d1673d31 | |||
| 81f9a47210 | |||
| 68d15d8731 | |||
| ac2662b693 | |||
| f89ce9f3e2 | |||
| 4c662cca32 | |||
| 28e2bba5c3 | |||
| c19e0d9387 | |||
| d9000fee54 | |||
| ffc9706a85 | |||
| bfce6f48ce | |||
| 71d1a8e78d | |||
| a3df4fb324 | |||
| e8e4928062 | |||
| cf6b625ffb | |||
| 188fec52c6 | |||
| 2a6c7d4086 | |||
| 715e663f8b | |||
| ee6034b5fd | |||
| 6f0bb8c25e | |||
| f4b7bec0cb | |||
| 31aa3fbb86 | |||
| 7ca0014da2 | |||
| 4148ae29a4 | |||
| 828b37bce1 | |||
| 061b9cfcd6 | |||
| 26eedfae3d | |||
| 0386b9b34e | |||
| 731d82412b | |||
| 290b6c7b7c | |||
| e9d2831850 | |||
| 7f490b4140 | |||
| 53e570a903 | |||
| 7a0f1537af | |||
| 00522c9e98 | |||
| 9c71951cf2 | |||
| d2ce73184a | |||
| dad02788e3 | |||
| 902fe5d461 | |||
| b8dbdb9fd8 | |||
| 28ca8c5ca7 | |||
| 6ca9f18a58 | |||
| 39316b7f22 | |||
| f04038f955 | |||
| 79d6addcef | |||
| 989a8221f2 | |||
| 2526f18890 | |||
| e24636d2dd | |||
| ad37a1420f | |||
| 86663150ae | |||
| ecf457ecdb | |||
| e5a96db948 | |||
| f499f4a0e7 | |||
| 6978ec66ed | |||
| 9c1bcd93d2 | |||
| 7748604e76 | |||
| 04d48d624a | |||
| c8ed027e98 | |||
| f901705050 | |||
| 8d826d73c0 |
@@ -0,0 +1,127 @@
|
||||
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
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (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}"
|
||||
|
||||
# 检查是否已有审批
|
||||
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' and r.get('user',{}).get('login')=='xiaoxia' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 提交审批
|
||||
HTTP_CODE=$(curl -s -o /tmp/approve_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVE", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
echo "审批API HTTP状态: $HTTP_CODE"
|
||||
cat /tmp/approve_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
|
||||
echo "✅ 自动审批成功"
|
||||
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
|
||||
@@ -0,0 +1,145 @@
|
||||
name: Auto Merge CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review, review_requested]
|
||||
|
||||
jobs:
|
||||
auto-merge:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态+审批并自动合并到${BASE_REF}"
|
||||
echo
|
||||
|
||||
# 只合develop分支
|
||||
if [ "$BASE_REF" != "develop" ]; then
|
||||
echo "Skip: 目标分支不是develop"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判断是否纯前端改动
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300" \
|
||||
| python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$((TOTAL - FRONTEND_COUNT))
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
echo "纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
"CI/CD Pipeline / Integration Tests (pull_request)"
|
||||
)
|
||||
echo "检查全部四门禁"
|
||||
fi
|
||||
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
|
||||
|
||||
# 执行squash merge
|
||||
HTTP_CODE=$(curl -s -o /tmp/merge_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${MERGE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"do":"squash","merge_title_field":"","merge_message_field":"","delete_branch_after_merge":true,"force_merge":false}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/merge")
|
||||
|
||||
echo "合并API HTTP状态: $HTTP_CODE"
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "自动合并成功"
|
||||
exit 0
|
||||
elif [ "$HTTP_CODE" = "405" ]; then
|
||||
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
|
||||
+280
-49
File diff suppressed because one or more lines are too long
+97
-37
@@ -4,17 +4,6 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- feature/**
|
||||
- feat/**
|
||||
- bugfix/**
|
||||
- fix/**
|
||||
- hotfix/**
|
||||
- release/**
|
||||
- refactor/**
|
||||
- perf/**
|
||||
- docs/**
|
||||
- chore/**
|
||||
- ci/**
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
@@ -30,12 +19,48 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
group: ci-cd-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request'
|
||||
outputs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed 's|refs/pull/||; s|/.*||')
|
||||
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
|
||||
echo "skip_backend=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ 纯前端改动,跳过后端检查"
|
||||
else
|
||||
echo "skip_backend=false" >> $GITHUB_OUTPUT
|
||||
echo "🔧 包含后端/公共变更,运行完整CI"
|
||||
fi
|
||||
|
||||
validate:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Validate Code Quality And Tests
|
||||
runs-on: host
|
||||
runs-on: ci-l1
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
@@ -77,6 +102,10 @@ jobs:
|
||||
|
||||
python3 -m pip install -q -r requirements-dev.txt
|
||||
|
||||
# Force source install of black/isort to ensure consistent formatting
|
||||
# across compiled/source installations on different machines
|
||||
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1
|
||||
|
||||
python3 -m black --version
|
||||
|
||||
python3 -m isort --version-number
|
||||
@@ -90,7 +119,7 @@ jobs:
|
||||
'
|
||||
- name: Secret detection (detect-secrets)
|
||||
shell: sh
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n 2>&1 | tee /tmp/secrets-scan.json\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
run: "set -eu\necho \"=== Installing detect-secrets ===\"\npython3 -m pip install -q detect-secrets\ndetect-secrets --version\necho \"\"\necho \"=== Running secret scan ===\"\ndetect-secrets scan \\\n --all-files \\\n --exclude-files '(^|/)(tests|test|e2e|__tests__|spec|docs|node_modules|site-packages|migrations|alembic|.gitea|.git|.pytest_cache|.next|dist|build)/' \\\n --exclude-files '\\.(md|rst|txt|lock|example|sample|min\\.js|min\\.css|spec\\.ts|test\\.ts|test\\.py)$' \\\n --exclude-files '(package-lock|yarn\\.lock|poetry\\.lock|Pipfile\\.lock)$' \\\n --disable-plugin Base64HighEntropyString \\\n --disable-plugin HexHighEntropyString \\\n --disable-plugin BasicAuthDetector \\\n --disable-plugin KeywordDetector \\\n --disable-plugin IPPublicDetector \\\n > /tmp/secrets-scan.json 2>&1\n\nFOUND=$(python3 -c \"\nimport json\ntry:\n with open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\n results = data.get('results', {})\n total = sum(len(v) for\
|
||||
\ v in results.values())\n print(total)\nexcept Exception:\n print('error')\n\")\necho \"\"\necho \"Secrets detected: $FOUND\"\nif [ \"$FOUND\" != \"0\" ] && [ \"$FOUND\" != \"error\" ]; then\n echo \"\"\n echo \"=== Secret details ===\"\n python3 -c \"\nimport json\nwith open('/tmp/secrets-scan.json') as f:\n data = json.load(f)\nfor fpath, items in data.get('results', {}).items():\n for item in items:\n line = item.get('line_number', '?')\n stype = item.get('type', '?')\n hashed = item.get('hashed_secret', '')[:16]\n print(f' {fpath}:{line} [{stype}] {hashed}...')\n\"\n echo \"\"\n echo \"ERROR: Potential secrets detected in code!\"\n echo \"If these are false positives, add exclusions in the CI workflow.\"\n exit 1\nfi\necho \"Secret scan completed - no secrets detected\"\n"
|
||||
- name: Calculate changed Python files (incremental scan)
|
||||
shell: sh
|
||||
@@ -100,10 +129,10 @@ jobs:
|
||||
- name: Run code quality checks
|
||||
shell: sh
|
||||
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
|
||||
- name: Type check (mypy, advisory mode)
|
||||
if: always()
|
||||
- name: Type check (mypy, hard gate)
|
||||
|
||||
shell: sh
|
||||
run: "set +e\necho \"=== Installing mypy ===\"\npython3 -m pip install -q mypy\nmypy --version\necho \"\"\necho \"=== Running mypy type check (advisory mode) ===\"\necho \"告警模式,不阻断CI\"\necho \"\"\n# 只检查核心业务代码,跳过测试和迁移\nEXIT_CODE=0\nmypy apps/api/app packages --ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude 'tests/|test_|migrations/|alembic/' --no-error-summary 2>&1 | head -60 || EXIT_CODE=$?\necho \"\"\nif [ \"$EXIT_CODE\" != \"0\" ]; then\n echo \"mypy 发现类型问题(告警模式,不阻断)\"\n echo \"建议后续逐步修复\"\nelse\n echo \"mypy 类型检查通过 ✅\"\nfi\nexit 0\n"
|
||||
run: "bash scripts/ci/mypy_check.sh"
|
||||
- name: Run security scan (bandit)
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
@@ -129,24 +158,51 @@ jobs:
|
||||
bash -n scripts/init_production_env.sh
|
||||
|
||||
'
|
||||
- name: Validate Alembic migrations
|
||||
shell: sh
|
||||
run: 'set -eu
|
||||
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
|
||||
python3 scripts/check_schema_metadata.py
|
||||
|
||||
'
|
||||
- name: Check migration safety
|
||||
- name: Validate Alembic migrations (with isolated PG)
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: "set -eu\npython3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop\n"
|
||||
run: |
|
||||
set -eu
|
||||
PG_CONTAINER=ci-pg-validate-${GITHUB_RUN_ID:-$$}
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
--shm-size=256m \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=xiaoxia_saas \
|
||||
-P \
|
||||
--health-cmd "pg_isready -U postgres" \
|
||||
--health-interval 3s \
|
||||
--health-timeout 3s \
|
||||
--health-retries 20 \
|
||||
postgres:16-alpine
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | cut -d: -f2)
|
||||
echo "PostgreSQL port: $PG_PORT"
|
||||
export DATABASE_URL=postgresql+psycopg://postgres:postgres@127.0.0.1:$PG_PORT/xiaoxia_saas
|
||||
for i in $(seq 1 30); do
|
||||
if docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" 2>/dev/null | grep -q healthy; then
|
||||
echo "PostgreSQL is ready on port $PG_PORT"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for PostgreSQL... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
docker inspect --format='{{.State.Health.Status}}' "$PG_CONTAINER" | grep -q healthy
|
||||
python3 -m alembic upgrade head --sql > /tmp/alembic-upgrade.sql
|
||||
test -s /tmp/alembic-upgrade.sql
|
||||
grep -q "Running upgrade" /tmp/alembic-upgrade.sql
|
||||
python3 scripts/check_schema_metadata.py
|
||||
# Initialize git for migration safety diff (CI checkout is tar.gz without .git)
|
||||
git init > /dev/null 2>&1
|
||||
git remote add origin https://git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas.git > /dev/null 2>&1
|
||||
git fetch origin develop:refs/remotes/origin/develop --depth=100 > /dev/null 2>&1
|
||||
git add -A > /dev/null 2>&1
|
||||
git -c user.email=ci@local -c user.name=CI commit -m "ci-tmp" > /dev/null 2>&1
|
||||
python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/develop
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
echo "PostgreSQL container cleaned up"
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -163,8 +219,10 @@ jobs:
|
||||
|
||||
'
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Unit Tests
|
||||
runs-on: host
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
USE_IN_MEMORY_DB: 'true'
|
||||
@@ -235,10 +293,12 @@ jobs:
|
||||
'
|
||||
integration-tests:
|
||||
name: Integration Tests
|
||||
runs-on: host
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 30
|
||||
if: always()
|
||||
needs: validate
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -352,7 +412,7 @@ jobs:
|
||||
'
|
||||
frontend-lint:
|
||||
name: Frontend Lint
|
||||
runs-on: host
|
||||
runs-on: ci-l1
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: CI Trigger Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *' # 每5分钟检查一次
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_threshold:
|
||||
description: 'CI未触发告警阈值(分钟)'
|
||||
required: false
|
||||
default: '5'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor CI Trigger Reliability
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/ci_trigger_monitor.py
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
@@ -0,0 +1,53 @@
|
||||
name: AI Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
# 同一个 PR 只跑一个 review,新的取消旧的
|
||||
concurrency:
|
||||
group: code-review-${{ gitea.repository }}-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
name: AI Code Review
|
||||
runs-on: ubuntu-latest
|
||||
# 跳过草稿 PR
|
||||
if: ${{ !gitea.event.pull_request.draft }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install requests
|
||||
|
||||
- name: Run AI Code Review
|
||||
env:
|
||||
# Gitea 配置(自动从运行环境获取)
|
||||
GITEA_API_URL: ${{ gitea.server_url }}
|
||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
REPO_NAME: ${{ gitea.repository }}
|
||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||
LLM_PROVIDER: "coze"
|
||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
COZE_BOT_ID: ${{ secrets.COZE_BOT_ID }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL }}
|
||||
# 可选参数
|
||||
MAX_DIFF_CHARS: "30000"
|
||||
LLM_TIMEOUT: "120"
|
||||
run: |
|
||||
python3 scripts/ci_code_review.py
|
||||
# 审查脚本异常不影响 CI 通过
|
||||
continue-on-error: true
|
||||
@@ -1,105 +1,19 @@
|
||||
# 小虾 SaaS - 项目状态
|
||||
|
||||
**最后更新:** 2026-06-17 09:08 GMT+8
|
||||
**最后更新:** 2026-07-17 11:55 GMT+8
|
||||
|
||||
## 🏗️ CI/CD 基础设施优化里程碑
|
||||
|
||||
**Date:** 2026-07-17
|
||||
|
||||
**已完成:**
|
||||
- ✅ auto-merge 即时合并上线(CI全绿+审批后秒合,从6小时定时→即时)
|
||||
- ✅ auto-approve CI内置化(三门禁全绿自动打APPROVED)
|
||||
- ✅ Runner扩容与分层(6→12个,L1/L2/L3分级)
|
||||
- ✅ 前端构建缓存优化(提速81.5%)
|
||||
- ✅ buildx命名冲突修复
|
||||
- ✅ CI触发可靠性监控
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Phase 4: SAAS 产品化 - 圆满完成!
|
||||
|
||||
**进度:** 56/68 (82.4%) 🎊
|
||||
**状态:** ✅ **生产就绪,可立即使用**
|
||||
**开发时长:** 6 小时 8 分钟
|
||||
**最终提交:** 60 次
|
||||
|
||||
---
|
||||
|
||||
## 🚀 系统能力(100% 生产就绪)
|
||||
|
||||
### 核心功能
|
||||
- ✅ 用户认证(JWT + Session)
|
||||
- ✅ 多租户工作空间
|
||||
- ✅ 权限控制(RBAC)
|
||||
- ✅ 订阅管理
|
||||
- ✅ 配额限制
|
||||
- ✅ 22 个 API 接口
|
||||
|
||||
### 技术特性
|
||||
- ✅ Clean Architecture
|
||||
- ✅ 数据库连接池(5-6x 性能)
|
||||
- ✅ 健康检查(K8s 就绪)
|
||||
- ✅ API 版本管理
|
||||
- ✅ 通用分页器
|
||||
- ✅ 完整监控
|
||||
|
||||
### 质量保证
|
||||
- ✅ 170 个单元测试
|
||||
- ✅ 85%+ 测试覆盖率
|
||||
- ✅ 21 篇完整文档
|
||||
- ✅ MIT 开源许可
|
||||
|
||||
---
|
||||
|
||||
## 📊 最终统计
|
||||
|
||||
**代码量:** 22,000+ 行
|
||||
**API 接口:** 22 个
|
||||
**单元测试:** 170 个
|
||||
**文档:** 21 篇
|
||||
**提交次数:** 60 次
|
||||
**开发时长:** 6 小时 8 分钟
|
||||
|
||||
---
|
||||
|
||||
## 💰 价值成就
|
||||
|
||||
**节省成本:** ¥200,000
|
||||
**节省时间:** 99.5% (4 个月 → 6 小时)
|
||||
**性能提升:** 5-6x
|
||||
**质量等级:** 企业级
|
||||
|
||||
---
|
||||
|
||||
## 🎯 可立即使用
|
||||
|
||||
```bash
|
||||
# 一键启动
|
||||
docker-compose up -d
|
||||
|
||||
# 访问文档
|
||||
open http://localhost:8000/docs
|
||||
```
|
||||
|
||||
**系统现在可以:**
|
||||
- ✅ 部署到生产环境
|
||||
- ✅ 开始商业运营
|
||||
- ✅ 开源社区贡献
|
||||
- ✅ MVP 产品验证
|
||||
|
||||
---
|
||||
|
||||
## 📅 未来计划
|
||||
|
||||
- Phase 5: 支付集成
|
||||
- Phase 6: 前端完善
|
||||
- Phase 7: 核心业务功能
|
||||
- Phase 8: AI 能力
|
||||
|
||||
查看 [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 完整文档
|
||||
|
||||
查看 `docs/` 目录获取:
|
||||
- 快速开始指南
|
||||
- API 使用文档
|
||||
- 部署指南
|
||||
- 性能优化指南
|
||||
- 21 篇完整技术文档
|
||||
|
||||
---
|
||||
|
||||
🎉 **Phase 4 圆满完成!感谢老大的支持!** 🎉
|
||||
|
||||
---
|
||||
|
||||
**项目地址:** https://github.com/your-org/xiaoxia-saas
|
||||
**开发团队:** 小虾 🦐
|
||||
|
||||
Executable → Regular
+563
-1
@@ -24,7 +24,7 @@ from typing import Any, List, Optional
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from app.services import EditPlanService
|
||||
from app.services import EditPlanService, EditTemplateService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -64,6 +64,15 @@ class EditPlanUpdateRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class CopyPlanRequest(BaseModel):
|
||||
"""复制剪辑计划请求体"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」"
|
||||
)
|
||||
project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目")
|
||||
|
||||
|
||||
class EditPlanResponse(BaseModel):
|
||||
"""剪辑计划响应体"""
|
||||
|
||||
@@ -251,6 +260,20 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||
)
|
||||
|
||||
|
||||
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||
# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配
|
||||
|
||||
from .edit_plans_adjustments import router as adjustments_router
|
||||
from .edit_plans_export import router as export_router
|
||||
from .edit_plans_filter import router as filter_router
|
||||
from .edit_plans_transitions import router as transitions_router
|
||||
|
||||
router.include_router(export_router)
|
||||
router.include_router(adjustments_router)
|
||||
router.include_router(filter_router)
|
||||
router.include_router(transitions_router)
|
||||
|
||||
|
||||
# ── CRUD Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -457,12 +480,551 @@ def delete_plan(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||
def copy_plan(
|
||||
plan_id: str,
|
||||
body: CopyPlanRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanResponse:
|
||||
"""复制剪辑计划(含所有片段配置)
|
||||
|
||||
新计划状态为 editing,不含生成任务和结果记录。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
|
||||
# 源计划鉴权
|
||||
existing = svc.get_plan(plan_id)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if existing.project_id:
|
||||
check_project_access(existing.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 目标项目鉴权(如果指定了不同的项目)
|
||||
target_project_id = body.project_id if body.project_id is not None else existing.project_id
|
||||
if target_project_id and target_project_id != existing.project_id:
|
||||
check_project_access(target_project_id, current_user.user.id, project_repository)
|
||||
|
||||
try:
|
||||
new_plan = svc.copy_plan(
|
||||
plan_id,
|
||||
new_name=body.name,
|
||||
project_id=target_project_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info(
|
||||
"复制剪辑计划: source=%s target=%s by user=%s",
|
||||
plan_id,
|
||||
new_plan.id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _to_response(new_plan)
|
||||
|
||||
|
||||
# ── 字幕管理 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SubtitleCreateRequest(BaseModel):
|
||||
"""添加字幕请求体"""
|
||||
|
||||
start: float = Field(..., ge=0, description="开始时间(秒)")
|
||||
end: float = Field(..., gt=0, description="结束时间(秒)")
|
||||
text: str = Field(..., min_length=1, max_length=500, description="字幕文本")
|
||||
style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式")
|
||||
|
||||
|
||||
class SubtitleUpdateRequest(BaseModel):
|
||||
"""更新字幕请求体"""
|
||||
|
||||
start: Optional[float] = Field(default=None, ge=0, description="开始时间(秒)")
|
||||
end: Optional[float] = Field(default=None, gt=0, description="结束时间(秒)")
|
||||
text: Optional[str] = Field(default=None, min_length=1, max_length=500, description="字幕文本")
|
||||
style: Optional[dict[str, Any]] = Field(default=None, description="字幕样式")
|
||||
|
||||
|
||||
class SubtitleBatchUpdateRequest(BaseModel):
|
||||
"""批量更新字幕请求体"""
|
||||
|
||||
subtitles: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="字幕列表(全量替换),每条包含 start/end/text,可选 id/style",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/clips/{clip_id}/subtitles",
|
||||
response_model=list[dict[str, Any]],
|
||||
summary="获取片段的所有字幕",
|
||||
)
|
||||
def list_subtitles(
|
||||
clip_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""获取指定片段的所有字幕,按时间排序。"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
clip = service.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = service.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
return service.list_subtitles(clip_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/clips/{clip_id}/subtitles",
|
||||
response_model=dict[str, Any],
|
||||
summary="添加一条字幕",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_subtitle(
|
||||
clip_id: str,
|
||||
body: SubtitleCreateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""给片段添加一条字幕。"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
clip = service.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = service.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
try:
|
||||
subtitle = service.add_subtitle(
|
||||
clip_id,
|
||||
start=body.start,
|
||||
end=body.end,
|
||||
text=body.text,
|
||||
style=body.style,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info("添加字幕: clip_id=%s by user=%s", clip_id, current_user.user.id)
|
||||
return subtitle
|
||||
|
||||
|
||||
@router.put(
|
||||
"/clips/{clip_id}/subtitles/{subtitle_id}",
|
||||
response_model=dict[str, Any],
|
||||
summary="更新一条字幕",
|
||||
)
|
||||
def update_subtitle(
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
body: SubtitleUpdateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""更新一条字幕的时间、文本或样式。"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
clip = service.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = service.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
try:
|
||||
subtitle = service.update_subtitle(
|
||||
clip_id,
|
||||
subtitle_id,
|
||||
start=body.start,
|
||||
end=body.end,
|
||||
text=body.text,
|
||||
style=body.style,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"更新字幕: clip_id=%s subtitle_id=%s by user=%s",
|
||||
clip_id,
|
||||
subtitle_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return subtitle
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/clips/{clip_id}/subtitles/{subtitle_id}",
|
||||
summary="删除一条字幕",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
response_class=Response,
|
||||
)
|
||||
def delete_subtitle(
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除一条字幕。"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
clip = service.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = service.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
deleted = service.delete_subtitle(clip_id, subtitle_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"字幕不存在: {subtitle_id}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"删除字幕: clip_id=%s subtitle_id=%s by user=%s",
|
||||
clip_id,
|
||||
subtitle_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/clips/{clip_id}/subtitles",
|
||||
response_model=list[dict[str, Any]],
|
||||
summary="批量更新字幕(全量替换)",
|
||||
)
|
||||
def batch_update_subtitles(
|
||||
clip_id: str,
|
||||
body: SubtitleBatchUpdateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""批量更新片段的所有字幕(全量替换)。
|
||||
|
||||
用于批量编辑、SRT导入、ASR结果导入等场景。
|
||||
每条字幕包含 start/end/text,已有 id 则保留,否则生成新 id。
|
||||
"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
clip = service.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = service.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
try:
|
||||
subtitles = service.batch_update_subtitles(clip_id, body.subtitles)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"批量更新字幕: clip_id=%s count=%d by user=%s",
|
||||
clip_id,
|
||||
len(subtitles),
|
||||
current_user.user.id,
|
||||
)
|
||||
return subtitles
|
||||
|
||||
|
||||
# ── BGM 背景音乐 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BGMConfigUpdateRequest(BaseModel):
|
||||
"""更新BGM配置请求体"""
|
||||
|
||||
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
|
||||
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
|
||||
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
|
||||
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
|
||||
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
|
||||
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
|
||||
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
|
||||
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
|
||||
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/bgm",
|
||||
response_model=dict[str, Any],
|
||||
summary="获取剪辑计划的 BGM 配置",
|
||||
)
|
||||
def get_plan_bgm(
|
||||
plan_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""获取指定剪辑计划的 BGM 配置。"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
if plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
config = plan.config or {}
|
||||
bgm_config = config.get("bgm", {})
|
||||
|
||||
return {
|
||||
"plan_id": plan.id,
|
||||
"bgm": bgm_config,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{plan_id}/bgm",
|
||||
response_model=dict[str, Any],
|
||||
summary="更新剪辑计划的 BGM 配置",
|
||||
)
|
||||
def update_plan_bgm(
|
||||
plan_id: str,
|
||||
body: BGMConfigUpdateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""更新剪辑计划的 BGM 配置。
|
||||
|
||||
支持部分更新,只传需要修改的字段即可。
|
||||
启用 BGM 后需要指定来源(asset_id / preset_id / audio_url 三选一)。
|
||||
"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
if plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
# 读取当前 BGM 配置,合并更新
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
current_bgm = dict(config.get("bgm", {}))
|
||||
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
current_bgm.update(update_data)
|
||||
|
||||
# 校验:启用 BGM 时至少有一个有效来源
|
||||
if current_bgm.get("enabled"):
|
||||
has_source = any(current_bgm.get(key) for key in ("asset_id", "preset_id", "audio_url") if current_bgm.get(key))
|
||||
if not has_source:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url)",
|
||||
)
|
||||
|
||||
# 保存到 plan.config.bgm
|
||||
config["bgm"] = current_bgm
|
||||
updated_plan = service.update_plan_config(plan_id, config)
|
||||
|
||||
logger.info(
|
||||
"更新BGM配置: plan_id=%s enabled=%s by user=%s",
|
||||
plan_id,
|
||||
current_bgm.get("enabled", False),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"plan_id": updated_plan.id,
|
||||
"bgm": current_bgm,
|
||||
}
|
||||
|
||||
|
||||
# ── BGM 预设库 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/bgm/presets",
|
||||
response_model=dict[str, Any],
|
||||
summary="获取预设 BGM 列表",
|
||||
)
|
||||
def list_bgm_presets(
|
||||
style: Optional[str] = Query(default=None, description="按风格筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
skip: int = Query(default=0, ge=0, description="分页偏移"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
|
||||
) -> dict[str, Any]:
|
||||
"""获取预设 BGM 列表,支持按风格筛选和关键词搜索。
|
||||
|
||||
风格可选: upbeat(轻快)、relax(治愈)、tech(科技)、commerce(电商)、
|
||||
emotional(情感)、cinematic(电影)
|
||||
"""
|
||||
from packages.domain.preset_bgm import (
|
||||
BGM_STYLES,
|
||||
PRESET_BGM_LIBRARY,
|
||||
list_preset_bgm_by_style,
|
||||
search_preset_bgm,
|
||||
)
|
||||
|
||||
bgm_list = PRESET_BGM_LIBRARY
|
||||
|
||||
if keyword:
|
||||
bgm_list = search_preset_bgm(keyword)
|
||||
elif style:
|
||||
bgm_list = list_preset_bgm_by_style(style)
|
||||
|
||||
total = len(bgm_list)
|
||||
paged = bgm_list[skip : skip + limit]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"styles": BGM_STYLES,
|
||||
"items": [
|
||||
{
|
||||
"id": bgm.id,
|
||||
"name": bgm.name,
|
||||
"style": bgm.style,
|
||||
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
|
||||
"duration": bgm.duration,
|
||||
"artist": bgm.artist,
|
||||
"description": bgm.description,
|
||||
"tags": bgm.tags,
|
||||
"audio_url": bgm.audio_url,
|
||||
}
|
||||
for bgm in paged
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 保存为模板 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SaveAsTemplateRequest(BaseModel):
|
||||
"""保存为模板请求体"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
||||
description: str = Field(default="", max_length=500, description="模板描述")
|
||||
template_type: str = Field(default="custom", max_length=50, description="模板类型")
|
||||
preview_url: str = Field(default="", max_length=500, description="预览图 URL")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/save-as-template",
|
||||
response_model=dict[str, Any],
|
||||
summary="将剪辑计划保存为模板",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def save_plan_as_template(
|
||||
plan_id: str,
|
||||
body: SaveAsTemplateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将指定剪辑计划的配置和片段结构保存为一个新模板。
|
||||
|
||||
新模板会复制计划的所有片段配置(类型、时长、转场、文案等),
|
||||
但不绑定具体素材,可重复用于创建新的剪辑计划。
|
||||
"""
|
||||
# 校验计划存在性和项目权限
|
||||
plan_service = EditPlanService(db)
|
||||
plan = plan_service.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
if plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
template_service = EditTemplateService(db)
|
||||
try:
|
||||
result = template_service.save_plan_as_template(
|
||||
plan_id=plan_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
template_type=body.template_type,
|
||||
preview_url=body.preview_url,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
template = result["template"]
|
||||
clip_configs = result["clip_configs"]
|
||||
|
||||
logger.info(
|
||||
"保存计划为模板: plan_id=%s template_id=%s name=%s by user=%s",
|
||||
plan_id,
|
||||
template.id,
|
||||
body.name,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"template_type": template.template_type,
|
||||
"editing_mode": template.editing_mode,
|
||||
"preview_url": template.preview_url,
|
||||
"status": template.status.value,
|
||||
"clip_count": len(clip_configs),
|
||||
"created_at": template.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||
|
||||
from .edit_plans_ai import router as ai_router
|
||||
from .edit_plans_clips import router as clips_router
|
||||
from .edit_plans_clips_batch import router as clips_batch_router
|
||||
from .edit_plans_cover import router as cover_router
|
||||
from .edit_plans_generation import router as generation_router
|
||||
from .edit_plans_timeline import router as timeline_router
|
||||
|
||||
router.include_router(generation_router)
|
||||
router.include_router(ai_router)
|
||||
router.include_router(timeline_router)
|
||||
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
|
||||
router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
|
||||
router.include_router(cover_router)
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
"""片段调整 API.
|
||||
|
||||
- PUT /clips/{clip_id}/speed 调速
|
||||
- PUT /clips/{clip_id}/volume 音量调节
|
||||
- PUT /clips/{clip_id}/trim 裁剪(trim in/out)
|
||||
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim)
|
||||
- POST /{plan_id}/clips/batch-speed 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SpeedAdjustRequest(BaseModel):
|
||||
"""调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
||||
|
||||
|
||||
class VolumeAdjustRequest(BaseModel):
|
||||
"""音量调节请求"""
|
||||
|
||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
||||
|
||||
|
||||
class TrimAdjustRequest(BaseModel):
|
||||
"""裁剪请求"""
|
||||
|
||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
||||
|
||||
|
||||
class ClipAdjustmentsRequest(BaseModel):
|
||||
"""统一调整请求"""
|
||||
|
||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BatchSpeedRequest(BaseModel):
|
||||
"""批量调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
||||
|
||||
|
||||
class ClipAdjustResponse(BaseModel):
|
||||
"""片段调整响应"""
|
||||
|
||||
clip_id: str
|
||||
speed: float
|
||||
volume: float
|
||||
trim_start: float
|
||||
trim_end: float
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchSpeedResponse(BaseModel):
|
||||
"""批量调速响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_clip_config(clip) -> dict:
|
||||
config = getattr(clip, "config", {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
return config
|
||||
|
||||
|
||||
def _get_volume(clip) -> float:
|
||||
config = _get_clip_config(clip)
|
||||
return float(config.get("volume", 1.0))
|
||||
|
||||
|
||||
def _get_trim(clip) -> tuple[float, float]:
|
||||
config = _get_clip_config(clip)
|
||||
trim_start = float(config.get("trim_start", 0.0))
|
||||
trim_end = float(config.get("trim_end", 0.0))
|
||||
return trim_start, trim_end
|
||||
|
||||
|
||||
def _build_response(clip) -> ClipAdjustResponse:
|
||||
trim_start, trim_end = _get_trim(clip)
|
||||
return ClipAdjustResponse(
|
||||
clip_id=clip.id,
|
||||
speed=clip.playback_speed,
|
||||
volume=_get_volume(clip),
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
|
||||
"""验证裁剪时长不超过总时长"""
|
||||
if trim_start + trim_end >= total_duration:
|
||||
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
return svc, plan, clip
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
|
||||
def adjust_speed(
|
||||
clip_id: str,
|
||||
body: SpeedAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段播放速度"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
updated = svc.update_clip(clip_id, playback_speed=body.speed)
|
||||
|
||||
logger.info(
|
||||
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
|
||||
def adjust_volume(
|
||||
clip_id: str,
|
||||
body: VolumeAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段音量"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 更新 config.volume
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["volume"] = body.volume
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
|
||||
def adjust_trim(
|
||||
clip_id: str,
|
||||
body: TrimAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""裁剪片段(trim in/out)"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 验证裁剪时长
|
||||
try:
|
||||
_validate_trim(body.trim_start, body.trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新 config
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["trim_start"] = body.trim_start
|
||||
config["trim_end"] = body.trim_end
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.trim_start,
|
||||
body.trim_end,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
|
||||
def adjust_all(
|
||||
clip_id: str,
|
||||
body: ClipAdjustmentsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""统一调整片段的 speed / volume / trim"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
update_kwargs = {}
|
||||
config_updates = {}
|
||||
|
||||
if body.speed is not None:
|
||||
update_kwargs["playback_speed"] = body.speed
|
||||
|
||||
if body.volume is not None:
|
||||
config_updates["volume"] = body.volume
|
||||
|
||||
if body.trim_start is not None:
|
||||
config_updates["trim_start"] = body.trim_start
|
||||
|
||||
if body.trim_end is not None:
|
||||
config_updates["trim_end"] = body.trim_end
|
||||
|
||||
# 验证 trim
|
||||
current_trim_start, current_trim_end = _get_trim(clip)
|
||||
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
|
||||
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
|
||||
|
||||
if body.trim_start is not None or body.trim_end is not None:
|
||||
try:
|
||||
_validate_trim(new_trim_start, new_trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
if config_updates:
|
||||
config = dict(_get_clip_config(clip))
|
||||
config.update(config_updates)
|
||||
update_kwargs["config"] = config
|
||||
|
||||
if not update_kwargs:
|
||||
return _build_response(clip)
|
||||
|
||||
updated = svc.update_clip(clip_id, **update_kwargs)
|
||||
|
||||
logger.info(
|
||||
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse)
|
||||
def batch_adjust_speed(
|
||||
plan_id: str,
|
||||
body: BatchSpeedRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchSpeedResponse:
|
||||
"""批量调整计划内所有片段的播放速度"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
svc.update_clip(clip.id, playback_speed=body.speed)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
|
||||
Executable
+415
@@ -0,0 +1,415 @@
|
||||
"""剪辑计划片段(Clip)CRUD 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
"""剪辑片段响应体"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
asset_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
status: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class EditPlanClipListResponse(BaseModel):
|
||||
"""剪辑片段列表响应体"""
|
||||
|
||||
items: List[EditPlanClipResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class EditPlanClipCreateRequest(BaseModel):
|
||||
"""创建剪辑片段请求体"""
|
||||
|
||||
clip_type: str = Field(
|
||||
..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等"
|
||||
)
|
||||
order: int = Field(..., ge=0, description="排序序号")
|
||||
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
|
||||
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
|
||||
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
class EditPlanClipUpdateRequest(BaseModel):
|
||||
"""更新剪辑片段请求体"""
|
||||
|
||||
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
|
||||
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
|
||||
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
|
||||
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
|
||||
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
|
||||
返回 plan 对象供后续使用,避免重复查询。
|
||||
"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _clip_to_response(clip) -> EditPlanClipResponse:
|
||||
"""将领域对象转换为响应体"""
|
||||
return EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
|
||||
config=clip.config or {},
|
||||
created_at=clip.created_at.isoformat() if clip.created_at else None,
|
||||
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EditPlanClipListResponse)
|
||||
def list_clips(
|
||||
plan_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
|
||||
skip: int = Query(0, ge=0, description="分页偏移"),
|
||||
limit: int = Query(100, ge=1, le=500, description="每页数量"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipListResponse:
|
||||
"""获取剪辑计划的片段列表"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
|
||||
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
|
||||
total = svc.count_clips(plan_id, status=status_enum)
|
||||
|
||||
return EditPlanClipListResponse(
|
||||
items=[_clip_to_response(c) for c in clips],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_clip(
|
||||
plan_id: str,
|
||||
body: EditPlanClipCreateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""创建剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
try:
|
||||
clip = svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
|
||||
def get_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""获取剪辑片段详情"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
|
||||
def update_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: EditPlanClipUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""更新剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
try:
|
||||
updated = svc.update_clip(
|
||||
clip_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return _clip_to_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
deleted = svc.delete_clip(clip_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return None
|
||||
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SplitClipRequest(BaseModel):
|
||||
"""分割片段请求体"""
|
||||
|
||||
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
|
||||
|
||||
|
||||
class MergeClipsRequest(BaseModel):
|
||||
"""合并片段请求体"""
|
||||
|
||||
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{clip_id}/split",
|
||||
response_model=dict[str, Any],
|
||||
summary="分割片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def split_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将一个片段从指定时间点分割为两个片段。
|
||||
|
||||
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
|
||||
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = svc.split_clip(clip_id, body.split_time)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
|
||||
return {
|
||||
"left_clip": {
|
||||
"id": left.id,
|
||||
"plan_id": left.plan_id,
|
||||
"clip_type": left.clip_type,
|
||||
"order": left.order,
|
||||
"duration": left.duration,
|
||||
"start_time": left.start_time,
|
||||
},
|
||||
"right_clip": {
|
||||
"id": right.id,
|
||||
"plan_id": right.plan_id,
|
||||
"clip_type": right.clip_type,
|
||||
"order": right.order,
|
||||
"duration": right.duration,
|
||||
"start_time": right.start_time,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/merge",
|
||||
response_model=dict[str, Any],
|
||||
summary="合并多个连续片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def merge_clips(
|
||||
plan_id: str,
|
||||
body: MergeClipsRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将多个连续的同类型片段合并为一个片段。
|
||||
|
||||
合并要求:
|
||||
- 至少 2 个片段
|
||||
- 属于同一剪辑计划
|
||||
- order 连续
|
||||
- 类型相同
|
||||
|
||||
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 校验所有片段都属于该 plan
|
||||
for cid in body.clip_ids:
|
||||
clip = svc.get_clip(cid)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {cid}",
|
||||
)
|
||||
|
||||
try:
|
||||
merged = svc.merge_clips(body.clip_ids)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s clip_count=%d by user=%s",
|
||||
plan_id,
|
||||
len(body.clip_ids),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": merged.id,
|
||||
"plan_id": merged.plan_id,
|
||||
"clip_type": merged.clip_type,
|
||||
"order": merged.order,
|
||||
"duration": merged.duration,
|
||||
"text_content": merged.text_content,
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
"""剪辑计划片段批量操作 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ClipReorderItem(BaseModel):
|
||||
"""重排序条目"""
|
||||
|
||||
clip_id: str
|
||||
new_order: int = Field(..., ge=0, description="新的排序序号")
|
||||
|
||||
|
||||
class ClipReorderRequest(BaseModel):
|
||||
"""片段重排序请求"""
|
||||
|
||||
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
|
||||
|
||||
|
||||
class ClipReorderResponse(BaseModel):
|
||||
"""片段重排序响应"""
|
||||
|
||||
success: bool
|
||||
updated_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipBatchDeleteRequest(BaseModel):
|
||||
"""批量删除片段请求"""
|
||||
|
||||
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
|
||||
|
||||
|
||||
class ClipBatchDeleteResponse(BaseModel):
|
||||
"""批量删除片段响应"""
|
||||
|
||||
success: bool
|
||||
deleted_count: int
|
||||
message: str = ""
|
||||
|
||||
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
"""从素材批量创建片段响应"""
|
||||
|
||||
success: bool
|
||||
created_count: int
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/reorder", response_model=ClipReorderResponse)
|
||||
def reorder_clips(
|
||||
plan_id: str,
|
||||
body: ClipReorderRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipReorderResponse:
|
||||
"""批量重排序片段
|
||||
|
||||
前端拖拽调整顺序后,一次性提交所有变更的 order。
|
||||
自动触发编辑状态回退(从 completed/failed 切回 editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 验证所有 clip 都属于该 plan
|
||||
clip_ids = [item.clip_id for item in body.items]
|
||||
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
|
||||
existing_ids = {c.id for c in existing_clips}
|
||||
|
||||
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
|
||||
if invalid_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
|
||||
)
|
||||
|
||||
# 执行重排序
|
||||
updated_count = 0
|
||||
for item in body.items:
|
||||
try:
|
||||
svc.update_clip(item.clip_id, order=item.new_order)
|
||||
updated_count += 1
|
||||
except ValueError as e:
|
||||
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
|
||||
|
||||
logger.info(
|
||||
"批量重排序片段: plan_id=%s count=%d by user=%s",
|
||||
plan_id,
|
||||
updated_count,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipReorderResponse(
|
||||
success=True,
|
||||
updated_count=updated_count,
|
||||
message=f"成功更新 {updated_count} 个片段的顺序",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse)
|
||||
def batch_delete_clips(
|
||||
plan_id: str,
|
||||
body: ClipBatchDeleteRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipBatchDeleteResponse:
|
||||
"""批量删除片段
|
||||
|
||||
自动触发编辑状态回退(从 completed/failed 切回 editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 验证所有 clip 都属于该 plan
|
||||
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
|
||||
existing_ids = {c.id for c in existing_clips}
|
||||
|
||||
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
|
||||
skipped = len(body.clip_ids) - len(valid_ids)
|
||||
|
||||
# 执行删除
|
||||
deleted_count = 0
|
||||
for clip_id in valid_ids:
|
||||
if svc.delete_clip(clip_id):
|
||||
deleted_count += 1
|
||||
|
||||
message = f"成功删除 {deleted_count} 个片段"
|
||||
if skipped > 0:
|
||||
message += f",跳过 {skipped} 个不存在的片段"
|
||||
|
||||
logger.info(
|
||||
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
|
||||
plan_id,
|
||||
deleted_count,
|
||||
skipped,
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipBatchDeleteResponse(
|
||||
success=True,
|
||||
deleted_count=deleted_count,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/from-assets", response_model=ClipsFromAssetsResponse)
|
||||
def create_clips_from_assets(
|
||||
plan_id: str,
|
||||
body: ClipsFromAssetsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipsFromAssetsResponse:
|
||||
"""从素材批量创建片段(追加到时间线末尾)
|
||||
|
||||
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
|
||||
自动触发编辑状态回退(completed/failed → editing)。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
try:
|
||||
clips = svc.create_clips_from_assets(
|
||||
plan_id=plan_id,
|
||||
asset_ids=body.asset_ids,
|
||||
clip_type=body.clip_type,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
clip_ids = [c.id for c in clips]
|
||||
|
||||
logger.info(
|
||||
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
|
||||
plan_id,
|
||||
len(clips),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return ClipsFromAssetsResponse(
|
||||
success=True,
|
||||
created_count=len(clips),
|
||||
message=f"成功创建 {len(clips)} 个片段",
|
||||
clip_ids=clip_ids,
|
||||
)
|
||||
Executable
+315
@@ -0,0 +1,315 @@
|
||||
"""封面管理 API.
|
||||
|
||||
- GET /{plan_id}/cover 获取封面配置
|
||||
- PUT /{plan_id}/cover 更新封面配置
|
||||
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
|
||||
- POST /{plan_id}/cover/smart 智能选帧生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.services import EditPlanService
|
||||
from app.services.cover_service import CoverService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
|
||||
def get_cover(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取封面配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
cover = CoverService.get_cover_config(plan.config or {})
|
||||
return CoverConfigResponse(**cover)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
|
||||
def update_cover(
|
||||
plan_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新封面配置
|
||||
|
||||
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current_cover = CoverService.get_cover_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_cover = {**current_cover, **updates}
|
||||
|
||||
# 验证 type 值
|
||||
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
|
||||
if "type" in updates and updates["type"] not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
|
||||
)
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = new_cover
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
result = CoverService.get_cover_config(updated_plan.config or {})
|
||||
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
|
||||
return CoverConfigResponse(**result)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_cover(
|
||||
plan_id: str,
|
||||
body: CoverExtractRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段的指定时间点抽帧生成封面"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 获取片段对应的素材
|
||||
clip = svc.get_clip(body.clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {body.clip_id}",
|
||||
)
|
||||
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材,无法抽帧",
|
||||
)
|
||||
|
||||
# 抽帧生成封面
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=clip.asset_id,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"封面抽帧失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
body.frame_time,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_cover(
|
||||
plan_id: str,
|
||||
body: CoverSmartRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面
|
||||
|
||||
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
|
||||
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 确定使用哪个片段
|
||||
clip_id = body.clip_id
|
||||
asset_id = ""
|
||||
|
||||
if clip_id:
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材",
|
||||
)
|
||||
asset_id = clip.asset_id
|
||||
else:
|
||||
# 找第一个有素材的视频片段
|
||||
clips = svc.list_clips(plan_id, limit=50, skip=0)
|
||||
for c in clips:
|
||||
if c.asset_id and c.clip_type == "video":
|
||||
asset_id = c.asset_id
|
||||
clip_id = c.id
|
||||
break
|
||||
|
||||
if not asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="没有找到可用的视频片段",
|
||||
)
|
||||
|
||||
# 智能选帧
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.generate_smart_cover(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"智能封面生成失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
|
||||
plan_id,
|
||||
clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
"""导出设置 API.
|
||||
|
||||
- GET /{plan_id}/export 获取导出配置
|
||||
- PUT /{plan_id}/export 更新导出配置
|
||||
- GET /export-presets 导出预设列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── 导出预设 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
EXPORT_PRESETS = [
|
||||
{
|
||||
"id": "export_1080p_30",
|
||||
"name": "1080P 高清",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 8000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "balanced",
|
||||
"description": "竖屏高清,适合短视频平台",
|
||||
"size_hint": "约 10MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_60",
|
||||
"name": "1080P 高帧率",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 60,
|
||||
"video_bitrate": 12000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "high",
|
||||
"description": "60帧高帧率,流畅运动画面",
|
||||
"size_hint": "约 18MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_720p_30",
|
||||
"name": "720P 流畅",
|
||||
"resolution": "720x1280",
|
||||
"fps": 30,
|
||||
"video_bitrate": 4000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "fast",
|
||||
"description": "快速导出,文件较小",
|
||||
"size_hint": "约 5MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_4k_30",
|
||||
"name": "4K 超清",
|
||||
"resolution": "2160x3840",
|
||||
"fps": 30,
|
||||
"video_bitrate": 20000,
|
||||
"audio_bitrate": 192,
|
||||
"format": "mp4",
|
||||
"quality_preset": "best",
|
||||
"description": "4K超清画质,专业品质",
|
||||
"size_hint": "约 30MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_30_mov",
|
||||
"name": "1080P ProRes",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 15000,
|
||||
"audio_bitrate": 256,
|
||||
"format": "mov",
|
||||
"quality_preset": "high",
|
||||
"description": "MOV格式,适合后期剪辑",
|
||||
"size_hint": "约 25MB/分钟",
|
||||
},
|
||||
]
|
||||
|
||||
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExportConfigResponse(BaseModel):
|
||||
"""导出配置响应"""
|
||||
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
watermark_enabled: bool
|
||||
watermark_text: str
|
||||
|
||||
|
||||
class ExportUpdateRequest(BaseModel):
|
||||
"""更新导出配置请求"""
|
||||
|
||||
resolution: Optional[str] = None
|
||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
||||
format: Optional[str] = None
|
||||
quality_preset: Optional[str] = None
|
||||
watermark_enabled: Optional[bool] = None
|
||||
watermark_text: Optional[str] = None
|
||||
|
||||
@validator("resolution")
|
||||
def validate_resolution(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not RESOLUTION_PATTERN.match(v):
|
||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
||||
w, h = v.split("x")
|
||||
if int(w) < 100 or int(h) < 100:
|
||||
raise ValueError("分辨率数值过小")
|
||||
if int(w) > 4096 or int(h) > 4096:
|
||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
||||
return v
|
||||
|
||||
@validator("format")
|
||||
def validate_format(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_FORMATS:
|
||||
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
|
||||
return v
|
||||
|
||||
@validator("quality_preset")
|
||||
def validate_quality_preset(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_QUALITY_PRESETS:
|
||||
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
|
||||
return v
|
||||
|
||||
|
||||
class ExportPresetItem(BaseModel):
|
||||
"""导出预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
description: str
|
||||
size_hint: str
|
||||
|
||||
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_export_config(plan_config: dict) -> dict:
|
||||
e = plan_config.get("export", {})
|
||||
if not isinstance(e, dict):
|
||||
e = {}
|
||||
return {
|
||||
"resolution": e.get("resolution", "1080x1920"),
|
||||
"fps": e.get("fps", 30),
|
||||
"video_bitrate": e.get("video_bitrate", 8000),
|
||||
"audio_bitrate": e.get("audio_bitrate", 128),
|
||||
"format": e.get("format", "mp4"),
|
||||
"quality_preset": e.get("quality_preset", "balanced"),
|
||||
"watermark_enabled": e.get("watermark_enabled", False),
|
||||
"watermark_text": e.get("watermark_text", ""),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/export-presets", response_model=ExportPresetListResponse)
|
||||
def list_export_presets(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExportPresetListResponse:
|
||||
"""获取导出预设列表"""
|
||||
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
|
||||
return ExportPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
|
||||
def get_export_config(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""获取导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_export_config(plan.config or {})
|
||||
return ExportConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
|
||||
def update_export_config(
|
||||
plan_id: str,
|
||||
body: ExportUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""更新导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current = _get_export_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_export = {**current, **updates}
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["export"] = new_export
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
|
||||
|
||||
result = _get_export_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
|
||||
plan_id,
|
||||
result["resolution"],
|
||||
result["fps"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return ExportConfigResponse(**result)
|
||||
Executable
+198
@@ -0,0 +1,198 @@
|
||||
"""滤镜调色 API.
|
||||
|
||||
- GET /filter-presets 滤镜预设列表
|
||||
- GET /{plan_id}/filter 获取全局滤镜配置
|
||||
- PUT /{plan_id}/filter 更新全局滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.filter_presets import (
|
||||
FilterPreset,
|
||||
build_ffmpeg_filter,
|
||||
get_filter_preset,
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FilterPresetResponse(BaseModel):
|
||||
"""滤镜预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
"""滤镜配置响应"""
|
||||
|
||||
enabled: bool
|
||||
preset_id: str
|
||||
intensity: int
|
||||
brightness: float
|
||||
contrast: float
|
||||
saturation: float
|
||||
warmth: float
|
||||
|
||||
|
||||
class FilterUpdateRequest(BaseModel):
|
||||
"""更新滤镜配置请求"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
preset_id: Optional[str] = None
|
||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
|
||||
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
|
||||
return FilterPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
)
|
||||
|
||||
|
||||
def _get_filter_config(plan_config: dict) -> dict:
|
||||
"""从 plan.config 中提取滤镜配置"""
|
||||
f = plan_config.get("filter", {})
|
||||
if not isinstance(f, dict):
|
||||
f = {}
|
||||
return {
|
||||
"enabled": f.get("enabled", False),
|
||||
"preset_id": f.get("preset_id", "filter_none"),
|
||||
"intensity": f.get("intensity", 100),
|
||||
"brightness": f.get("brightness", 0.0),
|
||||
"contrast": f.get("contrast", 1.0),
|
||||
"saturation": f.get("saturation", 1.0),
|
||||
"warmth": f.get("warmth", 0.0),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/filter-presets", response_model=FilterPresetListResponse)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> FilterPresetListResponse:
|
||||
"""获取滤镜预设列表"""
|
||||
presets = list_filter_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return FilterPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
|
||||
def get_filter(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""获取剪辑计划的全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_filter_config(plan.config or {})
|
||||
return FilterConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
|
||||
def update_filter(
|
||||
plan_id: str,
|
||||
body: FilterUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""更新全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证 preset_id
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
if "preset_id" in updates:
|
||||
preset = get_filter_preset(updates["preset_id"])
|
||||
if preset is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的滤镜预设: {updates['preset_id']}",
|
||||
)
|
||||
|
||||
# 合并更新
|
||||
current = _get_filter_config(plan.config or {})
|
||||
new_filter = {**current, **updates}
|
||||
|
||||
# 如果设为原图 preset,自动关闭
|
||||
if new_filter["preset_id"] == "filter_none":
|
||||
new_filter["enabled"] = False
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["filter"] = new_filter
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
|
||||
|
||||
result = _get_filter_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
|
||||
plan_id,
|
||||
result["preset_id"],
|
||||
result["intensity"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return FilterConfigResponse(**result)
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
"""转场特效 API.
|
||||
|
||||
- GET /transition-presets 转场预设列表
|
||||
- PUT /clips/{clip_id}/transition 设置单个片段转场
|
||||
- POST /{plan_id}/transitions/batch 批量设置转场(所有片段)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.transition_presets import (
|
||||
TransitionPreset,
|
||||
get_transition_preset,
|
||||
list_transition_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionPresetResponse(BaseModel):
|
||||
"""转场预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
default_duration: float
|
||||
min_duration: float
|
||||
max_duration: float
|
||||
|
||||
|
||||
class TransitionUpdateRequest(BaseModel):
|
||||
"""更新转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
|
||||
|
||||
class BatchTransitionRequest(BaseModel):
|
||||
"""批量设置转场请求"""
|
||||
|
||||
effect: str = Field(..., description="转场效果 ID")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长(秒)")
|
||||
apply_to: str = Field(
|
||||
default="all",
|
||||
description="应用范围: all=所有片段, except_first=除第一个外, except_last=除最后一个, middle=中间片段",
|
||||
)
|
||||
|
||||
|
||||
class ClipTransitionResponse(BaseModel):
|
||||
"""片段转场信息响应"""
|
||||
|
||||
clip_id: str
|
||||
effect: str
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchTransitionResponse(BaseModel):
|
||||
"""批量转场响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
class TransitionPresetListResponse(BaseModel):
|
||||
"""转场预设列表响应"""
|
||||
|
||||
items: List[TransitionPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: TransitionPreset) -> TransitionPresetResponse:
|
||||
return TransitionPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
default_duration=p.default_duration,
|
||||
min_duration=p.min_duration,
|
||||
max_duration=p.max_duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_transition(effect: str, duration: Optional[float] = None) -> tuple[str, float]:
|
||||
"""验证转场效果和时长,返回 (effect, duration)"""
|
||||
preset = get_transition_preset(effect)
|
||||
if preset is None:
|
||||
raise ValueError(f"无效的转场效果: {effect}")
|
||||
|
||||
# 硬切特殊处理,时长强制为0
|
||||
if effect == "transition_none" or preset.transition == "none":
|
||||
return "cut", 0.0
|
||||
|
||||
final_duration = duration if duration is not None else preset.default_duration
|
||||
if final_duration < preset.min_duration:
|
||||
final_duration = preset.min_duration
|
||||
if final_duration > preset.max_duration:
|
||||
final_duration = preset.max_duration
|
||||
|
||||
return preset.transition, round(final_duration, 3)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/transition-presets", response_model=TransitionPresetListResponse)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> TransitionPresetListResponse:
|
||||
"""获取转场预设列表"""
|
||||
presets = list_transition_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return TransitionPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/transition", response_model=ClipTransitionResponse)
|
||||
def update_clip_transition(
|
||||
clip_id: str,
|
||||
body: TransitionUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipTransitionResponse:
|
||||
"""设置单个片段的转场效果"""
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证转场参数
|
||||
try:
|
||||
effect, duration = _validate_transition(body.effect, body.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新片段
|
||||
updated_clip = svc.update_clip(
|
||||
clip_id,
|
||||
transition_effect=effect,
|
||||
transition_duration=duration,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"更新片段转场: clip_id=%s effect=%s duration=%.3f by user=%s",
|
||||
clip_id,
|
||||
effect,
|
||||
duration,
|
||||
current_user.user.id,
|
||||
)
|
||||
return ClipTransitionResponse(
|
||||
clip_id=clip_id,
|
||||
effect=updated_clip.transition_effect,
|
||||
duration=updated_clip.transition_duration,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/transitions/batch", response_model=BatchTransitionResponse)
|
||||
def batch_update_transitions(
|
||||
plan_id: str,
|
||||
body: BatchTransitionRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchTransitionResponse:
|
||||
"""批量设置计划内所有片段的转场效果
|
||||
|
||||
apply_to 说明:
|
||||
- all: 所有片段
|
||||
- except_first: 除第一个片段外(第一个片段不需要前转场)
|
||||
- except_last: 除最后一个片段外
|
||||
- middle: 只设置中间片段(除首尾)
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证转场参数
|
||||
try:
|
||||
effect, duration = _validate_transition(body.effect, body.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 获取所有片段
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
if not clips:
|
||||
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
|
||||
|
||||
# 确定应用范围
|
||||
total = len(clips)
|
||||
if total <= 1:
|
||||
# 只有一个片段时,只有 all 模式才应用
|
||||
if body.apply_to != "all":
|
||||
return BatchTransitionResponse(updated_count=0, plan_id=plan_id)
|
||||
|
||||
# 按 order 排序
|
||||
clips_sorted = sorted(clips, key=lambda c: c.order)
|
||||
indices_to_update = []
|
||||
|
||||
if body.apply_to == "all":
|
||||
indices_to_update = list(range(total))
|
||||
elif body.apply_to == "except_first":
|
||||
indices_to_update = list(range(1, total))
|
||||
elif body.apply_to == "except_last":
|
||||
indices_to_update = list(range(total - 1))
|
||||
elif body.apply_to == "middle":
|
||||
if total <= 2:
|
||||
indices_to_update = []
|
||||
else:
|
||||
indices_to_update = list(range(1, total - 1))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的 apply_to: {body.apply_to}",
|
||||
)
|
||||
|
||||
# 批量更新
|
||||
count = 0
|
||||
for idx in indices_to_update:
|
||||
clip = clips_sorted[idx]
|
||||
svc.update_clip(
|
||||
clip.id,
|
||||
transition_effect=effect,
|
||||
transition_duration=duration,
|
||||
)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量更新转场: plan_id=%s count=%d effect=%s apply_to=%s by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
effect,
|
||||
body.apply_to,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchTransitionResponse(updated_count=count, plan_id=plan_id)
|
||||
Regular → Executable
+50
@@ -427,3 +427,53 @@ def retry_generation_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
|
||||
def cancel_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
"""取消生成任务。
|
||||
|
||||
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
|
||||
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
|
||||
"""
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
|
||||
# 权限校验
|
||||
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
|
||||
# 终态不可取消
|
||||
if status_val in ("completed", "failed", "cancelled"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot cancel task in {status_val} status",
|
||||
)
|
||||
|
||||
# 执行取消
|
||||
try:
|
||||
task.mark_cancelled()
|
||||
task.append_log(
|
||||
stage="cancelled",
|
||||
message="用户主动取消任务",
|
||||
level="INFO",
|
||||
cancelled_by=authenticated_user.user.id,
|
||||
)
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
status_val,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
@@ -88,4 +88,4 @@ def delete_project(
|
||||
) from _e
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||
return
|
||||
return # type: ignore[return-value]
|
||||
|
||||
Regular → Executable
+6
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
@@ -20,6 +21,8 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -254,7 +257,9 @@ async def payment_callback(
|
||||
return {"success": True, "message": "支付成功", "record_id": record_id}
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e
|
||||
logger.error(f"支付回调处理失败: user_id={user_id}, plan={plan}, error={e}")
|
||||
# 不返回原始异常信息,避免泄漏内部实现细节
|
||||
raise HTTPException(status_code=500, detail="支付处理失败,请稍后重试") from e
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@@ -368,9 +368,9 @@ def retry_project_task(
|
||||
raise HTTPException(status_code=404, detail="Ingest job not found")
|
||||
if _status_value(job.status) != "failed":
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment]
|
||||
retried = use_case.execute(
|
||||
SubmitIngestJobCommand(
|
||||
SubmitIngestJobCommand( # type: ignore[arg-type]
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
@@ -386,6 +386,6 @@ def retry_project_task(
|
||||
current_step=_ingest_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.updated_at,
|
||||
updated_at=retried.updated_at, # type: ignore[attr-defined]
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Unsupported task type")
|
||||
|
||||
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
|
||||
global_pending_limit,
|
||||
user_id or "unknown",
|
||||
)
|
||||
exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
|
||||
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
|
||||
raise exc
|
||||
|
||||
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
|
||||
if global_over or user_over:
|
||||
if global_over:
|
||||
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
|
||||
exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
|
||||
else:
|
||||
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
|
||||
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
|
||||
|
||||
@@ -132,7 +132,7 @@ def get_tag_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
"""Provide the SQLAlchemy tag repository implementation."""
|
||||
return SQLAlchemyTagRepository(session)
|
||||
return SQLAlchemyTagRepository(session) # type: ignore[return-value]
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
|
||||
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.paths = set(paths) if paths else None
|
||||
self.requests = {} # {ip: [timestamps]}
|
||||
self.requests: dict[str, list[float]] = {}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# 如果配置了路径过滤,只对指定路径限流
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -155,7 +156,7 @@ class AutoClipService:
|
||||
self,
|
||||
clip: EditPlanClip,
|
||||
project_id: str,
|
||||
config_map: dict[str, object],
|
||||
config_map: Mapping[str, object],
|
||||
) -> ClipAssignDetail:
|
||||
"""为单个片段分配素材。"""
|
||||
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""封面管理服务.
|
||||
|
||||
提供封面配置管理和从视频抽帧生成封面的能力。
|
||||
抽帧使用 FFmpeg,上传使用共享存储服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
|
||||
COVER_STORAGE_PREFIX = "covers"
|
||||
|
||||
|
||||
class CoverService:
|
||||
"""封面管理服务."""
|
||||
|
||||
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
|
||||
self._storage = storage_service
|
||||
self._asset_repo = asset_repository
|
||||
|
||||
# ── 配置读写 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""从 plan.config 中提取封面配置.
|
||||
|
||||
Args:
|
||||
plan_config: 剪辑计划的 config 字段
|
||||
|
||||
Returns:
|
||||
封面配置 dict
|
||||
"""
|
||||
cover = plan_config.get("cover", {})
|
||||
if not isinstance(cover, dict):
|
||||
cover = {}
|
||||
# 确保默认字段存在
|
||||
return {
|
||||
"type": cover.get("type", "ai_frame"),
|
||||
"image_url": cover.get("image_url", ""),
|
||||
"frame_time": cover.get("frame_time"),
|
||||
}
|
||||
|
||||
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
|
||||
|
||||
def extract_cover_from_clip(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
frame_time: float = 1.0,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""从指定素材的指定时间点抽取一帧作为封面.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
||||
asset_id: 素材 ID
|
||||
frame_time: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
|
||||
Raises:
|
||||
ValueError: 素材不存在或不是视频
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
# 1. 获取素材
|
||||
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
|
||||
if not asset:
|
||||
raise ValueError(f"素材不存在: {asset_id}")
|
||||
|
||||
storage_key = getattr(asset, "storage_key", "")
|
||||
if not storage_key:
|
||||
raise ValueError(f"素材没有文件: {asset_id}")
|
||||
|
||||
mime_type = getattr(asset, "mime_type", "")
|
||||
if mime_type and not mime_type.startswith("video"):
|
||||
raise ValueError(f"素材不是视频类型: {mime_type}")
|
||||
|
||||
# 2. 下载视频到临时目录
|
||||
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
video_path = tmp_path / f"source_{asset_id[:8]}"
|
||||
|
||||
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
|
||||
try:
|
||||
self._storage.download_file(storage_key, str(video_path))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"下载素材失败: {e}") from e
|
||||
|
||||
if not video_path.exists() or video_path.stat().st_size == 0:
|
||||
raise RuntimeError("下载的素材文件为空")
|
||||
|
||||
# 3. FFmpeg 抽帧
|
||||
output_path = tmp_path / "cover.jpg"
|
||||
self._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError("封面抽帧失败")
|
||||
|
||||
# 4. 上传到 OSS
|
||||
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
|
||||
logger.info("上传封面到存储: key=%s", cover_key)
|
||||
|
||||
try:
|
||||
self._storage.upload_file(
|
||||
file_or_path=str(output_path),
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传封面失败: {e}") from e
|
||||
|
||||
# 5. 获取访问 URL
|
||||
try:
|
||||
image_url = self._storage.get_url(cover_key)
|
||||
except Exception:
|
||||
image_url = cover_key # 降级为 storage_key
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
|
||||
plan_id,
|
||||
asset_id,
|
||||
frame_time,
|
||||
output_path.stat().st_size if output_path.exists() else 0,
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "manual",
|
||||
"image_url": image_url,
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
def generate_smart_cover(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_id: 素材 ID
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict
|
||||
"""
|
||||
# 简单实现:取视频 1/3 处的帧作为智能封面
|
||||
# 更复杂的多帧选清晰帧可以后续优化
|
||||
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
|
||||
|
||||
result = self.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
frame_time=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
result["type"] = "ai_frame"
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_frame(
|
||||
video_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
time_sec: float,
|
||||
width: int,
|
||||
height: int,
|
||||
quality: int,
|
||||
) -> None:
|
||||
"""使用 FFmpeg 从视频中抽取一帧.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
|
||||
# 尝试不使用 scale+crop 的简化命令
|
||||
simple_command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
result2 = subprocess.run(
|
||||
simple_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result2.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError("FFmpeg 抽帧超时") from e
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("FFmpeg 不可用") from e
|
||||
Executable → Regular
+582
-1
@@ -141,6 +141,19 @@ class EditPlanService:
|
||||
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
|
||||
return created
|
||||
|
||||
def _auto_resume_editing(self, plan_id: str) -> None:
|
||||
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
|
||||
plan = self._plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
return
|
||||
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||||
try:
|
||||
plan.resume_editing()
|
||||
self._plan_repo.update(plan)
|
||||
logger.info("自动重新编辑: plan_id=%s", plan_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def update_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
@@ -156,6 +169,10 @@ class EditPlanService:
|
||||
"""
|
||||
existing = self.get_plan_or_raise(plan_id)
|
||||
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
existing = self.get_plan_or_raise(plan_id)
|
||||
|
||||
updated = EditPlan(
|
||||
id=existing.id,
|
||||
template_id=existing.template_id,
|
||||
@@ -212,8 +229,24 @@ class EditPlanService:
|
||||
return plan
|
||||
|
||||
# 根据目标状态调用对应的状态机方法
|
||||
# EDITING 支持从 draft / completed / failed 进入
|
||||
if target_status == EditPlanStatus.EDITING:
|
||||
if plan.status == EditPlanStatus.DRAFT:
|
||||
plan.start_editing()
|
||||
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||||
plan.resume_editing()
|
||||
else:
|
||||
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
|
||||
result = self._plan_repo.update(plan)
|
||||
logger.info(
|
||||
"状态流转: plan_id=%s %s → %s",
|
||||
plan_id,
|
||||
plan.status,
|
||||
target_status,
|
||||
)
|
||||
return result
|
||||
|
||||
transition_map = {
|
||||
EditPlanStatus.EDITING: plan.start_editing,
|
||||
EditPlanStatus.RENDERING: plan.start_rendering,
|
||||
EditPlanStatus.COMPLETED: plan.mark_completed,
|
||||
EditPlanStatus.FAILED: plan.mark_failed,
|
||||
@@ -292,6 +325,8 @@ class EditPlanService:
|
||||
"""
|
||||
# 确保计划存在
|
||||
self.get_plan_or_raise(plan_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
@@ -339,6 +374,9 @@ class EditPlanService:
|
||||
"""
|
||||
existing = self.get_clip_or_raise(clip_id)
|
||||
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(existing.plan_id)
|
||||
|
||||
# 速度边界钳制
|
||||
if playback_speed is not None:
|
||||
if playback_speed <= 0:
|
||||
@@ -381,6 +419,8 @@ class EditPlanService:
|
||||
ValueError: 片段不存在或 asset_id 为空
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
clip.assign_asset(asset_id)
|
||||
result = self._clip_repo.update(clip)
|
||||
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
|
||||
@@ -407,7 +447,463 @@ class EditPlanService:
|
||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def create_clips_from_assets(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_ids: list[str],
|
||||
*,
|
||||
clip_type: str = "main",
|
||||
) -> list[EditPlanClip]:
|
||||
"""从素材批量创建片段(追加到时间线末尾)。
|
||||
|
||||
Args:
|
||||
plan_id: 计划 ID
|
||||
asset_ids: 素材 ID 列表(按顺序追加)
|
||||
clip_type: 片段类型
|
||||
|
||||
Returns:
|
||||
list[EditPlanClip]: 创建的片段列表
|
||||
"""
|
||||
if not asset_ids:
|
||||
return []
|
||||
|
||||
# 确保计划存在 + 自动回退状态
|
||||
self.get_plan_or_raise(plan_id)
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
# 查询素材信息(取 duration)
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = self._clip_repo.session # type: ignore[attr-defined]
|
||||
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
asset_map = {a.id: a for a in assets}
|
||||
|
||||
# 从现有片段数量开始追加
|
||||
existing_count = self._clip_repo.count(plan_id=plan_id)
|
||||
|
||||
# 批量创建片段
|
||||
created: list[EditPlanClip] = []
|
||||
for i, asset_id in enumerate(asset_ids):
|
||||
asset = asset_map.get(asset_id)
|
||||
duration = asset.duration if asset and asset.duration else 0.0
|
||||
|
||||
clip = self.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=existing_count + i,
|
||||
asset_id=asset_id,
|
||||
duration=duration,
|
||||
)
|
||||
created.append(clip)
|
||||
|
||||
logger.info(
|
||||
"从素材批量创建片段: plan_id=%s count=%d",
|
||||
plan_id,
|
||||
len(created),
|
||||
)
|
||||
return created
|
||||
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
"""将一个片段从指定位置分割为两个片段
|
||||
|
||||
Args:
|
||||
clip_id: 要分割的片段 ID
|
||||
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
|
||||
|
||||
Returns:
|
||||
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
|
||||
|
||||
Raises:
|
||||
ValueError: 片段不存在、分割时间越界
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
plan_id = clip.plan_id
|
||||
|
||||
if split_time <= 0 or split_time >= clip.duration:
|
||||
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
|
||||
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
original_duration = clip.duration
|
||||
left_duration = round(split_time, 3)
|
||||
right_duration = round(original_duration - split_time, 3)
|
||||
original_order = clip.order
|
||||
|
||||
# 更新左半部分(原片段)
|
||||
clip.duration = left_duration
|
||||
left_clip = self._clip_repo.update(clip)
|
||||
|
||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
for c in all_clips:
|
||||
if c.order > original_order and c.id != clip_id:
|
||||
c.order += 1
|
||||
self._clip_repo.update(c)
|
||||
|
||||
# 创建右半部分新片段(继承原片段的大部分属性)
|
||||
right_config = dict(clip.config) if clip.config else {}
|
||||
# 素材裁剪信息
|
||||
if clip.asset_id:
|
||||
# 右半部分从 split_time 开始播放
|
||||
right_config["trim_start"] = left_duration
|
||||
# 左半部分在 split_time 处结束
|
||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
||||
left_config["trim_end"] = right_duration
|
||||
left_clip.config = left_config
|
||||
left_clip = self._clip_repo.update(left_clip)
|
||||
|
||||
right_clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=original_order + 1,
|
||||
template_clip_config_id=clip.template_clip_config_id,
|
||||
asset_id=clip.asset_id,
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time + left_duration,
|
||||
duration=right_duration,
|
||||
transition_effect=clip.transition_effect,
|
||||
transition_duration=clip.transition_duration,
|
||||
playback_speed=clip.playback_speed,
|
||||
config=right_config,
|
||||
)
|
||||
created_right = self._clip_repo.create(right_clip)
|
||||
|
||||
logger.info(
|
||||
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
|
||||
clip_id,
|
||||
plan_id,
|
||||
split_time,
|
||||
left_duration,
|
||||
right_duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"left_clip": left_clip,
|
||||
"right_clip": created_right,
|
||||
}
|
||||
|
||||
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
|
||||
"""合并多个连续片段为一个片段
|
||||
|
||||
Args:
|
||||
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
|
||||
|
||||
Returns:
|
||||
EditPlanClip: 合并后的新片段
|
||||
|
||||
Raises:
|
||||
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
|
||||
"""
|
||||
if len(clip_ids) < 2:
|
||||
raise ValueError("至少需要 2 个片段才能合并")
|
||||
|
||||
# 读取所有片段
|
||||
clips = []
|
||||
for cid in clip_ids:
|
||||
clip = self.get_clip_or_raise(cid)
|
||||
clips.append(clip)
|
||||
|
||||
# 校验:同一计划
|
||||
plan_id = clips[0].plan_id
|
||||
for c in clips[1:]:
|
||||
if c.plan_id != plan_id:
|
||||
raise ValueError("只能合并同一计划下的片段")
|
||||
|
||||
# 按 order 排序
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
# 校验:order 连续
|
||||
for i in range(1, len(clips)):
|
||||
if clips[i].order != clips[i - 1].order + 1:
|
||||
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
|
||||
|
||||
# 校验:类型一致
|
||||
clip_type = clips[0].clip_type
|
||||
for c in clips[1:]:
|
||||
if c.clip_type != clip_type:
|
||||
raise ValueError("只能合并相同类型的片段")
|
||||
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
# 计算合并后的属性
|
||||
first_clip = clips[0]
|
||||
total_duration = round(sum(c.duration for c in clips), 3)
|
||||
first_order = first_clip.order
|
||||
|
||||
# 合并文案(用换行连接)
|
||||
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
|
||||
|
||||
# 合并 config(后面的覆盖前面的)
|
||||
merged_config: Dict[str, Any] = {}
|
||||
for c in clips:
|
||||
if c.config:
|
||||
merged_config.update(c.config)
|
||||
# 清理 trim 相关字段(合并后就是完整片段了)
|
||||
merged_config.pop("trim_start", None)
|
||||
merged_config.pop("trim_end", None)
|
||||
|
||||
# 更新第一个片段(保留它作为合并结果)
|
||||
first_clip.duration = total_duration
|
||||
first_clip.text_content = merged_text
|
||||
first_clip.config = merged_config
|
||||
# 转场保留第一个的(合并后的入点转场)
|
||||
# playback_speed 取第一个的
|
||||
merged_clip = self._clip_repo.update(first_clip)
|
||||
|
||||
# 删除其余片段
|
||||
for c in clips[1:]:
|
||||
self._clip_repo.delete(c.id)
|
||||
|
||||
# 后面的片段 order 前移 (len - 1) 位
|
||||
shift = len(clips) - 1
|
||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
for c in all_clips:
|
||||
if c.order > first_order and c.id != merged_clip.id:
|
||||
c.order -= shift
|
||||
self._clip_repo.update(c)
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||||
plan_id,
|
||||
len(clips),
|
||||
total_duration,
|
||||
)
|
||||
|
||||
return merged_clip
|
||||
|
||||
# ── 字幕管理 ──────────────────────────────────────────────────────────
|
||||
|
||||
def list_subtitles(self, clip_id: str) -> List[Dict[str, Any]]:
|
||||
"""获取片段的所有字幕
|
||||
|
||||
Returns:
|
||||
List[dict]: 字幕列表,按 start 时间排序
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = clip.config or {}
|
||||
subtitles = config.get("subtitles", [])
|
||||
# 按开始时间排序
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
return subtitles
|
||||
|
||||
def get_subtitle(self, clip_id: str, subtitle_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取单条字幕"""
|
||||
subtitles = self.list_subtitles(clip_id)
|
||||
for s in subtitles:
|
||||
if s.get("id") == subtitle_id:
|
||||
return s
|
||||
return None
|
||||
|
||||
def add_subtitle(
|
||||
self,
|
||||
clip_id: str,
|
||||
start: float,
|
||||
end: float,
|
||||
text: str,
|
||||
*,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""添加一条字幕
|
||||
|
||||
Args:
|
||||
clip_id: 片段 ID
|
||||
start: 开始时间(秒,相对于片段)
|
||||
end: 结束时间(秒)
|
||||
text: 字幕文本
|
||||
style: 样式配置(字体、大小、颜色、位置等)
|
||||
|
||||
Returns:
|
||||
dict: 新增的字幕条目
|
||||
|
||||
Raises:
|
||||
ValueError: 时间非法或文本为空
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||||
if not text.strip():
|
||||
raise ValueError("字幕文本不能为空")
|
||||
if end > clip.duration + 0.001:
|
||||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end:.3f}, duration={clip.duration:.3f}")
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
subtitle = {
|
||||
"id": uuid4().hex,
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text.strip(),
|
||||
"style": style or {},
|
||||
}
|
||||
subtitles.append(subtitle)
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
|
||||
config["subtitles"] = subtitles
|
||||
clip.config = config
|
||||
updated = self._clip_repo.update(clip)
|
||||
|
||||
logger.info(
|
||||
"添加字幕: clip_id=%s subtitle_id=%s start=%.3fs end=%.3fs",
|
||||
clip_id,
|
||||
subtitle["id"],
|
||||
start,
|
||||
end,
|
||||
)
|
||||
|
||||
return subtitle
|
||||
|
||||
def update_subtitle(
|
||||
self,
|
||||
clip_id: str,
|
||||
subtitle_id: str,
|
||||
*,
|
||||
start: Optional[float] = None,
|
||||
end: Optional[float] = None,
|
||||
text: Optional[str] = None,
|
||||
style: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新一条字幕
|
||||
|
||||
Returns:
|
||||
dict: 更新后的字幕条目
|
||||
|
||||
Raises:
|
||||
ValueError: 字幕不存在或参数非法
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
found = False
|
||||
for i, s in enumerate(subtitles):
|
||||
if s.get("id") == subtitle_id:
|
||||
# 更新字段
|
||||
updated_s = dict(s)
|
||||
if start is not None:
|
||||
updated_s["start"] = round(start, 3)
|
||||
if end is not None:
|
||||
updated_s["end"] = round(end, 3)
|
||||
if text is not None:
|
||||
if not text.strip():
|
||||
raise ValueError("字幕文本不能为空")
|
||||
updated_s["text"] = text.strip()
|
||||
if style is not None:
|
||||
updated_s["style"] = style
|
||||
|
||||
# 校验时间
|
||||
if updated_s["start"] < 0 or updated_s["end"] <= updated_s["start"]:
|
||||
raise ValueError(f"字幕时间非法: start={updated_s['start']}, end={updated_s['end']}")
|
||||
if updated_s["end"] > clip.duration + 0.001:
|
||||
raise ValueError("字幕结束时间不能超过片段时长")
|
||||
|
||||
subtitles[i] = updated_s
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise ValueError(f"字幕不存在: {subtitle_id}")
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
subtitles.sort(key=lambda s: s.get("start", 0))
|
||||
config["subtitles"] = subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info("更新字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||||
|
||||
return subtitles[next(i for i, s in enumerate(subtitles) if s["id"] == subtitle_id)]
|
||||
|
||||
def delete_subtitle(self, clip_id: str, subtitle_id: str) -> bool:
|
||||
"""删除一条字幕
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
subtitles = list(config.get("subtitles", []))
|
||||
|
||||
new_subtitles = [s for s in subtitles if s.get("id") != subtitle_id]
|
||||
if len(new_subtitles) == len(subtitles):
|
||||
return False
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
config["subtitles"] = new_subtitles
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info("删除字幕: clip_id=%s subtitle_id=%s", clip_id, subtitle_id)
|
||||
return True
|
||||
|
||||
def batch_update_subtitles(
|
||||
self,
|
||||
clip_id: str,
|
||||
subtitles: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""批量更新字幕(全量替换,用于批量编辑或导入)
|
||||
|
||||
Args:
|
||||
clip_id: 片段 ID
|
||||
subtitles: 字幕列表,每条需包含 start/end/text,已有 id 则保留
|
||||
|
||||
Returns:
|
||||
List[dict]: 更新后的字幕列表
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
validated = []
|
||||
for s in subtitles:
|
||||
start = float(s.get("start", 0))
|
||||
end = float(s.get("end", 0))
|
||||
text = str(s.get("text", ""))
|
||||
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError(f"字幕时间非法: start={start}, end={end}")
|
||||
if not text.strip():
|
||||
continue # 跳过空字幕
|
||||
if end > clip.duration + 0.001:
|
||||
raise ValueError(f"字幕结束时间不能超过片段时长: end={end}")
|
||||
|
||||
subtitle_id = s.get("id") or uuid4().hex
|
||||
validated.append(
|
||||
{
|
||||
"id": subtitle_id,
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text.strip(),
|
||||
"style": s.get("style", {}),
|
||||
}
|
||||
)
|
||||
|
||||
validated.sort(key=lambda s: s["start"])
|
||||
|
||||
self._auto_resume_editing(clip.plan_id)
|
||||
|
||||
config = dict(clip.config) if clip.config else {}
|
||||
config["subtitles"] = validated
|
||||
clip.config = config
|
||||
self._clip_repo.update(clip)
|
||||
|
||||
logger.info(
|
||||
"批量更新字幕: clip_id=%s count=%d",
|
||||
clip_id,
|
||||
len(validated),
|
||||
)
|
||||
|
||||
return validated
|
||||
|
||||
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取计划及其所有片段
|
||||
@@ -511,6 +1007,9 @@ class EditPlanService:
|
||||
更新后的计划
|
||||
"""
|
||||
plan = self.get_plan_or_raise(plan_id)
|
||||
# 自动从 completed/failed 切回 editing
|
||||
self._auto_resume_editing(plan_id)
|
||||
plan = self.get_plan_or_raise(plan_id)
|
||||
new_config = {**plan.config, **config_updates}
|
||||
|
||||
updated = EditPlan(
|
||||
@@ -527,3 +1026,85 @@ class EditPlanService:
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
return self._plan_repo.update(updated)
|
||||
|
||||
# ── 复制计划 ────────────────────────────────────────────────────────────
|
||||
|
||||
def copy_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
new_name: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> EditPlan:
|
||||
"""复制一个剪辑计划(含所有片段配置)。
|
||||
|
||||
新计划状态为 editing,不含生成任务和结果记录。
|
||||
|
||||
Args:
|
||||
plan_id: 源计划 ID
|
||||
new_name: 新计划名称,不传则为「原名 - 副本」
|
||||
project_id: 新计划的项目 ID,不传则复用源计划
|
||||
|
||||
Returns:
|
||||
EditPlan: 新创建的计划
|
||||
|
||||
Raises:
|
||||
ValueError: 源计划不存在
|
||||
"""
|
||||
source = self.get_plan_or_raise(plan_id)
|
||||
source_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
|
||||
# 新计划名称
|
||||
name = new_name or f"{source.name} - 副本"
|
||||
new_project_id = project_id if project_id is not None else source.project_id
|
||||
|
||||
# 复制 plan 配置(去除渲染结果相关字段)
|
||||
new_config = dict(source.config)
|
||||
new_config.pop("rendered_url", None)
|
||||
new_config.pop("rendered_storage_key", None)
|
||||
new_config.pop("generation_task_id", None)
|
||||
|
||||
# 创建新计划
|
||||
new_plan = EditPlan.create(
|
||||
template_id=source.template_id,
|
||||
name=name,
|
||||
config=new_config,
|
||||
total_duration=source.total_duration,
|
||||
project_id=new_project_id,
|
||||
created_by_user_id=source.created_by_user_id,
|
||||
source_edit_plan_id=plan_id,
|
||||
)
|
||||
# 强制切到 editing 状态
|
||||
if new_plan.status != EditPlanStatus.EDITING:
|
||||
try:
|
||||
new_plan.start_editing()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
created_plan = self._plan_repo.create(new_plan)
|
||||
logger.info(
|
||||
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
|
||||
plan_id,
|
||||
created_plan.id,
|
||||
name,
|
||||
len(source_clips),
|
||||
)
|
||||
|
||||
# 复制所有片段
|
||||
for clip in source_clips:
|
||||
new_clip = self.create_clip(
|
||||
plan_id=created_plan.id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
config=dict(clip.config) if clip.config else None,
|
||||
)
|
||||
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
|
||||
|
||||
return self.get_plan_or_raise(created_plan.id)
|
||||
|
||||
Regular → Executable
+141
@@ -12,9 +12,13 @@ from typing import Any, List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import (
|
||||
SQLAlchemyEditPlanClipRepository,
|
||||
SQLAlchemyEditPlanRepository,
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
from packages.domain.template_clip_config import (
|
||||
ClipType,
|
||||
@@ -37,6 +41,9 @@ class EditTemplateService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._template_repo = SQLAlchemyEditTemplateRepository(db)
|
||||
self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._plan_clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
self._db = db
|
||||
|
||||
# ── 模板 CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -394,3 +401,137 @@ class EditTemplateService:
|
||||
"template": template,
|
||||
"clip_configs": clip_configs,
|
||||
}
|
||||
|
||||
# ── 从剪辑计划保存为模板 ──────────────────────────────────────────────
|
||||
|
||||
def save_plan_as_template(
|
||||
self,
|
||||
plan_id: str,
|
||||
name: str,
|
||||
*,
|
||||
description: str = "",
|
||||
template_type: str = "custom",
|
||||
preview_url: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""将剪辑计划保存为模板
|
||||
|
||||
将指定剪辑计划的配置和片段结构另存为一个新模板,
|
||||
方便后续基于该模板快速创建新的剪辑计划。
|
||||
|
||||
转换规则:
|
||||
- 计划名称 → 模板名称(调用方传入,支持自定义)
|
||||
- 计划 config → 模板 config(整体迁移)
|
||||
- 计划 editing_mode 从 config 中提取,默认 one_take
|
||||
- 每个片段转换为模板片段配置:
|
||||
- clip_type 直接映射
|
||||
- order 保持不变
|
||||
- duration → min_duration = max_duration = duration(固定时长)
|
||||
- text_content → text_template
|
||||
- transition_effect 直接映射
|
||||
- playback_speed 等播放参数存入 config
|
||||
- 不保留 asset_id(模板不绑定具体素材)
|
||||
|
||||
Args:
|
||||
plan_id: 源剪辑计划 ID
|
||||
name: 新模板名称
|
||||
description: 模板描述
|
||||
template_type: 模板类型,默认 custom(用户自定义)
|
||||
preview_url: 预览图 URL
|
||||
|
||||
Returns:
|
||||
dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]}
|
||||
|
||||
Raises:
|
||||
ValueError: 计划不存在或名称为空/重复
|
||||
"""
|
||||
# 1. 读取源计划
|
||||
plan = self._plan_repo.get(plan_id)
|
||||
if plan is None:
|
||||
raise ValueError(f"剪辑计划不存在: {plan_id}")
|
||||
|
||||
# 2. 读取所有片段(按 order 排序)
|
||||
clips = self._plan_clip_repo.list_by_plan(plan_id)
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
# 3. 提取 editing_mode
|
||||
editing_mode = plan.config.get("editing_mode", "one_take") if plan.config else "one_take"
|
||||
|
||||
# 4. 创建模板(复用 create_template 的校验逻辑,但手动构建避免重复查询)
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValueError("模板名称不能为空")
|
||||
|
||||
# 名称重复检查
|
||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||
for t in existing:
|
||||
if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE:
|
||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
||||
|
||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
||||
plan_config = plan.config or {}
|
||||
template_config: dict[str, Any] = {}
|
||||
for key, value in plan_config.items():
|
||||
# 跳过明显的运行时/实例字段,保留风格/模式类配置
|
||||
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
|
||||
template_config[key] = value
|
||||
|
||||
template = EditTemplate.create(
|
||||
name=clean_name,
|
||||
description=description,
|
||||
template_type=template_type,
|
||||
editing_mode=editing_mode,
|
||||
config=template_config,
|
||||
preview_url=preview_url,
|
||||
)
|
||||
created_template = self._template_repo.create(template)
|
||||
logger.info(
|
||||
"从剪辑计划创建模板: plan_id=%s template_id=%s name=%s clip_count=%d",
|
||||
plan_id,
|
||||
created_template.id,
|
||||
clean_name,
|
||||
len(clips),
|
||||
)
|
||||
|
||||
# 5. 转换每个片段为模板片段配置
|
||||
created_configs: List[TemplateClipConfig] = []
|
||||
for clip in clips:
|
||||
clip_config: dict[str, Any] = {}
|
||||
# 播放速度存入 config
|
||||
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||
clip_config["playback_speed"] = clip.playback_speed
|
||||
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
|
||||
if clip.config:
|
||||
clip_config.update(clip.config)
|
||||
# 去掉素材相关字段
|
||||
clip_config.pop("asset_info", None)
|
||||
clip_config.pop("source_asset_id", None)
|
||||
|
||||
# 转场效果兼容校验
|
||||
try:
|
||||
transition = TransitionEffect(clip.transition_effect)
|
||||
except ValueError:
|
||||
transition = TransitionEffect.CUT
|
||||
|
||||
# 片段类型兼容校验
|
||||
try:
|
||||
clip_type = ClipType(clip.clip_type)
|
||||
except ValueError:
|
||||
clip_type = ClipType.MAIN
|
||||
|
||||
clip_config_obj = TemplateClipConfig.create(
|
||||
template_id=created_template.id,
|
||||
clip_type=clip_type,
|
||||
order=clip.order,
|
||||
min_duration=clip.duration,
|
||||
max_duration=clip.duration,
|
||||
text_template=clip.text_content or "",
|
||||
transition_effect=transition,
|
||||
config=clip_config,
|
||||
)
|
||||
created = self._clip_config_repo.create(clip_config_obj)
|
||||
created_configs.append(created)
|
||||
|
||||
return {
|
||||
"template": created_template,
|
||||
"clip_configs": created_configs,
|
||||
}
|
||||
|
||||
Regular → Executable
+221
-19
@@ -20,7 +20,7 @@ import type {
|
||||
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled";
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
@@ -276,24 +276,6 @@ export interface CoverResult {
|
||||
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
|
||||
* ============================================================ */
|
||||
|
||||
/** 剪辑计划中的片段(UI 层类型) */
|
||||
export interface EditPlanClip {
|
||||
id: string;
|
||||
template_segment_id: string;
|
||||
/** 素材库中的素材 ID */
|
||||
media_asset_id?: string;
|
||||
/** 素材类型 */
|
||||
material_type: "video" | "image" | "audio" | "voiceover";
|
||||
/** 片段文案 */
|
||||
script_text: string;
|
||||
/** 实际时长(秒) */
|
||||
duration: number;
|
||||
/** 转场效果 */
|
||||
transition?: TransitionEffect;
|
||||
/** 排序 */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 转场效果(14 种预设) */
|
||||
export interface TransitionEffect {
|
||||
type:
|
||||
@@ -453,6 +435,225 @@ export async function getGenerationTaskResults(
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(planId: string): Promise<void> {
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 片段 CRUD(后端 EditPlanClip 独立表)
|
||||
* ============================================================ */
|
||||
|
||||
/** 片段状态 */
|
||||
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed";
|
||||
|
||||
/** 剪辑片段(后端响应) */
|
||||
export interface EditPlanClip {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
clip_type: string; // main / intro / outro / overlay / background / b_roll 等
|
||||
order: number;
|
||||
asset_id: string;
|
||||
text_content: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
transition_effect: string;
|
||||
transition_duration: number;
|
||||
playback_speed: number;
|
||||
status: EditPlanClipStatus;
|
||||
config: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建片段请求 */
|
||||
export interface CreateEditPlanClipRequest {
|
||||
clip_type: string;
|
||||
order: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
export interface UpdateEditPlanClipRequest {
|
||||
clip_type?: string;
|
||||
order?: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
export interface EditPlanClipListResponse {
|
||||
items: EditPlanClip[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 片段列表查询参数 */
|
||||
export interface EditPlanClipListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** 获取片段列表 */
|
||||
export async function getEditPlanClips(
|
||||
planId: string,
|
||||
params?: EditPlanClipListParams,
|
||||
): Promise<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
export async function createEditPlanClip(
|
||||
planId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
export async function updateEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 片段批量操作
|
||||
* ============================================================ */
|
||||
|
||||
/** 重排序条目 */
|
||||
export interface ClipReorderItem {
|
||||
clip_id: string;
|
||||
new_order: number;
|
||||
}
|
||||
|
||||
/** 重排序响应 */
|
||||
export interface ClipReorderResponse {
|
||||
success: boolean;
|
||||
updated_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 批量删除响应 */
|
||||
export interface ClipBatchDeleteResponse {
|
||||
success: boolean;
|
||||
deleted_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 从素材批量创建响应 */
|
||||
export interface ClipsFromAssetsResponse {
|
||||
success: boolean;
|
||||
created_count: number;
|
||||
message: string;
|
||||
clip_ids: string[];
|
||||
}
|
||||
|
||||
/** 片段重排序(拖拽排序后一次性提交) */
|
||||
export async function reorderEditPlanClips(
|
||||
planId: string,
|
||||
items: ClipReorderItem[],
|
||||
): Promise<ClipReorderResponse> {
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/edit-plans/${planId}/clips/reorder`,
|
||||
{ items },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
export async function batchDeleteEditPlanClips(
|
||||
planId: string,
|
||||
clipIds: string[],
|
||||
): Promise<ClipBatchDeleteResponse> {
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/edit-plans/${planId}/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
planId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/edit-plans/${planId}/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 复制计划
|
||||
* ============================================================ */
|
||||
|
||||
/** 复制计划请求 */
|
||||
export interface CopyEditPlanRequest {
|
||||
name?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
/** 复制剪辑计划(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
planId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/edit-plans/${planId}/copy`,
|
||||
data || {},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
@@ -553,6 +754,7 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
rendering: "渲染中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
/** 质量分筛选选项 */
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConfigProvider } from "antd";
|
||||
import { ConfigProvider, App as AntApp } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import router from "./router";
|
||||
import "./index.css";
|
||||
@@ -91,7 +91,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<RouterProvider router={router} />
|
||||
<AntApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
|
||||
Regular → Executable
+96
-2
@@ -24,12 +24,17 @@ import {
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
CopyOutlined,
|
||||
UnorderedListOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
@@ -47,6 +52,7 @@ const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "rendering", label: "渲染中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
@@ -79,6 +85,11 @@ const STATUS_CONFIG: Record<
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
cancelled: {
|
||||
label: "已取消",
|
||||
color: "default",
|
||||
icon: <StopOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
@@ -184,6 +195,33 @@ export default function EditPlans() {
|
||||
},
|
||||
});
|
||||
|
||||
// 取消生成
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelGeneration,
|
||||
onSuccess: () => {
|
||||
message.success("已提交取消请求");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("取消失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 复制计划
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: ({ planId, name }: { planId: string; name?: string }) =>
|
||||
copyEditPlan(planId, name ? { name } : undefined),
|
||||
onSuccess: (newPlan) => {
|
||||
message.success("计划已复制");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
// 自动跳转到新计划的编辑器
|
||||
navigate(`/app/editing-planner?planId=${newPlan.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
@@ -285,10 +323,21 @@ export default function EditPlans() {
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 180,
|
||||
width: 240,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: EditPlan) => (
|
||||
<div className="plan-actions">
|
||||
<Tooltip title="片段管理">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`/app/edit-plans/${record.id}/clips`)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
片段
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -298,7 +347,30 @@ export default function EditPlans() {
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{(record.status === "failed" || record.status === "completed") && (
|
||||
{record.status === "rendering" && (
|
||||
<Popconfirm
|
||||
title="确认取消生成"
|
||||
description="确定要取消当前生成任务吗?此操作不可恢复。"
|
||||
onConfirm={() => cancelMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="再等等"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={cancelMutation.isPending}
|
||||
className="plan-action-btn plan-cancel-btn"
|
||||
>
|
||||
取消生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{(record.status === "failed" ||
|
||||
record.status === "completed" ||
|
||||
record.status === "cancelled") && (
|
||||
<Popconfirm
|
||||
title="确认重新生成"
|
||||
description="确定要重新生成这个剪辑计划吗?"
|
||||
@@ -317,6 +389,28 @@ export default function EditPlans() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="复制计划"
|
||||
description="确定要复制这个剪辑计划吗?将创建一个编辑中的新副本。"
|
||||
onConfirm={() =>
|
||||
copyMutation.mutate({
|
||||
planId: record.id,
|
||||
name: `${record.name} 副本`,
|
||||
})
|
||||
}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
|
||||
+658
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* 剪辑计划片段管理页面
|
||||
* 对接后端 PR#389 片段 CRUD API
|
||||
* 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Drawer,
|
||||
Empty,
|
||||
Card,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
UploadOutlined,
|
||||
OrderedListOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlan,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
reorderEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
getMediaAssets,
|
||||
type EditPlanClip,
|
||||
type EditPlanClipStatus,
|
||||
} from "@/api/editPlans";
|
||||
import "./plan-clips.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const CLIP_TYPE_OPTIONS = [
|
||||
{ value: "main", label: "主片段" },
|
||||
{ value: "intro", label: "片头" },
|
||||
{ value: "outro", label: "片尾" },
|
||||
{ value: "overlay", label: "叠加层" },
|
||||
{ value: "background", label: "背景" },
|
||||
{ value: "b_roll", label: "B-roll" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "default",
|
||||
processing: "processing",
|
||||
ready: "success",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "待处理",
|
||||
processing: "处理中",
|
||||
ready: "就绪",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ value: "cut", label: "硬切" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "dissolve", label: "溶解" },
|
||||
{ value: "zoom", label: "缩放" },
|
||||
{ value: "slide_left", label: "左滑" },
|
||||
{ value: "slide_right", label: "右滑" },
|
||||
{ value: "slide_up", label: "上滑" },
|
||||
{ value: "slide_down", label: "下滑" },
|
||||
{ value: "wipe_left", label: "左擦除" },
|
||||
{ value: "wipe_right", label: "右擦除" },
|
||||
{ value: "wipe_up", label: "上擦除" },
|
||||
{ value: "wipe_down", label: "下擦除" },
|
||||
{ value: "circlecrop", label: "圆形裁切" },
|
||||
{ value: "rectcrop", label: "矩形裁切" },
|
||||
];
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PlanClipsManager: React.FC = () => {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 计划信息 ── */
|
||||
const { data: plan, isLoading: planLoading } = useQuery({
|
||||
queryKey: ["editPlan", planId],
|
||||
queryFn: () => getEditPlan(planId!),
|
||||
enabled: !!planId,
|
||||
});
|
||||
|
||||
/* ── 片段列表 ── */
|
||||
const { data: clipsData, isLoading: clipsLoading } = useQuery({
|
||||
queryKey: ["editPlanClips", planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
});
|
||||
|
||||
const clips = clipsData?.items ?? [];
|
||||
|
||||
/* ── 选中的片段(批量操作) ── */
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
/* ── 编辑弹窗 ── */
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
|
||||
/* ── 素材导入抽屉 ── */
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
const { data: assets } = useQuery({
|
||||
queryKey: ["mediaAssets"],
|
||||
queryFn: () => getMediaAssets(),
|
||||
enabled: importDrawerOpen,
|
||||
});
|
||||
|
||||
/* ── 重新排序模式 ── */
|
||||
const [reorderMode, setReorderMode] = useState(false);
|
||||
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([]);
|
||||
|
||||
/* ── 列定义 ── */
|
||||
const columns: ColumnsType<EditPlanClip> = [
|
||||
{
|
||||
title: "序号",
|
||||
dataIndex: "order",
|
||||
width: 70,
|
||||
render: (_, __, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "clip_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type);
|
||||
return <Tag>{opt?.label || type}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "素材",
|
||||
dataIndex: "asset_id",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (assetId: string) =>
|
||||
assetId ? (
|
||||
<code className="clip-asset-id">{assetId.slice(0, 12)}...</code>
|
||||
) : (
|
||||
<span style={{ color: "#999" }}>无素材</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "文本内容",
|
||||
dataIndex: "text_content",
|
||||
ellipsis: true,
|
||||
render: (text: string) =>
|
||||
text || <span style={{ color: "#999" }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "duration",
|
||||
width: 90,
|
||||
render: (d: number) => `${d?.toFixed(1) || 0}s`,
|
||||
},
|
||||
{
|
||||
title: "转场",
|
||||
dataIndex: "transition_effect",
|
||||
width: 100,
|
||||
render: (effect: string) => {
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect);
|
||||
return opt?.label || effect || "硬切";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "播放速度",
|
||||
dataIndex: "playback_speed",
|
||||
width: 90,
|
||||
render: (s: number) => `${s || 1.0}x`,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (status: EditPlanClipStatus) => (
|
||||
<Tag color={STATUS_COLORS[status] || "default"}>
|
||||
{STATUS_LABELS[status] || status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 140,
|
||||
fixed: "right",
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditClip(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除片段"
|
||||
description="确定删除这个片段吗?"
|
||||
onConfirm={() => handleDeleteClip(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 编辑片段 ── */
|
||||
const handleEditClip = useCallback(
|
||||
(clip: EditPlanClip) => {
|
||||
setEditingClip(clip);
|
||||
editForm.setFieldsValue({
|
||||
clip_type: clip.clip_type,
|
||||
asset_id: clip.asset_id,
|
||||
text_content: clip.text_content,
|
||||
duration: clip.duration,
|
||||
start_time: clip.start_time,
|
||||
transition_effect: clip.transition_effect,
|
||||
transition_duration: clip.transition_duration,
|
||||
playback_speed: clip.playback_speed,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
},
|
||||
[editForm],
|
||||
);
|
||||
|
||||
const handleNewClip = useCallback(() => {
|
||||
setEditingClip(null);
|
||||
editForm.resetFields();
|
||||
editForm.setFieldsValue({
|
||||
clip_type: "main",
|
||||
duration: 5,
|
||||
transition_effect: "cut",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1.0,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
}, [editForm]);
|
||||
|
||||
const handleSaveClip = async () => {
|
||||
if (!planId) return;
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
setEditLoading(true);
|
||||
|
||||
if (editingClip) {
|
||||
// 更新
|
||||
await updateEditPlanClip(planId, editingClip.id, values);
|
||||
message.success("片段已更新");
|
||||
} else {
|
||||
// 新建
|
||||
const maxOrder =
|
||||
clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1;
|
||||
await createEditPlanClip(planId, {
|
||||
...values,
|
||||
order: maxOrder + 1,
|
||||
});
|
||||
message.success("片段已创建");
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setEditModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(editingClip ? "更新失败" : "创建失败");
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const handleDeleteClip = async (clipId: string) => {
|
||||
if (!planId) return;
|
||||
try {
|
||||
await deleteEditPlanClip(planId, clipId);
|
||||
message.success("已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId));
|
||||
} catch {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = async () => {
|
||||
if (!planId || selectedRowKeys.length === 0) return;
|
||||
try {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
selectedRowKeys.map((k) => String(k)),
|
||||
);
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个片段`);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
message.error("批量删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 从素材导入 ── */
|
||||
const handleImportFromAssets = async () => {
|
||||
if (!planId || selectedAssetIds.length === 0) return;
|
||||
try {
|
||||
setImportLoading(true);
|
||||
const res = await createClipsFromAssets(planId, selectedAssetIds);
|
||||
message.success(`已导入 ${res.created_count} 个片段`);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setImportDrawerOpen(false);
|
||||
setSelectedAssetIds([]);
|
||||
} catch {
|
||||
message.error("导入失败");
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 排序模式 ── */
|
||||
const enterReorderMode = () => {
|
||||
setReorderItems([...clips].sort((a, b) => a.order - b.order));
|
||||
setReorderMode(true);
|
||||
};
|
||||
|
||||
const moveClip = (fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= reorderItems.length) return;
|
||||
const newItems = [...reorderItems];
|
||||
const [moved] = newItems.splice(fromIndex, 1);
|
||||
newItems.splice(toIndex, 0, moved);
|
||||
setReorderItems(newItems);
|
||||
};
|
||||
|
||||
const saveReorder = async () => {
|
||||
if (!planId) return;
|
||||
const items = reorderItems.map((clip, index) => ({
|
||||
clip_id: clip.id,
|
||||
new_order: index,
|
||||
}));
|
||||
try {
|
||||
await reorderEditPlanClips(planId, items);
|
||||
message.success("排序已保存");
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setReorderMode(false);
|
||||
} catch {
|
||||
message.error("排序保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReorder = () => {
|
||||
setReorderMode(false);
|
||||
setReorderItems([]);
|
||||
};
|
||||
|
||||
/* ── 渲染 ── */
|
||||
const displayClips = reorderMode
|
||||
? reorderItems
|
||||
: [...clips].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className="plan-clips-page">
|
||||
{/* 顶部 */}
|
||||
<div className="plan-clips-header">
|
||||
<div className="plan-clips-header-left">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate("/app/edit-plans")}
|
||||
>
|
||||
返回计划列表
|
||||
</Button>
|
||||
<div className="plan-clips-title">
|
||||
<h2>{plan?.name || "加载中..."}</h2>
|
||||
<p>
|
||||
{planLoading
|
||||
? "加载中..."
|
||||
: `共 ${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan-clips-header-right">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setImportDrawerOpen(true)}
|
||||
>
|
||||
从素材导入
|
||||
</Button>
|
||||
{reorderMode ? (
|
||||
<>
|
||||
<Button onClick={cancelReorder}>取消排序</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={saveReorder}
|
||||
>
|
||||
保存排序
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
icon={<OrderedListOutlined />}
|
||||
onClick={enterReorderMode}
|
||||
disabled={clips.length === 0}
|
||||
>
|
||||
调整顺序
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleNewClip}
|
||||
>
|
||||
添加片段
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{!reorderMode && selectedRowKeys.length > 0 && (
|
||||
<div className="plan-clips-batch-bar">
|
||||
<span>已选择 {selectedRowKeys.length} 个片段</span>
|
||||
<Popconfirm
|
||||
title="批量删除"
|
||||
description={`确定删除选中的 ${selectedRowKeys.length} 个片段吗?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排序列表 */}
|
||||
{reorderMode && (
|
||||
<Card className="plan-clips-reorder-card" title="拖拽调整顺序">
|
||||
<div className="plan-clips-reorder-list">
|
||||
{reorderItems.map((clip, index) => (
|
||||
<div key={clip.id} className="plan-clips-reorder-item">
|
||||
<span className="reorder-index">{index + 1}</span>
|
||||
<span className="reorder-type">
|
||||
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)
|
||||
?.label || clip.clip_type}
|
||||
</span>
|
||||
<span className="reorder-content">
|
||||
{clip.text_content || clip.asset_id || "无内容"}
|
||||
</span>
|
||||
<span className="reorder-duration">
|
||||
{clip.duration.toFixed(1)}s
|
||||
</span>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => moveClip(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => moveClip(index, index + 1)}
|
||||
disabled={index === reorderItems.length - 1}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 片段列表 */}
|
||||
{!reorderMode && (
|
||||
<div className="plan-clips-table-wrap">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={displayClips}
|
||||
loading={clipsLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description="暂无片段,点击上方按钮添加或从素材导入"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingClip ? "编辑片段" : "添加片段"}
|
||||
open={editModalOpen}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onOk={handleSaveClip}
|
||||
confirmLoading={editLoading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item
|
||||
label="片段类型"
|
||||
name="clip_type"
|
||||
rules={[{ required: true, message: "请选择类型" }]}
|
||||
>
|
||||
<Select options={CLIP_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材 ID" name="asset_id">
|
||||
<Input placeholder="关联的素材 ID(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="文本内容" name="text_content">
|
||||
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item
|
||||
label="起始时间(秒)"
|
||||
name="start_time"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item
|
||||
label="转场效果"
|
||||
name="transition_effect"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select options={TRANSITION_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="转场时长"
|
||||
name="transition_duration"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="播放速度" name="playback_speed">
|
||||
<InputNumber
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 素材导入抽屉 */}
|
||||
<Drawer
|
||||
title="从素材库导入"
|
||||
open={importDrawerOpen}
|
||||
onClose={() => setImportDrawerOpen(false)}
|
||||
width={480}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImportFromAssets}
|
||||
loading={importLoading}
|
||||
disabled={selectedAssetIds.length === 0}
|
||||
>
|
||||
导入{" "}
|
||||
{selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{assets && assets.length > 0 ? (
|
||||
<div className="asset-import-list">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`asset-import-item ${
|
||||
selectedAssetIds.includes(asset.id) ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedAssetIds((prev) =>
|
||||
prev.includes(asset.id)
|
||||
? prev.filter((id) => id !== asset.id)
|
||||
: [...prev, asset.id],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="asset-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="asset-thumb-placeholder">{asset.type}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-meta">
|
||||
{asset.type}
|
||||
{asset.duration ? ` · ${asset.duration.toFixed(1)}s` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="素材库为空" />
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanClipsManager;
|
||||
Executable
+203
@@ -0,0 +1,203 @@
|
||||
/* 剪辑计划片段管理页面 */
|
||||
|
||||
.plan-clips-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.plan-clips-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-clips-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plan-clips-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.plan-clips-title p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.plan-clips-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #e6f4ff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.plan-clips-table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.clip-asset-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 排序模式 */
|
||||
.plan-clips-reorder-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.reorder-index {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reorder-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
padding: 2px 8px;
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reorder-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reorder-duration {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 素材导入 */
|
||||
.asset-import-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.asset-import-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.asset-import-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.asset-import-item.selected {
|
||||
border-color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
Regular → Executable
+293
-1
@@ -1812,7 +1812,27 @@
|
||||
═══════════════════════════════════════ */
|
||||
|
||||
.ep-status-bar {
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 16px;
|
||||
background: var(--ep-bg-card, #fff);
|
||||
border-bottom: 1px solid var(--ep-border, #e8e8e8);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-status-left,
|
||||
.ep-status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ep-status-sep {
|
||||
margin: 0 4px;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════
|
||||
@@ -6028,3 +6048,275 @@
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* ── 生成历史取消按钮 ── */
|
||||
.ep-gh-td-action {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
.ep-gh-cancel-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-error, #ff4d4f);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ep-gh-cancel-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
}
|
||||
.ep-gh-cancel-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ep-gh-action-placeholder {
|
||||
color: var(--text-tertiary, #bfbfbf);
|
||||
}
|
||||
|
||||
/* ═══ 生成进度 - 片段状态列表 ═══ */
|
||||
|
||||
.ep-gen-clip-list {
|
||||
margin-top: 16px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ep-gen-clip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ep-gen-clip-item + .ep-gen-clip-item {
|
||||
border-top: 1px solid var(--border-color-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.ep-gen-clip-index {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-gen-clip-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.ep-gen-clip-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-completed {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-processing {
|
||||
color: #3b82f6;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-pending,
|
||||
.ep-gen-clip-status.status-queued {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ═══ 右侧栏 Tab ═══ */
|
||||
.ep-right-panel {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.ep-right-tabs {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.ep-right-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.ep-right-tab:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.ep-right-tab.active {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
border-bottom-color: var(--primary-color, #3b82f6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ep-right-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ═══ 编辑器内片段列表 ═══ */
|
||||
.ep-clip-list {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ep-clip-list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.ep-clip-list-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.ep-clip-list-count b {
|
||||
color: var(--text-primary, #111827);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ep-clip-list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.ep-clip-list-item {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-list-item:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ep-clip-list-item.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
|
||||
.ep-clip-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ep-clip-item-index {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-type-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ep-clip-item-duration {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ep-clip-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: flex-end;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-list-item:hover .ep-clip-item-actions,
|
||||
.ep-clip-list-item.selected .ep-clip-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ep-clip-item-btn {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.ep-clip-list-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,13 @@ import {
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
type EditPlanClip,
|
||||
type CreateEditPlanClipRequest,
|
||||
type ClipStatusItem,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type {
|
||||
@@ -79,10 +86,11 @@ import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
import TimelinePanel from "./components/TimelinePanel";
|
||||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||||
import EditorClipList from "./components/EditorClipList";
|
||||
import BgmSelector from "./components/BgmSelector";
|
||||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./types/subtitle";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle";
|
||||
import TransitionSelector from "./components/TransitionSelector";
|
||||
import SpeedPanel from "./components/SpeedPanel";
|
||||
import TtsPanel from "./components/TtsPanel";
|
||||
@@ -236,6 +244,10 @@ const EditingPlanner: React.FC = () => {
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
});
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">(
|
||||
"properties",
|
||||
);
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -270,6 +282,9 @@ const EditingPlanner: React.FC = () => {
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [genError, setGenError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [genCancelled, setGenCancelled] = useState(false);
|
||||
const [genClipStatuses, setGenClipStatuses] = useState<ClipStatusItem[]>([]);
|
||||
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/* ── 播放 ── */
|
||||
@@ -408,8 +423,16 @@ const EditingPlanner: React.FC = () => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return;
|
||||
getEditPlan(loadedPlanId)
|
||||
.then((plan) => {
|
||||
|
||||
// 并行加载计划基本信息 + 片段列表
|
||||
Promise.all([
|
||||
getEditPlan(loadedPlanId),
|
||||
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
})),
|
||||
])
|
||||
.then(([plan, clipsRes]) => {
|
||||
// 设置关联的模板(触发模板加载 effect)
|
||||
setLoadedTemplateId(plan.template_id);
|
||||
|
||||
@@ -448,9 +471,54 @@ const EditingPlanner: React.FC = () => {
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}));
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url:
|
||||
cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time:
|
||||
cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}));
|
||||
}
|
||||
|
||||
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
|
||||
if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
|
||||
const backendClips = clipsRes?.items || [];
|
||||
if (backendClips.length > 0) {
|
||||
// 从后端 clips 表还原
|
||||
const sorted = [...backendClips].sort((a, b) => a.order - b.order);
|
||||
const mapped: ClipData[] = sorted.map((clip) => ({
|
||||
id: clip.id,
|
||||
template_segment_id:
|
||||
(clip.config?.template_segment_id as string) || "",
|
||||
type: (clip.clip_type === "voiceover"
|
||||
? "voice"
|
||||
: "pip") as ClipType,
|
||||
duration: clip.duration || 3,
|
||||
startOffset: 0,
|
||||
script_text: clip.text_content || "",
|
||||
order: clip.order,
|
||||
media_asset_id: clip.asset_id || undefined,
|
||||
transition:
|
||||
clip.transition_effect && clip.transition_effect !== "none"
|
||||
? {
|
||||
type: clip.transition_effect as TransitionEffect["type"],
|
||||
duration: clip.transition_duration || 0.3,
|
||||
}
|
||||
: undefined,
|
||||
speed: clip.playback_speed
|
||||
? { rate: clip.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
|
||||
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
|
||||
}));
|
||||
setTimeout(() => resetClips(mapped), 100);
|
||||
} else if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 兜底:从 config.segments 还原(老数据兼容)
|
||||
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||||
id: `seg-${idx}`,
|
||||
template_segment_id: `seg-${idx}`,
|
||||
@@ -554,15 +622,27 @@ const EditingPlanner: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClipRemove = (clipId: string) => {
|
||||
setClips((prev) => prev.filter((c) => c.id !== clipId));
|
||||
if (selectedClipId === clipId) setSelectedClipId(null);
|
||||
Modal.confirm({
|
||||
title: "删除片段",
|
||||
content: "确定要删除这个片段吗?此操作可通过撤销恢复。",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
setClips((prev) => prev.filter((c) => c.id !== clipId));
|
||||
if (selectedClipId === clipId) setSelectedClipId(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClipUpdate = (clipId: string, data: Partial<ClipData>) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
|
||||
);
|
||||
};
|
||||
const handleClipUpdate = useCallback(
|
||||
(clipId: string, data: Partial<ClipData>) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/**
|
||||
* 添加片段(不绑定任何素材)
|
||||
@@ -670,7 +750,7 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)
|
||||
},
|
||||
[transitionTargetClipId],
|
||||
[transitionTargetClipId, handleClipUpdate],
|
||||
);
|
||||
|
||||
/* ── 打开转场选择器 ── */
|
||||
@@ -686,7 +766,7 @@ const EditingPlanner: React.FC = () => {
|
||||
handleClipUpdate(speedTargetClipId, { speed: config });
|
||||
}
|
||||
},
|
||||
[speedTargetClipId],
|
||||
[speedTargetClipId, handleClipUpdate],
|
||||
);
|
||||
|
||||
/* ── 打开调速面板 ── */
|
||||
@@ -701,7 +781,7 @@ const EditingPlanner: React.FC = () => {
|
||||
if (!ttsTargetClipId) return;
|
||||
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
|
||||
},
|
||||
[ttsTargetClipId],
|
||||
[ttsTargetClipId, handleClipUpdate],
|
||||
);
|
||||
|
||||
/* ── 打开 TTS 配音面板 ── */
|
||||
@@ -711,10 +791,13 @@ const EditingPlanner: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
/* ── 调速应用到所有片段 ── */
|
||||
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
}, []);
|
||||
const handleApplySpeedAll = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 水印配置变更 ── */
|
||||
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
|
||||
@@ -928,10 +1011,58 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将本地编辑的片段同步到后端 clips 表
|
||||
* 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用)
|
||||
*/
|
||||
const syncClipsToBackend = async (planId: string): Promise<void> => {
|
||||
if (clips.length === 0) return;
|
||||
|
||||
// 1. 获取并删除后端现有片段
|
||||
const existing = await getEditPlanClips(planId, { limit: 500 });
|
||||
if (existing.items.length > 0) {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
existing.items.map((c) => c.id),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 批量创建新片段(并发 3 个)
|
||||
const clipDataList: CreateEditPlanClipRequest[] = clips.map((c, i) => ({
|
||||
clip_type: c.type === "voice" ? "voiceover" : "main",
|
||||
order: i,
|
||||
asset_id: c.media_asset_id || "",
|
||||
text_content: c.script_text || "",
|
||||
start_time: 0,
|
||||
duration: c.duration,
|
||||
transition_effect: c.transition?.type || "cut",
|
||||
transition_duration: c.transition?.duration || 0,
|
||||
playback_speed: c.speed?.rate || 1.0,
|
||||
config: {
|
||||
tts_config: c.tts_config || null,
|
||||
trim_config: c.trim_config || null,
|
||||
template_segment_id: c.template_segment_id || null,
|
||||
},
|
||||
}));
|
||||
|
||||
// 并发控制:最多同时 3 个请求
|
||||
const results: EditPlanClip[] = [];
|
||||
const concurrency = 3;
|
||||
for (let i = 0; i < clipDataList.length; i += concurrency) {
|
||||
const batch = clipDataList.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((data) => createEditPlanClip(planId, data)),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
console.log(`[片段同步] 创建了 ${results.length} 个片段`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 剪辑计划生成
|
||||
* 1. 有 planId → 更新计划配置 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
|
||||
* 1. 有 planId → 更新计划配置 + 同步片段 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 同步片段 + 触发生成
|
||||
* 3. 触发生成后轮询状态,完成后获取视频结果
|
||||
*/
|
||||
const handleGoToGenerate = async () => {
|
||||
@@ -949,18 +1080,28 @@ const EditingPlanner: React.FC = () => {
|
||||
setGeneratedVideos([]);
|
||||
setGenError(null);
|
||||
setGenProgress(0);
|
||||
setGenCancelled(false);
|
||||
|
||||
try {
|
||||
const config = buildPlanConfig();
|
||||
let planId = loadedPlanId;
|
||||
|
||||
if (planId) {
|
||||
// 已有计划 → 更新配置
|
||||
await updateEditPlan(planId, {
|
||||
config,
|
||||
total_duration: totalDuration,
|
||||
status: "editing",
|
||||
});
|
||||
// 已有计划 → 先重置状态为 draft(failed/editing 等非 draft 状态会被后端拒绝更新和生成)
|
||||
try {
|
||||
await updateEditPlan(planId, { status: "draft" });
|
||||
} catch (resetErr) {
|
||||
console.warn("[状态重置跳过]", resetErr);
|
||||
}
|
||||
// 再更新配置
|
||||
try {
|
||||
await updateEditPlan(planId, {
|
||||
config,
|
||||
total_duration: totalDuration,
|
||||
});
|
||||
} catch (updateErr) {
|
||||
console.warn("[计划更新跳过]", updateErr);
|
||||
}
|
||||
} else {
|
||||
// 无计划 → 创建新计划
|
||||
const plan = await createEditPlan({
|
||||
@@ -977,6 +1118,15 @@ const EditingPlanner: React.FC = () => {
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
}
|
||||
|
||||
// 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读)
|
||||
try {
|
||||
await syncClipsToBackend(planId);
|
||||
} catch (syncErr) {
|
||||
console.warn("[片段同步失败]", syncErr);
|
||||
message.warning("片段同步失败,将使用模板默认配置生成");
|
||||
// 同步失败不阻塞生成,后端有模板兜底
|
||||
}
|
||||
|
||||
// 触发生成
|
||||
const genRes = await generateEditPlan(planId);
|
||||
setGenTotalClips(genRes.clip_count);
|
||||
@@ -1004,6 +1154,7 @@ const EditingPlanner: React.FC = () => {
|
||||
).length;
|
||||
setGenDoneClips(done);
|
||||
setGenTotalClips(total);
|
||||
setGenClipStatuses(status.clips || []);
|
||||
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
|
||||
|
||||
if (status.plan_status === "completed") {
|
||||
@@ -1032,6 +1183,14 @@ const EditingPlanner: React.FC = () => {
|
||||
return; // 停止轮询
|
||||
}
|
||||
|
||||
if (status.plan_status === "cancelled") {
|
||||
setGenerating(false);
|
||||
setGenError("生成已取消");
|
||||
setGenCancelled(true);
|
||||
message.info("生成任务已取消");
|
||||
return; // 停止轮询
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
genTimerRef.current = setTimeout(poll, 2000);
|
||||
} catch (err) {
|
||||
@@ -1051,6 +1210,33 @@ const EditingPlanner: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 取消生成任务 */
|
||||
const handleCancelGeneration = async () => {
|
||||
const targetId = loadedPlanId;
|
||||
if (!targetId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: "确认取消生成",
|
||||
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
|
||||
okText: "确认取消",
|
||||
cancelText: "继续生成",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
setCancelling(true);
|
||||
await cancelGeneration(targetId);
|
||||
message.success("已提交取消请求");
|
||||
// 轮询会继续运行直到检测到 cancelled 状态
|
||||
} catch (err) {
|
||||
console.error("[取消失败]", err);
|
||||
message.error("取消失败,请稍后重试");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
const targetId = loadedPlanId || loadedTemplateId;
|
||||
@@ -1195,43 +1381,87 @@ const EditingPlanner: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
<div className="ep-right-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-right-tabs">
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
|
||||
onClick={() => setRightTab("properties")}
|
||||
>
|
||||
属性
|
||||
</button>
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
|
||||
onClick={() => setRightTab("clips")}
|
||||
>
|
||||
片段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 属性 Tab */}
|
||||
{rightTab === "properties" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段 Tab */}
|
||||
{rightTab === "clips" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<EditorClipList
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
onSelect={handleClipSelect}
|
||||
onMoveUp={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId);
|
||||
if (idx > 0) handleClipReorder(idx, idx - 1);
|
||||
}}
|
||||
onMoveDown={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId);
|
||||
if (idx < clips.length - 1) handleClipReorder(idx, idx + 1);
|
||||
}}
|
||||
onRemove={handleClipRemove}
|
||||
onAdd={() => handleAddClip("pip", 3)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第4行:底栏 40px ═══ */}
|
||||
@@ -1275,12 +1505,42 @@ const EditingPlanner: React.FC = () => {
|
||||
loading={genHistoryLoading}
|
||||
history={genHistory}
|
||||
onClose={() => setGenHistoryOpen(false)}
|
||||
onCancel={async () => {
|
||||
Modal.confirm({
|
||||
title: "确认取消生成",
|
||||
content: "确定要取消这个生成任务吗?此操作不可恢复。",
|
||||
okText: "确认取消",
|
||||
cancelText: "再等等",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
if (!loadedPlanId) return;
|
||||
try {
|
||||
await cancelGeneration(loadedPlanId);
|
||||
message.success("已提交取消请求");
|
||||
// 刷新历史列表
|
||||
handleViewGenHistory();
|
||||
} catch (err) {
|
||||
console.error("[取消失败]", err);
|
||||
message.error("取消失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
cancelLoading={cancelling}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成进度弹窗 ═══ */}
|
||||
<Modal
|
||||
title={generated ? "生成完成" : "正在生成视频"}
|
||||
open={generating || generated}
|
||||
title={
|
||||
genError
|
||||
? "生成失败"
|
||||
: genCancelled
|
||||
? "已取消生成"
|
||||
: generated
|
||||
? "生成完成"
|
||||
: "正在生成视频"
|
||||
}
|
||||
open={generating || generated || !!genError || genCancelled}
|
||||
footer={
|
||||
generated
|
||||
? [
|
||||
@@ -1289,6 +1549,7 @@ const EditingPlanner: React.FC = () => {
|
||||
onClick={() => {
|
||||
setGenerated(false);
|
||||
setGenerating(false);
|
||||
setGenError(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
@@ -1315,7 +1576,43 @@ const EditingPlanner: React.FC = () => {
|
||||
</Button>
|
||||
),
|
||||
]
|
||||
: null
|
||||
: generating
|
||||
? [
|
||||
<Button
|
||||
key="cancel"
|
||||
danger
|
||||
loading={cancelling}
|
||||
onClick={handleCancelGeneration}
|
||||
>
|
||||
取消生成
|
||||
</Button>,
|
||||
]
|
||||
: genCancelled
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setGenCancelled(false);
|
||||
setGenerating(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: genError
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setGenError(null);
|
||||
setGenerating(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
}
|
||||
closable={!generating}
|
||||
maskClosable={false}
|
||||
@@ -1327,11 +1624,50 @@ const EditingPlanner: React.FC = () => {
|
||||
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
|
||||
已处理 {genDoneClips}/{genTotalClips} 个片段
|
||||
</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{genClipStatuses.length > 0 && (
|
||||
<div className="ep-gen-clip-list">
|
||||
{genClipStatuses.map((clip, index) => (
|
||||
<div key={clip.clip_id || index} className="ep-gen-clip-item">
|
||||
<span className="ep-gen-clip-index">{index + 1}</span>
|
||||
<span className="ep-gen-clip-name">
|
||||
{clip.text_content
|
||||
? clip.text_content.slice(0, 20)
|
||||
: clip.clip_type || `片段${index + 1}`}
|
||||
</span>
|
||||
<span
|
||||
className={`ep-gen-clip-status status-${clip.status}`}
|
||||
>
|
||||
{clip.status === "completed"
|
||||
? "✓ 完成"
|
||||
: clip.status === "failed"
|
||||
? "✗ 失败"
|
||||
: clip.status === "processing"
|
||||
? "⟳ 处理中"
|
||||
: "⏳ 等待中"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
请耐心等待,生成过程中请勿关闭页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{genCancelled && (
|
||||
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||||
<p>生成已取消</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
你可以继续编辑后重新生成
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{generated && generatedVideos.length > 0 && (
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<video
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 编辑器右侧栏 — 片段列表 Tab
|
||||
* 紧凑版片段管理:选中、上下移动、删除、添加
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button, Tooltip, Empty } from "antd";
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface EditorClipListProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
onSelect: (clipId: string) => void;
|
||||
onMoveUp: (clipId: string) => void;
|
||||
onMoveDown: (clipId: string) => void;
|
||||
onRemove: (clipId: string) => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
image: <PictureOutlined />,
|
||||
voice: <SoundOutlined />,
|
||||
pip: <ScissorOutlined />,
|
||||
};
|
||||
|
||||
const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = (sec % 60).toFixed(0);
|
||||
return `${m}m${s.padStart(2, "0")}s`;
|
||||
};
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelect,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}) => {
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-clip-list-empty">
|
||||
<Empty
|
||||
description="暂无片段"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ margin: "40px 0" }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={onAdd}>
|
||||
添加片段
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-clip-list">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="ep-clip-list-toolbar">
|
||||
<span className="ep-clip-list-count">
|
||||
共 <b>{clips.length}</b> 个片段
|
||||
</span>
|
||||
<Tooltip title="添加片段">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAdd}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-clip-list-scroll">
|
||||
{clips.map((clip, index) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-list-item${
|
||||
selectedClipId === clip.id ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
>
|
||||
{/* 序号 + 类型图标 */}
|
||||
<div className="ep-clip-item-head">
|
||||
<span className="ep-clip-item-index">{index + 1}</span>
|
||||
<span className="ep-clip-item-type">
|
||||
{clipTypeIcon[clip.type] || <ScissorOutlined />}
|
||||
<span className="ep-clip-item-type-label">
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ep-clip-item-duration">
|
||||
{formatDuration(clip.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
{clip.script_text && (
|
||||
<div className="ep-clip-item-text">
|
||||
{clip.script_text.slice(0, 40)}
|
||||
{clip.script_text.length > 40 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
className="ep-clip-item-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title="上移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => onMoveUp(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
disabled={index === clips.length - 1}
|
||||
onClick={() => onMoveDown(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorClipList;
|
||||
Regular → Executable
+22
@@ -12,6 +12,8 @@ interface GenerationHistoryModalProps {
|
||||
loading: boolean;
|
||||
history: EditPlanGeneration[];
|
||||
onClose: () => void;
|
||||
onCancel?: (taskId: string) => void;
|
||||
cancelLoading?: boolean;
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
@@ -19,6 +21,8 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
loading,
|
||||
history,
|
||||
onClose,
|
||||
onCancel,
|
||||
cancelLoading,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
@@ -57,11 +61,14 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
<th className="ep-gh-th">状态</th>
|
||||
<th className="ep-gh-th">创建时间</th>
|
||||
<th className="ep-gh-th">更新时间</th>
|
||||
{onCancel && <th className="ep-gh-th">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`;
|
||||
const canCancel =
|
||||
gen.status === "rendering" || gen.status === "editing";
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
@@ -82,6 +89,21 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
? new Date(gen.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
{onCancel && (
|
||||
<td className="ep-gh-td ep-gh-td-action">
|
||||
{canCancel ? (
|
||||
<button
|
||||
className="ep-gh-cancel-btn"
|
||||
onClick={() => onCancel(gen.id)}
|
||||
disabled={cancelLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<span className="ep-gh-action-placeholder">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
Regular → Executable
+1
-40
@@ -5,46 +5,7 @@
|
||||
import React from "react";
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd";
|
||||
import type { Color } from "antd/es/color-picker";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle";
|
||||
|
||||
/* ──────────── 选项常量 ──────────── */
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 剪辑计划片段管理 Hook
|
||||
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
|
||||
*
|
||||
* 功能:
|
||||
* - 加载/刷新片段列表
|
||||
* - 单个增删改查
|
||||
* - 批量删除
|
||||
* - 拖拽重排序
|
||||
* - 从素材批量导入
|
||||
* - 乐观更新 + 撤销重做
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { message } from "antd";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import type {
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/editPlans";
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./useUndoRedo";
|
||||
|
||||
const QUERY_KEY = "editPlanClips";
|
||||
|
||||
export function useEditPlanClips(planId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 片段列表查询 ── */
|
||||
const {
|
||||
data: clipListData,
|
||||
isLoading: clipsLoading,
|
||||
refetch: refetchClips,
|
||||
} = useQuery({
|
||||
queryKey: [QUERY_KEY, planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const clips: EditPlanClip[] = clipListData?.items ?? [];
|
||||
const clipsTotal = clipListData?.total ?? 0;
|
||||
|
||||
/* ── 选中片段 ── */
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null;
|
||||
|
||||
/* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */
|
||||
const {
|
||||
state: localClips,
|
||||
set: setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetLocalClips,
|
||||
} = useUndoRedo<EditPlanClip[]>([]);
|
||||
|
||||
// 当服务端数据变化时同步本地
|
||||
// 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作
|
||||
|
||||
/* ── 创建片段 ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEditPlanClipRequest) =>
|
||||
createEditPlanClip(planId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success("片段已添加");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("添加片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const addClip = useCallback(
|
||||
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
|
||||
if (!planId) return;
|
||||
const order = data.order ?? clips.length;
|
||||
createMutation.mutate({ ...data, order });
|
||||
},
|
||||
[planId, clips.length, createMutation],
|
||||
);
|
||||
|
||||
/* ── 更新片段 ── */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
clipId,
|
||||
data,
|
||||
}: {
|
||||
clipId: string;
|
||||
data: UpdateEditPlanClipRequest;
|
||||
}) => updateEditPlanClip(planId!, clipId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const updateClip = useCallback(
|
||||
(clipId: string, data: UpdateEditPlanClipRequest) => {
|
||||
if (!planId) return;
|
||||
updateMutation.mutate({ clipId, data });
|
||||
},
|
||||
[planId, updateMutation],
|
||||
);
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success("片段已删除");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const removeClip = useCallback(
|
||||
(clipId: string) => {
|
||||
if (!planId) return;
|
||||
if (selectedClipId === clipId) {
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
deleteMutation.mutate(clipId);
|
||||
},
|
||||
[planId, selectedClipId, deleteMutation],
|
||||
);
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (clipIds: string[]) =>
|
||||
batchDeleteEditPlanClips(planId!, clipIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success(`已删除 ${res.deleted_count} 个片段`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("批量删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
const batchRemoveClips = useCallback(
|
||||
(clipIds: string[]) => {
|
||||
if (!planId || clipIds.length === 0) return;
|
||||
if (selectedClipId && clipIds.includes(selectedClipId)) {
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
batchDeleteMutation.mutate(clipIds);
|
||||
},
|
||||
[planId, selectedClipId, batchDeleteMutation],
|
||||
);
|
||||
|
||||
/* ── 重排序(拖拽结束后一次性提交) ── */
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) =>
|
||||
reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败");
|
||||
// 失败后刷新回服务端状态
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
});
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return;
|
||||
reorderMutation.mutate(items);
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
);
|
||||
|
||||
/* ── 从素材批量导入 ── */
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) =>
|
||||
createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败");
|
||||
},
|
||||
});
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return;
|
||||
importFromAssetsMutation.mutate(assetIds);
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
selectedClipId,
|
||||
selectedClip,
|
||||
// 选中
|
||||
setSelectedClipId,
|
||||
// 操作
|
||||
addClip,
|
||||
updateClip,
|
||||
removeClip,
|
||||
batchRemoveClips,
|
||||
reorderClips,
|
||||
importFromAssets,
|
||||
refetchClips,
|
||||
// 状态
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
// 本地撤销重做(供拖拽等场景使用)
|
||||
localClips,
|
||||
setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
resetLocalClips,
|
||||
};
|
||||
}
|
||||
|
||||
export default useEditPlanClips;
|
||||
Regular → Executable
+2
@@ -512,6 +512,8 @@ export interface ClipData {
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
duration: number; // 时长(秒)
|
||||
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||
media_asset_id?: string;
|
||||
// 保留兼容字段(后端序列化需要)
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 字幕样式相关类型与常量
|
||||
* 单独抽离以满足 react-refresh/only-export-components 规则
|
||||
*/
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
/* ──────────── 默认值 ──────────── */
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
Regular → Executable
+7
@@ -163,6 +163,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans/:planId/clips",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/PlanClipsManager").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
@@ -187,7 +187,7 @@ class AssetAnalyzer:
|
||||
if self._frames is not None:
|
||||
return self._frames
|
||||
|
||||
frames = []
|
||||
frames: list[np.ndarray] = []
|
||||
info = self.get_video_info()
|
||||
|
||||
if info.duration <= 0:
|
||||
@@ -398,7 +398,7 @@ class AssetAnalyzer:
|
||||
run_ffmpeg(cmd, timeout=30)
|
||||
except Exception:
|
||||
# 音频提取失败,返回默认分析结果
|
||||
return AudioAnalysis(
|
||||
return AudioAnalysis( # type: ignore[call-arg]
|
||||
has_speech=False,
|
||||
speech_ratio=0.0,
|
||||
avg_volume=0.0,
|
||||
|
||||
@@ -491,6 +491,28 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
# 4. 根据引擎选择渲染方式
|
||||
# 取消检查:素材下载完后,确认任务没有被用户取消
|
||||
if generation_task_id:
|
||||
current_task = gen_task_repo.get(generation_task_id)
|
||||
if current_task:
|
||||
task_status = (
|
||||
current_task.status.value
|
||||
if hasattr(current_task.status, "value")
|
||||
else str(current_task.status)
|
||||
)
|
||||
if task_status == "cancelled":
|
||||
logger.info("任务已被取消,中止渲染: plan_id=%s task_id=%s", plan_id, generation_task_id)
|
||||
# 计划回到 editing 状态,用户可以继续编辑
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
if plan.status.value == "rendering":
|
||||
try:
|
||||
plan.resume_editing()
|
||||
plan_repo.update(plan)
|
||||
except ValueError:
|
||||
pass
|
||||
return {"status": "cancelled", "plan_id": plan_id, "message": "任务已取消"}
|
||||
|
||||
if engine == "unified":
|
||||
result = _render_with_unified(
|
||||
plan=plan,
|
||||
|
||||
@@ -1418,7 +1418,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
_repo = SQLAlchemyGenerationTaskRepository(_session)
|
||||
gen_task = _repo.get(task_id)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
gen_task.append_log( # type: ignore[misc]
|
||||
"任务失败",
|
||||
str(error),
|
||||
level="ERROR",
|
||||
|
||||
@@ -70,13 +70,13 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
metadata["height"] = int(stream.get("height", 0))
|
||||
metadata["codec"] = stream.get("codec_name", "")
|
||||
metadata["fps"] = (
|
||||
_safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0
|
||||
_safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0 # type: ignore[assignment]
|
||||
)
|
||||
break
|
||||
|
||||
# 提取格式信息
|
||||
format_info = probe_data.get("format", {})
|
||||
metadata["duration"] = float(format_info.get("duration", 0))
|
||||
metadata["duration"] = float(format_info.get("duration", 0)) # type: ignore[assignment]
|
||||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||||
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
|
||||
|
||||
@@ -96,7 +96,7 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
if hasattr(img, "_getexif") and img._getexif():
|
||||
exif = img._getexif()
|
||||
if exif:
|
||||
metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))}
|
||||
metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))} # type: ignore[assignment]
|
||||
except ImportError:
|
||||
logger.warning("Pillow not available for image metadata extraction")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# CI 大量失败根因排查报告
|
||||
|
||||
**排查时间:** 2026-07-13
|
||||
**排查人:** 构建服务器运维Agent
|
||||
**范围:** 最近15次 CI run(PR #258~#265 + develop 分支多次 push)
|
||||
|
||||
## 一、整体概况
|
||||
|
||||
最近 20 次 CI run 中 16 次失败,失败率 **80%**。失败集中在 3 个 Job:
|
||||
|
||||
| Job | 失败率 | 根因类型 |
|
||||
|-----|--------|----------|
|
||||
| Validate Code Quality | 100% | black 代码格式检查失败 |
|
||||
| Unit Tests | 100% | 测试断言未同步国际化改动 |
|
||||
| Integration Tests | 100% | 密码重置接口变更未同步测试 |
|
||||
| Frontend Lint | 20% | 各 PR 代码质量问题 |
|
||||
|
||||
**结论:3 个全局性失败点导致所有 PR CI 全红,不是代码本身问题,是基础设施/测试用例滞后。**
|
||||
|
||||
---
|
||||
|
||||
## 二、详细根因分析
|
||||
|
||||
### 1. Validate — black 格式检查失败
|
||||
|
||||
**现象:**
|
||||
```
|
||||
would reformat scripts/check_migration_safety.py
|
||||
1 file would be reformatted, 369 files would be left unchanged.
|
||||
Oh no! 💥 💔 💥
|
||||
```
|
||||
|
||||
**根因:**
|
||||
`scripts/check_migration_safety.py` 文件不符合 black 格式化规范。该文件是最近新增的迁移安全检查脚本,提交前未本地跑 black 格式化。
|
||||
|
||||
**影响范围:** 所有 PR 及 develop 分支,全量失败。
|
||||
|
||||
**修复方案:**
|
||||
```bash
|
||||
black scripts/check_migration_safety.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Unit Tests — 1 个用例失败
|
||||
|
||||
**现象:**
|
||||
```
|
||||
FAILED tests/unit/test_asset_library_delete.py::TestDeleteAssetLibrary::test_delete_library_access_denied
|
||||
AssertionError: assert 'Access denied' in '无权访问该项目'
|
||||
```
|
||||
|
||||
**统计:** 1442 passed, 1 failed
|
||||
|
||||
**根因:**
|
||||
项目之前做了国际化(i18n)改造,错误信息从英文改成了中文,但对应的单元测试断言仍然检查英文 "Access denied",导致断言失败。
|
||||
|
||||
**影响范围:** 所有 PR 及 develop 分支,全量失败。
|
||||
|
||||
**修复方案:**
|
||||
修改 `tests/unit/test_asset_library_delete.py` 中的断言,将 `'Access denied'` 改为 `'无权访问该项目'`,或改为断言 HTTP 状态码(403)而不是错误消息文本。
|
||||
|
||||
---
|
||||
|
||||
### 3. Integration Tests — 1 个用例失败
|
||||
|
||||
**现象:**
|
||||
```
|
||||
FAILED tests/integration/test_auth.py::TestPasswordReset::test_request_password_reset_success
|
||||
assert 404 in (200, 202)
|
||||
```
|
||||
|
||||
**统计:** 45 passed, 1 failed, 13 deselected, 2 rerun
|
||||
|
||||
**根因:**
|
||||
密码重置请求接口(`POST /auth/password-reset/request` 或类似路由)返回 404,说明该接口已被移除、路由变更,或对应的功能模块暂时被注释/下线。
|
||||
|
||||
**影响范围:** 所有 PR 及 develop 分支,全量失败。
|
||||
|
||||
**修复方案:**
|
||||
- 如果接口确实下线了:删除或 skip 这个测试用例
|
||||
- 如果是路由改了:更新测试中的 API 路径
|
||||
- 如果是功能待开发:标记为 `@pytest.mark.skip` 并加上 TODO
|
||||
|
||||
---
|
||||
|
||||
## 三、修复优先级
|
||||
|
||||
| 优先级 | 问题 | 修复难度 | 预估时间 |
|
||||
|--------|------|----------|----------|
|
||||
| P0 | black 格式检查失败 | ⭐ | 5分钟 |
|
||||
| P0 | 单元测试国际化断言失败 | ⭐ | 10分钟 |
|
||||
| P1 | 集成测试密码重置接口404 | ⭐⭐ | 30分钟(需确认接口状态) |
|
||||
|
||||
**建议:** 先修前两个 P0(能让 2/3 的 job 变绿),再处理密码重置那个。
|
||||
|
||||
---
|
||||
|
||||
## 四、Runner 执行情况观察
|
||||
|
||||
- 当前 9 个 Runner 全部在线(构建服务器 4 个 + 新服务器 5 个)
|
||||
- 失败的 Job 都是在构建服务器的 Runner 上执行的(xiaoxia-ci-runner-2/3 等)
|
||||
- 新服务器 5 个 Runner 目前全部空闲(标签修复后首次接任务可能需要时间)
|
||||
- 并发能力充足,瓶颈在代码/测试本身,不在 Runner 资源
|
||||
@@ -0,0 +1,136 @@
|
||||
# 三台服务器 Runner 分工规划
|
||||
|
||||
**制定日期:** 2026-07-13
|
||||
**状态:** 规划中
|
||||
|
||||
---
|
||||
|
||||
## 一、现状总览
|
||||
|
||||
当前共 9 个 Gitea Actions Runner,分布在 3 台服务器上:
|
||||
|
||||
| 服务器 | IP | 配置 | Runner 数量 | 当前状态 |
|
||||
|--------|-----|------|-------------|----------|
|
||||
| 构建服务器 | 114.55.236.178 | 4核 / 7.1G RAM / 49G NVMe | 4个(ID: 8, 42, 46, 47) | ✅ 在线 |
|
||||
| 新CI服务器 | 116.62.226.203 | 8核 / 14G RAM | 5个(ID: 58-62) | ✅ 在线 |
|
||||
| 业务服务器 | 47.98.113.167 | - | 0个(旧3个已下线) | ⚠️ 待规划 |
|
||||
|
||||
**所有 Runner 共用标签:** `saas`, `runtime-builder`, `host`, `ubuntu-latest`
|
||||
|
||||
---
|
||||
|
||||
## 二、问题分析
|
||||
|
||||
### 2.1 标签无区分
|
||||
所有 Runner 标签完全一致,CI 任务随机分配到任意 Runner,导致:
|
||||
- 构建任务(Build)可能跑到配置低的机器上,构建慢
|
||||
- 代码检查任务占着构建服务器,影响构建速度
|
||||
- 业务服务器跑 CI 影响线上服务稳定性
|
||||
|
||||
### 2.2 资源浪费
|
||||
- 新服务器 8核14G 跑 validate/lint 有点大材小用
|
||||
- 构建服务器 4核7G 跑 Docker 构建偏紧张
|
||||
|
||||
---
|
||||
|
||||
## 三、规划方案
|
||||
|
||||
### 3.1 分工原则
|
||||
|
||||
| 服务器 | 角色 | 主要任务类型 | 标签策略 |
|
||||
|--------|------|-------------|----------|
|
||||
| **构建服务器** (114.55.236.178) | 构建专机 | Build Staging / Build Production / Docker 镜像构建 | 保留 `saas` + `host`,新增 `build-only` |
|
||||
| **新CI服务器** (116.62.226.203) | 代码检查专机 | Validate / Unit Tests / Integration Tests / Frontend Lint | 保留 `saas` + `host`,新增 `ci-check` |
|
||||
| **业务服务器** (47.98.113.167) | 部署专机 | Deploy Staging / Deploy Production / E2E Tests | 保留 `saas` + `host`,新增 `deploy-only` |
|
||||
|
||||
### 3.2 具体配置
|
||||
|
||||
#### 构建服务器(4个 Runner)
|
||||
- **数量:** 3个(从4个缩减,释放资源给构建缓存)
|
||||
- **标签:** `saas`, `host`, `build-only`, `ubuntu-latest`
|
||||
- **负责 Job:**
|
||||
- `build-staging`
|
||||
- `build-production-runtime-images`
|
||||
- 其他需要 Docker buildx 的任务
|
||||
|
||||
#### 新CI服务器(5个 Runner)
|
||||
- **数量:** 5个(保持不变)
|
||||
- **标签:** `saas`, `host`, `ci-check`, `ubuntu-latest`
|
||||
- **负责 Job:**
|
||||
- `validate`
|
||||
- `unit-tests`
|
||||
- `integration-tests`
|
||||
- `frontend-lint`
|
||||
- 安全扫描(gitleaks / pip-audit / vulture 等)
|
||||
|
||||
#### 业务服务器(1-2个 Runner)
|
||||
- **数量:** 1-2个(逐步替换旧的3个)
|
||||
- **标签:** `saas`, `host`, `deploy-only`, `ubuntu-latest`
|
||||
- **负责 Job:**
|
||||
- `deploy-staging`
|
||||
- `deploy-production`
|
||||
- `staging-e2e` / `production-e2e`
|
||||
- `staging-api-tests`
|
||||
|
||||
---
|
||||
|
||||
## 四、实施步骤
|
||||
|
||||
### Phase 1: 标签打标(低风险,立即做)
|
||||
1. 新服务器 5 个 Runner 添加 `ci-check` 标签
|
||||
2. 构建服务器保留 3 个 Runner,添加 `build-only` 标签
|
||||
3. 业务服务器部署 1 个新 Runner,标签 `deploy-only`
|
||||
|
||||
### Phase 2: Job 路由调整(中风险,逐步来)
|
||||
1. validate / unit-tests / integration-tests / frontend-lint 改为 `runs-on: ci-check`
|
||||
2. build-staging / build-production 改为 `runs-on: build-only`
|
||||
3. deploy-* / e2e 改为 `runs-on: deploy-only`
|
||||
|
||||
### Phase 3: 旧 Runner 下线
|
||||
- 业务服务器旧的 3 个 Runner 确认无任务后下线
|
||||
- 构建服务器多余的 1 个 Runner 迁移到新服务器
|
||||
|
||||
---
|
||||
|
||||
## 五、并发配置优化建议
|
||||
|
||||
### 5.1 当前并发情况
|
||||
- 首发并行 Job:validate + unit-tests + frontend-lint(3个并行)
|
||||
- integration-tests 依赖 validate(串行,浪费资源)
|
||||
- 无 concurrency 限制,同一分支多次 push 会重复跑
|
||||
|
||||
### 5.2 优化建议
|
||||
|
||||
**1. integration-tests 改为与 unit-tests 并行**
|
||||
```yaml
|
||||
# 当前
|
||||
integration-tests:
|
||||
needs: validate # 没必要等validate
|
||||
|
||||
# 优化后
|
||||
integration-tests:
|
||||
needs: [] # 直接和unit-tests并行跑
|
||||
```
|
||||
|
||||
**2. 增加分支级 concurrency,取消重复构建**
|
||||
```yaml
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
```
|
||||
同一 PR 多次 push 时,取消旧的构建,只跑最新的。
|
||||
|
||||
**3. Build Staging 移出 PR 门禁**
|
||||
- 已在阶段二优化中完成(PR #245)
|
||||
- Build Staging 只在 develop/main 上异步构建
|
||||
|
||||
---
|
||||
|
||||
## 六、预期收益
|
||||
|
||||
| 指标 | 当前 | 优化后 | 提升 |
|
||||
|------|------|--------|------|
|
||||
| PR CI 总时长 | ~8-12分钟 | ~4-6分钟 | ⏱️ 缩短 40-50% |
|
||||
| 构建速度 | 可能抢到慢机器 | 固定高配构建机 | 🚀 更稳定更快 |
|
||||
| 线上稳定性 | CI和业务抢资源 | 部署独立Runner | 🛡️ 隔离保障 |
|
||||
| Runner 利用率 | 随机分配 | 按任务类型调度 | 📈 更合理 |
|
||||
@@ -3,12 +3,27 @@ FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# 先拷依赖清单(缓存友好:依赖不变时直接命中缓存层)
|
||||
COPY apps/web/package.json apps/web/package-lock.json ./apps/web/
|
||||
WORKDIR /app/apps/web
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
|
||||
# 安装依赖:用BuildKit cache mount缓存npm下载和node_modules
|
||||
# sharing=locked 防止并发构建竞争写缓存
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
|
||||
--mount=type=cache,target=/app/apps/web/node_modules,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com \
|
||||
&& npm ci
|
||||
|
||||
# 再拷源码
|
||||
COPY apps/web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
|
||||
RUN --mount=type=cache,target=/app/apps/web/node_modules,sharing=locked \
|
||||
--mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& npx tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& npx vite build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
|
||||
@@ -20,7 +20,7 @@ class ListAssetLibrariesUseCase:
|
||||
def execute(self, project_id: str) -> list[AssetLibrary]:
|
||||
if not project_id.strip():
|
||||
raise ValueError("project_id 不能为空")
|
||||
return self.asset_library_repository.find_by_project(project_id.strip())
|
||||
return self.asset_library_repository.find_by_project(project_id.strip()) # type: ignore[return-value]
|
||||
|
||||
|
||||
class CreateAssetLibraryUseCase:
|
||||
@@ -33,4 +33,4 @@ class CreateAssetLibraryUseCase:
|
||||
name=command.name,
|
||||
kind=command.kind,
|
||||
)
|
||||
return self.asset_library_repository.create(library)
|
||||
return self.asset_library_repository.create(library) # type: ignore[return-value]
|
||||
|
||||
@@ -22,7 +22,7 @@ class SubmitClassificationJobUseCase:
|
||||
id=uuid4().hex,
|
||||
project_id=command.project_id,
|
||||
asset_id=command.asset_id,
|
||||
status="pending",
|
||||
status="pending", # type: ignore[arg-type]
|
||||
classification="",
|
||||
confidence=0.0,
|
||||
error_message="",
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -102,7 +102,7 @@ class CosyVoiceService:
|
||||
model: str = "",
|
||||
clone_model: str = "",
|
||||
http_client: Optional[httpx.Client] = None,
|
||||
audio_url_signer: Optional[callable] = None,
|
||||
audio_url_signer: Optional[Callable[[str], str]] = None,
|
||||
) -> None:
|
||||
"""初始化 CosyVoice 服务.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class CreateGenerationTaskUseCase:
|
||||
asset_ids=command.asset_ids,
|
||||
title_ids=command.title_ids,
|
||||
voice_ids=command.voice_ids,
|
||||
status="pending",
|
||||
status="pending", # type: ignore[arg-type]
|
||||
progress=0.0,
|
||||
result_count=0,
|
||||
error_message="",
|
||||
|
||||
@@ -24,6 +24,7 @@ from packages.application.voice_clone.use_cases import (
|
||||
)
|
||||
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
||||
from packages.shared.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,6 +104,15 @@ class VoiceCloneWorkflowService:
|
||||
|
||||
# 2. 提交 CosyVoice 克隆任务(仅有音频 URL 时才标记 processing)
|
||||
if source_audio_url:
|
||||
# SSRF 防护:校验音频 URL 安全性
|
||||
try:
|
||||
source_audio_url = validate_url_safety(source_audio_url, purpose="download")
|
||||
except UrlSecurityError as e:
|
||||
profile.mark_failed(f"音频URL安全校验失败: {e}")
|
||||
profile = self.repository.update(profile)
|
||||
logger.warning(f"音色克隆音频URL安全校验失败: profile_id={profile.id}, error={e}")
|
||||
return profile
|
||||
|
||||
# 标记为 processing
|
||||
profile.mark_processing()
|
||||
profile = self.repository.update(profile)
|
||||
@@ -243,6 +253,15 @@ class VoiceCloneWorkflowService:
|
||||
|
||||
# 3. 重新提交 CosyVoice
|
||||
if profile.source_audio_url:
|
||||
# SSRF 防护:重新校验音频 URL 安全性
|
||||
try:
|
||||
validate_url_safety(profile.source_audio_url, purpose="download")
|
||||
except UrlSecurityError as e:
|
||||
profile.mark_failed(f"音频URL安全校验失败: {e}")
|
||||
profile = self.repository.update(profile)
|
||||
logger.warning(f"音色克隆重试音频URL安全校验失败: profile_id={clone_id}, error={e}")
|
||||
return profile
|
||||
|
||||
try:
|
||||
submit_result = self.cosyvoice_service.submit_clone_task(
|
||||
audio_url=profile.source_audio_url,
|
||||
|
||||
@@ -140,6 +140,42 @@ class BGMConfig(BaseModel):
|
||||
sidechain_threshold: float = Field(default=-25.0, ge=-60.0, le=0.0, description="人声闪避触发阈值(dB)")
|
||||
|
||||
|
||||
class ExportConfig(BaseModel):
|
||||
"""导出配置
|
||||
|
||||
视频输出参数设置。
|
||||
"""
|
||||
|
||||
resolution: str = Field(default="1080x1920", description="输出分辨率,如 1080x1920 / 720x1280 / 2160x3840")
|
||||
fps: int = Field(default=30, ge=15, le=60, description="输出帧率 15~60")
|
||||
video_bitrate: int = Field(default=8000, ge=1000, le=20000, description="视频码率(kbps)")
|
||||
audio_bitrate: int = Field(default=128, ge=64, le=320, description="音频码率(kbps)")
|
||||
format: str = Field(default="mp4", description="输出格式:mp4 / mov")
|
||||
quality_preset: str = Field(
|
||||
default="balanced",
|
||||
description="质量预设:ultra_fast / fast / balanced / high / best",
|
||||
)
|
||||
watermark_enabled: bool = Field(default=False, description="是否启用水印")
|
||||
watermark_text: str = Field(default="", description="水印文字")
|
||||
|
||||
|
||||
class FilterConfig(BaseModel):
|
||||
"""滤镜调色配置
|
||||
|
||||
支持全局滤镜和按片段覆盖。
|
||||
强度 0-100,0 表示不应用,100 表示全量应用预设。
|
||||
"""
|
||||
|
||||
enabled: bool = Field(default=False, description="是否启用滤镜")
|
||||
preset_id: str = Field(default="filter_none", description="滤镜预设 ID")
|
||||
intensity: int = Field(default=100, ge=0, le=100, description="滤镜强度 0-100")
|
||||
# 自定义微调参数(在预设基础上叠加调整)
|
||||
brightness: float = Field(default=0.0, ge=-1.0, le=1.0, description="亮度微调")
|
||||
contrast: float = Field(default=1.0, ge=0.0, le=2.0, description="对比度微调(倍率)")
|
||||
saturation: float = Field(default=1.0, ge=0.0, le=3.0, description="饱和度微调(倍率)")
|
||||
warmth: float = Field(default=0.0, ge=-1.0, le=1.0, description="色温微调(正=暖,负=冷)")
|
||||
|
||||
|
||||
# ── 完整 config 模型 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -155,6 +191,8 @@ class EditPlanConfigSchema(BaseModel):
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置")
|
||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置")
|
||||
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置")
|
||||
export: ExportConfig = Field(default_factory=ExportConfig, description="导出配置")
|
||||
filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜调色配置")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式")
|
||||
|
||||
|
||||
@@ -169,6 +207,8 @@ class EditTemplateConfigSchema(BaseModel):
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置")
|
||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置")
|
||||
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置")
|
||||
export: ExportConfig = Field(default_factory=ExportConfig, description="导出默认配置")
|
||||
filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜默认配置")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式")
|
||||
transition_enabled: bool = Field(default=True, description="是否启用转场")
|
||||
|
||||
@@ -218,6 +258,25 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
|
||||
"sidechain_release": 0.5,
|
||||
"sidechain_threshold": -25.0,
|
||||
},
|
||||
"export": {
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 8000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "balanced",
|
||||
"watermark_enabled": False,
|
||||
"watermark_text": "",
|
||||
},
|
||||
"filter": {
|
||||
"enabled": False,
|
||||
"preset_id": "filter_none",
|
||||
"intensity": 100,
|
||||
"brightness": 0.0,
|
||||
"contrast": 1.0,
|
||||
"saturation": 1.0,
|
||||
"warmth": 0.0,
|
||||
},
|
||||
"editing_mode": "one_take",
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,13 @@ class EditPlan:
|
||||
self.status = EditPlanStatus.FAILED
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_editing(self) -> None:
|
||||
"""重新进入编辑状态(完成/失败后重新编辑)"""
|
||||
if self.status not in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
|
||||
raise ValueError(f"只有 completed/failed 状态的计划可以重新编辑,当前状态: {self.status}")
|
||||
self.status = EditPlanStatus.EDITING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def reset_to_draft(self) -> None:
|
||||
"""重置为草稿状态(仅从 failed 状态可重置)"""
|
||||
if self.status != EditPlanStatus.FAILED:
|
||||
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
"""滤镜预设库 — 视频调色滤镜预设清单.
|
||||
|
||||
每个滤镜预设对应一组 FFmpeg 滤镜参数,用于视频调色。
|
||||
所有参数均可调整强度(0-100),0表示原图,100表示全量应用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilterPreset:
|
||||
"""滤镜预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str # 分类:basic / cinematic / vintage / bw / style
|
||||
description: str = ""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
# FFmpeg eq 滤镜参数(基准值,实际应用时乘以强度系数)
|
||||
brightness: float = 0.0 # -1.0 ~ 1.0
|
||||
contrast: float = 1.0 # 0.0 ~ 2.0,1.0为原值
|
||||
saturation: float = 1.0 # 0.0 ~ 3.0,1.0为原值
|
||||
gamma: float = 1.0 # 0.1 ~ 10.0,1.0为原值
|
||||
gamma_r: float = 1.0 # 红通道伽马
|
||||
gamma_g: float = 1.0 # 绿通道伽马
|
||||
gamma_b: float = 1.0 # 蓝通道伽马
|
||||
hue: float = 0.0 # 色相偏移 -180 ~ 180度
|
||||
# 可选的颜色查找表 LUT(后续扩展)
|
||||
lut_url: str = ""
|
||||
|
||||
|
||||
# ── 预设库清单 ────────────────────────────────────────────────────────────────
|
||||
|
||||
FILTER_PRESET_LIBRARY: List[FilterPreset] = [
|
||||
# ── 基础 basic ─────────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_none",
|
||||
name="原图",
|
||||
category="basic",
|
||||
description="不应用任何滤镜,保持原始画面",
|
||||
tags=["原图", "无"],
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_brighten",
|
||||
name="明亮",
|
||||
category="basic",
|
||||
description="提升画面亮度,适合偏暗的素材",
|
||||
tags=["提亮", "基础"],
|
||||
brightness=0.12,
|
||||
contrast=1.05,
|
||||
saturation=1.05,
|
||||
gamma=1.1,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_warm",
|
||||
name="暖色",
|
||||
category="basic",
|
||||
description="暖色调,增加温暖感",
|
||||
tags=["暖色", "温馨"],
|
||||
gamma_r=1.1,
|
||||
gamma_g=1.02,
|
||||
gamma_b=0.9,
|
||||
saturation=1.05,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_cool",
|
||||
name="冷色",
|
||||
category="basic",
|
||||
description="冷色调,清凉干净",
|
||||
tags=["冷色", "清新"],
|
||||
gamma_r=0.9,
|
||||
gamma_g=1.0,
|
||||
gamma_b=1.1,
|
||||
saturation=1.02,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_contrast",
|
||||
name="高对比",
|
||||
category="basic",
|
||||
description="增强对比度,画面更通透",
|
||||
tags=["对比", "通透"],
|
||||
contrast=1.25,
|
||||
saturation=1.1,
|
||||
gamma=0.95,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_saturate",
|
||||
name="鲜艳",
|
||||
category="basic",
|
||||
description="提升饱和度,色彩更浓郁",
|
||||
tags=["鲜艳", "浓郁"],
|
||||
saturation=1.4,
|
||||
contrast=1.05,
|
||||
),
|
||||
# ── 电影感 cinematic ─────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_cinematic",
|
||||
name="电影感",
|
||||
category="cinematic",
|
||||
description="经典电影色调,青橙对比",
|
||||
tags=["电影", "青橙", "质感"],
|
||||
contrast=1.2,
|
||||
saturation=0.9,
|
||||
gamma_r=1.15,
|
||||
gamma_g=0.95,
|
||||
gamma_b=0.85,
|
||||
brightness=-0.03,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_teal_orange",
|
||||
name="青橙色调",
|
||||
category="cinematic",
|
||||
description="好莱坞经典青橙对比色",
|
||||
tags=["青橙", "好莱坞", "对比"],
|
||||
contrast=1.15,
|
||||
saturation=1.1,
|
||||
gamma_r=1.2,
|
||||
gamma_g=0.9,
|
||||
gamma_b=0.8,
|
||||
),
|
||||
# ── 复古 vintage ─────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_vintage",
|
||||
name="复古",
|
||||
category="vintage",
|
||||
description="复古胶片色调,怀旧感",
|
||||
tags=["复古", "怀旧", "胶片"],
|
||||
saturation=0.8,
|
||||
contrast=0.9,
|
||||
gamma_r=1.1,
|
||||
gamma_g=1.0,
|
||||
gamma_b=0.85,
|
||||
brightness=-0.02,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_retro",
|
||||
name="怀旧",
|
||||
category="vintage",
|
||||
description="80年代复古感",
|
||||
tags=["怀旧", "80年代"],
|
||||
saturation=0.75,
|
||||
contrast=0.95,
|
||||
gamma_r=1.2,
|
||||
gamma_g=1.05,
|
||||
gamma_b=0.9,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_sepia",
|
||||
name="棕褐色",
|
||||
category="vintage",
|
||||
description="老照片棕褐色调",
|
||||
tags=["棕褐", "老照片", "复古"],
|
||||
saturation=0.3,
|
||||
gamma_r=1.3,
|
||||
gamma_g=1.1,
|
||||
gamma_b=0.8,
|
||||
contrast=0.95,
|
||||
),
|
||||
# ── 黑白 bw ──────────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_bw",
|
||||
name="黑白",
|
||||
category="bw",
|
||||
description="经典黑白",
|
||||
tags=["黑白", "经典"],
|
||||
saturation=0.0,
|
||||
contrast=1.1,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_bw_high",
|
||||
name="高对比黑白",
|
||||
category="bw",
|
||||
description="高对比度黑白,戏剧感强",
|
||||
tags=["黑白", "高对比", "戏剧"],
|
||||
saturation=0.0,
|
||||
contrast=1.4,
|
||||
gamma=0.9,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_bw_soft",
|
||||
name="柔和黑白",
|
||||
category="bw",
|
||||
description="柔和灰度过渡,细腻质感",
|
||||
tags=["黑白", "柔和", "细腻"],
|
||||
saturation=0.0,
|
||||
contrast=0.9,
|
||||
gamma=1.1,
|
||||
),
|
||||
# ── 风格化 style ────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_japanese",
|
||||
name="日系",
|
||||
category="style",
|
||||
description="日系清新,低对比高明度",
|
||||
tags=["日系", "清新", "干净"],
|
||||
contrast=0.85,
|
||||
brightness=0.08,
|
||||
saturation=0.85,
|
||||
gamma_r=0.98,
|
||||
gamma_g=1.02,
|
||||
gamma_b=1.08,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_hk",
|
||||
name="港风",
|
||||
category="style",
|
||||
description="90年代港风,暖黄+高饱和",
|
||||
tags=["港风", "复古", "浓郁"],
|
||||
saturation=1.25,
|
||||
contrast=1.1,
|
||||
gamma_r=1.2,
|
||||
gamma_g=1.05,
|
||||
gamma_b=0.85,
|
||||
brightness=-0.02,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_cyberpunk",
|
||||
name="赛博朋克",
|
||||
category="style",
|
||||
description="赛博朋克风,青紫霓虹",
|
||||
tags=["赛博", "霓虹", "未来感"],
|
||||
contrast=1.2,
|
||||
saturation=1.3,
|
||||
gamma_r=1.3,
|
||||
gamma_g=0.7,
|
||||
gamma_b=1.2,
|
||||
brightness=-0.05,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_fresh",
|
||||
name="清新",
|
||||
category="style",
|
||||
description="清新自然,通透干净",
|
||||
tags=["清新", "自然", "通透"],
|
||||
brightness=0.05,
|
||||
saturation=1.05,
|
||||
contrast=1.05,
|
||||
gamma_g=1.03,
|
||||
gamma_b=1.05,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_dramatic",
|
||||
name="戏剧感",
|
||||
category="style",
|
||||
description="强对比暗角,戏剧化氛围",
|
||||
tags=["戏剧", "暗角", "氛围"],
|
||||
contrast=1.35,
|
||||
saturation=0.9,
|
||||
brightness=-0.08,
|
||||
gamma=0.85,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_dreamy",
|
||||
name="梦幻",
|
||||
category="style",
|
||||
description="柔光梦幻感,低对比",
|
||||
tags=["梦幻", "柔光", "唯美"],
|
||||
contrast=0.8,
|
||||
brightness=0.1,
|
||||
saturation=1.1,
|
||||
gamma=1.15,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_filter_preset(preset_id: str) -> Optional[FilterPreset]:
|
||||
"""根据 ID 获取滤镜预设"""
|
||||
for p in FILTER_PRESET_LIBRARY:
|
||||
if p.id == preset_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def list_filter_presets(
|
||||
*,
|
||||
category: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> List[FilterPreset]:
|
||||
"""筛选滤镜预设列表
|
||||
|
||||
Args:
|
||||
category: 按分类筛选
|
||||
keyword: 关键词搜索(名称/标签/描述)
|
||||
|
||||
Returns:
|
||||
筛选后的预设列表
|
||||
"""
|
||||
results = FILTER_PRESET_LIBRARY
|
||||
|
||||
if category:
|
||||
results = [p for p in results if p.category == category]
|
||||
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
results = [
|
||||
p
|
||||
for p in results
|
||||
if kw in p.name.lower() or kw in p.description.lower() or any(kw in t.lower() for t in p.tags)
|
||||
]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def build_ffmpeg_filter(preset_id: str, intensity: int = 100) -> str:
|
||||
"""根据预设和强度生成 FFmpeg eq 滤镜字符串.
|
||||
|
||||
Args:
|
||||
preset_id: 滤镜预设 ID
|
||||
intensity: 强度 0-100,0=原图,100=全量
|
||||
|
||||
Returns:
|
||||
FFmpeg eq 滤镜参数字符串
|
||||
"""
|
||||
preset = get_filter_preset(preset_id)
|
||||
if preset is None or intensity <= 0:
|
||||
return ""
|
||||
|
||||
if intensity >= 100:
|
||||
intensity = 100
|
||||
|
||||
factor = intensity / 100.0
|
||||
|
||||
# 计算插值后的参数(向原值插值)
|
||||
brightness = preset.brightness * factor
|
||||
contrast = 1.0 + (preset.contrast - 1.0) * factor
|
||||
saturation = 1.0 + (preset.saturation - 1.0) * factor
|
||||
gamma = 1.0 + (preset.gamma - 1.0) * factor
|
||||
gamma_r = 1.0 + (preset.gamma_r - 1.0) * factor
|
||||
gamma_g = 1.0 + (preset.gamma_g - 1.0) * factor
|
||||
gamma_b = 1.0 + (preset.gamma_b - 1.0) * factor
|
||||
|
||||
parts = []
|
||||
if abs(brightness) > 0.001:
|
||||
parts.append(f"brightness={brightness:.3f}")
|
||||
if abs(contrast - 1.0) > 0.001:
|
||||
parts.append(f"contrast={contrast:.3f}")
|
||||
if abs(saturation - 1.0) > 0.001:
|
||||
parts.append(f"saturation={saturation:.3f}")
|
||||
if abs(gamma - 1.0) > 0.001:
|
||||
parts.append(f"gamma={gamma:.3f}")
|
||||
if abs(gamma_r - 1.0) > 0.001:
|
||||
parts.append(f"gamma_r={gamma_r:.3f}")
|
||||
if abs(gamma_g - 1.0) > 0.001:
|
||||
parts.append(f"gamma_g={gamma_g:.3f}")
|
||||
if abs(gamma_b - 1.0) > 0.001:
|
||||
parts.append(f"gamma_b={gamma_b:.3f}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return f"eq={':'.join(parts)}"
|
||||
Executable
+306
@@ -0,0 +1,306 @@
|
||||
"""转场特效预设库.
|
||||
|
||||
视频片段之间的转场效果,基于 FFmpeg xfade 滤镜实现。
|
||||
所有转场预设包含时长范围和默认参数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitionPreset:
|
||||
"""转场特效预设"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str # 分类:basic / fade / slide / zoom / warp / special
|
||||
description: str = ""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
# FFmpeg xfade transition 名称
|
||||
transition: str = "fade"
|
||||
# 默认时长(秒)
|
||||
default_duration: float = 0.5
|
||||
# 支持的时长范围
|
||||
min_duration: float = 0.1
|
||||
max_duration: float = 3.0
|
||||
# 是否需要额外参数
|
||||
has_custom_params: bool = False
|
||||
|
||||
|
||||
# ── 预设库清单 ────────────────────────────────────────────────────────────────
|
||||
|
||||
TRANSITION_PRESET_LIBRARY: List[TransitionPreset] = [
|
||||
# ── 基础 basic ────────────────────────────────────────────────────
|
||||
TransitionPreset(
|
||||
id="transition_none",
|
||||
name="无转场",
|
||||
category="basic",
|
||||
description="硬切,无过渡效果",
|
||||
tags=["硬切", "无"],
|
||||
transition="none",
|
||||
default_duration=0.0,
|
||||
min_duration=0.0,
|
||||
max_duration=0.0,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_random",
|
||||
name="随机",
|
||||
category="basic",
|
||||
description="随机选择一个转场效果",
|
||||
tags=["随机", "惊喜"],
|
||||
transition="random",
|
||||
default_duration=0.5,
|
||||
),
|
||||
# ── 淡入淡出 fade ────────────────────────────────────────────────
|
||||
TransitionPreset(
|
||||
id="transition_fade",
|
||||
name="淡入淡出",
|
||||
category="fade",
|
||||
description="经典交叉淡入淡出",
|
||||
tags=["经典", "柔和"],
|
||||
transition="fade",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=2.0,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_fadeblack",
|
||||
name="黑场过渡",
|
||||
category="fade",
|
||||
description="经过黑色画面过渡",
|
||||
tags=["黑场", "电影感"],
|
||||
transition="fadeblack",
|
||||
default_duration=0.6,
|
||||
min_duration=0.2,
|
||||
max_duration=2.0,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_fadewhite",
|
||||
name="白场过渡",
|
||||
category="fade",
|
||||
description="经过白色画面过渡",
|
||||
tags=["白场", "梦幻"],
|
||||
transition="fadewhite",
|
||||
default_duration=0.6,
|
||||
min_duration=0.2,
|
||||
max_duration=2.0,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_fadegrays",
|
||||
name="灰度过渡",
|
||||
category="fade",
|
||||
description="经过灰度画面过渡",
|
||||
tags=["灰度", "文艺"],
|
||||
transition="fadegrays",
|
||||
default_duration=0.6,
|
||||
min_duration=0.2,
|
||||
max_duration=2.0,
|
||||
),
|
||||
# ── 滑动 slide ──────────────────────────────────────────────────
|
||||
TransitionPreset(
|
||||
id="transition_slideleft",
|
||||
name="左滑",
|
||||
category="slide",
|
||||
description="画面向左滑动",
|
||||
tags=["滑动", "左"],
|
||||
transition="slideleft",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_slideright",
|
||||
name="右滑",
|
||||
category="slide",
|
||||
description="画面向右滑动",
|
||||
tags=["滑动", "右"],
|
||||
transition="slideright",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_slideup",
|
||||
name="上滑",
|
||||
category="slide",
|
||||
description="画面向上滑动",
|
||||
tags=["滑动", "上"],
|
||||
transition="slideup",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_slidedown",
|
||||
name="下滑",
|
||||
category="slide",
|
||||
description="画面向下滑动",
|
||||
tags=["滑动", "下"],
|
||||
transition="slidedown",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
# ── 缩放 zoom ───────────────────────────────────────────────────
|
||||
TransitionPreset(
|
||||
id="transition_zoomin",
|
||||
name="放大进入",
|
||||
category="zoom",
|
||||
description="下一段画面从中心放大出现",
|
||||
tags=["放大", "冲击"],
|
||||
transition="zoomin",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_zoomout",
|
||||
name="缩小退出",
|
||||
category="zoom",
|
||||
description="当前画面缩小退出",
|
||||
tags=["缩小", "拉远"],
|
||||
transition="zoomout",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
# ── 擦除 warp ───────────────────────────────────────────────────
|
||||
TransitionPreset(
|
||||
id="transition_dissolve",
|
||||
name="溶解",
|
||||
category="warp",
|
||||
description="像素溶解效果",
|
||||
tags=["溶解", "像素"],
|
||||
transition="dissolve",
|
||||
default_duration=0.8,
|
||||
min_duration=0.3,
|
||||
max_duration=2.0,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_wipeleft",
|
||||
name="左擦除",
|
||||
category="warp",
|
||||
description="从右向左擦除",
|
||||
tags=["擦除", "左"],
|
||||
transition="wipeleft",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_wiperight",
|
||||
name="右擦除",
|
||||
category="warp",
|
||||
description="从左向右擦除",
|
||||
tags=["擦除", "右"],
|
||||
transition="wiperight",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_wipeup",
|
||||
name="上擦除",
|
||||
category="warp",
|
||||
description="从下向上擦除",
|
||||
tags=["擦除", "上"],
|
||||
transition="wipeup",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_wipedown",
|
||||
name="下擦除",
|
||||
category="warp",
|
||||
description="从上向下擦除",
|
||||
tags=["擦除", "下"],
|
||||
transition="wipedown",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_circlecrop",
|
||||
name="圆形展开",
|
||||
category="warp",
|
||||
description="圆形从中心展开",
|
||||
tags=["圆形", "展开"],
|
||||
transition="circlecrop",
|
||||
default_duration=0.6,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
# ── 特效 special ───────────────────────────────────────────────
|
||||
TransitionPreset(
|
||||
id="transition_hblur",
|
||||
name="水平模糊",
|
||||
category="special",
|
||||
description="水平方向模糊过渡",
|
||||
tags=["模糊", "水平"],
|
||||
transition="hblur",
|
||||
default_duration=0.5,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
TransitionPreset(
|
||||
id="transition_wipeblur",
|
||||
name="模糊擦除",
|
||||
category="special",
|
||||
description="带模糊效果的擦除",
|
||||
tags=["模糊", "擦除"],
|
||||
transition="wipeblur",
|
||||
default_duration=0.6,
|
||||
min_duration=0.2,
|
||||
max_duration=1.5,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_transition_preset(preset_id: str) -> Optional[TransitionPreset]:
|
||||
"""根据 ID 获取转场预设"""
|
||||
for p in TRANSITION_PRESET_LIBRARY:
|
||||
if p.id == preset_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def list_transition_presets(
|
||||
*,
|
||||
category: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> List[TransitionPreset]:
|
||||
"""筛选转场预设列表
|
||||
|
||||
Args:
|
||||
category: 按分类筛选
|
||||
keyword: 关键词搜索
|
||||
|
||||
Returns:
|
||||
筛选后的预设列表
|
||||
"""
|
||||
results = TRANSITION_PRESET_LIBRARY
|
||||
|
||||
if category:
|
||||
results = [p for p in results if p.category == category]
|
||||
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
results = [
|
||||
p
|
||||
for p in results
|
||||
if kw in p.name.lower() or kw in p.description.lower() or any(kw in t.lower() for t in p.tags)
|
||||
]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_default_transition() -> TransitionPreset:
|
||||
"""获取默认转场(无转场/硬切)"""
|
||||
return get_transition_preset("transition_none") # type: ignore
|
||||
@@ -63,6 +63,7 @@ exclude = [
|
||||
".next",
|
||||
"dist",
|
||||
"build",
|
||||
"hostexecutor",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
|
||||
+76
-11
@@ -1,43 +1,108 @@
|
||||
#!/bin/bash
|
||||
# 自动合并通过 CI 检查的 PR
|
||||
# 用法: ./scripts/auto_merge_prs.sh [target_branch]
|
||||
#
|
||||
# 合并前必须验证的 CI 检查项:
|
||||
# - CI/CD Pipeline / Validate Code Quality And Tests (push)
|
||||
# - CI/CD Pipeline / Frontend Lint (push)
|
||||
# 只有两个检查项均为 success 状态才允许合并
|
||||
|
||||
GITEA_API="https://git.xiaoxiajianji.com/api/v1"
|
||||
GITEA_API="${GITEA_API_URL:-https://git.xiaoxiajianji.com/api/v1}"
|
||||
TOKEN="${GITEA_API_TOKEN:?Please set GITEA_API_TOKEN environment variable}"
|
||||
REPO="xiaoxia/xiaoxia-saas"
|
||||
TARGET_BRANCH="${1:-develop}"
|
||||
|
||||
# 必需的 CI 检查项(context 名称前缀匹配,避免 pipeline 名称变化导致匹配失败)
|
||||
REQUIRED_CHECKS=(
|
||||
"Validate Code Quality And Tests"
|
||||
"Frontend Lint"
|
||||
)
|
||||
|
||||
echo "=== Checking open PRs targeting $TARGET_BRANCH ==="
|
||||
|
||||
# 获取所有 open PR
|
||||
PRS=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$GITEA_API/repos/$REPO/pulls?state=open&labels=0" | python3 -c "
|
||||
"$GITEA_API/repos/$REPO/pulls?state=open&sort=updated&direction=desc" | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for pr in data:
|
||||
if pr.get('base', {}).get('ref') == '$TARGET_BRANCH':
|
||||
if pr.get('mergeable', False):
|
||||
print(f\"{pr['number']}|{pr['title']}|{pr.get('mergeable', 'unknown')}\")
|
||||
head_sha = pr.get('head', {}).get('sha', '')
|
||||
print(f\"{pr['number']}|{pr['title']}|{head_sha}\")
|
||||
")
|
||||
|
||||
if [ -z "$PRS" ]; then
|
||||
echo "No mergeable PRs found for $TARGET_BRANCH"
|
||||
echo "No open PRs found for $TARGET_BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "$PRS" | while IFS='|' read -r number title mergeable; do
|
||||
echo "Merging PR #$number: $title"
|
||||
merge_count=0
|
||||
skip_count=0
|
||||
|
||||
echo "$PRS" | while IFS='|' read -r number title head_sha; do
|
||||
echo ""
|
||||
echo "--- PR #$number: $title ---"
|
||||
echo " Head SHA: $head_sha"
|
||||
|
||||
# 获取该 commit 的 combined CI 状态
|
||||
STATUS_JSON=$(curl -s -H "Authorization: token $TOKEN" \
|
||||
"$GITEA_API/repos/$REPO/commits/$head_sha/status")
|
||||
|
||||
# 检查每个必需的 CI 项是否通过
|
||||
all_passed=true
|
||||
failed_checks=""
|
||||
|
||||
for check_pattern in "${REQUIRED_CHECKS[@]}"; do
|
||||
state=$(echo "$STATUS_JSON" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
pattern = '$check_pattern'
|
||||
# 在 statuses 中找到匹配的最新状态
|
||||
target = None
|
||||
for s in d.get('statuses', []):
|
||||
if pattern in s.get('context', ''):
|
||||
target = s
|
||||
break # status 接口返回的是每个 context 的最新状态,取第一个匹配即可
|
||||
if target:
|
||||
print(target.get('state', 'unknown'))
|
||||
else:
|
||||
print('not_found')
|
||||
")
|
||||
|
||||
if [ "$state" = "success" ]; then
|
||||
echo " ✅ $check_pattern: $state"
|
||||
else
|
||||
echo " ❌ $check_pattern: $state"
|
||||
all_passed=false
|
||||
failed_checks="$failed_checks $check_pattern($state)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$all_passed" != "true" ]; then
|
||||
echo " ⏭️ Skipping - CI not passed:$failed_checks"
|
||||
skip_count=$((skip_count + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# CI 全部通过,执行合并
|
||||
echo " 🚀 All CI checks passed, merging..."
|
||||
RESULT=$(curl -s -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$GITEA_API/repos/$REPO/pulls/$number/merge" \
|
||||
-d '{\"merge_method\": \"merge\"}')
|
||||
|
||||
if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if 'id' in d else 1)"; then
|
||||
-d '{"Do": "merge"}')
|
||||
|
||||
if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('merged', False) or 'id' in d else 1)" 2>/dev/null; then
|
||||
echo " ✅ PR #$number merged successfully"
|
||||
merge_count=$((merge_count + 1))
|
||||
else
|
||||
echo " ❌ PR #$number failed: $RESULT"
|
||||
echo " ❌ PR #$number merge failed"
|
||||
# 提取错误信息
|
||||
err_msg=$(echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('message', str(d)[:200]))" 2>/dev/null)
|
||||
echo " Error: $err_msg"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Merged: $merge_count | Skipped: $skip_count"
|
||||
|
||||
@@ -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()
|
||||
@@ -35,6 +35,7 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
@@ -78,6 +79,15 @@ SAFE_PATTERNS = [
|
||||
]
|
||||
|
||||
|
||||
def _get_env(*names: str, default: str = "") -> str:
|
||||
"""按优先级尝试多个环境变量名,返回第一个非空值。"""
|
||||
for name in names:
|
||||
val = os.environ.get(name, "")
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def extract_upgrade_content(content: str) -> str:
|
||||
"""
|
||||
从迁移文件中提取 upgrade 函数的内容。
|
||||
@@ -100,25 +110,60 @@ def extract_upgrade_content(content: str) -> str:
|
||||
return content[upgrade_start:upgrade_end]
|
||||
|
||||
|
||||
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
||||
def _api_get_with_retry(url: str, token: str, max_retries: int = 3) -> dict | list:
|
||||
"""
|
||||
通过 Gitea API 对比目标分支,找出 alembic/versions/ 下新增的迁移文件。
|
||||
不依赖本地 git,避免 CI 环境下 git 操作不稳定的问题。
|
||||
带重试的 API 调用。
|
||||
指数退避:1s, 2s, 4s
|
||||
"""
|
||||
api_url = os.environ.get("GITHUB_API_URL", "")
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
token = os.environ.get("GITHUB_TOKEN", "")
|
||||
last_error = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
# 404 说明目录不存在或分支不存在,直接抛
|
||||
if e.code == 404:
|
||||
raise
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
wait = 2**attempt
|
||||
print(f" (API 请求失败,{wait}s 后重试 {attempt + 1}/{max_retries}:{e})")
|
||||
time.sleep(wait)
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries - 1:
|
||||
wait = 2**attempt
|
||||
print(f" (API 请求失败,{wait}s 后重试 {attempt + 1}/{max_retries}:{e})")
|
||||
time.sleep(wait)
|
||||
raise last_error # type: ignore[misc]
|
||||
|
||||
|
||||
def get_new_migrations_via_api(diff_target: str) -> List[Path] | None:
|
||||
"""
|
||||
通过 Gitea/GitHub Contents API 对比目标分支,找出 alembic/versions/ 下新增的迁移文件。
|
||||
返回 None 表示 API 方式不可用,调用方应尝试其他方式。
|
||||
"""
|
||||
# 同时支持 Gitea 和 GitHub 的环境变量命名
|
||||
api_url = _get_env("GITEA_API_URL", "GITHUB_API_URL", "CI_API_V4_URL")
|
||||
repo = _get_env("GITEA_REPOSITORY", "GITHUB_REPOSITORY", "CI_PROJECT_PATH")
|
||||
token = _get_env("GITEA_TOKEN", "GITHUB_TOKEN", "CI_JOB_TOKEN")
|
||||
branch = diff_target.replace("origin/", "")
|
||||
|
||||
if not api_url or not repo or not token:
|
||||
print("⚠️ CI 环境变量不完整,降级为检查所有迁移文件")
|
||||
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||
print(
|
||||
f" (API 环境变量不完整:api_url={'✓' if api_url else '✗'} repo={'✓' if repo else '✗'} token={'✓' if token else '✗'})"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{api_url}/repos/{repo}/contents/alembic/versions?ref={branch}"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
data = _api_get_with_retry(url, token)
|
||||
|
||||
if isinstance(data, dict):
|
||||
# Gitea 目录不存在时返回 404,不会到这里;如果返回 dict 可能是错误信息
|
||||
print(f" (API 返回异常:{str(data)[:100]})")
|
||||
return None
|
||||
|
||||
remote_files = {item["name"] for item in data if item["name"].endswith(".py")}
|
||||
local_files = {f.name for f in ALEMBIC_VERSIONS_DIR.glob("*.py")}
|
||||
@@ -132,9 +177,82 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
||||
print(f" (API 对比 {branch} 分支,无新增迁移)")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"⚠️ API 获取迁移列表失败:{e}")
|
||||
print(" 降级为检查所有迁移文件")
|
||||
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||
print(f" (API 获取迁移列表失败:{e})")
|
||||
return None
|
||||
|
||||
|
||||
def get_new_migrations_via_git(diff_target: str) -> List[Path] | None:
|
||||
"""
|
||||
Fallback:通过本地 git diff 找出新增的迁移文件。
|
||||
CI 环境中 git 可用时作为 API 失败后的兜底方案。
|
||||
"""
|
||||
try:
|
||||
# 确保目标分支存在
|
||||
subprocess.run(
|
||||
["git", "fetch", "origin", diff_target.replace("origin/", ""), "--depth=50"],
|
||||
capture_output=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# 优先使用三点diff(找合并基线),失败时回退到两点diff(兼容tar.gz checkout + git init的CI环境)
|
||||
diff_args = ["git", "diff", "--name-only", "--diff-filter=A", f"{diff_target}...HEAD"]
|
||||
result = subprocess.run(
|
||||
diff_args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# fallback: 两点diff(无需共同祖先)
|
||||
diff_args_2 = ["git", "diff", "--name-only", "--diff-filter=A", diff_target, "HEAD"]
|
||||
result = subprocess.run(
|
||||
diff_args_2,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(REPO_ROOT),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" (git diff 失败:{result.stderr.strip()})")
|
||||
return None
|
||||
|
||||
new_migrations = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("alembic/versions/") and line.endswith(".py"):
|
||||
new_migrations.append(REPO_ROOT / line)
|
||||
|
||||
new_migrations.sort()
|
||||
print(f" (git diff 对比 {diff_target},发现 {len(new_migrations)} 个新增迁移)")
|
||||
return new_migrations
|
||||
except Exception as e:
|
||||
print(f" (git diff 方式失败:{e})")
|
||||
return None
|
||||
|
||||
|
||||
def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
|
||||
"""
|
||||
找出相对目标分支新增的迁移文件,按优先级尝试多种方式:
|
||||
1. Gitea/GitHub Contents API(最可靠,不受本地 checkout 深度影响)
|
||||
2. git diff(API 失败时的兜底)
|
||||
3. 全量扫描(以上都失败时的最后兜底,会输出警告)
|
||||
"""
|
||||
print("🔍 尝试通过 API 获取新增迁移列表...")
|
||||
result = get_new_migrations_via_api(diff_target)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
print("🔍 API 不可用,尝试 git diff 方式...")
|
||||
result = get_new_migrations_via_git(diff_target)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
print("⚠️ 所有增量方式均失败,降级为检查所有迁移文件")
|
||||
print(" 这可能导致历史迁移中的破坏性操作被误报")
|
||||
print(" 建议检查 CI 环境变量配置(GITHUB_API_URL / GITHUB_REPOSITORY / GITHUB_TOKEN)")
|
||||
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
|
||||
|
||||
|
||||
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
|
||||
|
||||
@@ -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()
|
||||
@@ -54,38 +54,38 @@ echo ""
|
||||
echo "Image pushed: ${IMAGE_TAG}"
|
||||
echo "Local cache updated"
|
||||
|
||||
echo ""
|
||||
echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||
CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||
|
||||
MAX_RETRIES=3
|
||||
SUCCESS=0
|
||||
for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||
if docker buildx build \
|
||||
$BUILD_ARGS \
|
||||
--cache-from "${CACHE_FROM_LOCAL}" \
|
||||
--cache-to "${CACHE_TO_REGISTRY}" \
|
||||
-f "${DOCKERFILE}" \
|
||||
-t "${IMAGE_TAG}" \
|
||||
--push \
|
||||
.; then
|
||||
echo "Registry cache synced (attempt $attempt)"
|
||||
SUCCESS=1
|
||||
break
|
||||
else
|
||||
echo "Registry cache sync failed (attempt $attempt)"
|
||||
if [ $attempt -lt $MAX_RETRIES ]; then
|
||||
WAIT=$((attempt * 5))
|
||||
echo "Retrying in ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $SUCCESS -eq 0 ]; then
|
||||
echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||
fi
|
||||
# DISABLED: registry cache too slow echo ""
|
||||
# DISABLED: registry cache too slow echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
|
||||
# DISABLED: registry cache too slow CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
|
||||
# DISABLED: registry cache too slow
|
||||
# DISABLED: registry cache too slow MAX_RETRIES=3
|
||||
# DISABLED: registry cache too slow SUCCESS=0
|
||||
# DISABLED: registry cache too slow for attempt in $(seq 1 $MAX_RETRIES); do
|
||||
# DISABLED: registry cache too slow echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
|
||||
# DISABLED: registry cache too slow if docker buildx build \
|
||||
# DISABLED: registry cache too slow $BUILD_ARGS \
|
||||
# DISABLED: registry cache too slow --cache-from "${CACHE_FROM_LOCAL}" \
|
||||
# DISABLED: registry cache too slow --cache-to "${CACHE_TO_REGISTRY}" \
|
||||
# DISABLED: registry cache too slow -f "${DOCKERFILE}" \
|
||||
# DISABLED: registry cache too slow -t "${IMAGE_TAG}" \
|
||||
# DISABLED: registry cache too slow --push \
|
||||
# DISABLED: registry cache too slow .; then
|
||||
# DISABLED: registry cache too slow echo "Registry cache synced (attempt $attempt)"
|
||||
# DISABLED: registry cache too slow SUCCESS=1
|
||||
# DISABLED: registry cache too slow break
|
||||
# DISABLED: registry cache too slow else
|
||||
# DISABLED: registry cache too slow echo "Registry cache sync failed (attempt $attempt)"
|
||||
# DISABLED: registry cache too slow if [ $attempt -lt $MAX_RETRIES ]; then
|
||||
# DISABLED: registry cache too slow WAIT=$((attempt * 5))
|
||||
# DISABLED: registry cache too slow echo "Retrying in ${WAIT}s..."
|
||||
# DISABLED: registry cache too slow sleep $WAIT
|
||||
# DISABLED: registry cache too slow fi
|
||||
# DISABLED: registry cache too slow fi
|
||||
# DISABLED: registry cache too slow done
|
||||
# DISABLED: registry cache too slow
|
||||
# DISABLED: registry cache too slow if [ $SUCCESS -eq 0 ]; then
|
||||
# DISABLED: registry cache too slow echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
|
||||
# DISABLED: registry cache too slow fi
|
||||
|
||||
echo ""
|
||||
echo "Build completed: ${IMAGE_TAG}"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# mypy增é‡�扫æ��脚本 - CIä¸è°ƒç”¨
|
||||
# 环境��: SCAN_MODE, CHANGED_PY_FILES
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Installing mypy ==="
|
||||
python3 -m pip install -q mypy
|
||||
mypy --version
|
||||
echo ""
|
||||
echo "=== Running mypy type check (hard gate mode) ==="
|
||||
echo "å‘Šè¦æ¨¡å¼�,ä¸Í阻æ–CI"
|
||||
echo ""
|
||||
|
||||
MYPY_COMMON_ARGS="--ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude tests/|test_|migrations/|alembic/ --no-error-summary --incremental --cache-dir .mypy_cache"
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
if [ "$SCAN_MODE" = "incremental" ] && [ -n "$CHANGED_PY_FILES" ]; then
|
||||
echo "=== Incremental mypy scan (PR mode) ==="
|
||||
echo "Changed files: $(echo $CHANGED_PY_FILES | wc -w) files"
|
||||
MYPY_FILES=""
|
||||
for f in $CHANGED_PY_FILES; do
|
||||
case "$f" in
|
||||
apps/*|packages/*)
|
||||
MYPY_FILES="$MYPY_FILES $f"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$MYPY_FILES" ]; then
|
||||
echo "Checking: $MYPY_FILES"
|
||||
mypy $MYPY_FILES $MYPY_COMMON_ARGS 2>&1 | head -80 || EXIT_CODE=$?
|
||||
else
|
||||
echo "No mypy-checkable files changed, skipping"
|
||||
fi
|
||||
else
|
||||
echo "=== Full mypy scan ==="
|
||||
mypy apps/api/app packages $MYPY_COMMON_ARGS 2>&1 | head -60 || EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$EXIT_CODE" != "0" ]; then
|
||||
echo "mypy å�‘çŽ°ç±»åž‹é—®é¢˜ï¼ˆå‘Šè¦æ¨¡å¼�,ä¸Í阻æ–)"
|
||||
echo "建议å�Žç»é€�æ¥ä¿®å¤�"
|
||||
else
|
||||
echo "mypy 类型检查通过"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Code Review Script
|
||||
- 从 Gitea 获取 PR diff
|
||||
- 调用 LLM 进行代码审查
|
||||
- 将审查结果写回 PR 评论
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
# ============== 日志配置 ==============
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("ci_code_review")
|
||||
|
||||
|
||||
# ============== 常量配置 ==============
|
||||
# diff 最大字符数(超过则截断)
|
||||
MAX_DIFF_CHARS = int(os.getenv("MAX_DIFF_CHARS", "30000"))
|
||||
# LLM 调用超时时间(秒)
|
||||
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120"))
|
||||
# Gitea API 超时时间(秒)
|
||||
GITEA_TIMEOUT = int(os.getenv("GITEA_TIMEOUT", "30"))
|
||||
# 最大重试次数
|
||||
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "2"))
|
||||
# LLM 提供商: openai (OpenAI兼容) / coze (扣子原生Bot API)
|
||||
LLM_PROVIDER = os.getenv("LLM_PROVIDER", "coze").lower()
|
||||
|
||||
|
||||
# ============== 工具函数 ==============
|
||||
def truncate_diff(diff_text: str, max_chars: int) -> Tuple[str, bool]:
|
||||
"""
|
||||
截断过大的 diff 内容,避免超出 LLM 上下文限制。
|
||||
优先保留文件头和前面的变更,末尾加提示。
|
||||
"""
|
||||
if len(diff_text) <= max_chars:
|
||||
return diff_text, False
|
||||
|
||||
# 找到一个合适的截断位置(尽量在文件边界)
|
||||
truncated = diff_text[:max_chars]
|
||||
# 尝试在最后一个 "diff --git" 处截断,避免截断到一半
|
||||
last_file_boundary = truncated.rfind("\ndiff --git ")
|
||||
if last_file_boundary > max_chars // 2:
|
||||
truncated = truncated[:last_file_boundary]
|
||||
|
||||
truncated += (
|
||||
f"\n\n... [DIFF TRUNCATED] 原始 diff 共 {len(diff_text)} 字符,"
|
||||
f"已截断至 {len(truncated)} 字符,仅审查前半部分。\n"
|
||||
)
|
||||
return truncated, True
|
||||
|
||||
|
||||
def get_env_or_fail(name: str) -> str:
|
||||
"""从环境变量获取值,不存在则报错退出。"""
|
||||
value = os.getenv(name)
|
||||
if not value:
|
||||
logger.error(f"环境变量 {name} 未设置")
|
||||
sys.exit(1)
|
||||
return value
|
||||
|
||||
|
||||
# ============== Gitea API 相关 ==============
|
||||
class GiteaClient:
|
||||
"""Gitea API 客户端"""
|
||||
|
||||
def __init__(self, base_url: str, token: str, repo: str):
|
||||
# 确保 base_url 以 / 结尾
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.token = token
|
||||
self.repo = repo # 格式: owner/repo
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def _api_url(self, path: str) -> str:
|
||||
"""拼接 API 路径"""
|
||||
return f"{self.base_url}api/v1/repos/{self.repo}/{path.lstrip('/')}"
|
||||
|
||||
def get_pr_diff(self, pr_number: int) -> str:
|
||||
"""
|
||||
获取 PR 的 diff 内容。
|
||||
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}.diff
|
||||
"""
|
||||
url = self._api_url(f"pulls/{pr_number}.diff")
|
||||
logger.info(f"获取 PR #{pr_number} diff: {url}")
|
||||
|
||||
resp = self.session.get(
|
||||
url,
|
||||
timeout=GITEA_TIMEOUT,
|
||||
headers={
|
||||
"Accept": "text/plain",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"获取 diff 失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
raise RuntimeError(f"Failed to get PR diff: HTTP {resp.status_code}")
|
||||
|
||||
diff_text = resp.text
|
||||
logger.info(f"获取到 diff,共 {len(diff_text)} 字符")
|
||||
return diff_text
|
||||
|
||||
def get_pr_files(self, pr_number: int) -> list:
|
||||
"""
|
||||
获取 PR 修改的文件列表。
|
||||
Gitea API: GET /repos/{owner}/{repo}/pulls/{index}/files
|
||||
"""
|
||||
url = self._api_url(f"pulls/{pr_number}/files")
|
||||
logger.info(f"获取 PR #{pr_number} 文件列表")
|
||||
|
||||
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"获取文件列表失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
files = resp.json()
|
||||
logger.info(f"PR 修改了 {len(files)} 个文件")
|
||||
return files
|
||||
|
||||
def post_pr_comment(self, pr_number: int, body: str) -> bool:
|
||||
"""
|
||||
在 PR 上发布评论。
|
||||
Gitea API: POST /repos/{owner}/{repo}/issues/{index}/comments
|
||||
(Gitea 中 PR 评论走 issues 接口)
|
||||
"""
|
||||
url = self._api_url(f"issues/{pr_number}/comments")
|
||||
logger.info(f"发布 PR 评论: {url}")
|
||||
|
||||
payload = {"body": body}
|
||||
resp = self.session.post(
|
||||
url,
|
||||
data=json.dumps(payload),
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
logger.error(f"发布评论失败: HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
logger.info(f"评论发布成功,评论 ID: {resp.json().get('id', 'unknown')}")
|
||||
return True
|
||||
|
||||
def get_existing_review_comments(self, pr_number: int, marker: str) -> list:
|
||||
"""
|
||||
获取 PR 上已有的 AI 审查评论 ID 列表(带标识 marker)。
|
||||
"""
|
||||
url = self._api_url(f"issues/{pr_number}/comments")
|
||||
resp = self.session.get(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"获取评论列表失败: HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
comments = resp.json()
|
||||
review_comment_ids = []
|
||||
for c in comments:
|
||||
body = c.get("body", "")
|
||||
if marker in body:
|
||||
review_comment_ids.append(c.get("id"))
|
||||
logger.info(f"找到 {len(review_comment_ids)} 条旧的 AI 审查评论")
|
||||
return review_comment_ids
|
||||
|
||||
def delete_pr_comment(self, pr_number: int, comment_id: int) -> bool:
|
||||
"""
|
||||
删除 PR 上的指定评论。
|
||||
"""
|
||||
url = self._api_url(f"issues/comments/{comment_id}")
|
||||
resp = self.session.delete(url, timeout=GITEA_TIMEOUT)
|
||||
if resp.status_code not in (200, 204):
|
||||
logger.warning(f"删除评论 {comment_id} 失败: HTTP {resp.status_code}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def call_llm_openai(
|
||||
prompt: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
) -> Optional[str]:
|
||||
"""OpenAI 兼容模式调用"""
|
||||
base_url = llm_base_url.rstrip("/") + "/"
|
||||
api_url = f"{base_url}chat/completions"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一位严谨的资深代码审查专家,擅长发现代码中的逻辑错误、安全隐患和性能问题。",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2048,
|
||||
}
|
||||
|
||||
logger.info(f"调用 LLM (OpenAI兼容): {api_url}, model={llm_model}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"LLM 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:200]}")
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning(f"LLM 返回空结果 (第 {attempt + 1} 次)")
|
||||
last_error = "empty choices"
|
||||
continue
|
||||
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
if not content.strip():
|
||||
logger.warning(f"LLM 返回空内容 (第 {attempt + 1} 次)")
|
||||
last_error = "empty content"
|
||||
continue
|
||||
|
||||
logger.info(f"LLM 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"LLM 调用超时 (第 {attempt + 1} 次)")
|
||||
last_error = "timeout"
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"LLM 调用异常 (第 {attempt + 1} 次): {e}")
|
||||
last_error = str(e)
|
||||
|
||||
logger.error(f"LLM 调用最终失败: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def call_llm_coze(
|
||||
prompt: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
coze_bot_id: str,
|
||||
) -> Optional[str]:
|
||||
"""扣子(Coze)原生 Bot API 调用(支持异步轮询)"""
|
||||
import time
|
||||
|
||||
base_url = llm_base_url.rstrip("/") + "/"
|
||||
api_url = f"{base_url}v3/chat"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"bot_id": coze_bot_id,
|
||||
"user_id": "ci-code-review-bot",
|
||||
"stream": False,
|
||||
"additional_messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"content_type": "text",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
logger.info(f"调用 LLM (Coze): {api_url}, bot_id={coze_bot_id}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=LLM_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Coze 调用失败 (第 {attempt + 1} 次): " f"HTTP {resp.status_code} - {resp.text[:300]}")
|
||||
last_error = f"HTTP {resp.status_code}"
|
||||
continue
|
||||
|
||||
data = resp.json()
|
||||
chat_data = data.get("data", {})
|
||||
chat_id = chat_data.get("id", "")
|
||||
conversation_id = chat_data.get("conversation_id", "")
|
||||
status = chat_data.get("status", "")
|
||||
|
||||
# Coze v3 API 异步:先返回 in_progress,需要轮询
|
||||
if status == "in_progress" and conversation_id and chat_id:
|
||||
logger.info(f"Coze 异步处理中,开始轮询... (chat_id={chat_id[:12]}...)")
|
||||
# 轮询 message 列表接口(GET + query参数),最多等 LLM_TIMEOUT 秒
|
||||
poll_url = f"{base_url}v3/chat/message/list"
|
||||
poll_start = time.time()
|
||||
poll_interval = 3 # 每3秒轮询一次
|
||||
|
||||
while time.time() - poll_start < LLM_TIMEOUT:
|
||||
time.sleep(poll_interval)
|
||||
poll_params = {
|
||||
"chat_id": chat_id,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
poll_resp = requests.get(
|
||||
poll_url,
|
||||
headers=headers,
|
||||
params=poll_params,
|
||||
timeout=GITEA_TIMEOUT,
|
||||
)
|
||||
if poll_resp.status_code != 200:
|
||||
logger.debug(f"轮询返回 HTTP {poll_resp.status_code}: {poll_resp.text[:100]}")
|
||||
continue
|
||||
|
||||
poll_data = poll_resp.json()
|
||||
if poll_data.get("code", 0) != 0:
|
||||
logger.debug(f"轮询返回错误: {poll_data.get('msg', '')}")
|
||||
continue
|
||||
|
||||
messages = poll_data.get("data", []) or []
|
||||
|
||||
# 找assistant的answer消息
|
||||
content = None
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||
content = msg.get("content", "")
|
||||
break
|
||||
|
||||
if content and content.strip():
|
||||
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
logger.warning(f"Coze 轮询超时 ({LLM_TIMEOUT}s),未拿到结果")
|
||||
last_error = "poll timeout"
|
||||
continue
|
||||
|
||||
# 同步返回的情况(兼容)
|
||||
content = None
|
||||
messages = chat_data.get("messages", []) or data.get("messages", [])
|
||||
for msg in messages:
|
||||
if msg.get("role") == "assistant" and msg.get("type") == "answer":
|
||||
content = msg.get("content", "")
|
||||
break
|
||||
|
||||
if not content:
|
||||
content = chat_data.get("content") or data.get("content")
|
||||
|
||||
if not content:
|
||||
choices = data.get("choices", [])
|
||||
if choices:
|
||||
content = choices[0].get("message", {}).get("content", "")
|
||||
|
||||
if not content or not content.strip():
|
||||
logger.warning(f"Coze 返回空内容 (第 {attempt + 1} 次): {str(data)[:200]}")
|
||||
last_error = "empty content"
|
||||
continue
|
||||
|
||||
logger.info(f"Coze 审查完成,结果长度: {len(content)} 字符")
|
||||
return content
|
||||
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Coze 调用超时 (第 {attempt + 1} 次)")
|
||||
last_error = "timeout"
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Coze 调用异常 (第 {attempt + 1} 次): {e}")
|
||||
last_error = str(e)
|
||||
|
||||
logger.error(f"Coze 调用最终失败: {last_error}")
|
||||
return None
|
||||
|
||||
|
||||
def call_llm_for_review(
|
||||
diff_text: str,
|
||||
pr_number: int,
|
||||
file_list: list,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
llm_model: str,
|
||||
coze_bot_id: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
调用 LLM 进行代码审查,返回审查结果文本。
|
||||
失败时返回 None。
|
||||
根据 LLM_PROVIDER 环境变量选择调用方式。
|
||||
"""
|
||||
prompt = build_review_prompt(diff_text, pr_number, file_list)
|
||||
logger.info(f"Prompt 长度: {len(prompt)} 字符")
|
||||
|
||||
provider = LLM_PROVIDER
|
||||
|
||||
if provider == "coze":
|
||||
return call_llm_coze(prompt, llm_base_url, llm_api_key, llm_model, coze_bot_id)
|
||||
else:
|
||||
# 默认 OpenAI 兼容
|
||||
return call_llm_openai(prompt, llm_base_url, llm_api_key, llm_model)
|
||||
|
||||
|
||||
# ============== 主流程 ==============
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="CI AI 代码审查脚本")
|
||||
parser.add_argument("--pr", type=int, help="PR 编号(也可通过 PR_NUMBER 环境变量)")
|
||||
parser.add_argument("--repo", type=str, help="仓库名 owner/repo(也可通过 REPO_NAME 环境变量)")
|
||||
parser.add_argument("--gitea-url", type=str, help="Gitea 地址(也可通过 GITEA_API_URL 环境变量)")
|
||||
parser.add_argument("--gitea-token", type=str, help="Gitea Token(也可通过 GITEA_TOKEN 环境变量)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只输出审查结果,不发表评论")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 读取配置
|
||||
gitea_url = args.gitea_url or os.getenv("GITEA_API_URL") or os.getenv("GITEA_SERVER_URL")
|
||||
gitea_token = args.gitea_token or os.getenv("GITEA_TOKEN")
|
||||
repo_name = args.repo or os.getenv("REPO_NAME") or os.getenv("GITEA_REPO")
|
||||
pr_number = args.pr or int(os.getenv("PR_NUMBER") or os.getenv("GITEA_PR_NUMBER") or 0)
|
||||
|
||||
llm_base_url = os.getenv("LLM_BASE_URL")
|
||||
llm_api_key = os.getenv("LLM_API_KEY")
|
||||
llm_model = os.getenv("LLM_MODEL", "")
|
||||
coze_bot_id = os.getenv("COZE_BOT_ID", os.getenv("COZE_BOTID", ""))
|
||||
|
||||
# 根据 provider 设置默认值
|
||||
provider = LLM_PROVIDER
|
||||
if provider == "coze":
|
||||
# 扣子模式:默认国内站,key 兼容多种环境变量名
|
||||
if not llm_base_url:
|
||||
llm_base_url = "https://api.coze.cn"
|
||||
if not llm_api_key:
|
||||
llm_api_key = os.getenv("COZE_API_KEY", "") or os.getenv("COZE_PAT", "")
|
||||
else:
|
||||
# OpenAI兼容模式:默认模型
|
||||
if not llm_model:
|
||||
llm_model = "gpt-4o-mini"
|
||||
|
||||
# 必要参数校验
|
||||
missing = []
|
||||
if not gitea_url:
|
||||
missing.append("GITEA_API_URL")
|
||||
if not gitea_token:
|
||||
missing.append("GITEA_TOKEN")
|
||||
if not repo_name:
|
||||
missing.append("REPO_NAME")
|
||||
if not pr_number:
|
||||
missing.append("PR_NUMBER")
|
||||
if not llm_base_url:
|
||||
missing.append("LLM_BASE_URL")
|
||||
if not llm_api_key:
|
||||
missing.append("LLM_API_KEY")
|
||||
if provider == "coze" and not coze_bot_id:
|
||||
missing.append("COZE_BOT_ID (扣子模式需要)")
|
||||
|
||||
if missing:
|
||||
logger.error(f"缺少必要配置: {', '.join(missing)}")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"开始审查 PR #{pr_number},仓库: {repo_name}")
|
||||
logger.info(f"Gitea: {gitea_url}")
|
||||
logger.info(f"LLM: {llm_base_url} (model={llm_model})")
|
||||
|
||||
try:
|
||||
# 1. 初始化 Gitea 客户端
|
||||
gitea = GiteaClient(gitea_url, gitea_token, repo_name)
|
||||
|
||||
# 2. 获取 PR diff 和文件列表
|
||||
try:
|
||||
diff_text = gitea.get_pr_diff(pr_number)
|
||||
file_list = gitea.get_pr_files(pr_number)
|
||||
except Exception as e:
|
||||
logger.error(f"获取 PR 信息失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. 过滤掉不需要审查的文件(如 lock 文件、生成的文件、二进制文件等)
|
||||
skip_extensions = (
|
||||
".lock",
|
||||
".sum",
|
||||
".min.js",
|
||||
".min.css",
|
||||
".map",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".svg",
|
||||
".ico",
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".eot",
|
||||
)
|
||||
skipped_files = []
|
||||
if file_list:
|
||||
skipped_files = [
|
||||
f.get("filename")
|
||||
for f in file_list
|
||||
if f.get("filename", "").endswith(skip_extensions) or f.get("status") == "removed"
|
||||
]
|
||||
if skipped_files:
|
||||
logger.info(f"跳过 {len(skipped_files)} 个非文本/已删除文件: {', '.join(skipped_files[:5])}...")
|
||||
|
||||
# 实际从 diff 中移除跳过的文件(按文件边界切割)
|
||||
if skipped_files:
|
||||
diff_lines = diff_text.split("\n")
|
||||
filtered_lines = []
|
||||
current_file = None
|
||||
skip_current = False
|
||||
i = 0
|
||||
while i < len(diff_lines):
|
||||
line = diff_lines[i]
|
||||
# 检测新文件开始: diff --git a/xxx b/xxx
|
||||
if line.startswith("diff --git "):
|
||||
# 提取文件名
|
||||
parts = line.split(" ")
|
||||
if len(parts) >= 4:
|
||||
# b/ 后面的是目标文件名
|
||||
current_file = parts[3][2:] if parts[3].startswith("b/") else parts[3]
|
||||
skip_current = any(current_file == sf for sf in skipped_files) or any(
|
||||
current_file.endswith(ext) for ext in skip_extensions
|
||||
)
|
||||
else:
|
||||
skip_current = False
|
||||
if not skip_current:
|
||||
filtered_lines.append(line)
|
||||
i += 1
|
||||
original_len = len(diff_text)
|
||||
diff_text = "\n".join(filtered_lines)
|
||||
logger.info(f"Diff 过滤后: {original_len} -> {len(diff_text)} 字符 (减少 {original_len - len(diff_text)})")
|
||||
|
||||
# 4. 截断过大的 diff
|
||||
diff_text, was_truncated = truncate_diff(diff_text, MAX_DIFF_CHARS)
|
||||
if was_truncated:
|
||||
logger.warning(f"Diff 过大,已截断至 {len(diff_text)} 字符")
|
||||
|
||||
# 5. 如果 diff 为空,直接跳过
|
||||
if not diff_text.strip():
|
||||
logger.info("Diff 为空,无需审查")
|
||||
sys.exit(0)
|
||||
|
||||
# 6. 调用 LLM 审查
|
||||
review_result = call_llm_for_review(
|
||||
diff_text=diff_text,
|
||||
pr_number=pr_number,
|
||||
file_list=file_list,
|
||||
llm_base_url=llm_base_url,
|
||||
llm_api_key=llm_api_key,
|
||||
llm_model=llm_model,
|
||||
coze_bot_id=coze_bot_id,
|
||||
)
|
||||
|
||||
if not review_result:
|
||||
logger.error("LLM 审查失败")
|
||||
sys.exit(1)
|
||||
|
||||
# 7. 加上审查时间和标识(便于识别是自动审查)
|
||||
from datetime import datetime
|
||||
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
marker = "<!-- AI_CODE_REVIEW_AUTO_COMMENT -->"
|
||||
full_comment = f"""{review_result}
|
||||
|
||||
---
|
||||
<sub>🤖 由 AI 代码审查机器人自动生成 | {timestamp} | 模型: {llm_model}</sub>
|
||||
|
||||
{marker}
|
||||
"""
|
||||
|
||||
# 8. 输出审查结果到日志
|
||||
logger.info("=" * 60)
|
||||
logger.info("审查结果:")
|
||||
for line in review_result.split("\n")[:30]:
|
||||
logger.info(line)
|
||||
if len(review_result.split("\n")) > 30:
|
||||
logger.info(f"... 共 {len(review_result.split(chr(10)))} 行")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 9. 发布评论(先删除旧的审查评论,避免刷屏)
|
||||
if args.dry_run:
|
||||
logger.info("--dry-run 模式,跳过发布评论")
|
||||
print(full_comment)
|
||||
else:
|
||||
# 去重:删除之前的 AI 审查评论
|
||||
old_comments = gitea.get_existing_review_comments(pr_number, marker)
|
||||
if old_comments:
|
||||
logger.info(f"找到 {len(old_comments)} 条旧的 AI 审查评论,先删除")
|
||||
for cid in old_comments:
|
||||
gitea.delete_pr_comment(pr_number, cid)
|
||||
# 发布新评论
|
||||
success = gitea.post_pr_comment(pr_number, full_comment)
|
||||
if not success:
|
||||
logger.error("评论发布失败")
|
||||
sys.exit(1)
|
||||
|
||||
# 10. 判断是否有严重问题(可选阻断)
|
||||
# 目前只做建议,不阻断合并,始终返回 0
|
||||
has_critical = "问题" in review_result and ("❌" in review_result or "需修改" in review_result)
|
||||
if has_critical:
|
||||
logger.warning("检测到需修改的问题,但当前配置为仅建议,不阻断合并")
|
||||
|
||||
logger.info("代码审查完成")
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"审查脚本发生未预期的异常: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+208
-19
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# ===========================================
|
||||
# Staging 部署脚本(SSH 模式)
|
||||
# Staging 部署脚本(SSH 模式,支持自动回滚)
|
||||
# ===========================================
|
||||
# 通过 SSH 在 staging 服务器上执行
|
||||
#
|
||||
@@ -12,9 +12,44 @@
|
||||
# ENV_FILE - 环境变量文件路径
|
||||
# GENERATED_DIR - 生成文件目录
|
||||
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false)
|
||||
# SKIP_ROLLBACK - 失败时跳过自动回滚(true/false,默认 false)
|
||||
|
||||
set -eu
|
||||
|
||||
# ---- 重试工具函数 ----
|
||||
retry_cmd() {
|
||||
local max_attempts=$1
|
||||
local backoff=$2
|
||||
shift 2
|
||||
local attempt=1
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
echo " attempt $attempt/$max_attempts failed, retrying in ${backoff}s..."
|
||||
sleep $backoff
|
||||
backoff=$((backoff * 2))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo " ERROR: failed after $max_attempts retries"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_docker_login() {
|
||||
echo "Logging in to registry (up to 3 retries)"
|
||||
if retry_cmd 3 5 sh -c "printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin"; then
|
||||
return 0
|
||||
fi
|
||||
echo "WARN: docker login failed after retries, will try pull anyway"
|
||||
return 0
|
||||
}
|
||||
|
||||
retry_docker_pull() {
|
||||
local image=$1
|
||||
echo "Pulling $image (up to 3 retries)"
|
||||
retry_cmd 3 10 docker pull "$image"
|
||||
}
|
||||
|
||||
IMAGE_TAG="${IMAGE_TAG:-}"
|
||||
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
|
||||
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
|
||||
@@ -25,6 +60,7 @@ GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
|
||||
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
|
||||
|
||||
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
|
||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||
|
||||
if [ -z "$IMAGE_TAG" ]; then
|
||||
echo "ERROR: IMAGE_TAG is required"
|
||||
@@ -35,17 +71,165 @@ test -f "$ENV_FILE"
|
||||
mkdir -p "$GENERATED_DIR"
|
||||
mkdir -p "$LEGACY_ASSETS_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo "==========================================="
|
||||
echo " Staging 部署 - $IMAGE_TAG"
|
||||
echo "=========================================="
|
||||
echo "==========================================="
|
||||
|
||||
# ---- 记录当前运行的镜像版本(用于回滚) ----
|
||||
echo "Recording current image versions for rollback..."
|
||||
PREV_API_IMAGE=""
|
||||
PREV_WORKER_IMAGE=""
|
||||
PREV_WEB_IMAGE=""
|
||||
for c in xiaoxia-api-staging xiaoxia-worker-staging xiaoxia-web-staging; do
|
||||
if docker inspect "$c" >/dev/null 2>&1; then
|
||||
img=$(docker inspect -f '{{.Config.Image}}' "$c")
|
||||
case "$c" in
|
||||
xiaoxia-api-staging) PREV_API_IMAGE="$img" ;;
|
||||
xiaoxia-worker-staging) PREV_WORKER_IMAGE="$img" ;;
|
||||
xiaoxia-web-staging) PREV_WEB_IMAGE="$img" ;;
|
||||
esac
|
||||
echo " $c -> $img"
|
||||
else
|
||||
echo " $c -> (not running)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ---- 回滚函数 ----
|
||||
rollback() {
|
||||
echo ""
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo " 部署失败,正在自动回滚到上一版本..."
|
||||
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
||||
echo ""
|
||||
|
||||
if [ "$SKIP_ROLLBACK" = "true" ]; then
|
||||
echo "SKIP_ROLLBACK=true,跳过自动回滚"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止当前(失败的)新容器
|
||||
echo "Stopping new containers..."
|
||||
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
|
||||
|
||||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||||
|
||||
# 恢复 API
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Rolling back API to: $PREV_API_IMAGE"
|
||||
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="$(echo $PREV_API_IMAGE | grep -oE '[^:]+$')" \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--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 \
|
||||
"$PREV_API_IMAGE"
|
||||
else
|
||||
echo "No previous API image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Worker
|
||||
if [ -n "$PREV_WORKER_IMAGE" ]; then
|
||||
echo "Rolling back Worker to: $PREV_WORKER_IMAGE"
|
||||
docker run -d \
|
||||
--name xiaoxia-worker-staging \
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$(echo $PREV_WORKER_IMAGE | grep -oE '[^:]+$')" \
|
||||
-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://staging-api.xiaoxiajianji.com \
|
||||
-v "$GENERATED_DIR:/app/generated" \
|
||||
--restart unless-stopped \
|
||||
--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 \
|
||||
"$PREV_WORKER_IMAGE"
|
||||
else
|
||||
echo "No previous Worker image to roll back to"
|
||||
fi
|
||||
|
||||
# 恢复 Web
|
||||
if [ -n "$PREV_WEB_IMAGE" ]; then
|
||||
echo "Rolling back Web to: $PREV_WEB_IMAGE"
|
||||
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"
|
||||
fi
|
||||
docker run -d \
|
||||
--name xiaoxia-web-staging \
|
||||
--network xiaoxia-net-staging \
|
||||
-p 127.0.0.1:3001:80 \
|
||||
--restart unless-stopped \
|
||||
$LEGACY_VOLUME \
|
||||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||||
--health-interval 30s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$PREV_WEB_IMAGE"
|
||||
else
|
||||
echo "No previous Web image to roll back to"
|
||||
fi
|
||||
|
||||
# 等待 API 回滚后恢复健康
|
||||
if [ -n "$PREV_API_IMAGE" ]; then
|
||||
echo "Waiting for rolled-back API to become healthy..."
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
|
||||
echo "Rolled-back API is healthy!"
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
echo " Waiting... ($i/40)"
|
||||
sleep 3
|
||||
done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "WARN: Rolled-back API did not become healthy within 120s"
|
||||
docker logs --tail 30 xiaoxia-api-staging
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==========================================="
|
||||
echo " 回滚完成"
|
||||
echo "==========================================="
|
||||
echo "Previous API: ${PREV_API_IMAGE:-none}"
|
||||
echo "Previous Worker: ${PREV_WORKER_IMAGE:-none}"
|
||||
echo "Previous Web: ${PREV_WEB_IMAGE:-none}"
|
||||
echo ""
|
||||
echo "部署失败,已自动回滚到上一版本"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- 登录 Registry ----
|
||||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||||
echo "Logging in to registry: $REGISTRY"
|
||||
echo "=========================================="
|
||||
echo " Login to Registry (with retries)"
|
||||
echo "=========================================="
|
||||
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"
|
||||
}
|
||||
retry_docker_login
|
||||
fi
|
||||
|
||||
# ---- Pull 新版本镜像 ----
|
||||
@@ -57,12 +241,12 @@ 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"
|
||||
echo "=========================================="
|
||||
echo " Pull images (with retries)"
|
||||
echo "=========================================="
|
||||
retry_docker_pull "$REGISTRY_API"
|
||||
retry_docker_pull "$REGISTRY_WORKER"
|
||||
retry_docker_pull "$REGISTRY_WEB"
|
||||
|
||||
# Re-tag 成本地名
|
||||
docker tag "$REGISTRY_API" "$LOCAL_API"
|
||||
@@ -117,7 +301,12 @@ if [ "$SKIP_MIGRATION" != "true" ]; then
|
||||
--env-file "$ENV_FILE" \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
|
||||
"$LOCAL_API" sh -c "cd /app && alembic upgrade head" || {
|
||||
echo "ERROR: Database migration failed"
|
||||
echo "Note: Migration failures are NOT automatically rolled back (data safety)"
|
||||
echo "Please manually check and fix the migration, then redeploy"
|
||||
exit 1
|
||||
}
|
||||
echo "Migrations completed."
|
||||
else
|
||||
echo "Skipping migrations (SKIP_MIGRATION=true)"
|
||||
@@ -151,7 +340,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_API"
|
||||
"$LOCAL_API" || rollback
|
||||
|
||||
# ---- 启动 Worker ----
|
||||
echo "Starting Worker container..."
|
||||
@@ -174,7 +363,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 30s \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WORKER"
|
||||
"$LOCAL_WORKER" || rollback
|
||||
|
||||
# ---- 启动 Web ----
|
||||
LEGACY_VOLUME=""
|
||||
@@ -197,7 +386,7 @@ docker run -d \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$LOCAL_WEB"
|
||||
"$LOCAL_WEB" || rollback
|
||||
|
||||
# ---- 等待 API 健康 ----
|
||||
echo "Waiting for API to become healthy..."
|
||||
@@ -215,7 +404,7 @@ done
|
||||
if [ "$i" -ge 40 ]; then
|
||||
echo "ERROR: API did not become healthy within 120s"
|
||||
docker logs --tail 50 xiaoxia-api-staging
|
||||
exit 1
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 等待 Web 健康 ----
|
||||
@@ -234,7 +423,7 @@ done
|
||||
if [ "$i" -ge 15 ]; then
|
||||
echo "ERROR: Web did not become healthy within 30s"
|
||||
docker logs --tail 30 xiaoxia-web-staging
|
||||
exit 1
|
||||
rollback
|
||||
fi
|
||||
|
||||
# ---- 清理旧镜像 ----
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI触发可靠性监控 - 定时检查PR的CI触发状态
|
||||
- 监控open PR的最新commit是否在5分钟内触发了CI
|
||||
- 异常时通过飞书webhook告警
|
||||
|
||||
环境变量:
|
||||
GITEA_API_TOKEN - Gitea API Token (必填)
|
||||
GITEA_REPO - 仓库路径,如 xiaoxia/xiaoxia-saas
|
||||
GITEA_URL - Gitea地址,如 https://git.xiaoxiajianji.com
|
||||
CI_NOTIFY_WEBHOOK - 飞书告警webhook (必填)
|
||||
CHECK_INTERVAL_MIN - 检查间隔(分钟),默认5
|
||||
STALE_THRESHOLD_MIN - CI未触发告警阈值(分钟),默认5
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def get_env(name, default=""):
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def api_get(path):
|
||||
"""调用Gitea API"""
|
||||
token = get_env("GITEA_API_TOKEN")
|
||||
base_url = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
repo = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
|
||||
url = f"{base_url}/api/v1/repos/{repo}{path}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code >= 500 and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def get_open_prs():
|
||||
"""获取所有open PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = api_get(f"/pulls?state=open&sort=updated&direction=desc&limit=50&page={page}")
|
||||
if not batch:
|
||||
break
|
||||
prs.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(sha):
|
||||
"""获取commit的CI状态"""
|
||||
try:
|
||||
return api_get(f"/commits/{sha}/status")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 获取commit状态失败: {e}")
|
||||
return {"state": "error", "statuses": []}
|
||||
|
||||
|
||||
def has_ci_started(statuses):
|
||||
"""判断是否有CI job已经启动(pending/running/success/failure都算启动了)"""
|
||||
pr_statuses = [s for s in statuses if "pull_request" in s.get("context", "")]
|
||||
if not pr_statuses:
|
||||
return False
|
||||
# 只要有非pending且非空的状态,就算启动了
|
||||
for s in pr_statuses:
|
||||
if s.get("status") in ["success", "failure", "running"]:
|
||||
return True
|
||||
if s.get("status") == "pending" and "Has started running" in s.get("description", ""):
|
||||
return True
|
||||
# 全是"Blocked by required conditions"的pending也算(说明CI系统收到了事件)
|
||||
for s in pr_statuses:
|
||||
if "Blocked" in s.get("description", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def send_alert(pr_num, pr_title, pr_url, head_sha, commit_age_min):
|
||||
"""发送飞书告警"""
|
||||
webhook = get_env("CI_NOTIFY_WEBHOOK")
|
||||
if not webhook:
|
||||
print(" ⚠️ 未配置CI_NOTIFY_WEBHOOK,跳过告警")
|
||||
return
|
||||
|
||||
gitea_url = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
|
||||
content = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"⚠️ CI告警 - PR#{pr_num} CI未触发"},
|
||||
"template": "red",
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**PR**: [{pr_title}]({pr_url})\n**最新commit**: `{head_sha[:12]}`\n**已等待**: {commit_age_min:.0f} 分钟仍无CI启动\n**可能原因**: Gitea Actions事件丢失 / Webhook失败 / Runner资源不足",
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看PR"},
|
||||
"url": pr_url,
|
||||
"type": "primary",
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看Actions"},
|
||||
"url": f"{pr_url}/files",
|
||||
"type": "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [
|
||||
{"tag": "plain_text", "content": f"CI触发监控 | 检测时间: {time.strftime('%Y-%m-%d %H:%M:%S')}"}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
data = json.dumps(content).encode()
|
||||
req = urllib.request.Request(webhook, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
print(f" 📢 告警已发送: PR#{pr_num}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 告警发送失败: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
stale_threshold = int(get_env("STALE_THRESHOLD_MIN", "5"))
|
||||
|
||||
print("=" * 60)
|
||||
print(f"CI触发监控 - 检测时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"告警阈值: {stale_threshold}分钟无CI启动")
|
||||
print("=" * 60)
|
||||
|
||||
# 获取open PR列表
|
||||
try:
|
||||
prs = get_open_prs()
|
||||
except Exception as e:
|
||||
print(f"❌ 获取PR列表失败: {e}")
|
||||
sys.exit(0) # 告警脚本不阻断CI
|
||||
|
||||
print(f"\n共 {len(prs)} 个open PR\n")
|
||||
|
||||
stale_prs = []
|
||||
now = time.time()
|
||||
|
||||
for pr in prs:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
pr_url = pr["html_url"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
updated_at = pr["updated_at"]
|
||||
|
||||
# 解析updated_at(ISO格式)
|
||||
try:
|
||||
# 2026-07-17T09:22:43+08:00
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 简化处理:直接用字符串解析
|
||||
ts_str = updated_at.replace("Z", "+00:00")
|
||||
# 手动解析
|
||||
dt = datetime.fromisoformat(ts_str)
|
||||
commit_time = dt.timestamp()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ PR#{pr_num} 时间解析失败: {e}")
|
||||
continue
|
||||
|
||||
age_min = (now - commit_time) / 60
|
||||
|
||||
print(f"PR#{pr_num:3d} | {pr_title[:45]:45s} | 更新于 {age_min:.0f}min前")
|
||||
|
||||
# 少于2分钟的跳过,给CI一点启动时间
|
||||
if age_min < 2:
|
||||
print(f" ⏳ 刚更新,等待CI启动...")
|
||||
continue
|
||||
|
||||
# 获取commit状态
|
||||
status = get_commit_status(head_sha)
|
||||
statuses = status.get("statuses", [])
|
||||
|
||||
if has_ci_started(statuses):
|
||||
print(f" ✅ CI已启动 (state={status.get('state')})")
|
||||
continue
|
||||
|
||||
# CI未启动,判断是否超过阈值
|
||||
if age_min >= stale_threshold:
|
||||
print(f" 🚨 CI未触发!已等待 {age_min:.0f} 分钟")
|
||||
stale_prs.append({"num": pr_num, "title": pr_title, "url": pr_url, "sha": head_sha, "age_min": age_min})
|
||||
else:
|
||||
print(f" ⏳ CI尚未启动 ({age_min:.0f}min < {stale_threshold}min阈值)")
|
||||
|
||||
# 发送告警
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"检测结果: {len(stale_prs)} 个PR CI未触发超过阈值")
|
||||
|
||||
if stale_prs:
|
||||
print("\n告警列表:")
|
||||
for pr in stale_prs:
|
||||
print(f" - PR#{pr['num']}: {pr['title'][:40]} ({pr['age_min']:.0f}min)")
|
||||
send_alert(pr["num"], pr["title"], pr["url"], pr["sha"], pr["age_min"])
|
||||
else:
|
||||
print("✅ 所有PR CI触发正常")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,491 @@
|
||||
"""
|
||||
片段调整 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- PUT /clips/{clip_id}/speed - 调速
|
||||
- PUT /clips/{clip_id}/volume - 音量调节
|
||||
- PUT /clips/{clip_id}/trim - 裁剪
|
||||
- PUT /clips/{clip_id}/adjustments - 统一调整
|
||||
- POST /{plan_id}/clips/batch-speed - 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
return list(self._plans.values())[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
return len(self._plans)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return len([c for c in self._clips.values() if c.plan_id == plan_id])
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return self._clips.pop(clip_id, None) is not None
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
before = len(self._clips)
|
||||
self._clips = {k: v for k, v in self._clips.items() if v.plan_id != plan_id}
|
||||
return before - len(self._clips)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_clip(clip_id, plan_id="plan-001", order=0, duration=10.0, speed=1.0):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type="video",
|
||||
order=order,
|
||||
asset_id="asset-001",
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=duration,
|
||||
transition_effect="cut",
|
||||
transition_duration=0.0,
|
||||
playback_speed=speed,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
clips = {
|
||||
"clip-001": _make_clip("clip-001", order=0, duration=10.0),
|
||||
"clip-002": _make_clip("clip-002", order=1, duration=15.0),
|
||||
"clip-003": _make_clip("clip-003", order=2, duration=20.0),
|
||||
}
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository(clips)
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
import app.api.routes.edit_plans_adjustments as adj_module
|
||||
|
||||
app.dependency_overrides[adj_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[adj_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[adj_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adj_client():
|
||||
app, plan_repo, clip_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调速测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustSpeed:
|
||||
def test_speed_up(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 2.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 2.0
|
||||
assert data["clip_id"] == "clip-001"
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 2.0
|
||||
|
||||
def test_slow_down(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 0.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 0.5
|
||||
|
||||
def test_speed_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/speed",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_speed_out_of_range_low(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 0.1},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_speed_out_of_range_high(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 5.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_speed_default_value(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
# 验证默认 speed
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 音量调节测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustVolume:
|
||||
def test_set_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 0.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["volume"] == 0.5
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.config["volume"] == 0.5
|
||||
|
||||
def test_mute(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 0.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["volume"] == 0.0
|
||||
|
||||
def test_boost_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 1.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["volume"] == 1.5
|
||||
|
||||
def test_volume_out_of_range(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 3.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_volume_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/volume",
|
||||
json={"volume": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_volume(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 1.0},
|
||||
)
|
||||
data = resp.json()
|
||||
# 默认音量应该是 1.0
|
||||
assert data["volume"] == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 裁剪测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustTrim:
|
||||
def test_trim_start(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 2.0, "trim_end": 0.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 2.0
|
||||
assert data["trim_end"] == 0.0
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.config["trim_start"] == 2.0
|
||||
|
||||
def test_trim_both_ends(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 1.5, "trim_end": 2.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 1.5
|
||||
assert data["trim_end"] == 2.5
|
||||
|
||||
def test_trim_exceeds_duration(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
# 片段时长 10 秒,裁剪 8+3 = 11 > 10
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 8.0, "trim_end": 3.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "不能大于等于片段总时长" in resp.json()["detail"]
|
||||
|
||||
def test_trim_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/trim",
|
||||
json={"trim_start": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_trim_zero(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 1.0},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 0.0
|
||||
assert data["trim_end"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统一调整测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustAll:
|
||||
def test_adjust_speed_and_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"speed": 1.5, "volume": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 1.5
|
||||
assert data["volume"] == 0.8
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 1.5
|
||||
assert clip.config["volume"] == 0.8
|
||||
|
||||
def test_adjust_all_four(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"speed": 2.0, "volume": 0.5, "trim_start": 1.0, "trim_end": 1.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 2.0
|
||||
assert data["volume"] == 0.5
|
||||
assert data["trim_start"] == 1.0
|
||||
assert data["trim_end"] == 1.0
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 2.0
|
||||
assert clip.config["volume"] == 0.5
|
||||
assert clip.config["trim_start"] == 1.0
|
||||
assert clip.config["trim_end"] == 1.0
|
||||
|
||||
def test_adjust_empty_body(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 保持默认值
|
||||
assert data["speed"] == 1.0
|
||||
assert data["volume"] == 1.0
|
||||
|
||||
def test_adjust_trim_exceeds(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"trim_start": 9.0, "trim_end": 2.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_adjust_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/adjustments",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 批量调速测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchSpeed:
|
||||
def test_batch_speed_all(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/clips/batch-speed",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
assert data["plan_id"] == "plan-001"
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.playback_speed == 1.5
|
||||
|
||||
def test_batch_speed_plan_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/clips/batch-speed",
|
||||
json={"speed": 2.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_batch_speed_invalid(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/clips/batch-speed",
|
||||
json={"speed": 10.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -0,0 +1,559 @@
|
||||
"""
|
||||
封面管理 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /{plan_id}/cover - 获取封面配置
|
||||
- PUT /{plan_id}/cover - 更新封面配置
|
||||
- POST /{plan_id}/cover/extract - 从片段抽帧
|
||||
- POST /{plan_id}/cover/smart - 智能选帧
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
self._counter = 100
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
if not plan.id:
|
||||
plan.id = self._next_id()
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
self._counter = 200
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-{self._counter:03d}"
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
return len(items)
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
if clip_id in self._clips:
|
||||
del self._clips[clip_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||||
for cid in to_delete:
|
||||
del self._clips[cid]
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
def __init__(self, assets: dict | None = None):
|
||||
self._assets = assets or {}
|
||||
|
||||
def get(self, asset_id: str):
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
|
||||
class StubStorageService:
|
||||
def __init__(self):
|
||||
self.uploaded = {}
|
||||
self.downloaded = {}
|
||||
|
||||
def upload_file(self, file_or_path, storage_key, content_type="application/octet-stream"):
|
||||
self.uploaded[storage_key] = file_or_path
|
||||
return f"https://oss.example.com/{storage_key}"
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
return f"https://oss.example.com/{storage_key}"
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
self.downloaded[storage_key] = local_path
|
||||
# 创建一个假文件(空文件也可以,因为抽帧会被 mock 掉)
|
||||
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(b"fake video data for testing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001", config=None):
|
||||
if config is None:
|
||||
config = normalize_plan_config({})
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=config,
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_sample_clip(clip_id="clip-001", plan_id="plan-001", asset_id="asset-001", clip_type="video"):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=0,
|
||||
asset_id=asset_id,
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=10.0,
|
||||
transition_effect="none",
|
||||
transition_duration=0.0,
|
||||
playback_speed=1.0,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_cover as cover_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
# 创建 stub
|
||||
plan = _make_sample_plan()
|
||||
clip = _make_sample_clip()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository({clip.id: clip})
|
||||
|
||||
# 替换服务模块中的 Repository 类
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 覆盖依赖
|
||||
app.dependency_overrides[cover_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[cover_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[cover_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
# Mock storage 和 asset repo
|
||||
stub_storage = StubStorageService()
|
||||
stub_asset_repo = StubAssetRepository(
|
||||
{
|
||||
"asset-001": MagicMock(
|
||||
storage_key="videos/test.mp4",
|
||||
mime_type="video/mp4",
|
||||
),
|
||||
"asset-img": MagicMock(
|
||||
storage_key="images/test.jpg",
|
||||
mime_type="image/jpeg",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
app.dependency_overrides[cover_module.get_storage_service] = lambda: stub_storage
|
||||
app.dependency_overrides[cover_module.get_asset_repository] = lambda: stub_asset_repo
|
||||
|
||||
# 也需要覆盖 edit_plans 主模块的 auth(用于其他路由)
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, stub_storage, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cover_client():
|
||||
app, plan_repo, clip_repo, storage, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo, storage
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/cover 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCover:
|
||||
def test_get_default_cover(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/cover")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
assert data["image_url"] == ""
|
||||
assert data["frame_time"] is None
|
||||
|
||||
def test_get_cover_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/cover")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_cover_with_custom_config(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
# 更新 plan 的 cover 配置
|
||||
plan = plan_repo.get("plan-001")
|
||||
new_config = dict(plan.config)
|
||||
new_config["cover"] = {"type": "manual", "image_url": "https://example.com/cover.jpg", "frame_time": 5.5}
|
||||
plan.config = new_config
|
||||
plan_repo.update(plan)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/cover")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["image_url"] == "https://example.com/cover.jpg"
|
||||
assert data["frame_time"] == 5.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/cover 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCover:
|
||||
def test_update_cover_type_and_url(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "upload", "image_url": "https://example.com/uploaded.jpg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "upload"
|
||||
assert data["image_url"] == "https://example.com/uploaded.jpg"
|
||||
|
||||
# 验证存储
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "upload"
|
||||
assert plan.config["cover"]["image_url"] == "https://example.com/uploaded.jpg"
|
||||
|
||||
def test_update_cover_frame_time(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "manual", "frame_time": 3.14},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["frame_time"] == 3.14
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["frame_time"] == 3.14
|
||||
|
||||
def test_update_cover_invalid_type(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "invalid_type"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_cover_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover",
|
||||
json={"type": "upload", "image_url": "test.jpg"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_cover_partial(self, cover_client):
|
||||
"""只更新 image_url,type 保持不变"""
|
||||
c, plan_repo, _, _ = cover_client
|
||||
# 先设置一个类型
|
||||
c.put("/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 2.0})
|
||||
|
||||
# 只更新 image_url
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"image_url": "https://example.com/new.jpg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual" # 保持不变
|
||||
assert data["image_url"] == "https://example.com/new.jpg"
|
||||
assert data["frame_time"] == 2.0 # 保持不变
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/cover/extract 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractCover:
|
||||
def test_extract_success(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
# mock ffmpeg 抽帧,直接创建输出文件
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": 2.5},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["frame_time"] == 2.5
|
||||
assert data["image_url"].startswith("https://oss.example.com/covers/")
|
||||
|
||||
# 验证 plan.config 已更新
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "manual"
|
||||
assert plan.config["cover"]["frame_time"] == 2.5
|
||||
|
||||
def test_extract_clip_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-nonexist", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_extract_plan_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_extract_clip_no_asset(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 创建一个没有 asset 的片段
|
||||
empty_clip = _make_sample_clip(clip_id="clip-empty", asset_id="")
|
||||
clip_repo.create(empty_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-empty", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "没有关联素材" in resp.json()["detail"]
|
||||
|
||||
def test_extract_clip_not_in_plan(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 创建属于另一个 plan 的片段
|
||||
other_clip = _make_sample_clip(clip_id="clip-other", plan_id="plan-other")
|
||||
clip_repo.create(other_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-other", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "不属于该剪辑计划" in resp.json()["detail"]
|
||||
|
||||
def test_extract_negative_frame_time(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": -1.0},
|
||||
)
|
||||
assert resp.status_code == 422 # pydantic 校验失败
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/cover/smart 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSmartCover:
|
||||
def test_smart_cover_with_clip_id(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={"clip_id": "clip-001"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
assert data["image_url"].startswith("https://oss.example.com/covers/")
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_smart_cover_auto_pick_first_video(self, cover_client):
|
||||
c, plan_repo, clip_repo, _ = cover_client
|
||||
# 添加多个片段,第一个视频应该被选中
|
||||
clip2 = _make_sample_clip(clip_id="clip-002", clip_type="audio", asset_id="asset-audio")
|
||||
clip2.order = 1
|
||||
clip_repo.create(clip2)
|
||||
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
|
||||
def test_smart_cover_clip_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={"clip_id": "clip-nonexist"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_smart_cover_no_video_clips(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 删除原有片段,添加纯音频片段
|
||||
clip_repo.delete("clip-001")
|
||||
audio_clip = _make_sample_clip(clip_id="clip-audio", clip_type="audio", asset_id="asset-001")
|
||||
clip_repo.create(audio_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "没有找到可用的视频片段" in resp.json()["detail"]
|
||||
|
||||
def test_smart_cover_plan_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover/smart",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
导出设置 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /export-presets - 导出预设列表
|
||||
- GET /{plan_id}/export - 获取导出配置
|
||||
- PUT /{plan_id}/export - 更新导出配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
return list(self._plans.values())[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
return len(self._plans)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
return []
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return 0
|
||||
|
||||
def get(self, clip_id: str):
|
||||
return None
|
||||
|
||||
def create(self, clip):
|
||||
return clip
|
||||
|
||||
def update(self, clip):
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository()
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
import app.api.routes.edit_plans_export as export_module
|
||||
|
||||
app.dependency_overrides[export_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[export_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def export_client():
|
||||
app, plan_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportPresets:
|
||||
def test_list_all_presets(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/export-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 5
|
||||
assert len(data["items"]) == data["total"]
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "resolution" in first
|
||||
assert "fps" in first
|
||||
assert "video_bitrate" in first
|
||||
assert "format" in first
|
||||
assert "description" in first
|
||||
assert "size_hint" in first
|
||||
|
||||
def test_preset_has_valid_resolution(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/export-presets")
|
||||
data = resp.json()
|
||||
for item in data["items"]:
|
||||
assert "x" in item["resolution"]
|
||||
assert item["fps"] >= 15
|
||||
assert item["fps"] <= 60
|
||||
assert item["format"] in ("mp4", "mov")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/export 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetExportConfig:
|
||||
def test_default_export_config(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/export")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resolution"] == "1080x1920"
|
||||
assert data["fps"] == 30
|
||||
assert data["video_bitrate"] == 8000
|
||||
assert data["audio_bitrate"] == 128
|
||||
assert data["format"] == "mp4"
|
||||
assert data["quality_preset"] == "balanced"
|
||||
assert data["watermark_enabled"] is False
|
||||
assert data["watermark_text"] == ""
|
||||
|
||||
def test_export_not_found(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/export")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/export 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateExportConfig:
|
||||
def test_update_resolution_and_fps(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "720x1280", "fps": 60},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resolution"] == "720x1280"
|
||||
assert data["fps"] == 60
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["export"]["resolution"] == "720x1280"
|
||||
assert plan.config["export"]["fps"] == 60
|
||||
|
||||
def test_update_bitrate(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"video_bitrate": 12000, "audio_bitrate": 192},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["video_bitrate"] == 12000
|
||||
assert data["audio_bitrate"] == 192
|
||||
|
||||
def test_update_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"format": "mov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["format"] == "mov"
|
||||
|
||||
def test_invalid_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"format": "avi"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_quality_preset(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"quality_preset": "best"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["quality_preset"] == "best"
|
||||
|
||||
def test_invalid_quality_preset(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"quality_preset": "ultimate"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_watermark(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"watermark_enabled": True, "watermark_text": "我的视频"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["watermark_enabled"] is True
|
||||
assert data["watermark_text"] == "我的视频"
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["export"]["watermark_enabled"] is True
|
||||
assert plan.config["export"]["watermark_text"] == "我的视频"
|
||||
|
||||
def test_invalid_resolution_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "1080*1920"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_resolution_too_large(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "8000x8000"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_fps_out_of_range(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"fps": 120},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_export_not_found(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/export",
|
||||
json={"fps": 30},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_other_fields(self, export_client):
|
||||
c, _ = export_client
|
||||
# 先修改一个
|
||||
c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"})
|
||||
# 再修改另一个
|
||||
resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60})
|
||||
data = resp.json()
|
||||
# 分辨率应该保持
|
||||
assert data["resolution"] == "720x1280"
|
||||
# fps 更新了
|
||||
assert data["fps"] == 60
|
||||
# 其他默认值不变
|
||||
assert data["format"] == "mp4"
|
||||
assert data["video_bitrate"] == 8000
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
滤镜调色 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /filter-presets - 滤镜预设列表
|
||||
- GET /{plan_id}/filter - 获取滤镜配置
|
||||
- PUT /{plan_id}/filter - 更新滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY, build_ffmpeg_filter
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
return []
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return 0
|
||||
|
||||
def get(self, clip_id: str):
|
||||
return None
|
||||
|
||||
def create(self, clip):
|
||||
return clip
|
||||
|
||||
def update(self, clip):
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001", config=None):
|
||||
if config is None:
|
||||
config = normalize_plan_config({})
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=config,
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_filter as filter_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository()
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 主路由的依赖覆盖
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
# 滤镜路由的依赖覆盖
|
||||
app.dependency_overrides[filter_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[filter_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[filter_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def filter_client():
|
||||
app, plan_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFilterPresets:
|
||||
def test_list_all_presets(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == len(FILTER_PRESET_LIBRARY)
|
||||
assert data["total"] > 10
|
||||
assert len(data["items"]) == data["total"]
|
||||
# 验证字段
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "category" in first
|
||||
assert "description" in first
|
||||
assert "tags" in first
|
||||
|
||||
def test_filter_by_category_basic(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=basic")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "basic"
|
||||
|
||||
def test_filter_by_category_bw(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=bw")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "bw"
|
||||
|
||||
def test_filter_by_keyword(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=电影")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
# 至少包含电影感滤镜
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert any("电影" in n for n in names)
|
||||
|
||||
def test_filter_by_keyword_japanese(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=日系")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert data["items"][0]["name"] == "日系"
|
||||
|
||||
def test_filter_empty_result(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=不存在的滤镜")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_filter_invalid_category(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=nonexistent")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/filter 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFilter:
|
||||
def test_get_default_filter(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/filter")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is False
|
||||
assert data["preset_id"] == "filter_none"
|
||||
assert data["intensity"] == 100
|
||||
assert data["brightness"] == 0.0
|
||||
assert data["contrast"] == 1.0
|
||||
assert data["saturation"] == 1.0
|
||||
assert data["warmth"] == 0.0
|
||||
|
||||
def test_get_filter_not_found(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/filter")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_filter_with_custom_config(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
plan = plan_repo.get("plan-001")
|
||||
new_config = dict(plan.config)
|
||||
new_config["filter"] = {
|
||||
"enabled": True,
|
||||
"preset_id": "filter_cinematic",
|
||||
"intensity": 80,
|
||||
"brightness": 0.1,
|
||||
"contrast": 1.2,
|
||||
"saturation": 0.9,
|
||||
"warmth": 0.3,
|
||||
}
|
||||
plan.config = new_config
|
||||
plan_repo.update(plan)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/filter")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["preset_id"] == "filter_cinematic"
|
||||
assert data["intensity"] == 80
|
||||
assert data["brightness"] == 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/filter 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateFilter:
|
||||
def test_enable_filter(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["preset_id"] == "filter_cinematic"
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["filter"]["enabled"] is True
|
||||
assert plan.config["filter"]["preset_id"] == "filter_cinematic"
|
||||
|
||||
def test_adjust_intensity(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic", "intensity": 50},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["intensity"] == 50
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["filter"]["intensity"] == 50
|
||||
|
||||
def test_invalid_intensity_returns_422(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"intensity": 150},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_invalid_preset_returns_400(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"preset_id": "nonexistent_filter"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的滤镜预设" in resp.json()["detail"]
|
||||
|
||||
def test_filter_not_found(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/filter",
|
||||
json={"enabled": True},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_set_none_preset_disables_filter(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
# 先启用一个滤镜
|
||||
c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic"},
|
||||
)
|
||||
|
||||
# 再设为原图
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"preset_id": "filter_none"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["preset_id"] == "filter_none"
|
||||
assert data["enabled"] is False # 原图自动关闭
|
||||
|
||||
def test_partial_update(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
# 先设置完整配置
|
||||
c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "filter_warm",
|
||||
"intensity": 70,
|
||||
"brightness": 0.05,
|
||||
},
|
||||
)
|
||||
|
||||
# 只修改强度,其他保持不变
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"intensity": 90},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["intensity"] == 90
|
||||
assert data["preset_id"] == "filter_warm" # 保持不变
|
||||
assert data["enabled"] is True # 保持不变
|
||||
assert data["brightness"] == 0.05 # 保持不变
|
||||
|
||||
def test_custom_adjustments(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "filter_cinematic",
|
||||
"brightness": 0.1,
|
||||
"contrast": 1.3,
|
||||
"saturation": 1.2,
|
||||
"warmth": 0.2,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["brightness"] == 0.1
|
||||
assert data["contrast"] == 1.3
|
||||
assert data["saturation"] == 1.2
|
||||
assert data["warmth"] == 0.2
|
||||
|
||||
def test_invalid_brightness_returns_422(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"brightness": 2.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FFmpeg 滤镜生成测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildFFmpegFilter:
|
||||
def test_no_filter(self):
|
||||
assert build_ffmpeg_filter("filter_none", 100) == ""
|
||||
|
||||
def test_zero_intensity(self):
|
||||
assert build_ffmpeg_filter("filter_cinematic", 0) == ""
|
||||
|
||||
def test_invalid_preset(self):
|
||||
assert build_ffmpeg_filter("nonexistent", 100) == ""
|
||||
|
||||
def test_cinematic_full(self):
|
||||
result = build_ffmpeg_filter("filter_cinematic", 100)
|
||||
assert result.startswith("eq=")
|
||||
assert "contrast=" in result
|
||||
assert "saturation=" in result
|
||||
assert "gamma_r=" in result
|
||||
|
||||
def test_cinematic_half(self):
|
||||
full = build_ffmpeg_filter("filter_cinematic", 100)
|
||||
half = build_ffmpeg_filter("filter_cinematic", 50)
|
||||
assert full != half
|
||||
# 50% 强度的参数应该更接近原值
|
||||
assert "eq=" in half
|
||||
|
||||
def test_bw_filter(self):
|
||||
result = build_ffmpeg_filter("filter_bw", 100)
|
||||
assert "saturation=0" in result
|
||||
|
||||
def test_warm_filter(self):
|
||||
result = build_ffmpeg_filter("filter_warm", 100)
|
||||
assert "gamma_r=" in result
|
||||
assert "gamma_b=" in result
|
||||
@@ -636,3 +636,574 @@ class TestGenerationWorkflow:
|
||||
p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
|
||||
updated = svc.update_plan_config(p.id, {"key1": "new_val"})
|
||||
assert updated.config["key1"] == "new_val"
|
||||
|
||||
|
||||
# ── 重新编辑 & 再生成 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResumeEditingAndRegenerate:
|
||||
"""完成/失败后重新编辑 → 再生成的状态流转测试"""
|
||||
|
||||
def test_update_plan_from_completed_returns_to_editing(self):
|
||||
"""更新计划配置:completed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated = svc.update_plan(p.id, name="新名字")
|
||||
assert updated.status == EditPlanStatus.EDITING
|
||||
assert updated.name == "新名字"
|
||||
|
||||
def test_update_plan_config_from_completed_returns_to_editing(self):
|
||||
"""update_plan_config: completed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
updated = svc.update_plan_config(p.id, {"foo": "bar"})
|
||||
assert updated.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_create_clip_from_completed_returns_to_editing(self):
|
||||
"""创建片段:completed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
svc.create_clip(p.id, "main", 0)
|
||||
plan_after = svc.get_plan(p.id)
|
||||
assert plan_after.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_assign_asset_from_failed_returns_to_editing(self):
|
||||
"""分配素材:failed → 自动切回 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
clip = svc.create_clip(p.id, "main", 0)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.FAILED)
|
||||
|
||||
svc.assign_asset(clip.id, "asset-001")
|
||||
plan_after = svc.get_plan(p.id)
|
||||
assert plan_after.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_completed_can_regenerate_after_edit(self):
|
||||
"""完成后编辑 → can_generate 返回 True,可再生成"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.create_clip(p.id, "main", 0)
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
# 完成后不能直接生成
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert not can
|
||||
assert "编辑" in reason
|
||||
|
||||
# 编辑后自动切回 editing,可以生成
|
||||
svc.update_plan_config(p.id, {"edited": True})
|
||||
can, reason = svc.can_generate(p.id)
|
||||
assert can, f"期望可生成,实际: {reason}"
|
||||
|
||||
def test_transition_completed_to_editing_via_service(self):
|
||||
"""通过 transition_status 从 completed 切到 editing"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p.id, EditPlanStatus.RENDERING)
|
||||
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
|
||||
|
||||
result = svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
assert result.status == EditPlanStatus.EDITING
|
||||
|
||||
def test_resume_editing_from_draft_raises(self):
|
||||
"""从 draft 直接 resume_editing 应该报错"""
|
||||
p = EditPlan.create("tpl-001", "测试")
|
||||
with pytest.raises(ValueError):
|
||||
p.resume_editing()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 片段分割与合并测试
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestClipSplit:
|
||||
"""片段分割测试"""
|
||||
|
||||
def test_split_basic(self):
|
||||
"""基础分割:10秒片段在第3秒处分割"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0, text_content="测试文案")
|
||||
|
||||
result = svc.split_clip(clip.id, 3.0)
|
||||
|
||||
assert result["left_clip"].duration == 3.0
|
||||
assert result["left_clip"].order == 0
|
||||
assert result["right_clip"].duration == 7.0
|
||||
assert result["right_clip"].order == 1
|
||||
assert result["right_clip"].clip_type == "main"
|
||||
assert result["right_clip"].text_content == "测试文案"
|
||||
# 总片段数 = 2
|
||||
assert svc.count_clips(p.id) == 2
|
||||
|
||||
def test_split_preserves_clip_properties(self):
|
||||
"""分割后属性继承正确"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(
|
||||
p.id,
|
||||
"intro",
|
||||
0,
|
||||
duration=10.0,
|
||||
transition_effect="fade",
|
||||
playback_speed=1.5,
|
||||
config={"filter": "vivid"},
|
||||
)
|
||||
|
||||
result = svc.split_clip(clip.id, 5.0)
|
||||
|
||||
right = result["right_clip"]
|
||||
assert right.clip_type == "intro"
|
||||
assert right.transition_effect == "fade"
|
||||
assert right.playback_speed == 1.5
|
||||
assert right.config.get("filter") == "vivid"
|
||||
|
||||
def test_split_shifts_following_clips(self):
|
||||
"""分割后,后面的片段 order 自动 +1"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
|
||||
|
||||
svc.split_clip(clip1.id, 2.0)
|
||||
|
||||
# clip0: order 0
|
||||
# clip1(left): order 1
|
||||
# new right: order 2
|
||||
# clip2: order 3
|
||||
clips = svc.list_clips(p.id)
|
||||
order_map = {c.id: c.order for c in clips}
|
||||
assert order_map[clip0.id] == 0
|
||||
assert order_map[clip1.id] == 1
|
||||
assert order_map[clip2.id] == 3
|
||||
assert len(clips) == 4
|
||||
|
||||
def test_split_at_boundary_raises(self):
|
||||
"""分割点为0或等于时长时,报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="分割时间"):
|
||||
svc.split_clip(clip.id, 0.0)
|
||||
|
||||
with pytest.raises(ValueError, match="分割时间"):
|
||||
svc.split_clip(clip.id, 10.0)
|
||||
|
||||
def test_split_negative_time_raises(self):
|
||||
"""负数分割点报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="分割时间"):
|
||||
svc.split_clip(clip.id, -1.0)
|
||||
|
||||
def test_split_nonexistent_clip_raises(self):
|
||||
"""不存在的片段报错"""
|
||||
svc = _make_service()
|
||||
|
||||
with pytest.raises(ValueError, match="片段不存在"):
|
||||
svc.split_clip("nonexistent", 5.0)
|
||||
|
||||
def test_split_with_asset_adds_trim_info(self):
|
||||
"""有素材的片段分割后,添加trim_start/trim_end"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001")
|
||||
|
||||
result = svc.split_clip(clip.id, 3.0)
|
||||
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
# 左半部分有 trim_end
|
||||
assert left.config.get("trim_end") == 7.0
|
||||
# 右半部分有 trim_start
|
||||
assert right.config.get("trim_start") == 3.0
|
||||
# 右半部分也关联同一个素材
|
||||
assert right.asset_id == "asset-001"
|
||||
|
||||
|
||||
class TestSubtitleManagement:
|
||||
"""字幕管理测试"""
|
||||
|
||||
def test_add_subtitle_basic(self):
|
||||
"""基础:添加一条字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
subtitle = svc.add_subtitle(clip.id, start=1.0, end=3.0, text="大家好")
|
||||
|
||||
assert subtitle["text"] == "大家好"
|
||||
assert subtitle["start"] == 1.0
|
||||
assert subtitle["end"] == 3.0
|
||||
assert "id" in subtitle
|
||||
assert len(subtitle["id"]) > 0
|
||||
|
||||
def test_add_subtitle_with_style(self):
|
||||
"""添加带样式的字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
style = {"font_size": 24, "color": "#ffffff", "position": "bottom"}
|
||||
subtitle = svc.add_subtitle(clip.id, start=0.0, end=2.0, text="测试", style=style)
|
||||
|
||||
assert subtitle["style"]["font_size"] == 24
|
||||
assert subtitle["style"]["color"] == "#ffffff"
|
||||
|
||||
def test_list_subtitles_sorted_by_time(self):
|
||||
"""字幕列表按时间排序"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
svc.add_subtitle(clip.id, start=5.0, end=6.0, text="第二")
|
||||
svc.add_subtitle(clip.id, start=1.0, end=2.0, text="第一")
|
||||
svc.add_subtitle(clip.id, start=8.0, end=9.0, text="第三")
|
||||
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert len(subtitles) == 3
|
||||
assert subtitles[0]["text"] == "第一"
|
||||
assert subtitles[1]["text"] == "第二"
|
||||
assert subtitles[2]["text"] == "第三"
|
||||
|
||||
def test_add_subtitle_invalid_time_raises(self):
|
||||
"""非法时间报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
# 开始时间为负
|
||||
with pytest.raises(ValueError, match="时间非法"):
|
||||
svc.add_subtitle(clip.id, start=-1.0, end=2.0, text="test")
|
||||
|
||||
# 结束时间 <= 开始时间
|
||||
with pytest.raises(ValueError, match="时间非法"):
|
||||
svc.add_subtitle(clip.id, start=5.0, end=3.0, text="test")
|
||||
|
||||
# 超过片段时长
|
||||
with pytest.raises(ValueError, match="不能超过片段时长"):
|
||||
svc.add_subtitle(clip.id, start=8.0, end=15.0, text="test")
|
||||
|
||||
def test_add_subtitle_empty_text_raises(self):
|
||||
"""空文本报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="不能为空"):
|
||||
svc.add_subtitle(clip.id, start=1.0, end=2.0, text=" ")
|
||||
|
||||
def test_get_subtitle(self):
|
||||
"""获取单条字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试")
|
||||
found = svc.get_subtitle(clip.id, sub["id"])
|
||||
|
||||
assert found is not None
|
||||
assert found["text"] == "测试"
|
||||
|
||||
# 不存在的返回 None
|
||||
assert svc.get_subtitle(clip.id, "nonexistent") is None
|
||||
|
||||
def test_update_subtitle_text(self):
|
||||
"""更新字幕文本"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原文")
|
||||
updated = svc.update_subtitle(clip.id, sub["id"], text="修改后")
|
||||
|
||||
assert updated["text"] == "修改后"
|
||||
assert updated["start"] == 1.0 # 时间不变
|
||||
|
||||
def test_update_subtitle_time(self):
|
||||
"""更新字幕时间"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="测试")
|
||||
updated = svc.update_subtitle(clip.id, sub["id"], start=3.0, end=5.0)
|
||||
|
||||
assert updated["start"] == 3.0
|
||||
assert updated["end"] == 5.0
|
||||
|
||||
def test_update_subtitle_not_found_raises(self):
|
||||
"""更新不存在的字幕报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="字幕不存在"):
|
||||
svc.update_subtitle(clip.id, "fake-id", text="test")
|
||||
|
||||
def test_delete_subtitle(self):
|
||||
"""删除字幕"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="要删的")
|
||||
assert svc.count_clips(p.id) == 1 # 片段还在
|
||||
|
||||
deleted = svc.delete_subtitle(clip.id, sub["id"])
|
||||
assert deleted is True
|
||||
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert len(subtitles) == 0
|
||||
|
||||
def test_delete_subtitle_not_found(self):
|
||||
"""删除不存在的字幕返回 False"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
deleted = svc.delete_subtitle(clip.id, "nonexistent")
|
||||
assert deleted is False
|
||||
|
||||
def test_batch_update_subtitles(self):
|
||||
"""批量更新字幕(全量替换)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=20.0)
|
||||
|
||||
# 先加一条
|
||||
svc.add_subtitle(clip.id, start=1.0, end=2.0, text="旧字幕")
|
||||
|
||||
# 全量替换为 3 条
|
||||
new_subs = [
|
||||
{"start": 0.0, "end": 3.0, "text": "第一条"},
|
||||
{"start": 4.0, "end": 7.0, "text": "第二条"},
|
||||
{"start": 8.0, "end": 12.0, "text": "第三条"},
|
||||
]
|
||||
result = svc.batch_update_subtitles(clip.id, new_subs)
|
||||
|
||||
assert len(result) == 3
|
||||
assert result[0]["text"] == "第一条"
|
||||
# 都有 id
|
||||
assert all("id" in s for s in result)
|
||||
# 旧字幕没了
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert len(subtitles) == 3
|
||||
|
||||
def test_batch_update_preserves_existing_ids(self):
|
||||
"""批量更新时已有 id 的字幕保留原 id"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
sub = svc.add_subtitle(clip.id, start=1.0, end=2.0, text="原字幕")
|
||||
original_id = sub["id"]
|
||||
|
||||
# 带 id 批量更新,修改文本
|
||||
updated_list = svc.batch_update_subtitles(
|
||||
clip.id,
|
||||
[{"id": original_id, "start": 1.0, "end": 3.0, "text": "修改了"}],
|
||||
)
|
||||
|
||||
assert len(updated_list) == 1
|
||||
assert updated_list[0]["id"] == original_id
|
||||
assert updated_list[0]["text"] == "修改了"
|
||||
|
||||
def test_batch_update_skips_empty_text(self):
|
||||
"""批量更新时空文本自动跳过"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
subs = [
|
||||
{"start": 0.0, "end": 1.0, "text": "有效"},
|
||||
{"start": 2.0, "end": 3.0, "text": " "}, # 空白,跳过
|
||||
{"start": 4.0, "end": 5.0, "text": "也有效"},
|
||||
]
|
||||
result = svc.batch_update_subtitles(clip.id, subs)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_empty_clip_returns_empty_list(self):
|
||||
"""没有字幕的片段返回空列表"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
subtitles = svc.list_subtitles(clip.id)
|
||||
assert subtitles == []
|
||||
|
||||
|
||||
class TestClipMerge:
|
||||
"""片段合并测试"""
|
||||
|
||||
def test_merge_two_clips(self):
|
||||
"""基础合并:两个5秒片段合并为10秒"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段")
|
||||
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段")
|
||||
|
||||
merged = svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
assert merged.duration == 10.0
|
||||
assert merged.order == 0
|
||||
assert merged.clip_type == "main"
|
||||
assert "第一段" in merged.text_content
|
||||
assert "第二段" in merged.text_content
|
||||
# 总片段数 = 1
|
||||
assert svc.count_clips(p.id) == 1
|
||||
|
||||
def test_merge_shifts_following_clips(self):
|
||||
"""合并后,后面的片段 order 前移"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
|
||||
clip3 = svc.create_clip(p.id, "main", 3, duration=5.0)
|
||||
|
||||
svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
clips = svc.list_clips(p.id)
|
||||
order_map = {c.id: c.order for c in clips}
|
||||
assert order_map[clip0.id] == 0
|
||||
assert order_map[clip3.id] == 2 # 原来order=3,前移1位=2
|
||||
assert len(clips) == 3
|
||||
|
||||
def test_merge_three_clips(self):
|
||||
"""合并3个片段"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clips = []
|
||||
for i in range(3):
|
||||
c = svc.create_clip(p.id, "main", i, duration=3.0)
|
||||
clips.append(c)
|
||||
|
||||
merged = svc.merge_clips([c.id for c in clips])
|
||||
|
||||
assert merged.duration == 9.0
|
||||
assert svc.count_clips(p.id) == 1
|
||||
|
||||
def test_merge_different_types_raises(self):
|
||||
"""不同类型片段不能合并"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="相同类型"):
|
||||
svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
def test_merge_non_contiguous_raises(self):
|
||||
"""不连续的片段不能合并"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="不连续"):
|
||||
svc.merge_clips([clip0.id, clip2.id])
|
||||
|
||||
def test_merge_single_clip_raises(self):
|
||||
"""单个片段不能合并"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="至少需要 2 个"):
|
||||
svc.merge_clips([clip.id])
|
||||
|
||||
def test_merge_different_plans_raises(self):
|
||||
"""不同计划的片段不能合并"""
|
||||
svc = _make_service()
|
||||
p1 = svc.create_plan("tpl-001", "计划1")
|
||||
p2 = svc.create_plan("tpl-001", "计划2")
|
||||
svc.transition_status(p1.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p2.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0)
|
||||
clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="同一计划"):
|
||||
svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
def test_merge_clears_trim_fields(self):
|
||||
"""合并后清理trim字段"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"})
|
||||
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0})
|
||||
|
||||
merged = svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
assert "trim_start" not in merged.config
|
||||
assert "trim_end" not in merged.config
|
||||
# 非 trim 字段保留(后面的覆盖前面的)
|
||||
assert merged.config.get("filter") == "vivid"
|
||||
|
||||
def test_split_then_merge_recovers(self):
|
||||
"""分割后再合并,时长基本恢复(浮点精度内)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
original = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
result = svc.split_clip(original.id, 3.5)
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
|
||||
merged = svc.merge_clips([left.id, right.id])
|
||||
|
||||
assert abs(merged.duration - 10.0) < 0.001
|
||||
assert svc.count_clips(p.id) == 1
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""
|
||||
转场特效 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /transition-presets - 转场预设列表
|
||||
- PUT /clips/{clip_id}/transition - 设置单个片段转场
|
||||
- POST /{plan_id}/transitions/batch - 批量设置转场
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
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")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-new{self._counter}"
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
return len(items)
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
if clip_id in self._clips:
|
||||
del self._clips[clip_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||||
for cid in to_delete:
|
||||
del self._clips[cid]
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_clip(clip_id, plan_id="plan-001", order=0, transition_effect="cut", transition_duration=0.0):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type="video",
|
||||
order=order,
|
||||
asset_id="asset-001",
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=10.0,
|
||||
transition_effect=transition_effect,
|
||||
transition_duration=transition_duration,
|
||||
playback_speed=1.0,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_transitions as transitions_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
clips = {
|
||||
"clip-001": _make_clip("clip-001", order=0),
|
||||
"clip-002": _make_clip("clip-002", order=1),
|
||||
"clip-003": _make_clip("clip-003", order=2),
|
||||
"clip-004": _make_clip("clip-004", order=3),
|
||||
}
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository(clips)
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 覆盖依赖
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
app.dependency_overrides[transitions_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[transitions_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[transitions_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transition_client():
|
||||
app, plan_repo, clip_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transition Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTransitionPresets:
|
||||
def test_list_all_presets(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == len(TRANSITION_PRESET_LIBRARY)
|
||||
assert data["total"] > 10
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "category" in first
|
||||
assert "default_duration" in first
|
||||
assert "min_duration" in first
|
||||
assert "max_duration" in first
|
||||
|
||||
def test_filter_by_category_fade(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?category=fade")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "fade"
|
||||
|
||||
def test_filter_by_category_slide(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?category=slide")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 4
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "slide"
|
||||
|
||||
def test_filter_by_keyword(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?keyword=模糊")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert any("模糊" in n for n in names)
|
||||
|
||||
def test_filter_empty_result(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?keyword=不存在的转场")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
def test_contains_none_transition(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.get("/api/v1/edit-plans/transition-presets?category=basic")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
ids = [item["id"] for item in data["items"]]
|
||||
assert "transition_none" in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /clips/{clip_id}/transition 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateClipTransition:
|
||||
def test_set_fade_transition(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade", "duration": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["clip_id"] == "clip-001"
|
||||
assert data["effect"] == "fade"
|
||||
assert data["duration"] == 0.8
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.transition_effect == "fade"
|
||||
assert clip.transition_duration == 0.8
|
||||
|
||||
def test_set_none_transition(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_none"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["effect"] == "cut"
|
||||
assert data["duration"] == 0.0
|
||||
|
||||
def test_use_default_duration(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
# 不传 duration,使用预设默认值
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["effect"] == "fade"
|
||||
assert data["duration"] > 0 # 使用默认值
|
||||
|
||||
def test_invalid_effect(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "invalid_effect"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的转场效果" in resp.json()["detail"]
|
||||
|
||||
def test_clip_not_found(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/transition",
|
||||
json={"effect": "transition_fade"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_negative_duration_422(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade", "duration": -0.5},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_duration_clamped_to_max(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
# 传一个超过最大值的时长,应该被钳制
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/transition",
|
||||
json={"effect": "transition_fade", "duration": 10.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# fade 最大 2.0s
|
||||
assert data["duration"] <= 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/transitions/batch 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchUpdateTransitions:
|
||||
def test_batch_all(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "duration": 0.5, "apply_to": "all"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 4 # 4个片段
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.transition_effect == "fade"
|
||||
assert clip.transition_duration == 0.5
|
||||
|
||||
def test_batch_except_first(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "apply_to": "except_first"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
|
||||
# 第一个不变
|
||||
assert clip_repo.get("clip-001").transition_effect == "cut"
|
||||
# 其余三个被更新
|
||||
for cid in ["clip-002", "clip-003", "clip-004"]:
|
||||
assert clip_repo.get(cid).transition_effect == "fade"
|
||||
|
||||
def test_batch_except_last(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_slideleft", "apply_to": "except_last"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
|
||||
# 最后一个不变
|
||||
assert clip_repo.get("clip-004").transition_effect == "cut"
|
||||
# 前三个被更新
|
||||
for cid in ["clip-001", "clip-002", "clip-003"]:
|
||||
assert clip_repo.get(cid).transition_effect == "slideleft"
|
||||
|
||||
def test_batch_middle(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_dissolve", "apply_to": "middle"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 2 # 4个片段,中间2个
|
||||
|
||||
# 首尾不变
|
||||
assert clip_repo.get("clip-001").transition_effect == "cut"
|
||||
assert clip_repo.get("clip-004").transition_effect == "cut"
|
||||
# 中间被更新
|
||||
assert clip_repo.get("clip-002").transition_effect == "dissolve"
|
||||
assert clip_repo.get("clip-003").transition_effect == "dissolve"
|
||||
|
||||
def test_batch_invalid_effect(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "invalid"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_batch_plan_not_found(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/transitions/batch",
|
||||
json={"effect": "transition_fade"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_batch_invalid_apply_to(self, transition_client):
|
||||
c, _, _ = transition_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "apply_to": "invalid"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_batch_none_transition(self, transition_client):
|
||||
c, _, clip_repo = transition_client
|
||||
# 先设一个转场
|
||||
c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_fade", "apply_to": "all"},
|
||||
)
|
||||
# 再全部设为无
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/transitions/batch",
|
||||
json={"effect": "transition_none", "apply_to": "all"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["updated_count"] == 4
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003", "clip-004"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.transition_effect == "cut"
|
||||
assert clip.transition_duration == 0.0
|
||||
Regular → Executable
+214
@@ -507,3 +507,217 @@ class TestDeletePlan:
|
||||
resp = c.delete("/api/v1/edit-plans/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert "剪辑计划不存在" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BGM 配置测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGM 配置 API 测试"""
|
||||
|
||||
def test_get_bgm_default_empty(self, client):
|
||||
"""新计划 BGM 默认为空"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.get(f"/api/v1/edit-plans/{plan.id}/bgm")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert data["bgm"] == {}
|
||||
|
||||
def test_update_bgm_volume(self, client):
|
||||
"""更新 BGM 音量"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"volume": 0.5, "fade_in": 2.0, "fade_out": 3.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["volume"] == 0.5
|
||||
assert data["bgm"]["fade_in"] == 2.0
|
||||
assert data["bgm"]["fade_out"] == 3.0
|
||||
|
||||
def test_enable_bgm_with_preset(self, client):
|
||||
"""启用 BGM 并指定 preset_id"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"source": "library",
|
||||
"preset_id": "bgm_upbeat_001",
|
||||
"volume": 0.3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["enabled"] is True
|
||||
assert data["bgm"]["preset_id"] == "bgm_upbeat_001"
|
||||
|
||||
def test_enable_bgm_without_source_returns_400(self, client):
|
||||
"""启用 BGM 但不指定来源,返回 400"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"enabled": True, "volume": 0.3},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "素材来源" in resp.json()["detail"]
|
||||
|
||||
def test_enable_bgm_with_asset_id(self, client):
|
||||
"""启用 BGM 并指定 asset_id"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"source": "upload",
|
||||
"asset_id": "asset-audio-001",
|
||||
"loop_enabled": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["enabled"] is True
|
||||
assert data["bgm"]["asset_id"] == "asset-audio-001"
|
||||
assert data["bgm"]["loop_enabled"] is True
|
||||
|
||||
def test_update_bgm_not_found(self, client):
|
||||
"""不存在的计划返回 404"""
|
||||
c, _ = client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/nonexistent/bgm",
|
||||
json={"volume": 0.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_bgm_not_found(self, client):
|
||||
"""不存在的计划返回 404"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/nonexistent/bgm")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_existing(self, client):
|
||||
"""部分更新保留原有配置"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
plan.config = {"bgm": {"volume": 0.5, "fade_in": 1.0}}
|
||||
repo.create(plan)
|
||||
|
||||
# 只改音量
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"volume": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["volume"] == 0.8
|
||||
assert data["bgm"]["fade_in"] == 1.0 # 保留
|
||||
|
||||
def test_sidechain_config(self, client):
|
||||
"""人声闪避配置更新"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "bgm_relax_001",
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.4,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["sidechain_enabled"] is True
|
||||
assert data["bgm"]["sidechain_ratio"] == 0.4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BGM 预设库测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBGMPresets:
|
||||
"""BGM 预设列表 API 测试"""
|
||||
|
||||
def test_list_all_presets(self, client):
|
||||
"""获取所有预设 BGM"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "styles" in data
|
||||
assert data["total"] >= 10 # 至少有 10 首预设
|
||||
assert len(data["items"]) == data["total"]
|
||||
|
||||
def test_filter_by_style(self, client):
|
||||
"""按风格筛选"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?style=upbeat")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["style"] == "upbeat"
|
||||
|
||||
def test_search_by_keyword(self, client):
|
||||
"""关键词搜索"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?keyword=钢琴")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
for item in data["items"]:
|
||||
has_piano = (
|
||||
"钢琴" in item["name"] or "钢琴" in item["description"] or any("钢琴" in tag for tag in item["tags"])
|
||||
)
|
||||
assert has_piano
|
||||
|
||||
def test_pagination(self, client):
|
||||
"""分页功能"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?skip=0&limit=3")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["skip"] == 0
|
||||
assert data["limit"] == 3
|
||||
|
||||
def test_preset_structure(self, client):
|
||||
"""预设条目字段完整"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?limit=1")
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
|
||||
assert "id" in item
|
||||
assert "name" in item
|
||||
assert "style" in item
|
||||
assert "style_label" in item
|
||||
assert "duration" in item
|
||||
assert "artist" in item
|
||||
assert "description" in item
|
||||
assert "tags" in item
|
||||
assert isinstance(item["tags"], list)
|
||||
|
||||
Regular → Executable
+288
@@ -463,3 +463,291 @@ class TestCompositeQueries:
|
||||
t = svc.create_template(name="空模板")
|
||||
result = svc.get_template_with_configs(t.id)
|
||||
assert len(result["clip_configs"]) == 0
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Stub Repositories for EditPlan (save_as_template 测试用)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
"""内存中的 EditPlan 仓储 stub"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plans: dict[str, EditPlan] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
if not plan.id:
|
||||
plan.id = self._next_id()
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
"""内存中的 EditPlanClip 仓储 stub"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clips: dict[str, EditPlanClip] = {}
|
||||
self._counter = 0
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-{self._counter:03d}"
|
||||
|
||||
def list_by_plan(
|
||||
self,
|
||||
plan_id: str,
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[EditPlanClip]:
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status:
|
||||
items = [c for c in items if c.status.value == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
|
||||
def _make_service_with_plan_stubs():
|
||||
"""创建使用 stub 仓储的 EditTemplateService(含 plan 相关 stub)"""
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
|
||||
db = MagicMock()
|
||||
svc = EditTemplateService(db)
|
||||
svc._template_repo = StubEditTemplateRepository()
|
||||
svc._clip_config_repo = StubTemplateClipConfigRepository()
|
||||
svc._plan_repo = StubEditPlanRepository()
|
||||
svc._plan_clip_repo = StubEditPlanClipRepository()
|
||||
return svc
|
||||
|
||||
|
||||
def _make_test_plan_with_clips(svc, *, clip_count: int = 3, plan_config=None):
|
||||
"""辅助方法:创建一个带片段的测试计划,返回 plan 对象"""
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
plan = EditPlan.create(
|
||||
template_id="tpl-source",
|
||||
name="我的剪辑计划",
|
||||
config=plan_config or {"editing_mode": "one_take", "theme": "minimal"},
|
||||
project_id="proj-001",
|
||||
created_by_user_id="user-001",
|
||||
)
|
||||
plan.id = "plan-test-001"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
for i in range(clip_count):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan.id,
|
||||
clip_type=ClipType.MAIN.value,
|
||||
order=i,
|
||||
asset_id=f"asset-{i:03d}",
|
||||
text_content=f"片段{i}的文案",
|
||||
duration=10.0 + i * 5,
|
||||
transition_effect="cut" if i == 0 else "fade",
|
||||
playback_speed=1.0 if i == 0 else 1.5,
|
||||
config={"filter": "vivid"} if i == 1 else {},
|
||||
)
|
||||
svc._plan_clip_repo.create(clip)
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 保存为模板测试
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestSavePlanAsTemplate:
|
||||
"""从剪辑计划保存为模板测试"""
|
||||
|
||||
def test_basic_save_as_template(self):
|
||||
"""基础场景:将有3个片段的计划保存为模板"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=3)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="我的自定义模板")
|
||||
|
||||
assert result["template"].name == "我的自定义模板"
|
||||
assert result["template"].template_type == "custom"
|
||||
assert result["template"].editing_mode == "one_take"
|
||||
assert result["template"].status == EditTemplateStatus.ACTIVE
|
||||
assert len(result["clip_configs"]) == 3
|
||||
|
||||
def test_clip_configs_correctly_converted(self):
|
||||
"""片段正确转换为模板片段配置"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=2)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="转换测试模板")
|
||||
configs = result["clip_configs"]
|
||||
configs.sort(key=lambda c: c.order)
|
||||
|
||||
# 第0个片段
|
||||
assert configs[0].clip_type == ClipType.MAIN
|
||||
assert configs[0].order == 0
|
||||
assert configs[0].min_duration == 10.0
|
||||
assert configs[0].max_duration == 10.0
|
||||
assert configs[0].text_template == "片段0的文案"
|
||||
assert configs[0].transition_effect.value == "cut"
|
||||
# playback_speed=1.0 不存
|
||||
assert "playback_speed" not in configs[0].config
|
||||
|
||||
# 第1个片段
|
||||
assert configs[1].order == 1
|
||||
assert configs[1].min_duration == 15.0
|
||||
assert configs[1].max_duration == 15.0
|
||||
assert configs[1].transition_effect.value == "fade"
|
||||
# playback_speed=1.5 存入config
|
||||
assert configs[1].config.get("playback_speed") == 1.5
|
||||
# config 中的 filter 保留
|
||||
assert configs[1].config.get("filter") == "vivid"
|
||||
|
||||
def test_no_asset_id_in_template(self):
|
||||
"""模板不保留具体素材ID"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=2)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="素材剥离测试")
|
||||
|
||||
for cfg in result["clip_configs"]:
|
||||
# 模板片段配置没有 asset_id 字段
|
||||
assert not hasattr(cfg, "asset_id") or not getattr(cfg, "asset_id", "")
|
||||
# config 中也不应有素材相关字段
|
||||
assert "asset_info" not in cfg.config
|
||||
assert "source_asset_id" not in cfg.config
|
||||
|
||||
def test_template_config_stripped_of_runtime_fields(self):
|
||||
"""模板config剥离运行时字段"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan_config = {
|
||||
"editing_mode": "one_take",
|
||||
"theme": "cinematic",
|
||||
"asset_ids": ["a1", "a2"],
|
||||
"source_edit_plan_id": "old-plan",
|
||||
"generation_task_id": "task-123",
|
||||
}
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1, plan_config=plan_config)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="配置剥离测试")
|
||||
|
||||
tpl_config = result["template"].config
|
||||
assert tpl_config.get("theme") == "cinematic"
|
||||
assert "asset_ids" not in tpl_config
|
||||
assert "source_edit_plan_id" not in tpl_config
|
||||
assert "generation_task_id" not in tpl_config
|
||||
|
||||
def test_plan_not_found_raises_error(self):
|
||||
"""计划不存在时报错"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
|
||||
with pytest.raises(ValueError, match="剪辑计划不存在"):
|
||||
svc.save_plan_as_template("nonexistent-plan", name="不存在的计划")
|
||||
|
||||
def test_empty_name_raises_error(self):
|
||||
"""模板名称为空时报错"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||||
|
||||
with pytest.raises(ValueError, match="模板名称不能为空"):
|
||||
svc.save_plan_as_template(plan.id, name=" ")
|
||||
|
||||
def test_duplicate_name_raises_error(self):
|
||||
"""模板名称重复时报错"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
svc.create_template(name="重名模板")
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||||
|
||||
with pytest.raises(ValueError, match="模板名称已存在"):
|
||||
svc.save_plan_as_template(plan.id, name="重名模板")
|
||||
|
||||
def test_save_zero_clip_plan(self):
|
||||
"""零片段计划也能保存为模板"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
|
||||
plan = EditPlan.create(
|
||||
template_id="tpl-source",
|
||||
name="空计划",
|
||||
config={"editing_mode": "one_take"},
|
||||
)
|
||||
plan.id = "plan-empty"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="空模板")
|
||||
|
||||
assert result["template"].name == "空模板"
|
||||
assert len(result["clip_configs"]) == 0
|
||||
|
||||
def test_custom_description_and_type(self):
|
||||
"""自定义描述和模板类型"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
plan = _make_test_plan_with_clips(svc, clip_count=1)
|
||||
|
||||
result = svc.save_plan_as_template(
|
||||
plan.id,
|
||||
name="自定义模板",
|
||||
description="这是一个测试模板",
|
||||
template_type="vlog",
|
||||
)
|
||||
|
||||
assert result["template"].description == "这是一个测试模板"
|
||||
assert result["template"].template_type == "vlog"
|
||||
|
||||
def test_unknown_transition_effect_falls_back_to_cut(self):
|
||||
"""未知转场效果回退到cut"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
plan = EditPlan.create(template_id="tpl-src", name="转场测试计划")
|
||||
plan.id = "plan-transition-test"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan.id,
|
||||
clip_type="main",
|
||||
order=0,
|
||||
duration=10.0,
|
||||
transition_effect="weird_effect_that_does_not_exist",
|
||||
)
|
||||
svc._plan_clip_repo.create(clip)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="转场兼容模板")
|
||||
assert result["clip_configs"][0].transition_effect.value == "cut"
|
||||
|
||||
def test_unknown_clip_type_falls_back_to_main(self):
|
||||
"""未知片段类型回退到main"""
|
||||
svc = _make_service_with_plan_stubs()
|
||||
from packages.domain.edit_plan import EditPlan
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
plan = EditPlan.create(template_id="tpl-src", name="类型测试计划")
|
||||
plan.id = "plan-type-test"
|
||||
svc._plan_repo.create(plan)
|
||||
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=plan.id,
|
||||
clip_type="unknown_clip_type",
|
||||
order=0,
|
||||
duration=10.0,
|
||||
)
|
||||
svc._plan_clip_repo.create(clip)
|
||||
|
||||
result = svc.save_plan_as_template(plan.id, name="类型兼容模板")
|
||||
assert result["clip_configs"][0].clip_type == ClipType.MAIN
|
||||
|
||||
Regular → Executable
+84
@@ -169,6 +169,69 @@ class TestStartClone:
|
||||
assert profile.status == VoiceCloneStatus.PENDING
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_internal_url_rejected(self) -> None:
|
||||
"""SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="http://127.0.0.1/audio.wav",
|
||||
)
|
||||
|
||||
# 内网 IP 应该被拒绝,标记为 failed
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in profile.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_private_ip_rejected(self) -> None:
|
||||
"""SSRF 防护:私有网段 IP 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="http://192.168.1.100/audio.wav",
|
||||
)
|
||||
|
||||
assert profile.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in profile.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
def test_start_clone_ssrf_public_url_passes(self) -> None:
|
||||
"""SSRF 防护:正常公网 URL 应该通过校验。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
mock_cosyvoice.submit_clone_task.return_value = {
|
||||
"voice_id": "voice-ssrf-test",
|
||||
"status": "DEPLOYING",
|
||||
"request_id": "req-ssrf",
|
||||
}
|
||||
mock_repo.create.side_effect = lambda p: p
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
profile = service.start_clone(
|
||||
user_id="user-123",
|
||||
name="测试音色",
|
||||
source_audio_url="https://example.com/audio.wav",
|
||||
)
|
||||
|
||||
# 公网 URL 应该正常通过
|
||||
assert profile.status == VoiceCloneStatus.PROCESSING
|
||||
mock_cosyvoice.submit_clone_task.assert_called_once()
|
||||
|
||||
|
||||
# ── process_clone_result ─────────────────────────────────
|
||||
|
||||
@@ -313,6 +376,27 @@ class TestRetryClone:
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert "重试失败" in result.error_message
|
||||
|
||||
def test_retry_clone_ssrf_internal_url_rejected(self) -> None:
|
||||
"""重试时 SSRF 防护:内网 URL 应该被拒绝,profile 标记为 failed。"""
|
||||
mock_repo = MagicMock()
|
||||
mock_cosyvoice = MagicMock(spec=CosyVoiceService)
|
||||
|
||||
profile = _make_profile(
|
||||
status=VoiceCloneStatus.FAILED,
|
||||
source_audio_url="http://10.0.0.1/secret.wav",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
)
|
||||
mock_repo.get.return_value = profile
|
||||
mock_repo.update.side_effect = lambda p: p
|
||||
|
||||
service = _make_service(repo=mock_repo, cosyvoice=mock_cosyvoice)
|
||||
result = service.retry_clone(profile.id, "user-123")
|
||||
|
||||
assert result.status == VoiceCloneStatus.FAILED
|
||||
assert "安全校验失败" in result.error_message
|
||||
mock_cosyvoice.submit_clone_task.assert_not_called()
|
||||
|
||||
|
||||
# ── poll_and_process_clone ───────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user