Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a65e6e293e | |||
| 7f21252634 | |||
| 6a12608104 | |||
| c08a064e55 | |||
| 83cb632653 | |||
| 45c07789e0 | |||
| 3745970515 | |||
| 34e8c56584 | |||
| 41fdd7e066 | |||
| 7e266d5d64 | |||
| 743d5d5467 | |||
| 8e962b3af2 | |||
| ea51a8af26 | |||
| 56766c6a1a | |||
| d45f0d6bdb | |||
| bc9df316dc | |||
| 5ed2ee1192 | |||
| 061554af89 | |||
| 87cca302f4 | |||
| deb127ae08 | |||
| a9438ed996 | |||
| 72f47592a9 | |||
| b6c340a352 | |||
| 6e76b45d34 | |||
| cd8e77c1f6 | |||
| 4ae6394c95 | |||
| e052c0545e | |||
| ee6fa3e1cf | |||
| afe78fb25f | |||
| 42939a557f | |||
| 864f0e8eb2 | |||
| 243b0e7c78 | |||
| dddbd3bfa2 | |||
| ab23ca7e5a | |||
| dc816991d6 | |||
| f7693baeae | |||
| 107a391c47 | |||
| 2376f0c807 | |||
| 8c0c34300f | |||
| 3bc850dc0f | |||
| 301b591037 | |||
| 8d8b565499 | |||
| 2662cd78fe | |||
| 26856e1015 | |||
| e5d7fddfcf | |||
| d2098b03ab | |||
| 40eca5e83f | |||
| 4cf6f222ed | |||
| 0fde7d687c | |||
| fb197e8386 | |||
| 15bc55f0ff | |||
| 61295b9c25 | |||
| 63e68756ac | |||
| 5c45629495 | |||
| dd71b644c0 | |||
| de97f5f0fb | |||
| 4f7746926c | |||
| 1106ba5c45 | |||
| b69588f14d | |||
| 1f9236bb16 | |||
| 411595ed90 | |||
| 839426a2cc | |||
| 3acdb8b729 | |||
| 652bbfe12b | |||
| 1e483b7bf9 | |||
| 72b30d7959 | |||
| d1b934970a | |||
| d8dd510cba | |||
| f988a028fe | |||
| 57cd3d92dd | |||
| 5d18b76cea | |||
| 6d5b860952 | |||
| 12fb0a8e14 | |||
| 545ff0fab8 | |||
| 89a8c8b6fb | |||
| e4997b9b4a | |||
| 68c89861e9 | |||
| 076601b431 | |||
| e23a60e98b | |||
| e2e91e1d58 | |||
| 682972469c | |||
| f8b3552a4e | |||
| 5e0dcaead8 | |||
| 1eca707db3 | |||
| 5331307cbc | |||
| 3bea00aa56 | |||
| 1addcffeba | |||
| f3819653b8 | |||
| b73d75a3e4 | |||
| 62967e078b | |||
| 2818f44282 | |||
| c9e5129ec8 | |||
| cd3d366f4a | |||
| 1dd640da87 | |||
| 4587d0b014 | |||
| 4749071c16 | |||
| 3353865f5b | |||
| 7061d7e672 | |||
| 6efdfbe194 | |||
| 02e3246f5a | |||
| 5cdafd2559 | |||
| a6afb344ba | |||
| 504e2e71c9 | |||
| fb2884b03c | |||
| 7fab42c3d0 | |||
| 561548c84c | |||
| 77704e7ec6 | |||
| 5ae6c33bf6 | |||
| db07738178 | |||
| 53c09e7d3c | |||
| e602439769 | |||
| a26fda1597 | |||
| 99a8ffa97b | |||
| f03c9d5453 | |||
| 8322e2b6e2 | |||
| d2409e16c1 | |||
| 6eac0b2cf2 | |||
| dfb2feef8a | |||
| b3ef7bb041 | |||
| a1a272b833 | |||
| 728db0faf8 | |||
| 7e5e412f7f | |||
| df08161630 | |||
| 0c9375ff32 | |||
| ef344e9ffc | |||
| 0d4904433e | |||
| 708662394f | |||
| 9b034764ad | |||
| 8748b43070 | |||
| d213a055a1 | |||
| 2371860f82 | |||
| dbd956fc6e | |||
| 1d59ee5336 |
+278
-148
@@ -24,6 +24,55 @@ concurrency:
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
dedupe-check:
|
||||
name: Dedup Check - skip PR tests when covered by push pipeline
|
||||
runs-on: ci-l1
|
||||
timeout-minutes: 3
|
||||
outputs:
|
||||
skip_tests: ${{ steps.dedupe.outputs.skip_tests }}
|
||||
reason: ${{ steps.dedupe.outputs.reason }}
|
||||
steps:
|
||||
- name: Decide test dedup
|
||||
id: dedupe
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
echo "skip_tests=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=push-event-tests-required" >> $GITHUB_OUTPUT
|
||||
echo "push 事件:测试照跑(部署链路门禁必需)"
|
||||
exit 0
|
||||
fi
|
||||
# 情形1:PR 已合并(合并瞬间/合并后触发的 PR run)-> 全量测试由 push 流水线承接
|
||||
MERGED=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('merged') else 'false')" || echo false)
|
||||
if [ "$MERGED" = "true" ]; then
|
||||
echo "skip_tests=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=pr-merged-push-pipeline-covers" >> $GITHUB_OUTPUT
|
||||
echo "::warning::PR #${PR_NUMBER} 已合并,测试由合并后 push 流水线承接,PR 侧测试类 job 跳过"
|
||||
exit 0
|
||||
fi
|
||||
# 情形2:同一 head_sha 已有在跑/排队/已成功的 push 流水线(rebase/ff 合并竞态;
|
||||
# 已成功也去重——竞态窗口内两边都通过判断时,PR侧再跑全量测试纯属重复)
|
||||
DUP=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${HEAD_SHA}&per_page=30" \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); runs=d if isinstance(d,list) else d.get('workflow_runs',d.get('runs',[])); hit=[r for r in runs if r.get('event')=='push' and (r.get('status') in ('in_progress','queued','waiting','pending') or r.get('conclusion')=='success')]; print('true' if hit else 'false')" || echo false)
|
||||
if [ "$DUP" = "true" ]; then
|
||||
echo "skip_tests=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=duplicate-push-run-active" >> $GITHUB_OUTPUT
|
||||
echo "::warning::同一 head_sha ${HEAD_SHA:0:8} 已有 push 流水线在跑,PR 侧测试类 job 跳过"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip_tests=false" >> $GITHUB_OUTPUT
|
||||
echo "reason=no-duplicate" >> $GITHUB_OUTPUT
|
||||
echo "无重复 push 流水线,PR 侧测试照跑"
|
||||
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
runs-on: ci-l2
|
||||
@@ -32,12 +81,6 @@ jobs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Check changed files
|
||||
id: check
|
||||
shell: bash
|
||||
@@ -76,12 +119,14 @@ jobs:
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-code-quality:
|
||||
name: Validate - Code Quality
|
||||
validate-style:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Style
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 6
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
@@ -94,16 +139,23 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-codequality-${{ hashFiles('requirements*.txt') }}
|
||||
key: ${{ runner.os }}-pip-style-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-codequality-
|
||||
${{ runner.os }}-pip-style-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
@@ -127,17 +179,9 @@ jobs:
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install --no-binary :all: black==26.5.1 isort==8.0.1 && break
|
||||
echo "pip install black/isort 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run code quality and security checks
|
||||
- name: Run style checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_code_quality.sh
|
||||
run: bash scripts/ci/validate_style.sh
|
||||
- name: Auto-fix formatting (black + isort)
|
||||
if: failure()
|
||||
shell: sh
|
||||
@@ -153,7 +197,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Code Quality" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Style" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -166,7 +210,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Code Quality" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Style" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -179,10 +223,16 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-type-check:
|
||||
name: Validate - Type Check (mypy)
|
||||
|
||||
validate-security:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Security
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -192,9 +242,125 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-security-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-security-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run security checks
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bash scripts/ci/validate_security.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Security" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Security" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
validate-python:
|
||||
needs: dedupe-check
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true'
|
||||
name: Validate - Python (mypy + alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
PIP_CACHE_DIR: /root/.cache/pip
|
||||
PIP_NO_CACHE_DIR: ''
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-python-${{ hashFiles('requirements*.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-python-
|
||||
${{ runner.os }}-pip-
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
@@ -220,82 +386,6 @@ jobs:
|
||||
- name: Run mypy type check
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_mypy.sh
|
||||
- name: CI failure notification
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Type Check (mypy)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_end.sh
|
||||
- name: Notify on failure
|
||||
continue-on-error: true
|
||||
if: failure()
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Type Check (mypy)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
env:
|
||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
||||
run: |
|
||||
STATUS="ok"
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
validate-migration:
|
||||
name: Validate - Migration (alembic)
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
CI_USE_SHARED_PG: 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Install dependencies
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-base.txt && break
|
||||
echo "pip install requirements-base.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements.txt && break
|
||||
echo "pip install requirements.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
for i in 1 2 3; do
|
||||
python3 -m pip install -q -r requirements-dev.txt && break
|
||||
echo "pip install requirements-dev.txt 失败,重试 $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
sleep 5
|
||||
done
|
||||
- name: Run alembic migration validation
|
||||
shell: bash
|
||||
run: bash scripts/ci/validate_migration.sh
|
||||
@@ -307,7 +397,7 @@ jobs:
|
||||
CI_WEBHOOK_URL: ${{ secrets.CI_WEBHOOK_URL }}
|
||||
run: |
|
||||
set +e
|
||||
FAILED_JOB="Validate - Migration (alembic)" python3 scripts/ci_notify_failure.py
|
||||
FAILED_JOB="Validate - Python (mypy + alembic)" python3 scripts/ci_notify_failure.py
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -320,7 +410,7 @@ jobs:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Migration (alembic)" python3 scripts/ci_notify.py
|
||||
NOTIFY_MODE=failure JOB_NAME="Validate - Python (mypy + alembic)" python3 scripts/ci_notify.py
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -333,9 +423,10 @@ jobs:
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
|
||||
unit-tests:
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
name: Unit Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 8
|
||||
@@ -354,6 +445,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -362,6 +459,7 @@ jobs:
|
||||
run: bash scripts/ci/step_install_ffmpeg.sh
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.cache/pip
|
||||
key: ${{ runner.os }}-pip-unittests-${{ hashFiles('requirements*.txt') }}
|
||||
@@ -412,12 +510,10 @@ jobs:
|
||||
name: Integration Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 30
|
||||
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_backend != 'true'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- dedupe-check
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
||||
USE_IN_MEMORY_DB: 'false'
|
||||
@@ -433,6 +529,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -478,8 +580,8 @@ jobs:
|
||||
name: Frontend Lint
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
needs: check-frontend-only
|
||||
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
if: needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -487,6 +589,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -540,8 +648,8 @@ jobs:
|
||||
name: Frontend Unit Tests
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
needs: check-frontend-only
|
||||
if: always() && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
needs: [check-frontend-only, dedupe-check]
|
||||
if: always() && needs.dedupe-check.outputs.skip_tests != 'true' && needs.check-frontend-only.outputs.skip_frontend != 'true'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -549,11 +657,18 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: /root/.npm
|
||||
key: ${{ runner.os }}-npm-vitest-${{ hashFiles('apps/web/package-lock.json') }}
|
||||
@@ -640,6 +755,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -754,19 +875,13 @@ jobs:
|
||||
skip_backend: ${{ steps.check.outputs.skip_backend }}
|
||||
skip_frontend: ${{ steps.check.outputs.skip_frontend }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Check changed paths
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
bash scripts/ci/ci_push_paths.sh
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_push_paths.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_push_paths.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_push_paths.sh
|
||||
- name: Report CI trace
|
||||
if: always()
|
||||
shell: sh
|
||||
@@ -777,7 +892,7 @@ jobs:
|
||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
@@ -818,6 +933,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1029,9 +1150,7 @@ jobs:
|
||||
- check-push-paths
|
||||
- build-staging
|
||||
- retag-staging-skipped
|
||||
# 显式 success() 状态检查:上游 build/retag 被路径过滤 if 跳过(skipped)时不阻塞本 job;
|
||||
# 上游真正失败时仍然阻断(act_runner 对无状态函数的 if 隐式包 success(),纯 skipped 也会连带跳过)
|
||||
if: success() && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
if: (!cancelled()) && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -1191,6 +1310,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1238,6 +1363,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1277,8 +1408,9 @@ jobs:
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-style
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- frontend-lint
|
||||
- frontend-unit-test
|
||||
@@ -1312,6 +1444,12 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
- name: Zombie run selfcheck
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
run: bash scripts/ci/ci_run_selfcheck.sh
|
||||
- name: Record job start time
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
@@ -1758,9 +1896,9 @@ jobs:
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs:
|
||||
- check-frontend-only
|
||||
- validate-code-quality
|
||||
- validate-type-check
|
||||
- validate-migration
|
||||
- validate-style
|
||||
- validate-security
|
||||
- validate-python
|
||||
- unit-tests
|
||||
- integration-tests
|
||||
- frontend-lint
|
||||
@@ -1768,14 +1906,6 @@ jobs:
|
||||
- build-pr
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
||||
|
||||
- name: Evaluate CI Gate
|
||||
id: gate
|
||||
shell: bash
|
||||
@@ -1784,9 +1914,9 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
|
||||
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
|
||||
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
|
||||
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
|
||||
RESULT_STYLE: ${{ needs.validate-style.result }}
|
||||
RESULT_SECURITY: ${{ needs.validate-security.result }}
|
||||
RESULT_PYTHON: ${{ needs.validate-python.result }}
|
||||
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
|
||||
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
|
||||
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
|
||||
@@ -1798,9 +1928,9 @@ jobs:
|
||||
echo ""
|
||||
echo "各job结果:"
|
||||
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
|
||||
echo " validate-code-quality: $RESULT_CODE_QUALITY"
|
||||
echo " validate-type-check: $RESULT_TYPE_CHECK"
|
||||
echo " validate-migration: $RESULT_MIGRATION"
|
||||
echo " validate-style: $RESULT_STYLE"
|
||||
echo " validate-security: $RESULT_SECURITY"
|
||||
echo " validate-python: $RESULT_PYTHON"
|
||||
echo " unit-tests: $RESULT_UNIT_TESTS"
|
||||
echo " integration-tests: $RESULT_INTEGRATION"
|
||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||
@@ -1840,9 +1970,9 @@ jobs:
|
||||
# 必填检查项(根据PR类型决定)
|
||||
# 通用检查(所有PR都必须过)
|
||||
REQUIRED_GENERAL=(
|
||||
"validate-code-quality:$RESULT_CODE_QUALITY"
|
||||
"validate-type-check:$RESULT_TYPE_CHECK"
|
||||
"validate-migration:$RESULT_MIGRATION"
|
||||
"validate-style:$RESULT_STYLE"
|
||||
"validate-security:$RESULT_SECURITY"
|
||||
"validate-python:$RESULT_PYTHON"
|
||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||
"build-pr:$RESULT_BUILD_PR"
|
||||
"ai-code-review:$AI_REVIEW_STATUS"
|
||||
@@ -1937,4 +2067,4 @@ jobs:
|
||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
curl -sfH "Authorization: token ${GITHUB_TOKEN:-$GITEA_TOKEN}" -o /tmp/_ci_trace.py "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/ci_trace_report.py?ref=${GITHUB_SHA}" 2>/dev/null && python3 /tmp/_ci_trace.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
|
||||
@@ -2,13 +2,13 @@ name: CI Trigger Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *' # 每5分钟检查一次
|
||||
- cron: '*/10 * * * *' # 每10分钟检查一次(与pr-auto-scan同步降频)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_threshold:
|
||||
description: 'CI未触发告警阈值(分钟)'
|
||||
required: false
|
||||
default: '5'
|
||||
default: '10'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -3,7 +3,7 @@ name: PR Auto Scan
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/5 * * * *" # 每5分钟扫描一次
|
||||
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -263,3 +263,4 @@ pytest --cov=packages --cov-report=html
|
||||
---
|
||||
|
||||
**License**: MIT
|
||||
<!-- CI trigger: 1788229339 -->
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Add confirm generation fields
|
||||
|
||||
Revision ID: 034
|
||||
Revises: 033
|
||||
Create Date: 2026-07-08
|
||||
|
||||
确认生成 API 改造:为 generation_tasks 表添加 is_preview、source_task_id、
|
||||
output_width、output_height、cover_url、custom_title 字段。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "034"
|
||||
down_revision = "033"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("is_preview", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_width", sa.Integer(), nullable=False, server_default=sa.text("1280")),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_height", sa.Integer(), nullable=False, server_default=sa.text("720")),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_generation_tasks_source_task_id"),
|
||||
"generation_tasks",
|
||||
["source_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_generation_tasks_source_task_id"), table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "custom_title")
|
||||
op.drop_column("generation_tasks", "cover_url")
|
||||
op.drop_column("generation_tasks", "output_height")
|
||||
op.drop_column("generation_tasks", "output_width")
|
||||
op.drop_column("generation_tasks", "source_task_id")
|
||||
op.drop_column("generation_tasks", "is_preview")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add unique index on asset_libraries(project_id, kind)
|
||||
|
||||
Revision ID: 058_uq_asset_lib_project_kind
|
||||
Revises: 057_title_config
|
||||
Create Date: 2026-08-30
|
||||
|
||||
同一项目下同 kind 的素材库业务上唯一(前端 getOrCreate 语义、TTS 保存自动建库)。
|
||||
加唯一索引兜底并发创建竞态,避免重复素材库。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "058_uq_asset_lib_project_kind"
|
||||
down_revision = "057_title_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 建唯一索引前清洗历史重复:同 (project_id, kind) 只保留 created_at 最新的一条。
|
||||
# project_id 为 NULL 的系统级行不参与去重(NULL 在唯一索引中互不冲突)。
|
||||
op.execute("""
|
||||
DELETE FROM asset_libraries
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY project_id, kind
|
||||
ORDER BY created_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM asset_libraries
|
||||
WHERE project_id IS NOT NULL
|
||||
) t
|
||||
WHERE t.rn > 1
|
||||
)
|
||||
""")
|
||||
# 与 model 的 UniqueConstraint 定义保持一致(pg_constraint + pg_index 同时注册),
|
||||
# 避免 Alembic autogenerate 检测到 schema drift
|
||||
op.create_unique_constraint(
|
||||
"uq_asset_libraries_project_kind",
|
||||
"asset_libraries",
|
||||
["project_id", "kind"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("uq_asset_libraries_project_kind", "asset_libraries", type_="unique")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""add duplicate_rate to generated_videos
|
||||
|
||||
Revision ID: 059_duplicate_rate
|
||||
Revises: 058_uq_asset_lib_project_kind
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "059_duplicate_rate"
|
||||
down_revision = "058_uq_asset_lib_project_kind"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "duplicate_rate")
|
||||
@@ -0,0 +1,57 @@
|
||||
"""migrate template_segments data to template_clip_configs
|
||||
|
||||
Revision ID: 060_migrate_segments
|
||||
Revises: 059_duplicate_rate
|
||||
Create Date: 2026-08-31
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "060_migrate_segments"
|
||||
down_revision = "059_duplicate_rate"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
dialect = op.get_bind().dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN json_build_object('material_type', s.material_type)::jsonb "
|
||||
"ELSE '{}'::jsonb END"
|
||||
)
|
||||
empty_json = "'{}'::jsonb"
|
||||
else:
|
||||
config_expr = (
|
||||
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
|
||||
"THEN JSON_OBJECT('material_type', s.material_type) "
|
||||
"ELSE '{}' END"
|
||||
)
|
||||
empty_json = "'{}'"
|
||||
|
||||
sql_str = (
|
||||
"INSERT INTO template_clip_configs "
|
||||
'(id, template_id, clip_type, "order", min_duration, max_duration, '
|
||||
"text_template, material_requirements, transition_effect, config, "
|
||||
"created_at, updated_at) "
|
||||
"SELECT "
|
||||
"s.id, s.template_id, 'main', s.segment_order, "
|
||||
"s.duration_min, s.duration_max, "
|
||||
"'', " + empty_json + ", "
|
||||
"'cut', " + config_expr + ", "
|
||||
"s.created_at, s.updated_at "
|
||||
"FROM template_segments s "
|
||||
"WHERE NOT EXISTS ("
|
||||
" SELECT 1 FROM template_clip_configs c "
|
||||
" WHERE c.template_id = s.template_id"
|
||||
")"
|
||||
)
|
||||
op.execute(sa.text(sql_str))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -26,7 +26,7 @@ from app.schemas.asset import (
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
from app.schemas.tag import TagAssetsRequest
|
||||
from app.services.asset_segment_tracker import compute_asset_availability
|
||||
from app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
@@ -576,47 +576,93 @@ def smart_match_assets(
|
||||
request.library_id, request.kind, status=["ready"], limit=10000
|
||||
)
|
||||
else:
|
||||
filtered_assets = asset_repository.find_by_library(
|
||||
request.library_id, status=["ready"], limit=10000
|
||||
)
|
||||
filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000)
|
||||
total_candidates = len(filtered_assets)
|
||||
|
||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(
|
||||
filtered_assets,
|
||||
limit=request.limit,
|
||||
kind=None,
|
||||
)
|
||||
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ──────────
|
||||
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补,
|
||||
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。
|
||||
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。
|
||||
|
||||
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
|
||||
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
|
||||
filtered_results = []
|
||||
for r in results:
|
||||
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
|
||||
usable_assets = []
|
||||
exhausted_assets = []
|
||||
for a in filtered_assets:
|
||||
try:
|
||||
avail = compute_asset_availability(r.asset)
|
||||
avail = compute_asset_availability(a)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"smart-match 余量计算失败,按可用处理: asset_id=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(a, "id", "?"),
|
||||
exc_info=True,
|
||||
)
|
||||
avail = None
|
||||
if avail is not None and not avail["usable"]:
|
||||
logger.info(
|
||||
"smart-match 排除已用尽素材: asset_id=%s name=%s",
|
||||
getattr(r.asset, "id", "?"),
|
||||
getattr(r.asset, "name", ""),
|
||||
)
|
||||
continue
|
||||
filtered_results.append(r)
|
||||
exhausted_assets.append(a)
|
||||
else:
|
||||
usable_assets.append(a)
|
||||
|
||||
if exhausted_assets:
|
||||
logger.info(
|
||||
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
|
||||
len(filtered_assets), len(exhausted_assets),
|
||||
)
|
||||
|
||||
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
|
||||
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
|
||||
# (全选同样会选到这些素材,且不经过评分排序)。
|
||||
pool = usable_assets if usable_assets else filtered_assets
|
||||
|
||||
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
MAX_RECENT_USE_COUNT = 3
|
||||
high_freq_assets = set()
|
||||
if pool:
|
||||
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
|
||||
if asset_ids:
|
||||
try:
|
||||
use_counts = get_asset_recent_use_counts(
|
||||
db=asset_repository.session,
|
||||
asset_ids=asset_ids,
|
||||
recent_video_count=5,
|
||||
)
|
||||
for a in pool:
|
||||
aid = getattr(a, "id", "")
|
||||
count = use_counts.get(aid, 0)
|
||||
if count > MAX_RECENT_USE_COUNT:
|
||||
high_freq_assets.add(aid)
|
||||
logger.info(
|
||||
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
|
||||
aid, count, MAX_RECENT_USE_COUNT,
|
||||
)
|
||||
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
|
||||
# 不再全部排除,保留全部可用素材
|
||||
if high_freq_assets:
|
||||
remaining_count = len(pool) - len(high_freq_assets)
|
||||
enough = request.limit is None or remaining_count >= request.limit
|
||||
if remaining_count > 0 and enough:
|
||||
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
|
||||
else:
|
||||
logger.info(
|
||||
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d 条",
|
||||
remaining_count,
|
||||
request.limit if request.limit is not None else "不限",
|
||||
len(pool),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
|
||||
|
||||
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(pool, limit=request.limit, kind=None)
|
||||
|
||||
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
|
||||
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
|
||||
items = [
|
||||
SmartMatchItem(
|
||||
asset=_to_asset_response(r.asset),
|
||||
**_to_asset_response(r.asset).model_dump(),
|
||||
score=r.score,
|
||||
breakdown=r.breakdown,
|
||||
)
|
||||
for r in filtered_results
|
||||
for r in results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
|
||||
@@ -17,7 +17,7 @@ def get_ingest_job(
|
||||
) -> IngestJobResponse:
|
||||
job = ingest_job_repository.get(job_id)
|
||||
if job is None:
|
||||
raise ValueError(f"IngestJob {job_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"IngestJob {job_id} not found")
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
project_id=job.project_id,
|
||||
|
||||
@@ -37,6 +37,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
||||
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
|
||||
SQLAlchemyTemplateClipConfigRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
@@ -367,7 +370,6 @@ def batch_delete_editor_clips(
|
||||
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
|
||||
|
||||
|
||||
|
||||
def _safe_segment_duration(value, default: float) -> float:
|
||||
"""安全地将数据库中的时长值转换为正浮点数.
|
||||
|
||||
@@ -403,9 +405,7 @@ def _get_template_segments(
|
||||
if clip_configs:
|
||||
result = []
|
||||
for cc in clip_configs:
|
||||
dur_min = _safe_segment_duration(
|
||||
cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION
|
||||
)
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
@@ -414,7 +414,25 @@ def _get_template_segments(
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
|
||||
logger.warning("新模板系统查询clip_configs失败(主表可能不存在),直接查clip_configs表", exc_info=True)
|
||||
|
||||
# 兜底:直接查 template_clip_configs 表(片段表有 template_id 外键,不依赖模板主表)
|
||||
try:
|
||||
direct_repo = SQLAlchemyTemplateClipConfigRepository(db)
|
||||
direct_configs = direct_repo.list_by_template(template_id)
|
||||
if direct_configs:
|
||||
result = []
|
||||
for cc in direct_configs:
|
||||
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
|
||||
dur_max = _safe_segment_duration(
|
||||
cc.max_duration or cc.min_duration,
|
||||
_DEFAULT_EDITOR_CLIP_DURATION,
|
||||
)
|
||||
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
|
||||
result.append((cc.order, dur_min, dur_max))
|
||||
return sorted(result, key=lambda x: x[0])
|
||||
except Exception:
|
||||
logger.warning("直接查clip_configs表也失败,继续回退旧系统", exc_info=True)
|
||||
|
||||
# 回退到旧模板系统(template_segments表)
|
||||
try:
|
||||
@@ -492,7 +510,7 @@ def _get_mediakit_recommendations(
|
||||
prompt = (
|
||||
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
|
||||
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
|
||||
'请严格以JSON数组格式返回,不要包含其他文字:'
|
||||
"请严格以JSON数组格式返回,不要包含其他文字:"
|
||||
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
|
||||
)
|
||||
|
||||
@@ -545,9 +563,7 @@ def _get_mediakit_recommendations(
|
||||
|
||||
# 尝试正则提取
|
||||
if not parsed:
|
||||
time_match = re.search(
|
||||
r'recommended_start_time["\s:]+([\d.]+)', content_text
|
||||
)
|
||||
time_match = re.search(r'recommended_start_time["\s:]+([\d.]+)', content_text)
|
||||
if time_match:
|
||||
try:
|
||||
recommendations[asset_id] = float(time_match.group(1))
|
||||
@@ -598,14 +614,17 @@ def create_clips_from_assets_editor(
|
||||
detail="模板没有片段配置,无法创建片段",
|
||||
)
|
||||
|
||||
if not body.asset_ids:
|
||||
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
|
||||
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
|
||||
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
|
||||
if not asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="素材列表为空,无法创建片段",
|
||||
)
|
||||
|
||||
# 2. 获取素材实际时长(去重查询)
|
||||
unique_asset_ids = list(dict.fromkeys(body.asset_ids))
|
||||
unique_asset_ids = list(dict.fromkeys(asset_ids))
|
||||
asset_durations: dict[str, float] = {}
|
||||
for asset_id in unique_asset_ids:
|
||||
asset = asset_repo.get(asset_id)
|
||||
@@ -615,9 +634,7 @@ def create_clips_from_assets_editor(
|
||||
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
|
||||
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
|
||||
# 格式与 _calc_random_start_time 的 used_segments 参数一致
|
||||
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(
|
||||
db, unique_asset_ids
|
||||
)
|
||||
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(db, unique_asset_ids)
|
||||
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
|
||||
# 复用片段时长累加到 reused_durations 供 15% 占比控制
|
||||
reused_durations: dict[str, float] = {}
|
||||
@@ -655,9 +672,9 @@ def create_clips_from_assets_editor(
|
||||
asset_id = ""
|
||||
clip_duration = 0.0
|
||||
start_time: float | None = None
|
||||
n_assets = len(body.asset_ids)
|
||||
n_assets = len(asset_ids)
|
||||
for offset in range(n_assets):
|
||||
candidate = body.asset_ids[(i + offset) % n_assets]
|
||||
candidate = asset_ids[(i + offset) % n_assets]
|
||||
candidate_total = asset_durations.get(candidate, 0.0)
|
||||
if candidate_total <= 0:
|
||||
continue
|
||||
@@ -701,18 +718,12 @@ def create_clips_from_assets_editor(
|
||||
)
|
||||
|
||||
# 记录已使用时间段(内存,供本次后续片段避开)
|
||||
used_segments.setdefault(asset_id, []).append(
|
||||
(start_time, start_time + clip_duration)
|
||||
)
|
||||
asset_assigned_durations[asset_id] = (
|
||||
asset_assigned_durations.get(asset_id, 0.0) + clip_duration
|
||||
)
|
||||
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
|
||||
asset_assigned_durations[asset_id] = asset_assigned_durations.get(asset_id, 0.0) + clip_duration
|
||||
# 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional
|
||||
# 处于同一事务,任一步失败整体回滚,不留脏数据);
|
||||
# 复用区间与历史记录高度重叠时 record 内部自动累加 use_count
|
||||
record_used_segments(
|
||||
db, asset_id, start_time, start_time + clip_duration, plan_id
|
||||
)
|
||||
record_used_segments(db, asset_id, start_time, start_time + clip_duration, plan_id)
|
||||
|
||||
clips_data.append(
|
||||
{
|
||||
@@ -803,18 +814,14 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
|
||||
# 批量预加载所有涉及的素材(消除 N+1 查询)
|
||||
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
|
||||
assets_map: dict[str, object] = {
|
||||
a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)
|
||||
}
|
||||
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
|
||||
|
||||
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
|
||||
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
|
||||
for clip in clips:
|
||||
aid = getattr(clip, "asset_id", "") or ""
|
||||
if aid and clip.start_time is not None:
|
||||
clips_by_asset[aid].append(
|
||||
(clip.id, clip.start_time, clip.start_time + clip.duration)
|
||||
)
|
||||
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
|
||||
|
||||
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
|
||||
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
|
||||
@@ -861,6 +868,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if cid != clip.id and cid not in updated_clip_ids
|
||||
]
|
||||
other_segments.extend(updated_segments.get(asset_id, []))
|
||||
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
|
||||
# 本 plan 片段创建时已写入历史记录
|
||||
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
|
||||
@@ -869,9 +877,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
def _norm(segs):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
|
||||
|
||||
other_segments = list(
|
||||
_norm(other_segments) | _norm(historical_segments.get(asset_id, []))
|
||||
)
|
||||
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
|
||||
|
||||
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
|
||||
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
|
||||
@@ -893,9 +899,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
|
||||
plan_svc.update_clip(clip.id, start_time=recommended_start)
|
||||
try:
|
||||
if remove_used_segment(
|
||||
db, asset_id, old_start, old_end, plan_id=plan_id
|
||||
):
|
||||
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
|
||||
record_used_segments(
|
||||
db,
|
||||
asset_id,
|
||||
@@ -915,18 +919,14 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
updated_count += 1
|
||||
updated_clip_ids.add(clip.id)
|
||||
except Exception as ue:
|
||||
logger.warning(
|
||||
"后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue
|
||||
)
|
||||
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
updated_segments.setdefault(asset_id, []).append(
|
||||
(recommended_start, recommended_start + clip_duration)
|
||||
)
|
||||
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
|
||||
logger.info(
|
||||
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
|
||||
clip.id,
|
||||
@@ -950,4 +950,3 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -167,7 +167,18 @@ class ClipsFromAssetsRequest(BaseModel):
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量")
|
||||
required_clips_count: Optional[int] = Field(
|
||||
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
|
||||
)
|
||||
|
||||
@validator("asset_ids", pre=True)
|
||||
def _drop_invalid_asset_ids(cls, v): # noqa: N805
|
||||
"""容错过滤:前端异常情况下可能把 undefined 序列化成 null 或空串混入
|
||||
asset_ids(会直接 422 或导致后续 /assets/{id} 404),这里统一剔除。
|
||||
过滤后为空时由 Field(min_length=1) / 路由层 400 兜底。"""
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
return [x for x in v if isinstance(x, str) and x.strip()]
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
|
||||
+195
-59
@@ -3,17 +3,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_user_repository,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
from app.schemas.tts import (
|
||||
ListTTSJobResponse,
|
||||
@@ -27,12 +31,12 @@ from app.schemas.tts import (
|
||||
TTSSynthesizeResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
|
||||
SQLAlchemyTTSJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
from packages.application.tts_job.use_cases import (
|
||||
@@ -44,13 +48,12 @@ from packages.application.tts_job.use_cases import (
|
||||
TTSJobNotFoundError,
|
||||
)
|
||||
from packages.application.tts_job.workflow import TTSWorkflowService
|
||||
from packages.application.voice_library.commands import CreateVoiceLibraryCommand
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus
|
||||
from packages.domain.voice_presets import list_voices
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,27 +137,47 @@ def synthesize(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
|
||||
if request.voice_clone_profile_id:
|
||||
profile = voice_clone_repo.get(request.voice_clone_profile_id)
|
||||
if profile is None:
|
||||
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
|
||||
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
|
||||
actual_voice_id = request.voice_id
|
||||
voice_clone_profile_id = request.voice_clone_profile_id
|
||||
resolved_profile = None
|
||||
if actual_voice_id:
|
||||
resolved_profile = voice_clone_repo.get(actual_voice_id)
|
||||
if resolved_profile is not None:
|
||||
voice_clone_profile_id = actual_voice_id
|
||||
|
||||
# 显式传了 voice_clone_profile_id(且与 voice_id 不同)时再查一次归属
|
||||
if voice_clone_profile_id and (resolved_profile is None or resolved_profile.id != voice_clone_profile_id):
|
||||
resolved_profile = voice_clone_repo.get(voice_clone_profile_id)
|
||||
if resolved_profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Voice clone profile not found",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
|
||||
if resolved_profile is not None:
|
||||
if resolved_profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to voice clone profile",
|
||||
detail="无权访问该音色",
|
||||
)
|
||||
if not resolved_profile.voice_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="音色克隆尚未完成,请稍后再试",
|
||||
)
|
||||
# 命中克隆音色:无论 voice_id 直接传 profile UUID 还是显式传 voice_clone_profile_id,
|
||||
# job.voice_id 统一存解析后的 CosyVoice voice_id
|
||||
actual_voice_id = resolved_profile.voice_id
|
||||
|
||||
use_case = CreateTTSJobUseCase(repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
input_text=request.text,
|
||||
voice_id=request.voice_id,
|
||||
voice_id=actual_voice_id,
|
||||
voice_model=request.voice_model,
|
||||
voice_clone_profile_id=request.voice_clone_profile_id,
|
||||
voice_clone_profile_id=voice_clone_profile_id,
|
||||
metadata=request.metadata_,
|
||||
)
|
||||
|
||||
@@ -284,6 +307,62 @@ def delete_tts_job(
|
||||
return
|
||||
|
||||
|
||||
def _find_or_create_voice_library(
|
||||
*,
|
||||
user_id: str,
|
||||
project_repository: ProjectRepository,
|
||||
asset_library_repository: Any, # port Protocol 声明为 async,SQLAlchemy 实现为同步,与 upload/asset_libraries 路由惯例一致用 Any
|
||||
) -> AssetLibrary:
|
||||
"""在用户可访问的项目中找到(或自动创建)voice 素材库。
|
||||
|
||||
与前端配音素材页逻辑一致:素材库挂在项目下,配音素材读取
|
||||
getAssetsByKind("voice") → 用户所有可访问项目中的 voice 库。
|
||||
优先使用已有 voice 库;没有则在第一个可访问项目中自动创建。
|
||||
"""
|
||||
projects = project_repository.find_accessible_projects(user_id)
|
||||
if not projects:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="没有可用的项目,请先创建项目后再保存配音素材",
|
||||
)
|
||||
|
||||
for project in projects:
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
return lib
|
||||
|
||||
# 所有项目都没有 voice 库 → 在第一个可访问项目中自动创建默认配音素材库。
|
||||
# asset_libraries 有 (project_id, kind) 唯一索引兜底并发:若两个请求同时创建,
|
||||
# 落败方捕获 IntegrityError 回滚后重新查询,返回抢先创建成功的库。
|
||||
project = projects[0]
|
||||
library = AssetLibrary.create(
|
||||
project_id=project.id,
|
||||
name="配音素材库",
|
||||
kind=AssetLibraryKind.VOICE,
|
||||
)
|
||||
try:
|
||||
return asset_library_repository.create(library)
|
||||
except IntegrityError:
|
||||
# 并发下另一个请求已抢先创建:回滚当前事务(立即 commit 模式下 session 已
|
||||
# 自动回滚,rollback 为幂等 no-op;UoW/flush 模式下必须显式回滚才能继续查询),
|
||||
# 再重查返回抢先创建成功的库。
|
||||
session = getattr(asset_library_repository, "session", None)
|
||||
if session is not None:
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception:
|
||||
logger.warning("IntegrityError 后回滚 session 失败(可能已关闭)", exc_info=True)
|
||||
for lib in asset_library_repository.find_by_project(project.id):
|
||||
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||
if kind == AssetLibraryKind.VOICE.value:
|
||||
return lib
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="配音素材库创建失败,请重试",
|
||||
) from None # IntegrityError 已处理,不保留异常链
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jobs/{job_id}/save-to-library",
|
||||
response_model=SaveToLibraryResponse,
|
||||
@@ -294,13 +373,17 @@ def save_tts_job_to_library(
|
||||
request: SaveToLibraryRequest = SaveToLibraryRequest(),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
asset_repository: AssetRepository = Depends(get_asset_repository),
|
||||
asset_library_repository: AssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
project_repository: ProjectRepository = Depends(get_project_repository),
|
||||
storage_service: SharedStorageService = Depends(get_storage_service),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> SaveToLibraryResponse:
|
||||
"""将已完成的 TTS 合成结果保存到配音库。
|
||||
"""将已完成的 TTS 合成结果保存到配音素材库(assets 表新素材体系)。
|
||||
|
||||
自动携带音色名、时长、语速等元信息。
|
||||
流程:把 TTS 输出音频转存到用户素材 OSS 路径 → 创建 file_type=audio、
|
||||
status=ready 的 asset(挂用户 voice 素材库)→ 返回前端可用结构。
|
||||
配额策略与素材上传一致(上传/ingest 链路无额外配额拦截)。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
@@ -318,64 +401,117 @@ def save_tts_job_to_library(
|
||||
detail="TTS job is not completed yet",
|
||||
)
|
||||
|
||||
# 构建配音素材名称
|
||||
if not job.output_audio_url and not job.output_audio_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="TTS job 缺少输出音频,无法保存",
|
||||
)
|
||||
|
||||
# 素材名称
|
||||
name = request.name or f"TTS-{job.id[:8]}"
|
||||
|
||||
# 构建元信息
|
||||
metadata_ = {
|
||||
# 找到(或自动创建)用户 voice 素材库
|
||||
library = _find_or_create_voice_library(
|
||||
user_id=user_id,
|
||||
project_repository=project_repository,
|
||||
asset_library_repository=asset_library_repository,
|
||||
)
|
||||
|
||||
# 转存音频到素材 OSS 路径(tts-outputs/ 下的产物归 TTS 任务所有,
|
||||
# 素材独立持有副本,删除 TTS 任务不影响配音库素材)
|
||||
audio_format = (job.format or "mp3").strip() or "mp3"
|
||||
content_type_map = {
|
||||
"mp3": "audio/mpeg",
|
||||
"wav": "audio/wav",
|
||||
"pcm": "audio/pcm",
|
||||
"opus": "audio/opus",
|
||||
}
|
||||
content_type = content_type_map.get(audio_format, "audio/mpeg")
|
||||
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
|
||||
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
# 优先用 OSS storage_key(走 oss2 SDK,私有 bucket 也可下载);
|
||||
# 兜底用 output_audio_url(旧任务可能没有 key)。
|
||||
# download_asset 自动识别输入:http(s):// 开头走 HTTP 下载,否则按 OSS key 走 SDK。
|
||||
download_source = job.output_audio_key or job.output_audio_url
|
||||
downloaded = storage_service.download_asset(download_source, tmp_path)
|
||||
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="TTS 音频下载失败,无法保存到配音库",
|
||||
)
|
||||
file_size = tmp_path.stat().st_size
|
||||
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="TTS 音频转存失败,无法保存到配音库",
|
||||
) from e
|
||||
finally:
|
||||
if tmp_path and tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 构建素材元信息
|
||||
metadata_: dict[str, object] = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
"voice_id": job.voice_id,
|
||||
"voice_name": job.voice_model or "",
|
||||
}
|
||||
if job.metadata:
|
||||
# 保留原始 job 的有用元信息
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata_[key] = job.metadata[key]
|
||||
|
||||
# 获取用户套餐(用于配额检查)
|
||||
user = user_repository.find_by_id(user_id)
|
||||
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
|
||||
|
||||
# 构建命令并执行
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
asset = Asset.create(
|
||||
project_id=library.project_id,
|
||||
library_id=library.id,
|
||||
name=name,
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id or "",
|
||||
tags=[],
|
||||
metadata_=metadata_,
|
||||
storage_key=storage_key,
|
||||
mime_type=content_type,
|
||||
metadata=metadata_,
|
||||
file_size=file_size,
|
||||
duration=job.duration or None,
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
|
||||
try:
|
||||
item = use_case.execute(command, plan_name=plan_name or "free")
|
||||
except QuotaExceededError as exc:
|
||||
asset = asset_repository.create(asset)
|
||||
except Exception as e:
|
||||
# DB 写入失败:清理已上传到 OSS 的素材文件,避免产生无法索引的孤儿文件
|
||||
logger.error("素材记录创建失败,清理 OSS 文件: %s, error=%s", storage_key, e, exc_info=True)
|
||||
try:
|
||||
storage_service.delete_file(storage_key)
|
||||
except Exception:
|
||||
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="素材保存失败,请重试",
|
||||
) from e
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
name=item.name,
|
||||
audio_url=sign_url(item.audio_url) if item.audio_url else "",
|
||||
duration=item.duration,
|
||||
voice_id=item.voice_id,
|
||||
voice_name=item.voice_name,
|
||||
status=item.status,
|
||||
id=asset.id,
|
||||
name=asset.name,
|
||||
audio_url=sign_url(storage_key),
|
||||
duration=asset.duration or 0.0,
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
status="completed",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/preview", response_model=TTSPreviewResponse)
|
||||
def preview_tts(
|
||||
request: TTSPreviewRequest,
|
||||
|
||||
@@ -52,6 +52,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
|
||||
duplicate_rate=getattr(item, "duplicate_rate", None),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_cosyvoice_service,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
ListVoiceCloneResponse,
|
||||
@@ -32,6 +38,9 @@ from packages.application.voice_clone.use_cases import (
|
||||
from packages.application.voice_clone.workflow import (
|
||||
VoiceCloneWorkflowService,
|
||||
)
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,23 +92,68 @@ def create_voice_clone(
|
||||
request: CreateVoiceCloneRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
|
||||
asset_repository: AssetRepository = Depends(get_asset_repository),
|
||||
project_repository: ProjectRepository = Depends(get_project_repository),
|
||||
storage_service: SharedStorageService = Depends(get_storage_service),
|
||||
) -> VoiceCloneProfileResponse:
|
||||
"""创建音色克隆任务。
|
||||
|
||||
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
|
||||
如果有 source_audio_url,状态会变为 processing;否则保持 pending。
|
||||
参考音频两种来源(二选一):
|
||||
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
|
||||
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
|
||||
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
|
||||
如果有参考音频,状态会变为 processing;否则保持 pending。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
source_audio_url = request.source_audio_url
|
||||
clone_metadata = dict(request.metadata_ or {})
|
||||
|
||||
if request.asset_id:
|
||||
if source_audio_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="asset_id 与 source_audio_url 只能传一个",
|
||||
)
|
||||
asset = asset_repository.find_by_id(request.asset_id)
|
||||
if asset is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="素材不存在",
|
||||
)
|
||||
# 归属校验:素材挂在项目素材库下,用户必须能访问该项目
|
||||
project = project_repository.find_by_id(asset.project_id)
|
||||
if project is None or not project.can_access(user_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="无权使用该素材",
|
||||
)
|
||||
# 类型校验:仅支持音频素材
|
||||
if asset.file_type != "audio":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="仅支持音频素材进行音色克隆",
|
||||
)
|
||||
if not asset.storage_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该素材缺少音频文件,无法用于克隆",
|
||||
)
|
||||
# 用 OSS storage_key 生成服务端预签名 URL(7 天有效,覆盖克隆重试周期)
|
||||
source_audio_url = storage_service.get_download_url(asset.storage_key, expires_seconds=7 * 24 * 3600)
|
||||
clone_metadata["source_asset_id"] = asset.id
|
||||
|
||||
profile = workflow.start_clone(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
source_audio_url=request.source_audio_url,
|
||||
source_audio_url=source_audio_url,
|
||||
voice_model=request.voice_model,
|
||||
language=request.language,
|
||||
gender=request.gender,
|
||||
max_retries=request.max_retries,
|
||||
metadata=request.metadata_,
|
||||
metadata=clone_metadata,
|
||||
)
|
||||
|
||||
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
|
||||
|
||||
@@ -129,10 +129,13 @@ class SmartMatchRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SmartMatchItem(BaseModel):
|
||||
"""智能选素材结果条目。"""
|
||||
class SmartMatchItem(AssetResponse):
|
||||
"""智能选素材结果条目(扁平结构)。
|
||||
|
||||
素材字段(id/usable/余量等)直接挂在条目顶层,前端拿到 item 即可读 item.id,
|
||||
与 AssetResponse 字段完全一致;score/breakdown 为智能匹配附加的评分字段。
|
||||
"""
|
||||
|
||||
asset: AssetResponse
|
||||
score: float = Field(..., ge=0, le=100, description="综合得分 0-100")
|
||||
breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细")
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class VideoItemResponse(BaseModel):
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
generated_at: str = ""
|
||||
duplicate_rate: float | None = None
|
||||
|
||||
|
||||
class ListVideosResponse(BaseModel):
|
||||
|
||||
@@ -13,7 +13,8 @@ class CreateVoiceCloneRequest(BaseModel):
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="音色名称")
|
||||
description: str = Field("", description="音色描述")
|
||||
source_audio_url: str = Field("", description="参考音频 URL")
|
||||
source_audio_url: str = Field("", description="参考音频 URL(与 asset_id 二选一)")
|
||||
asset_id: str = Field("", description="参考音频素材 ID(配音素材库中的音频 asset,与 source_audio_url 二选一)")
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
gender: str = Field("unknown", description="性别")
|
||||
|
||||
@@ -438,3 +438,57 @@ def make_reuse_callback(
|
||||
return result
|
||||
|
||||
return _reuse
|
||||
|
||||
|
||||
def get_asset_recent_use_counts(
|
||||
db: Session,
|
||||
asset_ids: list[str],
|
||||
recent_video_count: int = 5,
|
||||
) -> dict[str, int]:
|
||||
"""统计每个素材在最近 N 个不同 plan_id 中的使用次数。
|
||||
|
||||
遍历素材 metadata 中的 used_time_ranges,统计有多少个不同的 plan_id(去重),
|
||||
返回 {asset_id: count}。只统计最近 recent_video_count 个不同 plan_id 的使用次数。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
asset_ids: 素材 ID 列表
|
||||
recent_video_count: 统计最近多少个不同 plan_id
|
||||
|
||||
Returns:
|
||||
{asset_id: 在最近 recent_video_count 个 plan 中的使用次数}
|
||||
"""
|
||||
if not asset_ids:
|
||||
return {}
|
||||
|
||||
result: dict[str, int] = {}
|
||||
models = db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
for model in models:
|
||||
meta = _read_meta(model)
|
||||
ranges = meta.get(USED_RANGES_KEY) or []
|
||||
if not ranges:
|
||||
result[model.id] = 0
|
||||
continue
|
||||
|
||||
# 按 created_at 倒序收集不同 plan_id
|
||||
sorted_ranges = sorted(
|
||||
ranges,
|
||||
key=lambda r: r.get("created_at") or "",
|
||||
reverse=True,
|
||||
)
|
||||
recent_plan_ids: set[str] = set()
|
||||
for r in sorted_ranges:
|
||||
plan_id = r.get("plan_id")
|
||||
if plan_id:
|
||||
recent_plan_ids.add(plan_id)
|
||||
if len(recent_plan_ids) >= recent_video_count:
|
||||
break
|
||||
|
||||
result[model.id] = len(recent_plan_ids)
|
||||
|
||||
# 未找到的素材计为 0
|
||||
for aid in asset_ids:
|
||||
if aid not in result:
|
||||
result[aid] = 0
|
||||
|
||||
return result
|
||||
|
||||
@@ -52,12 +52,40 @@ export const getAssetsByKind = async (
|
||||
/**
|
||||
* 智能匹配素材(后端 AI 选素材)
|
||||
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
|
||||
*
|
||||
* 后端返回 items 元素兼容两种结构(过渡期):
|
||||
* - 扁平结构:AssetItem 本身(id 在顶层)
|
||||
* - 包装结构:{ asset: AssetItem, score, breakdown }(id 需从 .asset 取)
|
||||
* 这里统一归一化为 AssetItem[],调用方无需关心包装层。
|
||||
*/
|
||||
export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
|
||||
const response = await apiClient.post("/assets/smart-match", {
|
||||
library_id: libraryId,
|
||||
})
|
||||
return response.data
|
||||
export interface SmartMatchResult {
|
||||
items: AssetItem[]
|
||||
}
|
||||
|
||||
interface SmartMatchWrappedItem {
|
||||
asset?: AssetItem
|
||||
id?: string
|
||||
score?: number
|
||||
breakdown?: unknown
|
||||
}
|
||||
|
||||
export const smartMatchAssets = async (
|
||||
libraryId: string,
|
||||
limit?: number,
|
||||
): Promise<SmartMatchResult> => {
|
||||
const payload: Record<string, unknown> = { library_id: libraryId }
|
||||
if (limit && limit > 0) payload.limit = limit
|
||||
const response = await apiClient.post("/assets/smart-match", payload)
|
||||
const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? []
|
||||
const items = rawItems
|
||||
.map((it) =>
|
||||
// 包装结构 { asset: {...} } 优先解包;否则视其本身为扁平 AssetItem
|
||||
it?.asset && typeof it.asset === "object" && "id" in it.asset
|
||||
? it.asset
|
||||
: (it as unknown as AssetItem),
|
||||
)
|
||||
.filter((it): it is AssetItem => !!it && typeof it.id === "string" && it.id.length > 0)
|
||||
return { items }
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
* 素材诊断 API
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import { getOrCreateDefaultProject } from "../projects"
|
||||
import type { AssetDiagnosis } from "./types"
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
export const getAssetDiagnosis = async (
|
||||
assetId?: string,
|
||||
projectId?: string,
|
||||
): Promise<AssetDiagnosis> => {
|
||||
const pid = projectId ?? (await getOrCreateDefaultProject()).id
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
const response = await apiClient.get(`/projects/${pid}/asset-diagnosis`, { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ export type {
|
||||
// 素材诊断
|
||||
export { getAssetDiagnosis } from "./diagnosis"
|
||||
|
||||
// 素材余量/可用性判断
|
||||
export { isAssetUsable } from "./usage"
|
||||
export type { AssetUsageLike } from "./usage"
|
||||
|
||||
// 素材库
|
||||
export {
|
||||
getAssetLibraries,
|
||||
@@ -39,7 +43,13 @@ export {
|
||||
} from "./assets"
|
||||
|
||||
// 上传
|
||||
export { prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
||||
export {
|
||||
prepareDirectUpload,
|
||||
completeDirectUpload,
|
||||
uploadAssetDirect,
|
||||
prepareDirectUploadHandle,
|
||||
type DirectUploadHandle,
|
||||
} from "./upload"
|
||||
|
||||
// 任务
|
||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
||||
|
||||
@@ -40,6 +40,10 @@ export interface AssetItem {
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number
|
||||
/** 已切片段占用时长占比(0~1,后端片段重复率控制机制返回;字段缺失视为未统计) */
|
||||
used_ratio?: number | null
|
||||
/** 是否已彻底用尽(无新区间且历史区间复用次数均达上限);false 的素材不参与生成选片 */
|
||||
usable?: boolean | null
|
||||
status?: string
|
||||
classification_status?: AssetClassificationStatus | null
|
||||
quality_score?: number | null
|
||||
@@ -129,6 +133,12 @@ export interface DirectUploadPrepareResult {
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
/**
|
||||
* prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。
|
||||
* 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。
|
||||
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
|
||||
*/
|
||||
asset_id?: string
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
@@ -136,4 +146,8 @@ export interface DirectUploadCompleteResult {
|
||||
storage_key: string
|
||||
ingest_job_id: string
|
||||
url: string
|
||||
/** 同库已存在相同 file_hash 的素材时为 true,ingest_job_id 为空 */
|
||||
duplicated?: boolean
|
||||
/** duplicated 为 true 时返回已存在素材的 id */
|
||||
asset_id?: string
|
||||
}
|
||||
|
||||
@@ -27,28 +27,18 @@ export const completeDirectUpload = async (data: {
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */
|
||||
const putToOSS = (
|
||||
prepared: DirectUploadPrepareResult,
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", file)
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", data.file)
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
|
||||
@@ -56,8 +46,8 @@ export const uploadAssetDirect = async (data: {
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
@@ -102,9 +92,53 @@ export const uploadAssetDirect = async (data: {
|
||||
xhr.send(directForm)
|
||||
})
|
||||
|
||||
return completeDirectUpload({
|
||||
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */
|
||||
export interface DirectUploadHandle {
|
||||
/** prepare 返回(含可能的预建 asset_id) */
|
||||
prepared: DirectUploadPrepareResult
|
||||
/** 直传 OSS(可重复调用用于重试) */
|
||||
transfer: (onProgress?: (percent: number) => void) => Promise<void>
|
||||
/** 直传完成后调用 complete 确认入库 */
|
||||
complete: () => Promise<DirectUploadCompleteResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
|
||||
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
|
||||
*/
|
||||
export const prepareDirectUploadHandle = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
}): Promise<DirectUploadHandle> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
})
|
||||
|
||||
return {
|
||||
prepared,
|
||||
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
|
||||
complete: () =>
|
||||
completeDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
|
||||
await handle.transfer(data.onProgress)
|
||||
return handle.complete()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 素材余量/可用性判断
|
||||
* 后端片段重复率控制机制(任意两条成片画面重复率 ≤15%)上线后,
|
||||
* 素材列表会附加 usable / used_ratio 字段。字段未上线前一律按可用处理。
|
||||
*/
|
||||
|
||||
/** 仅依赖素材余量相关字段的最小结构,api 层与 pages 层 AssetItem 均可传入 */
|
||||
export interface AssetUsageLike {
|
||||
usable?: boolean | null
|
||||
used_ratio?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材是否仍可参与生成选片。
|
||||
* usable === false 表示已彻底用尽(无新区间且复用次数全部达上限);
|
||||
* 字段缺失(undefined/null)时降级为可用,保证后端字段上线前零影响。
|
||||
*/
|
||||
export const isAssetUsable = (asset: AssetUsageLike): boolean => asset.usable !== false
|
||||
@@ -44,14 +44,19 @@ export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
/** 创建克隆音色(audio_url 与 asset_id 二选一) */
|
||||
export const createVoiceClone = async (
|
||||
data: CreateVoiceCloneRequest,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const payload: CreateVoiceCloneRequestFull = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
}
|
||||
// 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆
|
||||
if (data.asset_id) {
|
||||
payload.asset_id = data.asset_id
|
||||
} else {
|
||||
payload.source_audio_url = data.audio_url
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
|
||||
@@ -22,10 +22,13 @@ export interface VoiceClone {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
/** 创建克隆请求(前端简化版:audio_url 与 asset_id 二选一) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string
|
||||
audio_url: string
|
||||
/** 录音/文件上传后的音频 URL(与 asset_id 二选一) */
|
||||
audio_url?: string
|
||||
/** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */
|
||||
asset_id?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -72,11 +75,13 @@ export interface VoiceCloneStatusResponse {
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
/** 后端创建克隆请求(完整版:source_audio_url 与 asset_id 二选一) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
source_audio_url?: string
|
||||
/** 从配音素材选择克隆时传素材 ID */
|
||||
asset_id?: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
|
||||
@@ -149,53 +149,29 @@
|
||||
|
||||
/* ── 上传区域 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-upload-zone {
|
||||
border: 2px dashed var(--xx-color-border, #e5e7eb);
|
||||
/* ── 素材选择空态 ─────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-asset-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--xx-color-border, #e5e7eb);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 28px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--xx-color-bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone:hover {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.03);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone--active {
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-zone--has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--xx-color-primary, #6366f1);
|
||||
background: rgba(99, 102, 241, 0.04);
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--xx-color-text, #111827);
|
||||
margin: 0 0 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-clonemodal-upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
.xx-clonemodal-asset-empty-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
/* ── 错误提示 ───────────────────────────────────────────── */
|
||||
|
||||
.xx-clonemodal-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,14 +7,5 @@ export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
|
||||
|
||||
/** 文件选择器 accept 属性 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
||||
import { validateFile } from "./utils"
|
||||
import { PROGRESS_STEPS } from "./constants"
|
||||
import { formatRecordTime } from "./utils"
|
||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
||||
import type { CloneModalProps, ModalPhase } from "./types"
|
||||
import "./clone-modal.css"
|
||||
@@ -18,15 +20,21 @@ const getExtensionFromMime = (mime: string): string => {
|
||||
return "webm"
|
||||
}
|
||||
|
||||
/** 格式化素材时长(秒 → mm:ss) */
|
||||
const formatAssetDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "--:--"
|
||||
return formatRecordTime(Math.round(seconds))
|
||||
}
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const navigate = useNavigate()
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
/** 从配音素材选择的素材 ID */
|
||||
const [selectedAssetId, setSelectedAssetId] = useState<string>("")
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1)
|
||||
@@ -34,6 +42,14 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
const isMountedRef = useRef(true)
|
||||
const isSubmittingRef = useRef(false)
|
||||
|
||||
/* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */
|
||||
const { data: voiceAssets, isLoading: assetsLoading } = useQuery({
|
||||
queryKey: ["assets", "voice", "clone-modal"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 100 }),
|
||||
enabled: open,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
/* ── 录音 Hook ──────────────────────────────────── */
|
||||
const {
|
||||
isRecording,
|
||||
@@ -55,8 +71,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setSelectedAssetId("")
|
||||
// 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit;
|
||||
// 此前在此上锁且无复位路径,弹窗打开即死锁
|
||||
setErrorMessage("")
|
||||
@@ -85,65 +100,26 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────── */
|
||||
/* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
|
||||
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
}
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/* ── 拖拽 ──────────────────────────────────────── */
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false)
|
||||
const handleSelectAsset = (assetId: string) => {
|
||||
setSelectedAssetId(assetId)
|
||||
if (assetId) {
|
||||
resetRecorder()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
// 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作,
|
||||
// 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
resetRecorder()
|
||||
}
|
||||
const handleToggleRecord = () => {
|
||||
// 开始录音会清掉已选素材;停止录音保留录音结果
|
||||
if (!isRecording) {
|
||||
setSelectedAssetId("")
|
||||
}
|
||||
toggleRecord()
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
const hasAudio = selectedAssetId !== "" || recordedBlob !== null
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
const canSubmit = hasAudio && !isProcessing
|
||||
|
||||
@@ -158,7 +134,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
return
|
||||
}
|
||||
if (!hasAudio) {
|
||||
setErrorMessage("请上传音频文件或录制一段声音")
|
||||
setErrorMessage("请从配音素材选择一段音频,或直接录制声音")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,20 +143,37 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
// 路径 A:从配音素材选择 → 无需上传,直接克隆
|
||||
if (selectedAssetId) {
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name,
|
||||
description: voiceDescription.trim() || undefined,
|
||||
asset_id: selectedAssetId,
|
||||
})
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
isSubmittingRef.current = false
|
||||
setPhase("done")
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
// 路径 B:录音 → 先上传为配音素材,再克隆
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
||||
const mimeType = recordedBlob?.type || "audio/webm"
|
||||
const ext = getExtensionFromMime(mimeType)
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
||||
type: mimeType,
|
||||
})
|
||||
}
|
||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
||||
const mimeType = recordedBlob?.type || "audio/webm"
|
||||
const ext = getExtensionFromMime(mimeType)
|
||||
const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
||||
type: mimeType,
|
||||
})
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
@@ -226,6 +219,8 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
}
|
||||
}
|
||||
|
||||
const hasAssets = (voiceAssets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -244,7 +239,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<div className="xx-clonemodal-steps">
|
||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
||||
<div className="xx-clonemodal-step-number">1</div>
|
||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
||||
<span className="xx-clonemodal-step-label">选择/录制音频</span>
|
||||
</div>
|
||||
<div className="xx-clonemodal-step-connector" />
|
||||
<div className="xx-clonemodal-step">
|
||||
@@ -274,30 +269,42 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
{/* 从配音素材选择 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">上传音频</label>
|
||||
<div
|
||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||
<p className="xx-clonemodal-upload-title">
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<label className="xx-clonemodal-label">从配音素材选择</label>
|
||||
{hasAssets ? (
|
||||
<select
|
||||
className="xx-clonemodal-input"
|
||||
value={selectedAssetId}
|
||||
onChange={(e) => handleSelectAsset(e.target.value)}
|
||||
disabled={assetsLoading}
|
||||
>
|
||||
<option value="">{assetsLoading ? "素材加载中…" : "请选择已上传的配音素材"}</option>
|
||||
{voiceAssets!.map((asset) => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.name}({formatAssetDuration(asset.duration)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="xx-clonemodal-asset-empty">
|
||||
<p className="xx-clonemodal-asset-empty-text">
|
||||
{assetsLoading ? "素材加载中…" : "请先在配音库上传素材"}
|
||||
</p>
|
||||
{!assetsLoading && (
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => {
|
||||
handleClose()
|
||||
navigate("/app/voice-materials")
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
@@ -332,7 +339,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={toggleRecord}
|
||||
onClick={handleToggleRecord}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
@@ -365,7 +372,7 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
<span>建议使用 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
@@ -415,18 +422,18 @@ const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) =>
|
||||
{/* 当前阶段描述 */}
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
<p className="xx-clonemodal-progress-text">正在上传录音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将录音上传至服务器</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
|
||||
|
||||
/**
|
||||
* 验证音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
export const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import UploadZone from "./UploadZone"
|
||||
import RecordArea from "./RecordArea"
|
||||
import StepIndicator from "./StepIndicator"
|
||||
import { MAX_VOICE_NAME_LENGTH, MAX_VOICE_DESC_LENGTH } from "../constants/cloneModal"
|
||||
|
||||
interface InputViewProps {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
errorMessage: string
|
||||
canSubmit: boolean
|
||||
onVoiceNameChange: (value: string) => void
|
||||
onVoiceDescChange: (value: string) => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
onRecordToggle: () => void
|
||||
onClose: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const INPUT_STEPS = ["上传/录制音频", "填写信息", "提交克隆"]
|
||||
|
||||
const InputView: React.FC<InputViewProps> = ({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
errorMessage,
|
||||
canSubmit,
|
||||
onVoiceNameChange,
|
||||
onVoiceDescChange,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
onRecordToggle,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-body">
|
||||
{/* 步骤引导 */}
|
||||
<StepIndicator currentStep={0} steps={INPUT_STEPS} />
|
||||
|
||||
{/* 音色名称 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">
|
||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="xx-clonemodal-input"
|
||||
value={voiceName}
|
||||
onChange={(e) => onVoiceNameChange(e.target.value)}
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={MAX_VOICE_NAME_LENGTH}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/{MAX_VOICE_NAME_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">上传音频</label>
|
||||
<UploadZone
|
||||
selectedFile={selectedFile}
|
||||
dragActive={dragActive}
|
||||
onDragActiveChange={onDragActiveChange}
|
||||
onFileSelect={onFileSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 或分隔 */}
|
||||
<div className="xx-clonemodal-divider">
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
<span className="xx-clonemodal-divider-text">或</span>
|
||||
<div className="xx-clonemodal-divider-line" />
|
||||
</div>
|
||||
|
||||
{/* 录制区域 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">直接录制</label>
|
||||
<RecordArea
|
||||
isRecording={isRecording}
|
||||
recordTime={recordTime}
|
||||
recordedBlob={recordedBlob}
|
||||
onRecordToggle={onRecordToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色描述 */}
|
||||
<div className="xx-clonemodal-field">
|
||||
<label className="xx-clonemodal-label">音色描述</label>
|
||||
<textarea
|
||||
className="xx-clonemodal-textarea"
|
||||
value={voiceDescription}
|
||||
onChange={(e) => onVoiceDescChange(e.target.value)}
|
||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
||||
maxLength={MAX_VOICE_DESC_LENGTH}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceDescription.length}/{MAX_VOICE_DESC_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{errorMessage && (
|
||||
<div className="xx-clonemodal-error">
|
||||
<span className="xx-clonemodal-error-icon">⚠️</span>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="xx-clonemodal-footer">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button buttonType="primary" disabled={!canSubmit} onClick={onSubmit}>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputView
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from "react"
|
||||
import { PROGRESS_STEPS } from "../constants/cloneModal"
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
|
||||
interface ProgressViewProps {
|
||||
phase: ModalPhase
|
||||
}
|
||||
|
||||
const getProgressIndex = (phase: ModalPhase): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1
|
||||
case "done":
|
||||
return 2
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const ProgressView: React.FC<ProgressViewProps> = ({ phase }) => {
|
||||
const progressIndex = getProgressIndex(phase)
|
||||
const isDone = phase === "done"
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-progress-body">
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step: ProgressStep, idx: number) => {
|
||||
const isActive = idx === progressIndex && !isDone
|
||||
const stepDone = idx < progressIndex || isDone
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
stepDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
{idx > 0 && (
|
||||
<div
|
||||
className={`xx-clonemodal-step-connector${stepDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">{stepDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 完成阶段 */}
|
||||
{isDone && (
|
||||
<div className="xx-clonemodal-success">
|
||||
<div className="xx-clonemodal-success-icon">🎉</div>
|
||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
||||
<p className="xx-clonemodal-success-desc">
|
||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进行中阶段 */}
|
||||
{!isDone && (
|
||||
<div className="xx-clonemodal-progress-info">
|
||||
{phase === "uploading" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressView
|
||||
@@ -1,49 +0,0 @@
|
||||
import React from "react"
|
||||
import { formatRecordTime } from "../utils/cloneModal"
|
||||
|
||||
interface RecordAreaProps {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
onRecordToggle: () => void
|
||||
}
|
||||
|
||||
const RecordArea: React.FC<RecordAreaProps> = ({
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
onRecordToggle,
|
||||
}) => {
|
||||
const getHintText = () => {
|
||||
if (isRecording) return `录制中 ${formatRecordTime(recordTime)}`
|
||||
if (recordedBlob) return `已录制 ${formatRecordTime(recordTime)}`
|
||||
return "点击按钮开始录制(最长 5 分钟)"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-clonemodal-record-area">
|
||||
<div className="xx-clonemodal-record-info">
|
||||
<p className="xx-clonemodal-record-hint">{getHintText()}</p>
|
||||
{isRecording && (
|
||||
<div className="xx-clonemodal-record-wave">
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
<span className="xx-clonemodal-record-wave-bar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
||||
onClick={onRecordToggle}
|
||||
title={isRecording ? "停止录制" : "开始录制"}
|
||||
>
|
||||
{isRecording ? "⏹" : "🎙️"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordArea
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入阶段顶部的步骤引导(数字步骤)
|
||||
*/
|
||||
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep, steps }) => {
|
||||
return (
|
||||
<div className="xx-clonemodal-steps">
|
||||
{steps.map((label, idx) => {
|
||||
const isActive = idx <= currentStep
|
||||
return (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <div className="xx-clonemodal-step-connector" />}
|
||||
<div className={`xx-clonemodal-step${isActive ? " xx-clonemodal-step--active" : ""}`}>
|
||||
<div className="xx-clonemodal-step-number">{idx + 1}</div>
|
||||
<span className="xx-clonemodal-step-label">{label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StepIndicator
|
||||
@@ -1,79 +0,0 @@
|
||||
import React, { useRef } from "react"
|
||||
import { ACCEPTED_MIME } from "../constants/cloneModal"
|
||||
import { validateFile } from "../utils/cloneModal"
|
||||
|
||||
interface UploadZoneProps {
|
||||
selectedFile: File | null
|
||||
dragActive: boolean
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
onFileSelect: (file: File | null, error: string) => void
|
||||
}
|
||||
|
||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
||||
selectedFile,
|
||||
dragActive,
|
||||
onDragActiveChange,
|
||||
onFileSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
onDragActiveChange(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
onDragActiveChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onDragActiveChange(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file)
|
||||
onFileSelect(error ? null : file, error || "")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
||||
onClick={handleUploadClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||
<p className="xx-clonemodal-upload-title">
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_MIME}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadZone
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ProgressStep } from "../types/cloneModal"
|
||||
|
||||
/** 进度阶段配置 */
|
||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
]
|
||||
|
||||
/** 支持的音频扩展名 */
|
||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
|
||||
/** input accept 属性值 */
|
||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
|
||||
/** 最大文件大小:10MB */
|
||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 最长录制时长(秒):5 分钟 */
|
||||
export const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/** 音色名称最小长度 */
|
||||
export const MIN_VOICE_NAME_LENGTH = 2
|
||||
|
||||
/** 音色名称最大长度 */
|
||||
export const MAX_VOICE_NAME_LENGTH = 20
|
||||
|
||||
/** 音色描述最大长度 */
|
||||
export const MAX_VOICE_DESC_LENGTH = 100
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { MAX_RECORD_SECONDS } from "../constants/cloneModal"
|
||||
|
||||
interface UseAudioRecorderReturn {
|
||||
isRecording: boolean
|
||||
recordTime: number
|
||||
recordedBlob: Blob | null
|
||||
toggleRecording: () => void
|
||||
resetRecording: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 录音 Hook —— 封装 MediaRecorder 录音逻辑
|
||||
*/
|
||||
const useAudioRecorder = (): UseAudioRecorderReturn => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||
setRecordedBlob(blob)
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
setTimeout(() => {
|
||||
stopRecording()
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
// 错误由调用方通过其他机制提示
|
||||
setIsRecording(false)
|
||||
}
|
||||
}, [stopRecording])
|
||||
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (isRecording) {
|
||||
stopRecording()
|
||||
} else {
|
||||
startRecording()
|
||||
}
|
||||
}, [isRecording, startRecording, stopRecording])
|
||||
|
||||
const resetRecording = useCallback(() => {
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
}, [])
|
||||
|
||||
// 卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
toggleRecording,
|
||||
resetRecording,
|
||||
}
|
||||
}
|
||||
|
||||
export default useAudioRecorder
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import type { ModalPhase } from "../types/cloneModal"
|
||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||
import useAudioRecorder from "./useAudioRecorder"
|
||||
|
||||
/**
|
||||
* 克隆弹窗表单状态 Hook
|
||||
* 管理表单字段、录音、文件选择、验证逻辑
|
||||
*/
|
||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||
useAudioRecorder()
|
||||
|
||||
/** 默认音色名称计数器 */
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||
hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
resetRecording()
|
||||
}, [getNextDefaultName, resetRecording])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState])
|
||||
|
||||
/** 选择文件(来自上传或拖拽) */
|
||||
const handleFileSelect = useCallback(
|
||||
(file: File | null, error: string) => {
|
||||
if (error) {
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
resetRecording()
|
||||
}
|
||||
},
|
||||
[resetRecording],
|
||||
)
|
||||
|
||||
/** 录音切换 */
|
||||
const handleRecordToggle = useCallback(() => {
|
||||
setErrorMessage("")
|
||||
if (isRecording) {
|
||||
toggleRecording()
|
||||
} else {
|
||||
// 开始录制前清除已选文件
|
||||
setSelectedFile(null)
|
||||
toggleRecording()
|
||||
}
|
||||
}, [isRecording, toggleRecording])
|
||||
|
||||
/** 表单验证 */
|
||||
const validateForm = useCallback((): string | null => {
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null
|
||||
}, [voiceName, hasAudio])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
phase,
|
||||
setPhase,
|
||||
voiceName,
|
||||
setVoiceName,
|
||||
voiceDescription,
|
||||
setVoiceDescription,
|
||||
selectedFile,
|
||||
dragActive,
|
||||
setDragActive,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
// 录音
|
||||
isRecording,
|
||||
recordTime,
|
||||
recordedBlob,
|
||||
// 计算属性
|
||||
hasAudio,
|
||||
canSubmit,
|
||||
isProcessing,
|
||||
// handlers
|
||||
handleFileSelect,
|
||||
handleRecordToggle,
|
||||
handleClose,
|
||||
validateForm,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { CloneModalProps } from "../types/cloneModal"
|
||||
import { useCloneFormState } from "./useCloneFormState"
|
||||
import { useCloneSubmit } from "./useCloneSubmit"
|
||||
|
||||
/**
|
||||
* 音色克隆弹窗主业务 Hook
|
||||
* 组合表单状态 + 提交流程两个子 Hook
|
||||
*/
|
||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
||||
const formState = useCloneFormState({ open, onClose })
|
||||
|
||||
const { handleSubmit } = useCloneSubmit({
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
setPhase: formState.setPhase,
|
||||
setErrorMessage: formState.setErrorMessage,
|
||||
validateForm: formState.validateForm,
|
||||
onSuccess,
|
||||
onClose: formState.handleClose,
|
||||
})
|
||||
|
||||
return {
|
||||
phase: formState.phase,
|
||||
voiceName: formState.voiceName,
|
||||
voiceDescription: formState.voiceDescription,
|
||||
selectedFile: formState.selectedFile,
|
||||
dragActive: formState.dragActive,
|
||||
errorMessage: formState.errorMessage,
|
||||
isRecording: formState.isRecording,
|
||||
recordTime: formState.recordTime,
|
||||
recordedBlob: formState.recordedBlob,
|
||||
canSubmit: formState.canSubmit,
|
||||
isProcessing: formState.isProcessing,
|
||||
setVoiceName: formState.setVoiceName,
|
||||
setVoiceDescription: formState.setVoiceDescription,
|
||||
setDragActive: formState.setDragActive,
|
||||
handleFileSelect: formState.handleFileSelect,
|
||||
handleRecordToggle: formState.handleRecordToggle,
|
||||
handleClose: formState.handleClose,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCloneModal
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRef, useCallback, useEffect } from "react"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||
import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets"
|
||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
interface UseCloneSubmitOptions {
|
||||
voiceName: string
|
||||
voiceDescription: string
|
||||
selectedFile: File | null
|
||||
recordedBlob: Blob | null
|
||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
||||
setErrorMessage: (msg: string) => void
|
||||
validateForm: () => string | null
|
||||
onSuccess?: (clone: VoiceClone) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 克隆提交流程 Hook
|
||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
||||
*/
|
||||
export function useCloneSubmit({
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
validateForm,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: UseCloneSubmitOptions) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/** 组件卸载时清理定时器 */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
// 获取默认项目和素材库
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
||||
|
||||
// 直传到 OSS
|
||||
const uploadResult = await uploadAssetDirect({
|
||||
file: fileToUpload,
|
||||
library_id: library.id,
|
||||
})
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
}, [
|
||||
validateForm,
|
||||
selectedFile,
|
||||
recordedBlob,
|
||||
voiceName,
|
||||
voiceDescription,
|
||||
setPhase,
|
||||
setErrorMessage,
|
||||
onSuccess,
|
||||
onClose,
|
||||
])
|
||||
|
||||
return { handleSubmit }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/** 弹窗阶段 */
|
||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/** 进度步骤项 */
|
||||
export interface ProgressStep {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "../constants/cloneModal"
|
||||
|
||||
/**
|
||||
* 格式化录制时间 mm:ss
|
||||
*/
|
||||
export const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证上传的音频文件
|
||||
* @returns 错误信息,null 表示验证通过
|
||||
*/
|
||||
export const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import LibrarySidebar from "@/pages/assets/components/LibrarySidebar"
|
||||
import AssetFilterBar from "@/pages/assets/components/AssetFilterBar"
|
||||
import BatchOperationBar from "@/pages/assets/components/BatchOperationBar"
|
||||
import AssetUploadZone from "@/pages/assets/components/AssetUploadZone"
|
||||
import UploadQueuePanel from "@/pages/assets/components/UploadQueuePanel"
|
||||
import AssetGridSection from "@/pages/assets/components/AssetGridSection"
|
||||
import AssetModals from "@/pages/assets/components/AssetModals"
|
||||
import { useAssetsData } from "@/pages/assets/hooks/useAssetsData"
|
||||
@@ -69,7 +70,30 @@ const AssetLibrary: React.FC = () => {
|
||||
})
|
||||
|
||||
/* ── 上传 ── */
|
||||
const { uploading, uploadProgress, handleUpload } = useAssetUpload({ effectiveLibId })
|
||||
const {
|
||||
uploadItems,
|
||||
enqueueUploads,
|
||||
retryUpload,
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
uploading,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
} = useAssetUpload({ effectiveLibId })
|
||||
|
||||
/* ── 上传中 asset_id → 进度/状态映射,合并进网格卡片展示真实进度 ── */
|
||||
const uploadProgressMap = React.useMemo(() => {
|
||||
const map = new Map<string, { progress: number; uploading: boolean }>()
|
||||
for (const it of uploadItems) {
|
||||
if (it.assetId && (it.status === "uploading" || it.status === "ingesting")) {
|
||||
map.set(it.assetId, {
|
||||
progress: it.status === "ingesting" ? 100 : it.progress,
|
||||
uploading: it.status === "uploading",
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [uploadItems])
|
||||
|
||||
/* ── 选中态管理 ── */
|
||||
const { selectedIds, setSelectedIds, toggleSelect, selectAll, deselectAll } = useAssetSelection({
|
||||
@@ -144,8 +168,17 @@ const AssetLibrary: React.FC = () => {
|
||||
{/* 上传区域 */}
|
||||
<AssetUploadZone
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
onUpload={handleUpload}
|
||||
activeCount={activeCount}
|
||||
pendingCount={pendingCount}
|
||||
onUpload={enqueueUploads}
|
||||
/>
|
||||
|
||||
{/* 上传队列:独立进度 + 失败重试/移除 */}
|
||||
<UploadQueuePanel
|
||||
items={uploadItems}
|
||||
onRetry={retryUpload}
|
||||
onRemove={removeUpload}
|
||||
onClearFinished={clearFinished}
|
||||
/>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
@@ -180,6 +213,7 @@ const AssetLibrary: React.FC = () => {
|
||||
assets={filteredAssets}
|
||||
selectedIds={selectedIds}
|
||||
diagnosingId={diagnosingId}
|
||||
uploadProgressMap={uploadProgressMap}
|
||||
onRetry={refetchAssets}
|
||||
onToggleSelect={toggleSelect}
|
||||
onDiagnose={handleDiagnose}
|
||||
@@ -191,8 +225,6 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
{/* ─── 弹窗集合 ─── */}
|
||||
<AssetModals
|
||||
uploading={uploading}
|
||||
uploadProgress={uploadProgress}
|
||||
createModalOpen={createModalOpen}
|
||||
onCreateModalCancel={() => setCreateModalOpen(false)}
|
||||
onCreateModalOk={handleCreateLibrary}
|
||||
|
||||
@@ -147,44 +147,52 @@
|
||||
/* ============================================================
|
||||
上传区域
|
||||
============================================================ */
|
||||
.xx-asset-upload-zone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-2xl) var(--space-xl);
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
.xx-asset-upload-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px dashed transparent;
|
||||
border-radius: var(--radius-md);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
.xx-asset-upload-zone:hover {
|
||||
.xx-asset-upload-entry-dragover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-asset-upload-zone:active {
|
||||
border-style: solid;
|
||||
transform: scale(0.99);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.xx-asset-upload-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: var(--space-sm);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.xx-asset-upload-text {
|
||||
font-size: var(--font-size-base) !important;
|
||||
color: var(--text-primary) !important;
|
||||
margin: 0 0 var(--space-xs) !important;
|
||||
.xx-asset-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: 6px 16px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-inverse);
|
||||
background: var(--primary-color);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-asset-upload-hint {
|
||||
font-size: var(--font-size-sm) !important;
|
||||
color: var(--text-tertiary) !important;
|
||||
margin: 0 !important;
|
||||
.xx-asset-upload-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.xx-asset-upload-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.xx-asset-upload-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -215,7 +223,7 @@
|
||||
============================================================ */
|
||||
.xx-asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -234,7 +242,7 @@
|
||||
.xx-asset-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.xx-asset-card:active {
|
||||
@@ -244,7 +252,7 @@
|
||||
|
||||
/* 缩略图 */
|
||||
.xx-asset-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 3 / 4;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
@@ -260,22 +268,22 @@
|
||||
}
|
||||
|
||||
.xx-asset-thumb-placeholder {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-size: var(--font-size-xl);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 播放按钮 */
|
||||
.xx-asset-play {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(4px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
font-size: var(--font-size-md);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: var(--transition-all);
|
||||
}
|
||||
|
||||
@@ -332,8 +340,8 @@
|
||||
position: absolute;
|
||||
bottom: var(--space-sm, 8px);
|
||||
right: var(--space-sm, 8px);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
@@ -380,12 +388,12 @@
|
||||
|
||||
/* 卡片信息 */
|
||||
.xx-asset-info {
|
||||
padding: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.xx-asset-name {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0 0 4px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
@@ -397,9 +405,67 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 状态标签行:标签过长省略 */
|
||||
.xx-asset-meta-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-asset-meta-status .xx-status-pill {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-asset-meta-duration {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 余量标签独占一行 */
|
||||
.xx-asset-meta-usage {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 视频素材余量角标(仅状态展示,不影响卡片操作) */
|
||||
.xx-asset-usage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 已用尽:红色实心 */
|
||||
.xx-asset-usage-badge-exhausted {
|
||||
background: var(--error-color);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* 即将用尽:红色软底 */
|
||||
.xx-asset-usage-badge-warning {
|
||||
background: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* 已用 xx%:橙色软底 */
|
||||
.xx-asset-usage-badge-ratio {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
/* 诊断按钮 */
|
||||
@@ -443,12 +509,16 @@
|
||||
.xx-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xxs) var(--space-sm);
|
||||
gap: 2px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xx-status-pill-ok {
|
||||
@@ -592,7 +662,7 @@
|
||||
============================================================ */
|
||||
.xx-assets-skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@@ -607,6 +677,10 @@
|
||||
.xx-asset-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.xx-assets-skeleton-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@@ -630,6 +704,10 @@
|
||||
.xx-asset-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.xx-assets-skeleton-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -641,6 +719,10 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.xx-assets-skeleton-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.xx-assets-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -660,43 +742,132 @@
|
||||
.xx-asset-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-assets-skeleton-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 上传进度弹窗 ─── */
|
||||
.xx-upload-progress-modal .ant-modal-content {
|
||||
padding: 24px 16px 20px;
|
||||
border-radius: 16px;
|
||||
/* ─── 上传队列面板 ─── */
|
||||
.xx-upload-queue {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-upload-progress-body {
|
||||
.xx-upload-queue-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-primary, #eef2f7);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.xx-upload-progress-ring {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-upload-progress-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-upload-progress-pct {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.xx-upload-progress-label {
|
||||
.xx-upload-queue-title {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-upload-queue-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-upload-queue-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-item + .xx-upload-queue-item {
|
||||
border-top: 1px solid var(--border-primary, #f1f5f9);
|
||||
}
|
||||
|
||||
.xx-upload-queue-icon {
|
||||
padding-top: 2px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-upload-queue-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary, #1e293b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.xx-upload-queue-progress {
|
||||
margin-top: 6px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--border-primary, #e5e7eb);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-upload-queue-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--primary-color, #6366f1);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.xx-upload-queue-status {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-upload-queue-error .xx-upload-queue-status {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-upload-queue-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.xx-upload-queue-btn:hover {
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
/* ─── 素材卡片上传中遮罩进度条 ─── */
|
||||
.xx-asset-thumb-uploading {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-asset-upload-bar {
|
||||
width: 70%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-asset-upload-bar-inner {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
/* ─── 批量打标签弹窗 ─── */
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Popconfirm } from "antd"
|
||||
import type { AssetItem } from "@/pages/assets/types"
|
||||
import { getUsageBadge, type AssetItem } from "@/pages/assets/types"
|
||||
import { thumbGradient } from "@/pages/assets/utils/asset"
|
||||
import { kindIcon } from "@/pages/assets/utils/kindIcon"
|
||||
import { StatusPill } from "./AssetSkeleton"
|
||||
@@ -20,6 +20,8 @@ export interface AssetCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
diagnosing?: boolean
|
||||
/** 上传中实时进度(仅 uploading 态有值;ingesting 后由后端状态接管) */
|
||||
uploadProgress?: { progress: number; uploading: boolean }
|
||||
onToggle: () => void
|
||||
onDiagnose: () => void
|
||||
onPlay: () => void
|
||||
@@ -30,99 +32,128 @@ const AssetCard: React.FC<AssetCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
diagnosing,
|
||||
uploadProgress,
|
||||
onToggle,
|
||||
onDiagnose,
|
||||
onPlay,
|
||||
onDelete,
|
||||
}) => (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
}) => {
|
||||
const isUploading = !!uploadProgress?.uploading
|
||||
// 视频素材余量角标(已用尽/即将用尽/已用 xx%);非视频或字段缺失返回 null
|
||||
const usageBadge = getUsageBadge(asset)
|
||||
return (
|
||||
<div className={`xx-asset-card${selected ? " xx-asset-card-selected" : ""}`} onClick={onToggle}>
|
||||
{/* 缩略图区 */}
|
||||
<div className="xx-asset-thumb" style={{ background: thumbGradient(asset.kind) }}>
|
||||
{asset.thumbUrl ? (
|
||||
<img src={asset.thumbUrl} alt={asset.name} />
|
||||
) : (
|
||||
<span className="xx-asset-thumb-placeholder">
|
||||
{asset.loading ? <LoadingOutlined /> : kindIcon(asset.kind)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 处理中遮罩 */}
|
||||
{asset.loading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>处理中</span>
|
||||
{/* 上传中遮罩:真实进度百分比 + 进度条 */}
|
||||
{isUploading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-uploading">
|
||||
<LoadingOutlined />
|
||||
<span>上传中 {uploadProgress?.progress ?? 0}%</span>
|
||||
<div className="xx-asset-upload-bar">
|
||||
<div
|
||||
className="xx-asset-upload-bar-inner"
|
||||
style={{ width: `${uploadProgress?.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 转码/处理中遮罩 */}
|
||||
{asset.loading && !isUploading && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-processing">
|
||||
<LoadingOutlined />
|
||||
<span>转码处理中</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<span className="xx-asset-meta-status">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
</span>
|
||||
{asset.duration && <span className="xx-asset-meta-duration">{asset.duration}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 失败状态标识 */}
|
||||
{asset.status === "bad" && asset.statusLabel === "处理失败" && (
|
||||
<div className="xx-asset-thumb-overlay xx-asset-thumb-failed">
|
||||
<CloseCircleOutlined />
|
||||
<span>处理失败</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频/配音类显示播放按钮(处理中/失败不显示) */}
|
||||
{asset.kind === "video" && !asset.loading && asset.status !== "bad" && (
|
||||
<span
|
||||
className="xx-asset-play"
|
||||
{usageBadge && (
|
||||
<div className="xx-asset-meta xx-asset-meta-usage">
|
||||
<span className={`xx-asset-usage-badge xx-asset-usage-badge-${usageBadge.variant}`}>
|
||||
{usageBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPlay()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="删除后不可恢复,确定要删除这个素材吗?"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<span className="xx-asset-delete" onClick={(e) => e.stopPropagation()}>
|
||||
<DeleteOutlined />
|
||||
</span>
|
||||
</Popconfirm>
|
||||
|
||||
{/* 选中态勾选 */}
|
||||
{selected && (
|
||||
<span className="xx-asset-check">
|
||||
<CheckOutlined />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区 */}
|
||||
<div className="xx-asset-info">
|
||||
<p className="xx-asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</p>
|
||||
<div className="xx-asset-meta">
|
||||
<StatusPill status={asset.status} label={asset.statusLabel} />
|
||||
{asset.duration && <span>{asset.duration}</span>}
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`}
|
||||
disabled={diagnosing || asset.loading || asset.status === "bad"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDiagnose()
|
||||
}}
|
||||
>
|
||||
{diagnosing ? <LoadingOutlined /> : <ExperimentOutlined />}
|
||||
{diagnosing ? "诊断中..." : "诊断"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetCard
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { AssetItem } from "../types"
|
||||
import AssetCard from "./AssetCard"
|
||||
import { SkeletonCard } from "./AssetSkeleton"
|
||||
|
||||
/** 上传中素材的实时进度(asset_id → 进度信息),由上传队列合并到卡片 */
|
||||
export type UploadProgressMap = Map<string, { progress: number; uploading: boolean }>
|
||||
|
||||
export interface AssetGridSectionProps {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
@@ -15,6 +18,7 @@ export interface AssetGridSectionProps {
|
||||
assets: AssetItem[]
|
||||
selectedIds: Set<string>
|
||||
diagnosingId: string | null
|
||||
uploadProgressMap?: UploadProgressMap
|
||||
onRetry?: () => void
|
||||
onToggleSelect: (id: string) => void
|
||||
onDiagnose: (asset: AssetItem) => void
|
||||
@@ -29,6 +33,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
assets,
|
||||
selectedIds,
|
||||
diagnosingId,
|
||||
uploadProgressMap,
|
||||
onRetry,
|
||||
onToggleSelect,
|
||||
onDiagnose,
|
||||
@@ -70,6 +75,7 @@ export const AssetGridSection: React.FC<AssetGridSectionProps> = ({
|
||||
asset={asset}
|
||||
selected={selectedIds.has(asset.id)}
|
||||
diagnosing={diagnosingId === asset.id}
|
||||
uploadProgress={uploadProgressMap?.get(asset.id)}
|
||||
onToggle={() => onToggleSelect(asset.id)}
|
||||
onDiagnose={() => onDiagnose(asset)}
|
||||
onPlay={() => onPlay(asset)}
|
||||
|
||||
@@ -11,12 +11,9 @@ import BatchTagModal from "./BatchTagModal"
|
||||
import BatchClassifyModal from "./BatchClassifyModal"
|
||||
import BatchMarkModal from "./BatchMarkModal"
|
||||
import ResultDrawer from "./ResultDrawer"
|
||||
import UploadProgressModal from "./UploadProgressModal"
|
||||
|
||||
export interface AssetModalsProps {
|
||||
/* 上传进度 */
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
|
||||
/* 新建视频库 */
|
||||
createModalOpen: boolean
|
||||
@@ -68,8 +65,6 @@ export interface AssetModalsProps {
|
||||
}
|
||||
|
||||
export const AssetModals: React.FC<AssetModalsProps> = ({
|
||||
uploading,
|
||||
uploadProgress,
|
||||
createModalOpen,
|
||||
onCreateModalCancel,
|
||||
onCreateModalOk,
|
||||
@@ -109,9 +104,6 @@ export const AssetModals: React.FC<AssetModalsProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 上传进度弹窗 */}
|
||||
<UploadProgressModal open={uploading} progress={uploadProgress} />
|
||||
|
||||
{/* 新建视频库弹窗 */}
|
||||
<CreateLibraryModal
|
||||
open={createModalOpen}
|
||||
|
||||
@@ -1,37 +1,91 @@
|
||||
/**
|
||||
* AssetLibrary 上传拖拽区域
|
||||
* AssetLibrary 上传入口(紧凑按钮模式)
|
||||
* - 点击按钮打开文件选择(多选),多文件入队由 useAssetUpload 队列控制(最多 3 路直传)
|
||||
* - 拖拽文件到内容区任意位置同样触发上传(不再占用大面积虚线框)
|
||||
*/
|
||||
import React from "react"
|
||||
import { Upload } from "antd"
|
||||
import { InboxOutlined } from "@ant-design/icons"
|
||||
import React, { useRef, useState } from "react"
|
||||
import { PlusOutlined, CloudUploadOutlined } from "@ant-design/icons"
|
||||
|
||||
export interface AssetUploadZoneProps {
|
||||
uploading: boolean
|
||||
uploadProgress: number
|
||||
onUpload: (file: File) => void
|
||||
activeCount: number
|
||||
pendingCount: number
|
||||
onUpload: (files: File[]) => void
|
||||
}
|
||||
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({ uploading, onUpload }) => {
|
||||
export const AssetUploadZone: React.FC<AssetUploadZoneProps> = ({
|
||||
uploading,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
onUpload,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// dragenter/dragleave 在经过子元素时会成对触发,用计数器避免高亮闪烁;
|
||||
// 计数器归零(拖拽真正离开容器)才取消高亮
|
||||
const dragDepthRef = useRef(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const pickFiles = (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return
|
||||
onUpload(Array.from(list))
|
||||
}
|
||||
|
||||
return (
|
||||
<Upload.Dragger
|
||||
beforeUpload={(file) => {
|
||||
onUpload(file as File)
|
||||
return false
|
||||
<div
|
||||
className={`xx-asset-upload-entry${dragOver ? " xx-asset-upload-entry-dragover" : ""}`}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault()
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDragOver(false)
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
dragDepthRef.current = 0
|
||||
setDragOver(false)
|
||||
pickFiles(e.dataTransfer.files)
|
||||
}}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
>
|
||||
<div className="xx-asset-upload-zone">
|
||||
<p className="xx-asset-upload-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="xx-asset-upload-text">
|
||||
{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}
|
||||
</p>
|
||||
<p className="xx-asset-upload-hint">支持视频、图片,单文件不超过 2GB</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-asset-upload-btn"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<PlusOutlined />
|
||||
上传素材
|
||||
</button>
|
||||
<span className="xx-asset-upload-status">
|
||||
{uploading ? (
|
||||
<>
|
||||
<CloudUploadOutlined />
|
||||
上传中…(进行 {activeCount} 个{pendingCount > 0 ? `,排队 ${pendingCount} 个` : ""})
|
||||
</>
|
||||
) : (
|
||||
"视频、图片均可,单文件不超过 2GB;也可直接拖拽文件到此区域"
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="video/*,image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
pickFiles(e.target.files)
|
||||
// 允许连续选择同一文件
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
|
||||
/* ============================================================
|
||||
* UploadProgressModal — 上传进度弹窗(圆形动画 + 百分比)
|
||||
* ============================================================ */
|
||||
export interface UploadProgressModalProps {
|
||||
open: boolean
|
||||
progress: number
|
||||
}
|
||||
|
||||
const UploadProgressModal: React.FC<UploadProgressModalProps> = ({ open, progress }) => (
|
||||
<AntModal
|
||||
open={open}
|
||||
footer={null}
|
||||
closable={false}
|
||||
centered
|
||||
width={260}
|
||||
maskClosable={false}
|
||||
className="xx-upload-progress-modal"
|
||||
>
|
||||
<div className="xx-upload-progress-body">
|
||||
<svg className="xx-upload-progress-ring" viewBox="0 0 120 120" width={120} height={120}>
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--border-primary, #e5e7eb)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
{/* 进度圆弧 */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #6366f1)"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 52}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 52 * (1 - progress / 100)}`}
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: "stroke-dashoffset 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="xx-upload-progress-text">
|
||||
<span className="xx-upload-progress-pct">{progress}%</span>
|
||||
<span className="xx-upload-progress-label">上传中…</span>
|
||||
</div>
|
||||
</div>
|
||||
</AntModal>
|
||||
)
|
||||
|
||||
export default UploadProgressModal
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 上传队列面板
|
||||
* 展示批量上传中每个文件的独立状态/进度;失败可重试、可移除、可清空已完成。
|
||||
* 上传中的素材卡片同时也会出现在素材网格(后端 prepare 预建 asset),
|
||||
* 此面板用于展示真实传输进度与失败重试入口。
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleFilled,
|
||||
CloseCircleFilled,
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { UploadItem } from "../hooks/useAssetUpload"
|
||||
|
||||
export interface UploadQueuePanelProps {
|
||||
items: UploadItem[]
|
||||
onRetry: (tempId: string) => void
|
||||
onRemove: (tempId: string) => void
|
||||
onClearFinished: () => void
|
||||
}
|
||||
|
||||
const STATUS_TEXT: Record<UploadItem["status"], string> = {
|
||||
preparing: "排队中…",
|
||||
uploading: "上传中",
|
||||
ingesting: "转码中…",
|
||||
done: "已完成",
|
||||
error: "上传失败",
|
||||
}
|
||||
|
||||
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
items,
|
||||
onRetry,
|
||||
onRemove,
|
||||
onClearFinished,
|
||||
}) => {
|
||||
if (items.length === 0) return null
|
||||
const finishedCount = items.filter((it) => it.status === "done").length
|
||||
|
||||
return (
|
||||
<div className="xx-upload-queue">
|
||||
<div className="xx-upload-queue-header">
|
||||
<span className="xx-upload-queue-title">
|
||||
上传任务({items.length}
|
||||
{finishedCount > 0 ? `,已完成 ${finishedCount}` : ""})
|
||||
</span>
|
||||
{finishedCount > 0 && (
|
||||
<button type="button" className="xx-link-btn" onClick={onClearFinished}>
|
||||
清空已完成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="xx-upload-queue-list">
|
||||
{items.map((it) => {
|
||||
const isActive = it.status === "preparing" || it.status === "uploading"
|
||||
const showProgress = it.status === "uploading" || it.status === "ingesting"
|
||||
return (
|
||||
<div key={it.tempId} className={`xx-upload-queue-item xx-upload-queue-${it.status}`}>
|
||||
<span className="xx-upload-queue-icon">
|
||||
{it.status === "done" || it.duplicated ? (
|
||||
<CheckCircleFilled style={{ color: "#22c55e" }} />
|
||||
) : it.status === "error" ? (
|
||||
<CloseCircleFilled style={{ color: "#ef4444" }} />
|
||||
) : (
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)" }} />
|
||||
)}
|
||||
</span>
|
||||
<div className="xx-upload-queue-body">
|
||||
<div className="xx-upload-queue-name" title={it.fileName}>
|
||||
{it.fileName}
|
||||
</div>
|
||||
{showProgress ? (
|
||||
<div className="xx-upload-queue-progress">
|
||||
<div
|
||||
className="xx-upload-queue-progress-bar"
|
||||
style={{ width: `${it.status === "ingesting" ? 100 : it.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="xx-upload-queue-status">
|
||||
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
|
||||
{it.status === "uploading" ? ` ${it.progress}%` : ""}
|
||||
{it.status === "error" && it.error ? `:${it.error}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="xx-upload-queue-actions">
|
||||
{it.status === "error" && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-upload-queue-btn"
|
||||
title="重试"
|
||||
onClick={() => onRetry(it.tempId)}
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</button>
|
||||
)}
|
||||
{(it.status === "error" || it.status === "done") && !isActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-upload-queue-btn"
|
||||
title="移除"
|
||||
onClick={() => onRemove(it.tempId)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadQueuePanel
|
||||
@@ -1,65 +1,196 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { uploadAssetDirect } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE, LARGE_FILE_THRESHOLD } from "../constants"
|
||||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||||
import { MAX_FILE_SIZE } from "../constants"
|
||||
|
||||
/**
|
||||
* 素材上传 Hook
|
||||
* 封装上传状态、进度管理和上传逻辑
|
||||
*/
|
||||
interface UseAssetUploadProps {
|
||||
effectiveLibId: string
|
||||
/** 单文件上传状态机 */
|
||||
export type UploadItemStatus = "preparing" | "uploading" | "ingesting" | "done" | "error"
|
||||
|
||||
export interface UploadItem {
|
||||
/** 前端临时 id(prepare 前无 asset_id 时用) */
|
||||
tempId: string
|
||||
file: File
|
||||
fileName: string
|
||||
/** 进度 0~100(仅直传阶段有真实进度) */
|
||||
progress: number
|
||||
status: UploadItemStatus
|
||||
/** 后端 prepare 预建的 asset id(旧后端可能为空) */
|
||||
assetId?: string
|
||||
/** 去重命中:complete 返回 duplicated,标记完成但不产生新素材 */
|
||||
duplicated?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useAssetUpload({ effectiveLibId }: UseAssetUploadProps) {
|
||||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
/**
|
||||
* 素材批量上传 Hook
|
||||
* - prepare 阶段后端预建 status=uploading 的 asset,前端拿到 asset_id 立即刷新列表
|
||||
* - OSS 直传并发限制为 3,其余排队;每个文件独立进度/状态
|
||||
* - complete 后素材进入转码(ingesting/processing),由列表轮询反映
|
||||
* - 失败卡片支持重试/移除
|
||||
*/
|
||||
export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [items, setItems] = useState<UploadItem[]>([])
|
||||
const itemsRef = useRef<UploadItem[]>([])
|
||||
itemsRef.current = items
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
return
|
||||
const updateItem = useCallback((tempId: string, patch: Partial<UploadItem>) => {
|
||||
setItems((prev) => prev.map((it) => (it.tempId === tempId ? { ...it, ...patch } : it)))
|
||||
}, [])
|
||||
|
||||
/** 刷新素材列表(prepare 后/complete 后调用,让卡片即时出现/流转) */
|
||||
const refreshList = useCallback(() => {
|
||||
// 使用 refetchQueries 强制立即重新获取,避免 staleTime 导致延迟
|
||||
if (effectiveLibId) {
|
||||
queryClient.refetchQueries({ queryKey: ["assets", effectiveLibId] })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
}, [queryClient, effectiveLibId])
|
||||
|
||||
/** 执行单个文件的完整上传流程(prepare→transfer→complete) */
|
||||
const runUpload = useCallback(
|
||||
async (item: UploadItem, handle?: DirectUploadHandle) => {
|
||||
try {
|
||||
// 1. prepare(重试时复用已准备的 handle 也行,但签名可能过期,重新 prepare 最稳)
|
||||
const h =
|
||||
handle ??
|
||||
(await prepareDirectUploadHandle({ file: item.file, library_id: effectiveLibId }))
|
||||
if (h.prepared.asset_id) {
|
||||
updateItem(item.tempId, {
|
||||
status: "uploading",
|
||||
assetId: h.prepared.asset_id,
|
||||
progress: 0,
|
||||
})
|
||||
// 预建 asset 已入库,立即刷新让「上传中」卡片出现在网格
|
||||
refreshList()
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "uploading", progress: 0 })
|
||||
}
|
||||
|
||||
// 2. OSS 直传(真实进度)
|
||||
await h.transfer((pct) => updateItem(item.tempId, { progress: pct }))
|
||||
|
||||
// 3. complete:后端创建 ingest job,素材进入转码
|
||||
updateItem(item.tempId, { status: "ingesting", progress: 100 })
|
||||
const result = await h.complete()
|
||||
refreshList()
|
||||
|
||||
if (result.duplicated) {
|
||||
updateItem(item.tempId, { status: "done", duplicated: true, assetId: result.asset_id })
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
} else {
|
||||
updateItem(item.tempId, { status: "done" })
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "上传失败"
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, err)
|
||||
updateItem(item.tempId, { status: "error", error: detail })
|
||||
message.error(`"${item.fileName}" 上传失败:${detail}`)
|
||||
}
|
||||
},
|
||||
[effectiveLibId, refreshList, updateItem],
|
||||
)
|
||||
|
||||
/**
|
||||
* 队列调度:把并发槽塞满(同时在途的 prepare+transfer 不超过 MAX_CONCURRENT)。
|
||||
* runUpload 在 await prepare 期间 state 仍是 preparing,多个并发 pump 若只看 state
|
||||
* 会重复认领同一项,因此用 claimedRef 记录已被认领的 tempId。
|
||||
*/
|
||||
const inFlightRef = useRef(0)
|
||||
const claimedRef = useRef<Set<string>>(new Set())
|
||||
const pumpRef = useRef<() => void>(() => {})
|
||||
pumpRef.current = () => {
|
||||
while (inFlightRef.current < MAX_CONCURRENT) {
|
||||
const next = itemsRef.current.find(
|
||||
(it) => it.status === "preparing" && !claimedRef.current.has(it.tempId),
|
||||
)
|
||||
if (!next) return
|
||||
claimedRef.current.add(next.tempId)
|
||||
inFlightRef.current += 1
|
||||
void runUpload(next).finally(() => {
|
||||
inFlightRef.current -= 1
|
||||
claimedRef.current.delete(next.tempId)
|
||||
// 一个任务结束(成功/失败)后继续拉起排队任务
|
||||
setTimeout(() => pumpRef.current(), 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
pumpRef.current()
|
||||
}, [items])
|
||||
|
||||
/** 入队一个或多个文件 */
|
||||
const enqueueUploads = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!effectiveLibId) {
|
||||
message.warning("请先选择或创建一个视频库")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
if (file.size > LARGE_FILE_THRESHOLD) {
|
||||
message.info(`大文件 "${file.name}" 将使用直传上传`)
|
||||
const valid: File[] = []
|
||||
for (const file of files) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error(`文件 "${file.name}" 超过 2GB 限制`)
|
||||
continue
|
||||
}
|
||||
await uploadAssetDirect({
|
||||
file,
|
||||
library_id: effectiveLibId,
|
||||
onProgress: (pct) => setUploadProgress(pct),
|
||||
})
|
||||
message.success(`"${file.name}" 上传成功`)
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : ""
|
||||
console.error("[handleUpload] 上传失败:", err)
|
||||
message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`)
|
||||
// 错误时延迟关闭弹窗,让用户能看到错误提示
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
valid.push(file)
|
||||
}
|
||||
if (valid.length === 0) return
|
||||
|
||||
const newItems: UploadItem[] = valid.map((file, idx) => ({
|
||||
tempId: `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
file,
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: "preparing",
|
||||
}))
|
||||
setItems((prev) => [...prev, ...newItems])
|
||||
},
|
||||
[effectiveLibId, queryClient],
|
||||
[effectiveLibId],
|
||||
)
|
||||
|
||||
/** 重试失败任务 */
|
||||
const retryUpload = useCallback(
|
||||
(tempId: string) => {
|
||||
const target = itemsRef.current.find((it) => it.tempId === tempId)
|
||||
if (!target) return
|
||||
updateItem(tempId, { status: "preparing", progress: 0, error: undefined })
|
||||
// 状态更新后由 useEffect 触发 pump
|
||||
},
|
||||
[updateItem],
|
||||
)
|
||||
|
||||
/** 从上传列表移除(已进入转码的由素材网格管理;这里只移除上传面板记录) */
|
||||
const removeUpload = useCallback((tempId: string) => {
|
||||
setItems((prev) => prev.filter((it) => it.tempId !== tempId))
|
||||
}, [])
|
||||
|
||||
/** 清空已完成/去重记录 */
|
||||
const clearFinished = useCallback(() => {
|
||||
setItems((prev) => prev.filter((it) => it.status !== "done"))
|
||||
}, [])
|
||||
|
||||
const activeCount = items.filter(
|
||||
(it) => it.status === "preparing" || it.status === "uploading",
|
||||
).length
|
||||
const pendingCount = items.filter((it) => it.status === "preparing").length
|
||||
const hasActive = activeCount > 0 || items.some((it) => it.status === "ingesting")
|
||||
|
||||
return {
|
||||
uploading,
|
||||
uploadProgress,
|
||||
handleUpload,
|
||||
uploadItems: items,
|
||||
enqueueUploads,
|
||||
retryUpload,
|
||||
removeUpload,
|
||||
clearFinished,
|
||||
/** 是否有进行中的上传(用于上传区文案) */
|
||||
uploading: hasActive,
|
||||
activeCount,
|
||||
pendingCount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,21 @@ export function useAssetsData() {
|
||||
queryKey: ["assets", effectiveLibId],
|
||||
queryFn: () =>
|
||||
getAssets(effectiveLibId, {
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"处理中"的素材
|
||||
// 拉取所有非删除状态的素材,让用户上传后立刻能看到"上传中/处理中"的素材
|
||||
status: "ready,uploading,ingesting,processing,pending,error,failed",
|
||||
}),
|
||||
enabled: !!effectiveLibId,
|
||||
staleTime: 30_000,
|
||||
// 列表中存在上传中/转码中素材时每 3s 轮询;全部就绪后自动停止
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as { items: ApiAssetItem[] } | undefined
|
||||
const items = data?.items ?? []
|
||||
const processing = items.some((a) => {
|
||||
const st = a.status ?? ""
|
||||
return st === "uploading" || st === "ingesting" || st === "processing" || st === "pending"
|
||||
})
|
||||
return processing ? 3000 : false
|
||||
},
|
||||
})
|
||||
|
||||
const assets: AssetItem[] = useMemo(
|
||||
|
||||
@@ -27,6 +27,36 @@ export interface AssetItem {
|
||||
duration?: string
|
||||
size: number
|
||||
createdAt: string
|
||||
/** 已切片段占用时长占比(0~1),后端字段缺失时为 undefined */
|
||||
usedRatio?: number
|
||||
/** 是否已彻底用尽(false 的素材不参与生成选片),字段缺失时视为可用 */
|
||||
usable?: boolean
|
||||
}
|
||||
|
||||
/** 素材余量角标状态(仅视频素材) */
|
||||
export interface UsageBadge {
|
||||
/** 角标文案 */
|
||||
label: string
|
||||
/** 样式变体:exhausted=红色实心,warning=红色软底,ratio=橙色软底 */
|
||||
variant: "exhausted" | "warning" | "ratio"
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据后端余量字段计算视频素材的余量角标;
|
||||
* 非视频、字段缺失或已用占比 <50% 时不显示(返回 null)。
|
||||
*/
|
||||
export const getUsageBadge = (asset: {
|
||||
kind?: AssetKind
|
||||
usable?: boolean
|
||||
usedRatio?: number
|
||||
}): UsageBadge | null => {
|
||||
if (asset.kind && asset.kind !== "video") return null
|
||||
if (asset.usable === false) return { label: "已用尽", variant: "exhausted" }
|
||||
const ratio = asset.usedRatio
|
||||
if (ratio == null) return null
|
||||
if (ratio >= 0.85) return { label: "即将用尽", variant: "warning" }
|
||||
if (ratio >= 0.5) return { label: `已用 ${Math.round(ratio * 100)}%`, variant: "ratio" }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 根据 mime_type 推断前端 AssetKind */
|
||||
@@ -111,5 +141,7 @@ export const mapAsset = (item: ApiAssetItem): AssetItem => {
|
||||
duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined,
|
||||
size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—",
|
||||
usedRatio: item.used_ratio ?? undefined,
|
||||
usable: item.usable ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ export function useTemplateSave(options: UseTemplateSaveOptions) {
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
segment_order: i,
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
duration_min: c.duration,
|
||||
duration_max: c.duration,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
|
||||
@@ -62,8 +62,9 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<ManualMaterialList
|
||||
materials={m.materials}
|
||||
materials={m.selectableMaterials}
|
||||
materialsLoading={m.materialsLoading}
|
||||
allExhausted={m.allMaterialsExhausted}
|
||||
selectedMaterials={m.selectedMaterials}
|
||||
onToggle={m.handleToggleMaterial}
|
||||
/>
|
||||
@@ -77,21 +78,11 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
onMatch={m.handleSmartMatch}
|
||||
hasMatched={m.hasMatched}
|
||||
onRefresh={m.handleRefreshMatch}
|
||||
materialsCount={m.materials.items.length}
|
||||
materialsCount={m.selectableMaterials.items.length}
|
||||
loading={m.materialsLoading}
|
||||
/>
|
||||
|
||||
<SmartMatchResults
|
||||
matchedAssets={m.smartMatchedResults}
|
||||
selectedIds={m.smartSelectedIds}
|
||||
matching={m.smartMatching}
|
||||
hasMatched={m.hasMatched}
|
||||
onToggle={m.handleToggleSmartSelect}
|
||||
onSelectAll={m.handleSelectAllMatched}
|
||||
onClear={m.handleClearSmartSelect}
|
||||
formatDuration={m.formatDuration}
|
||||
selectedTotalDuration={m.smartSelectedTotalDuration}
|
||||
/>
|
||||
<SmartMatchResults matching={m.smartMatching} hasMatched={m.hasMatched} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,13 @@ const getFileSize = (item: AssetItem): number => {
|
||||
return item.file_size ?? (item.metadata?.file_size as number) ?? 0
|
||||
}
|
||||
|
||||
/** 是否为 AI 音色(克隆/预置音色模型:无固定时长、无实体音频文件,按脚本实时合成) */
|
||||
const isAiVoice = (item: AssetItem): boolean => {
|
||||
const duration = getDuration(item)
|
||||
const size = getFileSize(item)
|
||||
return (!duration || duration <= 0) && (!size || size <= 0)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -96,10 +103,10 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
/** 选中素材(含时长校验) */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
// 如果启用了时长校验,且配音时长不足
|
||||
// 如果启用了时长校验,且配音时长不足(AI 音色按脚本实时合成,不参与时长校验)
|
||||
if (totalVideoDuration > 0) {
|
||||
const material = materials.find((m) => m.id === id)
|
||||
if (material && getDuration(material) < totalVideoDuration) {
|
||||
if (material && !isAiVoice(material) && getDuration(material) < totalVideoDuration) {
|
||||
setPendingVoiceId(id)
|
||||
setDurationWarningOpen(true)
|
||||
return
|
||||
@@ -280,25 +287,29 @@ const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(getDuration(item))}
|
||||
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{formatFileSize(getFileSize(item))}</span>
|
||||
{isAiVoice(item) ? (
|
||||
<span style={{ color: "#1677ff", fontWeight: 500 }}>AI 音色</span>
|
||||
) : (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{formatDuration(getDuration(item))}
|
||||
{totalVideoDuration > 0 && getDuration(item) < Number(totalVideoDuration) && (
|
||||
<span
|
||||
style={{
|
||||
color: "#ff4d4f",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<WarningOutlined />
|
||||
时长不足
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span>{isAiVoice(item) ? "按文本合成" : formatFileSize(getFileSize(item))}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,8 @@ const { Text } = Typography
|
||||
interface ManualMaterialListProps {
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
materialsLoading: boolean
|
||||
/** 库内有素材但全部已用尽(usable === false),用于区分空状态文案 */
|
||||
allExhausted?: boolean
|
||||
selectedMaterials: string[]
|
||||
onToggle: (materialId: string) => void
|
||||
}
|
||||
@@ -247,6 +249,7 @@ const MaterialCard: React.FC<{
|
||||
const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
materials,
|
||||
materialsLoading,
|
||||
allExhausted,
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
@@ -256,7 +259,9 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>加载素材中…</Text>
|
||||
) : materials.items.length === 0 ? (
|
||||
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
|
||||
暂无素材,请先在视频库中上传
|
||||
{allExhausted
|
||||
? "暂无可选素材(素材可能已用尽,请先上传新素材)"
|
||||
: "暂无素材,请先在视频库中上传"}
|
||||
</Text>
|
||||
) : (
|
||||
<div
|
||||
|
||||
@@ -23,7 +23,7 @@ const MaterialModeTabs: React.FC<MaterialModeTabsProps> = ({ mode, onModeChange
|
||||
onClick={() => onModeChange("auto")}
|
||||
type="button"
|
||||
>
|
||||
选择视频库自动匹配
|
||||
AI智能匹配
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* 智能匹配卡片(Q5 简化版)
|
||||
* 只展示素材缩略图、名称、时长,无匹配分数
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SmartMatchCardProps {
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
asset,
|
||||
selected,
|
||||
onClick,
|
||||
formatDuration,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-smart-match-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="xx-smart-match-thumb-placeholder">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="xx-smart-match-check">
|
||||
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
|
||||
</div>
|
||||
)}
|
||||
{asset.duration && (
|
||||
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="xx-smart-match-info">
|
||||
<div className="xx-smart-match-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMatchCard
|
||||
@@ -1,35 +1,17 @@
|
||||
/**
|
||||
* 智能匹配结果区(Q5 简化版)
|
||||
* 直接展示 AI 选中的素材,无匹配分数和理由
|
||||
* 智能匹配状态区
|
||||
* 仅展示匹配中 / 未匹配 / 匹配成功三种状态,不展示 AI 选中的素材明细
|
||||
* (选中的素材仍由 smartSelectedIds 驱动提交,逻辑不变)
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import SmartMatchCard from "./SmartMatchCard"
|
||||
|
||||
interface SmartMatchResultsProps {
|
||||
matchedAssets: AssetItem[]
|
||||
selectedIds: string[]
|
||||
matching: boolean
|
||||
hasMatched: boolean
|
||||
onToggle: (assetId: string) => void
|
||||
onSelectAll: () => void
|
||||
onClear: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
selectedTotalDuration: number
|
||||
}
|
||||
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
matchedAssets,
|
||||
selectedIds,
|
||||
matching,
|
||||
hasMatched,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
formatDuration,
|
||||
selectedTotalDuration,
|
||||
}) => {
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({ matching, hasMatched }) => {
|
||||
// 匹配中状态
|
||||
if (matching) {
|
||||
return (
|
||||
@@ -45,66 +27,26 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 未匹配状态提示
|
||||
if (!hasMatched) {
|
||||
// 匹配成功:轻量提示,不展示素材明细卡片
|
||||
if (hasMatched) {
|
||||
return (
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
点击「让 AI 帮你选」,自动从视频库中选择最合适的素材
|
||||
</div>
|
||||
<div className="xx-smart-match-success">
|
||||
<span style={{ fontSize: 16 }}>✅</span>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
AI 已帮你选好素材,可直接进入下一步
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 无结果
|
||||
if (matchedAssets.length === 0) return null
|
||||
|
||||
// 未匹配状态提示
|
||||
return (
|
||||
<>
|
||||
<div className="xx-smart-match-results">
|
||||
<div className="xx-smart-match-results-header">
|
||||
<span className="xx-smart-match-results-title">
|
||||
AI 已选素材 ({matchedAssets.length}个)
|
||||
</span>
|
||||
<div className="xx-smart-match-results-actions">
|
||||
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
|
||||
全选
|
||||
</button>
|
||||
<span style={{ color: "var(--border-color)" }}>|</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onClear}>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-smart-match-grid">
|
||||
{matchedAssets.map((asset) => {
|
||||
const isSelected = selectedIds.includes(asset.id)
|
||||
return (
|
||||
<SmartMatchCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={isSelected}
|
||||
onClick={() => onToggle(asset.id)}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
点击「让 AI 帮你选」,自动从视频库中选择最合适的素材
|
||||
</div>
|
||||
|
||||
{/* 已选素材汇总 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="xx-smart-match-summary">
|
||||
<div className="xx-smart-match-summary-header">
|
||||
<span className="xx-pill xx-pill-ok">已选 {selectedIds.length} 个素材</span>
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 12 }}>
|
||||
预计总时长约 {selectedTotalDuration.toFixed(0)} 秒
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,9 +38,8 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
className="xx-title-preset-preview-text"
|
||||
style={{ ...p.previewStyle, fontFamily: getFontFamily(fontFamily || "思源黑体") }}
|
||||
>
|
||||
标题
|
||||
T
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1409,154 +1409,15 @@
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-results-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.xx-smart-match-results-actions {
|
||||
.xx-smart-match-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.xx-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #4f46e5);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.xx-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.xx-smart-match-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-card {
|
||||
background: #fff;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
padding: 18px 20px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-smart-match-card:hover {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.xx-smart-match-card.selected {
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.xx-smart-match-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-score {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--primary-color, #4f46e5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-info {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-smart-match-reason {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-smart-match-loading {
|
||||
@@ -1580,19 +1441,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.xx-smart-match-summary {
|
||||
padding: 12px 16px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.xx-smart-match-summary-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AI 智能生成标题
|
||||
============================================================ */
|
||||
@@ -1885,8 +1733,8 @@
|
||||
/* 标题预设卡片网格 */
|
||||
.xx-title-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-title-preset-card {
|
||||
@@ -1894,7 +1742,8 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 8px;
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -1914,21 +1763,10 @@
|
||||
}
|
||||
|
||||
.xx-title-preset-preview-text {
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-title-preset-card-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-title-preset-card.active .xx-title-preset-card-label {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 样式按钮组 */
|
||||
.xx-style-btns {
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets"
|
||||
import { getAssets, getAssetLibraries, isAssetUsable } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
/**
|
||||
@@ -31,11 +31,26 @@ export function useMaterialLibrary() {
|
||||
enabled: !!selectedLibraryId,
|
||||
})
|
||||
|
||||
// 生成选片只展示仍可切出不重复片段的素材(usable !== false);
|
||||
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
|
||||
const selectableMaterials = useMemo(
|
||||
() => ({
|
||||
items: materials.items.filter(isAssetUsable),
|
||||
total: materials.total,
|
||||
}),
|
||||
[materials],
|
||||
)
|
||||
|
||||
// 库内有素材但全部已用尽(用于区分空状态文案)
|
||||
const allMaterialsExhausted = materials.items.length > 0 && selectableMaterials.items.length === 0
|
||||
|
||||
return {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,48 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { smartMatchAssets } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { smartMatchAssets, isAssetUsable } from "@/api/assets"
|
||||
|
||||
interface UseSmartMatchOptions {
|
||||
libraryId: string
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
/** 当前模板的 segments,用于根据总时长计算 limit */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
/** 默认 limit(拿不到目标时长时的兜底上限) */
|
||||
const DEFAULT_LIMIT = 10
|
||||
/** 每个素材切片按 15 秒估算所需素材数 */
|
||||
const SECONDS_PER_ASSET = 15
|
||||
|
||||
/**
|
||||
* 根据模板 segments 计算所需素材数量上限。
|
||||
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
|
||||
* 再按 15 秒/素材估算需要多少个素材;结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
*/
|
||||
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
if (!segments || segments.length === 0) return DEFAULT_LIMIT
|
||||
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
|
||||
if (totalSeconds <= 0) return DEFAULT_LIMIT
|
||||
const limit = Math.ceil(totalSeconds / SECONDS_PER_ASSET)
|
||||
return Math.max(1, Math.min(limit, 200))
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能素材匹配 Hook(Q5 简化版)
|
||||
* 用户不手动选素材时,一键调用后端 AI 选素材
|
||||
* 后端统一选素材逻辑后续完善,当前先走前端流程简化
|
||||
* 智能素材匹配 Hook
|
||||
* 用户不手动选素材时,一键调用后端 AI 选素材;匹配结果自动全量写入
|
||||
* smartSelectedIds(由上层持有),不向用户展示素材明细。
|
||||
*/
|
||||
export function useSmartMatch({
|
||||
libraryId,
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
templateSegments,
|
||||
}: UseSmartMatchOptions) {
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<AssetItem[]>([])
|
||||
|
||||
/* ── 一键智能匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
@@ -32,88 +51,57 @@ export function useSmartMatch({
|
||||
return
|
||||
}
|
||||
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
// 已用尽素材(usable === false)不参与智能匹配;
|
||||
// 后端字段未上线时 isAssetUsable 恒为 true,过滤为 no-op
|
||||
const usableItems = materials.items.filter(isAssetUsable)
|
||||
if (usableItems.length === 0) {
|
||||
message.warning(
|
||||
materials.items.length === 0 ? "当前视频库暂无素材" : "素材可能已用尽,请先上传新素材",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
|
||||
try {
|
||||
// 调用后端智能匹配 API
|
||||
const result = await smartMatchAssets(libraryId)
|
||||
const matchedIds = result.items?.map((a: AssetItem) => a.id) ?? []
|
||||
// 根据目标视频时长计算合理的素材数量上限,避免"有几个选几个"
|
||||
const limit = computeLimitFromSegments(templateSegments)
|
||||
|
||||
// 调用后端智能匹配 API(后端也会排除已用尽素材,这里前端兜底过滤)
|
||||
const result = await smartMatchAssets(libraryId, limit)
|
||||
// 兜底过滤:id 为空或不可用的素材不参与匹配(smartMatchAssets 已做归一化,这里双保险)
|
||||
const matched = (result.items ?? []).filter((a) => !!a?.id && isAssetUsable(a))
|
||||
const matchedIds = matched.map((a: AssetItem) => a.id)
|
||||
|
||||
if (matchedIds.length > 0) {
|
||||
onSmartSelectedIdsChange(matchedIds)
|
||||
// 保存 API 返回的完整素材列表
|
||||
const resolved = result.items?.length
|
||||
? result.items
|
||||
: materials.items.filter((a) => matchedIds.includes(a.id))
|
||||
setSmartMatchedResults(resolved)
|
||||
setHasMatched(true)
|
||||
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
|
||||
} else {
|
||||
// 后端返回空结果,回退到全选
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
// 后端返回空结果,回退到全选可用素材
|
||||
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
|
||||
setHasMatched(true)
|
||||
message.info("AI 暂未找到匹配素材,已全选当前库素材")
|
||||
}
|
||||
} catch {
|
||||
// 后端 API 尚未就绪时,回退到全选当前库素材
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
// 后端 API 尚未就绪时,回退到全选当前库可用素材
|
||||
onSmartSelectedIdsChange(usableItems.map((a) => a.id))
|
||||
setHasMatched(true)
|
||||
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
|
||||
} finally {
|
||||
setSmartMatching(false)
|
||||
}
|
||||
}, [libraryId, materials.items, onSmartSelectedIdsChange])
|
||||
}, [libraryId, materials.items, onSmartSelectedIdsChange, templateSegments])
|
||||
|
||||
/* ── 换一批 = 重新触发智能匹配 ── */
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
// 换一批 = 重新触发智能匹配
|
||||
await handleSmartMatch()
|
||||
}, [handleSmartMatch])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
}, [materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
onSmartSelectedIdsChange(
|
||||
smartSelectedIds.includes(assetId)
|
||||
? smartSelectedIds.filter((id) => id !== assetId)
|
||||
: [...smartSelectedIds, assetId],
|
||||
)
|
||||
},
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
/* ── 计算已选素材总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(
|
||||
() =>
|
||||
materials.items
|
||||
.filter((a) => smartSelectedIds.includes(a.id))
|
||||
.reduce((sum, a) => sum + (a.duration || 0), 0),
|
||||
[materials.items, smartSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
smartMatching,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
smartMatchedResults,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
handleSelectAllMatched,
|
||||
handleClearSmartSelect,
|
||||
smartSelectedTotalDuration,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ import type { AxiosResponse } from "axios"
|
||||
* 使用 Promise.allSettled 确保单个失败不影响整体
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
if (!ids.length) return []
|
||||
// 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return []
|
||||
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
validIds.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
)
|
||||
return results
|
||||
.filter(
|
||||
@@ -57,7 +59,10 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!stableAssetIds.length || !enabled) {
|
||||
const validIds = stableAssetIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length > 0,
|
||||
)
|
||||
if (!validIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
@@ -68,7 +73,7 @@ export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePrevi
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(stableAssetIds)
|
||||
const result = await fetchAssetsByIds(validIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { message } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
@@ -38,14 +37,21 @@ export function useStep2Materials({
|
||||
templateSegments,
|
||||
onServerClipsChange,
|
||||
}: UseStep2MaterialsProps) {
|
||||
const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } =
|
||||
useMaterialLibrary()
|
||||
const {
|
||||
libraries,
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
} = useMaterialLibrary()
|
||||
|
||||
const smartMatch = useSmartMatch({
|
||||
libraryId: selectedLibraryId,
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
materials: selectableMaterials,
|
||||
onSmartSelectedIdsChange,
|
||||
templateSegments,
|
||||
})
|
||||
|
||||
/* ── 自动触发智能匹配:选择视频库后自动调用 ── */
|
||||
@@ -59,13 +65,19 @@ export function useStep2Materials({
|
||||
}
|
||||
if (!selectedLibraryId) return
|
||||
if (materialsLoading) return
|
||||
if (materials.items.length === 0) return
|
||||
if (selectableMaterials.items.length === 0) return
|
||||
// 防止同一视频库重复触发
|
||||
if (autoTriggeredRef.current === selectedLibraryId) return
|
||||
|
||||
autoTriggeredRef.current = selectedLibraryId
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
}, [
|
||||
selectedLibraryId,
|
||||
materialMode,
|
||||
materialsLoading,
|
||||
selectableMaterials.items,
|
||||
handleSmartMatch,
|
||||
])
|
||||
|
||||
/* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */
|
||||
const { scheduleSave } = useDraftAutoSave(selectedTemplate)
|
||||
@@ -169,6 +181,8 @@ export function useStep2Materials({
|
||||
selectedLibraryId,
|
||||
setSelectedLibraryId,
|
||||
materials,
|
||||
selectableMaterials,
|
||||
allMaterialsExhausted,
|
||||
materialsLoading,
|
||||
// 模式
|
||||
materialMode,
|
||||
@@ -176,19 +190,11 @@ export function useStep2Materials({
|
||||
// 手动选择
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配(简化版)
|
||||
// 智能匹配
|
||||
smartMatching: smartMatch.smartMatching,
|
||||
hasMatched: smartMatch.hasMatched,
|
||||
smartSelectedIds: smartMatch.smartSelectedIds,
|
||||
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||
handleSmartMatch: smartMatch.handleSmartMatch,
|
||||
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
|
||||
handleRefreshMatch: smartMatch.handleRefreshMatch,
|
||||
handleSelectAllMatched: smartMatch.handleSelectAllMatched,
|
||||
handleClearSmartSelect: smartMatch.handleClearSmartSelect,
|
||||
smartSelectedTotalDuration: smartMatch.smartSelectedTotalDuration,
|
||||
// utils
|
||||
formatDuration,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 成片库页面 — V21 设计系统
|
||||
* 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选
|
||||
* 卡片网格布局,支持视频内联播放/下载/分享、批量操作、筛选
|
||||
*
|
||||
* 主组件仅保留 Hook 组装与整体布局
|
||||
* 列表查询 → hooks/useProductList
|
||||
@@ -8,15 +8,12 @@
|
||||
* 筛选栏 → components/ProductFilterBar
|
||||
* 批量操作栏 → components/ProductBatchBar
|
||||
* 空状态 → components/ProductEmptyState
|
||||
* 产品卡片 → components/ProductCard
|
||||
* 视频播放 → components/VideoPlayer
|
||||
* 产品卡片 → components/ProductCard(内联视频播放)
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React from "react"
|
||||
import { VideoCameraOutlined, DownloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "@/components/ui"
|
||||
import type { ProductItem } from "./types"
|
||||
import { ProductCard } from "./components/ProductCard"
|
||||
import { VideoPlayer } from "./components/VideoPlayer"
|
||||
import { ProductFilterBar } from "./components/ProductFilterBar"
|
||||
import { ProductBatchBar } from "./components/ProductBatchBar"
|
||||
import { ProductEmptyState } from "./components/ProductEmptyState"
|
||||
@@ -53,13 +50,9 @@ const ProductLibrary: React.FC = () => {
|
||||
clearSelection,
|
||||
} = useProductList()
|
||||
|
||||
/* 播放器 */
|
||||
const [playingProduct, setPlayingProduct] = useState<ProductItem | null>(null)
|
||||
|
||||
const {
|
||||
handleDownload,
|
||||
handleShare,
|
||||
handleViewDetail,
|
||||
handleDelete,
|
||||
handlePublish,
|
||||
handleReviewStatusChange,
|
||||
@@ -71,7 +64,7 @@ const ProductLibrary: React.FC = () => {
|
||||
selectedIds,
|
||||
clearSelection,
|
||||
products,
|
||||
setPlayingProduct,
|
||||
setPlayingProduct: () => {}, // 不再使用弹窗播放
|
||||
})
|
||||
|
||||
// ── Loading 状态 ──
|
||||
@@ -146,7 +139,6 @@ const ProductLibrary: React.FC = () => {
|
||||
isSelected={selectedIds.has(product.id)}
|
||||
batchMode={batchMode}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onPlay={setPlayingProduct}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
@@ -158,17 +150,6 @@ const ProductLibrary: React.FC = () => {
|
||||
) : (
|
||||
<ProductEmptyState type="empty" />
|
||||
)}
|
||||
|
||||
{/* 视频播放弹窗 */}
|
||||
{playingProduct && (
|
||||
<VideoPlayer
|
||||
product={playingProduct}
|
||||
onClose={() => setPlayingProduct(null)}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react"
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { Popconfirm } from "antd"
|
||||
import {
|
||||
CheckOutlined,
|
||||
@@ -7,17 +7,17 @@ import {
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
CloudUploadOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { ProductItem } from "../types"
|
||||
import { statusConfig, reviewStatusConfig } from "../constants"
|
||||
import { formatTime } from "../utils"
|
||||
import { formatTime, formatSize } from "../utils"
|
||||
|
||||
interface ProductCardProps {
|
||||
product: ProductItem
|
||||
isSelected: boolean
|
||||
batchMode: boolean
|
||||
onToggleSelect: (id: string) => void
|
||||
onPlay: (product: ProductItem) => void
|
||||
onDownload: (product: ProductItem) => void
|
||||
onShare: (product: ProductItem) => void
|
||||
onDelete: (id: string) => void
|
||||
@@ -30,7 +30,6 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
isSelected,
|
||||
batchMode,
|
||||
onToggleSelect,
|
||||
onPlay,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete,
|
||||
@@ -38,17 +37,39 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status]
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [aspectRatio, setAspectRatio] = useState<string | null>(null)
|
||||
const [hasVideo, setHasVideo] = useState(!!product.videoUrl)
|
||||
|
||||
/** 查重率样式 */
|
||||
const dupClass =
|
||||
product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad"
|
||||
|
||||
/** 点击卡片 */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
/** 视频元数据加载后获取实际比例 */
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
const v = videoRef.current
|
||||
if (v && v.videoWidth > 0 && v.videoHeight > 0) {
|
||||
setAspectRatio(`${v.videoWidth} / ${v.videoHeight}`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 视频播放/暂停结束事件 */
|
||||
const handlePlay = useCallback(() => setIsPlaying(true), [])
|
||||
const handlePause = useCallback(() => setIsPlaying(false), [])
|
||||
|
||||
/** 视频出错时降级为缩略图 */
|
||||
const handleVideoError = useCallback(() => setHasVideo(false), [])
|
||||
|
||||
/** 点击缩略图区域:有视频则内联播放,否则不响应 */
|
||||
const handleThumbClick = () => {
|
||||
if (batchMode) return
|
||||
if (!hasVideo || !videoRef.current) return
|
||||
const v = videoRef.current
|
||||
if (v.paused) {
|
||||
v.play()
|
||||
} else {
|
||||
onPlay(product)
|
||||
v.pause()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +79,24 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
|
||||
/** 卡片容器点击(非批量模式下不再触发弹窗播放) */
|
||||
const handleCardClick = () => {
|
||||
if (batchMode) {
|
||||
onToggleSelect(product.id)
|
||||
}
|
||||
}
|
||||
|
||||
/** 组件卸载时暂停视频 */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current
|
||||
return () => {
|
||||
v?.pause()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 缩略图区域 style:有实际比例则用实际比例,否则默认 16:9 */
|
||||
const thumbStyle: React.CSSProperties = aspectRatio ? { aspectRatio } : { aspectRatio: "16 / 9" }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-product-card${isSelected ? " selected" : ""}${product.isPublished ? " published" : ""}`}
|
||||
@@ -102,21 +141,61 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
{/* 视频/缩略图区域 */}
|
||||
<div className="xx-product-thumb" style={thumbStyle}>
|
||||
{hasVideo ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="xx-product-thumb-video"
|
||||
src={product.videoUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onEnded={handlePause}
|
||||
onError={handleVideoError}
|
||||
controls={isPlaying}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleThumbClick()
|
||||
}}
|
||||
/>
|
||||
{/* 未播放时显示播放按钮覆盖层 */}
|
||||
{!isPlaying && (
|
||||
<div className="xx-product-play" onClick={handleThumbClick}>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="xx-product-thumb-bg" style={{ background: product.gradient }} />
|
||||
<>
|
||||
{product.thumbnailUrl ? (
|
||||
<img
|
||||
className="xx-product-thumb-bg"
|
||||
src={product.thumbnailUrl}
|
||||
alt={product.name}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-product-thumb-bg"
|
||||
style={{
|
||||
background: product.gradient,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
fontSize: "48px",
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="xx-product-play">
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
{product.duration > 0 && (
|
||||
<span className="xx-product-duration">{formatTime(product.duration)}</span>
|
||||
)}
|
||||
@@ -131,11 +210,20 @@ export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
<span className={`xx-product-status ${st.className}`}>{st.text}</span>
|
||||
<span className="xx-product-date">{product.date}</span>
|
||||
</div>
|
||||
{product.duplicateRate > 0 && (
|
||||
<span className={`xx-product-dup-rate ${dupClass}`}>
|
||||
查重率:{product.duplicateRate.toFixed(1)}%
|
||||
<div className="xx-product-meta-grid">
|
||||
<span className="xx-product-meta-item">分辨率:{product.resolution || "-"}</span>
|
||||
<span className="xx-product-meta-item">
|
||||
时长:{product.duration > 0 ? formatTime(product.duration) : "-"}
|
||||
</span>
|
||||
)}
|
||||
<span className="xx-product-meta-item">大小:{formatSize(product.fileSize)}</span>
|
||||
<span
|
||||
className={`xx-product-meta-item xx-product-dup-rate${
|
||||
product.duplicateRate > 0 ? ` ${dupClass}` : ""
|
||||
}`}
|
||||
>
|
||||
查重率:{product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -260,13 +260,13 @@
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 220px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
background: var(--color-gray-950);
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.xx-product-thumb-bg {
|
||||
@@ -276,6 +276,18 @@
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* 内联视频播放器(cover 填满容器,竖屏视频不留左右空白) */
|
||||
.xx-product-thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xx-product-thumb-video[controls] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-product-play {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -335,6 +347,21 @@
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* 卡片信息网格:分辨率/时长 一行,大小/查重率 一行 */
|
||||
.xx-product-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-product-meta-item {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.xx-product-status {
|
||||
padding: 2px 10px;
|
||||
|
||||
@@ -102,6 +102,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
@@ -318,6 +319,7 @@ const VoiceMaterialLibrary: React.FC = () => {
|
||||
audioUrl={ttsAudioUrl ?? ""}
|
||||
error={ttsError ?? ""}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
onClose={handleTtsClose}
|
||||
onTextChange={setTtsText}
|
||||
onVoiceChange={setTtsVoiceId}
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface TtsPresetVoice {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 克隆音色下拉选项(仅 ready) */
|
||||
export interface TtsClonedVoice {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TtsModalProps {
|
||||
open: boolean
|
||||
text: string
|
||||
@@ -18,6 +24,8 @@ interface TtsModalProps {
|
||||
audioUrl: string
|
||||
error: string
|
||||
presetVoices: TtsPresetVoice[]
|
||||
/** 可用克隆音色(仅 ready),为空时不显示该分组 */
|
||||
clonedVoices?: TtsClonedVoice[]
|
||||
onClose: () => void
|
||||
onTextChange: (value: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
@@ -35,6 +43,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
audioUrl,
|
||||
error,
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
onClose: _onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
@@ -101,11 +110,22 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="预置音色">
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.voice_id} value={v.voice_id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{clonedVoices.length > 0 && (
|
||||
<optgroup label="我的克隆音色">
|
||||
{clonedVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ const CardPlayer: React.FC<CardPlayerProps> = ({
|
||||
document.addEventListener("mouseup", handleUp)
|
||||
}
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
|
||||
const progress = isPlaying && duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="vmat-card-player">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { fetchPresetVoices, type PresetVoiceItem } from "@/api/voices"
|
||||
import { getVoiceClonesWithTotal, toVoiceClone } from "@/api/voice-clone"
|
||||
|
||||
/**
|
||||
* TTS 合成 Hook
|
||||
@@ -31,6 +32,17 @@ export function useTtsSynthesize() {
|
||||
})
|
||||
const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []
|
||||
|
||||
// 我的克隆音色(仅 ready 可用于合成;voice_id 直接传 profile UUID,后端解析)
|
||||
const { data: clonedData } = useQuery({
|
||||
queryKey: ["voice-clones"],
|
||||
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const clonedVoices = (clonedData?.items ?? [])
|
||||
.map((p) => toVoiceClone(p))
|
||||
.filter((c) => c.status === "ready")
|
||||
.map((c) => ({ id: c.id, name: c.name }))
|
||||
|
||||
/** 开始 AI 配音合成 */
|
||||
const handleTtsSynthesize = useCallback(async () => {
|
||||
if (!ttsText.trim()) {
|
||||
@@ -124,6 +136,7 @@ export function useTtsSynthesize() {
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
setTtsOpen,
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
|
||||
+17
-5
@@ -48,20 +48,32 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
}
|
||||
|
||||
// 2. 上传文件(带进度,后端自动创建 ingest job)
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
const complete = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
// 3. 轮询 ingest job 状态
|
||||
// 去重命中(同库已存在相同 file_hash 素材):
|
||||
// 后端返回 duplicated=true,ingest_job_id 为空;跳过轮询和打标签,
|
||||
// mutationFn 正常 return 即 resolve,useMutation 自动触发 onSuccess 刷新列表。
|
||||
// 已存在素材复用上次标签,无需重新打标。
|
||||
if (complete.duplicated === true) {
|
||||
return
|
||||
}
|
||||
if (!complete.ingest_job_id) {
|
||||
throw new Error("上传完成但未返回处理任务 ID,请重试")
|
||||
}
|
||||
const { ingest_job_id } = complete
|
||||
|
||||
// 3. 轮询 ingest job 状态(complete 后先立即查一次,未完成再每 5s 轮询)
|
||||
let job: Awaited<ReturnType<typeof getIngestJob>> | null = null
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟
|
||||
while (retries < maxRetries) {
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
while (job.status !== "completed" && job.status !== "failed" && retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
if (job.status === "completed" || job.status === "failed") break
|
||||
retries++
|
||||
}
|
||||
|
||||
@@ -72,7 +84,7 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo
|
||||
throw new Error("音频处理超时,请稍后在素材库查看")
|
||||
}
|
||||
|
||||
// 4. 打标签(标签走独立 API)
|
||||
// 4. 打标签(标签走独立 API;去重命中时已提前 return,这里只对新创建的素材执行)
|
||||
if (data.tagIds.length > 0 && job.result_asset_id) {
|
||||
await tagAsset(job.result_asset_id, data.tagIds)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,13 @@ const VoiceLibrary: React.FC = () => {
|
||||
handleTtsSynthesize,
|
||||
handleTtsSave,
|
||||
handleTtsClose,
|
||||
} = useTtsSynthesize({ presetVoices, showToast })
|
||||
} = useTtsSynthesize({
|
||||
presetVoices,
|
||||
clonedVoices: clonedVoices
|
||||
.filter((v) => v.status === "ready")
|
||||
.map((v) => ({ id: v.id, name: v.name })),
|
||||
showToast,
|
||||
})
|
||||
|
||||
// ── 上传音频 ──────────────────────────────────────────
|
||||
const {
|
||||
@@ -308,6 +314,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices
|
||||
.filter((v) => v.status === "ready")
|
||||
.map((v) => ({ id: v.id, name: v.name }))}
|
||||
onTtsClose={handleTtsClose}
|
||||
onTtsTextChange={setTtsText}
|
||||
onTtsVoiceChange={setTtsVoiceId}
|
||||
|
||||
@@ -140,7 +140,9 @@ export const MaterialVoiceTab: React.FC<MaterialVoiceTabProps> = ({
|
||||
const isSelected = selectedIds.has(asset.id)
|
||||
// 播放中以 audio 真实时长为准,未播放显示卡片时长
|
||||
const effectiveDuration = isPlaying ? playDuration || cardDuration : cardDuration
|
||||
const progress = effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
|
||||
// 仅播放中的卡片显示进度,避免页面级 currentTime 联动所有卡片
|
||||
const progress =
|
||||
isPlaying && effectiveDuration > 0 ? (currentTime / effectiveDuration) * 100 : 0
|
||||
return (
|
||||
<div
|
||||
key={asset.id}
|
||||
|
||||
@@ -18,6 +18,7 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
onClose,
|
||||
onTextChange,
|
||||
onVoiceChange,
|
||||
@@ -36,7 +37,12 @@ const TtsModal: React.FC<TtsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<TextInputSection value={ttsText} onChange={onTextChange} />
|
||||
<VoiceSelector value={ttsVoiceId} onChange={onVoiceChange} presetVoices={presetVoices} />
|
||||
<VoiceSelector
|
||||
value={ttsVoiceId}
|
||||
onChange={onVoiceChange}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
/>
|
||||
<SpeedControl speed={ttsSpeed} onChange={onSpeedChange} />
|
||||
<SynthesizeButton status={ttsStatus} text={ttsText} onClick={onSynthesize} />
|
||||
{ttsError && <ErrorAlert error={ttsError} />}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from "react"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import type { TtsClonedVoiceOption } from "./tts-modal/VoiceSelector"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import CloneDetailModal from "./CloneDetailModal"
|
||||
import UploadVoiceModal from "./UploadVoiceModal"
|
||||
@@ -45,6 +46,8 @@ export interface VoiceModalsProps {
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready) */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
onTtsClose: () => void
|
||||
onTtsTextChange: (text: string) => void
|
||||
onTtsVoiceChange: (id: string) => void
|
||||
@@ -81,6 +84,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsAudioUrl,
|
||||
ttsError,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
onTtsClose,
|
||||
onTtsTextChange,
|
||||
onTtsVoiceChange,
|
||||
@@ -129,6 +133,7 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
ttsAudioUrl={ttsAudioUrl}
|
||||
ttsError={ttsError}
|
||||
presetVoices={presetVoices}
|
||||
clonedVoices={clonedVoices}
|
||||
onClose={onTtsClose}
|
||||
onTextChange={onTtsTextChange}
|
||||
onVoiceChange={onTtsVoiceChange}
|
||||
|
||||
@@ -2,14 +2,28 @@ import React from "react"
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import { genderLabel } from "@/pages/voices/utils/format"
|
||||
|
||||
/** 克隆音色下拉选项(最小结构,新旧页面各自映射) */
|
||||
export interface TtsClonedVoiceOption {
|
||||
/** 克隆音色 profile id(合成时直接作为 voice_id 传后端解析) */
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
value: string
|
||||
onChange: (voiceId: string) => void
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅克隆完成/ready),为空时不显示「我的克隆音色」分组 */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
}
|
||||
|
||||
/** 音色选择下拉 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVoices }) => {
|
||||
/** 音色选择下拉:预置音色 + 我的克隆音色 */
|
||||
const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -36,11 +50,22 @@ const VoiceSelector: React.FC<VoiceSelectorProps> = ({ value, onChange, presetVo
|
||||
}}
|
||||
>
|
||||
<option value="">默认音色</option>
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
<optgroup label="预置音色">
|
||||
{presetVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name} — {genderLabel(v.gender)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{clonedVoices.length > 0 && (
|
||||
<optgroup label="我的克隆音色">
|
||||
{clonedVoices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type PresetVoiceDisplay } from "@/pages/voices/types"
|
||||
import type { TtsClonedVoiceOption } from "./VoiceSelector"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -11,6 +12,8 @@ export interface TtsModalProps {
|
||||
ttsAudioUrl: string | null
|
||||
ttsError: string | null
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready),为空时下拉不显示该分组 */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
onClose: () => void
|
||||
onTextChange: (text: string) => void
|
||||
onVoiceChange: (voiceId: string) => void
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
|
||||
import { type PresetVoiceDisplay } from "../types"
|
||||
import type { TtsClonedVoiceOption } from "../components/tts-modal/VoiceSelector"
|
||||
|
||||
export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
|
||||
@@ -12,10 +13,16 @@ export type TtsStatus = "idle" | "synthesizing" | "done" | "error"
|
||||
*/
|
||||
interface UseTtsSynthesizeProps {
|
||||
presetVoices: PresetVoiceDisplay[]
|
||||
/** 可用克隆音色(仅 ready;合成时 voice_id 直接传克隆 profile UUID,后端解析) */
|
||||
clonedVoices?: TtsClonedVoiceOption[]
|
||||
showToast: (message: string, type: "success" | "error") => void
|
||||
}
|
||||
|
||||
export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizeProps) {
|
||||
export function useTtsSynthesize({
|
||||
presetVoices,
|
||||
clonedVoices = [],
|
||||
showToast,
|
||||
}: UseTtsSynthesizeProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [ttsOpen, setTtsOpen] = useState(false)
|
||||
@@ -131,6 +138,7 @@ export function useTtsSynthesize({ presetVoices, showToast }: UseTtsSynthesizePr
|
||||
ttsError,
|
||||
// 可选音色列表
|
||||
ttsPresetVoices: presetVoices,
|
||||
ttsClonedVoices: clonedVoices,
|
||||
// Setters
|
||||
setTtsText,
|
||||
setTtsVoiceId,
|
||||
|
||||
@@ -32,24 +32,39 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
|
||||
|
||||
/* 直传文件(后端会自动创建 ingest job) */
|
||||
const { ingest_job_id } = await uploadAssetDirect({
|
||||
const complete = await uploadAssetDirect({
|
||||
file: data.file,
|
||||
library_id: lib.id,
|
||||
onProgress: (p) => setUploadProgress(p),
|
||||
})
|
||||
|
||||
/* 轮询 ingest job 状态,等待 Worker 处理完成 */
|
||||
let jobStatus = ""
|
||||
/* 去重命中(同库已存在相同 file_hash 素材):
|
||||
* 后端返回 duplicated=true,ingest_job_id 为空,
|
||||
* 不轮询、直接按上传成功处理(onSuccess 分支 toast + 刷新列表)。
|
||||
* 注意:mutationFn 正常 return 即视为 resolve,useMutation 会自动调 onSuccess。
|
||||
*/
|
||||
if (complete.duplicated === true) {
|
||||
return
|
||||
}
|
||||
if (!complete.ingest_job_id) {
|
||||
throw new Error("上传完成但未返回处理任务 ID,请重试")
|
||||
}
|
||||
const { ingest_job_id } = complete
|
||||
|
||||
/* 轮询 ingest job 状态,等待 Worker 处理完成;
|
||||
* complete 后先立即查一次(后端通常不到 1s 处理完),未完成再每 5s 轮询。
|
||||
* 成功状态为 IngestJobStatus.completed;"ready" 是 voice-clones 的状态,此处误用需避免。
|
||||
*/
|
||||
let job = await getIngestJob(ingest_job_id)
|
||||
let retries = 0
|
||||
const maxRetries = 60 // 最多等待 5 分钟(60 * 5秒)
|
||||
while (jobStatus !== "ready" && jobStatus !== "failed" && retries < maxRetries) {
|
||||
while (job.status !== "completed" && job.status !== "failed" && retries < maxRetries) {
|
||||
await new Promise((r) => setTimeout(r, 5000))
|
||||
const job = await getIngestJob(ingest_job_id)
|
||||
jobStatus = job.status
|
||||
job = await getIngestJob(ingest_job_id)
|
||||
retries++
|
||||
}
|
||||
|
||||
if (jobStatus === "failed") {
|
||||
if (job.status === "failed") {
|
||||
throw new Error("音频处理失败,请重试")
|
||||
}
|
||||
if (retries >= maxRetries) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { isAssetUsable } from "@/api/assets"
|
||||
|
||||
describe("isAssetUsable", () => {
|
||||
it("usable 字段缺失时降级为可用(后端字段未上线零影响)", () => {
|
||||
expect(isAssetUsable({})).toBe(true)
|
||||
expect(isAssetUsable({ usable: undefined })).toBe(true)
|
||||
expect(isAssetUsable({ usable: null })).toBe(true)
|
||||
})
|
||||
|
||||
it("usable === true 时可用", () => {
|
||||
expect(isAssetUsable({ usable: true })).toBe(true)
|
||||
})
|
||||
|
||||
it("usable === false 时不可用(已彻底用尽)", () => {
|
||||
expect(isAssetUsable({ usable: false })).toBe(false)
|
||||
expect(isAssetUsable({ usable: false, used_ratio: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it("used_ratio 不影响可用性判断(只影响角标展示)", () => {
|
||||
expect(isAssetUsable({ used_ratio: 0.99 })).toBe(true)
|
||||
expect(isAssetUsable({ used_ratio: 0 })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,42 +1,82 @@
|
||||
import React from "react"
|
||||
// 重构:useCloneModal Hook 已拆分为 useCloneFormState + useCloneSubmit 子 Hook
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/voice-clone", () => ({
|
||||
createVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn(),
|
||||
toVoiceClone: vi.fn((x) => x),
|
||||
}))
|
||||
|
||||
const mockGetAssetsByKind = vi.fn()
|
||||
vi.mock("@/api/assets", () => ({
|
||||
uploadAssetDirect: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ storage_key: "test", ingest_job_id: "test", url: "http://test" }),
|
||||
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "lib-1" }),
|
||||
getAssetsByKind: (...args: unknown[]) => mockGetAssetsByKind(...args),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/projects", () => ({
|
||||
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "proj-1" }),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ enabled, queryFn }: { enabled: boolean; queryFn: () => unknown }) => {
|
||||
// enabled=false 时不发请求(模拟弹窗关闭)
|
||||
if (!enabled) return { data: undefined, isLoading: false }
|
||||
return { data: mockQueryData, isLoading: mockLoading }
|
||||
},
|
||||
}))
|
||||
|
||||
let mockQueryData: unknown = undefined
|
||||
let mockLoading = false
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
Modal: ({ open, children, onCancel, onOk, title }: any) =>
|
||||
Modal: ({ open, children, onCancel, title }: any) =>
|
||||
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
|
||||
Button: ({ children, onClick, disabled, buttonType }: any) =>
|
||||
React.createElement("button", { onClick, disabled, "data-type": buttonType }, children),
|
||||
}))
|
||||
|
||||
describe("CloneModal", () => {
|
||||
it("should render when closed", () => {
|
||||
it("关闭时不渲染", () => {
|
||||
mockQueryData = undefined
|
||||
const { container } = render(<CloneModal open={false} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it("should render input phase when open", () => {
|
||||
const { container } = render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(container).toBeTruthy()
|
||||
it("打开时显示「从配音素材选择」和「直接录制」,不再有文件上传入口", () => {
|
||||
mockQueryData = []
|
||||
mockLoading = false
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("从配音素材选择")).toBeTruthy()
|
||||
expect(screen.getByText("直接录制")).toBeTruthy()
|
||||
// 文件上传入口已删除
|
||||
expect(screen.queryByText(/拖拽音频文件/)).toBeNull()
|
||||
expect(screen.queryByText("上传音频")).toBeNull()
|
||||
})
|
||||
|
||||
it("should call onClose when cancel", () => {
|
||||
const onClose = vi.fn()
|
||||
render(<CloneModal open={true} onClose={onClose} />)
|
||||
// just verify render doesn't crash
|
||||
expect(onClose).toBeDefined()
|
||||
it("素材为空时提示先上传素材并给跳转入口", () => {
|
||||
mockQueryData = []
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("请先在配音库上传素材")).toBeTruthy()
|
||||
expect(screen.getByText("去配音库上传")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("有素材时下拉展示素材名和时长", () => {
|
||||
mockQueryData = [
|
||||
{ id: "a1", name: "旁白录音.m4a", duration: 65 },
|
||||
{ id: "a2", name: "访谈.mp3", duration: undefined },
|
||||
]
|
||||
render(<CloneModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText("旁白录音.m4a(01:05)")).toBeTruthy()
|
||||
expect(screen.getByText("访谈.mp3(--:--)")).toBeTruthy()
|
||||
// 空态提示不出现
|
||||
expect(screen.queryByText("请先在配音库上传素材")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Smoke test for utils
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import "@/components/voice/CloneModal/utils"
|
||||
|
||||
describe("utils smoke", () => {
|
||||
it("should load module successfully", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@ import "@/pages/assets/components/CreateLibraryModal"
|
||||
import "@/pages/assets/components/LibrarySidebar"
|
||||
import "@/pages/assets/components/PlayModal"
|
||||
import "@/pages/assets/components/ResultDrawer"
|
||||
import "@/pages/assets/components/UploadProgressModal"
|
||||
import "@/pages/assets/components/UploadQueuePanel"
|
||||
|
||||
// 类型与常量
|
||||
import "@/pages/assets/types"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { getUsageBadge } from "@/pages/assets/types"
|
||||
|
||||
describe("getUsageBadge", () => {
|
||||
it("非视频素材不显示角标", () => {
|
||||
expect(getUsageBadge({ kind: "voice", usable: false })).toBeNull()
|
||||
expect(getUsageBadge({ kind: "image", usable: false })).toBeNull()
|
||||
})
|
||||
|
||||
it("字段缺失时不显示角标(降级零影响)", () => {
|
||||
expect(getUsageBadge({ kind: "video" })).toBeNull()
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: undefined })).toBeNull()
|
||||
})
|
||||
|
||||
it("usable === false 显示红色实心「已用尽」", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: false, usedRatio: 1 })).toEqual({
|
||||
label: "已用尽",
|
||||
variant: "exhausted",
|
||||
})
|
||||
// usable === false 优先级最高,即使 usedRatio 字段缺失
|
||||
expect(getUsageBadge({ kind: "video", usable: false })).toEqual({
|
||||
label: "已用尽",
|
||||
variant: "exhausted",
|
||||
})
|
||||
})
|
||||
|
||||
it("used_ratio >= 0.85 显示红色「即将用尽」", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.85 })).toEqual({
|
||||
label: "即将用尽",
|
||||
variant: "warning",
|
||||
})
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.97 })).toEqual({
|
||||
label: "即将用尽",
|
||||
variant: "warning",
|
||||
})
|
||||
})
|
||||
|
||||
it("used_ratio >= 0.5 显示橙色「已用 xx%」", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.5 })).toEqual({
|
||||
label: "已用 50%",
|
||||
variant: "ratio",
|
||||
})
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.84 })).toEqual({
|
||||
label: "已用 84%",
|
||||
variant: "ratio",
|
||||
})
|
||||
})
|
||||
|
||||
it("used_ratio < 0.5 不显示角标", () => {
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0.49 })).toBeNull()
|
||||
expect(getUsageBadge({ kind: "video", usable: true, usedRatio: 0 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* useAssetUpload 队列批量上传测试
|
||||
* - 并发直传不超过 MAX_CONCURRENT(3)
|
||||
* - prepare 返回 asset_id 后 invalidate 列表
|
||||
* - 状态流转 uploading→ingesting→done;失败可重试;duplicated 命中
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor, act } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
const invalidateQueries = vi.fn()
|
||||
vi.mock("@/api/assets", () => ({
|
||||
prepareDirectUploadHandle: vi.fn(),
|
||||
}))
|
||||
|
||||
const { prepareDirectUploadHandle } = await import("@/api/assets")
|
||||
const { useAssetUpload } = await import("@/pages/assets/hooks/useAssetUpload")
|
||||
|
||||
interface FakeHandle {
|
||||
prepared: {
|
||||
upload_url: string
|
||||
method: string
|
||||
storage_key: string
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
asset_id: string
|
||||
}
|
||||
transfer: ReturnType<typeof vi.fn>
|
||||
complete: ReturnType<typeof vi.fn>
|
||||
/** 手动结束传输(transfer 被调用后挂载);finish(true) 以失败结束 */
|
||||
finish: (fail?: boolean) => void
|
||||
}
|
||||
|
||||
let activeTransfers = 0
|
||||
let maxConcurrent = 0
|
||||
|
||||
/**
|
||||
* 创建一个假 handle:transfer 返回挂起的 promise,
|
||||
* finish 槽位在 transfer executor 同步执行时挂载,测试中调用 finish() 控制成败
|
||||
*/
|
||||
const makeFakeHandle = (opts: { id: string; duplicated?: boolean; failTransfer?: boolean }) => {
|
||||
const h = {
|
||||
prepared: {
|
||||
upload_url: "https://oss.example.com/u",
|
||||
method: "POST",
|
||||
storage_key: `uploads/${opts.id}/y.mp4`,
|
||||
expires_at: "2099-01-01",
|
||||
fields: {},
|
||||
max_size_bytes: 2_000_000_000,
|
||||
asset_id: opts.id,
|
||||
},
|
||||
transfer: vi.fn(),
|
||||
complete: vi.fn().mockResolvedValue({
|
||||
storage_key: "uploads/x/y.mp4",
|
||||
ingest_job_id: opts.duplicated ? "" : "job-1",
|
||||
url: "https://oss.example.com/u",
|
||||
duplicated: opts.duplicated,
|
||||
asset_id: opts.id,
|
||||
}),
|
||||
finish: (() => {}) as (fail?: boolean) => void,
|
||||
}
|
||||
h.transfer.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
activeTransfers += 1
|
||||
maxConcurrent = Math.max(maxConcurrent, activeTransfers)
|
||||
h.finish = (fail = false) => {
|
||||
activeTransfers -= 1
|
||||
if (fail || opts.failTransfer) reject(new Error("OSS boom"))
|
||||
else _resolve()
|
||||
}
|
||||
}),
|
||||
)
|
||||
return h
|
||||
}
|
||||
|
||||
type FakeHandleLike = ReturnType<typeof makeFakeHandle>
|
||||
|
||||
/** prepare mock:调用序号生成稳定 id,立即把 handle(含 finish 槽位)推入数组 */
|
||||
const installPrepareMock = (
|
||||
handles: FakeHandleLike[],
|
||||
optOverrides?: (id: string) => { duplicated?: boolean; failTransfer?: boolean },
|
||||
) => {
|
||||
let callNo = 0
|
||||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async () => {
|
||||
const id = `asset-${callNo++}`
|
||||
const overrides = optOverrides?.(id) ?? {}
|
||||
const h = makeFakeHandle({ id, ...overrides })
|
||||
handles.push(h)
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
return h
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const createWrapper = () => {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
// 监听 invalidate 调用
|
||||
const orig = qc.invalidateQueries.bind(qc)
|
||||
qc.invalidateQueries = ((...args: unknown[]) => {
|
||||
invalidateQueries()
|
||||
return orig(...(args as never))
|
||||
}) as never
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mp4 = (name: string) => new File([new Uint8Array(10)], name, { type: "video/mp4" })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
activeTransfers = 0
|
||||
maxConcurrent = 0
|
||||
})
|
||||
|
||||
describe("useAssetUpload", () => {
|
||||
it("5 个文件批量入队:同时直传不超过 3 个,全部完成且刷新列表", async () => {
|
||||
const handles: FakeHandleLike[] = []
|
||||
installPrepareMock(handles)
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads(Array.from({ length: 5 }, (_, i) => mp4(`v${i}.mp4`)))
|
||||
})
|
||||
|
||||
// 3 个进入 uploading(transfer 被挂起),2 个排队
|
||||
await waitFor(() => {
|
||||
expect(handles.length).toBe(3)
|
||||
expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(3)
|
||||
expect(result.current.uploadItems.filter((it) => it.status === "preparing").length).toBe(2)
|
||||
})
|
||||
expect(maxConcurrent).toBe(3)
|
||||
|
||||
// 完成前 3 个 → 队列拉起后 2 个
|
||||
await act(async () => {
|
||||
handles[0].finish()
|
||||
handles[1].finish()
|
||||
handles[2].finish()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(handles.length).toBe(5)
|
||||
expect(result.current.uploadItems.filter((it) => it.status === "uploading").length).toBe(2)
|
||||
})
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(3)
|
||||
await waitFor(() => expect(handles[4].transfer).toHaveBeenCalled())
|
||||
|
||||
// 完成剩余 2 个
|
||||
await act(async () => {
|
||||
handles[3].finish()
|
||||
handles[4].finish()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploadItems.filter((it) => it.status === "done").length).toBe(5)
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("传输失败标记 error,重试后成功", async () => {
|
||||
const handles: FakeHandleLike[] = []
|
||||
let firstCall = true
|
||||
installPrepareMock(handles, () => {
|
||||
const fail = firstCall
|
||||
firstCall = false
|
||||
return { failTransfer: fail }
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("bad.mp4")])
|
||||
})
|
||||
await waitFor(() => expect(handles.length).toBe(1))
|
||||
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
|
||||
await act(async () => {
|
||||
handles[0].finish()
|
||||
})
|
||||
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
|
||||
|
||||
// 重试:重新 prepare(handles[1] 成功)
|
||||
const tempId = result.current.uploadItems[0].tempId
|
||||
await act(async () => {
|
||||
result.current.retryUpload(tempId)
|
||||
})
|
||||
await waitFor(() => expect(handles.length).toBe(2))
|
||||
await waitFor(() => expect(handles[1].transfer).toHaveBeenCalled())
|
||||
await act(async () => {
|
||||
handles[1].finish()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploadItems.find((it) => it.tempId === tempId)?.status).toBe("done")
|
||||
})
|
||||
})
|
||||
|
||||
it("complete 返回 duplicated 时标记去重完成", async () => {
|
||||
const handles: FakeHandleLike[] = []
|
||||
installPrepareMock(handles, () => ({ duplicated: true }))
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("dup.mp4")])
|
||||
})
|
||||
await waitFor(() => expect(handles.length).toBe(1))
|
||||
await waitFor(() => expect(handles[0].transfer).toHaveBeenCalled())
|
||||
await act(async () => {
|
||||
handles[0].finish()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(result.current.uploadItems[0].duplicated).toBe(true)
|
||||
expect(result.current.uploadItems[0].status).toBe("done")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,6 @@ import "@/pages/generate/components/material/MaterialModeTabs"
|
||||
import "@/pages/generate/components/material/ManualMaterialList"
|
||||
import "@/pages/generate/components/material/SmartMatchInput"
|
||||
import "@/pages/generate/components/material/SmartMatchResults"
|
||||
import "@/pages/generate/components/material/SmartMatchCard"
|
||||
import "@/pages/generate/components/title/AiTitleGenerator"
|
||||
import "@/pages/generate/components/title/AiTitleCard"
|
||||
import "@/pages/generate/components/title/TitleStylePanel"
|
||||
|
||||
@@ -308,6 +308,71 @@ class VideoDeduplicator:
|
||||
|
||||
return sum(similarities) / len(similarities) if similarities else 0.0
|
||||
|
||||
def compute_duplicate_rate(
|
||||
self,
|
||||
fingerprint: VideoFingerprint,
|
||||
project_id: str,
|
||||
current_video_id: str | None,
|
||||
session: Session,
|
||||
) -> float:
|
||||
"""计算当前视频与项目内已有视频的最高相似度百分比。
|
||||
|
||||
遍历项目内所有其他有指纹的视频,对每个计算相似度:
|
||||
- MD5 精确匹配 → 100%
|
||||
- pHash 相似度 → (1.0 - avg_distance / 64) * 100
|
||||
取最高值作为 duplicate_rate(0~100)。
|
||||
如果没有其他视频可比较,返回 0.0。
|
||||
|
||||
Args:
|
||||
fingerprint: 当前视频的指纹
|
||||
project_id: 项目 ID
|
||||
current_video_id: 当前视频 ID(排除自身,可为 None)
|
||||
session: 数据库会话
|
||||
|
||||
Returns:
|
||||
duplicate_rate: 0~100 的浮点数
|
||||
"""
|
||||
# 限制查询最近 100 个视频,避免大项目内存溢出
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
recent_models = (
|
||||
session.query(GeneratedVideoModel)
|
||||
.filter(GeneratedVideoModel.project_id == project_id)
|
||||
.order_by(GeneratedVideoModel.generated_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
existing_videos = [video_repo._to_domain(m) for m in recent_models]
|
||||
|
||||
max_similarity = 0.0
|
||||
for existing in existing_videos:
|
||||
if current_video_id and existing.id == current_video_id:
|
||||
continue
|
||||
if not existing.video_fingerprint:
|
||||
continue
|
||||
|
||||
ef = existing.video_fingerprint
|
||||
|
||||
# MD5 精确匹配 → 100%
|
||||
if fingerprint.md5 == ef.get("md5"):
|
||||
return 100.0
|
||||
|
||||
# pHash 相似度
|
||||
existing_phashes = ef.get("keyframe_phashes", [])
|
||||
if not existing_phashes or not fingerprint.keyframe_phashes:
|
||||
continue
|
||||
|
||||
min_distances = []
|
||||
for phash in fingerprint.keyframe_phashes:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 64
|
||||
similarity = (1.0 - avg_distance / 64) * 100
|
||||
max_similarity = max(max_similarity, similarity)
|
||||
|
||||
return round(max(max_similarity, 0.0), 2)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3, name="worker.check_duplicate")
|
||||
def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
|
||||
@@ -121,6 +121,15 @@ def create_video_record_and_dedup(
|
||||
generated_video.is_duplicate = False
|
||||
generated_video.duplicate_of = None
|
||||
|
||||
# 计算重复率百分比(与项目内所有已有视频对比取最高相似度)
|
||||
try:
|
||||
dup_rate = deduplicator.compute_duplicate_rate(fingerprint, project_id, video_id, session)
|
||||
generated_video.duplicate_rate = dup_rate
|
||||
logger.info("Duplicate rate for %s: %.2f%%", video_id, dup_rate)
|
||||
except Exception as rate_err:
|
||||
logger.warning("Failed to compute duplicate_rate for %s: %s", video_id, rate_err)
|
||||
generated_video.duplicate_rate = None
|
||||
|
||||
video_repo.update(generated_video)
|
||||
session.commit()
|
||||
logger.info(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -150,6 +151,214 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
|
||||
return metadata, success
|
||||
|
||||
|
||||
# ── HEVC 自动转码辅助函数(模块级,便于单元测试)─────────────────────────
|
||||
HEVC_CODECS = ("hevc", "h265", "hvh1")
|
||||
# 转码目标:长边封顶 1920(只缩不放,与 validate 的 max_long_edge 一致),
|
||||
# 竖屏/横屏/超宽屏统一按长边等比缩放,短边自动按比例(-2 保证偶数)。
|
||||
TRANSCODE_MAX_LONG_EDGE = 1920
|
||||
TRANSCODE_TIMEOUT_SECONDS = 900
|
||||
# ffmpeg scale 滤镜中 if(...) 表达式内的逗号必须用 \, 转义,
|
||||
# 否则逗号被当作 filter 分隔符解析,报 "No such filter" / Invalid size。
|
||||
# subprocess list 传参不经 shell,\ 在 Python 字符串里直接写一个字面反斜杠即可。
|
||||
# 横屏(iw>=ih)限宽 min(1920,iw)、高 -2 自适应;竖屏(ih>iw)限高、宽自适应;
|
||||
# min() 保证小视频不放大。与 validate_transcode_output 的"长边 <= 1920"规则对齐,
|
||||
# 超宽屏(如 4000x1000)短边不触发旧的短边缩放、长边超限被误降级的问题由此消除。
|
||||
_TRANSCODE_VF = (
|
||||
rf"scale=w=if(gte(iw\,ih)\,min({TRANSCODE_MAX_LONG_EDGE}\,iw)\,-2):"
|
||||
rf"h=if(gt(ih\,iw)\,min({TRANSCODE_MAX_LONG_EDGE}\,ih)\,-2),format=yuv420p"
|
||||
)
|
||||
|
||||
|
||||
def is_hevc_codec(codec: str | None) -> bool:
|
||||
"""判断编码是否为 HEVC(不区分大小写)。"""
|
||||
return (codec or "").lower() in HEVC_CODECS
|
||||
|
||||
|
||||
def probe_rotation(path: str) -> int | None:
|
||||
"""ffprobe 读取视频旋转角度(display matrix side data)。
|
||||
|
||||
返回 0/90/-90/180 等整数;无 side data 或探测失败返回 None。
|
||||
|
||||
注意:旧实现同时请求 side_data 和 stream_tags 且取输出第一行,
|
||||
iOS 文件会输出两行(如 "270\\n90")导致取到错误值,现仅读 side_data。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"side_data=rotation",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
first_line = (result.stdout or "").strip().split("\n")[0].strip()
|
||||
if not first_line:
|
||||
return None
|
||||
return int(float(first_line))
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def is_portrait_rotation(rotation: int | None) -> bool:
|
||||
"""rotation side data 为 ±90/270 时表示竖屏拍摄。
|
||||
|
||||
注意:这只覆盖"存储横屏 + display matrix 旋转"的 iOS 风格视频;
|
||||
物理竖屏视频(Android 常见,存储即 h>w、rotation=None/0)不会命中,
|
||||
方向判定请用 is_portrait_video()。
|
||||
"""
|
||||
return rotation in (90, 270, -90)
|
||||
|
||||
|
||||
def is_portrait_video(
|
||||
stored_width: int | None,
|
||||
stored_height: int | None,
|
||||
rotation: int | None,
|
||||
) -> bool:
|
||||
"""按显示方向判断是否竖屏(显示高度 > 显示宽度)。
|
||||
|
||||
- rotation 为 90/270/-90 时,显示方向的宽高相对存储维度互换;
|
||||
- rotation 为 0/180/None 时,显示方向即存储维度。
|
||||
|
||||
这样两类竖屏都能正确识别:
|
||||
- iOS:存储 1920x1080 + rotation=90 → 显示 1080x1920 竖屏
|
||||
- Android/物理竖屏:存储 1080x1920、无 rotation → 显示 1080x1920 竖屏
|
||||
探测失败(维度为 None)时退回仅看 rotation,保证调用链不中断。
|
||||
"""
|
||||
if not stored_width or not stored_height:
|
||||
return is_portrait_rotation(rotation)
|
||||
if is_portrait_rotation(rotation):
|
||||
return stored_width > stored_height
|
||||
return stored_height > stored_width
|
||||
|
||||
|
||||
def build_transcode_vf() -> str:
|
||||
"""构建转码视频滤镜(竖屏/横屏统一,按显示长边封顶 1920、只缩不放)。
|
||||
|
||||
依赖 ffmpeg 内置 autorotate(默认开启)按 display matrix 物理旋转画面,
|
||||
输出自动剥离 rotation side data;滤镜只做等比缩放,方向无关:
|
||||
横屏限宽、竖屏限高,短边 -2 自适应偶数,min() 保证小视频不放大。
|
||||
|
||||
旧实现的问题:
|
||||
- 显式 transpose=1 与 autorotate 叠加,竖屏被二次旋转成横屏;
|
||||
- 竖屏沿用按高缩放表达式,1080x1920 被错误缩成 608x1080;
|
||||
- 仅按短边 1080 触发缩放,超宽屏(如 4000x1000)长边超 1920 会被
|
||||
validate 拦截误降级,用户拿到浏览器无法播放的 HEVC 原文件。
|
||||
"""
|
||||
return _TRANSCODE_VF
|
||||
|
||||
|
||||
def probe_dimensions(path: str) -> tuple[int | None, int | None]:
|
||||
"""ffprobe 读取视频宽高(像素维度)。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"csv=p=0:s=x",
|
||||
str(path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
text = (result.stdout or "").strip().split("\n")[0].strip()
|
||||
width_str, height_str = text.split("x")
|
||||
return int(width_str), int(height_str)
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError):
|
||||
return None, None
|
||||
|
||||
|
||||
def probe_video_info(path: str) -> tuple[int | None, int | None, int | None]:
|
||||
"""一次 ffprobe 同时读取视频宽高与旋转角度(display matrix side data)。
|
||||
|
||||
返回 (width, height, rotation);探测失败对应位置为 None。
|
||||
合并维度/角度两次探测,减少大文件、高并发下的 ffprobe 进程开销。
|
||||
rotation 仅取 stream side_data_list 的 Display Matrix(不读 tags.rotate,
|
||||
避免 iOS 文件 tag 值与 side data 双来源取错)。用 -show_streams 全量 JSON
|
||||
输出解析,兼容 ffmpeg 4.x/7.x(show_entries 嵌套 section 名跨版本不一致)。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_streams",
|
||||
"-of",
|
||||
"json",
|
||||
str(path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
data = json.loads(result.stdout or "{}")
|
||||
streams = data.get("streams") or []
|
||||
if not streams:
|
||||
return None, None, None
|
||||
stream = streams[0]
|
||||
width = int(stream["width"]) if stream.get("width") else None
|
||||
height = int(stream["height"]) if stream.get("height") else None
|
||||
rotation = None
|
||||
for side in stream.get("side_data_list") or []:
|
||||
if side.get("side_data_type") == "Display Matrix" and side.get("rotation") is not None:
|
||||
deg = int(round(float(side["rotation"]))) % 360
|
||||
# ffprobe:顺时针 90 拍摄输出 90,逆时针 90 输出 -90(归一为 270)
|
||||
rotation = {0: 0, 90: 90, 180: 180, 270: -90}.get(deg, deg if deg in (90, 180) else None)
|
||||
break
|
||||
return width, height, rotation
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError, json.JSONDecodeError, KeyError, TypeError):
|
||||
return None, None, None
|
||||
|
||||
|
||||
def validate_transcode_output(
|
||||
output_path: str,
|
||||
expected_portrait: bool,
|
||||
max_long_edge: int = TRANSCODE_MAX_LONG_EDGE,
|
||||
) -> bool:
|
||||
"""校验转码产物方向与维度。
|
||||
|
||||
- 竖屏源:产物必须 height > width,且仍有 rotation side data 视为失败
|
||||
(播放器会二次旋转成横屏)
|
||||
- 横屏源:产物必须 width >= height
|
||||
- 长边不得超过 max_long_edge(只缩不放)
|
||||
校验失败时调用方应降级使用原始文件,不允许产出方向错误的文件覆盖。
|
||||
"""
|
||||
width, height = probe_dimensions(output_path)
|
||||
if not width or not height:
|
||||
return False
|
||||
if expected_portrait and height <= width:
|
||||
return False
|
||||
if not expected_portrait and width < height:
|
||||
return False
|
||||
if max(width, height) > max_long_edge:
|
||||
return False
|
||||
# 产物仍带 rotation side data 说明方向没有物理固化,播放器会再次旋转
|
||||
if probe_rotation(output_path) is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@celery_app.task(name="worker.ingest_asset")
|
||||
def ingest_asset(job_id: str) -> dict:
|
||||
"""
|
||||
@@ -245,15 +454,13 @@ def ingest_asset(job_id: str) -> dict:
|
||||
# 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码
|
||||
# 失败时降级使用原始文件,不阻塞上传流程
|
||||
if media_type == "video" and local_file and local_file.exists():
|
||||
codec = (metadata.get("codec") or "").lower()
|
||||
if codec in ("hevc", "h265", "hvh1"):
|
||||
if is_hevc_codec(metadata.get("codec")):
|
||||
logger.info(
|
||||
"检测到 HEVC 编码 (codec=%s),启动转码: job_id=%s",
|
||||
codec,
|
||||
metadata.get("codec"),
|
||||
job_id,
|
||||
)
|
||||
_tc_tmp = None
|
||||
_needs_rotation = False
|
||||
|
||||
# ── Step 1: 磁盘空间检查(独立 try/except,失败仍尝试转码)──
|
||||
try:
|
||||
@@ -264,50 +471,21 @@ def ingest_asset(job_id: str) -> dict:
|
||||
except Exception as _disk_err:
|
||||
logger.warning("磁盘检查失败,仍尝试转码: job_id=%s err=%s", job_id, _disk_err)
|
||||
|
||||
# ── Step 2: ffprobe 旋转检测(独立 try/except,失败不阻塞转码)──
|
||||
try:
|
||||
_probe_cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"side_data=rotation",
|
||||
"-show_entries",
|
||||
"stream_tags=rotate",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_file),
|
||||
]
|
||||
_probe_result = subprocess.run(
|
||||
_probe_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=60, # 大文件在容器 overlay 文件系统上解析可能较慢
|
||||
)
|
||||
_rotation_str = (_probe_result.stdout or "").strip().split("\n")[0]
|
||||
if _rotation_str in ("90", "270", "-90"):
|
||||
_needs_rotation = True
|
||||
logger.info(
|
||||
"检测到竖屏视频 (rotation=%s),将物理旋转画面: job_id=%s",
|
||||
_rotation_str,
|
||||
job_id,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"ffprobe 旋转检测超时(60s),跳过旋转继续转码: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
_needs_rotation = False
|
||||
except Exception as _probe_err:
|
||||
logger.warning(
|
||||
"ffprobe 旋转检测异常,跳过旋转继续转码: job_id=%s err=%s",
|
||||
job_id,
|
||||
_probe_err,
|
||||
)
|
||||
_needs_rotation = False
|
||||
# ── Step 2: 方向检测(按显示方向判定竖/横屏)──────────────
|
||||
# 不能只看 rotation side data:Android 等设备的物理竖屏视频
|
||||
# 存储维度已是 h>w 且 rotation=0/None,只看 rotation 会误判横屏、
|
||||
# 套用横屏滤镜把 1080x1920 压成 608x1080,转码产物校验失败降级,
|
||||
# 用户拿到 HEVC 原文件浏览器仍黑帧。
|
||||
_src_w, _src_h, _rotation = probe_video_info(str(local_file))
|
||||
_is_portrait = is_portrait_video(_src_w, _src_h, _rotation)
|
||||
logger.info(
|
||||
"视频方向检测: stored=%sx%s rotation=%s portrait=%s: job_id=%s",
|
||||
_src_w,
|
||||
_src_h,
|
||||
_rotation,
|
||||
_is_portrait,
|
||||
job_id,
|
||||
)
|
||||
|
||||
# ── Step 3: ffmpeg 转码(独立 try/except)──
|
||||
try:
|
||||
@@ -315,11 +493,11 @@ def ingest_asset(job_id: str) -> dict:
|
||||
_tc_tmp = Path(_tc_tmp_file.name)
|
||||
_tc_tmp_file.close() # 关闭文件描述符,ffmpeg 会自己打开
|
||||
|
||||
# 构建 video filter:竖屏先旋转再缩放
|
||||
if _needs_rotation:
|
||||
_vf = "transpose=1,scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
|
||||
else:
|
||||
_vf = "scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'"
|
||||
# 旋转交给 ffmpeg 内置 autorotate(按 display matrix 物理旋转,
|
||||
# 输出自动剥离 side data);滤镜只做 1080p 等比"只缩不放"。
|
||||
# 注意不能再加 transpose:旧逻辑 autorotate + transpose 双重旋转,
|
||||
# 竖屏被转成横屏;scale 表达式内逗号必须 \, 转义(见 build_transcode_vf)。
|
||||
_vf = build_transcode_vf()
|
||||
|
||||
_cmd = [
|
||||
"ffmpeg",
|
||||
@@ -333,7 +511,7 @@ def ingest_asset(job_id: str) -> dict:
|
||||
"-crf",
|
||||
"18",
|
||||
"-vf",
|
||||
_vf + ",format=yuv420p",
|
||||
_vf,
|
||||
"-colorspace",
|
||||
"bt709",
|
||||
"-color_primaries",
|
||||
@@ -344,54 +522,62 @@ def ingest_asset(job_id: str) -> dict:
|
||||
"yuv420p",
|
||||
"-level",
|
||||
"4.2",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(_tc_tmp),
|
||||
]
|
||||
# 竖屏视频:清除旋转元数据
|
||||
if _needs_rotation:
|
||||
_cmd.extend(["-metadata:s:v:0", "rotate=0"])
|
||||
_cmd.extend(
|
||||
[
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(_tc_tmp),
|
||||
]
|
||||
)
|
||||
_proc = subprocess.run(
|
||||
_cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=900,
|
||||
timeout=TRANSCODE_TIMEOUT_SECONDS,
|
||||
)
|
||||
if _proc.returncode == 0 and _tc_tmp.exists() and _tc_tmp.stat().st_size > 0:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
_p = Path(job.storage_key)
|
||||
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
|
||||
_url = upload_to_oss(_tc_tmp, _new_key)
|
||||
if _url:
|
||||
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
|
||||
_new_metadata, _new_extract_success = extract_media_metadata(
|
||||
str(_tc_tmp),
|
||||
media_type,
|
||||
)
|
||||
if _new_extract_success:
|
||||
job.storage_key = _new_key
|
||||
metadata = _new_metadata
|
||||
extract_success = _new_extract_success
|
||||
logger.info(
|
||||
"HEVC→H.264 转码完成: job_id=%s key=%s",
|
||||
# ── Step 4: 方向/维度校验,不符则降级,杜绝横屏文件覆盖 ──
|
||||
if not validate_transcode_output(str(_tc_tmp), _is_portrait):
|
||||
_w, _h = probe_dimensions(str(_tc_tmp))
|
||||
_rot = probe_rotation(str(_tc_tmp))
|
||||
logger.error(
|
||||
"转码产物方向/维度校验失败,降级使用原始文件: "
|
||||
"job_id=%s source_rotation=%s portrait=%s out=%sx%s out_rotation=%s",
|
||||
job_id,
|
||||
_new_key[:80],
|
||||
_rotation,
|
||||
_is_portrait,
|
||||
_w,
|
||||
_h,
|
||||
_rot,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
_p = Path(job.storage_key)
|
||||
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
|
||||
_url = upload_to_oss(_tc_tmp, _new_key)
|
||||
if _url:
|
||||
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
|
||||
_new_metadata, _new_extract_success = extract_media_metadata(
|
||||
str(_tc_tmp),
|
||||
media_type,
|
||||
)
|
||||
if _new_extract_success:
|
||||
job.storage_key = _new_key
|
||||
metadata = _new_metadata
|
||||
extract_success = _new_extract_success
|
||||
logger.info(
|
||||
"HEVC→H.264 转码完成: job_id=%s key=%s",
|
||||
job_id,
|
||||
_new_key[:80],
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
|
||||
job_id,
|
||||
)
|
||||
else:
|
||||
_tail = _proc.stderr[-300:] if _proc.stderr else ""
|
||||
logger.warning(
|
||||
|
||||
@@ -30,6 +30,14 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-bake ffmpeg: unit-tests run in fresh containers each time; installing ffmpeg
|
||||
# on every job cost ~24 min (apt update + hundreds of codec deps). Bake it into the
|
||||
# image so step_install_ffmpeg.sh detects it and exits instantly.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& ffmpeg -version | head -1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Pre-install base deps (layer cache)
|
||||
COPY requirements-base.txt ./
|
||||
RUN python -m venv "$VIRTUAL_ENV" \
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Build stage
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
ARG SOURCE_HASH=""
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
@@ -18,8 +19,10 @@ COPY apps/web/ ./
|
||||
|
||||
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
|
||||
# node_modules直接使用镜像中已安装的(layer缓存保证完整性)
|
||||
# SOURCE_HASH 变化时强制重新执行(防止 buildkit 幽灵缓存命中)
|
||||
RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& echo "SOURCE_HASH=${SOURCE_HASH}" > .cache_bust \
|
||||
&& ./node_modules/.bin/tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& ./node_modules/.bin/vite build
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.dumps(video.video_fingerprint) if video.video_fingerprint else None,
|
||||
is_duplicate=video.is_duplicate,
|
||||
duplicate_of=video.duplicate_of,
|
||||
duplicate_rate=video.duplicate_rate,
|
||||
generated_at=video.generated_at,
|
||||
created_at=video.created_at,
|
||||
)
|
||||
@@ -60,6 +61,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
duplicate_rate=getattr(model, "duplicate_rate", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -74,6 +76,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
model.video_fingerprint = json.dumps(video.video_fingerprint) if video.video_fingerprint else None
|
||||
model.is_duplicate = video.is_duplicate
|
||||
model.duplicate_of = video.duplicate_of
|
||||
model.duplicate_rate = video.duplicate_rate
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return video
|
||||
@@ -204,6 +207,7 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
video_fingerprint=json.loads(getattr(model, "video_fingerprint", "null") or "null"),
|
||||
is_duplicate=getattr(model, "is_duplicate", False),
|
||||
duplicate_of=getattr(model, "duplicate_of", None),
|
||||
duplicate_rate=getattr(model, "duplicate_rate", None),
|
||||
generated_at=model.generated_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ class ProjectModel(Base):
|
||||
|
||||
class AssetLibraryModel(Base):
|
||||
__tablename__ = "asset_libraries"
|
||||
__table_args__ = (UniqueConstraint("project_id", "kind", name="uq_asset_libraries_project_kind"),)
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
project_id = Column(String(36), nullable=True, index=True)
|
||||
@@ -337,6 +338,7 @@ class GeneratedVideoModel(Base):
|
||||
video_fingerprint = Column(Text, nullable=True)
|
||||
is_duplicate = Column(Boolean, nullable=False, default=False)
|
||||
duplicate_of = Column(String(36), nullable=True)
|
||||
duplicate_rate = Column(Float, nullable=True)
|
||||
|
||||
|
||||
class TitleLibraryModel(Base):
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""SQLAlchemy implementation of TemplateRepository."""
|
||||
"""SQLAlchemy implementation of TemplateRepository.
|
||||
|
||||
模板 segments 数据源已统一为 template_clip_configs 表。
|
||||
读取时优先 template_clip_configs,回退 template_segments(兼容历史数据)。
|
||||
写入全部走 template_clip_configs。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +15,7 @@ from sqlalchemy.orm import Session
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
EditPlanModel,
|
||||
TemplateCategoryModel,
|
||||
TemplateClipConfigModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
)
|
||||
@@ -47,27 +53,38 @@ class SQLAlchemyTemplateRepository:
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.filter(TemplateModel.name.like(like_pattern))
|
||||
if tag:
|
||||
# JSON 数组包含指定标签(MySQL JSON_CONTAINS / SQLite json_each 兼容写法用 LIKE)
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
models = query.order_by(TemplateModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
templates = [self._model_to_entity(m) for m in models]
|
||||
# 批量加载所有 segments,避免 N+1 查询
|
||||
# 批量加载 segments —— 优先 template_clip_configs
|
||||
if templates:
|
||||
template_ids = [t.id for t in templates]
|
||||
seg_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
clip_models = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id.in_(template_ids))
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
# 按 template_id 分组
|
||||
seg_map: dict[str, list] = {}
|
||||
for sm in seg_models:
|
||||
seg_map.setdefault(sm.template_id, []).append(
|
||||
self._segment_model_to_entity(sm),
|
||||
clip_map: dict[str, list] = {}
|
||||
for cm in clip_models:
|
||||
clip_map.setdefault(cm.template_id, []).append(
|
||||
self._clip_config_to_segment(cm),
|
||||
)
|
||||
# 对没有 clip_configs 的模板,回退读 template_segments
|
||||
missing_ids = [t.id for t in templates if t.id not in clip_map]
|
||||
if missing_ids:
|
||||
old_models = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id.in_(missing_ids))
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
for om in old_models:
|
||||
clip_map.setdefault(om.template_id, []).append(
|
||||
self._segment_model_to_entity(om),
|
||||
)
|
||||
for t in templates:
|
||||
t.segments = seg_map.get(t.id, [])
|
||||
t.segments = clip_map.get(t.id, [])
|
||||
return templates
|
||||
|
||||
def get(self, template_id: str, user_id: str) -> Optional[Template]:
|
||||
@@ -100,7 +117,6 @@ class SQLAlchemyTemplateRepository:
|
||||
is_active=template.is_active,
|
||||
)
|
||||
self.session.add(model)
|
||||
# flush 而非 commit,让 create + create_segments 在同一事务中提交
|
||||
self.session.flush()
|
||||
self.session.refresh(model)
|
||||
result = self._model_to_entity(model)
|
||||
@@ -145,11 +161,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if model is None:
|
||||
return False
|
||||
model.is_active = False
|
||||
# 级联清理关联的 segments,避免孤儿数据
|
||||
self.session.query(TemplateSegmentModel).filter(
|
||||
TemplateSegmentModel.template_id == template_id,
|
||||
).delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
# 复用 delete_segments_by_template 清理两张表的关联数据
|
||||
self.delete_segments_by_template(template_id)
|
||||
return True
|
||||
|
||||
def count_by_user(
|
||||
@@ -172,7 +185,7 @@ class SQLAlchemyTemplateRepository:
|
||||
if keyword:
|
||||
query = query.filter(TemplateModel.name.like(f"%{keyword}%"))
|
||||
if tag:
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
query = query.filter(TemplateModel.tags.like(f'"%{tag}"%'))
|
||||
return query.count()
|
||||
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template:
|
||||
@@ -181,9 +194,8 @@ class SQLAlchemyTemplateRepository:
|
||||
if source is None:
|
||||
raise ValueError(f"Template {template_id} not found")
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
new_template = Template(
|
||||
id=new_id,
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
name=new_name,
|
||||
mode=source.mode,
|
||||
@@ -197,28 +209,22 @@ class SQLAlchemyTemplateRepository:
|
||||
)
|
||||
created = self.create(new_template)
|
||||
|
||||
# 复制 segments
|
||||
# 复用 create_segments 写入 template_clip_configs
|
||||
new_segments: List[TemplateSegment] = []
|
||||
for seg in source.segments:
|
||||
new_seg = TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=new_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
new_segments.append(
|
||||
TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=created.id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
)
|
||||
)
|
||||
new_segments.append(new_seg)
|
||||
model = TemplateSegmentModel(
|
||||
id=new_seg.id,
|
||||
template_id=new_seg.template_id,
|
||||
segment_order=new_seg.segment_order,
|
||||
duration_min=new_seg.duration_min,
|
||||
duration_max=new_seg.duration_max,
|
||||
material_type=new_seg.material_type,
|
||||
)
|
||||
self.session.add(model)
|
||||
if new_segments:
|
||||
self.create_segments(new_segments)
|
||||
else:
|
||||
self.session.commit()
|
||||
|
||||
created.segments = new_segments
|
||||
@@ -227,34 +233,58 @@ class SQLAlchemyTemplateRepository:
|
||||
# ── Segments ──
|
||||
|
||||
def list_segments(self, template_id: str) -> List[TemplateSegment]:
|
||||
models = (
|
||||
"""优先从 template_clip_configs 读取,回退读 template_segments。"""
|
||||
clips = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.order_by(TemplateClipConfigModel.order)
|
||||
.all()
|
||||
)
|
||||
if clips:
|
||||
return [self._clip_config_to_segment(m) for m in clips]
|
||||
# 回退:旧表
|
||||
old = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.order_by(TemplateSegmentModel.segment_order)
|
||||
.all()
|
||||
)
|
||||
return [self._segment_model_to_entity(m) for m in models]
|
||||
return [self._segment_model_to_entity(m) for m in old]
|
||||
|
||||
def create_segments(self, segments: List[TemplateSegment]) -> List[TemplateSegment]:
|
||||
"""写入 template_clip_configs 表。material_type 存入 config JSON。"""
|
||||
for seg in segments:
|
||||
model = TemplateSegmentModel(
|
||||
config = {"material_type": seg.material_type} if seg.material_type else {}
|
||||
model = TemplateClipConfigModel(
|
||||
id=seg.id,
|
||||
template_id=seg.template_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
clip_type="main",
|
||||
order=seg.segment_order,
|
||||
min_duration=seg.duration_min,
|
||||
max_duration=seg.duration_max,
|
||||
text_template="",
|
||||
material_requirements={},
|
||||
transition_effect="cut",
|
||||
config=config,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return segments
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == template_id).delete()
|
||||
"""删除两张表中的 segments 数据,返回删除总数。"""
|
||||
c1 = (
|
||||
self.session.query(TemplateClipConfigModel)
|
||||
.filter(TemplateClipConfigModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
c2 = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
return c1 + c2
|
||||
|
||||
# ── Categories ──
|
||||
|
||||
@@ -366,6 +396,23 @@ class SQLAlchemyTemplateRepository:
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clip_config_to_segment(model: TemplateClipConfigModel) -> TemplateSegment:
|
||||
"""将 TemplateClipConfigModel 转换为 TemplateSegment 域实体。"""
|
||||
material_type = None
|
||||
if model.config and isinstance(model.config, dict):
|
||||
material_type = model.config.get("material_type")
|
||||
return TemplateSegment(
|
||||
id=model.id,
|
||||
template_id=model.template_id,
|
||||
segment_order=model.order,
|
||||
duration_min=model.min_duration,
|
||||
duration_max=model.max_duration,
|
||||
material_type=material_type,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _category_model_to_entity(model: TemplateCategoryModel) -> TemplateCategory:
|
||||
return TemplateCategory(
|
||||
|
||||
@@ -256,6 +256,42 @@ def _wrap_title_text(
|
||||
return "\\N".join(wrapped_segments)
|
||||
|
||||
|
||||
def _parse_title_position(
|
||||
title_config: dict[str, Any],
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
) -> tuple[int, int] | None:
|
||||
"""解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。
|
||||
|
||||
要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height]
|
||||
闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。
|
||||
|
||||
Args:
|
||||
title_config: 标题配置 dict
|
||||
video_width: PlayResX(视频宽度像素)
|
||||
video_height: PlayResY(视频高度像素)
|
||||
|
||||
Returns:
|
||||
(x, y) 整数坐标,或 None 表示不使用自由位置
|
||||
"""
|
||||
if "pos_x" not in title_config or "pos_y" not in title_config:
|
||||
return None
|
||||
raw_x = title_config["pos_x"]
|
||||
raw_y = title_config["pos_y"]
|
||||
# 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True)
|
||||
# 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退
|
||||
if isinstance(raw_x, bool) or isinstance(raw_y, bool):
|
||||
return None
|
||||
if not isinstance(raw_x, int) or not isinstance(raw_y, int):
|
||||
return None
|
||||
x, y = raw_x, raw_y
|
||||
if video_width <= 0 or video_height <= 0:
|
||||
return None
|
||||
if not (0 <= x <= video_width and 0 <= y <= video_height):
|
||||
return None
|
||||
return (x, y)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
*,
|
||||
video_width: int,
|
||||
@@ -343,7 +379,16 @@ def build_ass_content(
|
||||
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
|
||||
)
|
||||
|
||||
title_alignment = position_to_ass_alignment(title_config.get("position", "top"))
|
||||
# ── 自由位置拖拽(工单 #1405 方案 B)────────────────────────────
|
||||
# pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。
|
||||
# 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心),
|
||||
# Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退
|
||||
# position → alignment 三档逻辑,现有输出保持一字节不变。
|
||||
title_pos = _parse_title_position(title_config, video_width, video_height)
|
||||
|
||||
title_alignment = 5 if title_pos is not None else position_to_ass_alignment(
|
||||
title_config.get("position", "top")
|
||||
)
|
||||
|
||||
styles.append(
|
||||
build_ass_style(
|
||||
@@ -370,6 +415,10 @@ def build_ass_content(
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
# 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5)
|
||||
if title_pos is not None:
|
||||
safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}"
|
||||
|
||||
events.append(
|
||||
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ class GeneratedVideo:
|
||||
video_fingerprint: dict[str, Any] | None = None
|
||||
is_duplicate: bool = False
|
||||
duplicate_of: str | None = None
|
||||
duplicate_rate: float | None = None
|
||||
generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user