Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e47583a3c3 | |||
| f991da2b33 | |||
| 17fbae13a8 | |||
| 41e421b44b | |||
| 9f86bd40ca | |||
| 881eea9195 | |||
| 4faceb8093 |
Executable → Regular
+61
-1
@@ -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/*" \
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable → Regular
+4
-3
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Executable → Regular
+1
-2
@@ -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,
|
||||
|
||||
Executable → Regular
+3
-3
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Executable → Regular
+3
-4
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Executable → Regular
-1
@@ -13,7 +13,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
||||
from packages.application.jobs import (
|
||||
CancelJobUseCase,
|
||||
CompleteJobCommand,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+572
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Executable → Regular
+7
-7
@@ -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
@@ -7,7 +7,6 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
from packages.adapters.redis.feature_flag_store import (
|
||||
FeatureFlagConfig,
|
||||
InMemoryFeatureFlagStore,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user