Compare commits

..

7 Commits

Author SHA1 Message Date
CI Bot e47583a3c3 chore: rebase到develop + 格式化代码
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1541h43m54s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1541h43m56s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1541h43m56s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Failing after 1541h43m58s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1542h15m32s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Failing after 1542h15m34s
2026-07-14 09:36:06 +08:00
CI Bot f991da2b33 feat: 滤镜调色引擎 - 8种预设 + 基础调色 + 分段应用
新增 ColorGradeEngine,在统一渲染管道中加入画面色彩调整能力:

核心能力:
- 基础调色5参数:亮度(-100~100)、对比度(-100~100)、饱和度(0~200)、色温(-100~100)、色调(-180~180)
- 8种风格预设:清新、日系、复古、电影、胶片、黑白、暖色、冷色
- 预设+自定义覆盖模式:先加载预设参数,再用自定义值覆盖
- 分段应用:每个 clip 独立配置 color_grade,不同片段不同风格
- 降级策略:参数越界自动钳制到合法范围,解析失败不阻断渲染
- 性能优化:无效果(所有参数=默认值)时跳过滤镜,零开销

滤镜实现:
- eq 滤镜:亮度(brightness) + 对比度(contrast) + 饱和度(saturation)
- colorbalance 滤镜:色温(红/绿/蓝三通道平衡)
- hue 滤镜:色调调整
- 滤镜顺序:eq → colorbalance → hue

系统集成:
- 每个 clip 的 config.color_grade 读取配置
- 在 clip 预处理环节(scale/crop 之后,setpts 之前)插入调色滤镜链
- 直通模式(-vf)和 filter_complex 模式均已接入
- 调色有效果时自动失效 stream copy(需重编码)

测试覆盖:
- 预设常量:8种预设完整性、参数范围校验、色温方向验证
- from_dict:None/空/enabled/preset/自定义/字符串值/无效值
- resolve_params:默认值/预设应用/自定义覆盖/5参数边界钳制
- has_effect:disabled/默认值/单参数变化/预设/抵消无效果
- 参数映射:brightness/contrast/saturation/temperature/hue 全部验证
- build_filter:单参数/多参数组合/标签/滤镜顺序/所有预设有效性
- 分段调色:不同预设不同滤镜/确定性/自定义覆盖影响/有无调色差异

70个新增单测 + 84个现有测试,共154个全绿,零回归
2026-07-14 09:36:05 +08:00
xiaoxia 17fbae13a8 fix(ci): 移除构建脚本中docker driver不支持的--cache-to导出 (#302)
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 14m27s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1542h10m45s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1542h10m46s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1542h25m12s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1542h25m13s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 1542h56m48s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
fix(ci): 移除构建脚本中docker driver不支持的--cache-to导出
2026-07-14 09:04:18 +08:00
xiaoxia 41e421b44b fix(e2e): Playwright chromium禁用GPU,修复无显示环境下浏览器不稳定问题 (#301)
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 3s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1542h40m39s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1542h40m42s
CI/CD Pipeline / Deploy Production (push) Failing after 1542h40m43s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1542h40m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1543h12m16s
fix(e2e): Playwright chromium禁用GPU,修复无显示环境下浏览器不稳定问题
2026-07-14 08:48:42 +08:00
xiaoxia 9f86bd40ca fix(ci): 修复 build_release_images.sh 中 CACHE_TAG 未定义的问题 (#297)
CI/CD Pipeline / Staging E2E Tests (push) Failing after 19m9s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1544h7m25s
CI/CD Pipeline / Deploy Production (push) Failing after 1544h7m26s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1544h7m27s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
2026-07-14 07:22:05 +08:00
xiaoxia 881eea9195 fix(api+ci): DELETE 204响应体全修复 + isort/black整理 + CI环境兼容 + 测试修复 (#286)
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 9s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1544h21m30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1544h21m31s
CI/CD Pipeline / Deploy Production (push) Failing after 1544h21m40s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1544h21m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Failing after 1544h53m14s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
fix(api+ci): DELETE 204响应体全修复 + isort/black整理 + CI环境兼容 + 测试修复
2026-07-14 07:07:51 +08:00
CI Test 4faceb8093 fix: DELETE 204路由改用response_model=None并移除return None,彻底解决FastAPI断言错误
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 15s
CI/CD Pipeline / Unit Tests (push) Failing after 11s
CI/CD Pipeline / Integration Tests (push) Failing after 5s
CI/CD Pipeline / Frontend Lint (push) Failing after 2m13s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Failing after 1552h27m50s
CI/CD Pipeline / Build Production Runtime Images (push) Failing after 1552h27m49s
CI/CD Pipeline / Deploy Production (push) Failing after 1552h59m23s
CI/CD Pipeline / Production Browser E2E (push) Failing after 1552h27m47s
CI/CD Pipeline / Staging API Integration Tests (push) Failing after 1552h27m48s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1552h27m48s
2026-07-13 22:52:53 +08:00
34 changed files with 1160 additions and 167 deletions
Executable → Regular
+61 -1
View File
@@ -169,6 +169,10 @@ jobs:
env:
USE_IN_MEMORY_DB: "true"
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
steps:
- name: Checkout code
@@ -217,6 +221,32 @@ jobs:
tar.extract(member, '.')
PY
- name: Install ffmpeg
shell: sh
run: |
set +e
if command -v ffmpeg > /dev/null 2>&1; then
echo "ffmpeg already installed: $(ffmpeg -version | head -1)"
exit 0
fi
if command -v apt-get > /dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq ffmpeg
elif command -v yum > /dev/null 2>&1; then
yum install -y -q epel-release 2>/dev/null
yum install -y -q ffmpeg 2>/dev/null
if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then
dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null
dnf install -y -q ffmpeg 2>/dev/null
fi
elif command -v dnf > /dev/null 2>&1; then
dnf install -y -q ffmpeg 2>/dev/null
fi
if command -v ffmpeg > /dev/null 2>&1; then
echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)"
else
echo "Warning: ffmpeg installation failed or not available, some tests may be skipped"
fi
- name: Install dependencies
shell: sh
run: |
@@ -260,6 +290,10 @@ jobs:
env:
DATABASE_URL: postgresql+psycopg://postgres:postgres@127.0.0.1:5432/xiaoxia_saas
USE_IN_MEMORY_DB: "false"
OSS_ACCESS_KEY_ID: placeholder
OSS_ACCESS_KEY_SECRET: placeholder
OSS_BUCKET_NAME: xiaoxia-autocut
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
steps:
- name: Checkout code
@@ -325,6 +359,32 @@ jobs:
python3 -m pip install -q -r requirements-dev.txt
pytest --version
- name: Install ffmpeg
shell: sh
run: |
set +e
if command -v ffmpeg > /dev/null 2>&1; then
echo "ffmpeg already installed: $(ffmpeg -version | head -1)"
exit 0
fi
if command -v apt-get > /dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq ffmpeg
elif command -v yum > /dev/null 2>&1; then
yum install -y -q epel-release 2>/dev/null
yum install -y -q ffmpeg 2>/dev/null
if [ $? -ne 0 ] && command -v dnf > /dev/null 2>&1; then
dnf install -y -q --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-$(rpm -E %rhel).noarch.rpm 2>/dev/null
dnf install -y -q ffmpeg 2>/dev/null
fi
elif command -v dnf > /dev/null 2>&1; then
dnf install -y -q ffmpeg 2>/dev/null
fi
if command -v ffmpeg > /dev/null 2>&1; then
echo "ffmpeg installed successfully: $(ffmpeg -version | head -1)"
else
echo "Warning: ffmpeg installation failed or not available, some tests may be skipped"
fi
- name: Start Redis
shell: sh
run: |
@@ -394,7 +454,7 @@ jobs:
shell: sh
run: |
set -eu
pip install -q pytest-rerunfailures
python3 -m pip install -q pytest-rerunfailures
PYTHONPATH="$PWD/apps/api:$PWD" python3 -m coverage run --append \
--source=apps/api/app,packages \
--omit="*/migrations/*,*/tests/*,*/test_*.py,*/site-packages/*" \
+2 -2
View File
@@ -12,7 +12,7 @@ from app.schemas.asset_library import (
EnsureDefaultLibraryRequest,
ListAssetLibrariesResponse,
)
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from packages.application import (
CreateAssetLibraryCommand,
@@ -146,7 +146,7 @@ def ensure_default_library(
return _to_asset_library_response(created)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
def delete_asset_library(
library_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+4 -6
View File
@@ -1,6 +1,7 @@
import logging
from typing import Any, Optional
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
@@ -19,7 +20,7 @@ from app.schemas.asset import (
UpdateAssetReviewRequest,
)
from app.schemas.tag import TagAssetsRequest
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.application import (
CreateAssetCommand,
@@ -27,8 +28,6 @@ from packages.application import (
)
from packages.domain import AssetStatus, ClassificationStatus
from app.api.routes._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -74,7 +73,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
)
@router.get("", response_model=ListAssetsResponse)
def list_assets(
library_id: Optional[str] = Query(None),
@@ -330,7 +328,7 @@ def update_asset(
return _to_asset_response(updated)
@router.delete("/{asset_id}", status_code=204)
@router.delete("/{asset_id}", status_code=204, response_class=Response)
def delete_asset(
asset_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -369,7 +367,7 @@ def tag_asset(
return _to_asset_response(updated)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204)
@router.delete("/{asset_id}/tags/{tag_id}", status_code=204, response_class=Response)
def untag_asset(
asset_id: str,
tag_id: str,
+1 -2
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
@@ -34,8 +35,6 @@ from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
router = APIRouter()
logger = logging.getLogger(__name__)
+2 -2
View File
@@ -239,7 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@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),
@@ -257,7 +257,7 @@ def delete_duplication_record(
use_case = DeleteDuplicationRecordUseCase(duplication_repository)
use_case.execute(record_id)
return Response(status_code=204)
return
@router.post("/records/{record_id}/retry", response_model=DuplicationUploadResponse)
+4 -3
View File
@@ -25,14 +25,15 @@ from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from app.schemas.generation_task import GenerationTaskResponse
from app.services import EditPlanService
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from ._helpers import check_project_access
from packages.domain.config_schemas import normalize_plan_config
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from ._helpers import check_project_access
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -421,7 +422,7 @@ def update_plan(
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
@@ -15,8 +15,8 @@ from app.api.routes._helpers import check_project_access
from app.api.routes.edit_plans import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationStatusResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
+3 -3
View File
@@ -173,12 +173,12 @@ async def update_feature_flag(
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
) -> None:
) :
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -188,7 +188,7 @@ async def delete_feature_flag(
try:
deleted = store.delete(name)
logger.info("Feature flag deleted: name=%s deleted=%s", name, deleted)
return None
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}")
+1 -2
View File
@@ -3,6 +3,7 @@ import random
import uuid
from typing import Any
from app.api.routes._helpers import check_project_access
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.core.task_enqueue import (
@@ -31,8 +32,6 @@ from app.schemas.generation_task import (
)
from fastapi import APIRouter, Depends, HTTPException
from app.api.routes._helpers import check_project_access
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
+3 -3
View File
@@ -7,7 +7,7 @@ from app.schemas.project import (
ListProjectsResponse,
ProjectResponse,
)
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from packages.application import (
CreateProjectCommand,
@@ -72,7 +72,7 @@ def create_project(
return _to_project_response(project)
@router.delete("/{project_id}")
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_project(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -88,4 +88,4 @@ def delete_project(
)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return {"message": "Project deleted successfully"}
return
+2 -2
View File
@@ -10,7 +10,7 @@ from app.schemas.tag import (
ListTagsResponse,
TagResponse,
)
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Response
from packages.domain import Tag
@@ -52,7 +52,7 @@ def create_tag(
return TagResponse(id=created.id, name=created.name, created_at=created.created_at)
@router.delete("/{tag_id}", status_code=204)
@router.delete("/{tag_id}", status_code=204, response_class=Response)
def delete_tag(
tag_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+4 -4
View File
@@ -206,7 +206,7 @@ def update_template(
return _to_response(template)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -217,7 +217,7 @@ def delete_template(
deleted = use_case.execute(template_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return Response(status_code=204)
return
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
@@ -307,7 +307,7 @@ def create_category(
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@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),
@@ -318,4 +318,4 @@ def delete_category(
deleted = use_case.execute(category_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return Response(status_code=204)
return
+3 -4
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_user_repository
from app.schemas.title_library import (
@@ -28,8 +29,6 @@ from packages.application.title_library.use_cases import (
)
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -138,7 +137,7 @@ def update_title(
return _to_response(item)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@router.delete("/{title_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_title(
title_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -149,4 +148,4 @@ def delete_title(
deleted = use_case.execute(title_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return Response(status_code=204)
return
+2 -2
View File
@@ -241,7 +241,7 @@ def get_tts_job_status(
)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_tts_job(
job_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -253,7 +253,7 @@ def delete_tts_job(
deleted = use_case.execute(job_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return Response(status_code=204)
return
@router.post(
+1 -2
View File
@@ -2,6 +2,7 @@ import logging
from typing import Any
from uuid import uuid4
from app.api.routes._helpers import require_project_and_library
from app.auth import AuthenticatedUser, get_current_user
from app.config import get_settings
from app.core.celery_app import celery_app
@@ -23,8 +24,6 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
from app.api.routes._helpers import require_project_and_library
logger = logging.getLogger(__name__)
router = APIRouter()
+2 -1
View File
@@ -172,6 +172,7 @@ def get_voice_clone_status(
"/{clone_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
response_class=Response,
)
def delete_voice_clone(
clone_id: str,
@@ -184,7 +185,7 @@ def delete_voice_clone(
deleted = use_case.execute(clone_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return Response(status_code=204)
return
@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse)
+3 -4
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.schemas.voice import (
@@ -39,8 +40,6 @@ from packages.application.voice_library.use_cases import (
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
from app.api.routes._helpers import get_user_plan
router = APIRouter()
@@ -323,7 +322,7 @@ def update_voice(
return _to_response(item, sign_url)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
@router.delete("/{voice_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_voice(
voice_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -334,4 +333,4 @@ def delete_voice(
deleted = use_case.execute(voice_id, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return Response(status_code=204)
return
+1 -1
View File
@@ -5,8 +5,8 @@ This module keeps old import paths working so existing code
does not need to change.
"""
from packages.shared.storage import SharedStorageService as OSSStorageService
from packages.shared.storage import (
SharedStorageService as OSSStorageService,
get_shared_storage_service,
get_storage_service,
)
-1
View File
@@ -13,7 +13,6 @@ from __future__ import annotations
import logging
from typing import Any
from packages.application.jobs import (
CancelJobUseCase,
CompleteJobCommand,
+6
View File
@@ -26,6 +26,9 @@ export default defineConfig({
use: {
...devices["Desktop Chrome"],
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
launchOptions: {
args: ["--disable-gpu", "--disable-software-rasterizer"],
},
},
},
{
@@ -51,6 +54,9 @@ export default defineConfig({
use: {
...devices["Desktop Chrome"],
channel: process.env.E2E_BROWSER_CHANNEL || "msedge",
launchOptions: {
args: ["--disable-gpu", "--disable-software-rasterizer"],
},
},
},
],
+416
View File
@@ -0,0 +1,416 @@
"""滤镜调色引擎 — 基于 FFmpeg eq + colorbalance + hue + curves 滤镜组合实现画面色彩调整.
支持能力:
- 基础调色参数:亮度、对比度、饱和度、色温、色调
- 8种风格预设:清新、日系、复古、电影、胶片、黑白、暖色、冷色
- 分段应用:每个 clip 可独立设置不同滤镜
- 降级策略:参数越界自动钳制,不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ── 预设滤镜包 ────────────────────────────────────────────────────────────────
# 预设名称常量
PRESET_FRESH = "fresh" # 清新
PRESET_JAPANESE = "japanese" # 日系
PRESET_VINTAGE = "vintage" # 复古
PRESET_CINEMA = "cinema" # 电影
PRESET_FILM = "film" # 胶片
PRESET_BW = "black_white" # 黑白
PRESET_WARM = "warm" # 暖色
PRESET_COOL = "cool" # 冷色
VALID_PRESETS = {
PRESET_FRESH,
PRESET_JAPANESE,
PRESET_VINTAGE,
PRESET_CINEMA,
PRESET_FILM,
PRESET_BW,
PRESET_WARM,
PRESET_COOL,
}
# 预设名称 → 中文显示名
PRESET_DISPLAY_NAMES = {
PRESET_FRESH: "清新",
PRESET_JAPANESE: "日系",
PRESET_VINTAGE: "复古",
PRESET_CINEMA: "电影",
PRESET_FILM: "胶片",
PRESET_BW: "黑白",
PRESET_WARM: "暖色",
PRESET_COOL: "冷色",
}
# 预设参数配置
# 每个预设包含:brightness, contrast, saturation, temperature, hue
# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180
PRESET_PARAMS: dict[str, dict[str, float]] = {
PRESET_FRESH: {
# 清新:提亮、高饱和、偏冷、微微调
"brightness": 8,
"contrast": 10,
"saturation": 120,
"temperature": -8,
"hue": 5,
},
PRESET_JAPANESE: {
# 日系:低对比、低饱和、偏暖、偏黄绿
"brightness": 12,
"contrast": -15,
"saturation": 70,
"temperature": 10,
"hue": -5,
},
PRESET_VINTAGE: {
# 复古:低饱和、偏黄、对比度适中、偏暖
"brightness": -5,
"contrast": 5,
"saturation": 60,
"temperature": 25,
"hue": -8,
},
PRESET_CINEMA: {
# 电影:高对比、低饱和、偏冷蓝、暗角感
"brightness": -8,
"contrast": 20,
"saturation": 75,
"temperature": -15,
"hue": -3,
},
PRESET_FILM: {
# 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟)
"brightness": -3,
"contrast": 12,
"saturation": 95,
"temperature": 15,
"hue": -2,
},
PRESET_BW: {
# 黑白:饱和度为0,对比度略高
"brightness": 0,
"contrast": 15,
"saturation": 0,
"temperature": 0,
"hue": 0,
},
PRESET_WARM: {
# 暖色:高色温、偏红黄
"brightness": 5,
"contrast": 8,
"saturation": 110,
"temperature": 30,
"hue": -5,
},
PRESET_COOL: {
# 冷色:低色温、偏蓝青
"brightness": 3,
"contrast": 8,
"saturation": 105,
"temperature": -25,
"hue": 8,
},
}
# ── 参数范围 ──────────────────────────────────────────────────────────────────
PARAM_RANGES = {
"brightness": (-100.0, 100.0),
"contrast": (-100.0, 100.0),
"saturation": (0.0, 200.0),
"temperature": (-100.0, 100.0),
"hue": (-180.0, 180.0),
}
# 默认值(零调整)
DEFAULT_PARAMS = {
"brightness": 0.0,
"contrast": 0.0,
"saturation": 100.0,
"temperature": 0.0,
"hue": 0.0,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ColorGradeConfig:
"""色彩调色配置.
优先级:自定义参数 > 预设参数
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
"""
enabled: bool = False
preset: str = "" # 预设名称,空表示不使用预设
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
brightness: float | None = None
contrast: float | None = None
saturation: float | None = None
temperature: float | None = None
hue: float | None = None
def resolve_params(self) -> dict[str, float]:
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
Returns:
包含 brightness, contrast, saturation, temperature, hue 的参数字典
"""
# 1. 从默认值开始
params = dict(DEFAULT_PARAMS)
# 2. 应用预设
if self.preset and self.preset in PRESET_PARAMS:
params.update(PRESET_PARAMS[self.preset])
# 3. 应用自定义覆盖
if self.brightness is not None:
params["brightness"] = self.brightness
if self.contrast is not None:
params["contrast"] = self.contrast
if self.saturation is not None:
params["saturation"] = self.saturation
if self.temperature is not None:
params["temperature"] = self.temperature
if self.hue is not None:
params["hue"] = self.hue
# 4. 边界钳制
for key, (min_val, max_val) in PARAM_RANGES.items():
params[key] = max(min_val, min(max_val, params[key]))
return params
def has_effect(self) -> bool:
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
用于优化:无效果时跳过滤镜,不浪费性能。
"""
params = self.resolve_params()
for key, default in DEFAULT_PARAMS.items():
if abs(params[key] - default) > 0.001:
return True
return False
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
preset = data.get("preset", "")
if preset and preset not in VALID_PRESETS:
logger.warning("未知的调色预设: %s,忽略预设", preset)
preset = ""
def _get_float(key: str) -> float | None:
val = data.get(key)
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
try:
return cls(
enabled=True,
preset=preset,
brightness=_get_float("brightness"),
contrast=_get_float("contrast"),
saturation=_get_float("saturation"),
temperature=_get_float("temperature"),
hue=_get_float("hue"),
)
except Exception as e:
logger.warning("调色配置解析失败: %s,使用默认配置", e)
return cls(enabled=False)
# ── 调色引擎 ──────────────────────────────────────────────────────────────────
class ColorGradeEngine:
"""滤镜调色引擎 — 生成 FFmpeg 调色滤镜链.
滤镜组合策略:
1. eq 滤镜:调整亮度(brightness)、对比度(contrast)、饱和度(saturation)
2. colorbalance 滤镜:调整色温(通过调整红/青、黄/蓝平衡)
3. hue 滤镜:调整色调
所有参数转换公式:
- brightness: 用户值 -100~100 → FFmpeg eq brightness -1.0~1.0
- contrast: 用户值 -100~100 → FFmpeg eq contrast -1000~1000(非线性映射)
- saturation: 用户值 0~200 → FFmpeg eq saturation 0.0~2.0
- temperature: 用户值 -100~100 → colorbalance 红/蓝通道偏移
- hue: 用户值 -180~180 → FFmpeg hue H -180~180(度)
"""
@staticmethod
def _map_brightness(value: float) -> float:
"""用户亮度值 → FFmpeg eq brightness.
用户范围 -100~100 → FFmpeg范围 -1.0~1.0
"""
return value / 100.0
@staticmethod
def _map_contrast(value: float) -> float:
"""用户对比度值 → FFmpeg eq contrast.
用户范围 -100~100 → FFmpeg范围 -2.0~2.0
注:FFmpeg eq 的 contrast 公式为 linear gain,1.0 为原始
-2 ~ 2 的范围对应 ~-1000 ~ 1000 的老式定义的约 -66% ~ +100%
"""
if value >= 0:
# 正向:0~100 → 1.0~2.0
return 1.0 + value / 100.0
else:
# 负向:-100~0 → 0.0~1.0
return 1.0 + value / 100.0 # value为负数,相当于 1.0 - |value|/100
@staticmethod
def _map_saturation(value: float) -> float:
"""用户饱和度 → FFmpeg eq saturation.
用户范围 0~200 → FFmpeg范围 0.0~2.0
"""
return value / 100.0
@staticmethod
def _map_temperature(value: float) -> tuple[float, float, float]:
"""用户色温值 → colorbalance 三个通道参数.
返回:(red, green, blue) — 每个通道 -1.0~1.0 的偏移
色温为正(暖):增加红、减蓝
色温为负(冷):减红、加蓝
"""
# -100~100 → -0.5~0.5
normalized = value / 200.0
if normalized >= 0:
# 暖色调:红+,绿微+,蓝-
red = normalized * 0.8
green = normalized * 0.3
blue = -normalized * 0.8
else:
# 冷色调:红-,绿微+,蓝+
red = normalized * 0.8 # 负数
green = -normalized * 0.2 # 正数(冷色也加点绿让它偏青)
blue = -normalized * 0.8 # 正数
return (red, green, blue)
@staticmethod
def _map_hue(value: float) -> float:
"""用户色调值 → FFmpeg hue滤镜角度.
用户范围 -180~180 → FFmpeg H -180~180
"""
return value
@classmethod
def build_filter(cls, config: ColorGradeConfig, input_label: str = "", output_label: str = "") -> str:
"""构建调色滤镜字符串.
Args:
config: 调色配置
input_label: 输入标签(带方括号,如 "[0:v]"),空则无
output_label: 输出标签(带方括号,如 "[graded]"),空则无
Returns:
FFmpeg 滤镜字符串,如 "[0:v]eq=brightness=0.1:contrast=1.2,hue=H=10[graded]"
"""
if not config.enabled or not config.has_effect():
# 无效果时直通
if input_label and output_label:
return f"{input_label}copy{output_label}"
return ""
params = config.resolve_params()
filters: list[str] = []
# 1. eq 滤镜:亮度 + 对比度 + 饱和度
eq_parts: list[str] = []
brightness = cls._map_brightness(params["brightness"])
contrast = cls._map_contrast(params["contrast"])
saturation = cls._map_saturation(params["saturation"])
if abs(brightness) > 0.001:
eq_parts.append(f"brightness={brightness:.3f}")
if abs(contrast - 1.0) > 0.001:
eq_parts.append(f"contrast={contrast:.3f}")
if abs(saturation - 1.0) > 0.001:
eq_parts.append(f"saturation={saturation:.3f}")
if eq_parts:
filters.append(f"eq={':'.join(eq_parts)}")
# 2. colorbalance 滤镜:色温
if abs(params["temperature"]) > 0.001:
red, green, blue = cls._map_temperature(params["temperature"])
cb_parts = []
# 调整阴影/中间调/高光的平衡(简化:全部统一调整)
if abs(red) > 0.001:
cb_parts.append(f"rs={red:.3f}")
cb_parts.append(f"rm={red:.3f}")
cb_parts.append(f"rh={red:.3f}")
if abs(green) > 0.001:
cb_parts.append(f"gs={green:.3f}")
cb_parts.append(f"gm={green:.3f}")
cb_parts.append(f"gh={green:.3f}")
if abs(blue) > 0.001:
cb_parts.append(f"bs={blue:.3f}")
cb_parts.append(f"bm={blue:.3f}")
cb_parts.append(f"bh={blue:.3f}")
if cb_parts:
filters.append(f"colorbalance={':'.join(cb_parts)}")
# 3. hue 滤镜:色调
if abs(params["hue"]) > 0.001:
hue_val = cls._map_hue(params["hue"])
filters.append(f"hue=h={hue_val:.1f}")
if not filters:
# 理论上不会到这里(has_effect 已判断),保险起见
if input_label and output_label:
return f"{input_label}copy{output_label}"
return ""
filter_str = ",".join(filters)
if input_label:
filter_str = f"{input_label}{filter_str}"
if output_label:
filter_str = f"{filter_str}{output_label}"
return filter_str
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def get_preset_names() -> list[tuple[str, str]]:
"""获取所有预设名称列表.
Returns:
[(preset_key, display_name), ...]
"""
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
def get_preset_params(preset: str) -> dict[str, float] | None:
"""获取指定预设的参数."""
return PRESET_PARAMS.get(preset)
+3 -3
View File
@@ -16,15 +16,15 @@ import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
# 延迟导入避免循环依赖:unified_render_service 定义 ResolvedClip / RenderLayer,
# 本模块提供音频函数供 unified_render_service 调用。
# 使用 from __future__ import annotations + TYPE_CHECKING 解决类型引用。
from typing import TYPE_CHECKING
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
if TYPE_CHECKING:
from video_processing.unified_render_service import ResolvedClip, RenderLayer
from video_processing.unified_render_service import RenderLayer, ResolvedClip
logger = logging.getLogger(__name__)
@@ -28,6 +28,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine
from video_processing.ffmpeg_utils import (
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
@@ -244,9 +245,7 @@ class UnifiedRenderService:
video_duration=video_duration,
)
else:
filter_complex, input_args = self._build_filter_complex(
layers, ass_path=ass_path
)
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
t_video_end = time.time()
@@ -331,9 +330,7 @@ class UnifiedRenderService:
if not main_layer or not main_layer.clips:
return 0.0
total = sum(
UnifiedRenderService._clip_effective_duration(c) for c in main_layer.clips
)
total = sum(UnifiedRenderService._clip_effective_duration(c) for c in main_layer.clips)
# 减去转场重叠时间(粗略估算)
n_clips = len(main_layer.clips)
@@ -441,10 +438,7 @@ class UnifiedRenderService:
return False, f"像素格式不是yuv420p: {info.get('pix_fmt', 'unknown')}"
# 分辨率必须一致
if (
info.get("width", 0) != self.output_width
or info.get("height", 0) != self.output_height
):
if info.get("width", 0) != self.output_width or info.get("height", 0) != self.output_height:
return False, (
f"分辨率不匹配: "
f"{info.get('width', 0)}x{info.get('height', 0)} "
@@ -494,9 +488,7 @@ class UnifiedRenderService:
role = layers[0].role
# 判断是否满足 copy 条件
can_copy, reason = self._can_use_stream_copy(
clip, ass_path=ass_path, video_duration=video_duration
)
can_copy, reason = self._can_use_stream_copy(clip, ass_path=ass_path, video_duration=video_duration)
if not can_copy:
logger.info(
"[unified-render] stream_copy 跳过: plan_id=%s reason=%s",
@@ -524,9 +516,7 @@ class UnifiedRenderService:
# 计算最终时长
final_duration = effective_duration
if video_duration > 0 and (
final_duration <= 0 or final_duration > video_duration
):
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
@@ -563,9 +553,7 @@ class UnifiedRenderService:
)
return True
else:
logger.warning(
"[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id
)
logger.warning("[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id)
return False
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
@@ -628,12 +616,16 @@ class UnifiedRenderService:
filters.append(f"scale={pip_w}:{pip_h}")
else:
# main / broll / background: 铺满裁剪
filters.append(
f"scale={self.output_width}:{self.output_height}"
":force_original_aspect_ratio=increase"
)
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase")
filters.append(f"crop={self.output_width}:{self.output_height}")
# 调色滤镜
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
grade_filter = ColorGradeEngine.build_filter(color_grade)
if grade_filter:
filters.append(grade_filter)
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
filters.append("format=yuv420p")
@@ -647,9 +639,7 @@ class UnifiedRenderService:
# 最终输出时长:取 clip 有效时长和 video_duration 的较小值
final_duration = effective_duration
if video_duration > 0 and (
final_duration <= 0 or final_duration > video_duration
):
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
command = [
@@ -748,9 +738,7 @@ class UnifiedRenderService:
resolved.sort(key=lambda c: c.order)
return resolved
def _group_clips_into_layers(
self, resolved_clips: list[ResolvedClip]
) -> list[RenderLayer]:
def _group_clips_into_layers(self, resolved_clips: list[ResolvedClip]) -> list[RenderLayer]:
"""将 ResolvedClips 分组为 RenderLayers。
分组规则见 _resolve_layer_role 函数文档。
@@ -840,19 +828,24 @@ class UnifiedRenderService:
filters.append(f"scale={pip_w}:{pip_h}")
elif role == "background":
filters.append(
f"scale={self.output_width}:{self.output_height}"
":force_original_aspect_ratio=increase"
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
)
filters.append(f"crop={self.output_width}:{self.output_height}")
else:
# main / broll: 铺满裁剪(scale to cover + center crop)
# 对齐链路A编辑器合成行为,与主流短视频平台一致
filters.append(
f"scale={self.output_width}:{self.output_height}"
":force_original_aspect_ratio=increase"
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
)
filters.append(f"crop={self.output_width}:{self.output_height}")
# 调色滤镜(每个 clip 独立的 color grade 配置)
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
grade_filter = ColorGradeEngine.build_filter(color_grade)
if grade_filter:
filters.append(grade_filter)
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
@@ -866,13 +859,8 @@ class UnifiedRenderService:
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
layer_durations = [
UnifiedRenderService._clip_effective_duration(all_clips[i])
for i in layer_clip_indices
]
layer_transitions = [
all_clips[i].transition_effect for i in layer_clip_indices
]
layer_durations = [UnifiedRenderService._clip_effective_duration(all_clips[i]) for i in layer_clip_indices]
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
if len(layer_labels) == 1:
# 单 clip 层,直接使用预处理标签
@@ -903,8 +891,7 @@ class UnifiedRenderService:
base_label = layer_output_labels[role]
combined_label = f"combined_{role}"
filter_parts.append(
f"[{final_video_label}][{base_label}]"
f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
f"[{final_video_label}][{base_label}]" f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
)
final_video_label = combined_label
else:
@@ -929,18 +916,13 @@ class UnifiedRenderService:
20,
)
combined_label = f"combined_{layer.role}"
filter_parts.append(
f"[{final_video_label}][{overlay_label}]"
f"overlay={x}:{y}[{combined_label}]"
)
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
final_video_label = combined_label
# 叠加字幕(如有)+ 最终像素格式
if ass_path is not None:
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
filter_parts.append(
f"[{final_video_label}]subtitles='{ass_filter_path}',format=yuv420p[final_video]"
)
filter_parts.append(f"[{final_video_label}]subtitles='{ass_filter_path}',format=yuv420p[final_video]")
else:
filter_parts.append(f"[{final_video_label}]format=yuv420p[final_video]")
@@ -1015,9 +997,5 @@ class UnifiedRenderService:
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长."""
if clip.duration > 0:
return (
min(clip.duration, clip.actual_duration)
if clip.actual_duration > 0
else clip.duration
)
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
+10 -33
View File
@@ -96,11 +96,7 @@ def _flush_logs(task_id: str, gen_task) -> None:
try:
from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel
model = (
session.query(GenerationTaskModel)
.filter(GenerationTaskModel.id == task_id)
.first()
)
model = session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task_id).first()
if model:
model.logs = gen_task.logs
session.commit()
@@ -413,9 +409,7 @@ def _download_library_assets(
else:
# 未指定 asset_ids:按 library 或 project 下载全部 ready 视频
if asset_library_id:
query = query.filter(
AssetModel.asset_library_id == asset_library_id
)
query = query.filter(AssetModel.asset_library_id == asset_library_id)
logger.info(
"下载素材库全部视频: asset_library_id=%s",
asset_library_id,
@@ -430,11 +424,7 @@ def _download_library_assets(
assets = query.order_by(AssetModel.created_at).all()
if not assets:
mode_desc = (
f"素材库 {asset_library_id}"
if asset_library_id
else f"项目 {project_id}"
)
mode_desc = f"素材库 {asset_library_id}" if asset_library_id else f"项目 {project_id}"
msg = f"未找到视频素材: {mode_desc}, asset_ids={asset_ids or 'all'}"
logger.error(msg)
raise RuntimeError(msg)
@@ -489,9 +479,7 @@ def _download_library_assets(
duration=0.0,
)
if strict:
raise RuntimeError(
f"素材缺少 file_url: asset_id={asset.id}, name={asset.name}"
)
raise RuntimeError(f"素材缺少 file_url: asset_id={asset.id}, name={asset.name}")
continue
ext = Path(storage_key).suffix or ".mp4"
@@ -541,9 +529,7 @@ def _download_library_assets(
duration=round(asset_elapsed, 2),
)
if strict:
raise RuntimeError(
f"素材下载失败: asset_id={asset.id}, name={asset.name}"
)
raise RuntimeError(f"素材下载失败: asset_id={asset.id}, name={asset.name}")
# 指定了 asset_ids 但全部下载失败 → 无论 strict 与否都报错
if asset_ids and not downloaded:
@@ -852,9 +838,7 @@ def _render_video(
# 选择渲染引擎
engine = _resolve_render_engine(user_id) if user_id else ENGINE_UNIFIED
logger.info(
"[task_id=%s] [渲染] 引擎选择: %s (user_id=%s)", task_id, engine, user_id
)
logger.info("[task_id=%s] [渲染] 引擎选择: %s (user_id=%s)", task_id, engine, user_id)
render_start = time.monotonic()
render_output_path = temp_path / f"rendered-{task_id}.mp4"
@@ -898,9 +882,7 @@ def _render_video(
_mux_audio_track(render_output_path, voice_path, final_path)
output_path = final_path
except Exception as mux_err:
logger.warning(
"[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err
)
logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err)
output_path = render_output_path
else:
output_path = render_output_path
@@ -929,9 +911,7 @@ def _upload_and_record(
file_url = upload_to_oss(output_path, storage_key)
upload_elapsed = time.monotonic() - upload_start
if not file_url:
raise RuntimeError(
f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}"
)
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
@@ -942,8 +922,7 @@ def _upload_and_record(
key = normalize_storage_key(file_url)
if not (bucket and bucket.object_exists(key)):
raise RuntimeError(
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, "
f"storage_key={storage_key}"
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
)
logger.info(
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
@@ -1044,9 +1023,7 @@ def generate_video(self, task_id: str) -> dict:
_update_task_status(task_id, "mark_processing")
try:
editing_mode = (
EditingMode(mode) if (mode := task_info["mode"]) else EditingMode.ONE_TAKE
)
editing_mode = EditingMode(mode) if (mode := task_info["mode"]) else EditingMode.ONE_TAKE
except ValueError:
editing_mode = EditingMode.ONE_TAKE
+3 -6
View File
@@ -63,8 +63,7 @@ BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG},mode=max" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/api-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/api.Dockerfile \
-t "$API_IMAGE" -t "$API_LATEST" \
--load \
@@ -123,8 +122,7 @@ echo "=== Building Worker image ==="
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG},mode=max" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/worker-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/worker.Dockerfile \
-t "$WORKER_IMAGE" -t "$WORKER_LATEST" \
--load \
@@ -152,8 +150,7 @@ test -f apps/web/dist/index.html
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},ignore-error=true" \
--cache-to "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG},mode=max" \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
-f infra/docker/web-artifact.Dockerfile \
--build-arg "NGINX_CONF=$NGINX_CONF_FILE" \
-t "$WEB_IMAGE" \
+4 -12
View File
@@ -119,9 +119,7 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
text=True,
check=True,
)
files = [
line.strip() for line in result.stdout.strip().split("\n") if line.strip()
]
files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
@@ -129,9 +127,7 @@ def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
def find_new_migrations(
since_revision: str | None = None, diff_against: str | None = None
) -> List[Path]:
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
"""
找出需要检查的迁移文件。
优先级:diff_against > since_revision > 全部
@@ -183,9 +179,7 @@ def analyze_migration(file_path: Path) -> Tuple[List[str], List[str], List[str]]
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument(
"--since",
default=os.getenv("MIGRATION_SINCE_REVISION"),
@@ -248,9 +242,7 @@ def main() -> int:
print()
print("=" * 60)
print(
f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险"
)
print(f"检查结果:{len(all_safe)} 项安全 / {len(all_medium)} 项中风险 / {len(all_high)} 项高风险")
print()
if all_high:
+2 -2
View File
@@ -308,7 +308,7 @@ class TestPasswordReset:
)
response = client.post(
"/api/v1/auth/password/forgot",
"/api/v1/auth/forgot-password",
json={"email": test_email},
)
@@ -318,7 +318,7 @@ class TestPasswordReset:
def test_request_password_reset_nonexistent_user(self):
"""测试请求不存在的用户密码重置"""
response = client.post(
"/api/v1/auth/password/forgot",
"/api/v1/auth/forgot-password",
json={"email": "nonexistent@example.com"},
)
+1 -1
View File
@@ -219,7 +219,7 @@ class TestDeleteAssetLibrary:
response = client.delete("/api/v1/asset-libraries/lib-1")
assert response.status_code == 403
assert "Access denied" in response.json()["detail"]
assert "无权访问该项目" in response.json()["detail"]
# 库未被删除
assert lib_repo.find_by_id("lib-1") is not None
+572
View File
@@ -0,0 +1,572 @@
"""滤镜调色引擎单元测试."""
from __future__ import annotations
import pytest
from video_processing.color_grade_engine import (
DEFAULT_PARAMS,
PARAM_RANGES,
PRESET_BW,
PRESET_CINEMA,
PRESET_COOL,
PRESET_DISPLAY_NAMES,
PRESET_FILM,
PRESET_FRESH,
PRESET_JAPANESE,
PRESET_PARAMS,
PRESET_VINTAGE,
PRESET_WARM,
ColorGradeConfig,
ColorGradeEngine,
get_preset_names,
get_preset_params,
)
# ── 预设常量测试 ──────────────────────────────────────────────────────────────
class TestPresetConstants:
"""预设常量完整性测试."""
def test_eight_presets_defined(self):
"""应该有8种预设."""
assert len(PRESET_PARAMS) == 8
assert len(PRESET_DISPLAY_NAMES) == 8
def test_all_presets_have_display_names(self):
"""每个预设都应该有中文显示名."""
for key in PRESET_PARAMS:
assert key in PRESET_DISPLAY_NAMES
assert PRESET_DISPLAY_NAMES[key] # 非空
def test_preset_params_have_all_keys(self):
"""每个预设应该包含所有5个参数."""
required_keys = {"brightness", "contrast", "saturation", "temperature", "hue"}
for key, params in PRESET_PARAMS.items():
assert required_keys.issubset(params.keys()), f"预设 {key} 缺少参数"
def test_preset_params_in_valid_range(self):
"""所有预设参数应该在合法范围内."""
for preset_name, params in PRESET_PARAMS.items():
for param_name, value in params.items():
min_val, max_val = PARAM_RANGES[param_name]
assert (
min_val <= value <= max_val
), f"预设 {preset_name} 的 {param_name}={value} 超出范围 [{min_val}, {max_val}]"
def test_black_white_has_zero_saturation(self):
"""黑白预设饱和度应该为0."""
assert PRESET_PARAMS[PRESET_BW]["saturation"] == 0
def test_warm_preset_has_positive_temperature(self):
"""暖色预设色温应该为正."""
assert PRESET_PARAMS[PRESET_WARM]["temperature"] > 0
def test_cool_preset_has_negative_temperature(self):
"""冷色预设色温应该为负."""
assert PRESET_PARAMS[PRESET_COOL]["temperature"] < 0
# ── ColorGradeConfig.from_dict 测试 ───────────────────────────────────────────
class TestColorGradeConfigFromDict:
"""配置字典解析测试."""
def test_none_config(self):
"""None返回disabled."""
config = ColorGradeConfig.from_dict(None)
assert not config.enabled
def test_empty_dict(self):
"""空字典返回disabled."""
config = ColorGradeConfig.from_dict({})
assert not config.enabled
def test_enabled_false(self):
"""enabled=False返回disabled."""
config = ColorGradeConfig.from_dict({"enabled": False})
assert not config.enabled
def test_enabled_only(self):
"""只开enabled,无预设无自定义参数."""
config = ColorGradeConfig.from_dict({"enabled": True})
assert config.enabled
assert config.preset == ""
assert config.brightness is None
assert config.contrast is None
assert config.saturation is None
assert config.temperature is None
assert config.hue is None
def test_with_preset(self):
"""指定预设."""
config = ColorGradeConfig.from_dict({"enabled": True, "preset": PRESET_FRESH})
assert config.enabled
assert config.preset == PRESET_FRESH
def test_invalid_preset_ignored(self):
"""无效预设名应该被忽略."""
config = ColorGradeConfig.from_dict({"enabled": True, "preset": "invalid_preset"})
assert config.preset == "" # 被清空
def test_with_custom_params(self):
"""自定义参数覆盖."""
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": 20,
"contrast": -10,
"saturation": 150,
"temperature": 25,
"hue": 30,
}
)
assert config.enabled
assert config.brightness == 20
assert config.contrast == -10
assert config.saturation == 150
assert config.temperature == 25
assert config.hue == 30
def test_string_numeric_values(self):
"""字符串形式的数字应该能解析."""
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": "20.5",
"saturation": "150",
}
)
assert config.brightness == 20.5
assert config.saturation == 150.0
def test_invalid_value_returns_none(self):
"""无效值应该返回None(不覆盖)."""
config = ColorGradeConfig.from_dict(
{
"enabled": True,
"brightness": "not_a_number",
}
)
assert config.brightness is None
# ── ColorGradeConfig.resolve_params 测试 ──────────────────────────────────────
class TestResolveParams:
"""参数解析与边界钳制测试."""
def test_default_params_when_empty(self):
"""无预设无自定义时返回默认值."""
config = ColorGradeConfig(enabled=True)
params = config.resolve_params()
for key, val in DEFAULT_PARAMS.items():
assert params[key] == val
def test_preset_params_applied(self):
"""预设参数应该被应用."""
config = ColorGradeConfig(enabled=True, preset=PRESET_FRESH)
params = config.resolve_params()
preset = PRESET_PARAMS[PRESET_FRESH]
for key, val in preset.items():
assert params[key] == val
def test_custom_overrides_preset(self):
"""自定义参数应该覆盖预设值."""
config = ColorGradeConfig(
enabled=True,
preset=PRESET_FRESH,
brightness=50, # 覆盖预设的8
)
params = config.resolve_params()
assert params["brightness"] == 50
# 其他参数还是预设值
assert params["contrast"] == PRESET_PARAMS[PRESET_FRESH]["contrast"]
def test_clamp_brightness_high(self):
"""亮度超过上限应该被钳制."""
config = ColorGradeConfig(enabled=True, brightness=200)
params = config.resolve_params()
assert params["brightness"] == 100
def test_clamp_brightness_low(self):
"""亮度低于下限应该被钳制."""
config = ColorGradeConfig(enabled=True, brightness=-200)
params = config.resolve_params()
assert params["brightness"] == -100
def test_clamp_saturation_low(self):
"""饱和度低于0应该被钳制到0."""
config = ColorGradeConfig(enabled=True, saturation=-50)
params = config.resolve_params()
assert params["saturation"] == 0
def test_clamp_saturation_high(self):
"""饱和度超过200应该被钳制."""
config = ColorGradeConfig(enabled=True, saturation=300)
params = config.resolve_params()
assert params["saturation"] == 200
def test_clamp_hue_high(self):
"""色调超过180应该被钳制."""
config = ColorGradeConfig(enabled=True, hue=270)
params = config.resolve_params()
assert params["hue"] == 180
def test_clamp_hue_low(self):
"""色调低于-180应该被钳制."""
config = ColorGradeConfig(enabled=True, hue=-270)
params = config.resolve_params()
assert params["hue"] == -180
def test_clamp_contrast(self):
"""对比度越界应该被钳制."""
config = ColorGradeConfig(enabled=True, contrast=150)
params = config.resolve_params()
assert params["contrast"] == 100
config2 = ColorGradeConfig(enabled=True, contrast=-150)
params2 = config2.resolve_params()
assert params2["contrast"] == -100
def test_clamp_temperature(self):
"""色温越界应该被钳制."""
config = ColorGradeConfig(enabled=True, temperature=150)
params = config.resolve_params()
assert params["temperature"] == 100
def test_preset_with_clamping(self):
"""预设+自定义覆盖,自定义值超范围仍需钳制."""
config = ColorGradeConfig(
enabled=True,
preset=PRESET_FRESH,
brightness=999, # 超范围
)
params = config.resolve_params()
assert params["brightness"] == 100 # 被钳制
# ── ColorGradeConfig.has_effect 测试 ──────────────────────────────────────────
class TestHasEffect:
"""是否有实际效果判断测试."""
def test_disabled_has_no_effect(self):
"""disabled的配置has_effect应该返回False."""
config = ColorGradeConfig(enabled=False)
assert not config.has_effect()
def test_default_params_no_effect(self):
"""所有参数都是默认值时应该返回False."""
config = ColorGradeConfig(enabled=True)
assert not config.has_effect()
def test_brightness_change_has_effect(self):
"""亮度变化应该有效果."""
config = ColorGradeConfig(enabled=True, brightness=10)
assert config.has_effect()
def test_saturation_100_no_effect(self):
"""饱和度100是默认值,无效果."""
config = ColorGradeConfig(enabled=True, saturation=100)
assert not config.has_effect()
def test_saturation_not_100_has_effect(self):
"""饱和度不等于100有效果."""
config = ColorGradeConfig(enabled=True, saturation=99)
assert config.has_effect()
def test_preset_has_effect(self):
"""预设通常有效果."""
for preset in PRESET_PARAMS:
config = ColorGradeConfig(enabled=True, preset=preset)
assert config.has_effect(), f"预设 {preset} 应该有效果"
def test_custom_zero_override_no_effect(self):
"""用预设但所有自定义值都设为默认值抵消 → 应该has_effect看实际值."""
# 黑白预设饱和度=0,如果手动覆盖饱和度=100、其他都=默认值,则可能无效果
config = ColorGradeConfig(
enabled=True,
preset=PRESET_BW,
brightness=0,
contrast=0,
saturation=100,
temperature=0,
hue=0,
)
assert not config.has_effect()
# ── ColorGradeEngine 参数映射测试 ─────────────────────────────────────────────
class TestParameterMapping:
"""FFmpeg参数映射测试."""
def test_brightness_mapping_zero(self):
"""亮度0 → 0.0."""
assert ColorGradeEngine._map_brightness(0) == 0.0
def test_brightness_mapping_max(self):
"""亮度100 → 1.0."""
assert ColorGradeEngine._map_brightness(100) == 1.0
def test_brightness_mapping_min(self):
"""亮度-100 → -1.0."""
assert ColorGradeEngine._map_brightness(-100) == -1.0
def test_contrast_mapping_zero(self):
"""对比度0 → 1.0(原始)."""
assert ColorGradeEngine._map_contrast(0) == 1.0
def test_contrast_mapping_positive(self):
"""正对比度应该 > 1.0."""
assert ColorGradeEngine._map_contrast(50) == 1.5
assert ColorGradeEngine._map_contrast(100) == 2.0
def test_contrast_mapping_negative(self):
"""负对比度应该 < 1.0."""
assert ColorGradeEngine._map_contrast(-50) == 0.5
assert ColorGradeEngine._map_contrast(-100) == 0.0
def test_saturation_mapping_default(self):
"""饱和度100 → 1.0."""
assert ColorGradeEngine._map_saturation(100) == 1.0
def test_saturation_mapping_zero(self):
"""饱和度0 → 0.0(黑白)."""
assert ColorGradeEngine._map_saturation(0) == 0.0
def test_saturation_mapping_double(self):
"""饱和度200 → 2.0."""
assert ColorGradeEngine._map_saturation(200) == 2.0
def test_temperature_warm(self):
"""暖色温应该红+蓝-."""
red, green, blue = ColorGradeEngine._map_temperature(100)
assert red > 0
assert blue < 0
def test_temperature_cool(self):
"""冷色温应该红-蓝+."""
red, green, blue = ColorGradeEngine._map_temperature(-100)
assert red < 0
assert blue > 0
def test_temperature_zero(self):
"""色温0应该全0."""
red, green, blue = ColorGradeEngine._map_temperature(0)
assert red == 0
assert green == 0
assert blue == 0
def test_hue_mapping_passthrough(self):
"""色调直接透传."""
assert ColorGradeEngine._map_hue(0) == 0
assert ColorGradeEngine._map_hue(90) == 90
assert ColorGradeEngine._map_hue(-45) == -45
# ── ColorGradeEngine.build_filter 测试 ────────────────────────────────────────
class TestBuildFilter:
"""滤镜字符串构建测试."""
def test_disabled_returns_empty(self):
"""disabled配置返回空."""
config = ColorGradeConfig(enabled=False)
result = ColorGradeEngine.build_filter(config)
assert result == ""
def test_no_effect_returns_empty(self):
"""无效果的配置返回空."""
config = ColorGradeConfig(enabled=True)
result = ColorGradeEngine.build_filter(config)
assert result == ""
def test_brightness_only(self):
"""只有亮度调整."""
config = ColorGradeConfig(enabled=True, brightness=20)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
assert "brightness=" in result
assert "contrast=" not in result
assert "saturation=" not in result
def test_contrast_only(self):
"""只有对比度调整."""
config = ColorGradeConfig(enabled=True, contrast=30)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
assert "contrast=" in result
def test_saturation_only(self):
"""只有饱和度调整."""
config = ColorGradeConfig(enabled=True, saturation=50)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
assert "saturation=" in result
def test_temperature_only(self):
"""只有色温调整."""
config = ColorGradeConfig(enabled=True, temperature=20)
result = ColorGradeEngine.build_filter(config)
assert "colorbalance=" in result
# 暖色调应该有红通道调整
assert "rs=" in result
def test_hue_only(self):
"""只有色调调整."""
config = ColorGradeConfig(enabled=True, hue=30)
result = ColorGradeEngine.build_filter(config)
assert "hue=h=" in result
def test_with_input_output_labels(self):
"""带输入输出标签."""
config = ColorGradeConfig(enabled=True, brightness=10)
result = ColorGradeEngine.build_filter(config, input_label="[0:v]", output_label="[out]")
assert result.startswith("[0:v]")
assert result.endswith("[out]")
def test_preset_fresh_filter(self):
"""清新预设应该生成eq滤镜."""
config = ColorGradeConfig(enabled=True, preset=PRESET_FRESH)
result = ColorGradeEngine.build_filter(config)
assert "eq=" in result
# 清新预设饱和度>100,应该有saturation
assert "saturation=" in result
def test_preset_bw_filter(self):
"""黑白预设应该有saturation=0."""
config = ColorGradeConfig(enabled=True, preset=PRESET_BW)
result = ColorGradeEngine.build_filter(config)
assert "saturation=0.0" in result
def test_combined_params(self):
"""多个参数组合."""
config = ColorGradeConfig(
enabled=True,
brightness=15,
contrast=20,
saturation=130,
temperature=10,
hue=5,
)
result = ColorGradeEngine.build_filter(config)
# 应该有三个滤镜用逗号连接
assert "eq=" in result
assert "colorbalance=" in result
assert "hue=" in result
# 逗号分隔
assert "," in result
def test_filter_chain_order(self):
"""滤镜顺序应该是 eq → colorbalance → hue."""
config = ColorGradeConfig(
enabled=True,
brightness=10,
temperature=10,
hue=10,
)
result = ColorGradeEngine.build_filter(config)
eq_pos = result.find("eq=")
cb_pos = result.find("colorbalance=")
hue_pos = result.find("hue=")
assert eq_pos < cb_pos < hue_pos
def test_zero_temperature_no_colorbalance(self):
"""色温为0不应该有colorbalance滤镜."""
config = ColorGradeConfig(enabled=True, temperature=0, brightness=10)
result = ColorGradeEngine.build_filter(config)
assert "colorbalance" not in result
def test_zero_hue_no_hue_filter(self):
"""色调为0不应该有hue滤镜."""
config = ColorGradeConfig(enabled=True, hue=0, brightness=10)
result = ColorGradeEngine.build_filter(config)
assert "hue=" not in result
def test_all_presets_generate_valid_filter(self):
"""所有预设都应该能生成有效的非空滤镜."""
for preset_name in PRESET_PARAMS:
config = ColorGradeConfig(enabled=True, preset=preset_name)
result = ColorGradeEngine.build_filter(config)
assert result, f"预设 {preset_name} 应该生成非空滤镜"
# 不应该有语法错误(连续冒号、空参数等)
assert "::" not in result
assert result[0] != ":"
assert result[-1] != ":"
# ── 便捷函数测试 ──────────────────────────────────────────────────────────────
class TestHelperFunctions:
"""便捷函数测试."""
def test_get_preset_names_returns_eight(self):
"""应该返回8个预设."""
names = get_preset_names()
assert len(names) == 8
# 每个是 (key, display_name) 元组
for key, display in names:
assert key in PRESET_PARAMS
assert isinstance(display, str)
assert display
def test_get_preset_params_valid(self):
"""获取有效预设的参数."""
params = get_preset_params(PRESET_FRESH)
assert params is not None
assert params == PRESET_PARAMS[PRESET_FRESH]
def test_get_preset_params_invalid(self):
"""获取无效预设返回None."""
params = get_preset_params("nonexistent")
assert params is None
# ── 分段调色(不同clip不同滤镜)概念验证 ──────────────────────────────────────
class TestPerClipGrading:
"""分段调色概念验证 — 不同配置生成不同滤镜."""
def test_different_presets_different_filters(self):
"""不同预设应该生成不同的滤镜字符串."""
configs = [
ColorGradeConfig(enabled=True, preset=PRESET_FRESH),
ColorGradeConfig(enabled=True, preset=PRESET_VINTAGE),
ColorGradeConfig(enabled=True, preset=PRESET_BW),
]
filters = [ColorGradeEngine.build_filter(c) for c in configs]
# 三个滤镜应该各不相同
assert len(set(filters)) == 3
def test_same_preset_same_filter(self):
"""相同配置应该生成相同滤镜(确定性)."""
config1 = ColorGradeConfig(enabled=True, preset=PRESET_CINEMA)
config2 = ColorGradeConfig(enabled=True, preset=PRESET_CINEMA)
assert ColorGradeEngine.build_filter(config1) == ColorGradeEngine.build_filter(config2)
def test_custom_override_changes_filter(self):
"""自定义覆盖应该改变滤镜."""
base = ColorGradeConfig(enabled=True, preset=PRESET_FILM)
modified = ColorGradeConfig(enabled=True, preset=PRESET_FILM, brightness=50)
assert ColorGradeEngine.build_filter(base) != ColorGradeEngine.build_filter(modified)
def test_clips_with_and_without_grading(self):
"""有的clip有调色有的没有,生成结果不同."""
with_grade = ColorGradeConfig(enabled=True, preset=PRESET_WARM)
without_grade = ColorGradeConfig(enabled=False)
filter_with = ColorGradeEngine.build_filter(with_grade, "[0:v]", "[v0]")
filter_without = ColorGradeEngine.build_filter(without_grade, "[0:v]", "[v0]")
assert filter_with # 有调色应该非空
# 无调色但带标签时应该走 copy 直通(保证标签传递)
assert "[0:v]copy[v0]" in filter_without
Regular → Executable
+1 -1
View File
@@ -37,7 +37,7 @@ def _fresh_settings(**env_overrides: dict[str, str]):
"JWT_SECRET_KEY": "unit-test-secret-key-12345",
**env_overrides,
}
with patch.dict(os.environ, env, clear=False):
with patch.dict(os.environ, env, clear=True):
Settings = _load_settings_class()
return Settings()
+7 -7
View File
@@ -318,7 +318,7 @@ class TestGeneratePlan:
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
@@ -406,7 +406,7 @@ class TestGeneratePlan:
clip = _make_clip(plan.id, order=1, status=EditPlanClipStatus.READY)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
@@ -427,7 +427,7 @@ class TestGeneratePlan:
clip = _make_clip(plan.id, order=i + 1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
@@ -580,7 +580,7 @@ class TestResponseSchema:
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
mock_celery.send_task = MagicMock()
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
@@ -623,7 +623,7 @@ class TestGeneratePlanErrorHandling:
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
# 模拟 Celery 调度失败
mock_celery.send_task.side_effect = RuntimeError("Redis 连接超时")
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
@@ -647,7 +647,7 @@ class TestGeneratePlanErrorHandling:
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
mock_celery.send_task.side_effect = RuntimeError("调度失败")
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
@@ -668,7 +668,7 @@ class TestGeneratePlanErrorHandling:
clip = _make_clip(plan.id, order=1)
clip_repo.create(clip)
with patch("app.api.routes.edit_plans.celery_app") as mock_celery:
with patch("app.api.routes.edit_plans_generation.celery_app") as mock_celery:
mock_celery.send_task.side_effect = ConnectionError("Broker 不可达")
resp = client.post(f"/api/v1/edit-plans/{plan.id}/generate")
Executable → Regular
-1
View File
@@ -7,7 +7,6 @@ from __future__ import annotations
from unittest.mock import MagicMock
from packages.adapters.redis.feature_flag_store import (
FeatureFlagConfig,
InMemoryFeatureFlagStore,
+3
View File
@@ -8,9 +8,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
import app.config as app_config
from app.core.storage import OSSStorageService
import packages.shared.config as shared_config
def _reset_settings() -> None:
app_config._settings = None
shared_config._settings = None
def test_create_direct_upload_post_limits_key_and_size(monkeypatch):
-1
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain.tts_job import TTSJob, TTSJobStatus