Compare commits

...

8 Commits

Author SHA1 Message Date
CI Bot c134152838 fix(ci): 修复 migration diff step 失败 - 增加容错避免阻断 CI
CI/CD Pipeline / Frontend Lint (push) Successful in 1m7s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m6s
CI/CD Pipeline / Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m37s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m48s
CI/CD Pipeline / Integration Tests (push) Successful in 1m48s
- Prepare git for migration diff step 改为 set +e + exit 0
- 该 step 失败不影响后续检查(Check migration safety 有全量检查 fallback)
- 解决 tarball checkout 环境下 git init/commit 失败导致 Validate 全挂的问题
2026-07-15 12:53:13 +08:00
xiaoxia 2977e27ede fix(ci): 修复增量扫描误检 - 改用 Gitea API 获取 PR 改动文件
CI/CD Pipeline / Frontend Lint (push) Successful in 59s
CI/CD Pipeline / Unit Tests (push) Successful in 3m3s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m26s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m21s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-15 12:39:33 +08:00
CI Bot d99cda8bc2 fix: 剪辑计划P0+P1修复 - asset_ids传递 + result_count修正 + total_duration回写 + logs埋点
CI/CD Pipeline / Frontend Lint (push) Successful in 44s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m33s
CI/CD Pipeline / Unit Tests (push) Successful in 2m33s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m39s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m53s
CI/CD Pipeline / Build Staging API Image (push) Successful in 12m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 14m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
P0修复:
- 触发生成时将plan.config.asset_ids传递到GenerationTask
- 同时补全project_id字段(之前为空字符串)

P1修复:
- result_count: 剪辑计划多片段合成1个成片,改为1(之前误存为clip数)
- total_duration: 渲染成功后回写实际输出时长到EditPlan.total_duration
- logs: 新增5个关键节点日志埋点(render_start/download_done/download_failed/render_complete/render_failed)
- 下载完成后进度推进到30%,便于前端展示素材下载中状态
2026-07-15 12:19:42 +08:00
xiaoxia 4b2f42f0be fix(worker): 修复unified_render_service.py的black格式化问题 (#359)
CI/CD Pipeline / Frontend Lint (push) Successful in 1m7s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m49s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m50s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 5m59s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-15 11:52:51 +08:00
xiaoxia 531aacb57e fix(code-quality): 第二批 - B904 raise-without-from 批量修复 (71个) (#353)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 11:51:45 +08:00
xiaoxia 7c8748ece6 perf(ci): 扩展CI触发分支模式 + 增加手动触发入口 (#349)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
2026-07-15 11:20:08 +08:00
CI Bot f0a5f4fe42 fix: 硬切场景改用concat filter替代xfade,提升多素材场景性能与稳定性
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 46s
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Build Staging API Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m24s
- 全硬切(no transition)场景下使用concat filter,性能远优于xfade
- 有转场效果时继续走xfade路径,保持兼容
- 修复硬切默认走xfade(fade fallback)的设计问题
- 新增单元测试覆盖concat和xfade两种路径
- 87个unified_render测试全绿,全套2147测试零回归
2026-07-15 11:18:28 +08:00
xiaoxia eba96fc864 feat(api): 剪辑计划生成状态API增强 - 新增progress/video_url/error_message等字段 (#351)
CI/CD Pipeline / Frontend Lint (push) Successful in 1m35s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 2m16s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 2m29s
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
2026-07-15 11:15:55 +08:00
39 changed files with 331 additions and 140 deletions
+16 -3
View File
@@ -5,19 +5,32 @@ on:
- main
- develop
- feature/**
- feat/**
- bugfix/**
- fix/**
- hotfix/**
- release/**
- refactor/**
- perf/**
- docs/**
- chore/**
- ci/**
tags:
- v*
pull_request:
branches:
- main
- develop
workflow_dispatch:
inputs:
reason:
description: "触发原因"
required: false
default: "手动触发 - CI漏触发补跑"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
@@ -83,7 +96,7 @@ jobs:
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_BASE_REF:-}\" ]; then\n echo \"PR mode (base: ${GITHUB_BASE_REF}) - preparing incremental scan\"\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n if git fetch origin \"${GITHUB_BASE_REF}\" --depth=50 -q 2>/dev/null; then\n CHANGED_PY_FILES=$(git diff --name-only --diff-filter=ACMRT \"origin/${GITHUB_BASE_REF}\" HEAD -- '*.py' 2>/dev/null | tr '\\n' ' ')\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: failed to fetch base branch, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
run: "set -eu\nSCAN_MODE=\"full\"\nCHANGED_PY_FILES=\"\"\n\nif [ \"${GITHUB_EVENT_NAME:-}\" = \"pull_request\" ] && [ -n \"${GITHUB_REF_NAME:-}\" ]; then\n echo \"PR mode (#${GITHUB_REF_NAME}) - fetching changed files from API\"\n\n PR_NUMBER=$(echo \"$GITHUB_REF\" | sed 's|refs/pull/||; s|/.*||')\n API_URL=\"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?limit=100\"\n\n set +e\n RESPONSE=$(curl -s -w \"\\n%{http_code}\" -H \"Authorization: token ${GITHUB_TOKEN}\" \"${API_URL}\")\n HTTP_CODE=$(echo \"$RESPONSE\" | tail -n1)\n BODY=$(echo \"$RESPONSE\" | sed '$d')\n set -e\n\n if [ \"$HTTP_CODE\" = \"200\" ]; then\n CHANGED_PY_FILES=$(echo \"$BODY\" | python3 -c \"\nimport json, sys\ntry:\n files = json.load(sys.stdin)\n py_files = [f['filename'] for f in files\n if f['filename'].endswith('.py') and f['status'] != 'removed']\n print(' '.join(py_files))\nexcept Exception:\n print('')\n\")\n if [ -n \"$CHANGED_PY_FILES\" ]; then\n SCAN_MODE=\"incremental\"\n FILE_COUNT=$(echo \"$CHANGED_PY_FILES\" | wc -w)\n echo \"Changed Python files: ${FILE_COUNT}\"\n echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^$'\n else\n SCAN_MODE=\"skip_py\"\n echo \"No Python files changed in this PR\"\n fi\n else\n echo \"WARN: API returned HTTP $HTTP_CODE, falling back to full scan\"\n fi\nelse\n echo \"Full scan mode (not a PR event)\"\nfi\n\necho \"SCAN_MODE=$SCAN_MODE\" >> $GITHUB_ENV\necho \"CHANGED_PY_FILES=$CHANGED_PY_FILES\" >> $GITHUB_ENV\n"
- name: Run code quality checks
shell: sh
run: "set -eu\n\nif [ \"$SCAN_MODE\" = \"incremental\" ]; then\n echo \"=== Incremental scan mode ===\"\n\n python3 -m compileall -q $CHANGED_PY_FILES\n\n python3 -m black --check --fast $CHANGED_PY_FILES\n\n python3 -m isort --check-only $CHANGED_PY_FILES\n\n RUFF_FILES=$(echo \"$CHANGED_PY_FILES\" | tr ' ' '\\n' | grep -v '^scripts/' | tr '\\n' ' ')\n if [ -n \"$RUFF_FILES\" ]; then\n python3 -m ruff check $RUFF_FILES --statistics\n else\n echo \"No ruff-checkable files changed, skipping\"\n fi\n\nelif [ \"$SCAN_MODE\" = \"skip_py\" ]; then\n echo \"No Python files changed - skipping Python lint checks\"\n\nelse\n echo \"=== Full scan mode ===\"\n\n python3 -m compileall -q alembic apps packages tests scripts\n\n python3 -m black --check --fast alembic apps packages tests scripts\n\n python3 -m isort --check-only alembic apps packages tests scripts\n\n python3 -m ruff check apps packages tests --statistics\nfi\n"
@@ -133,7 +146,7 @@ jobs:
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\nif command -v git >/dev/null 2>&1; then\n if [ ! -d .git ]; then\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n fi\n git fetch origin main --depth=1 -q 2>/dev/null || echo \"WARN: cannot fetch main, will check all migrations\"\nelse\n echo \"WARN: git not available, will check all migrations\"\nfi\n"
run: "set +e\nif command -v git >/dev/null 2>&1; then\n if [ ! -d .git ]; then\n git init -q\n git config user.email \"ci@localhost\"\n git config user.name \"CI\"\n git add .\n git commit -q -m \"current\"\n REPO_URL=\"https://x-access-token:${GITHUB_TOKEN}@${GITHUB_SERVER_URL#https://}/${GITHUB_REPOSITORY}.git\"\n git remote add origin \"$REPO_URL\"\n fi\n git fetch origin main --depth=1 -q 2>/dev/null || echo \"WARN: cannot fetch main, will check all migrations\"\nelse\n echo \"WARN: git not available, will check all migrations\"\nfi\nexit 0\n"
- name: Check migration safety
shell: sh
run: "set -eu\nif git rev-parse origin/main >/dev/null 2>&1; then\n python3 scripts/check_migration_safety.py --allow-medium-risk --diff-against origin/main\nelse\n python3 scripts/check_migration_safety.py --allow-medium-risk\nfi\n"
+4 -2
View File
@@ -239,7 +239,9 @@ def get_duplication_detail(
return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete(
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_duplication_record(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -287,7 +289,7 @@ def retry_duplication(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
) from e
if updated is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
+12 -8
View File
@@ -108,6 +108,10 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem]
@@ -275,11 +279,11 @@ def list_plans(
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
except ValueError as _e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
)
) from _e
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
@@ -322,7 +326,7 @@ def get_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
) from exc
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -358,7 +362,7 @@ def create_plan(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
) from exc
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
@@ -401,11 +405,11 @@ def update_plan(
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError:
except ValueError as _e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
)
) from _e
svc.transition_status(plan_id, target_status)
except ValueError as exc:
err_msg = str(exc)
@@ -413,11 +417,11 @@ def update_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
)
) from exc
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
)
) from exc
# 返回最新状态
result = svc.get_plan_or_raise(plan_id)
+4 -4
View File
@@ -58,7 +58,7 @@ def ai_recommend_clips(
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -103,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception:
except Exception as _e:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
@@ -116,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
)
) from _e
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -167,7 +167,7 @@ def generate_cover(
try:
plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
+25 -9
View File
@@ -183,9 +183,7 @@ def _auto_fallback_auto_material_mode(
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total")
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
@@ -241,7 +239,7 @@ def generate_plan(
try:
can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
@@ -255,12 +253,15 @@ def generate_plan(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id)
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id="",
project_id=plan.project_id or "",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
)
)
@@ -286,7 +287,7 @@ def generate_plan(
)
except HTTPException:
raise
except Exception:
except Exception as _e:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -295,7 +296,7 @@ def generate_plan(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
)
) from _e
@router.get(
@@ -313,7 +314,7 @@ def get_generation_status(
try:
gen_status = svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
plan = gen_status["plan"]
if plan.project_id:
@@ -333,10 +334,25 @@ def get_generation_status(
for c in clips
]
# 从 plan.config 中取渲染结果 URL
video_url = (plan.config or {}).get("rendered_url", "")
# 从 gen_status 中取进度、错误信息、任务状态
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
# 如果计划已完成但进度还是0,补100
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@@ -173,7 +173,7 @@ def generate_from_template(
try:
template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
+6 -6
View File
@@ -105,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name)
except Exception as exc:
logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc
@router.get("/{name}", response_model=FeatureFlagResponse)
@@ -120,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config)
except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
@@ -136,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc
@router.put("/{name}", response_model=FeatureFlagResponse)
@@ -170,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config)
except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@@ -178,7 +178,7 @@ async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) :
):
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -191,4 +191,4 @@ async def delete_feature_flag(
pass
except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc
+6 -5
View File
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -282,28 +283,28 @@ def create_generation_task(
created_tasks.append(task)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded:
except UserPendingLimitExceeded as _e:
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
)
) from _e
break
except GlobalQueueFull:
except GlobalQueueFull as _e:
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
) from _e
break
except HTTPException:
raise
except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items))
+2 -2
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository)
try:
deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError:
except PermissionError as _e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project",
)
) from _e
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return
+1 -1
View File
@@ -254,7 +254,7 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e:
session.rollback()
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e
finally:
session.close()
+17 -15
View File
@@ -147,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id)
except Exception:
except Exception as _e:
logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage)
@@ -186,7 +186,7 @@ def create_template(
try:
template = use_case.execute(command)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template)
@@ -226,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template)
@@ -264,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return _to_response(template)
@@ -299,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository)
try:
template = use_case.execute(template_id, user_id)
except Exception:
except Exception as _e:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository)
try:
result = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
return ValidateTemplateResponse(
template=_to_response(result.template),
@@ -375,7 +375,9 @@ def create_category(
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@router.delete(
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
) from exc
return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
output_url = job.output_audio_url
if output_url:
output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository)
try:
job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
# 校验已完成
if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
) from exc
return SaveToLibraryResponse(
id=item.id,
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
return VoiceCloneStatusResponse(
id=profile.id,
status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id
try:
profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError:
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
except VoiceCloneNotRetryableError as _e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)",
)
) from _e
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+3 -3
View File
@@ -287,7 +287,7 @@ def create_voice(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
)
) from exc
return _to_response(item, sign_url)
@@ -317,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository)
try:
item = use_case.execute(command)
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
return _to_response(item, sign_url)
@@ -431,6 +431,8 @@ class EditPlanService:
"clips": List[EditPlanClip],
"generation_task_id": Optional[str],
"generation_task_status": Optional[str],
"progress": float,
"error_message": str,
}
Raises:
@@ -442,17 +444,23 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id:
task = self._generation_task_repo.get(generation_task_id)
if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
progress = getattr(task, "progress", 0.0) or 0.0
error_message = getattr(task, "error_message", "") or ""
return {
"plan": plan,
"clips": clips,
"generation_task_id": generation_task_id,
"generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
}
def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -181,9 +181,9 @@ def _validate_video_path(video_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError:
except ValueError as _e:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
+1 -1
View File
@@ -356,7 +356,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
except Exception as e:
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60)
raise self.retry(exc=e, countdown=60) from e
finally:
session.close()
import shutil
@@ -191,9 +191,9 @@ def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError:
except ValueError as _e:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}") from _e
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
@@ -129,8 +129,8 @@ def safe_resolve_path(
if not allow_outside:
try:
full_path.relative_to(base_dir)
except ValueError:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
except ValueError as _e:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围") from _e
# 扩展名校验
if allowed_extensions is not None:
@@ -682,6 +682,6 @@ def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError:
except ValueError as _e:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}") from _e
@@ -1363,23 +1363,37 @@ class UnifiedRenderService:
# 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0]
else:
# 多 clip 层,用 TransitionEngine 构建转场链
out_label = f"{layer.role}_merged"
# 计算该层使用的转场时长(取首个非零值,否则用默认)
layer_dur = 0.0
for d in layer_transition_durations:
if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
# 判断是否全部为硬切:是则用 concat filter,否则用 xfade 转场链
all_cut = all(
t is None or t == "" or str(t).lower() == "cut"
for t in layer_transitions[1:] # 第一个 clip 的转场忽略
)
if xfade_filter:
filter_parts.append(xfade_filter)
if all_cut:
# 全硬切:用 concat filter,性能远优于 xfade
concat_inputs = "".join(f"[{label}]" for label in layer_labels)
filter_parts.append(f"{concat_inputs}concat=n={len(layer_labels)}:v=1:a=0[{out_label}]")
logger.info(
"[unified-render] layer=%s clips=%d using concat (all hard-cut)",
layer.role,
len(layer_labels),
)
else:
# 有转场效果:用 TransitionEngine 构建 xfade 链
layer_dur = 0.0
for d in layer_transition_durations:
if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
)
if xfade_filter:
filter_parts.append(xfade_filter)
layer_output_labels[layer.role] = out_label
# Step 3: 合成各层
+13 -1
View File
@@ -63,6 +63,18 @@ def compose_video(self, job_id: str, **kwargs):
resolver = get_render_engine_resolver()
user_id = job.created_by_user_id or None
engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s",
job_id,
engine,
user_id,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
if engine == "unified":
return _compose_with_unified_engine(self, job_service, job, plan_id, db)
@@ -78,7 +90,7 @@ def compose_video(self, job_id: str, **kwargs):
job_service.fail_job(job_id, str(exc)[:500])
except Exception:
logger.exception("更新 Job 失败状态时出错")
raise self.retry(exc=exc, countdown=60)
raise self.retry(exc=exc, countdown=60) from exc
finally:
db.close()
@@ -77,9 +77,21 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
return resolver.get_engine(user_id=user_id)
engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"edit_plan 引擎选择: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
return "legacy"
@@ -95,6 +107,14 @@ def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, err
gen_task.status = "failed"
gen_task.error_message = error_msg
gen_task.completed_at = datetime.now(timezone.utc)
try:
gen_task.append_log(
stage="render_failed",
message=error_msg[:500],
level="ERROR",
)
except Exception:
pass
gen_task_repo.update(gen_task)
@@ -148,9 +168,11 @@ def _finalize_render_success(
clip.mark_rendered()
clip_repo.update(clip)
# 更新 EditPlan 状态为 completed
# 更新 EditPlan 状态为 completed + 回写实际渲染时长
plan.config["rendered_url"] = output_url or ""
plan.config["rendered_storage_key"] = storage_key
if hasattr(plan, "total_duration") and duration > 0:
plan.total_duration = duration
plan.mark_completed()
plan_repo.update(plan)
@@ -160,7 +182,15 @@ def _finalize_render_success(
if gen_task:
gen_task.status = "completed"
gen_task.progress = 100.0
gen_task.result_count = len(rendered_clip_ids)
# 剪辑计划是多片段合成 1 个成片,result_count = 1
gen_task.result_count = 1
gen_task.append_log(
stage="render_complete",
message=f"渲染完成,输出时长 {duration:.1f}s",
level="INFO",
engine=engine,
clip_count=len(rendered_clip_ids),
)
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
@@ -365,6 +395,13 @@ def render_edit_plan(self, plan_id: str) -> dict:
if gen_task:
gen_task.status = "running"
gen_task.started_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="render_start",
message=f"开始渲染,引擎 {engine},片段数 {len(clips)}",
level="INFO",
engine=engine,
clip_count=len(clips),
)
gen_task_repo.update(gen_task)
# 3. 下载素材并构建 asset_path_map
@@ -429,9 +466,28 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed"
gen_task.error_message = "所有片段素材下载失败"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="download_failed",
message="所有片段素材下载失败",
level="ERROR",
)
gen_task_repo.update(gen_task)
return {"status": "error", "message": "所有片段素材下载失败"}
# 素材下载完成,记录日志
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.append_log(
stage="download_done",
message=f"素材下载完成,成功 {len(asset_path_map)} 个,失败 {len(failed_clip_ids)}",
level="INFO",
success_count=len(asset_path_map),
failed_count=len(failed_clip_ids),
)
gen_task.progress = 30.0
gen_task_repo.update(gen_task)
# 4. 根据引擎选择渲染方式
if engine == "unified":
result = _render_with_unified(
@@ -483,6 +539,15 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
gen_task.completed_at = datetime.now(timezone.utc)
try:
gen_task.append_log(
stage="render_failed",
message=f"渲染异常: {type(exc).__name__}: {str(exc)[:500]}",
level="ERROR",
exception_type=type(exc).__name__,
)
except Exception:
pass
gen_task_repo.update(gen_task)
logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
@@ -493,6 +558,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.warning(
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
)
raise self.retry(exc=exc, countdown=60)
raise self.retry(exc=exc, countdown=60) from exc
return {"status": "error", "message": "数据库连接失败"}
+16 -3
View File
@@ -787,10 +787,23 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
return resolver.get_engine(user_id=user_id)
engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"[渲染引擎] flag 解析: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 unified: %s", exc)
return ENGINE_UNIFIED
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
return ENGINE_LEGACY
# ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
@@ -63,7 +63,7 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
if session is not None:
session.rollback()
# 超时重试,指数退避
raise self.retry(exc=e, countdown=30)
raise self.retry(exc=e, countdown=30) from e
except CosyVoiceError as e:
logger.error(f"TTS synthesis failed for {job_id}: {e}")
@@ -139,7 +139,7 @@ def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
if session is not None:
session.rollback()
raise self.retry(exc=e, countdown=60)
raise self.retry(exc=e, countdown=60) from e
except CosyVoiceError as e:
logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
+1 -1
View File
@@ -73,7 +73,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
if session is not None:
session.rollback()
# 超时属于临时性故障,延迟 30 秒后重试
raise self.retry(exc=e, countdown=30)
raise self.retry(exc=e, countdown=30) from e
except CosyVoiceError as e:
logger.error(f"Voice clone failed for {profile_id}: {e}")
@@ -105,7 +105,7 @@ def extract_voice_task(self: Task, asset_id: str) -> dict:
except Exception as e:
logger.error(f"Voice extraction failed for {asset_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60)
raise self.retry(exc=e, countdown=60) from e
finally:
session.close()
import shutil
@@ -141,7 +141,7 @@ def extract_background_task(self: Task, asset_id: str) -> dict:
except Exception as e:
logger.error(f"Background extraction failed for {asset_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60)
raise self.retry(exc=e, countdown=60) from e
finally:
session.close()
import shutil
+3 -3
View File
@@ -147,10 +147,10 @@ class JWTService:
algorithms=[self.config.ALGORITHM],
)
return payload
except ExpiredSignatureError:
raise ExpiredSignatureError("Token has expired")
except ExpiredSignatureError as _e:
raise ExpiredSignatureError("Token has expired") from _e
except InvalidTokenError as e:
raise InvalidTokenError(f"Invalid token: {str(e)}")
raise InvalidTokenError(f"Invalid token: {str(e)}") from e
def verify_access_token(self, token: str) -> Dict[str, Any]:
"""
+2 -2
View File
@@ -656,8 +656,8 @@ class CosyVoiceService:
code = body.get("code", "")
message = body.get("message", "")
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
except ValueError:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
except ValueError as _e:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}") from _e
elif response.status_code >= 500:
# 服务端错误,可重试
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
+4 -4
View File
@@ -78,16 +78,16 @@ class AudioMerger:
run_ffmpeg(cmd, timeout=120)
except CalledProcessError as e:
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}") from e
with open(output_path, "rb") as f:
return f.read()
except TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except TimeoutExpired as _e:
raise AudioMergeError("FFmpeg 合并超时(120 秒)") from _e
except AudioMergeError:
raise
except Exception as e:
raise AudioMergeError(f"音频合并失败: {e}")
raise AudioMergeError(f"音频合并失败: {e}") from e
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
+2 -2
View File
@@ -170,8 +170,8 @@ class GenerationTask:
if isinstance(new_status, str):
try:
new_status = GenerationTaskStatus(new_status)
except ValueError:
raise ValueError(f"无效状态: {new_status}")
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+4 -4
View File
@@ -147,8 +147,8 @@ class Job:
if isinstance(job_type, str):
try:
job_type = JobType(job_type)
except ValueError:
raise ValueError(f"不支持的任务类型: {job_type}")
except ValueError as _e:
raise ValueError(f"不支持的任务类型: {job_type}") from _e
return cls(
id=uuid4().hex,
@@ -182,8 +182,8 @@ class Job:
if isinstance(new_status, str):
try:
new_status = JobStatus(new_status)
except ValueError:
raise ValueError(f"无效状态: {new_status}")
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+2 -2
View File
@@ -193,8 +193,8 @@ class TTSJob:
if isinstance(new_status, str):
try:
new_status = TTSJobStatus(new_status)
except ValueError:
raise ValueError(f"无效状态: {new_status}")
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+2 -2
View File
@@ -177,8 +177,8 @@ class VoiceCloneProfile:
if isinstance(new_status, str):
try:
new_status = VoiceCloneStatus(new_status)
except ValueError:
raise ValueError(f"无效状态: {new_status}")
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+2 -2
View File
@@ -114,7 +114,7 @@ class SharedStorageService:
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}")
raise Exception(f"Failed to upload file to OSS: {e}") from e
def get_url(self, storage_key: str) -> str:
"""Get public URL for a file."""
@@ -129,7 +129,7 @@ class SharedStorageService:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}")
raise Exception(f"Failed to download file from OSS: {e}") from e
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""Get signed download URL."""
+10 -1
View File
@@ -665,7 +665,16 @@ class TestResponseSchema:
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200
data = resp.json()
expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
expected_keys = {
"plan_id",
"plan_status",
"generation_task_id",
"generation_task_status",
"progress",
"video_url",
"error_message",
"clips",
}
assert set(data.keys()) == expected_keys
+1 -1
View File
@@ -367,7 +367,7 @@ class TestVerifyUrlRedirectValidation:
except urllib.error.HTTPError as e:
if 300 <= e.code < 400 and e.headers.get("Location"):
if redirect_count >= max_redirects:
raise Exception(f"重定向次数超过上限 ({max_redirects})")
raise Exception(f"重定向次数超过上限 ({max_redirects})") from e
location = e.headers["Location"]
current = urljoin(safe_url, location)
redirect_count += 1
+34 -2
View File
@@ -45,6 +45,7 @@ class FakeClip:
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0
status: str = "ready"
config: dict[str, Any] = field(default_factory=dict)
@@ -65,6 +66,7 @@ def _make_clip(
asset_id: str = "",
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
config: dict[str, Any] | None = None,
) -> FakeClip:
return FakeClip(
@@ -74,6 +76,7 @@ def _make_clip(
asset_id=asset_id or f"asset_{clip_id}.mp4",
duration=duration,
transition_effect=transition_effect,
transition_duration=transition_duration,
config=config or {},
)
@@ -332,7 +335,7 @@ class TestBuildFilterComplex:
assert "[final_video]" in fc
def test_single_layer_multi_clips(self):
"""多个 main clips → xfade 串联。"""
"""多个 main clips(默认硬切)→ concat 串联。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.0),
@@ -343,6 +346,35 @@ class TestBuildFilterComplex:
}
svc = _make_service(clips, asset_paths)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
fc, input_args = svc._build_filter_complex(layers)
assert input_args.count("-i") == 2
# 全硬切场景走 concat filter(性能远优于 xfade
assert "concat=n=2:v=1:a=0" in fc
assert "[final_video]" in fc
def test_single_layer_multi_clips_with_transition(self):
"""多个 main clips 带转场效果 → xfade 串联。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip(
"c2",
"main",
order=1,
duration=3.0,
transition_effect="fade",
transition_duration=0.5,
),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
}
svc = _make_service(clips, asset_paths)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
@@ -380,7 +412,7 @@ class TestBuildFilterComplex:
"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=5.0),
_make_clip("c2", "main", order=1, duration=5.0, transition_effect="fade", transition_duration=0.5),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),