Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c8b5f1a2 | |||
| d83d0a5d69 | |||
| cdb68636bf | |||
| ac1ae892ac | |||
| aeef4011de | |||
| 5abc6552f4 | |||
| e1db127123 | |||
| 6b1b2e710e | |||
| 143d11fe07 |
@@ -1,127 +0,0 @@
|
||||
name: Auto Approve CI PRs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-l1
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Auto approve when CI passes
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
REVIEW_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
echo "PR #${PR_NUMBER} - 检查CI状态并自动审批"
|
||||
|
||||
# 检查是否纯前端改动
|
||||
API_URL="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=300"
|
||||
FILES=$(curl -s -H "Authorization: token ${GITHUB_TOKEN}" "$API_URL" | python3 -c "import sys,json; [print(f['filename']) for f in json.load(sys.stdin)]")
|
||||
FRONTEND_COUNT=$(echo "$FILES" | grep -c '^apps/web/' || true)
|
||||
BACKEND_COUNT=$(echo "$FILES" | grep -cv '^apps/web/' || true)
|
||||
TOTAL=$(echo "$FILES" | grep -cv '^$' || true)
|
||||
echo "变更文件: ${TOTAL} 个 (前端: ${FRONTEND_COUNT}, 后端/公共: ${BACKEND_COUNT})"
|
||||
|
||||
if [ "$BACKEND_COUNT" = "0" ] && [ "$FRONTEND_COUNT" -gt "0" ]; then
|
||||
SKIP_BACKEND=true
|
||||
echo "✅ 纯前端改动,只检查Frontend Lint"
|
||||
else
|
||||
SKIP_BACKEND=false
|
||||
echo "🔧 包含后端/公共变更,检查全部CI"
|
||||
fi
|
||||
|
||||
# 定义需要检查的context
|
||||
if [ "$SKIP_BACKEND" = "true" ]; then
|
||||
CONTEXTS=("CI/CD Pipeline / Frontend Lint (pull_request)")
|
||||
else
|
||||
CONTEXTS=(
|
||||
"CI/CD Pipeline / Validate Code Quality And Tests (pull_request)"
|
||||
"CI/CD Pipeline / Unit Tests (pull_request)"
|
||||
"CI/CD Pipeline / Frontend Lint (pull_request)"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "需要通过的CI检查: ${#CONTEXTS[@]} 项"
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
echo " - $ctx"
|
||||
done
|
||||
echo
|
||||
|
||||
# 轮询等待,最多20分钟(120次x10秒)
|
||||
for attempt in $(seq 1 120); do
|
||||
ALL_SUCCESS=true
|
||||
ANY_FAILED=false
|
||||
|
||||
echo "--- 第${attempt}次检查 ($(date '+%H:%M:%S')) ---"
|
||||
|
||||
# 调用辅助脚本检查每个context状态
|
||||
for ctx in "${CONTEXTS[@]}"; do
|
||||
STATE=$(python3 scripts/check_ci_status.py "$GITHUB_TOKEN" "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$ctx")
|
||||
echo " $ctx: $STATE"
|
||||
|
||||
if [ "$STATE" != "success" ]; then
|
||||
ALL_SUCCESS=false
|
||||
fi
|
||||
if [ "$STATE" = "failure" ] || [ "$STATE" = "error" ]; then
|
||||
ANY_FAILED=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_SUCCESS" = "true" ]; then
|
||||
echo
|
||||
echo "✅ 所有CI检查通过,自动审批 PR #${PR_NUMBER}"
|
||||
|
||||
# 检查是否已有审批
|
||||
EXISTING=$(curl -s -H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \
|
||||
| python3 -c "import sys,json; reviews=json.load(sys.stdin); print('yes' if any(r.get('state')=='APPROVED' and r.get('user',{}).get('login')=='xiaoxia' for r in reviews) else 'no')")
|
||||
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "ℹ️ PR #${PR_NUMBER} 已有审批,跳过"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 提交审批
|
||||
HTTP_CODE=$(curl -s -o /tmp/approve_resp.json -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${REVIEW_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "APPROVE", "body": "CI全绿,自动审批通过。"}' \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/reviews")
|
||||
|
||||
echo "审批API HTTP状态: $HTTP_CODE"
|
||||
cat /tmp/approve_resp.json 2>/dev/null || true
|
||||
echo
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
|
||||
echo "✅ 自动审批成功"
|
||||
exit 0
|
||||
else
|
||||
echo "❌ 自动审批失败"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ANY_FAILED" = "true" ]; then
|
||||
echo
|
||||
echo "❌ CI检查有失败项,不自动审批"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo
|
||||
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
|
||||
exit 0
|
||||
@@ -24,7 +24,7 @@ concurrency:
|
||||
jobs:
|
||||
build-staging:
|
||||
name: Build Staging ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
|
||||
strategy:
|
||||
@@ -109,13 +109,9 @@ jobs:
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: 'set -eu
|
||||
|
||||
printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin
|
||||
|
||||
docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"
|
||||
|
||||
echo "Docker login successful"
|
||||
|
||||
@@ -123,9 +119,13 @@ jobs:
|
||||
- name: Setup cache strategy
|
||||
shell: sh
|
||||
run: "set -eu\n# develop/main 分支写回缓存,其他分支只读\nif [ \"${GITHUB_REF_NAME}\" = \"develop\" ] || [ \"${GITHUB_REF_NAME}\" = \"main\" ]; then\n echo \"CACHE_MODE=read-write\" >> $GITHUB_ENV\n echo \"Cache mode: read-write (will push cache)\"\nelse\n echo \"CACHE_MODE=read-only\" >> $GITHUB_ENV\n echo \"Cache mode: read-only\"\nfi\n"
|
||||
- name: Build frontend assets (npm build)
|
||||
if: matrix.service == 'web'
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete: $(ls apps/web/dist/ | head -5)\"\n"
|
||||
- name: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push ${{ matrix.service_display }} image (buildx cache)
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nEXTRA_BUILD_ARGS=\"APP_VERSION=\\\"${GITHUB_SHA}\\\"\"\nif [ \"${{ matrix.service }}\" = \"web\" ]; then\n EXTRA_BUILD_ARGS=\"$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf\"\nfi\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" $EXTRA_BUILD_ARGS\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\""
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
'
|
||||
deploy-staging:
|
||||
name: Deploy Staging (Watchtower auto-deploy)
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: deploy-staging-${{ gitea.ref }}
|
||||
@@ -224,13 +224,9 @@ jobs:
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: 'set -eu
|
||||
|
||||
printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin
|
||||
|
||||
docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"
|
||||
|
||||
echo "Docker login successful"
|
||||
|
||||
@@ -304,7 +300,7 @@ jobs:
|
||||
'
|
||||
staging-e2e:
|
||||
name: Staging E2E Tests
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 15
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
@@ -384,7 +380,7 @@ jobs:
|
||||
'
|
||||
staging-api-tests:
|
||||
name: Staging API Integration Tests
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
if: github.ref_name == 'develop' || github.ref_name == 'main'
|
||||
needs: deploy-staging
|
||||
@@ -464,7 +460,7 @@ jobs:
|
||||
'
|
||||
build-production:
|
||||
name: Build Production ${{ matrix.service_display }} Image
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: ${{ matrix.timeout }}
|
||||
needs:
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -550,20 +546,20 @@ jobs:
|
||||
env:
|
||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: 'set -eu
|
||||
|
||||
printf ''%s'' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin
|
||||
|
||||
docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"
|
||||
|
||||
echo "Docker login successful"
|
||||
|
||||
'
|
||||
- name: Build frontend assets (npm build)
|
||||
if: matrix.service == 'web'
|
||||
shell: sh
|
||||
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\nfi\n\ndocker run --rm -v \"$PWD:/workspace\" -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web docker.m.daocloud.io/library/node:20 sh -lc \"npm ci && npx tsc --incremental --tsBuildInfoFile node_modules/.tsbuildinfo && npx vite build\"\n\ntest -f apps/web/dist/index.html\necho \"Frontend build complete\"\n"
|
||||
- name: Setup buildx builder (docker-container driver)
|
||||
shell: sh
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}-${{ matrix.cache_name }}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB} --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder-${GITHUB_RUN_ID}-${GITHUB_JOB}\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
|
||||
- name: Build and push ${{ matrix.service_display }} image (buildx cache)
|
||||
shell: sh
|
||||
run: "set -eu\nREGISTRY=\"xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji\"\nIMAGE_TAG=\"${REGISTRY}/${{ matrix.image_name }}:${GITHUB_SHA}\"\nCACHE_REF=\"${REGISTRY}/${{ matrix.cache_name }}:${GITHUB_REF_NAME}\"\n\nbash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} \"${IMAGE_TAG}\" \"${CACHE_REF}\" APP_VERSION=\"${GITHUB_SHA}\"\n\necho\necho \"${{ matrix.service_display }} image pushed: ${IMAGE_TAG}\""
|
||||
@@ -588,7 +584,7 @@ jobs:
|
||||
'
|
||||
deploy-production:
|
||||
name: Deploy Production
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: deploy-production-${{ gitea.ref }}
|
||||
@@ -734,7 +730,7 @@ jobs:
|
||||
'
|
||||
production-e2e:
|
||||
name: Production Browser E2E
|
||||
runs-on: runtime-builder
|
||||
runs-on: ci-l3
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
needs: deploy-production
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
name: CI Trigger Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/5 * * * *' # 每5分钟检查一次
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_threshold:
|
||||
description: 'CI未触发告警阈值(分钟)'
|
||||
required: false
|
||||
default: '5'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor CI Trigger Reliability
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Check CI trigger status for all open PRs
|
||||
env:
|
||||
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
GITEA_URL: https://git.xiaoxiajianji.com
|
||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
STALE_THRESHOLD_MIN: ${{ inputs.stale_threshold || 5 }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/ci_trigger_monitor.py
|
||||
# 监控脚本永远不fail,避免告警风暴
|
||||
exit 0
|
||||
@@ -1 +0,0 @@
|
||||
# CI Trigger Rebase Verify Test - Updated
|
||||
Regular → Executable
+1
-189
@@ -67,9 +67,7 @@ class EditPlanUpdateRequest(BaseModel):
|
||||
class CopyPlanRequest(BaseModel):
|
||||
"""复制剪辑计划请求体"""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」"
|
||||
)
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」")
|
||||
project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目")
|
||||
|
||||
|
||||
@@ -260,18 +258,6 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
|
||||
)
|
||||
|
||||
|
||||
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||
# 注意:含静态路径的子路由需放在 CRUD 路由之前,避免被 /{plan_id} 抢先匹配
|
||||
|
||||
from .edit_plans_adjustments import router as adjustments_router
|
||||
from .edit_plans_export import router as export_router
|
||||
from .edit_plans_filter import router as filter_router
|
||||
|
||||
router.include_router(export_router)
|
||||
router.include_router(adjustments_router)
|
||||
router.include_router(filter_router)
|
||||
|
||||
|
||||
# ── CRUD Routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -520,178 +506,6 @@ def copy_plan(
|
||||
current_user.user.id,
|
||||
)
|
||||
return _to_response(new_plan)
|
||||
|
||||
|
||||
# ── BGM 背景音乐 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BGMConfigUpdateRequest(BaseModel):
|
||||
"""更新BGM配置请求体"""
|
||||
|
||||
enabled: Optional[bool] = Field(default=None, description="是否启用 BGM")
|
||||
source: Optional[str] = Field(default=None, description="BGM 来源: library/upload/ai_recommend")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="BGM 素材 ID")
|
||||
preset_id: Optional[str] = Field(default=None, max_length=64, description="预设 BGM ID")
|
||||
audio_url: Optional[str] = Field(default=None, max_length=500, description="BGM 音频 URL")
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
|
||||
fade_in: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡入时长(秒)")
|
||||
fade_out: Optional[float] = Field(default=None, ge=0.0, le=30.0, description="淡出时长(秒)")
|
||||
loop_enabled: Optional[bool] = Field(default=None, description="是否循环播放")
|
||||
sidechain_enabled: Optional[bool] = Field(default=None, description="是否启用人声闪避")
|
||||
sidechain_ratio: Optional[float] = Field(default=None, ge=0.0, le=1.0, description="闪避音量降低比例")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{plan_id}/bgm",
|
||||
response_model=dict[str, Any],
|
||||
summary="获取剪辑计划的 BGM 配置",
|
||||
)
|
||||
def get_plan_bgm(
|
||||
plan_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""获取指定剪辑计划的 BGM 配置。"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
if plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
config = plan.config or {}
|
||||
bgm_config = config.get("bgm", {})
|
||||
|
||||
return {
|
||||
"plan_id": plan.id,
|
||||
"bgm": bgm_config,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{plan_id}/bgm",
|
||||
response_model=dict[str, Any],
|
||||
summary="更新剪辑计划的 BGM 配置",
|
||||
)
|
||||
def update_plan_bgm(
|
||||
plan_id: str,
|
||||
body: BGMConfigUpdateRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repo=Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""更新剪辑计划的 BGM 配置。
|
||||
|
||||
支持部分更新,只传需要修改的字段即可。
|
||||
启用 BGM 后需要指定来源(asset_id / preset_id / audio_url 三选一)。
|
||||
"""
|
||||
service = EditPlanService(db)
|
||||
|
||||
plan = service.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
if plan.project_id:
|
||||
check_project_access(project_repo, current_user, plan.project_id)
|
||||
|
||||
# 读取当前 BGM 配置,合并更新
|
||||
config = dict(plan.config) if plan.config else {}
|
||||
current_bgm = dict(config.get("bgm", {}))
|
||||
|
||||
update_data = body.model_dump(exclude_none=True)
|
||||
current_bgm.update(update_data)
|
||||
|
||||
# 校验:启用 BGM 时至少有一个有效来源
|
||||
if current_bgm.get("enabled"):
|
||||
has_source = any(current_bgm.get(key) for key in ("asset_id", "preset_id", "audio_url") if current_bgm.get(key))
|
||||
if not has_source:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="启用 BGM 时需要指定素材来源(asset_id / preset_id / audio_url)",
|
||||
)
|
||||
|
||||
# 保存到 plan.config.bgm
|
||||
config["bgm"] = current_bgm
|
||||
updated_plan = service.update_plan_config(plan_id, config)
|
||||
|
||||
logger.info(
|
||||
"更新BGM配置: plan_id=%s enabled=%s by user=%s",
|
||||
plan_id,
|
||||
current_bgm.get("enabled", False),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"plan_id": updated_plan.id,
|
||||
"bgm": current_bgm,
|
||||
}
|
||||
|
||||
|
||||
# ── BGM 预设库 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/bgm/presets",
|
||||
response_model=dict[str, Any],
|
||||
summary="获取预设 BGM 列表",
|
||||
)
|
||||
def list_bgm_presets(
|
||||
style: Optional[str] = Query(default=None, description="按风格筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
skip: int = Query(default=0, ge=0, description="分页偏移"),
|
||||
limit: int = Query(default=50, ge=1, le=200, description="每页数量"),
|
||||
) -> dict[str, Any]:
|
||||
"""获取预设 BGM 列表,支持按风格筛选和关键词搜索。
|
||||
|
||||
风格可选: upbeat(轻快)、relax(治愈)、tech(科技)、commerce(电商)、
|
||||
emotional(情感)、cinematic(电影)
|
||||
"""
|
||||
from packages.domain.preset_bgm import (
|
||||
BGM_STYLES,
|
||||
PRESET_BGM_LIBRARY,
|
||||
list_preset_bgm_by_style,
|
||||
search_preset_bgm,
|
||||
)
|
||||
|
||||
bgm_list = PRESET_BGM_LIBRARY
|
||||
|
||||
if keyword:
|
||||
bgm_list = search_preset_bgm(keyword)
|
||||
elif style:
|
||||
bgm_list = list_preset_bgm_by_style(style)
|
||||
|
||||
total = len(bgm_list)
|
||||
paged = bgm_list[skip : skip + limit]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"skip": skip,
|
||||
"limit": limit,
|
||||
"styles": BGM_STYLES,
|
||||
"items": [
|
||||
{
|
||||
"id": bgm.id,
|
||||
"name": bgm.name,
|
||||
"style": bgm.style,
|
||||
"style_label": BGM_STYLES.get(bgm.style, bgm.style),
|
||||
"duration": bgm.duration,
|
||||
"artist": bgm.artist,
|
||||
"description": bgm.description,
|
||||
"tags": bgm.tags,
|
||||
"audio_url": bgm.audio_url,
|
||||
}
|
||||
for bgm in paged
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 保存为模板 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -777,7 +591,6 @@ def save_plan_as_template(
|
||||
from .edit_plans_ai import router as ai_router
|
||||
from .edit_plans_clips import router as clips_router
|
||||
from .edit_plans_clips_batch import router as clips_batch_router
|
||||
from .edit_plans_cover import router as cover_router
|
||||
from .edit_plans_generation import router as generation_router
|
||||
from .edit_plans_timeline import router as timeline_router
|
||||
|
||||
@@ -786,4 +599,3 @@ router.include_router(ai_router)
|
||||
router.include_router(timeline_router)
|
||||
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
|
||||
router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
|
||||
router.include_router(cover_router)
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
"""片段调整 API.
|
||||
|
||||
- PUT /clips/{clip_id}/speed 调速
|
||||
- PUT /clips/{clip_id}/volume 音量调节
|
||||
- PUT /clips/{clip_id}/trim 裁剪(trim in/out)
|
||||
- PUT /clips/{clip_id}/adjustments 统一调整(speed+volume+trim)
|
||||
- POST /{plan_id}/clips/batch-speed 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SpeedAdjustRequest(BaseModel):
|
||||
"""调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度 0.25~4.0")
|
||||
|
||||
|
||||
class VolumeAdjustRequest(BaseModel):
|
||||
"""音量调节请求"""
|
||||
|
||||
volume: float = Field(..., ge=0.0, le=2.0, description="音量倍率 0~2.0(1.0=原音量)")
|
||||
|
||||
|
||||
class TrimAdjustRequest(BaseModel):
|
||||
"""裁剪请求"""
|
||||
|
||||
trim_start: float = Field(0.0, ge=0.0, description="开头裁剪秒数")
|
||||
trim_end: float = Field(0.0, ge=0.0, description="结尾裁剪秒数")
|
||||
|
||||
|
||||
class ClipAdjustmentsRequest(BaseModel):
|
||||
"""统一调整请求"""
|
||||
|
||||
speed: Optional[float] = Field(default=None, ge=0.25, le=4.0)
|
||||
volume: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
trim_start: Optional[float] = Field(default=None, ge=0.0)
|
||||
trim_end: Optional[float] = Field(default=None, ge=0.0)
|
||||
|
||||
|
||||
class BatchSpeedRequest(BaseModel):
|
||||
"""批量调速请求"""
|
||||
|
||||
speed: float = Field(..., ge=0.25, le=4.0, description="播放速度")
|
||||
|
||||
|
||||
class ClipAdjustResponse(BaseModel):
|
||||
"""片段调整响应"""
|
||||
|
||||
clip_id: str
|
||||
speed: float
|
||||
volume: float
|
||||
trim_start: float
|
||||
trim_end: float
|
||||
duration: float
|
||||
|
||||
|
||||
class BatchSpeedResponse(BaseModel):
|
||||
"""批量调速响应"""
|
||||
|
||||
updated_count: int
|
||||
plan_id: str
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_clip_config(clip) -> dict:
|
||||
config = getattr(clip, "config", {}) or {}
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
return config
|
||||
|
||||
|
||||
def _get_volume(clip) -> float:
|
||||
config = _get_clip_config(clip)
|
||||
return float(config.get("volume", 1.0))
|
||||
|
||||
|
||||
def _get_trim(clip) -> tuple[float, float]:
|
||||
config = _get_clip_config(clip)
|
||||
trim_start = float(config.get("trim_start", 0.0))
|
||||
trim_end = float(config.get("trim_end", 0.0))
|
||||
return trim_start, trim_end
|
||||
|
||||
|
||||
def _build_response(clip) -> ClipAdjustResponse:
|
||||
trim_start, trim_end = _get_trim(clip)
|
||||
return ClipAdjustResponse(
|
||||
clip_id=clip.id,
|
||||
speed=clip.playback_speed,
|
||||
volume=_get_volume(clip),
|
||||
trim_start=trim_start,
|
||||
trim_end=trim_end,
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
|
||||
def _validate_trim(trim_start: float, trim_end: float, total_duration: float) -> None:
|
||||
"""验证裁剪时长不超过总时长"""
|
||||
if trim_start + trim_end >= total_duration:
|
||||
raise ValueError(f"裁剪总时长({trim_start + trim_end:.2f}s)不能大于等于片段总时长({total_duration:.2f}s)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository):
|
||||
svc = EditPlanService(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
plan = svc.get_plan(clip.plan_id)
|
||||
if plan and plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
return svc, plan, clip
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/speed", response_model=ClipAdjustResponse)
|
||||
def adjust_speed(
|
||||
clip_id: str,
|
||||
body: SpeedAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段播放速度"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
updated = svc.update_clip(clip_id, playback_speed=body.speed)
|
||||
|
||||
logger.info(
|
||||
"调整片段速度: clip_id=%s speed=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/volume", response_model=ClipAdjustResponse)
|
||||
def adjust_volume(
|
||||
clip_id: str,
|
||||
body: VolumeAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""调整片段音量"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 更新 config.volume
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["volume"] = body.volume
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"调整片段音量: clip_id=%s volume=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/trim", response_model=ClipAdjustResponse)
|
||||
def adjust_trim(
|
||||
clip_id: str,
|
||||
body: TrimAdjustRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""裁剪片段(trim in/out)"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
# 验证裁剪时长
|
||||
try:
|
||||
_validate_trim(body.trim_start, body.trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
# 更新 config
|
||||
config = dict(_get_clip_config(clip))
|
||||
config["trim_start"] = body.trim_start
|
||||
config["trim_end"] = body.trim_end
|
||||
updated = svc.update_clip(clip_id, config=config)
|
||||
|
||||
logger.info(
|
||||
"裁剪片段: clip_id=%s trim_start=%.2f trim_end=%.2f by user=%s",
|
||||
clip_id,
|
||||
body.trim_start,
|
||||
body.trim_end,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.put("/clips/{clip_id}/adjustments", response_model=ClipAdjustResponse)
|
||||
def adjust_all(
|
||||
clip_id: str,
|
||||
body: ClipAdjustmentsRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ClipAdjustResponse:
|
||||
"""统一调整片段的 speed / volume / trim"""
|
||||
svc, _, clip = _get_svc_and_plan_and_clip(db, clip_id, current_user, project_repository)
|
||||
|
||||
update_kwargs = {}
|
||||
config_updates = {}
|
||||
|
||||
if body.speed is not None:
|
||||
update_kwargs["playback_speed"] = body.speed
|
||||
|
||||
if body.volume is not None:
|
||||
config_updates["volume"] = body.volume
|
||||
|
||||
if body.trim_start is not None:
|
||||
config_updates["trim_start"] = body.trim_start
|
||||
|
||||
if body.trim_end is not None:
|
||||
config_updates["trim_end"] = body.trim_end
|
||||
|
||||
# 验证 trim
|
||||
current_trim_start, current_trim_end = _get_trim(clip)
|
||||
new_trim_start = body.trim_start if body.trim_start is not None else current_trim_start
|
||||
new_trim_end = body.trim_end if body.trim_end is not None else current_trim_end
|
||||
|
||||
if body.trim_start is not None or body.trim_end is not None:
|
||||
try:
|
||||
_validate_trim(new_trim_start, new_trim_end, clip.duration)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
if config_updates:
|
||||
config = dict(_get_clip_config(clip))
|
||||
config.update(config_updates)
|
||||
update_kwargs["config"] = config
|
||||
|
||||
if not update_kwargs:
|
||||
return _build_response(clip)
|
||||
|
||||
updated = svc.update_clip(clip_id, **update_kwargs)
|
||||
|
||||
logger.info(
|
||||
"统一调整片段: clip_id=%s speed=%s volume=%s by user=%s",
|
||||
clip_id,
|
||||
body.speed,
|
||||
body.volume,
|
||||
current_user.user.id,
|
||||
)
|
||||
return _build_response(updated)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/clips/batch-speed", response_model=BatchSpeedResponse)
|
||||
def batch_adjust_speed(
|
||||
plan_id: str,
|
||||
body: BatchSpeedRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchSpeedResponse:
|
||||
"""批量调整计划内所有片段的播放速度"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
clips = svc.list_clips(plan_id, limit=500, skip=0)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
svc.update_clip(clip.id, playback_speed=body.speed)
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"批量调速: plan_id=%s count=%d speed=%.2f by user=%s",
|
||||
plan_id,
|
||||
count,
|
||||
body.speed,
|
||||
current_user.user.id,
|
||||
)
|
||||
return BatchSpeedResponse(updated_count=count, plan_id=plan_id)
|
||||
@@ -276,140 +276,3 @@ def delete_clip(
|
||||
|
||||
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return None
|
||||
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SplitClipRequest(BaseModel):
|
||||
"""分割片段请求体"""
|
||||
|
||||
split_time: float = Field(..., gt=0, description="分割点(秒,相对于片段起始)")
|
||||
|
||||
|
||||
class MergeClipsRequest(BaseModel):
|
||||
"""合并片段请求体"""
|
||||
|
||||
clip_ids: list[str] = Field(..., min_length=2, description="要合并的片段 ID 列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{clip_id}/split",
|
||||
response_model=dict[str, Any],
|
||||
summary="分割片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def split_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: SplitClipRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将一个片段从指定时间点分割为两个片段。
|
||||
|
||||
分割后原片段变为左半部分,新增右半部分片段,后续片段顺序自动后移。
|
||||
若片段有关联素材,会自动设置 trim_start/trim_end 标记裁剪范围。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
result = svc.split_clip(clip_id, body.split_time)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
logger.info("分割片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
|
||||
return {
|
||||
"left_clip": {
|
||||
"id": left.id,
|
||||
"plan_id": left.plan_id,
|
||||
"clip_type": left.clip_type,
|
||||
"order": left.order,
|
||||
"duration": left.duration,
|
||||
"start_time": left.start_time,
|
||||
},
|
||||
"right_clip": {
|
||||
"id": right.id,
|
||||
"plan_id": right.plan_id,
|
||||
"clip_type": right.clip_type,
|
||||
"order": right.order,
|
||||
"duration": right.duration,
|
||||
"start_time": right.start_time,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/merge",
|
||||
response_model=dict[str, Any],
|
||||
summary="合并多个连续片段",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def merge_clips(
|
||||
plan_id: str,
|
||||
body: MergeClipsRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> dict[str, Any]:
|
||||
"""将多个连续的同类型片段合并为一个片段。
|
||||
|
||||
合并要求:
|
||||
- 至少 2 个片段
|
||||
- 属于同一剪辑计划
|
||||
- order 连续
|
||||
- 类型相同
|
||||
|
||||
合并后保留第一个片段,其余删除,后续片段顺序自动前移。
|
||||
"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
|
||||
# 校验所有片段都属于该 plan
|
||||
for cid in body.clip_ids:
|
||||
clip = svc.get_clip(cid)
|
||||
if clip is None or clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {cid}",
|
||||
)
|
||||
|
||||
try:
|
||||
merged = svc.merge_clips(body.clip_ids)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s clip_count=%d by user=%s",
|
||||
plan_id,
|
||||
len(body.clip_ids),
|
||||
current_user.user.id,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": merged.id,
|
||||
"plan_id": merged.plan_id,
|
||||
"clip_type": merged.clip_type,
|
||||
"order": merged.order,
|
||||
"duration": merged.duration,
|
||||
"text_content": merged.text_content,
|
||||
}
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
"""封面管理 API.
|
||||
|
||||
- GET /{plan_id}/cover 获取封面配置
|
||||
- PUT /{plan_id}/cover 更新封面配置
|
||||
- POST /{plan_id}/cover/extract 从指定片段抽帧生成封面
|
||||
- POST /{plan_id}/cover/smart 智能选帧生成封面
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
)
|
||||
from app.services import EditPlanService
|
||||
from app.services.cover_service import CoverService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverConfigResponse(BaseModel):
|
||||
"""封面配置响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||
image_url: str = Field(default="", description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverUpdateRequest(BaseModel):
|
||||
"""更新封面配置请求"""
|
||||
|
||||
type: Optional[str] = Field(default=None, description="封面类型")
|
||||
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverExtractRequest(BaseModel):
|
||||
"""从片段抽帧生成封面请求"""
|
||||
|
||||
clip_id: str = Field(..., description="片段 ID")
|
||||
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
class CoverSmartRequest(BaseModel):
|
||||
"""智能选帧请求"""
|
||||
|
||||
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||
|
||||
|
||||
class CoverGenerateResponse(BaseModel):
|
||||
"""封面生成响应"""
|
||||
|
||||
type: str = Field(..., description="封面类型")
|
||||
image_url: str = Field(..., description="封面图片 URL")
|
||||
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{plan_id}/cover", response_model=CoverConfigResponse)
|
||||
def get_cover(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""获取封面配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
cover = CoverService.get_cover_config(plan.config or {})
|
||||
return CoverConfigResponse(**cover)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/cover", response_model=CoverConfigResponse)
|
||||
def update_cover(
|
||||
plan_id: str,
|
||||
body: CoverUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> CoverConfigResponse:
|
||||
"""更新封面配置
|
||||
|
||||
用于:设置上传的封面图片 URL、切换封面类型、调整时间点等。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current_cover = CoverService.get_cover_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_cover = {**current_cover, **updates}
|
||||
|
||||
# 验证 type 值
|
||||
valid_types = {"ai_frame", "manual", "upload", "ai_regenerate"}
|
||||
if "type" in updates and updates["type"] not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的封面类型: {updates['type']},有效值: {valid_types}",
|
||||
)
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = new_cover
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
result = CoverService.get_cover_config(updated_plan.config or {})
|
||||
logger.info("更新封面配置: plan_id=%s type=%s by user=%s", plan_id, result["type"], current_user.user.id)
|
||||
return CoverConfigResponse(**result)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/extract", response_model=CoverGenerateResponse)
|
||||
def extract_cover(
|
||||
plan_id: str,
|
||||
body: CoverExtractRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""从指定片段的指定时间点抽帧生成封面"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 获取片段对应的素材
|
||||
clip = svc.get_clip(body.clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {body.clip_id}",
|
||||
)
|
||||
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材,无法抽帧",
|
||||
)
|
||||
|
||||
# 抽帧生成封面
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=clip.asset_id,
|
||||
frame_time=body.frame_time,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("封面抽帧失败: plan_id=%s clip_id=%s error=%s", plan_id, body.clip_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"封面抽帧失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s clip_id=%s time=%.2fs by user=%s",
|
||||
plan_id,
|
||||
body.clip_id,
|
||||
body.frame_time,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/cover/smart", response_model=CoverGenerateResponse)
|
||||
def smart_cover(
|
||||
plan_id: str,
|
||||
body: CoverSmartRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
storage_service: Any = Depends(get_storage_service),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
) -> CoverGenerateResponse:
|
||||
"""智能选帧生成封面
|
||||
|
||||
从指定片段(或第一个视频片段)中智能选取一帧作为封面。
|
||||
当前实现:取片段第3秒帧(后续可优化为多帧选最清晰)。
|
||||
"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 确定使用哪个片段
|
||||
clip_id = body.clip_id
|
||||
asset_id = ""
|
||||
|
||||
if clip_id:
|
||||
clip = svc.get_clip(clip_id)
|
||||
if not clip:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"片段不存在: {clip_id}",
|
||||
)
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段不属于该剪辑计划",
|
||||
)
|
||||
if not clip.asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="片段没有关联素材",
|
||||
)
|
||||
asset_id = clip.asset_id
|
||||
else:
|
||||
# 找第一个有素材的视频片段
|
||||
clips = svc.list_clips(plan_id, limit=50, skip=0)
|
||||
for c in clips:
|
||||
if c.asset_id and c.clip_type == "video":
|
||||
asset_id = c.asset_id
|
||||
clip_id = c.id
|
||||
break
|
||||
|
||||
if not asset_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="没有找到可用的视频片段",
|
||||
)
|
||||
|
||||
# 智能选帧
|
||||
cover_svc = CoverService(storage_service, asset_repository)
|
||||
try:
|
||||
cover_data = cover_svc.generate_smart_cover(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
except RuntimeError as e:
|
||||
logger.error("智能封面生成失败: plan_id=%s error=%s", plan_id, e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"智能封面生成失败: {e}",
|
||||
) from e
|
||||
|
||||
# 更新到 plan.config.cover
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["cover"] = cover_data
|
||||
normalized = normalize_plan_config(current_config)
|
||||
svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: plan_id=%s clip_id=%s by user=%s",
|
||||
plan_id,
|
||||
clip_id,
|
||||
current_user.user.id,
|
||||
)
|
||||
return CoverGenerateResponse(**cover_data)
|
||||
@@ -1,274 +0,0 @@
|
||||
"""导出设置 API.
|
||||
|
||||
- GET /{plan_id}/export 获取导出配置
|
||||
- PUT /{plan_id}/export 更新导出配置
|
||||
- GET /export-presets 导出预设列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── 导出预设 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
EXPORT_PRESETS = [
|
||||
{
|
||||
"id": "export_1080p_30",
|
||||
"name": "1080P 高清",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 8000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "balanced",
|
||||
"description": "竖屏高清,适合短视频平台",
|
||||
"size_hint": "约 10MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_60",
|
||||
"name": "1080P 高帧率",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 60,
|
||||
"video_bitrate": 12000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "high",
|
||||
"description": "60帧高帧率,流畅运动画面",
|
||||
"size_hint": "约 18MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_720p_30",
|
||||
"name": "720P 流畅",
|
||||
"resolution": "720x1280",
|
||||
"fps": 30,
|
||||
"video_bitrate": 4000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "fast",
|
||||
"description": "快速导出,文件较小",
|
||||
"size_hint": "约 5MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_4k_30",
|
||||
"name": "4K 超清",
|
||||
"resolution": "2160x3840",
|
||||
"fps": 30,
|
||||
"video_bitrate": 20000,
|
||||
"audio_bitrate": 192,
|
||||
"format": "mp4",
|
||||
"quality_preset": "best",
|
||||
"description": "4K超清画质,专业品质",
|
||||
"size_hint": "约 30MB/分钟",
|
||||
},
|
||||
{
|
||||
"id": "export_1080p_30_mov",
|
||||
"name": "1080P ProRes",
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 15000,
|
||||
"audio_bitrate": 256,
|
||||
"format": "mov",
|
||||
"quality_preset": "high",
|
||||
"description": "MOV格式,适合后期剪辑",
|
||||
"size_hint": "约 25MB/分钟",
|
||||
},
|
||||
]
|
||||
|
||||
VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"}
|
||||
VALID_FORMATS = {"mp4", "mov"}
|
||||
|
||||
RESOLUTION_PATTERN = re.compile(r"^\d+x\d+$")
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ExportConfigResponse(BaseModel):
|
||||
"""导出配置响应"""
|
||||
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
watermark_enabled: bool
|
||||
watermark_text: str
|
||||
|
||||
|
||||
class ExportUpdateRequest(BaseModel):
|
||||
"""更新导出配置请求"""
|
||||
|
||||
resolution: Optional[str] = None
|
||||
fps: Optional[int] = Field(default=None, ge=15, le=60)
|
||||
video_bitrate: Optional[int] = Field(default=None, ge=1000, le=20000)
|
||||
audio_bitrate: Optional[int] = Field(default=None, ge=64, le=320)
|
||||
format: Optional[str] = None
|
||||
quality_preset: Optional[str] = None
|
||||
watermark_enabled: Optional[bool] = None
|
||||
watermark_text: Optional[str] = None
|
||||
|
||||
@validator("resolution")
|
||||
def validate_resolution(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if not RESOLUTION_PATTERN.match(v):
|
||||
raise ValueError("分辨率格式错误,应为 宽x高,如 1080x1920")
|
||||
w, h = v.split("x")
|
||||
if int(w) < 100 or int(h) < 100:
|
||||
raise ValueError("分辨率数值过小")
|
||||
if int(w) > 4096 or int(h) > 4096:
|
||||
raise ValueError("分辨率数值过大,最大 4096x4096")
|
||||
return v
|
||||
|
||||
@validator("format")
|
||||
def validate_format(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_FORMATS:
|
||||
raise ValueError(f"无效格式: {v},支持: {VALID_FORMATS}")
|
||||
return v
|
||||
|
||||
@validator("quality_preset")
|
||||
def validate_quality_preset(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
if v not in VALID_QUALITY_PRESETS:
|
||||
raise ValueError(f"无效质量预设: {v},支持: {VALID_QUALITY_PRESETS}")
|
||||
return v
|
||||
|
||||
|
||||
class ExportPresetItem(BaseModel):
|
||||
"""导出预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
resolution: str
|
||||
fps: int
|
||||
video_bitrate: int
|
||||
audio_bitrate: int
|
||||
format: str
|
||||
quality_preset: str
|
||||
description: str
|
||||
size_hint: str
|
||||
|
||||
|
||||
class ExportPresetListResponse(BaseModel):
|
||||
"""导出预设列表响应"""
|
||||
|
||||
items: List[ExportPresetItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_export_config(plan_config: dict) -> dict:
|
||||
e = plan_config.get("export", {})
|
||||
if not isinstance(e, dict):
|
||||
e = {}
|
||||
return {
|
||||
"resolution": e.get("resolution", "1080x1920"),
|
||||
"fps": e.get("fps", 30),
|
||||
"video_bitrate": e.get("video_bitrate", 8000),
|
||||
"audio_bitrate": e.get("audio_bitrate", 128),
|
||||
"format": e.get("format", "mp4"),
|
||||
"quality_preset": e.get("quality_preset", "balanced"),
|
||||
"watermark_enabled": e.get("watermark_enabled", False),
|
||||
"watermark_text": e.get("watermark_text", ""),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/export-presets", response_model=ExportPresetListResponse)
|
||||
def list_export_presets(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> ExportPresetListResponse:
|
||||
"""获取导出预设列表"""
|
||||
items = [ExportPresetItem(**p) for p in EXPORT_PRESETS]
|
||||
return ExportPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/export", response_model=ExportConfigResponse)
|
||||
def get_export_config(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""获取导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_export_config(plan.config or {})
|
||||
return ExportConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/export", response_model=ExportConfigResponse)
|
||||
def update_export_config(
|
||||
plan_id: str,
|
||||
body: ExportUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> ExportConfigResponse:
|
||||
"""更新导出配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 合并更新
|
||||
current = _get_export_config(plan.config or {})
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
new_export = {**current, **updates}
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["export"] = new_export
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"export": normalized["export"]})
|
||||
|
||||
result = _get_export_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新导出配置: plan_id=%s resolution=%s fps=%d by user=%s",
|
||||
plan_id,
|
||||
result["resolution"],
|
||||
result["fps"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return ExportConfigResponse(**result)
|
||||
@@ -1,198 +0,0 @@
|
||||
"""滤镜调色 API.
|
||||
|
||||
- GET /filter-presets 滤镜预设列表
|
||||
- GET /{plan_id}/filter 获取全局滤镜配置
|
||||
- PUT /{plan_id}/filter 更新全局滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from app.services import EditPlanService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.filter_presets import (
|
||||
FilterPreset,
|
||||
build_ffmpeg_filter,
|
||||
get_filter_preset,
|
||||
list_filter_presets,
|
||||
)
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FilterPresetResponse(BaseModel):
|
||||
"""滤镜预设响应"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FilterConfigResponse(BaseModel):
|
||||
"""滤镜配置响应"""
|
||||
|
||||
enabled: bool
|
||||
preset_id: str
|
||||
intensity: int
|
||||
brightness: float
|
||||
contrast: float
|
||||
saturation: float
|
||||
warmth: float
|
||||
|
||||
|
||||
class FilterUpdateRequest(BaseModel):
|
||||
"""更新滤镜配置请求"""
|
||||
|
||||
enabled: Optional[bool] = None
|
||||
preset_id: Optional[str] = None
|
||||
intensity: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
brightness: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
contrast: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
saturation: Optional[float] = Field(default=None, ge=0.0, le=3.0)
|
||||
warmth: Optional[float] = Field(default=None, ge=-1.0, le=1.0)
|
||||
|
||||
|
||||
class FilterPresetListResponse(BaseModel):
|
||||
"""滤镜预设列表响应"""
|
||||
|
||||
items: List[FilterPresetResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _preset_to_response(p: FilterPreset) -> FilterPresetResponse:
|
||||
return FilterPresetResponse(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
category=p.category,
|
||||
description=p.description,
|
||||
tags=list(p.tags),
|
||||
)
|
||||
|
||||
|
||||
def _get_filter_config(plan_config: dict) -> dict:
|
||||
"""从 plan.config 中提取滤镜配置"""
|
||||
f = plan_config.get("filter", {})
|
||||
if not isinstance(f, dict):
|
||||
f = {}
|
||||
return {
|
||||
"enabled": f.get("enabled", False),
|
||||
"preset_id": f.get("preset_id", "filter_none"),
|
||||
"intensity": f.get("intensity", 100),
|
||||
"brightness": f.get("brightness", 0.0),
|
||||
"contrast": f.get("contrast", 1.0),
|
||||
"saturation": f.get("saturation", 1.0),
|
||||
"warmth": f.get("warmth", 0.0),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/filter-presets", response_model=FilterPresetListResponse)
|
||||
def list_presets(
|
||||
category: Optional[str] = Query(default=None, description="按分类筛选"),
|
||||
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> FilterPresetListResponse:
|
||||
"""获取滤镜预设列表"""
|
||||
presets = list_filter_presets(category=category, keyword=keyword)
|
||||
items = [_preset_to_response(p) for p in presets]
|
||||
return FilterPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{plan_id}/filter", response_model=FilterConfigResponse)
|
||||
def get_filter(
|
||||
plan_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""获取剪辑计划的全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
config = _get_filter_config(plan.config or {})
|
||||
return FilterConfigResponse(**config)
|
||||
|
||||
|
||||
@router.put("/{plan_id}/filter", response_model=FilterConfigResponse)
|
||||
def update_filter(
|
||||
plan_id: str,
|
||||
body: FilterUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> FilterConfigResponse:
|
||||
"""更新全局滤镜配置"""
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"剪辑计划不存在: {plan_id}",
|
||||
)
|
||||
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, current_user.user.id, project_repository)
|
||||
|
||||
# 验证 preset_id
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
if "preset_id" in updates:
|
||||
preset = get_filter_preset(updates["preset_id"])
|
||||
if preset is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效的滤镜预设: {updates['preset_id']}",
|
||||
)
|
||||
|
||||
# 合并更新
|
||||
current = _get_filter_config(plan.config or {})
|
||||
new_filter = {**current, **updates}
|
||||
|
||||
# 如果设为原图 preset,自动关闭
|
||||
if new_filter["preset_id"] == "filter_none":
|
||||
new_filter["enabled"] = False
|
||||
|
||||
# 更新到 plan.config
|
||||
current_config = dict(plan.config or {})
|
||||
current_config["filter"] = new_filter
|
||||
normalized = normalize_plan_config(current_config)
|
||||
updated_plan = svc.update_plan_config(plan_id, {"filter": normalized["filter"]})
|
||||
|
||||
result = _get_filter_config(updated_plan.config or {})
|
||||
logger.info(
|
||||
"更新滤镜配置: plan_id=%s preset=%s intensity=%d by user=%s",
|
||||
plan_id,
|
||||
result["preset_id"],
|
||||
result["intensity"],
|
||||
current_user.user.id,
|
||||
)
|
||||
return FilterConfigResponse(**result)
|
||||
Executable → Regular
-50
@@ -427,53 +427,3 @@ def retry_generation_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel", response_model=GenerationTaskResponse)
|
||||
def cancel_generation_task(
|
||||
task_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> GenerationTaskResponse:
|
||||
"""取消生成任务。
|
||||
|
||||
仅 pending / running 状态的任务可取消;取消后状态变为 cancelled。
|
||||
对于已在运行的 Celery 任务,标记为 cancelled 后,worker 在下次检查点会中止执行。
|
||||
"""
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
|
||||
# 权限校验
|
||||
if task.created_by_user_id and task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
||||
|
||||
# 终态不可取消
|
||||
if status_val in ("completed", "failed", "cancelled"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot cancel task in {status_val} status",
|
||||
)
|
||||
|
||||
# 执行取消
|
||||
try:
|
||||
task.mark_cancelled()
|
||||
task.append_log(
|
||||
stage="cancelled",
|
||||
message="用户主动取消任务",
|
||||
level="INFO",
|
||||
cancelled_by=authenticated_user.user.id,
|
||||
)
|
||||
generation_task_repository.update(task)
|
||||
logger.info(
|
||||
"生成任务已取消: task_id=%s user_id=%s previous_status=%s",
|
||||
task_id,
|
||||
authenticated_user.user.id,
|
||||
status_val,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
return _to_generation_task_response(task)
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
"""封面管理服务.
|
||||
|
||||
提供封面配置管理和从视频抽帧生成封面的能力。
|
||||
抽帧使用 FFmpeg,上传使用共享存储服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
|
||||
COVER_STORAGE_PREFIX = "covers"
|
||||
|
||||
|
||||
class CoverService:
|
||||
"""封面管理服务."""
|
||||
|
||||
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
|
||||
self._storage = storage_service
|
||||
self._asset_repo = asset_repository
|
||||
|
||||
# ── 配置读写 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""从 plan.config 中提取封面配置.
|
||||
|
||||
Args:
|
||||
plan_config: 剪辑计划的 config 字段
|
||||
|
||||
Returns:
|
||||
封面配置 dict
|
||||
"""
|
||||
cover = plan_config.get("cover", {})
|
||||
if not isinstance(cover, dict):
|
||||
cover = {}
|
||||
# 确保默认字段存在
|
||||
return {
|
||||
"type": cover.get("type", "ai_frame"),
|
||||
"image_url": cover.get("image_url", ""),
|
||||
"frame_time": cover.get("frame_time"),
|
||||
}
|
||||
|
||||
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
|
||||
|
||||
def extract_cover_from_clip(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
frame_time: float = 1.0,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""从指定素材的指定时间点抽取一帧作为封面.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID(用于生成存储路径)
|
||||
asset_id: 素材 ID
|
||||
frame_time: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict,包含 type / image_url / frame_time
|
||||
|
||||
Raises:
|
||||
ValueError: 素材不存在或不是视频
|
||||
RuntimeError: 抽帧或上传失败
|
||||
"""
|
||||
# 1. 获取素材
|
||||
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
|
||||
if not asset:
|
||||
raise ValueError(f"素材不存在: {asset_id}")
|
||||
|
||||
storage_key = getattr(asset, "storage_key", "")
|
||||
if not storage_key:
|
||||
raise ValueError(f"素材没有文件: {asset_id}")
|
||||
|
||||
mime_type = getattr(asset, "mime_type", "")
|
||||
if mime_type and not mime_type.startswith("video"):
|
||||
raise ValueError(f"素材不是视频类型: {mime_type}")
|
||||
|
||||
# 2. 下载视频到临时目录
|
||||
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
video_path = tmp_path / f"source_{asset_id[:8]}"
|
||||
|
||||
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
|
||||
try:
|
||||
self._storage.download_file(storage_key, str(video_path))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"下载素材失败: {e}") from e
|
||||
|
||||
if not video_path.exists() or video_path.stat().st_size == 0:
|
||||
raise RuntimeError("下载的素材文件为空")
|
||||
|
||||
# 3. FFmpeg 抽帧
|
||||
output_path = tmp_path / "cover.jpg"
|
||||
self._extract_frame(
|
||||
video_path=video_path,
|
||||
output_path=output_path,
|
||||
time_sec=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError("封面抽帧失败")
|
||||
|
||||
# 4. 上传到 OSS
|
||||
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
|
||||
logger.info("上传封面到存储: key=%s", cover_key)
|
||||
|
||||
try:
|
||||
self._storage.upload_file(
|
||||
file_or_path=str(output_path),
|
||||
storage_key=cover_key,
|
||||
content_type="image/jpeg",
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传封面失败: {e}") from e
|
||||
|
||||
# 5. 获取访问 URL
|
||||
try:
|
||||
image_url = self._storage.get_url(cover_key)
|
||||
except Exception:
|
||||
image_url = cover_key # 降级为 storage_key
|
||||
|
||||
logger.info(
|
||||
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
|
||||
plan_id,
|
||||
asset_id,
|
||||
frame_time,
|
||||
output_path.stat().st_size if output_path.exists() else 0,
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "manual",
|
||||
"image_url": image_url,
|
||||
"frame_time": frame_time,
|
||||
}
|
||||
|
||||
def generate_smart_cover(
|
||||
self,
|
||||
plan_id: str,
|
||||
asset_id: str,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
|
||||
|
||||
Args:
|
||||
plan_id: 剪辑计划 ID
|
||||
asset_id: 素材 ID
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面数据 dict
|
||||
"""
|
||||
# 简单实现:取视频 1/3 处的帧作为智能封面
|
||||
# 更复杂的多帧选清晰帧可以后续优化
|
||||
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
|
||||
|
||||
result = self.extract_cover_from_clip(
|
||||
plan_id=plan_id,
|
||||
asset_id=asset_id,
|
||||
frame_time=frame_time,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
result["type"] = "ai_frame"
|
||||
return result
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _extract_frame(
|
||||
video_path: Path,
|
||||
output_path: Path,
|
||||
*,
|
||||
time_sec: float,
|
||||
width: int,
|
||||
height: int,
|
||||
quality: int,
|
||||
) -> None:
|
||||
"""使用 FFmpeg 从视频中抽取一帧.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
|
||||
# 尝试不使用 scale+crop 的简化命令
|
||||
simple_command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
result2 = subprocess.run(
|
||||
simple_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result2.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError("FFmpeg 抽帧超时") from e
|
||||
except FileNotFoundError as e:
|
||||
raise RuntimeError("FFmpeg 不可用") from e
|
||||
@@ -504,175 +504,6 @@ class EditPlanService:
|
||||
return created
|
||||
|
||||
# ── 渲染生成流程 ────────────────────────────────────────────────────────
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
"""将一个片段从指定位置分割为两个片段
|
||||
|
||||
Args:
|
||||
clip_id: 要分割的片段 ID
|
||||
split_time: 分割点(相对于片段起始的秒数),必须在 (0, duration) 范围内
|
||||
|
||||
Returns:
|
||||
dict: {"left_clip": EditPlanClip, "right_clip": EditPlanClip}
|
||||
|
||||
Raises:
|
||||
ValueError: 片段不存在、分割时间越界
|
||||
"""
|
||||
clip = self.get_clip_or_raise(clip_id)
|
||||
plan_id = clip.plan_id
|
||||
|
||||
if split_time <= 0 or split_time >= clip.duration:
|
||||
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
|
||||
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
original_duration = clip.duration
|
||||
left_duration = round(split_time, 3)
|
||||
right_duration = round(original_duration - split_time, 3)
|
||||
original_order = clip.order
|
||||
|
||||
# 更新左半部分(原片段)
|
||||
clip.duration = left_duration
|
||||
left_clip = self._clip_repo.update(clip)
|
||||
|
||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
for c in all_clips:
|
||||
if c.order > original_order and c.id != clip_id:
|
||||
c.order += 1
|
||||
self._clip_repo.update(c)
|
||||
|
||||
# 创建右半部分新片段(继承原片段的大部分属性)
|
||||
right_config = dict(clip.config) if clip.config else {}
|
||||
# 素材裁剪信息
|
||||
if clip.asset_id:
|
||||
# 右半部分从 split_time 开始播放
|
||||
right_config["trim_start"] = left_duration
|
||||
# 左半部分在 split_time 处结束
|
||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
||||
left_config["trim_end"] = right_duration
|
||||
left_clip.config = left_config
|
||||
left_clip = self._clip_repo.update(left_clip)
|
||||
|
||||
right_clip = EditPlanClip.create(
|
||||
plan_id=plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=original_order + 1,
|
||||
template_clip_config_id=clip.template_clip_config_id,
|
||||
asset_id=clip.asset_id,
|
||||
text_content=clip.text_content,
|
||||
start_time=clip.start_time + left_duration,
|
||||
duration=right_duration,
|
||||
transition_effect=clip.transition_effect,
|
||||
transition_duration=clip.transition_duration,
|
||||
playback_speed=clip.playback_speed,
|
||||
config=right_config,
|
||||
)
|
||||
created_right = self._clip_repo.create(right_clip)
|
||||
|
||||
logger.info(
|
||||
"分割片段: clip_id=%s plan_id=%s split_time=%.3fs left_dur=%.3fs right_dur=%.3fs",
|
||||
clip_id,
|
||||
plan_id,
|
||||
split_time,
|
||||
left_duration,
|
||||
right_duration,
|
||||
)
|
||||
|
||||
return {
|
||||
"left_clip": left_clip,
|
||||
"right_clip": created_right,
|
||||
}
|
||||
|
||||
def merge_clips(self, clip_ids: List[str]) -> EditPlanClip:
|
||||
"""合并多个连续片段为一个片段
|
||||
|
||||
Args:
|
||||
clip_ids: 要合并的片段 ID 列表(至少2个),必须属于同一个计划且 order 连续
|
||||
|
||||
Returns:
|
||||
EditPlanClip: 合并后的新片段
|
||||
|
||||
Raises:
|
||||
ValueError: 数量不足、不属于同一计划、不连续、类型不一致
|
||||
"""
|
||||
if len(clip_ids) < 2:
|
||||
raise ValueError("至少需要 2 个片段才能合并")
|
||||
|
||||
# 读取所有片段
|
||||
clips = []
|
||||
for cid in clip_ids:
|
||||
clip = self.get_clip_or_raise(cid)
|
||||
clips.append(clip)
|
||||
|
||||
# 校验:同一计划
|
||||
plan_id = clips[0].plan_id
|
||||
for c in clips[1:]:
|
||||
if c.plan_id != plan_id:
|
||||
raise ValueError("只能合并同一计划下的片段")
|
||||
|
||||
# 按 order 排序
|
||||
clips.sort(key=lambda c: c.order)
|
||||
|
||||
# 校验:order 连续
|
||||
for i in range(1, len(clips)):
|
||||
if clips[i].order != clips[i - 1].order + 1:
|
||||
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
|
||||
|
||||
# 校验:类型一致
|
||||
clip_type = clips[0].clip_type
|
||||
for c in clips[1:]:
|
||||
if c.clip_type != clip_type:
|
||||
raise ValueError("只能合并相同类型的片段")
|
||||
|
||||
self._auto_resume_editing(plan_id)
|
||||
|
||||
# 计算合并后的属性
|
||||
first_clip = clips[0]
|
||||
total_duration = round(sum(c.duration for c in clips), 3)
|
||||
first_order = first_clip.order
|
||||
|
||||
# 合并文案(用换行连接)
|
||||
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
|
||||
|
||||
# 合并 config(后面的覆盖前面的)
|
||||
merged_config: Dict[str, Any] = {}
|
||||
for c in clips:
|
||||
if c.config:
|
||||
merged_config.update(c.config)
|
||||
# 清理 trim 相关字段(合并后就是完整片段了)
|
||||
merged_config.pop("trim_start", None)
|
||||
merged_config.pop("trim_end", None)
|
||||
|
||||
# 更新第一个片段(保留它作为合并结果)
|
||||
first_clip.duration = total_duration
|
||||
first_clip.text_content = merged_text
|
||||
first_clip.config = merged_config
|
||||
# 转场保留第一个的(合并后的入点转场)
|
||||
# playback_speed 取第一个的
|
||||
merged_clip = self._clip_repo.update(first_clip)
|
||||
|
||||
# 删除其余片段
|
||||
for c in clips[1:]:
|
||||
self._clip_repo.delete(c.id)
|
||||
|
||||
# 后面的片段 order 前移 (len - 1) 位
|
||||
shift = len(clips) - 1
|
||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||
for c in all_clips:
|
||||
if c.order > first_order and c.id != merged_clip.id:
|
||||
c.order -= shift
|
||||
self._clip_repo.update(c)
|
||||
|
||||
logger.info(
|
||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||||
plan_id,
|
||||
len(clips),
|
||||
total_duration,
|
||||
)
|
||||
|
||||
return merged_clip
|
||||
|
||||
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
|
||||
"""获取计划及其所有片段
|
||||
|
||||
Executable → Regular
+19
-221
@@ -20,7 +20,7 @@ import type {
|
||||
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled";
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
@@ -276,6 +276,24 @@ export interface CoverResult {
|
||||
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
|
||||
* ============================================================ */
|
||||
|
||||
/** 剪辑计划中的片段(UI 层类型) */
|
||||
export interface EditPlanClip {
|
||||
id: string;
|
||||
template_segment_id: string;
|
||||
/** 素材库中的素材 ID */
|
||||
media_asset_id?: string;
|
||||
/** 素材类型 */
|
||||
material_type: "video" | "image" | "audio" | "voiceover";
|
||||
/** 片段文案 */
|
||||
script_text: string;
|
||||
/** 实际时长(秒) */
|
||||
duration: number;
|
||||
/** 转场效果 */
|
||||
transition?: TransitionEffect;
|
||||
/** 排序 */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 转场效果(14 种预设) */
|
||||
export interface TransitionEffect {
|
||||
type:
|
||||
@@ -435,225 +453,6 @@ export async function getGenerationTaskResults(
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(planId: string): Promise<void> {
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 片段 CRUD(后端 EditPlanClip 独立表)
|
||||
* ============================================================ */
|
||||
|
||||
/** 片段状态 */
|
||||
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed";
|
||||
|
||||
/** 剪辑片段(后端响应) */
|
||||
export interface EditPlanClip {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
clip_type: string; // main / intro / outro / overlay / background / b_roll 等
|
||||
order: number;
|
||||
asset_id: string;
|
||||
text_content: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
transition_effect: string;
|
||||
transition_duration: number;
|
||||
playback_speed: number;
|
||||
status: EditPlanClipStatus;
|
||||
config: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建片段请求 */
|
||||
export interface CreateEditPlanClipRequest {
|
||||
clip_type: string;
|
||||
order: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
export interface UpdateEditPlanClipRequest {
|
||||
clip_type?: string;
|
||||
order?: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
export interface EditPlanClipListResponse {
|
||||
items: EditPlanClip[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 片段列表查询参数 */
|
||||
export interface EditPlanClipListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** 获取片段列表 */
|
||||
export async function getEditPlanClips(
|
||||
planId: string,
|
||||
params?: EditPlanClipListParams,
|
||||
): Promise<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
export async function createEditPlanClip(
|
||||
planId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
export async function updateEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 片段批量操作
|
||||
* ============================================================ */
|
||||
|
||||
/** 重排序条目 */
|
||||
export interface ClipReorderItem {
|
||||
clip_id: string;
|
||||
new_order: number;
|
||||
}
|
||||
|
||||
/** 重排序响应 */
|
||||
export interface ClipReorderResponse {
|
||||
success: boolean;
|
||||
updated_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 批量删除响应 */
|
||||
export interface ClipBatchDeleteResponse {
|
||||
success: boolean;
|
||||
deleted_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 从素材批量创建响应 */
|
||||
export interface ClipsFromAssetsResponse {
|
||||
success: boolean;
|
||||
created_count: number;
|
||||
message: string;
|
||||
clip_ids: string[];
|
||||
}
|
||||
|
||||
/** 片段重排序(拖拽排序后一次性提交) */
|
||||
export async function reorderEditPlanClips(
|
||||
planId: string,
|
||||
items: ClipReorderItem[],
|
||||
): Promise<ClipReorderResponse> {
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/edit-plans/${planId}/clips/reorder`,
|
||||
{ items },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
export async function batchDeleteEditPlanClips(
|
||||
planId: string,
|
||||
clipIds: string[],
|
||||
): Promise<ClipBatchDeleteResponse> {
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/edit-plans/${planId}/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
export async function createClipsFromAssets(
|
||||
planId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/edit-plans/${planId}/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 复制计划
|
||||
* ============================================================ */
|
||||
|
||||
/** 复制计划请求 */
|
||||
export interface CopyEditPlanRequest {
|
||||
name?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
/** 复制剪辑计划(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
planId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/edit-plans/${planId}/copy`,
|
||||
data || {},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
@@ -754,7 +553,6 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
rendering: "渲染中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
/** 质量分筛选选项 */
|
||||
|
||||
Executable → Regular
+2
-96
@@ -24,17 +24,12 @@ import {
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
CopyOutlined,
|
||||
UnorderedListOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
cancelGeneration,
|
||||
copyEditPlan,
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
@@ -52,7 +47,6 @@ const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "rendering", label: "渲染中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
@@ -85,11 +79,6 @@ const STATUS_CONFIG: Record<
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
cancelled: {
|
||||
label: "已取消",
|
||||
color: "default",
|
||||
icon: <StopOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
@@ -195,33 +184,6 @@ export default function EditPlans() {
|
||||
},
|
||||
});
|
||||
|
||||
// 取消生成
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelGeneration,
|
||||
onSuccess: () => {
|
||||
message.success("已提交取消请求");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("取消失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 复制计划
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: ({ planId, name }: { planId: string; name?: string }) =>
|
||||
copyEditPlan(planId, name ? { name } : undefined),
|
||||
onSuccess: (newPlan) => {
|
||||
message.success("计划已复制");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
// 自动跳转到新计划的编辑器
|
||||
navigate(`/app/editing-planner?planId=${newPlan.id}`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
@@ -323,21 +285,10 @@ export default function EditPlans() {
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 240,
|
||||
width: 180,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: EditPlan) => (
|
||||
<div className="plan-actions">
|
||||
<Tooltip title="片段管理">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => navigate(`/app/edit-plans/${record.id}/clips`)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
片段
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -347,30 +298,7 @@ export default function EditPlans() {
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{record.status === "rendering" && (
|
||||
<Popconfirm
|
||||
title="确认取消生成"
|
||||
description="确定要取消当前生成任务吗?此操作不可恢复。"
|
||||
onConfirm={() => cancelMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="再等等"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={cancelMutation.isPending}
|
||||
className="plan-action-btn plan-cancel-btn"
|
||||
>
|
||||
取消生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{(record.status === "failed" ||
|
||||
record.status === "completed" ||
|
||||
record.status === "cancelled") && (
|
||||
{(record.status === "failed" || record.status === "completed") && (
|
||||
<Popconfirm
|
||||
title="确认重新生成"
|
||||
description="确定要重新生成这个剪辑计划吗?"
|
||||
@@ -389,28 +317,6 @@ export default function EditPlans() {
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="复制计划"
|
||||
description="确定要复制这个剪辑计划吗?将创建一个编辑中的新副本。"
|
||||
onConfirm={() =>
|
||||
copyMutation.mutate({
|
||||
planId: record.id,
|
||||
name: `${record.name} 副本`,
|
||||
})
|
||||
}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
loading={copyMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
|
||||
@@ -1,658 +0,0 @@
|
||||
/**
|
||||
* 剪辑计划片段管理页面
|
||||
* 对接后端 PR#389 片段 CRUD API
|
||||
* 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Tag,
|
||||
Drawer,
|
||||
Empty,
|
||||
Card,
|
||||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
UploadOutlined,
|
||||
OrderedListOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlan,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
reorderEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
getMediaAssets,
|
||||
type EditPlanClip,
|
||||
type EditPlanClipStatus,
|
||||
} from "@/api/editPlans";
|
||||
import "./plan-clips.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const CLIP_TYPE_OPTIONS = [
|
||||
{ value: "main", label: "主片段" },
|
||||
{ value: "intro", label: "片头" },
|
||||
{ value: "outro", label: "片尾" },
|
||||
{ value: "overlay", label: "叠加层" },
|
||||
{ value: "background", label: "背景" },
|
||||
{ value: "b_roll", label: "B-roll" },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "default",
|
||||
processing: "processing",
|
||||
ready: "success",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "待处理",
|
||||
processing: "处理中",
|
||||
ready: "就绪",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ value: "cut", label: "硬切" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "dissolve", label: "溶解" },
|
||||
{ value: "zoom", label: "缩放" },
|
||||
{ value: "slide_left", label: "左滑" },
|
||||
{ value: "slide_right", label: "右滑" },
|
||||
{ value: "slide_up", label: "上滑" },
|
||||
{ value: "slide_down", label: "下滑" },
|
||||
{ value: "wipe_left", label: "左擦除" },
|
||||
{ value: "wipe_right", label: "右擦除" },
|
||||
{ value: "wipe_up", label: "上擦除" },
|
||||
{ value: "wipe_down", label: "下擦除" },
|
||||
{ value: "circlecrop", label: "圆形裁切" },
|
||||
{ value: "rectcrop", label: "矩形裁切" },
|
||||
];
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PlanClipsManager: React.FC = () => {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 计划信息 ── */
|
||||
const { data: plan, isLoading: planLoading } = useQuery({
|
||||
queryKey: ["editPlan", planId],
|
||||
queryFn: () => getEditPlan(planId!),
|
||||
enabled: !!planId,
|
||||
});
|
||||
|
||||
/* ── 片段列表 ── */
|
||||
const { data: clipsData, isLoading: clipsLoading } = useQuery({
|
||||
queryKey: ["editPlanClips", planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
});
|
||||
|
||||
const clips = clipsData?.items ?? [];
|
||||
|
||||
/* ── 选中的片段(批量操作) ── */
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
/* ── 编辑弹窗 ── */
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
|
||||
/* ── 素材导入抽屉 ── */
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
const { data: assets } = useQuery({
|
||||
queryKey: ["mediaAssets"],
|
||||
queryFn: () => getMediaAssets(),
|
||||
enabled: importDrawerOpen,
|
||||
});
|
||||
|
||||
/* ── 重新排序模式 ── */
|
||||
const [reorderMode, setReorderMode] = useState(false);
|
||||
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([]);
|
||||
|
||||
/* ── 列定义 ── */
|
||||
const columns: ColumnsType<EditPlanClip> = [
|
||||
{
|
||||
title: "序号",
|
||||
dataIndex: "order",
|
||||
width: 70,
|
||||
render: (_, __, index) => index + 1,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "clip_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type);
|
||||
return <Tag>{opt?.label || type}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "素材",
|
||||
dataIndex: "asset_id",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (assetId: string) =>
|
||||
assetId ? (
|
||||
<code className="clip-asset-id">{assetId.slice(0, 12)}...</code>
|
||||
) : (
|
||||
<span style={{ color: "#999" }}>无素材</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "文本内容",
|
||||
dataIndex: "text_content",
|
||||
ellipsis: true,
|
||||
render: (text: string) =>
|
||||
text || <span style={{ color: "#999" }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "duration",
|
||||
width: 90,
|
||||
render: (d: number) => `${d?.toFixed(1) || 0}s`,
|
||||
},
|
||||
{
|
||||
title: "转场",
|
||||
dataIndex: "transition_effect",
|
||||
width: 100,
|
||||
render: (effect: string) => {
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect);
|
||||
return opt?.label || effect || "硬切";
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "播放速度",
|
||||
dataIndex: "playback_speed",
|
||||
width: 90,
|
||||
render: (s: number) => `${s || 1.0}x`,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (status: EditPlanClipStatus) => (
|
||||
<Tag color={STATUS_COLORS[status] || "default"}>
|
||||
{STATUS_LABELS[status] || status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 140,
|
||||
fixed: "right",
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditClip(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除片段"
|
||||
description="确定删除这个片段吗?"
|
||||
onConfirm={() => handleDeleteClip(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/* ── 编辑片段 ── */
|
||||
const handleEditClip = useCallback(
|
||||
(clip: EditPlanClip) => {
|
||||
setEditingClip(clip);
|
||||
editForm.setFieldsValue({
|
||||
clip_type: clip.clip_type,
|
||||
asset_id: clip.asset_id,
|
||||
text_content: clip.text_content,
|
||||
duration: clip.duration,
|
||||
start_time: clip.start_time,
|
||||
transition_effect: clip.transition_effect,
|
||||
transition_duration: clip.transition_duration,
|
||||
playback_speed: clip.playback_speed,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
},
|
||||
[editForm],
|
||||
);
|
||||
|
||||
const handleNewClip = useCallback(() => {
|
||||
setEditingClip(null);
|
||||
editForm.resetFields();
|
||||
editForm.setFieldsValue({
|
||||
clip_type: "main",
|
||||
duration: 5,
|
||||
transition_effect: "cut",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1.0,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
}, [editForm]);
|
||||
|
||||
const handleSaveClip = async () => {
|
||||
if (!planId) return;
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
setEditLoading(true);
|
||||
|
||||
if (editingClip) {
|
||||
// 更新
|
||||
await updateEditPlanClip(planId, editingClip.id, values);
|
||||
message.success("片段已更新");
|
||||
} else {
|
||||
// 新建
|
||||
const maxOrder =
|
||||
clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1;
|
||||
await createEditPlanClip(planId, {
|
||||
...values,
|
||||
order: maxOrder + 1,
|
||||
});
|
||||
message.success("片段已创建");
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setEditModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(editingClip ? "更新失败" : "创建失败");
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const handleDeleteClip = async (clipId: string) => {
|
||||
if (!planId) return;
|
||||
try {
|
||||
await deleteEditPlanClip(planId, clipId);
|
||||
message.success("已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId));
|
||||
} catch {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = async () => {
|
||||
if (!planId || selectedRowKeys.length === 0) return;
|
||||
try {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
selectedRowKeys.map((k) => String(k)),
|
||||
);
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个片段`);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setSelectedRowKeys([]);
|
||||
} catch {
|
||||
message.error("批量删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 从素材导入 ── */
|
||||
const handleImportFromAssets = async () => {
|
||||
if (!planId || selectedAssetIds.length === 0) return;
|
||||
try {
|
||||
setImportLoading(true);
|
||||
const res = await createClipsFromAssets(planId, selectedAssetIds);
|
||||
message.success(`已导入 ${res.created_count} 个片段`);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setImportDrawerOpen(false);
|
||||
setSelectedAssetIds([]);
|
||||
} catch {
|
||||
message.error("导入失败");
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 排序模式 ── */
|
||||
const enterReorderMode = () => {
|
||||
setReorderItems([...clips].sort((a, b) => a.order - b.order));
|
||||
setReorderMode(true);
|
||||
};
|
||||
|
||||
const moveClip = (fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= reorderItems.length) return;
|
||||
const newItems = [...reorderItems];
|
||||
const [moved] = newItems.splice(fromIndex, 1);
|
||||
newItems.splice(toIndex, 0, moved);
|
||||
setReorderItems(newItems);
|
||||
};
|
||||
|
||||
const saveReorder = async () => {
|
||||
if (!planId) return;
|
||||
const items = reorderItems.map((clip, index) => ({
|
||||
clip_id: clip.id,
|
||||
new_order: index,
|
||||
}));
|
||||
try {
|
||||
await reorderEditPlanClips(planId, items);
|
||||
message.success("排序已保存");
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setReorderMode(false);
|
||||
} catch {
|
||||
message.error("排序保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const cancelReorder = () => {
|
||||
setReorderMode(false);
|
||||
setReorderItems([]);
|
||||
};
|
||||
|
||||
/* ── 渲染 ── */
|
||||
const displayClips = reorderMode
|
||||
? reorderItems
|
||||
: [...clips].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className="plan-clips-page">
|
||||
{/* 顶部 */}
|
||||
<div className="plan-clips-header">
|
||||
<div className="plan-clips-header-left">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate("/app/edit-plans")}
|
||||
>
|
||||
返回计划列表
|
||||
</Button>
|
||||
<div className="plan-clips-title">
|
||||
<h2>{plan?.name || "加载中..."}</h2>
|
||||
<p>
|
||||
{planLoading
|
||||
? "加载中..."
|
||||
: `共 ${clipsData?.total || 0} 个片段 · ${plan?.status || ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan-clips-header-right">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setImportDrawerOpen(true)}
|
||||
>
|
||||
从素材导入
|
||||
</Button>
|
||||
{reorderMode ? (
|
||||
<>
|
||||
<Button onClick={cancelReorder}>取消排序</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={saveReorder}
|
||||
>
|
||||
保存排序
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
icon={<OrderedListOutlined />}
|
||||
onClick={enterReorderMode}
|
||||
disabled={clips.length === 0}
|
||||
>
|
||||
调整顺序
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleNewClip}
|
||||
>
|
||||
添加片段
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作栏 */}
|
||||
{!reorderMode && selectedRowKeys.length > 0 && (
|
||||
<div className="plan-clips-batch-bar">
|
||||
<span>已选择 {selectedRowKeys.length} 个片段</span>
|
||||
<Popconfirm
|
||||
title="批量删除"
|
||||
description={`确定删除选中的 ${selectedRowKeys.length} 个片段吗?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排序列表 */}
|
||||
{reorderMode && (
|
||||
<Card className="plan-clips-reorder-card" title="拖拽调整顺序">
|
||||
<div className="plan-clips-reorder-list">
|
||||
{reorderItems.map((clip, index) => (
|
||||
<div key={clip.id} className="plan-clips-reorder-item">
|
||||
<span className="reorder-index">{index + 1}</span>
|
||||
<span className="reorder-type">
|
||||
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)
|
||||
?.label || clip.clip_type}
|
||||
</span>
|
||||
<span className="reorder-content">
|
||||
{clip.text_content || clip.asset_id || "无内容"}
|
||||
</span>
|
||||
<span className="reorder-duration">
|
||||
{clip.duration.toFixed(1)}s
|
||||
</span>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => moveClip(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
>
|
||||
↑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => moveClip(index, index + 1)}
|
||||
disabled={index === reorderItems.length - 1}
|
||||
>
|
||||
↓
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 片段列表 */}
|
||||
{!reorderMode && (
|
||||
<div className="plan-clips-table-wrap">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={displayClips}
|
||||
loading={clipsLoading}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description="暂无片段,点击上方按钮添加或从素材导入"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingClip ? "编辑片段" : "添加片段"}
|
||||
open={editModalOpen}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
onOk={handleSaveClip}
|
||||
confirmLoading={editLoading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
width={560}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item
|
||||
label="片段类型"
|
||||
name="clip_type"
|
||||
rules={[{ required: true, message: "请选择类型" }]}
|
||||
>
|
||||
<Select options={CLIP_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材 ID" name="asset_id">
|
||||
<Input placeholder="关联的素材 ID(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="文本内容" name="text_content">
|
||||
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item
|
||||
label="起始时间(秒)"
|
||||
name="start_time"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item
|
||||
label="转场效果"
|
||||
name="transition_effect"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Select options={TRANSITION_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="转场时长"
|
||||
name="transition_duration"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="播放速度" name="playback_speed">
|
||||
<InputNumber
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 素材导入抽屉 */}
|
||||
<Drawer
|
||||
title="从素材库导入"
|
||||
open={importDrawerOpen}
|
||||
onClose={() => setImportDrawerOpen(false)}
|
||||
width={480}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleImportFromAssets}
|
||||
loading={importLoading}
|
||||
disabled={selectedAssetIds.length === 0}
|
||||
>
|
||||
导入{" "}
|
||||
{selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{assets && assets.length > 0 ? (
|
||||
<div className="asset-import-list">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className={`asset-import-item ${
|
||||
selectedAssetIds.includes(asset.id) ? "selected" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedAssetIds((prev) =>
|
||||
prev.includes(asset.id)
|
||||
? prev.filter((id) => id !== asset.id)
|
||||
: [...prev, asset.id],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="asset-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<div className="asset-thumb-placeholder">{asset.type}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="asset-info">
|
||||
<div className="asset-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="asset-meta">
|
||||
{asset.type}
|
||||
{asset.duration ? ` · ${asset.duration.toFixed(1)}s` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="素材库为空" />
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanClipsManager;
|
||||
@@ -1,203 +0,0 @@
|
||||
/* 剪辑计划片段管理页面 */
|
||||
|
||||
.plan-clips-page {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.plan-clips-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-clips-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.plan-clips-title h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.plan-clips-title p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.plan-clips-batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #e6f4ff;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.plan-clips-table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.clip-asset-id {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 排序模式 */
|
||||
.plan-clips-reorder-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: grab;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.plan-clips-reorder-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.reorder-index {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reorder-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
padding: 2px 8px;
|
||||
background: #eef2ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reorder-content {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reorder-duration {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 素材导入 */
|
||||
.asset-import-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.asset-import-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.asset-import-item:hover {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.asset-import-item.selected {
|
||||
border-color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
|
||||
.asset-thumb {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.asset-thumb-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
margin-top: 2px;
|
||||
}
|
||||
Executable → Regular
-272
@@ -6048,275 +6048,3 @@
|
||||
color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
/* ── 生成历史取消按钮 ── */
|
||||
.ep-gh-td-action {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
.ep-gh-cancel-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-error, #ff4d4f);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.ep-gh-cancel-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 77, 79, 0.1);
|
||||
}
|
||||
.ep-gh-cancel-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ep-gh-action-placeholder {
|
||||
color: var(--text-tertiary, #bfbfbf);
|
||||
}
|
||||
|
||||
/* ═══ 生成进度 - 片段状态列表 ═══ */
|
||||
|
||||
.ep-gen-clip-list {
|
||||
margin-top: 16px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ep-gen-clip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ep-gen-clip-item + .ep-gen-clip-item {
|
||||
border-top: 1px solid var(--border-color-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.ep-gen-clip-index {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-gen-clip-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.ep-gen-clip-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-completed {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-processing {
|
||||
color: #3b82f6;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.ep-gen-clip-status.status-pending,
|
||||
.ep-gen-clip-status.status-queued {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ═══ 右侧栏 Tab ═══ */
|
||||
.ep-right-panel {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
|
||||
.ep-right-tabs {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.ep-right-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.ep-right-tab:hover {
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.ep-right-tab.active {
|
||||
color: var(--primary-color, #3b82f6);
|
||||
border-bottom-color: var(--primary-color, #3b82f6);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ep-right-tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ═══ 编辑器内片段列表 ═══ */
|
||||
.ep-clip-list {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ep-clip-list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.ep-clip-list-count {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
.ep-clip-list-count b {
|
||||
color: var(--text-primary, #111827);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ep-clip-list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.ep-clip-list-item {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-list-item:hover {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ep-clip-list-item.selected {
|
||||
border-color: var(--primary-color, #3b82f6);
|
||||
background: rgba(59, 130, 246, 0.04);
|
||||
}
|
||||
|
||||
.ep-clip-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ep-clip-item-index {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-type-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ep-clip-item-duration {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #111827);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-clip-item-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.ep-clip-item-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: flex-end;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ep-clip-list-item:hover .ep-clip-item-actions,
|
||||
.ep-clip-list-item.selected .ep-clip-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ep-clip-item-btn {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
padding: 0 !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.ep-clip-list-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -37,13 +37,6 @@ import {
|
||||
generateEditPlan,
|
||||
getGenerationStatus,
|
||||
getGenerationTaskResults,
|
||||
cancelGeneration,
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
batchDeleteEditPlanClips,
|
||||
type EditPlanClip,
|
||||
type CreateEditPlanClipRequest,
|
||||
type ClipStatusItem,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type {
|
||||
@@ -86,11 +79,10 @@ import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
import TimelinePanel from "./components/TimelinePanel";
|
||||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||||
import EditorClipList from "./components/EditorClipList";
|
||||
import BgmSelector from "./components/BgmSelector";
|
||||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./types/subtitle";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle";
|
||||
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
|
||||
import TransitionSelector from "./components/TransitionSelector";
|
||||
import SpeedPanel from "./components/SpeedPanel";
|
||||
import TtsPanel from "./components/TtsPanel";
|
||||
@@ -244,10 +236,6 @@ const EditingPlanner: React.FC = () => {
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
});
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">(
|
||||
"properties",
|
||||
);
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -282,9 +270,6 @@ const EditingPlanner: React.FC = () => {
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [genError, setGenError] = useState<string | null>(null);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [genCancelled, setGenCancelled] = useState(false);
|
||||
const [genClipStatuses, setGenClipStatuses] = useState<ClipStatusItem[]>([]);
|
||||
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
/* ── 播放 ── */
|
||||
@@ -423,16 +408,8 @@ const EditingPlanner: React.FC = () => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!loadedPlanId) return;
|
||||
|
||||
// 并行加载计划基本信息 + 片段列表
|
||||
Promise.all([
|
||||
getEditPlan(loadedPlanId),
|
||||
getEditPlanClips(loadedPlanId, { limit: 500 }).catch(() => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
})),
|
||||
])
|
||||
.then(([plan, clipsRes]) => {
|
||||
getEditPlan(loadedPlanId)
|
||||
.then((plan) => {
|
||||
// 设置关联的模板(触发模板加载 effect)
|
||||
setLoadedTemplateId(plan.template_id);
|
||||
|
||||
@@ -471,54 +448,9 @@ const EditingPlanner: React.FC = () => {
|
||||
music_id: cfg.bgm_config!.music_id || "",
|
||||
}));
|
||||
}
|
||||
// 还原封面配置
|
||||
if (cfg.cover_config) {
|
||||
setCoverSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: cfg.cover_config!.enabled ?? prev.enabled,
|
||||
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
|
||||
frame_time: cfg.cover_config!.frame_time ?? prev.frame_time,
|
||||
upload_url: cfg.cover_config!.upload_url || prev.upload_url,
|
||||
thumbnail_url:
|
||||
cfg.cover_config!.thumbnail_url || prev.thumbnail_url,
|
||||
ai_suggested_time:
|
||||
cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
|
||||
}));
|
||||
}
|
||||
|
||||
// 还原片段:优先从后端 clips 表,其次从 config.segments 兜底
|
||||
const backendClips = clipsRes?.items || [];
|
||||
if (backendClips.length > 0) {
|
||||
// 从后端 clips 表还原
|
||||
const sorted = [...backendClips].sort((a, b) => a.order - b.order);
|
||||
const mapped: ClipData[] = sorted.map((clip) => ({
|
||||
id: clip.id,
|
||||
template_segment_id:
|
||||
(clip.config?.template_segment_id as string) || "",
|
||||
type: (clip.clip_type === "voiceover"
|
||||
? "voice"
|
||||
: "pip") as ClipType,
|
||||
duration: clip.duration || 3,
|
||||
startOffset: 0,
|
||||
script_text: clip.text_content || "",
|
||||
order: clip.order,
|
||||
media_asset_id: clip.asset_id || undefined,
|
||||
transition:
|
||||
clip.transition_effect && clip.transition_effect !== "none"
|
||||
? {
|
||||
type: clip.transition_effect as TransitionEffect["type"],
|
||||
duration: clip.transition_duration || 0.3,
|
||||
}
|
||||
: undefined,
|
||||
speed: clip.playback_speed
|
||||
? { rate: clip.playback_speed, pitchCorrection: true }
|
||||
: undefined,
|
||||
tts_config: (clip.config?.tts_config as TtsConfig) || undefined,
|
||||
trim_config: (clip.config?.trim_config as TrimConfig) || undefined,
|
||||
}));
|
||||
setTimeout(() => resetClips(mapped), 100);
|
||||
} else if (cfg.segments && cfg.segments.length > 0) {
|
||||
// 兜底:从 config.segments 还原(老数据兼容)
|
||||
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
|
||||
if (cfg.segments && cfg.segments.length > 0) {
|
||||
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
|
||||
id: `seg-${idx}`,
|
||||
template_segment_id: `seg-${idx}`,
|
||||
@@ -626,14 +558,11 @@ const EditingPlanner: React.FC = () => {
|
||||
if (selectedClipId === clipId) setSelectedClipId(null);
|
||||
};
|
||||
|
||||
const handleClipUpdate = useCallback(
|
||||
(clipId: string, data: Partial<ClipData>) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
const handleClipUpdate = (clipId: string, data: Partial<ClipData>) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加片段(不绑定任何素材)
|
||||
@@ -741,7 +670,7 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)
|
||||
},
|
||||
[transitionTargetClipId, handleClipUpdate],
|
||||
[transitionTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开转场选择器 ── */
|
||||
@@ -757,7 +686,7 @@ const EditingPlanner: React.FC = () => {
|
||||
handleClipUpdate(speedTargetClipId, { speed: config });
|
||||
}
|
||||
},
|
||||
[speedTargetClipId, handleClipUpdate],
|
||||
[speedTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开调速面板 ── */
|
||||
@@ -772,7 +701,7 @@ const EditingPlanner: React.FC = () => {
|
||||
if (!ttsTargetClipId) return;
|
||||
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
|
||||
},
|
||||
[ttsTargetClipId, handleClipUpdate],
|
||||
[ttsTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开 TTS 配音面板 ── */
|
||||
@@ -782,13 +711,10 @@ const EditingPlanner: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
/* ── 调速应用到所有片段 ── */
|
||||
const handleApplySpeedAll = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
}, []);
|
||||
|
||||
/* ── 水印配置变更 ── */
|
||||
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
|
||||
@@ -1002,62 +928,10 @@ const EditingPlanner: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将本地编辑的片段同步到后端 clips 表
|
||||
* 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用)
|
||||
*/
|
||||
const syncClipsToBackend = async (planId: string): Promise<void> => {
|
||||
if (clips.length === 0) return;
|
||||
|
||||
// 1. 获取后端现有片段 ID
|
||||
try {
|
||||
const existing = await getEditPlanClips(planId, { limit: 500 });
|
||||
if (existing.items.length > 0) {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
existing.items.map((c) => c.id),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[获取后端片段失败,跳过删除]", err);
|
||||
}
|
||||
|
||||
// 2. 批量创建新片段(并发 3 个)
|
||||
const clipDataList: CreateEditPlanClipRequest[] = clips.map((c, i) => ({
|
||||
clip_type: c.type === "voice" ? "voiceover" : "main",
|
||||
order: i,
|
||||
asset_id: c.media_asset_id || "",
|
||||
text_content: c.script_text || "",
|
||||
start_time: 0,
|
||||
duration: c.duration,
|
||||
transition_effect: c.transition?.type || "cut",
|
||||
transition_duration: c.transition?.duration || 0,
|
||||
playback_speed: c.speed?.rate || 1.0,
|
||||
config: {
|
||||
tts_config: c.tts_config || null,
|
||||
trim_config: c.trim_config || null,
|
||||
template_segment_id: c.template_segment_id || null,
|
||||
},
|
||||
}));
|
||||
|
||||
// 并发控制:最多同时 3 个请求
|
||||
const results: EditPlanClip[] = [];
|
||||
const concurrency = 3;
|
||||
for (let i = 0; i < clipDataList.length; i += concurrency) {
|
||||
const batch = clipDataList.slice(i, i + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((data) => createEditPlanClip(planId, data)),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
console.log(`[片段同步] 创建了 ${results.length} 个片段`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 剪辑计划生成
|
||||
* 1. 有 planId → 更新计划配置 + 同步片段 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 同步片段 + 触发生成
|
||||
* 1. 有 planId → 更新计划配置 + 触发生成
|
||||
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
|
||||
* 3. 触发生成后轮询状态,完成后获取视频结果
|
||||
*/
|
||||
const handleGoToGenerate = async () => {
|
||||
@@ -1075,7 +949,6 @@ const EditingPlanner: React.FC = () => {
|
||||
setGeneratedVideos([]);
|
||||
setGenError(null);
|
||||
setGenProgress(0);
|
||||
setGenCancelled(false);
|
||||
|
||||
try {
|
||||
const config = buildPlanConfig();
|
||||
@@ -1113,14 +986,6 @@ const EditingPlanner: React.FC = () => {
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
}
|
||||
|
||||
// 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读)
|
||||
try {
|
||||
await syncClipsToBackend(planId);
|
||||
} catch (syncErr) {
|
||||
console.warn("[片段同步失败]", syncErr);
|
||||
// 同步失败不阻塞生成,后端有模板兜底
|
||||
}
|
||||
|
||||
// 触发生成
|
||||
const genRes = await generateEditPlan(planId);
|
||||
setGenTotalClips(genRes.clip_count);
|
||||
@@ -1148,7 +1013,6 @@ const EditingPlanner: React.FC = () => {
|
||||
).length;
|
||||
setGenDoneClips(done);
|
||||
setGenTotalClips(total);
|
||||
setGenClipStatuses(status.clips || []);
|
||||
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
|
||||
|
||||
if (status.plan_status === "completed") {
|
||||
@@ -1177,14 +1041,6 @@ const EditingPlanner: React.FC = () => {
|
||||
return; // 停止轮询
|
||||
}
|
||||
|
||||
if (status.plan_status === "cancelled") {
|
||||
setGenerating(false);
|
||||
setGenError("生成已取消");
|
||||
setGenCancelled(true);
|
||||
message.info("生成任务已取消");
|
||||
return; // 停止轮询
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
genTimerRef.current = setTimeout(poll, 2000);
|
||||
} catch (err) {
|
||||
@@ -1204,33 +1060,6 @@ const EditingPlanner: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 取消生成任务 */
|
||||
const handleCancelGeneration = async () => {
|
||||
const targetId = loadedPlanId;
|
||||
if (!targetId) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: "确认取消生成",
|
||||
content: "取消后已开始的生成任务,已生成的片段不会保留。确定要取消吗?",
|
||||
okText: "确认取消",
|
||||
cancelText: "继续生成",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
setCancelling(true);
|
||||
await cancelGeneration(targetId);
|
||||
message.success("已提交取消请求");
|
||||
// 轮询会继续运行直到检测到 cancelled 状态
|
||||
} catch (err) {
|
||||
console.error("[取消失败]", err);
|
||||
message.error("取消失败,请稍后重试");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
const targetId = loadedPlanId || loadedTemplateId;
|
||||
@@ -1375,87 +1204,43 @@ const EditingPlanner: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 右栏 260px:设置面板 */}
|
||||
<div className="ep-right-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-right-tabs">
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "properties" ? " active" : ""}`}
|
||||
onClick={() => setRightTab("properties")}
|
||||
>
|
||||
属性
|
||||
</button>
|
||||
<button
|
||||
className={`ep-right-tab${rightTab === "clips" ? " active" : ""}`}
|
||||
onClick={() => setRightTab("clips")}
|
||||
>
|
||||
片段
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 属性 Tab */}
|
||||
{rightTab === "properties" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 片段 Tab */}
|
||||
{rightTab === "clips" && (
|
||||
<div className="ep-right-tab-content">
|
||||
<EditorClipList
|
||||
clips={clips}
|
||||
selectedClipId={selectedClipId}
|
||||
onSelect={handleClipSelect}
|
||||
onMoveUp={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId);
|
||||
if (idx > 0) handleClipReorder(idx, idx - 1);
|
||||
}}
|
||||
onMoveDown={(clipId) => {
|
||||
const idx = clips.findIndex((c) => c.id === clipId);
|
||||
if (idx < clips.length - 1) handleClipReorder(idx, idx + 1);
|
||||
}}
|
||||
onRemove={handleClipRemove}
|
||||
onAdd={() => handleAddClip("pip", 3)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ClipPropertiesPanel
|
||||
selectedClip={selectedClip}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
bgmSettings={bgmSettings}
|
||||
clipsCount={clips.length}
|
||||
totalDuration={totalDuration}
|
||||
currentMode={currentMode}
|
||||
onTitleSettingsChange={(partial) =>
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ 第4行:底栏 40px ═══ */}
|
||||
@@ -1499,42 +1284,12 @@ const EditingPlanner: React.FC = () => {
|
||||
loading={genHistoryLoading}
|
||||
history={genHistory}
|
||||
onClose={() => setGenHistoryOpen(false)}
|
||||
onCancel={async () => {
|
||||
Modal.confirm({
|
||||
title: "确认取消生成",
|
||||
content: "确定要取消这个生成任务吗?此操作不可恢复。",
|
||||
okText: "确认取消",
|
||||
cancelText: "再等等",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
if (!loadedPlanId) return;
|
||||
try {
|
||||
await cancelGeneration(loadedPlanId);
|
||||
message.success("已提交取消请求");
|
||||
// 刷新历史列表
|
||||
handleViewGenHistory();
|
||||
} catch (err) {
|
||||
console.error("[取消失败]", err);
|
||||
message.error("取消失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
cancelLoading={cancelling}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成进度弹窗 ═══ */}
|
||||
<Modal
|
||||
title={
|
||||
genError
|
||||
? "生成失败"
|
||||
: genCancelled
|
||||
? "已取消生成"
|
||||
: generated
|
||||
? "生成完成"
|
||||
: "正在生成视频"
|
||||
}
|
||||
open={generating || generated || !!genError || genCancelled}
|
||||
title={genError ? "生成失败" : generated ? "生成完成" : "正在生成视频"}
|
||||
open={generating || generated || !!genError}
|
||||
footer={
|
||||
generated
|
||||
? [
|
||||
@@ -1543,7 +1298,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onClick={() => {
|
||||
setGenerated(false);
|
||||
setGenerating(false);
|
||||
setGenError(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
@@ -1570,43 +1324,7 @@ const EditingPlanner: React.FC = () => {
|
||||
</Button>
|
||||
),
|
||||
]
|
||||
: generating
|
||||
? [
|
||||
<Button
|
||||
key="cancel"
|
||||
danger
|
||||
loading={cancelling}
|
||||
onClick={handleCancelGeneration}
|
||||
>
|
||||
取消生成
|
||||
</Button>,
|
||||
]
|
||||
: genCancelled
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setGenCancelled(false);
|
||||
setGenerating(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: genError
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setGenError(null);
|
||||
setGenerating(false);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
: null
|
||||
}
|
||||
closable={!generating}
|
||||
maskClosable={false}
|
||||
@@ -1618,47 +1336,8 @@ const EditingPlanner: React.FC = () => {
|
||||
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
|
||||
已处理 {genDoneClips}/{genTotalClips} 个片段
|
||||
</p>
|
||||
{genClipStatuses.length > 0 && (
|
||||
<div className="ep-gen-clip-list">
|
||||
{genClipStatuses.map((clip, index) => (
|
||||
<div key={clip.clip_id || index} className="ep-gen-clip-item">
|
||||
<span className="ep-gen-clip-index">{index + 1}</span>
|
||||
<span className="ep-gen-clip-name">
|
||||
{clip.text_content
|
||||
? clip.text_content.slice(0, 20)
|
||||
: clip.clip_type || `片段${index + 1}`}
|
||||
</span>
|
||||
<span
|
||||
className={`ep-gen-clip-status status-${clip.status}`}
|
||||
>
|
||||
{clip.status === "completed"
|
||||
? "✓ 完成"
|
||||
: clip.status === "failed"
|
||||
? "✗ 失败"
|
||||
: clip.status === "processing"
|
||||
? "⟳ 处理中"
|
||||
: "⏳ 等待中"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
请耐心等待,生成过程中请勿关闭页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{genCancelled && (
|
||||
<div style={{ padding: "24px 0", textAlign: "center" }}>
|
||||
<p>生成已取消</p>
|
||||
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
你可以继续编辑后重新生成
|
||||
请耐心等待,生成过程中请勿关闭页面
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* 编辑器右侧栏 — 片段列表 Tab
|
||||
* 紧凑版片段管理:选中、上下移动、删除、添加
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button, Tooltip, Empty } from "antd";
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ScissorOutlined,
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
|
||||
interface EditorClipListProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
onSelect: (clipId: string) => void;
|
||||
onMoveUp: (clipId: string) => void;
|
||||
onMoveDown: (clipId: string) => void;
|
||||
onRemove: (clipId: string) => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
video: <VideoCameraOutlined />,
|
||||
image: <PictureOutlined />,
|
||||
voice: <SoundOutlined />,
|
||||
pip: <ScissorOutlined />,
|
||||
};
|
||||
|
||||
const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = (sec % 60).toFixed(0);
|
||||
return `${m}m${s.padStart(2, "0")}s`;
|
||||
};
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
onSelect,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemove,
|
||||
onAdd,
|
||||
}) => {
|
||||
if (clips.length === 0) {
|
||||
return (
|
||||
<div className="ep-clip-list-empty">
|
||||
<Empty
|
||||
description="暂无片段"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
style={{ margin: "40px 0" }}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} block onClick={onAdd}>
|
||||
添加片段
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ep-clip-list">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="ep-clip-list-toolbar">
|
||||
<span className="ep-clip-list-count">
|
||||
共 <b>{clips.length}</b> 个片段
|
||||
</span>
|
||||
<Tooltip title="添加片段">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAdd}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 片段列表 */}
|
||||
<div className="ep-clip-list-scroll">
|
||||
{clips.map((clip, index) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-list-item${
|
||||
selectedClipId === clip.id ? " selected" : ""
|
||||
}`}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
>
|
||||
{/* 序号 + 类型图标 */}
|
||||
<div className="ep-clip-item-head">
|
||||
<span className="ep-clip-item-index">{index + 1}</span>
|
||||
<span className="ep-clip-item-type">
|
||||
{clipTypeIcon[clip.type] || <ScissorOutlined />}
|
||||
<span className="ep-clip-item-type-label">
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ep-clip-item-duration">
|
||||
{formatDuration(clip.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
{clip.script_text && (
|
||||
<div className="ep-clip-item-text">
|
||||
{clip.script_text.slice(0, 40)}
|
||||
{clip.script_text.length > 40 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
className="ep-clip-item-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title="上移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => onMoveUp(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="下移">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownOutlined />}
|
||||
disabled={index === clips.length - 1}
|
||||
onClick={() => onMoveDown(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => onRemove(clip.id)}
|
||||
className="ep-clip-item-btn"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorClipList;
|
||||
Executable → Regular
-22
@@ -12,8 +12,6 @@ interface GenerationHistoryModalProps {
|
||||
loading: boolean;
|
||||
history: EditPlanGeneration[];
|
||||
onClose: () => void;
|
||||
onCancel?: (taskId: string) => void;
|
||||
cancelLoading?: boolean;
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
@@ -21,8 +19,6 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
loading,
|
||||
history,
|
||||
onClose,
|
||||
onCancel,
|
||||
cancelLoading,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
|
||||
@@ -61,14 +57,11 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
<th className="ep-gh-th">状态</th>
|
||||
<th className="ep-gh-th">创建时间</th>
|
||||
<th className="ep-gh-th">更新时间</th>
|
||||
{onCancel && <th className="ep-gh-th">操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`;
|
||||
const canCancel =
|
||||
gen.status === "rendering" || gen.status === "editing";
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
@@ -89,21 +82,6 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
? new Date(gen.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
</td>
|
||||
{onCancel && (
|
||||
<td className="ep-gh-td ep-gh-td-action">
|
||||
{canCancel ? (
|
||||
<button
|
||||
className="ep-gh-cancel-btn"
|
||||
onClick={() => onCancel(gen.id)}
|
||||
disabled={cancelLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<span className="ep-gh-action-placeholder">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
Executable → Regular
+40
-1
@@ -5,7 +5,46 @@
|
||||
import React from "react";
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd";
|
||||
import type { Color } from "antd/es/color-picker";
|
||||
import type { SubtitleStyleConfig } from "../types/subtitle";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
|
||||
/* ──────────── 选项常量 ──────────── */
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* 剪辑计划片段管理 Hook
|
||||
* 对接后端 PR#389 片段 CRUD API,替代原来的 config.segments 模式
|
||||
*
|
||||
* 功能:
|
||||
* - 加载/刷新片段列表
|
||||
* - 单个增删改查
|
||||
* - 批量删除
|
||||
* - 拖拽重排序
|
||||
* - 从素材批量导入
|
||||
* - 乐观更新 + 撤销重做
|
||||
*/
|
||||
import { useCallback, useState } from "react";
|
||||
import { message } from "antd";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import type {
|
||||
EditPlanClip,
|
||||
CreateEditPlanClipRequest,
|
||||
UpdateEditPlanClipRequest,
|
||||
ClipReorderItem,
|
||||
} from "@/api/editPlans";
|
||||
import {
|
||||
getEditPlanClips,
|
||||
createEditPlanClip,
|
||||
updateEditPlanClip,
|
||||
deleteEditPlanClip,
|
||||
reorderEditPlanClips,
|
||||
batchDeleteEditPlanClips,
|
||||
createClipsFromAssets,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./useUndoRedo";
|
||||
|
||||
const QUERY_KEY = "editPlanClips";
|
||||
|
||||
export function useEditPlanClips(planId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 片段列表查询 ── */
|
||||
const {
|
||||
data: clipListData,
|
||||
isLoading: clipsLoading,
|
||||
refetch: refetchClips,
|
||||
} = useQuery({
|
||||
queryKey: [QUERY_KEY, planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const clips: EditPlanClip[] = clipListData?.items ?? [];
|
||||
const clipsTotal = clipListData?.total ?? 0;
|
||||
|
||||
/* ── 选中片段 ── */
|
||||
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null;
|
||||
|
||||
/* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */
|
||||
const {
|
||||
state: localClips,
|
||||
set: setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
reset: resetLocalClips,
|
||||
} = useUndoRedo<EditPlanClip[]>([]);
|
||||
|
||||
// 当服务端数据变化时同步本地
|
||||
// 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作
|
||||
|
||||
/* ── 创建片段 ── */
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateEditPlanClipRequest) =>
|
||||
createEditPlanClip(planId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success("片段已添加");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("添加片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const addClip = useCallback(
|
||||
(data: Omit<CreateEditPlanClipRequest, "order"> & { order?: number }) => {
|
||||
if (!planId) return;
|
||||
const order = data.order ?? clips.length;
|
||||
createMutation.mutate({ ...data, order });
|
||||
},
|
||||
[planId, clips.length, createMutation],
|
||||
);
|
||||
|
||||
/* ── 更新片段 ── */
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
clipId,
|
||||
data,
|
||||
}: {
|
||||
clipId: string;
|
||||
data: UpdateEditPlanClipRequest;
|
||||
}) => updateEditPlanClip(planId!, clipId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const updateClip = useCallback(
|
||||
(clipId: string, data: UpdateEditPlanClipRequest) => {
|
||||
if (!planId) return;
|
||||
updateMutation.mutate({ clipId, data });
|
||||
},
|
||||
[planId, updateMutation],
|
||||
);
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success("片段已删除");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除片段失败");
|
||||
},
|
||||
});
|
||||
|
||||
const removeClip = useCallback(
|
||||
(clipId: string) => {
|
||||
if (!planId) return;
|
||||
if (selectedClipId === clipId) {
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
deleteMutation.mutate(clipId);
|
||||
},
|
||||
[planId, selectedClipId, deleteMutation],
|
||||
);
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (clipIds: string[]) =>
|
||||
batchDeleteEditPlanClips(planId!, clipIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success(`已删除 ${res.deleted_count} 个片段`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("批量删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
const batchRemoveClips = useCallback(
|
||||
(clipIds: string[]) => {
|
||||
if (!planId || clipIds.length === 0) return;
|
||||
if (selectedClipId && clipIds.includes(selectedClipId)) {
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
batchDeleteMutation.mutate(clipIds);
|
||||
},
|
||||
[planId, selectedClipId, batchDeleteMutation],
|
||||
);
|
||||
|
||||
/* ── 重排序(拖拽结束后一次性提交) ── */
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (items: ClipReorderItem[]) =>
|
||||
reorderEditPlanClips(planId!, items),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("排序失败");
|
||||
// 失败后刷新回服务端状态
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
},
|
||||
});
|
||||
|
||||
const reorderClips = useCallback(
|
||||
(items: ClipReorderItem[]) => {
|
||||
if (!planId || items.length === 0) return;
|
||||
reorderMutation.mutate(items);
|
||||
},
|
||||
[planId, reorderMutation],
|
||||
);
|
||||
|
||||
/* ── 从素材批量导入 ── */
|
||||
const importFromAssetsMutation = useMutation({
|
||||
mutationFn: (assetIds: string[]) =>
|
||||
createClipsFromAssets(planId!, assetIds),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] });
|
||||
message.success(`已导入 ${res.created_count} 个素材片段`);
|
||||
},
|
||||
onError: () => {
|
||||
message.error("导入素材失败");
|
||||
},
|
||||
});
|
||||
|
||||
const importFromAssets = useCallback(
|
||||
(assetIds: string[]) => {
|
||||
if (!planId || assetIds.length === 0) return;
|
||||
importFromAssetsMutation.mutate(assetIds);
|
||||
},
|
||||
[planId, importFromAssetsMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
// 数据
|
||||
clips,
|
||||
clipsTotal,
|
||||
clipsLoading,
|
||||
selectedClipId,
|
||||
selectedClip,
|
||||
// 选中
|
||||
setSelectedClipId,
|
||||
// 操作
|
||||
addClip,
|
||||
updateClip,
|
||||
removeClip,
|
||||
batchRemoveClips,
|
||||
reorderClips,
|
||||
importFromAssets,
|
||||
refetchClips,
|
||||
// 状态
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isReordering: reorderMutation.isPending,
|
||||
isImporting: importFromAssetsMutation.isPending,
|
||||
// 本地撤销重做(供拖拽等场景使用)
|
||||
localClips,
|
||||
setLocalClips,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
resetLocalClips,
|
||||
};
|
||||
}
|
||||
|
||||
export default useEditPlanClips;
|
||||
Executable → Regular
-2
@@ -512,8 +512,6 @@ export interface ClipData {
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
duration: number; // 时长(秒)
|
||||
startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒)
|
||||
/** 素材库素材 ID(main/pip 类型片段使用) */
|
||||
media_asset_id?: string;
|
||||
// 保留兼容字段(后端序列化需要)
|
||||
template_segment_id?: string;
|
||||
script_text?: string;
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* 字幕样式相关类型与常量
|
||||
* 单独抽离以满足 react-refresh/only-export-components 规则
|
||||
*/
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
/* ──────────── 默认值 ──────────── */
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
Executable → Regular
-7
@@ -163,13 +163,6 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans/:planId/clips",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/PlanClipsManager").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
@@ -491,28 +491,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
|
||||
gen_task_repo.update(gen_task)
|
||||
|
||||
# 4. 根据引擎选择渲染方式
|
||||
# 取消检查:素材下载完后,确认任务没有被用户取消
|
||||
if generation_task_id:
|
||||
current_task = gen_task_repo.get(generation_task_id)
|
||||
if current_task:
|
||||
task_status = (
|
||||
current_task.status.value
|
||||
if hasattr(current_task.status, "value")
|
||||
else str(current_task.status)
|
||||
)
|
||||
if task_status == "cancelled":
|
||||
logger.info("任务已被取消,中止渲染: plan_id=%s task_id=%s", plan_id, generation_task_id)
|
||||
# 计划回到 editing 状态,用户可以继续编辑
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
|
||||
if plan.status.value == "rendering":
|
||||
try:
|
||||
plan.resume_editing()
|
||||
plan_repo.update(plan)
|
||||
except ValueError:
|
||||
pass
|
||||
return {"status": "cancelled", "plan_id": plan_id, "message": "任务已取消"}
|
||||
|
||||
if engine == "unified":
|
||||
result = _render_with_unified(
|
||||
plan=plan,
|
||||
|
||||
@@ -3,27 +3,12 @@ FROM git.xiaoxiajianji.com/xiaoxia/base/node:20 AS builder
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=https://saas-api.xiaoxiajianji.com
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# 先拷依赖清单(缓存友好:依赖不变时直接命中缓存层)
|
||||
COPY apps/web/package.json apps/web/package-lock.json ./apps/web/
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
# 安装依赖:用BuildKit cache mount缓存npm下载和node_modules
|
||||
# sharing=locked 防止并发构建竞争写缓存
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
|
||||
--mount=type=cache,target=/app/apps/web/node_modules,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com \
|
||||
RUN npm config set registry https://registry.npmmirror.com \
|
||||
&& npm ci
|
||||
|
||||
# 再拷源码
|
||||
COPY apps/web/ ./
|
||||
|
||||
# 构建:TS增量编译 + Vite构建,tsbuildinfo用cache mount持久化
|
||||
RUN --mount=type=cache,target=/app/apps/web/node_modules,sharing=locked \
|
||||
--mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& npx tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& npx vite build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
|
||||
@@ -140,42 +140,6 @@ class BGMConfig(BaseModel):
|
||||
sidechain_threshold: float = Field(default=-25.0, ge=-60.0, le=0.0, description="人声闪避触发阈值(dB)")
|
||||
|
||||
|
||||
class ExportConfig(BaseModel):
|
||||
"""导出配置
|
||||
|
||||
视频输出参数设置。
|
||||
"""
|
||||
|
||||
resolution: str = Field(default="1080x1920", description="输出分辨率,如 1080x1920 / 720x1280 / 2160x3840")
|
||||
fps: int = Field(default=30, ge=15, le=60, description="输出帧率 15~60")
|
||||
video_bitrate: int = Field(default=8000, ge=1000, le=20000, description="视频码率(kbps)")
|
||||
audio_bitrate: int = Field(default=128, ge=64, le=320, description="音频码率(kbps)")
|
||||
format: str = Field(default="mp4", description="输出格式:mp4 / mov")
|
||||
quality_preset: str = Field(
|
||||
default="balanced",
|
||||
description="质量预设:ultra_fast / fast / balanced / high / best",
|
||||
)
|
||||
watermark_enabled: bool = Field(default=False, description="是否启用水印")
|
||||
watermark_text: str = Field(default="", description="水印文字")
|
||||
|
||||
|
||||
class FilterConfig(BaseModel):
|
||||
"""滤镜调色配置
|
||||
|
||||
支持全局滤镜和按片段覆盖。
|
||||
强度 0-100,0 表示不应用,100 表示全量应用预设。
|
||||
"""
|
||||
|
||||
enabled: bool = Field(default=False, description="是否启用滤镜")
|
||||
preset_id: str = Field(default="filter_none", description="滤镜预设 ID")
|
||||
intensity: int = Field(default=100, ge=0, le=100, description="滤镜强度 0-100")
|
||||
# 自定义微调参数(在预设基础上叠加调整)
|
||||
brightness: float = Field(default=0.0, ge=-1.0, le=1.0, description="亮度微调")
|
||||
contrast: float = Field(default=1.0, ge=0.0, le=2.0, description="对比度微调(倍率)")
|
||||
saturation: float = Field(default=1.0, ge=0.0, le=3.0, description="饱和度微调(倍率)")
|
||||
warmth: float = Field(default=0.0, ge=-1.0, le=1.0, description="色温微调(正=暖,负=冷)")
|
||||
|
||||
|
||||
# ── 完整 config 模型 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -191,8 +155,6 @@ class EditPlanConfigSchema(BaseModel):
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="标题配置")
|
||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕配置")
|
||||
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 配置")
|
||||
export: ExportConfig = Field(default_factory=ExportConfig, description="导出配置")
|
||||
filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜调色配置")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式")
|
||||
|
||||
|
||||
@@ -207,8 +169,6 @@ class EditTemplateConfigSchema(BaseModel):
|
||||
title: TitleConfig = Field(default_factory=TitleConfig, description="标题默认配置")
|
||||
subtitle: SubtitleConfig = Field(default_factory=SubtitleConfig, description="字幕默认配置")
|
||||
bgm: BGMConfig = Field(default_factory=BGMConfig, description="BGM 默认配置")
|
||||
export: ExportConfig = Field(default_factory=ExportConfig, description="导出默认配置")
|
||||
filter: FilterConfig = Field(default_factory=FilterConfig, description="滤镜默认配置")
|
||||
editing_mode: str = Field(default="one_take", description="剪辑模式")
|
||||
transition_enabled: bool = Field(default=True, description="是否启用转场")
|
||||
|
||||
@@ -258,25 +218,6 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
|
||||
"sidechain_release": 0.5,
|
||||
"sidechain_threshold": -25.0,
|
||||
},
|
||||
"export": {
|
||||
"resolution": "1080x1920",
|
||||
"fps": 30,
|
||||
"video_bitrate": 8000,
|
||||
"audio_bitrate": 128,
|
||||
"format": "mp4",
|
||||
"quality_preset": "balanced",
|
||||
"watermark_enabled": False,
|
||||
"watermark_text": "",
|
||||
},
|
||||
"filter": {
|
||||
"enabled": False,
|
||||
"preset_id": "filter_none",
|
||||
"intensity": 100,
|
||||
"brightness": 0.0,
|
||||
"contrast": 1.0,
|
||||
"saturation": 1.0,
|
||||
"warmth": 0.0,
|
||||
},
|
||||
"editing_mode": "one_take",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
"""滤镜预设库 — 视频调色滤镜预设清单.
|
||||
|
||||
每个滤镜预设对应一组 FFmpeg 滤镜参数,用于视频调色。
|
||||
所有参数均可调整强度(0-100),0表示原图,100表示全量应用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilterPreset:
|
||||
"""滤镜预设条目"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
category: str # 分类:basic / cinematic / vintage / bw / style
|
||||
description: str = ""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
# FFmpeg eq 滤镜参数(基准值,实际应用时乘以强度系数)
|
||||
brightness: float = 0.0 # -1.0 ~ 1.0
|
||||
contrast: float = 1.0 # 0.0 ~ 2.0,1.0为原值
|
||||
saturation: float = 1.0 # 0.0 ~ 3.0,1.0为原值
|
||||
gamma: float = 1.0 # 0.1 ~ 10.0,1.0为原值
|
||||
gamma_r: float = 1.0 # 红通道伽马
|
||||
gamma_g: float = 1.0 # 绿通道伽马
|
||||
gamma_b: float = 1.0 # 蓝通道伽马
|
||||
hue: float = 0.0 # 色相偏移 -180 ~ 180度
|
||||
# 可选的颜色查找表 LUT(后续扩展)
|
||||
lut_url: str = ""
|
||||
|
||||
|
||||
# ── 预设库清单 ────────────────────────────────────────────────────────────────
|
||||
|
||||
FILTER_PRESET_LIBRARY: List[FilterPreset] = [
|
||||
# ── 基础 basic ─────────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_none",
|
||||
name="原图",
|
||||
category="basic",
|
||||
description="不应用任何滤镜,保持原始画面",
|
||||
tags=["原图", "无"],
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_brighten",
|
||||
name="明亮",
|
||||
category="basic",
|
||||
description="提升画面亮度,适合偏暗的素材",
|
||||
tags=["提亮", "基础"],
|
||||
brightness=0.12,
|
||||
contrast=1.05,
|
||||
saturation=1.05,
|
||||
gamma=1.1,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_warm",
|
||||
name="暖色",
|
||||
category="basic",
|
||||
description="暖色调,增加温暖感",
|
||||
tags=["暖色", "温馨"],
|
||||
gamma_r=1.1,
|
||||
gamma_g=1.02,
|
||||
gamma_b=0.9,
|
||||
saturation=1.05,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_cool",
|
||||
name="冷色",
|
||||
category="basic",
|
||||
description="冷色调,清凉干净",
|
||||
tags=["冷色", "清新"],
|
||||
gamma_r=0.9,
|
||||
gamma_g=1.0,
|
||||
gamma_b=1.1,
|
||||
saturation=1.02,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_contrast",
|
||||
name="高对比",
|
||||
category="basic",
|
||||
description="增强对比度,画面更通透",
|
||||
tags=["对比", "通透"],
|
||||
contrast=1.25,
|
||||
saturation=1.1,
|
||||
gamma=0.95,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_saturate",
|
||||
name="鲜艳",
|
||||
category="basic",
|
||||
description="提升饱和度,色彩更浓郁",
|
||||
tags=["鲜艳", "浓郁"],
|
||||
saturation=1.4,
|
||||
contrast=1.05,
|
||||
),
|
||||
# ── 电影感 cinematic ─────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_cinematic",
|
||||
name="电影感",
|
||||
category="cinematic",
|
||||
description="经典电影色调,青橙对比",
|
||||
tags=["电影", "青橙", "质感"],
|
||||
contrast=1.2,
|
||||
saturation=0.9,
|
||||
gamma_r=1.15,
|
||||
gamma_g=0.95,
|
||||
gamma_b=0.85,
|
||||
brightness=-0.03,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_teal_orange",
|
||||
name="青橙色调",
|
||||
category="cinematic",
|
||||
description="好莱坞经典青橙对比色",
|
||||
tags=["青橙", "好莱坞", "对比"],
|
||||
contrast=1.15,
|
||||
saturation=1.1,
|
||||
gamma_r=1.2,
|
||||
gamma_g=0.9,
|
||||
gamma_b=0.8,
|
||||
),
|
||||
# ── 复古 vintage ─────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_vintage",
|
||||
name="复古",
|
||||
category="vintage",
|
||||
description="复古胶片色调,怀旧感",
|
||||
tags=["复古", "怀旧", "胶片"],
|
||||
saturation=0.8,
|
||||
contrast=0.9,
|
||||
gamma_r=1.1,
|
||||
gamma_g=1.0,
|
||||
gamma_b=0.85,
|
||||
brightness=-0.02,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_retro",
|
||||
name="怀旧",
|
||||
category="vintage",
|
||||
description="80年代复古感",
|
||||
tags=["怀旧", "80年代"],
|
||||
saturation=0.75,
|
||||
contrast=0.95,
|
||||
gamma_r=1.2,
|
||||
gamma_g=1.05,
|
||||
gamma_b=0.9,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_sepia",
|
||||
name="棕褐色",
|
||||
category="vintage",
|
||||
description="老照片棕褐色调",
|
||||
tags=["棕褐", "老照片", "复古"],
|
||||
saturation=0.3,
|
||||
gamma_r=1.3,
|
||||
gamma_g=1.1,
|
||||
gamma_b=0.8,
|
||||
contrast=0.95,
|
||||
),
|
||||
# ── 黑白 bw ──────────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_bw",
|
||||
name="黑白",
|
||||
category="bw",
|
||||
description="经典黑白",
|
||||
tags=["黑白", "经典"],
|
||||
saturation=0.0,
|
||||
contrast=1.1,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_bw_high",
|
||||
name="高对比黑白",
|
||||
category="bw",
|
||||
description="高对比度黑白,戏剧感强",
|
||||
tags=["黑白", "高对比", "戏剧"],
|
||||
saturation=0.0,
|
||||
contrast=1.4,
|
||||
gamma=0.9,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_bw_soft",
|
||||
name="柔和黑白",
|
||||
category="bw",
|
||||
description="柔和灰度过渡,细腻质感",
|
||||
tags=["黑白", "柔和", "细腻"],
|
||||
saturation=0.0,
|
||||
contrast=0.9,
|
||||
gamma=1.1,
|
||||
),
|
||||
# ── 风格化 style ────────────────────────────────────────────────
|
||||
FilterPreset(
|
||||
id="filter_japanese",
|
||||
name="日系",
|
||||
category="style",
|
||||
description="日系清新,低对比高明度",
|
||||
tags=["日系", "清新", "干净"],
|
||||
contrast=0.85,
|
||||
brightness=0.08,
|
||||
saturation=0.85,
|
||||
gamma_r=0.98,
|
||||
gamma_g=1.02,
|
||||
gamma_b=1.08,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_hk",
|
||||
name="港风",
|
||||
category="style",
|
||||
description="90年代港风,暖黄+高饱和",
|
||||
tags=["港风", "复古", "浓郁"],
|
||||
saturation=1.25,
|
||||
contrast=1.1,
|
||||
gamma_r=1.2,
|
||||
gamma_g=1.05,
|
||||
gamma_b=0.85,
|
||||
brightness=-0.02,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_cyberpunk",
|
||||
name="赛博朋克",
|
||||
category="style",
|
||||
description="赛博朋克风,青紫霓虹",
|
||||
tags=["赛博", "霓虹", "未来感"],
|
||||
contrast=1.2,
|
||||
saturation=1.3,
|
||||
gamma_r=1.3,
|
||||
gamma_g=0.7,
|
||||
gamma_b=1.2,
|
||||
brightness=-0.05,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_fresh",
|
||||
name="清新",
|
||||
category="style",
|
||||
description="清新自然,通透干净",
|
||||
tags=["清新", "自然", "通透"],
|
||||
brightness=0.05,
|
||||
saturation=1.05,
|
||||
contrast=1.05,
|
||||
gamma_g=1.03,
|
||||
gamma_b=1.05,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_dramatic",
|
||||
name="戏剧感",
|
||||
category="style",
|
||||
description="强对比暗角,戏剧化氛围",
|
||||
tags=["戏剧", "暗角", "氛围"],
|
||||
contrast=1.35,
|
||||
saturation=0.9,
|
||||
brightness=-0.08,
|
||||
gamma=0.85,
|
||||
),
|
||||
FilterPreset(
|
||||
id="filter_dreamy",
|
||||
name="梦幻",
|
||||
category="style",
|
||||
description="柔光梦幻感,低对比",
|
||||
tags=["梦幻", "柔光", "唯美"],
|
||||
contrast=0.8,
|
||||
brightness=0.1,
|
||||
saturation=1.1,
|
||||
gamma=1.15,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_filter_preset(preset_id: str) -> Optional[FilterPreset]:
|
||||
"""根据 ID 获取滤镜预设"""
|
||||
for p in FILTER_PRESET_LIBRARY:
|
||||
if p.id == preset_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def list_filter_presets(
|
||||
*,
|
||||
category: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
) -> List[FilterPreset]:
|
||||
"""筛选滤镜预设列表
|
||||
|
||||
Args:
|
||||
category: 按分类筛选
|
||||
keyword: 关键词搜索(名称/标签/描述)
|
||||
|
||||
Returns:
|
||||
筛选后的预设列表
|
||||
"""
|
||||
results = FILTER_PRESET_LIBRARY
|
||||
|
||||
if category:
|
||||
results = [p for p in results if p.category == category]
|
||||
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
results = [
|
||||
p
|
||||
for p in results
|
||||
if kw in p.name.lower() or kw in p.description.lower() or any(kw in t.lower() for t in p.tags)
|
||||
]
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def build_ffmpeg_filter(preset_id: str, intensity: int = 100) -> str:
|
||||
"""根据预设和强度生成 FFmpeg eq 滤镜字符串.
|
||||
|
||||
Args:
|
||||
preset_id: 滤镜预设 ID
|
||||
intensity: 强度 0-100,0=原图,100=全量
|
||||
|
||||
Returns:
|
||||
FFmpeg eq 滤镜参数字符串
|
||||
"""
|
||||
preset = get_filter_preset(preset_id)
|
||||
if preset is None or intensity <= 0:
|
||||
return ""
|
||||
|
||||
if intensity >= 100:
|
||||
intensity = 100
|
||||
|
||||
factor = intensity / 100.0
|
||||
|
||||
# 计算插值后的参数(向原值插值)
|
||||
brightness = preset.brightness * factor
|
||||
contrast = 1.0 + (preset.contrast - 1.0) * factor
|
||||
saturation = 1.0 + (preset.saturation - 1.0) * factor
|
||||
gamma = 1.0 + (preset.gamma - 1.0) * factor
|
||||
gamma_r = 1.0 + (preset.gamma_r - 1.0) * factor
|
||||
gamma_g = 1.0 + (preset.gamma_g - 1.0) * factor
|
||||
gamma_b = 1.0 + (preset.gamma_b - 1.0) * factor
|
||||
|
||||
parts = []
|
||||
if abs(brightness) > 0.001:
|
||||
parts.append(f"brightness={brightness:.3f}")
|
||||
if abs(contrast - 1.0) > 0.001:
|
||||
parts.append(f"contrast={contrast:.3f}")
|
||||
if abs(saturation - 1.0) > 0.001:
|
||||
parts.append(f"saturation={saturation:.3f}")
|
||||
if abs(gamma - 1.0) > 0.001:
|
||||
parts.append(f"gamma={gamma:.3f}")
|
||||
if abs(gamma_r - 1.0) > 0.001:
|
||||
parts.append(f"gamma_r={gamma_r:.3f}")
|
||||
if abs(gamma_g - 1.0) > 0.001:
|
||||
parts.append(f"gamma_g={gamma_g:.3f}")
|
||||
if abs(gamma_b - 1.0) > 0.001:
|
||||
parts.append(f"gamma_b={gamma_b:.3f}")
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
return f"eq={':'.join(parts)}"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查指定commit的CI status状态。
|
||||
|
||||
用法: python3 check_ci_status.py <token> <repo> <sha> <context>
|
||||
返回: 打印状态 (success/failure/pending/error)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 5:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
token = sys.argv[1]
|
||||
repo = sys.argv[2]
|
||||
sha = sys.argv[3]
|
||||
target_context = sys.argv[4]
|
||||
|
||||
api_url = f"https://git.xiaoxiajianji.com/api/v1/repos/{repo}/commits/{sha}/statuses?per_page=100"
|
||||
req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"})
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
statuses = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
print("pending")
|
||||
return
|
||||
|
||||
# API返回按时间倒序,第一个就是最新的
|
||||
for s in statuses:
|
||||
if s.get("context") == target_context:
|
||||
print(s.get("status", "pending"))
|
||||
return
|
||||
|
||||
print("pending")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI触发可靠性监控 - 定时检查PR的CI触发状态
|
||||
- 监控open PR的最新commit是否在5分钟内触发了CI
|
||||
- 异常时通过飞书webhook告警
|
||||
|
||||
环境变量:
|
||||
GITEA_API_TOKEN - Gitea API Token (必填)
|
||||
GITEA_REPO - 仓库路径,如 xiaoxia/xiaoxia-saas
|
||||
GITEA_URL - Gitea地址,如 https://git.xiaoxiajianji.com
|
||||
CI_NOTIFY_WEBHOOK - 飞书告警webhook (必填)
|
||||
CHECK_INTERVAL_MIN - 检查间隔(分钟),默认5
|
||||
STALE_THRESHOLD_MIN - CI未触发告警阈值(分钟),默认5
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def get_env(name, default=""):
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
def api_get(path):
|
||||
"""调用Gitea API"""
|
||||
token = get_env("GITEA_API_TOKEN")
|
||||
base_url = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
repo = get_env("GITEA_REPO", "xiaoxia/xiaoxia-saas")
|
||||
|
||||
url = f"{base_url}/api/v1/repos/{repo}{path}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code >= 500 and attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
if attempt < 2:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def get_open_prs():
|
||||
"""获取所有open PR"""
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = api_get(f"/pulls?state=open&sort=updated&direction=desc&limit=50&page={page}")
|
||||
if not batch:
|
||||
break
|
||||
prs.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
|
||||
def get_commit_status(sha):
|
||||
"""获取commit的CI状态"""
|
||||
try:
|
||||
return api_get(f"/commits/{sha}/status")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 获取commit状态失败: {e}")
|
||||
return {"state": "error", "statuses": []}
|
||||
|
||||
|
||||
def has_ci_started(statuses):
|
||||
"""判断是否有CI job已经启动(pending/running/success/failure都算启动了)"""
|
||||
pr_statuses = [s for s in statuses if "pull_request" in s.get("context", "")]
|
||||
if not pr_statuses:
|
||||
return False
|
||||
# 只要有非pending且非空的状态,就算启动了
|
||||
for s in pr_statuses:
|
||||
if s.get("status") in ["success", "failure", "running"]:
|
||||
return True
|
||||
if s.get("status") == "pending" and "Has started running" in s.get("description", ""):
|
||||
return True
|
||||
# 全是"Blocked by required conditions"的pending也算(说明CI系统收到了事件)
|
||||
for s in pr_statuses:
|
||||
if "Blocked" in s.get("description", ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def send_alert(pr_num, pr_title, pr_url, head_sha, commit_age_min):
|
||||
"""发送飞书告警"""
|
||||
webhook = get_env("CI_NOTIFY_WEBHOOK")
|
||||
if not webhook:
|
||||
print(" ⚠️ 未配置CI_NOTIFY_WEBHOOK,跳过告警")
|
||||
return
|
||||
|
||||
gitea_url = get_env("GITEA_URL", "https://git.xiaoxiajianji.com")
|
||||
|
||||
content = {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"⚠️ CI告警 - PR#{pr_num} CI未触发"},
|
||||
"template": "red",
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**PR**: [{pr_title}]({pr_url})\n**最新commit**: `{head_sha[:12]}`\n**已等待**: {commit_age_min:.0f} 分钟仍无CI启动\n**可能原因**: Gitea Actions事件丢失 / Webhook失败 / Runner资源不足",
|
||||
},
|
||||
},
|
||||
{
|
||||
"tag": "action",
|
||||
"actions": [
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看PR"},
|
||||
"url": pr_url,
|
||||
"type": "primary",
|
||||
},
|
||||
{
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": "查看Actions"},
|
||||
"url": f"{pr_url}/files",
|
||||
"type": "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag": "note",
|
||||
"elements": [
|
||||
{"tag": "plain_text", "content": f"CI触发监控 | 检测时间: {time.strftime('%Y-%m-%d %H:%M:%S')}"}
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
data = json.dumps(content).encode()
|
||||
req = urllib.request.Request(webhook, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
resp.read()
|
||||
print(f" 📢 告警已发送: PR#{pr_num}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ 告警发送失败: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
stale_threshold = int(get_env("STALE_THRESHOLD_MIN", "5"))
|
||||
|
||||
print("=" * 60)
|
||||
print(f"CI触发监控 - 检测时间: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print(f"告警阈值: {stale_threshold}分钟无CI启动")
|
||||
print("=" * 60)
|
||||
|
||||
# 获取open PR列表
|
||||
try:
|
||||
prs = get_open_prs()
|
||||
except Exception as e:
|
||||
print(f"❌ 获取PR列表失败: {e}")
|
||||
sys.exit(0) # 告警脚本不阻断CI
|
||||
|
||||
print(f"\n共 {len(prs)} 个open PR\n")
|
||||
|
||||
stale_prs = []
|
||||
now = time.time()
|
||||
|
||||
for pr in prs:
|
||||
pr_num = pr["number"]
|
||||
pr_title = pr["title"]
|
||||
pr_url = pr["html_url"]
|
||||
head_sha = pr["head"]["sha"]
|
||||
updated_at = pr["updated_at"]
|
||||
|
||||
# 解析updated_at(ISO格式)
|
||||
try:
|
||||
# 2026-07-17T09:22:43+08:00
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# 简化处理:直接用字符串解析
|
||||
ts_str = updated_at.replace("Z", "+00:00")
|
||||
# 手动解析
|
||||
dt = datetime.fromisoformat(ts_str)
|
||||
commit_time = dt.timestamp()
|
||||
except Exception as e:
|
||||
print(f" ⚠️ PR#{pr_num} 时间解析失败: {e}")
|
||||
continue
|
||||
|
||||
age_min = (now - commit_time) / 60
|
||||
|
||||
print(f"PR#{pr_num:3d} | {pr_title[:45]:45s} | 更新于 {age_min:.0f}min前")
|
||||
|
||||
# 少于2分钟的跳过,给CI一点启动时间
|
||||
if age_min < 2:
|
||||
print(f" ⏳ 刚更新,等待CI启动...")
|
||||
continue
|
||||
|
||||
# 获取commit状态
|
||||
status = get_commit_status(head_sha)
|
||||
statuses = status.get("statuses", [])
|
||||
|
||||
if has_ci_started(statuses):
|
||||
print(f" ✅ CI已启动 (state={status.get('state')})")
|
||||
continue
|
||||
|
||||
# CI未启动,判断是否超过阈值
|
||||
if age_min >= stale_threshold:
|
||||
print(f" 🚨 CI未触发!已等待 {age_min:.0f} 分钟")
|
||||
stale_prs.append({"num": pr_num, "title": pr_title, "url": pr_url, "sha": head_sha, "age_min": age_min})
|
||||
else:
|
||||
print(f" ⏳ CI尚未启动 ({age_min:.0f}min < {stale_threshold}min阈值)")
|
||||
|
||||
# 发送告警
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"检测结果: {len(stale_prs)} 个PR CI未触发超过阈值")
|
||||
|
||||
if stale_prs:
|
||||
print("\n告警列表:")
|
||||
for pr in stale_prs:
|
||||
print(f" - PR#{pr['num']}: {pr['title'][:40]} ({pr['age_min']:.0f}min)")
|
||||
send_alert(pr["num"], pr["title"], pr["url"], pr["sha"], pr["age_min"])
|
||||
else:
|
||||
print("✅ 所有PR CI触发正常")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,491 +0,0 @@
|
||||
"""
|
||||
片段调整 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- PUT /clips/{clip_id}/speed - 调速
|
||||
- PUT /clips/{clip_id}/volume - 音量调节
|
||||
- PUT /clips/{clip_id}/trim - 裁剪
|
||||
- PUT /clips/{clip_id}/adjustments - 统一调整
|
||||
- POST /{plan_id}/clips/batch-speed - 批量调速
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
return list(self._plans.values())[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
return len(self._plans)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return len([c for c in self._clips.values() if c.plan_id == plan_id])
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return self._clips.pop(clip_id, None) is not None
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
before = len(self._clips)
|
||||
self._clips = {k: v for k, v in self._clips.items() if v.plan_id != plan_id}
|
||||
return before - len(self._clips)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_clip(clip_id, plan_id="plan-001", order=0, duration=10.0, speed=1.0):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type="video",
|
||||
order=order,
|
||||
asset_id="asset-001",
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=duration,
|
||||
transition_effect="cut",
|
||||
transition_duration=0.0,
|
||||
playback_speed=speed,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
clips = {
|
||||
"clip-001": _make_clip("clip-001", order=0, duration=10.0),
|
||||
"clip-002": _make_clip("clip-002", order=1, duration=15.0),
|
||||
"clip-003": _make_clip("clip-003", order=2, duration=20.0),
|
||||
}
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository(clips)
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
import app.api.routes.edit_plans_adjustments as adj_module
|
||||
|
||||
app.dependency_overrides[adj_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[adj_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[adj_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adj_client():
|
||||
app, plan_repo, clip_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 调速测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustSpeed:
|
||||
def test_speed_up(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 2.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 2.0
|
||||
assert data["clip_id"] == "clip-001"
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 2.0
|
||||
|
||||
def test_slow_down(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 0.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 0.5
|
||||
|
||||
def test_speed_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/speed",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_speed_out_of_range_low(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 0.1},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_speed_out_of_range_high(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 5.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_speed_default_value(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
# 验证默认 speed
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 音量调节测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustVolume:
|
||||
def test_set_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 0.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["volume"] == 0.5
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.config["volume"] == 0.5
|
||||
|
||||
def test_mute(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 0.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["volume"] == 0.0
|
||||
|
||||
def test_boost_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 1.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["volume"] == 1.5
|
||||
|
||||
def test_volume_out_of_range(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/volume",
|
||||
json={"volume": 3.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_volume_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/volume",
|
||||
json={"volume": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_volume(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 1.0},
|
||||
)
|
||||
data = resp.json()
|
||||
# 默认音量应该是 1.0
|
||||
assert data["volume"] == 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 裁剪测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustTrim:
|
||||
def test_trim_start(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 2.0, "trim_end": 0.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 2.0
|
||||
assert data["trim_end"] == 0.0
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.config["trim_start"] == 2.0
|
||||
|
||||
def test_trim_both_ends(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 1.5, "trim_end": 2.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 1.5
|
||||
assert data["trim_end"] == 2.5
|
||||
|
||||
def test_trim_exceeds_duration(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
# 片段时长 10 秒,裁剪 8+3 = 11 > 10
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/trim",
|
||||
json={"trim_start": 8.0, "trim_end": 3.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "不能大于等于片段总时长" in resp.json()["detail"]
|
||||
|
||||
def test_trim_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/trim",
|
||||
json={"trim_start": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_default_trim_zero(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/speed",
|
||||
json={"speed": 1.0},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["trim_start"] == 0.0
|
||||
assert data["trim_end"] == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统一调整测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdjustAll:
|
||||
def test_adjust_speed_and_volume(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"speed": 1.5, "volume": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 1.5
|
||||
assert data["volume"] == 0.8
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 1.5
|
||||
assert clip.config["volume"] == 0.8
|
||||
|
||||
def test_adjust_all_four(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"speed": 2.0, "volume": 0.5, "trim_start": 1.0, "trim_end": 1.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["speed"] == 2.0
|
||||
assert data["volume"] == 0.5
|
||||
assert data["trim_start"] == 1.0
|
||||
assert data["trim_end"] == 1.0
|
||||
|
||||
clip = clip_repo.get("clip-001")
|
||||
assert clip.playback_speed == 2.0
|
||||
assert clip.config["volume"] == 0.5
|
||||
assert clip.config["trim_start"] == 1.0
|
||||
assert clip.config["trim_end"] == 1.0
|
||||
|
||||
def test_adjust_empty_body(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# 保持默认值
|
||||
assert data["speed"] == 1.0
|
||||
assert data["volume"] == 1.0
|
||||
|
||||
def test_adjust_trim_exceeds(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-001/adjustments",
|
||||
json={"trim_start": 9.0, "trim_end": 2.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_adjust_clip_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/clips/clip-nonexist/adjustments",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 批量调速测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBatchSpeed:
|
||||
def test_batch_speed_all(self, adj_client):
|
||||
c, _, clip_repo = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/clips/batch-speed",
|
||||
json={"speed": 1.5},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["updated_count"] == 3
|
||||
assert data["plan_id"] == "plan-001"
|
||||
|
||||
for cid in ["clip-001", "clip-002", "clip-003"]:
|
||||
clip = clip_repo.get(cid)
|
||||
assert clip.playback_speed == 1.5
|
||||
|
||||
def test_batch_speed_plan_not_found(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/clips/batch-speed",
|
||||
json={"speed": 2.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_batch_speed_invalid(self, adj_client):
|
||||
c, _, _ = adj_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/clips/batch-speed",
|
||||
json={"speed": 10.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -1,559 +0,0 @@
|
||||
"""
|
||||
封面管理 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /{plan_id}/cover - 获取封面配置
|
||||
- PUT /{plan_id}/cover - 更新封面配置
|
||||
- POST /{plan_id}/cover/extract - 从片段抽帧
|
||||
- POST /{plan_id}/cover/smart - 智能选帧
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
self._counter = 100
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"plan-{self._counter:03d}"
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
if not plan.id:
|
||||
plan.id = self._next_id()
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def __init__(self, clips: dict[str, EditPlanClip] | None = None):
|
||||
self._clips = clips or {}
|
||||
self._counter = 200
|
||||
|
||||
def _next_id(self) -> str:
|
||||
self._counter += 1
|
||||
return f"clip-{self._counter:03d}"
|
||||
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
items.sort(key=lambda c: c.order)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
items = [c for c in self._clips.values() if c.plan_id == plan_id]
|
||||
if status is not None:
|
||||
items = [c for c in items if c.status == status]
|
||||
return len(items)
|
||||
|
||||
def get(self, clip_id: str) -> Optional[EditPlanClip]:
|
||||
return self._clips.get(clip_id)
|
||||
|
||||
def create(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
if not clip.id:
|
||||
clip.id = self._next_id()
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def update(self, clip: EditPlanClip) -> EditPlanClip:
|
||||
self._clips[clip.id] = clip
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
if clip_id in self._clips:
|
||||
del self._clips[clip_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
to_delete = [cid for cid, c in self._clips.items() if c.plan_id == plan_id]
|
||||
for cid in to_delete:
|
||||
del self._clips[cid]
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
class StubAssetRepository:
|
||||
def __init__(self, assets: dict | None = None):
|
||||
self._assets = assets or {}
|
||||
|
||||
def get(self, asset_id: str):
|
||||
return self._assets.get(asset_id)
|
||||
|
||||
|
||||
class StubStorageService:
|
||||
def __init__(self):
|
||||
self.uploaded = {}
|
||||
self.downloaded = {}
|
||||
|
||||
def upload_file(self, file_or_path, storage_key, content_type="application/octet-stream"):
|
||||
self.uploaded[storage_key] = file_or_path
|
||||
return f"https://oss.example.com/{storage_key}"
|
||||
|
||||
def get_url(self, storage_key: str) -> str:
|
||||
return f"https://oss.example.com/{storage_key}"
|
||||
|
||||
def download_file(self, storage_key: str, local_path: str):
|
||||
self.downloaded[storage_key] = local_path
|
||||
# 创建一个假文件(空文件也可以,因为抽帧会被 mock 掉)
|
||||
Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(b"fake video data for testing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001", config=None):
|
||||
if config is None:
|
||||
config = normalize_plan_config({})
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=config,
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _make_sample_clip(clip_id="clip-001", plan_id="plan-001", asset_id="asset-001", clip_type="video"):
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id=plan_id,
|
||||
clip_type=clip_type,
|
||||
order=0,
|
||||
asset_id=asset_id,
|
||||
text_content="",
|
||||
start_time=0.0,
|
||||
duration=10.0,
|
||||
transition_effect="none",
|
||||
transition_duration=0.0,
|
||||
playback_speed=1.0,
|
||||
status=EditPlanClipStatus.READY,
|
||||
config={},
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_cover as cover_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
# 创建 stub
|
||||
plan = _make_sample_plan()
|
||||
clip = _make_sample_clip()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository({clip.id: clip})
|
||||
|
||||
# 替换服务模块中的 Repository 类
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 覆盖依赖
|
||||
app.dependency_overrides[cover_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[cover_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[cover_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
# Mock storage 和 asset repo
|
||||
stub_storage = StubStorageService()
|
||||
stub_asset_repo = StubAssetRepository(
|
||||
{
|
||||
"asset-001": MagicMock(
|
||||
storage_key="videos/test.mp4",
|
||||
mime_type="video/mp4",
|
||||
),
|
||||
"asset-img": MagicMock(
|
||||
storage_key="images/test.jpg",
|
||||
mime_type="image/jpeg",
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
app.dependency_overrides[cover_module.get_storage_service] = lambda: stub_storage
|
||||
app.dependency_overrides[cover_module.get_asset_repository] = lambda: stub_asset_repo
|
||||
|
||||
# 也需要覆盖 edit_plans 主模块的 auth(用于其他路由)
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, stub_clip_repo, stub_storage, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cover_client():
|
||||
app, plan_repo, clip_repo, storage, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo, clip_repo, storage
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/cover 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCover:
|
||||
def test_get_default_cover(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/cover")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
assert data["image_url"] == ""
|
||||
assert data["frame_time"] is None
|
||||
|
||||
def test_get_cover_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/cover")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_cover_with_custom_config(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
# 更新 plan 的 cover 配置
|
||||
plan = plan_repo.get("plan-001")
|
||||
new_config = dict(plan.config)
|
||||
new_config["cover"] = {"type": "manual", "image_url": "https://example.com/cover.jpg", "frame_time": 5.5}
|
||||
plan.config = new_config
|
||||
plan_repo.update(plan)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/cover")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["image_url"] == "https://example.com/cover.jpg"
|
||||
assert data["frame_time"] == 5.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/cover 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateCover:
|
||||
def test_update_cover_type_and_url(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "upload", "image_url": "https://example.com/uploaded.jpg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "upload"
|
||||
assert data["image_url"] == "https://example.com/uploaded.jpg"
|
||||
|
||||
# 验证存储
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "upload"
|
||||
assert plan.config["cover"]["image_url"] == "https://example.com/uploaded.jpg"
|
||||
|
||||
def test_update_cover_frame_time(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "manual", "frame_time": 3.14},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["frame_time"] == 3.14
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["frame_time"] == 3.14
|
||||
|
||||
def test_update_cover_invalid_type(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"type": "invalid_type"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_cover_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover",
|
||||
json={"type": "upload", "image_url": "test.jpg"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_cover_partial(self, cover_client):
|
||||
"""只更新 image_url,type 保持不变"""
|
||||
c, plan_repo, _, _ = cover_client
|
||||
# 先设置一个类型
|
||||
c.put("/api/v1/edit-plans/plan-001/cover", json={"type": "manual", "frame_time": 2.0})
|
||||
|
||||
# 只更新 image_url
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/cover",
|
||||
json={"image_url": "https://example.com/new.jpg"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual" # 保持不变
|
||||
assert data["image_url"] == "https://example.com/new.jpg"
|
||||
assert data["frame_time"] == 2.0 # 保持不变
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/cover/extract 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractCover:
|
||||
def test_extract_success(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
# mock ffmpeg 抽帧,直接创建输出文件
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": 2.5},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "manual"
|
||||
assert data["frame_time"] == 2.5
|
||||
assert data["image_url"].startswith("https://oss.example.com/covers/")
|
||||
|
||||
# 验证 plan.config 已更新
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "manual"
|
||||
assert plan.config["cover"]["frame_time"] == 2.5
|
||||
|
||||
def test_extract_clip_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-nonexist", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_extract_plan_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_extract_clip_no_asset(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 创建一个没有 asset 的片段
|
||||
empty_clip = _make_sample_clip(clip_id="clip-empty", asset_id="")
|
||||
clip_repo.create(empty_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-empty", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "没有关联素材" in resp.json()["detail"]
|
||||
|
||||
def test_extract_clip_not_in_plan(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 创建属于另一个 plan 的片段
|
||||
other_clip = _make_sample_clip(clip_id="clip-other", plan_id="plan-other")
|
||||
clip_repo.create(other_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-other", "frame_time": 1.0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "不属于该剪辑计划" in resp.json()["detail"]
|
||||
|
||||
def test_extract_negative_frame_time(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/extract",
|
||||
json={"clip_id": "clip-001", "frame_time": -1.0},
|
||||
)
|
||||
assert resp.status_code == 422 # pydantic 校验失败
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /{plan_id}/cover/smart 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSmartCover:
|
||||
def test_smart_cover_with_clip_id(self, cover_client):
|
||||
c, plan_repo, _, _ = cover_client
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={"clip_id": "clip-001"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
assert data["image_url"].startswith("https://oss.example.com/covers/")
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["cover"]["type"] == "ai_frame"
|
||||
|
||||
def test_smart_cover_auto_pick_first_video(self, cover_client):
|
||||
c, plan_repo, clip_repo, _ = cover_client
|
||||
# 添加多个片段,第一个视频应该被选中
|
||||
clip2 = _make_sample_clip(clip_id="clip-002", clip_type="audio", asset_id="asset-audio")
|
||||
clip2.order = 1
|
||||
clip_repo.create(clip2)
|
||||
|
||||
with patch("app.services.cover_service.CoverService._extract_frame") as mock_extract:
|
||||
|
||||
def fake_extract(video_path, output_path, **kwargs):
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(b"fake jpeg data")
|
||||
|
||||
mock_extract.side_effect = fake_extract
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "ai_frame"
|
||||
|
||||
def test_smart_cover_clip_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={"clip_id": "clip-nonexist"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_smart_cover_no_video_clips(self, cover_client):
|
||||
c, _, clip_repo, _ = cover_client
|
||||
# 删除原有片段,添加纯音频片段
|
||||
clip_repo.delete("clip-001")
|
||||
audio_clip = _make_sample_clip(clip_id="clip-audio", clip_type="audio", asset_id="asset-001")
|
||||
clip_repo.create(audio_clip)
|
||||
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-001/cover/smart",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "没有找到可用的视频片段" in resp.json()["detail"]
|
||||
|
||||
def test_smart_cover_plan_not_found(self, cover_client):
|
||||
c, _, _, _ = cover_client
|
||||
resp = c.post(
|
||||
"/api/v1/edit-plans/plan-nonexist/cover/smart",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -1,350 +0,0 @@
|
||||
"""
|
||||
导出设置 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /export-presets - 导出预设列表
|
||||
- GET /{plan_id}/export - 获取导出配置
|
||||
- PUT /{plan_id}/export - 更新导出配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
return list(self._plans.values())[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
return [p for p in self._plans.values() if p.template_id == template_id][skip : skip + limit]
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
return self._plans.pop(plan_id, None) is not None
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
return len(self._plans)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
return []
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return 0
|
||||
|
||||
def get(self, clip_id: str):
|
||||
return None
|
||||
|
||||
def create(self, clip):
|
||||
return clip
|
||||
|
||||
def update(self, clip):
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001"):
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=normalize_plan_config({}),
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository()
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
import app.api.routes.edit_plans_export as export_module
|
||||
|
||||
app.dependency_overrides[export_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[export_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[export_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def export_client():
|
||||
app, plan_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExportPresets:
|
||||
def test_list_all_presets(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/export-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 5
|
||||
assert len(data["items"]) == data["total"]
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "resolution" in first
|
||||
assert "fps" in first
|
||||
assert "video_bitrate" in first
|
||||
assert "format" in first
|
||||
assert "description" in first
|
||||
assert "size_hint" in first
|
||||
|
||||
def test_preset_has_valid_resolution(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/export-presets")
|
||||
data = resp.json()
|
||||
for item in data["items"]:
|
||||
assert "x" in item["resolution"]
|
||||
assert item["fps"] >= 15
|
||||
assert item["fps"] <= 60
|
||||
assert item["format"] in ("mp4", "mov")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/export 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetExportConfig:
|
||||
def test_default_export_config(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/export")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resolution"] == "1080x1920"
|
||||
assert data["fps"] == 30
|
||||
assert data["video_bitrate"] == 8000
|
||||
assert data["audio_bitrate"] == 128
|
||||
assert data["format"] == "mp4"
|
||||
assert data["quality_preset"] == "balanced"
|
||||
assert data["watermark_enabled"] is False
|
||||
assert data["watermark_text"] == ""
|
||||
|
||||
def test_export_not_found(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/export")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/export 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateExportConfig:
|
||||
def test_update_resolution_and_fps(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "720x1280", "fps": 60},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resolution"] == "720x1280"
|
||||
assert data["fps"] == 60
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["export"]["resolution"] == "720x1280"
|
||||
assert plan.config["export"]["fps"] == 60
|
||||
|
||||
def test_update_bitrate(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"video_bitrate": 12000, "audio_bitrate": 192},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["video_bitrate"] == 12000
|
||||
assert data["audio_bitrate"] == 192
|
||||
|
||||
def test_update_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"format": "mov"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["format"] == "mov"
|
||||
|
||||
def test_invalid_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"format": "avi"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_quality_preset(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"quality_preset": "best"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["quality_preset"] == "best"
|
||||
|
||||
def test_invalid_quality_preset(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"quality_preset": "ultimate"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_update_watermark(self, export_client):
|
||||
c, plan_repo = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"watermark_enabled": True, "watermark_text": "我的视频"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["watermark_enabled"] is True
|
||||
assert data["watermark_text"] == "我的视频"
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["export"]["watermark_enabled"] is True
|
||||
assert plan.config["export"]["watermark_text"] == "我的视频"
|
||||
|
||||
def test_invalid_resolution_format(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "1080*1920"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_resolution_too_large(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"resolution": "8000x8000"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_fps_out_of_range(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/export",
|
||||
json={"fps": 120},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_export_not_found(self, export_client):
|
||||
c, _ = export_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/export",
|
||||
json={"fps": 30},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_other_fields(self, export_client):
|
||||
c, _ = export_client
|
||||
# 先修改一个
|
||||
c.put("/api/v1/edit-plans/plan-001/export", json={"resolution": "720x1280"})
|
||||
# 再修改另一个
|
||||
resp = c.put("/api/v1/edit-plans/plan-001/export", json={"fps": 60})
|
||||
data = resp.json()
|
||||
# 分辨率应该保持
|
||||
assert data["resolution"] == "720x1280"
|
||||
# fps 更新了
|
||||
assert data["fps"] == 60
|
||||
# 其他默认值不变
|
||||
assert data["format"] == "mp4"
|
||||
assert data["video_bitrate"] == 8000
|
||||
@@ -1,476 +0,0 @@
|
||||
"""
|
||||
滤镜调色 API 单元测试
|
||||
|
||||
覆盖:
|
||||
- GET /filter-presets - 滤镜预设列表
|
||||
- GET /{plan_id}/filter - 获取滤镜配置
|
||||
- PUT /{plan_id}/filter - 更新滤镜配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain.config_schemas import normalize_plan_config
|
||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY, build_ffmpeg_filter
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StubEditPlanRepository:
|
||||
def __init__(self, plans: dict[str, EditPlan] | None = None):
|
||||
self._plans = plans or {}
|
||||
|
||||
def list_all(self, *, status=None, skip=0, limit=50):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def list_by_template(self, template_id, *, status=None, skip=0, limit=50):
|
||||
items = [p for p in self._plans.values() if p.template_id == template_id]
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
items.sort(key=lambda p: p.created_at, reverse=True)
|
||||
return items[skip : skip + limit]
|
||||
|
||||
def get(self, plan_id: str) -> Optional[EditPlan]:
|
||||
return self._plans.get(plan_id)
|
||||
|
||||
def create(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def update(self, plan: EditPlan) -> EditPlan:
|
||||
self._plans[plan.id] = plan
|
||||
return plan
|
||||
|
||||
def delete(self, plan_id: str) -> bool:
|
||||
if plan_id in self._plans:
|
||||
del self._plans[plan_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def count(self, *, status=None, template_id=None):
|
||||
items = list(self._plans.values())
|
||||
if status is not None:
|
||||
items = [p for p in items if p.status == status]
|
||||
if template_id is not None:
|
||||
items = [p for p in items if p.template_id == template_id]
|
||||
return len(items)
|
||||
|
||||
|
||||
class StubEditPlanClipRepository:
|
||||
def list_by_plan(self, plan_id, *, status=None, skip=0, limit=100):
|
||||
return []
|
||||
|
||||
def count(self, plan_id, *, status=None):
|
||||
return 0
|
||||
|
||||
def get(self, clip_id: str):
|
||||
return None
|
||||
|
||||
def create(self, clip):
|
||||
return clip
|
||||
|
||||
def update(self, clip):
|
||||
return clip
|
||||
|
||||
def delete(self, clip_id: str) -> bool:
|
||||
return False
|
||||
|
||||
def delete_by_plan(self, plan_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_sample_plan(plan_id="plan-001", config=None):
|
||||
if config is None:
|
||||
config = normalize_plan_config({})
|
||||
return EditPlan(
|
||||
id=plan_id,
|
||||
template_id="tpl-001",
|
||||
name="测试计划",
|
||||
status=EditPlanStatus.EDITING,
|
||||
total_duration=30.0,
|
||||
config=config,
|
||||
project_id="",
|
||||
created_by_user_id="user-001",
|
||||
created_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
updated_at=datetime(2026, 7, 16, 10, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def _create_test_app():
|
||||
import app.api.routes.edit_plans_filter as filter_module
|
||||
import app.services.edit_plan_service as service_module
|
||||
from app.api.routes.edit_plans import router
|
||||
|
||||
plan = _make_sample_plan()
|
||||
stub_plan_repo = StubEditPlanRepository({plan.id: plan})
|
||||
stub_clip_repo = StubEditPlanClipRepository()
|
||||
|
||||
original_plan_repo = service_module.SQLAlchemyEditPlanRepository
|
||||
original_clip_repo = service_module.SQLAlchemyEditPlanClipRepository
|
||||
original_gen_repo = service_module.SQLAlchemyGenerationTaskRepository
|
||||
service_module.SQLAlchemyEditPlanRepository = lambda db: stub_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = lambda db: stub_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = lambda db: MagicMock()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/edit-plans")
|
||||
|
||||
# Mock 认证
|
||||
def _mock_auth():
|
||||
mock = MagicMock()
|
||||
mock.user.id = "user-001"
|
||||
return mock
|
||||
|
||||
# Mock 项目访问检查
|
||||
import app.api.routes._helpers as helpers_module
|
||||
|
||||
original_check = helpers_module.check_project_access
|
||||
helpers_module.check_project_access = lambda *a, **kw: None
|
||||
|
||||
# 主路由的依赖覆盖
|
||||
from app.api.routes import edit_plans as main_module
|
||||
|
||||
app.dependency_overrides[main_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[main_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[main_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
# 滤镜路由的依赖覆盖
|
||||
app.dependency_overrides[filter_module.get_current_user] = _mock_auth
|
||||
app.dependency_overrides[filter_module.get_db_session] = lambda: MagicMock()
|
||||
app.dependency_overrides[filter_module.get_project_repository] = lambda: MagicMock()
|
||||
|
||||
def cleanup():
|
||||
service_module.SQLAlchemyEditPlanRepository = original_plan_repo
|
||||
service_module.SQLAlchemyEditPlanClipRepository = original_clip_repo
|
||||
service_module.SQLAlchemyGenerationTaskRepository = original_gen_repo
|
||||
helpers_module.check_project_access = original_check
|
||||
|
||||
return app, stub_plan_repo, cleanup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def filter_client():
|
||||
app, plan_repo, cleanup = _create_test_app()
|
||||
yield TestClient(app), plan_repo
|
||||
cleanup()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter Presets 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFilterPresets:
|
||||
def test_list_all_presets(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == len(FILTER_PRESET_LIBRARY)
|
||||
assert data["total"] > 10
|
||||
assert len(data["items"]) == data["total"]
|
||||
# 验证字段
|
||||
first = data["items"][0]
|
||||
assert "id" in first
|
||||
assert "name" in first
|
||||
assert "category" in first
|
||||
assert "description" in first
|
||||
assert "tags" in first
|
||||
|
||||
def test_filter_by_category_basic(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=basic")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "basic"
|
||||
|
||||
def test_filter_by_category_bw(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=bw")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["category"] == "bw"
|
||||
|
||||
def test_filter_by_keyword(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=电影")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] > 0
|
||||
# 至少包含电影感滤镜
|
||||
names = [item["name"] for item in data["items"]]
|
||||
assert any("电影" in n for n in names)
|
||||
|
||||
def test_filter_by_keyword_japanese(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=日系")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
assert data["items"][0]["name"] == "日系"
|
||||
|
||||
def test_filter_empty_result(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?keyword=不存在的滤镜")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
def test_filter_invalid_category(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/filter-presets?category=nonexistent")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /{plan_id}/filter 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetFilter:
|
||||
def test_get_default_filter(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/filter")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is False
|
||||
assert data["preset_id"] == "filter_none"
|
||||
assert data["intensity"] == 100
|
||||
assert data["brightness"] == 0.0
|
||||
assert data["contrast"] == 1.0
|
||||
assert data["saturation"] == 1.0
|
||||
assert data["warmth"] == 0.0
|
||||
|
||||
def test_get_filter_not_found(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.get("/api/v1/edit-plans/plan-nonexist/filter")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_filter_with_custom_config(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
plan = plan_repo.get("plan-001")
|
||||
new_config = dict(plan.config)
|
||||
new_config["filter"] = {
|
||||
"enabled": True,
|
||||
"preset_id": "filter_cinematic",
|
||||
"intensity": 80,
|
||||
"brightness": 0.1,
|
||||
"contrast": 1.2,
|
||||
"saturation": 0.9,
|
||||
"warmth": 0.3,
|
||||
}
|
||||
plan.config = new_config
|
||||
plan_repo.update(plan)
|
||||
|
||||
resp = c.get("/api/v1/edit-plans/plan-001/filter")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["preset_id"] == "filter_cinematic"
|
||||
assert data["intensity"] == 80
|
||||
assert data["brightness"] == 0.1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /{plan_id}/filter 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUpdateFilter:
|
||||
def test_enable_filter(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["enabled"] is True
|
||||
assert data["preset_id"] == "filter_cinematic"
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["filter"]["enabled"] is True
|
||||
assert plan.config["filter"]["preset_id"] == "filter_cinematic"
|
||||
|
||||
def test_adjust_intensity(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic", "intensity": 50},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["intensity"] == 50
|
||||
|
||||
plan = plan_repo.get("plan-001")
|
||||
assert plan.config["filter"]["intensity"] == 50
|
||||
|
||||
def test_invalid_intensity_returns_422(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"intensity": 150},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_invalid_preset_returns_400(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"preset_id": "nonexistent_filter"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的滤镜预设" in resp.json()["detail"]
|
||||
|
||||
def test_filter_not_found(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-nonexist/filter",
|
||||
json={"enabled": True},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_set_none_preset_disables_filter(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
# 先启用一个滤镜
|
||||
c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"enabled": True, "preset_id": "filter_cinematic"},
|
||||
)
|
||||
|
||||
# 再设为原图
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"preset_id": "filter_none"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["preset_id"] == "filter_none"
|
||||
assert data["enabled"] is False # 原图自动关闭
|
||||
|
||||
def test_partial_update(self, filter_client):
|
||||
c, plan_repo = filter_client
|
||||
# 先设置完整配置
|
||||
c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "filter_warm",
|
||||
"intensity": 70,
|
||||
"brightness": 0.05,
|
||||
},
|
||||
)
|
||||
|
||||
# 只修改强度,其他保持不变
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"intensity": 90},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["intensity"] == 90
|
||||
assert data["preset_id"] == "filter_warm" # 保持不变
|
||||
assert data["enabled"] is True # 保持不变
|
||||
assert data["brightness"] == 0.05 # 保持不变
|
||||
|
||||
def test_custom_adjustments(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "filter_cinematic",
|
||||
"brightness": 0.1,
|
||||
"contrast": 1.3,
|
||||
"saturation": 1.2,
|
||||
"warmth": 0.2,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["brightness"] == 0.1
|
||||
assert data["contrast"] == 1.3
|
||||
assert data["saturation"] == 1.2
|
||||
assert data["warmth"] == 0.2
|
||||
|
||||
def test_invalid_brightness_returns_422(self, filter_client):
|
||||
c, _ = filter_client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/plan-001/filter",
|
||||
json={"brightness": 2.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FFmpeg 滤镜生成测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildFFmpegFilter:
|
||||
def test_no_filter(self):
|
||||
assert build_ffmpeg_filter("filter_none", 100) == ""
|
||||
|
||||
def test_zero_intensity(self):
|
||||
assert build_ffmpeg_filter("filter_cinematic", 0) == ""
|
||||
|
||||
def test_invalid_preset(self):
|
||||
assert build_ffmpeg_filter("nonexistent", 100) == ""
|
||||
|
||||
def test_cinematic_full(self):
|
||||
result = build_ffmpeg_filter("filter_cinematic", 100)
|
||||
assert result.startswith("eq=")
|
||||
assert "contrast=" in result
|
||||
assert "saturation=" in result
|
||||
assert "gamma_r=" in result
|
||||
|
||||
def test_cinematic_half(self):
|
||||
full = build_ffmpeg_filter("filter_cinematic", 100)
|
||||
half = build_ffmpeg_filter("filter_cinematic", 50)
|
||||
assert full != half
|
||||
# 50% 强度的参数应该更接近原值
|
||||
assert "eq=" in half
|
||||
|
||||
def test_bw_filter(self):
|
||||
result = build_ffmpeg_filter("filter_bw", 100)
|
||||
assert "saturation=0" in result
|
||||
|
||||
def test_warm_filter(self):
|
||||
result = build_ffmpeg_filter("filter_warm", 100)
|
||||
assert "gamma_r=" in result
|
||||
assert "gamma_b=" in result
|
||||
@@ -727,255 +727,3 @@ class TestResumeEditingAndRegenerate:
|
||||
p = EditPlan.create("tpl-001", "测试")
|
||||
with pytest.raises(ValueError):
|
||||
p.resume_editing()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 片段分割与合并测试
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestClipSplit:
|
||||
"""片段分割测试"""
|
||||
|
||||
def test_split_basic(self):
|
||||
"""基础分割:10秒片段在第3秒处分割"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0, text_content="测试文案")
|
||||
|
||||
result = svc.split_clip(clip.id, 3.0)
|
||||
|
||||
assert result["left_clip"].duration == 3.0
|
||||
assert result["left_clip"].order == 0
|
||||
assert result["right_clip"].duration == 7.0
|
||||
assert result["right_clip"].order == 1
|
||||
assert result["right_clip"].clip_type == "main"
|
||||
assert result["right_clip"].text_content == "测试文案"
|
||||
# 总片段数 = 2
|
||||
assert svc.count_clips(p.id) == 2
|
||||
|
||||
def test_split_preserves_clip_properties(self):
|
||||
"""分割后属性继承正确"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(
|
||||
p.id,
|
||||
"intro",
|
||||
0,
|
||||
duration=10.0,
|
||||
transition_effect="fade",
|
||||
playback_speed=1.5,
|
||||
config={"filter": "vivid"},
|
||||
)
|
||||
|
||||
result = svc.split_clip(clip.id, 5.0)
|
||||
|
||||
right = result["right_clip"]
|
||||
assert right.clip_type == "intro"
|
||||
assert right.transition_effect == "fade"
|
||||
assert right.playback_speed == 1.5
|
||||
assert right.config.get("filter") == "vivid"
|
||||
|
||||
def test_split_shifts_following_clips(self):
|
||||
"""分割后,后面的片段 order 自动 +1"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
|
||||
|
||||
svc.split_clip(clip1.id, 2.0)
|
||||
|
||||
# clip0: order 0
|
||||
# clip1(left): order 1
|
||||
# new right: order 2
|
||||
# clip2: order 3
|
||||
clips = svc.list_clips(p.id)
|
||||
order_map = {c.id: c.order for c in clips}
|
||||
assert order_map[clip0.id] == 0
|
||||
assert order_map[clip1.id] == 1
|
||||
assert order_map[clip2.id] == 3
|
||||
assert len(clips) == 4
|
||||
|
||||
def test_split_at_boundary_raises(self):
|
||||
"""分割点为0或等于时长时,报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="分割时间"):
|
||||
svc.split_clip(clip.id, 0.0)
|
||||
|
||||
with pytest.raises(ValueError, match="分割时间"):
|
||||
svc.split_clip(clip.id, 10.0)
|
||||
|
||||
def test_split_negative_time_raises(self):
|
||||
"""负数分割点报错"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
with pytest.raises(ValueError, match="分割时间"):
|
||||
svc.split_clip(clip.id, -1.0)
|
||||
|
||||
def test_split_nonexistent_clip_raises(self):
|
||||
"""不存在的片段报错"""
|
||||
svc = _make_service()
|
||||
|
||||
with pytest.raises(ValueError, match="片段不存在"):
|
||||
svc.split_clip("nonexistent", 5.0)
|
||||
|
||||
def test_split_with_asset_adds_trim_info(self):
|
||||
"""有素材的片段分割后,添加trim_start/trim_end"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=10.0, asset_id="asset-001")
|
||||
|
||||
result = svc.split_clip(clip.id, 3.0)
|
||||
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
# 左半部分有 trim_end
|
||||
assert left.config.get("trim_end") == 7.0
|
||||
# 右半部分有 trim_start
|
||||
assert right.config.get("trim_start") == 3.0
|
||||
# 右半部分也关联同一个素材
|
||||
assert right.asset_id == "asset-001"
|
||||
|
||||
|
||||
class TestClipMerge:
|
||||
"""片段合并测试"""
|
||||
|
||||
def test_merge_two_clips(self):
|
||||
"""基础合并:两个5秒片段合并为10秒"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, text_content="第一段")
|
||||
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, text_content="第二段")
|
||||
|
||||
merged = svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
assert merged.duration == 10.0
|
||||
assert merged.order == 0
|
||||
assert merged.clip_type == "main"
|
||||
assert "第一段" in merged.text_content
|
||||
assert "第二段" in merged.text_content
|
||||
# 总片段数 = 1
|
||||
assert svc.count_clips(p.id) == 1
|
||||
|
||||
def test_merge_shifts_following_clips(self):
|
||||
"""合并后,后面的片段 order 前移"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
clip1 = svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
|
||||
clip3 = svc.create_clip(p.id, "main", 3, duration=5.0)
|
||||
|
||||
svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
clips = svc.list_clips(p.id)
|
||||
order_map = {c.id: c.order for c in clips}
|
||||
assert order_map[clip0.id] == 0
|
||||
assert order_map[clip3.id] == 2 # 原来order=3,前移1位=2
|
||||
assert len(clips) == 3
|
||||
|
||||
def test_merge_three_clips(self):
|
||||
"""合并3个片段"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clips = []
|
||||
for i in range(3):
|
||||
c = svc.create_clip(p.id, "main", i, duration=3.0)
|
||||
clips.append(c)
|
||||
|
||||
merged = svc.merge_clips([c.id for c in clips])
|
||||
|
||||
assert merged.duration == 9.0
|
||||
assert svc.count_clips(p.id) == 1
|
||||
|
||||
def test_merge_different_types_raises(self):
|
||||
"""不同类型片段不能合并"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "intro", 0, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="相同类型"):
|
||||
svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
def test_merge_non_contiguous_raises(self):
|
||||
"""不连续的片段不能合并"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip0 = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
svc.create_clip(p.id, "main", 1, duration=5.0)
|
||||
clip2 = svc.create_clip(p.id, "main", 2, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="不连续"):
|
||||
svc.merge_clips([clip0.id, clip2.id])
|
||||
|
||||
def test_merge_single_clip_raises(self):
|
||||
"""单个片段不能合并"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip = svc.create_clip(p.id, "main", 0, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="至少需要 2 个"):
|
||||
svc.merge_clips([clip.id])
|
||||
|
||||
def test_merge_different_plans_raises(self):
|
||||
"""不同计划的片段不能合并"""
|
||||
svc = _make_service()
|
||||
p1 = svc.create_plan("tpl-001", "计划1")
|
||||
p2 = svc.create_plan("tpl-001", "计划2")
|
||||
svc.transition_status(p1.id, EditPlanStatus.EDITING)
|
||||
svc.transition_status(p2.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p1.id, "main", 0, duration=5.0)
|
||||
clip2 = svc.create_clip(p2.id, "main", 0, duration=5.0)
|
||||
|
||||
with pytest.raises(ValueError, match="同一计划"):
|
||||
svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
def test_merge_clears_trim_fields(self):
|
||||
"""合并后清理trim字段"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
clip1 = svc.create_clip(p.id, "main", 0, duration=5.0, config={"trim_end": 2.0, "filter": "vivid"})
|
||||
clip2 = svc.create_clip(p.id, "main", 1, duration=5.0, config={"trim_start": 1.0})
|
||||
|
||||
merged = svc.merge_clips([clip1.id, clip2.id])
|
||||
|
||||
assert "trim_start" not in merged.config
|
||||
assert "trim_end" not in merged.config
|
||||
# 非 trim 字段保留(后面的覆盖前面的)
|
||||
assert merged.config.get("filter") == "vivid"
|
||||
|
||||
def test_split_then_merge_recovers(self):
|
||||
"""分割后再合并,时长基本恢复(浮点精度内)"""
|
||||
svc = _make_service()
|
||||
p = svc.create_plan("tpl-001", "测试计划")
|
||||
svc.transition_status(p.id, EditPlanStatus.EDITING)
|
||||
original = svc.create_clip(p.id, "main", 0, duration=10.0)
|
||||
|
||||
result = svc.split_clip(original.id, 3.5)
|
||||
left = result["left_clip"]
|
||||
right = result["right_clip"]
|
||||
|
||||
merged = svc.merge_clips([left.id, right.id])
|
||||
|
||||
assert abs(merged.duration - 10.0) < 0.001
|
||||
assert svc.count_clips(p.id) == 1
|
||||
|
||||
Executable → Regular
-214
@@ -507,217 +507,3 @@ class TestDeletePlan:
|
||||
resp = c.delete("/api/v1/edit-plans/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
assert "剪辑计划不存在" in resp.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BGM 配置测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBGMConfig:
|
||||
"""BGM 配置 API 测试"""
|
||||
|
||||
def test_get_bgm_default_empty(self, client):
|
||||
"""新计划 BGM 默认为空"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.get(f"/api/v1/edit-plans/{plan.id}/bgm")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["plan_id"] == plan.id
|
||||
assert data["bgm"] == {}
|
||||
|
||||
def test_update_bgm_volume(self, client):
|
||||
"""更新 BGM 音量"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"volume": 0.5, "fade_in": 2.0, "fade_out": 3.0},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["volume"] == 0.5
|
||||
assert data["bgm"]["fade_in"] == 2.0
|
||||
assert data["bgm"]["fade_out"] == 3.0
|
||||
|
||||
def test_enable_bgm_with_preset(self, client):
|
||||
"""启用 BGM 并指定 preset_id"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"source": "library",
|
||||
"preset_id": "bgm_upbeat_001",
|
||||
"volume": 0.3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["enabled"] is True
|
||||
assert data["bgm"]["preset_id"] == "bgm_upbeat_001"
|
||||
|
||||
def test_enable_bgm_without_source_returns_400(self, client):
|
||||
"""启用 BGM 但不指定来源,返回 400"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"enabled": True, "volume": 0.3},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "素材来源" in resp.json()["detail"]
|
||||
|
||||
def test_enable_bgm_with_asset_id(self, client):
|
||||
"""启用 BGM 并指定 asset_id"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"source": "upload",
|
||||
"asset_id": "asset-audio-001",
|
||||
"loop_enabled": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["enabled"] is True
|
||||
assert data["bgm"]["asset_id"] == "asset-audio-001"
|
||||
assert data["bgm"]["loop_enabled"] is True
|
||||
|
||||
def test_update_bgm_not_found(self, client):
|
||||
"""不存在的计划返回 404"""
|
||||
c, _ = client
|
||||
resp = c.put(
|
||||
"/api/v1/edit-plans/nonexistent/bgm",
|
||||
json={"volume": 0.5},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_bgm_not_found(self, client):
|
||||
"""不存在的计划返回 404"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/nonexistent/bgm")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_partial_update_preserves_existing(self, client):
|
||||
"""部分更新保留原有配置"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
plan.config = {"bgm": {"volume": 0.5, "fade_in": 1.0}}
|
||||
repo.create(plan)
|
||||
|
||||
# 只改音量
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={"volume": 0.8},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["volume"] == 0.8
|
||||
assert data["bgm"]["fade_in"] == 1.0 # 保留
|
||||
|
||||
def test_sidechain_config(self, client):
|
||||
"""人声闪避配置更新"""
|
||||
c, repo = client
|
||||
plan = EditPlan.create("tpl-001", "测试")
|
||||
repo.create(plan)
|
||||
|
||||
resp = c.put(
|
||||
f"/api/v1/edit-plans/{plan.id}/bgm",
|
||||
json={
|
||||
"enabled": True,
|
||||
"preset_id": "bgm_relax_001",
|
||||
"sidechain_enabled": True,
|
||||
"sidechain_ratio": 0.4,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bgm"]["sidechain_enabled"] is True
|
||||
assert data["bgm"]["sidechain_ratio"] == 0.4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BGM 预设库测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBGMPresets:
|
||||
"""BGM 预设列表 API 测试"""
|
||||
|
||||
def test_list_all_presets(self, client):
|
||||
"""获取所有预设 BGM"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "styles" in data
|
||||
assert data["total"] >= 10 # 至少有 10 首预设
|
||||
assert len(data["items"]) == data["total"]
|
||||
|
||||
def test_filter_by_style(self, client):
|
||||
"""按风格筛选"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?style=upbeat")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 3
|
||||
for item in data["items"]:
|
||||
assert item["style"] == "upbeat"
|
||||
|
||||
def test_search_by_keyword(self, client):
|
||||
"""关键词搜索"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?keyword=钢琴")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 1
|
||||
for item in data["items"]:
|
||||
has_piano = (
|
||||
"钢琴" in item["name"] or "钢琴" in item["description"] or any("钢琴" in tag for tag in item["tags"])
|
||||
)
|
||||
assert has_piano
|
||||
|
||||
def test_pagination(self, client):
|
||||
"""分页功能"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?skip=0&limit=3")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["skip"] == 0
|
||||
assert data["limit"] == 3
|
||||
|
||||
def test_preset_structure(self, client):
|
||||
"""预设条目字段完整"""
|
||||
c, _ = client
|
||||
resp = c.get("/api/v1/edit-plans/bgm/presets?limit=1")
|
||||
assert resp.status_code == 200
|
||||
item = resp.json()["items"][0]
|
||||
|
||||
assert "id" in item
|
||||
assert "name" in item
|
||||
assert "style" in item
|
||||
assert "style_label" in item
|
||||
assert "duration" in item
|
||||
assert "artist" in item
|
||||
assert "description" in item
|
||||
assert "tags" in item
|
||||
assert isinstance(item["tags"], list)
|
||||
|
||||
Reference in New Issue
Block a user