Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2a5c9417e | |||
| 2b6ba6e074 | |||
| 640672b798 | |||
| 1feff1c6fb | |||
| f77f519582 | |||
| 82de158e35 | |||
| 6213569ec8 | |||
| d91a803719 | |||
| b08598290b | |||
| 438de40998 | |||
| 0bb1406b75 | |||
| 72d77b2b9a | |||
| 445ba24ab5 | |||
| 83137d5c2e | |||
| 3ce6d47d46 |
@@ -1 +0,0 @@
|
||||
re-trigger
|
||||
+1
-1
@@ -1 +1 @@
|
||||
trigger: 1784009947
|
||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
||||
|
||||
+993
-952
File diff suppressed because one or more lines are too long
@@ -1,47 +0,0 @@
|
||||
"""add error_info and retry fields to generation_tasks
|
||||
|
||||
Revision ID: 038_error_retry
|
||||
Revises: 037_generation_logs
|
||||
Create Date: 2026-07-13 22:15:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import JSON as MySQLJSON
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "038_error_retry"
|
||||
down_revision = "037_generation_logs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# error_info: 结构化错误信息(error_type, message, stack_trace, failed_at, stage等)
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("error_info", sa.JSON(), nullable=True),
|
||||
)
|
||||
# retry_count: 重试次数
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
# auto_retry_enabled: 是否开启自动重试
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("auto_retry_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
)
|
||||
# auto_retry_max: 最大自动重试次数
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("auto_retry_max", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("generation_tasks", "auto_retry_max")
|
||||
op.drop_column("generation_tasks", "auto_retry_enabled")
|
||||
op.drop_column("generation_tasks", "retry_count")
|
||||
op.drop_column("generation_tasks", "error_info")
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add transition_duration to edit_plan_clips
|
||||
|
||||
Revision ID: 039_transition_duration
|
||||
Revises: 038_error_retry
|
||||
Create Date: 2026-07-14 09:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "039_transition_duration"
|
||||
down_revision = "038_error_retry"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plan_clips",
|
||||
sa.Column(
|
||||
"transition_duration",
|
||||
sa.Float(),
|
||||
nullable=False,
|
||||
server_default="0.0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_plan_clips", "transition_duration")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""add playback_speed to edit_plan_clips
|
||||
|
||||
Revision ID: 040_playback_speed
|
||||
Revises: 039_transition_duration
|
||||
Create Date: 2026-07-14 10:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "040_playback_speed"
|
||||
down_revision = "039_transition_duration"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"edit_plan_clips",
|
||||
sa.Column("playback_speed", sa.Float(), nullable=False, server_default="1.0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("edit_plan_clips", "playback_speed")
|
||||
@@ -19,7 +19,6 @@ from app.api.routes.templates import router as templates_router
|
||||
from app.api.routes.titles import router as titles_router
|
||||
from app.api.routes.tts import router as tts_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
from app.api.routes.videos import router as videos_router
|
||||
from app.api.routes.voice_clones import router as voice_clones_router
|
||||
from app.api.routes.voices import router as voices_router
|
||||
from fastapi import APIRouter
|
||||
@@ -100,10 +99,6 @@ api_router.include_router(
|
||||
prefix="/voice-clones",
|
||||
tags=["VoiceClone"],
|
||||
)
|
||||
api_router.include_router(
|
||||
videos_router,
|
||||
tags=["VideoCenter"],
|
||||
)
|
||||
api_router.include_router(
|
||||
duplication_router,
|
||||
prefix="/duplication",
|
||||
|
||||
Executable → Regular
+4
-3
@@ -163,10 +163,11 @@ def delete_asset_library(
|
||||
# 权限校验:检查用户是否有项目访问权限
|
||||
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 删除库内所有素材(硬删除,素材库已删除,无需保留软删除状态)
|
||||
# 删除库内所有素材(无 FK 级联,需手动清理)
|
||||
assets_in_library = asset_repository.find_by_library(library_id)
|
||||
for asset in assets_in_library:
|
||||
asset_repository.delete(asset.id)
|
||||
if assets_in_library:
|
||||
asset_ids_to_delete = [a.id for a in assets_in_library]
|
||||
asset_repository.batch_delete(asset_ids_to_delete)
|
||||
|
||||
# 删除素材库本身
|
||||
asset_library_repository.delete(library_id)
|
||||
|
||||
Executable → Regular
+16
-181
@@ -12,11 +12,8 @@ from app.dependencies import (
|
||||
)
|
||||
from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
BatchDeleteResponse,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
UpdateAssetRequest,
|
||||
@@ -85,15 +82,6 @@ def list_assets(
|
||||
gender: Optional[str] = Query(None, description="按 metadata.gender 筛选"),
|
||||
style: Optional[str] = Query(None, description="按 metadata.style 筛选"),
|
||||
tag_ids: Optional[str] = Query(None, description="按标签 ID 筛选(逗号分隔,取交集)"),
|
||||
smart_view: Optional[str] = Query(
|
||||
None,
|
||||
description="智能视图筛选:recommended=推荐(质量分≥80)、cautious=慎用(60-79)、risky=高风险(<60或已驳回)、unused=未使用、used=已使用、pending_review=待复核",
|
||||
pattern="^(recommended|cautious|risky|unused|used|pending_review)$",
|
||||
),
|
||||
classification: Optional[str] = Query(
|
||||
None,
|
||||
description="按内容分类筛选:scenic=风景、product=产品、person=人物、animal=动物、food=美食、tech=科技、sport=运动、music=音乐、other=其他",
|
||||
),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -113,11 +101,11 @@ def list_assets(
|
||||
if not filter_tag_ids:
|
||||
filter_tag_ids = None
|
||||
|
||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids/smart_view/classification 无法在 DB 层过滤)
|
||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids or smart_view or classification)
|
||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
|
||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
|
||||
|
||||
def _apply_memory_filters(items):
|
||||
"""应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。"""
|
||||
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
|
||||
result = items
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
@@ -126,38 +114,9 @@ def list_assets(
|
||||
result = [i for i in result if (i.metadata or {}).get("gender") == gender]
|
||||
if style:
|
||||
result = [i for i in result if (i.metadata or {}).get("style") == style]
|
||||
if classification:
|
||||
result = [i for i in result if (i.metadata or {}).get("classification") == classification]
|
||||
if filter_tag_ids:
|
||||
tag_set = set(filter_tag_ids)
|
||||
result = [i for i in result if tag_set.issubset(set(getattr(i, "tag_ids", [])))]
|
||||
if smart_view:
|
||||
|
||||
def __meta(a):
|
||||
return a.metadata or {}
|
||||
|
||||
def __use_count(a):
|
||||
return int(__meta(a).get("generation_use_count") or 0)
|
||||
|
||||
def __review_status(a):
|
||||
return __meta(a).get("review_status", "")
|
||||
|
||||
if smart_view == "recommended":
|
||||
result = [i for i in result if i.quality_score is not None and i.quality_score >= 80]
|
||||
elif smart_view == "cautious":
|
||||
result = [i for i in result if i.quality_score is not None and 60 <= i.quality_score < 80]
|
||||
elif smart_view == "risky":
|
||||
result = [
|
||||
i
|
||||
for i in result
|
||||
if (i.quality_score is not None and i.quality_score < 60) or __review_status(i) == "rejected"
|
||||
]
|
||||
elif smart_view == "unused":
|
||||
result = [i for i in result if __use_count(i) == 0]
|
||||
elif smart_view == "used":
|
||||
result = [i for i in result if __use_count(i) > 0]
|
||||
elif smart_view == "pending_review":
|
||||
result = [i for i in result if __review_status(i) == "pending_review"]
|
||||
return result
|
||||
|
||||
# ── 优化路径:无内存过滤时,使用 DB 级分页 ──
|
||||
@@ -301,157 +260,33 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
|
||||
) -> BatchDeleteResponse:
|
||||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
deleted_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
for asset_id in request.ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
failed_ids.append(asset_id)
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
deleted_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
failed_ids.append(asset_id)
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_delete(success_ids)
|
||||
if deleted_ids:
|
||||
asset_repository.batch_delete(deleted_ids)
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-tag", response_model=BatchOperationResponse)
|
||||
def batch_tag_assets(
|
||||
request: BatchTagRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
tag_repository: Any = Depends(get_tag_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量打标签(添加或替换模式),需逐项校验项目权限和标签权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
# 校验标签存在且属于当前用户
|
||||
for tag_id in request.tag_ids:
|
||||
tag = tag_repository.get(tag_id)
|
||||
if tag is None:
|
||||
return BatchOperationResponse(
|
||||
success_count=0,
|
||||
failed_ids=list(request.asset_ids),
|
||||
failed_details={aid: f"tag_not_found:{tag_id}" for aid in request.asset_ids},
|
||||
)
|
||||
if tag.user_id != user_id:
|
||||
return BatchOperationResponse(
|
||||
success_count=0,
|
||||
failed_ids=list(request.asset_ids),
|
||||
failed_details={aid: f"tag_access_denied:{tag_id}" for aid in request.asset_ids},
|
||||
)
|
||||
|
||||
# 校验素材权限
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
if request.mode == "replace":
|
||||
asset_repository.batch_replace_tags(success_ids, request.tag_ids)
|
||||
else:
|
||||
asset_repository.batch_add_tags(success_ids, request.tag_ids)
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-classify", response_model=BatchOperationResponse)
|
||||
def batch_classify_assets(
|
||||
request: BatchClassifyRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量修改素材内容分类(person/scenic/product等),存在metadata.category中。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_update_metadata(success_ids, {"category": request.category})
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/batch-mark", response_model=BatchOperationResponse)
|
||||
def batch_mark_assets(
|
||||
request: BatchMarkRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchOperationResponse:
|
||||
"""批量设置智能视图标记(recommended/caution/high_risk),存在metadata.smart_view中。"""
|
||||
user_id = authenticated_user.user.id
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if success_ids:
|
||||
asset_repository.batch_update_metadata(success_ids, {"smart_view": request.smart_view})
|
||||
|
||||
return BatchOperationResponse(
|
||||
success_count=len(success_ids),
|
||||
failed_ids=list(failed_details.keys()),
|
||||
failed_details=failed_details,
|
||||
)
|
||||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_ids)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
|
||||
@@ -146,7 +146,6 @@ class AIRecommendClipItem(BaseModel):
|
||||
text_content: str = Field(default="", description="文字内容")
|
||||
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
|
||||
transition_effect: str = Field(default="cut", description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
|
||||
asset_id: str = Field(default="", description="关联素材 ID")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
|
||||
@@ -210,8 +209,6 @@ class _PlanClipItem(BaseModel):
|
||||
start_time: float
|
||||
duration: float
|
||||
transition_effect: str
|
||||
transition_duration: float
|
||||
playback_speed: float = 1.0
|
||||
status: str
|
||||
config: Optional[dict[str, Any]] = None
|
||||
created_at: datetime
|
||||
|
||||
Executable → Regular
-1
@@ -210,7 +210,6 @@ def generate_from_template(
|
||||
start_time=c.start_time,
|
||||
duration=c.duration,
|
||||
transition_effect=c.transition_effect,
|
||||
transition_duration=c.transition_duration,
|
||||
status=c.status.value if hasattr(c.status, "value") else c.status,
|
||||
config=c.config,
|
||||
created_at=c.created_at,
|
||||
|
||||
@@ -267,8 +267,6 @@ def create_generation_task(
|
||||
source_edit_plan_id=request.source_edit_plan_id,
|
||||
asset_select_mode=request.asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -21,12 +21,11 @@ from app.schemas.task_center import (
|
||||
ProjectTaskResponse,
|
||||
UserTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
RetryGenerationTaskUseCase,
|
||||
SubmitIngestJobCommand,
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
@@ -35,10 +34,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_PAGE_SIZE = 50
|
||||
MAX_PAGE_SIZE = 200
|
||||
|
||||
|
||||
def _humanize_task_error(error_message: str) -> str:
|
||||
raw = (error_message or "").strip()
|
||||
if not raw:
|
||||
@@ -68,8 +63,6 @@ def _generation_step(task) -> str:
|
||||
return "生成完成"
|
||||
if s == "failed":
|
||||
return "生成失败"
|
||||
if s == "cancelled":
|
||||
return "已取消"
|
||||
return s
|
||||
|
||||
|
||||
@@ -86,26 +79,6 @@ def _ingest_step(job) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def _generation_task_to_user_response(task) -> UserTaskResponse:
|
||||
return UserTaskResponse(
|
||||
id=f"generation:{task.id}",
|
||||
task_type="generation",
|
||||
project_id=task.project_id,
|
||||
template_id=task.template_id,
|
||||
status=_status_value(task.status),
|
||||
progress=task.progress,
|
||||
current_step=_generation_step(task),
|
||||
error_message=task.error_message,
|
||||
error_info=task.error_info or {},
|
||||
user_message=_humanize_task_error(task.error_message),
|
||||
retryable=_status_value(task.status) == "failed",
|
||||
retry_count=task.retry_count or 0,
|
||||
source_id=task.id,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
||||
return ProjectTaskResponse(
|
||||
id=f"generation:{task.id}",
|
||||
@@ -115,10 +88,8 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
||||
progress=task.progress,
|
||||
current_step=_generation_step(task),
|
||||
error_message=task.error_message,
|
||||
error_info=task.error_info or {},
|
||||
user_message=_humanize_task_error(task.error_message),
|
||||
retryable=_status_value(task.status) == "failed",
|
||||
retry_count=task.retry_count or 0,
|
||||
source_id=task.id,
|
||||
template_id=task.template_id,
|
||||
created_at=task.created_at,
|
||||
@@ -126,66 +97,40 @@ def _generation_task_to_project_response(task) -> ProjectTaskResponse:
|
||||
)
|
||||
|
||||
|
||||
def _validate_status(status: str | None) -> str | None:
|
||||
"""校验状态值合法性。"""
|
||||
if status is None:
|
||||
return None
|
||||
valid = {"pending", "running", "completed", "failed", "cancelled"}
|
||||
if status not in valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效的状态筛选值: {status},允许值: {', '.join(sorted(valid))}",
|
||||
)
|
||||
return status
|
||||
|
||||
|
||||
def _clamp_page_size(page_size: int) -> int:
|
||||
if page_size <= 0:
|
||||
return DEFAULT_PAGE_SIZE
|
||||
if page_size > MAX_PAGE_SIZE:
|
||||
return MAX_PAGE_SIZE
|
||||
return page_size
|
||||
|
||||
|
||||
# ── 用户级端点(放在项目级端点之前,避免路由冲突) ──
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListTasksResponse)
|
||||
def list_user_tasks(
|
||||
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> ListTasksResponse:
|
||||
"""用户级任务列表(跨 project),支持状态/类型筛选和分页。"""
|
||||
status = _validate_status(status)
|
||||
page_size = _clamp_page_size(page_size)
|
||||
"""用户级任务列表(跨 project),合并 ingest + generation 任务。"""
|
||||
user_id = authenticated_user.user.id
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
items: list[UserTaskResponse] = []
|
||||
|
||||
# 生成任务
|
||||
if task_type is None or task_type == "generation":
|
||||
gen_result = generation_task_repository.list_by_user_filtered(
|
||||
user_id,
|
||||
status=status,
|
||||
limit=page_size + 1, # 多取一条判断是否还有下一页(简单起见这里用offset)
|
||||
offset=offset,
|
||||
for task in generation_task_repository.list_by_user(user_id):
|
||||
items.append(
|
||||
UserTaskResponse(
|
||||
id=f"generation:{task.id}",
|
||||
task_type="generation",
|
||||
project_id=task.project_id,
|
||||
template_id=task.template_id,
|
||||
status=_status_value(task.status),
|
||||
progress=task.progress,
|
||||
current_step=_generation_step(task),
|
||||
error_message=task.error_message,
|
||||
user_message=_humanize_task_error(task.error_message),
|
||||
retryable=_status_value(task.status) == "failed",
|
||||
source_id=task.id,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.completed_at or task.started_at or task.created_at,
|
||||
)
|
||||
)
|
||||
for task in gen_result:
|
||||
items.append(_generation_task_to_user_response(task))
|
||||
|
||||
# 按时间倒序
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||
|
||||
# 总数(仅generation,ingest暂不计入总数以保持简单)
|
||||
total = generation_task_repository.count_by_user_filtered(user_id, status=status)
|
||||
|
||||
return ListTasksResponse(items=items[:page_size], total=total)
|
||||
return ListTasksResponse(items=items)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
|
||||
@@ -194,7 +139,7 @@ def retry_task_by_id(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> UserTaskResponse:
|
||||
"""原地重试失败的生成任务(复用同一个task_id,retry_count+1)。"""
|
||||
"""简化重试:通过 task_id 直接重试失败的生成任务。"""
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
@@ -204,7 +149,6 @@ def retry_task_by_id(
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 预检查
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
@@ -219,11 +163,20 @@ def retry_task_by_id(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
# 原地重试
|
||||
use_case = RetryGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(task_id)
|
||||
|
||||
# 重新入队
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
template_id=task.template_id,
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
@@ -239,8 +192,18 @@ def retry_task_by_id(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return _generation_task_to_user_response(retried)
|
||||
return UserTaskResponse(
|
||||
id=f"generation:{retried.id}",
|
||||
task_type="generation",
|
||||
project_id=retried.project_id,
|
||||
template_id=retried.template_id,
|
||||
status=_status_value(retried.status),
|
||||
progress=retried.progress,
|
||||
current_step=_generation_step(retried),
|
||||
source_id=retried.id,
|
||||
created_at=retried.created_at,
|
||||
updated_at=retried.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ── 项目级端点 ──
|
||||
@@ -249,64 +212,37 @@ def retry_task_by_id(
|
||||
@router.get("/projects/{project_id}/tasks", response_model=ListProjectTasksResponse)
|
||||
def list_project_tasks(
|
||||
project_id: str,
|
||||
status: str | None = Query(None, description="按状态筛选:pending/running/completed/failed/cancelled"),
|
||||
task_type: str | None = Query(None, description="按任务类型筛选:generation/ingest"),
|
||||
page: int = Query(1, ge=1, description="页码,从1开始"),
|
||||
page_size: int = Query(DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="每页数量"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> ListProjectTasksResponse:
|
||||
"""项目级任务列表,支持状态/类型筛选和分页。"""
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
status = _validate_status(status)
|
||||
page_size = _clamp_page_size(page_size)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
items: list[ProjectTaskResponse] = []
|
||||
|
||||
# 导入任务
|
||||
if task_type is None or task_type == "ingest":
|
||||
for job in ingest_job_repository.list_by_project(project_id):
|
||||
if status and _status_value(job.status) != status:
|
||||
continue
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"ingest:{job.id}",
|
||||
task_type="ingest",
|
||||
project_id=job.project_id,
|
||||
status=_status_value(job.status),
|
||||
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
|
||||
current_step=_ingest_step(job),
|
||||
error_message=job.error_message,
|
||||
user_message=_humanize_task_error(job.error_message),
|
||||
retryable=_status_value(job.status) == "failed",
|
||||
source_id=job.id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
for job in ingest_job_repository.list_by_project(project_id):
|
||||
items.append(
|
||||
ProjectTaskResponse(
|
||||
id=f"ingest:{job.id}",
|
||||
task_type="ingest",
|
||||
project_id=job.project_id,
|
||||
status=_status_value(job.status),
|
||||
progress=100.0 if _status_value(job.status) == "completed" else 0.0,
|
||||
current_step=_ingest_step(job),
|
||||
error_message=job.error_message,
|
||||
user_message=_humanize_task_error(job.error_message),
|
||||
retryable=_status_value(job.status) == "failed",
|
||||
source_id=job.id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
|
||||
# 生成任务
|
||||
if task_type is None or task_type == "generation":
|
||||
gen_items = generation_task_repository.list_by_project_filtered(
|
||||
project_id,
|
||||
status=status,
|
||||
limit=page_size + 1,
|
||||
offset=offset,
|
||||
)
|
||||
for task in gen_items:
|
||||
items.append(_generation_task_to_project_response(task))
|
||||
|
||||
for task in generation_task_repository.list_by_project(project_id):
|
||||
items.append(_generation_task_to_project_response(task))
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||
|
||||
total = generation_task_repository.count_by_project_filtered(project_id, status=status)
|
||||
|
||||
return ListProjectTasksResponse(items=items[:page_size], total=total)
|
||||
return ListProjectTasksResponse(items=items)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
||||
@@ -317,7 +253,6 @@ def retry_project_task(
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
) -> ProjectTaskResponse:
|
||||
"""项目级任务重试。"""
|
||||
if task_type == "generation":
|
||||
task = generation_task_repository.get(source_id)
|
||||
if task is None:
|
||||
@@ -326,7 +261,6 @@ def retry_project_task(
|
||||
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
|
||||
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 预检查
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
@@ -341,10 +275,20 @@ def retry_project_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
# 原地重试
|
||||
use_case = RetryGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(source_id)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=task.project_id,
|
||||
asset_library_id=task.asset_library_id,
|
||||
strategy_id=task.strategy_id,
|
||||
voice_library_id=task.voice_library_id,
|
||||
template_id=task.template_id,
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
@@ -361,7 +305,6 @@ def retry_project_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _generation_task_to_project_response(retried)
|
||||
|
||||
if task_type == "ingest":
|
||||
job = ingest_job_repository.get(source_id)
|
||||
if job is None:
|
||||
|
||||
@@ -8,16 +8,13 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.template import (
|
||||
CategoryResponse,
|
||||
CopyTemplateRequest,
|
||||
CreateCategoryRequest,
|
||||
CreateTemplateRequest,
|
||||
GenerateWarningResponse,
|
||||
ListCategoriesResponse,
|
||||
ListTagsResponse,
|
||||
ListTemplatesResponse,
|
||||
SegmentResponse,
|
||||
TemplateResponse,
|
||||
TemplateUsageResponse,
|
||||
ToggleFavoriteResponse,
|
||||
UpdateTemplateRequest,
|
||||
ValidateTemplateRequest,
|
||||
@@ -30,24 +27,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository
|
||||
from packages.application.template.commands import (
|
||||
CopyTemplateCommand,
|
||||
CreateCategoryCommand,
|
||||
CreateTemplateCommand,
|
||||
ListTemplatesFilter,
|
||||
SegmentCommand,
|
||||
UpdateTemplateCommand,
|
||||
ValidateTemplateCommand,
|
||||
)
|
||||
from packages.application.template.use_cases import (
|
||||
CopyTemplateUseCase,
|
||||
CountTemplatesUseCase,
|
||||
CreateCategoryUseCase,
|
||||
CreateTemplateUseCase,
|
||||
DeleteCategoryUseCase,
|
||||
DeleteTemplateUseCase,
|
||||
GetTemplateUseCase,
|
||||
ListCategoriesUseCase,
|
||||
ListTagsUseCase,
|
||||
ListTemplatesUseCase,
|
||||
NotFoundError,
|
||||
UpdateTemplateUseCase,
|
||||
@@ -75,7 +67,7 @@ def _segment_to_response(seg) -> SegmentResponse:
|
||||
)
|
||||
|
||||
|
||||
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
def _to_response(template) -> TemplateResponse:
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
user_id=template.user_id,
|
||||
@@ -89,7 +81,6 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
estimated_duration=template.estimated_duration,
|
||||
segments=[_segment_to_response(s) for s in getattr(template, "segments", [])],
|
||||
is_active=template.is_active,
|
||||
usage_count=usage_count,
|
||||
created_at=template.created_at,
|
||||
updated_at=template.updated_at,
|
||||
)
|
||||
@@ -102,36 +93,19 @@ def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
def list_templates(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
category: str | None = Query(None, description="按分类筛选"),
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
tpl_filter = ListTemplatesFilter(
|
||||
category=category,
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
count_use_case = CountTemplatesUseCase(template_repository)
|
||||
total = count_use_case.execute(user_id, filter=tpl_filter)
|
||||
|
||||
# 批量查询使用次数
|
||||
items = []
|
||||
for t in templates:
|
||||
usage = template_repository.get_usage_count(t.id)
|
||||
items.append(_to_response(t, usage_count=usage))
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit)
|
||||
total = template_repository.count_by_user(user_id)
|
||||
except Exception:
|
||||
logger.exception("list_templates 查询失败: user_id=%s", user_id)
|
||||
return ListTemplatesResponse(items=[], total=0)
|
||||
return ListTemplatesResponse(
|
||||
items=items,
|
||||
items=[_to_response(t) for t in templates],
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -146,13 +120,12 @@ def get_template(
|
||||
try:
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
usage = template_repository.get_usage_count(template_id)
|
||||
except Exception:
|
||||
logger.exception("get_template 查询失败: template_id=%s", template_id)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
return _to_response(template, usage_count=usage)
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -247,47 +220,6 @@ def delete_template(
|
||||
return
|
||||
|
||||
|
||||
@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
def copy_template(
|
||||
template_id: str,
|
||||
request: CopyTemplateRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateResponse:
|
||||
"""复制模板(含所有片段配置)"""
|
||||
user_id = authenticated_user.user.id
|
||||
command = CopyTemplateCommand(
|
||||
template_id=template_id,
|
||||
user_id=user_id,
|
||||
new_name=request.new_name,
|
||||
)
|
||||
use_case = CopyTemplateUseCase(template_repository)
|
||||
try:
|
||||
template = use_case.execute(command)
|
||||
except NotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
return _to_response(template)
|
||||
|
||||
|
||||
@router.get("/{template_id}/usage", response_model=TemplateUsageResponse)
|
||||
def get_template_usage(
|
||||
template_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> TemplateUsageResponse:
|
||||
"""获取模板使用次数(关联的剪辑计划数量)"""
|
||||
user_id = authenticated_user.user.id
|
||||
# 鉴权:确保模板存在且属于当前用户
|
||||
use_case = GetTemplateUseCase(template_repository)
|
||||
template = use_case.execute(template_id, user_id)
|
||||
if template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
|
||||
usage = template_repository.get_usage_count(template_id)
|
||||
return TemplateUsageResponse(template_id=template_id, usage_count=usage)
|
||||
|
||||
|
||||
@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse)
|
||||
def toggle_favorite(
|
||||
template_id: str,
|
||||
@@ -386,23 +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)
|
||||
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
|
||||
@router.get("/tags/list", response_model=ListTagsResponse)
|
||||
def list_tags(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTagsResponse:
|
||||
"""获取用户所有模板标签(去重排序)"""
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
use_case = ListTagsUseCase(template_repository)
|
||||
tags = use_case.execute(user_id)
|
||||
except Exception:
|
||||
logger.exception("list_tags 查询失败: user_id=%s", user_id)
|
||||
return ListTagsResponse(items=[])
|
||||
return ListTagsResponse(items=tags)
|
||||
return
|
||||
|
||||
Executable → Regular
+1
-41
@@ -17,18 +17,13 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
|
||||
from packages.application.title_library.commands import (
|
||||
CreateTitleLibraryCommand,
|
||||
PickTitleCommand,
|
||||
UpdateTitleLibraryCommand,
|
||||
)
|
||||
from packages.application.title_library.commands import CreateTitleLibraryCommand, UpdateTitleLibraryCommand
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
GetTitleLibraryUseCase,
|
||||
ListTitleLibraryUseCase,
|
||||
NotFoundError,
|
||||
PickTitleUseCase,
|
||||
QuotaExceededError,
|
||||
UpdateTitleLibraryUseCase,
|
||||
)
|
||||
@@ -75,41 +70,6 @@ def list_titles(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/pick", response_model=TitleLibraryItemResponse)
|
||||
def pick_title(
|
||||
category: Optional[str] = Query(None, description="按分类筛选,不填则从全部标题中选"),
|
||||
exclude_ids: Optional[str] = Query(
|
||||
None,
|
||||
description="排除的标题ID(逗号分隔),用于批量生成时避免重复",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
title_repository: SQLAlchemyTitleLibraryRepository = Depends(_get_title_repository),
|
||||
) -> TitleLibraryItemResponse:
|
||||
"""智能选择一个标题。
|
||||
|
||||
策略:优先使用次数少的,从最少的前5个中随机选一个,兼顾公平和多样性。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
exclude_list: list[str] = []
|
||||
if exclude_ids:
|
||||
exclude_list = [t.strip() for t in exclude_ids.split(",") if t.strip()]
|
||||
|
||||
use_case = PickTitleUseCase(title_repository)
|
||||
item = use_case.execute(
|
||||
PickTitleCommand(
|
||||
user_id=user_id,
|
||||
category=category,
|
||||
exclude_ids=exclude_list,
|
||||
)
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="标题库为空,请先添加标题",
|
||||
)
|
||||
return _to_response(item)
|
||||
|
||||
|
||||
@router.get("/{title_id}", response_model=TitleLibraryItemResponse)
|
||||
def get_title(
|
||||
title_id: str,
|
||||
|
||||
@@ -46,7 +46,6 @@ from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
QuotaExceededError,
|
||||
)
|
||||
from packages.domain.voice_presets import list_voices
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,34 +53,6 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/presets", summary="获取预设音色列表")
|
||||
def list_preset_voices(
|
||||
gender: Optional[str] = Query(None, description="按性别筛选: male/female/child"),
|
||||
style: Optional[str] = Query(None, description="按风格筛选: stable/lively/customer_service/narration/news/story"),
|
||||
keyword: Optional[str] = Query(None, description="按关键词搜索"),
|
||||
_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> list[dict]:
|
||||
"""获取可用的预设音色列表。
|
||||
|
||||
用于配音功能的音色选择。
|
||||
"""
|
||||
voices = list_voices(gender=gender, style=style, keyword=keyword)
|
||||
return [
|
||||
{
|
||||
"voice_id": v.voice_id,
|
||||
"name": v.name,
|
||||
"gender": v.gender.value,
|
||||
"style": v.style.value,
|
||||
"description": v.description,
|
||||
"default_speed": v.default_speed,
|
||||
"default_pitch": v.default_pitch,
|
||||
"sample_rate": v.sample_rate,
|
||||
"language": v.language,
|
||||
}
|
||||
for v in voices
|
||||
]
|
||||
|
||||
|
||||
def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository:
|
||||
return SQLAlchemyTTSJobRepository(session)
|
||||
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.api.routes._helpers import check_project_access
|
||||
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
|
||||
from app.dependencies import get_generated_video_repository
|
||||
from app.schemas.video_center import (
|
||||
BatchDownloadRequest,
|
||||
BatchDownloadResponse,
|
||||
ListVideosResponse,
|
||||
UpdateVideoReviewRequest,
|
||||
VideoItemResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
GetGeneratedVideoUseCase,
|
||||
GetVideosByIdsUseCase,
|
||||
ListGeneratedVideosPaginatedUseCase,
|
||||
UpdateVideoReviewStatusUseCase,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoItemResponse:
|
||||
download_url = None
|
||||
if storage and item.file_url:
|
||||
try:
|
||||
download_url = storage.get_download_url(item.file_url)
|
||||
except Exception:
|
||||
download_url = item.file_url
|
||||
return VideoItemResponse(
|
||||
id=item.id,
|
||||
project_id=item.project_id,
|
||||
generation_task_id=item.generation_task_id,
|
||||
name=item.name,
|
||||
file_url=item.file_url,
|
||||
file_size=item.file_size,
|
||||
duration=item.duration,
|
||||
thumbnail_url=item.thumbnail_url,
|
||||
width=item.width,
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
status=item.status,
|
||||
review_status=item.review_status,
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
generated_at=item.generated_at.isoformat() if hasattr(item, "generated_at") and item.generated_at else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/videos", response_model=ListVideosResponse)
|
||||
def list_videos(
|
||||
project_id: str | None = Query(None, description="项目ID,不传则返回所有项目"),
|
||||
status: str | None = Query(None, description="按状态筛选"),
|
||||
review_status: str | None = Query(None, description="按复核状态筛选"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
repo=Depends(get_generated_video_repository),
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
|
||||
use_case = ListGeneratedVideosPaginatedUseCase(repo)
|
||||
items, total = use_case.execute(
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
review_status=review_status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return ListVideosResponse(
|
||||
items=[_to_video_response(item, storage) for item in items],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/videos/{video_id}", response_model=VideoItemResponse)
|
||||
def get_video(
|
||||
video_id: str,
|
||||
repo=Depends(get_generated_video_repository),
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""获取单个成片详情。"""
|
||||
use_case = GetGeneratedVideoUseCase(repo)
|
||||
item = use_case.execute(video_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
return _to_video_response(item, storage)
|
||||
|
||||
|
||||
@router.patch("/videos/{video_id}/review", response_model=VideoItemResponse)
|
||||
def update_video_review_status(
|
||||
video_id: str,
|
||||
request: UpdateVideoReviewRequest,
|
||||
repo=Depends(get_generated_video_repository),
|
||||
storage: OSSStorageService = Depends(get_storage_service),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""更新成片复核状态:pending_review / approved / rejected。"""
|
||||
use_case = UpdateVideoReviewStatusUseCase(repo)
|
||||
item = use_case.execute(video_id, request.review_status)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Video not found")
|
||||
logger.info("Video %s review status updated to %s by user %s", video_id, request.review_status, current_user.user_id)
|
||||
return _to_video_response(item, storage)
|
||||
|
||||
|
||||
@router.post("/videos/batch-download", response_model=BatchDownloadResponse)
|
||||
def batch_download_videos(
|
||||
request: BatchDownloadRequest,
|
||||
repo=Depends(get_generated_video_repository),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""批量下载成片,异步打包 zip。
|
||||
|
||||
传入 video_ids 列表,创建一个批量下载任务,任务完成后返回 zip 下载链接。
|
||||
"""
|
||||
if not request.video_ids:
|
||||
raise HTTPException(status_code=400, detail="video_ids cannot be empty")
|
||||
if len(request.video_ids) > 50:
|
||||
raise HTTPException(status_code=400, detail="Maximum 50 videos per batch download")
|
||||
|
||||
# 校验视频都存在
|
||||
use_case = GetVideosByIdsUseCase(repo)
|
||||
videos = use_case.execute(request.video_ids)
|
||||
if len(videos) != len(request.video_ids):
|
||||
raise HTTPException(status_code=404, detail="Some videos not found")
|
||||
|
||||
# 发送 celery 任务
|
||||
task = celery_app.send_task(
|
||||
"worker.batch_download_videos",
|
||||
args=[request.video_ids, current_user.user_id],
|
||||
)
|
||||
|
||||
logger.info("Batch download job created: %s, videos=%d", task.id, len(request.video_ids))
|
||||
return BatchDownloadResponse(job_id=task.id, status="pending")
|
||||
|
||||
|
||||
@router.get("/videos/batch-download/{job_id}", response_model=BatchDownloadResponse)
|
||||
def get_batch_download_status(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询批量下载任务状态。"""
|
||||
from celery.result import AsyncResult
|
||||
|
||||
task = AsyncResult(job_id, app=celery_app)
|
||||
|
||||
status_map = {
|
||||
"PENDING": "pending",
|
||||
"STARTED": "running",
|
||||
"SUCCESS": "success",
|
||||
"FAILURE": "failed",
|
||||
"RETRY": "pending",
|
||||
"REVOKED": "cancelled",
|
||||
}
|
||||
api_status = status_map.get(task.state, "pending")
|
||||
|
||||
download_url = None
|
||||
if task.state == "SUCCESS" and task.result:
|
||||
if isinstance(task.result, dict):
|
||||
download_url = task.result.get("download_url")
|
||||
elif isinstance(task.result, str):
|
||||
download_url = task.result
|
||||
|
||||
return BatchDownloadResponse(
|
||||
job_id=job_id,
|
||||
status=api_status,
|
||||
download_url=download_url,
|
||||
)
|
||||
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
|
||||
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
|
||||
"""处理已下线的 API 版本"""
|
||||
|
||||
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
|
||||
SUNSET_VERSIONS = [] # 已下线的版本列表
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
version = self._extract_version(request.url.path)
|
||||
|
||||
Executable → Regular
+6
-34
@@ -54,45 +54,17 @@ class AssetResponse(BaseModel):
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求(软删除)。"""
|
||||
"""批量删除请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表")
|
||||
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
|
||||
|
||||
|
||||
class BatchOperationResponse(BaseModel):
|
||||
"""批量操作通用响应。"""
|
||||
class BatchDeleteResponse(BaseModel):
|
||||
"""批量删除响应。"""
|
||||
|
||||
success_count: int = Field(..., ge=0, description="成功数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="失败的 ID 列表")
|
||||
failed_details: dict[str, str] = Field(default_factory=dict, description="失败详情 {asset_id: reason}")
|
||||
|
||||
|
||||
class BatchTagRequest(BaseModel):
|
||||
"""批量打标签请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
tag_ids: list[str] = Field(..., min_length=1, max_length=50, description="标签 ID 列表")
|
||||
mode: str = Field(default="add", pattern="^(add|replace)$", description="add=添加合并,replace=全量替换")
|
||||
|
||||
|
||||
class BatchClassifyRequest(BaseModel):
|
||||
"""批量修改分类请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
category: str = Field(..., min_length=1, max_length=50, description="内容分类,如 person/scenic/product")
|
||||
|
||||
|
||||
class BatchMarkRequest(BaseModel):
|
||||
"""批量设置智能视图标记请求。"""
|
||||
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
smart_view: str = Field(
|
||||
..., pattern="^(recommended|caution|high_risk)$", description="智能视图标记:recommended/caution/high_risk"
|
||||
)
|
||||
deleted_count: int = Field(..., ge=0, description="实际删除数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
|
||||
Executable → Regular
-15
@@ -33,17 +33,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
asset_select_count: int = Field(
|
||||
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
|
||||
)
|
||||
# ── 自动重试 ──
|
||||
auto_retry_enabled: bool = Field(
|
||||
default=False,
|
||||
description="是否开启失败自动重试,默认关闭",
|
||||
)
|
||||
auto_retry_max: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
le=5,
|
||||
description="最大自动重试次数,0表示不自动重试,最大5次",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -75,10 +64,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
progress: float
|
||||
result_count: int
|
||||
error_message: str
|
||||
error_info: dict = Field(default_factory=dict)
|
||||
retry_count: int = 0
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
logs: list[dict] = Field(default_factory=list)
|
||||
|
||||
@field_validator("logs", mode="before")
|
||||
|
||||
Executable → Regular
-6
@@ -11,10 +11,8 @@ class ProjectTaskResponse(BaseModel):
|
||||
progress: float
|
||||
current_step: str
|
||||
error_message: str = ""
|
||||
error_info: dict = Field(default_factory=dict)
|
||||
user_message: str = ""
|
||||
retryable: bool = False
|
||||
retry_count: int = 0
|
||||
source_id: str = ""
|
||||
template_id: str = ""
|
||||
created_at: datetime | None = None
|
||||
@@ -23,7 +21,6 @@ class ProjectTaskResponse(BaseModel):
|
||||
|
||||
class ListProjectTasksResponse(BaseModel):
|
||||
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class UserTaskResponse(BaseModel):
|
||||
@@ -37,10 +34,8 @@ class UserTaskResponse(BaseModel):
|
||||
progress: float
|
||||
current_step: str
|
||||
error_message: str = ""
|
||||
error_info: dict = Field(default_factory=dict)
|
||||
user_message: str = ""
|
||||
retryable: bool = False
|
||||
retry_count: int = 0
|
||||
source_id: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
@@ -50,4 +45,3 @@ class ListTasksResponse(BaseModel):
|
||||
"""用户级任务列表响应(GET /api/v1/tasks)。"""
|
||||
|
||||
items: list[UserTaskResponse] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
Executable → Regular
-23
@@ -45,7 +45,6 @@ class TemplateResponse(BaseModel):
|
||||
segments: List[SegmentResponse] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
is_favorite: bool = False
|
||||
usage_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -121,25 +120,3 @@ class CreateCategoryRequest(BaseModel):
|
||||
|
||||
class ListCategoriesResponse(BaseModel):
|
||||
items: List[CategoryResponse]
|
||||
|
||||
|
||||
# ── Copy Template ──
|
||||
|
||||
|
||||
class CopyTemplateRequest(BaseModel):
|
||||
new_name: str
|
||||
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
|
||||
class ListTagsResponse(BaseModel):
|
||||
items: List[str]
|
||||
|
||||
|
||||
# ── Usage Stats ──
|
||||
|
||||
|
||||
class TemplateUsageResponse(BaseModel):
|
||||
template_id: str
|
||||
usage_count: int
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
VideoReviewStatus = Literal["pending_review", "approved", "rejected"]
|
||||
|
||||
|
||||
class VideoItemResponse(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
generation_task_id: str
|
||||
name: str
|
||||
file_url: str
|
||||
file_size: int
|
||||
duration: float
|
||||
thumbnail_url: str | None = None
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
status: str = "completed"
|
||||
review_status: str = "pending_review"
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
generated_at: str = ""
|
||||
|
||||
|
||||
class ListVideosResponse(BaseModel):
|
||||
items: list[VideoItemResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class UpdateVideoReviewRequest(BaseModel):
|
||||
review_status: VideoReviewStatus
|
||||
|
||||
|
||||
class BatchDownloadRequest(BaseModel):
|
||||
video_ids: list[str]
|
||||
|
||||
|
||||
class BatchDownloadResponse(BaseModel):
|
||||
job_id: str
|
||||
status: str = "pending"
|
||||
download_url: str | None = None
|
||||
Executable → Regular
-19
@@ -281,8 +281,6 @@ class EditPlanService:
|
||||
start_time: float = 0.0,
|
||||
duration: float = 0.0,
|
||||
transition_effect: str = "cut",
|
||||
transition_duration: float = 0.0,
|
||||
playback_speed: float = 1.0,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> EditPlanClip:
|
||||
"""创建片段
|
||||
@@ -303,8 +301,6 @@ class EditPlanService:
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
transition_effect=transition_effect,
|
||||
transition_duration=transition_duration,
|
||||
playback_speed=playback_speed,
|
||||
config=config,
|
||||
)
|
||||
created = self._clip_repo.create(clip)
|
||||
@@ -328,8 +324,6 @@ class EditPlanService:
|
||||
start_time: Optional[float] = None,
|
||||
duration: Optional[float] = None,
|
||||
transition_effect: Optional[str] = None,
|
||||
transition_duration: Optional[float] = None,
|
||||
playback_speed: Optional[float] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> EditPlanClip:
|
||||
"""更新片段
|
||||
@@ -339,15 +333,6 @@ class EditPlanService:
|
||||
"""
|
||||
existing = self.get_clip_or_raise(clip_id)
|
||||
|
||||
# 速度边界钳制
|
||||
if playback_speed is not None:
|
||||
if playback_speed <= 0:
|
||||
playback_speed = 1.0
|
||||
elif playback_speed < 0.25:
|
||||
playback_speed = 0.25
|
||||
elif playback_speed > 4.0:
|
||||
playback_speed = 4.0
|
||||
|
||||
updated = EditPlanClip(
|
||||
id=existing.id,
|
||||
plan_id=existing.plan_id,
|
||||
@@ -361,10 +346,6 @@ class EditPlanService:
|
||||
transition_effect=(
|
||||
transition_effect.strip() if transition_effect is not None else existing.transition_effect
|
||||
),
|
||||
transition_duration=(
|
||||
transition_duration if transition_duration is not None else existing.transition_duration
|
||||
),
|
||||
playback_speed=playback_speed if playback_speed is not None else existing.playback_speed,
|
||||
status=existing.status,
|
||||
config=config if config is not None else existing.config,
|
||||
created_at=existing.created_at,
|
||||
|
||||
@@ -251,9 +251,6 @@ test.describe("Core generation flow", () => {
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||
});
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
|
||||
@@ -204,7 +204,7 @@ export const createAsset = async (data: {
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
data: { name?: string; metadata?: Record<string, unknown> },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
|
||||
@@ -129,8 +129,7 @@ apiClient.interceptors.response.use(
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
|
||||
@@ -97,30 +97,6 @@ export interface EditPlanConfig {
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
sticker_config?: StickerConfig;
|
||||
cover_config?: CoverConfig;
|
||||
/** 前端扩展:关联的素材 ID 列表 */
|
||||
asset_ids?: string[];
|
||||
/** 配音 ID */
|
||||
voice_id?: string;
|
||||
/** 克隆音色档案 ID */
|
||||
voice_clone_profile_id?: string;
|
||||
/** 自定义配音音频 URL */
|
||||
custom_audio_url?: string;
|
||||
/** 自定义配音文本 */
|
||||
custom_text?: string;
|
||||
/** 视频比例 */
|
||||
ratio?: string;
|
||||
/** 视频风格 */
|
||||
style?: string;
|
||||
/** 目标时长(秒) */
|
||||
duration?: number;
|
||||
/** 是否自动生成字幕 */
|
||||
auto_subtitles?: boolean;
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean;
|
||||
/** 生成数量 */
|
||||
generate_count?: number;
|
||||
/** 素材模式 */
|
||||
material_mode?: string;
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
@@ -194,7 +170,7 @@ export interface GenerationStatusResponse {
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id?: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
@@ -206,8 +182,6 @@ export interface GeneratedVideo {
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -252,15 +226,13 @@ export interface GenerateCoverRequest {
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string;
|
||||
cover: CoverResult;
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
cover: {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -139,23 +139,11 @@ export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number;
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string;
|
||||
/** 期望值 */
|
||||
expected?: string | number;
|
||||
/** 实际值 */
|
||||
actual?: string | number;
|
||||
/** 建议值 */
|
||||
suggested?: string | number;
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: ValidationWarningDetails;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
|
||||
@@ -60,26 +60,33 @@ export interface BatchDownloadStatus {
|
||||
/**
|
||||
* 将 generation task 数据映射为 ProductItem 格式
|
||||
*/
|
||||
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
function mapTaskToProductItem(
|
||||
task: GeneratedVideo | Record<string, unknown>,
|
||||
): ProductItem {
|
||||
const video = task as GeneratedVideo;
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.name || "未命名视频",
|
||||
video_url: task.file_url,
|
||||
thumbnail_url: task.thumbnail_url,
|
||||
duration_seconds: task.duration,
|
||||
file_size: task.file_size,
|
||||
id: video.id,
|
||||
title: video.name || "未命名视频",
|
||||
video_url: video.file_url,
|
||||
thumbnail_url: video.thumbnail_url,
|
||||
duration_seconds: video.duration,
|
||||
file_size: video.file_size,
|
||||
resolution:
|
||||
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
video.width && video.height
|
||||
? `${video.width}x${video.height}`
|
||||
: undefined,
|
||||
status:
|
||||
task.status === "completed"
|
||||
video.status === "completed"
|
||||
? "completed"
|
||||
: task.status === "failed"
|
||||
: video.status === "failed"
|
||||
? "failed"
|
||||
: "processing",
|
||||
review_status: task.review_status as ReviewStatus | undefined,
|
||||
project_id: task.project_id,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
review_status: video.review_status as ReviewStatus | undefined,
|
||||
project_id: video.project_id,
|
||||
created_at: (task as Record<string, unknown>).created_at as
|
||||
string | undefined,
|
||||
updated_at: (task as Record<string, unknown>).updated_at as
|
||||
string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-14
@@ -8,18 +8,6 @@ import apiClient from "./client";
|
||||
|
||||
/* ── 类型定义 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 元数据(合成时附带的扩展信息) */
|
||||
export interface TTSMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** TTS 合成请求参数 */
|
||||
export interface TTSSynthesizeRequest {
|
||||
text: string;
|
||||
@@ -30,7 +18,7 @@ export interface TTSSynthesizeRequest {
|
||||
voice_model?: string;
|
||||
voice_clone_profile_id?: string;
|
||||
format?: string;
|
||||
metadata?: TTSMetadata;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
@@ -61,7 +49,7 @@ export interface TTSJob {
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: TTSMetadata | null;
|
||||
metadata_: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -36,18 +36,6 @@ export interface CreateVoiceCloneRequest {
|
||||
|
||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||
|
||||
/** 音色克隆元数据(克隆时附带的扩展信息) */
|
||||
export interface VoiceCloneMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 音色 ID(克隆完成后分配) */
|
||||
voice_id?: string;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 后端克隆档案响应 */
|
||||
export interface VoiceCloneProfile {
|
||||
id: string;
|
||||
@@ -63,7 +51,7 @@ export interface VoiceCloneProfile {
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: VoiceCloneMetadata | null;
|
||||
metadata_: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -92,7 +80,7 @@ export interface CreateVoiceCloneRequestFull {
|
||||
language?: string;
|
||||
gender?: string;
|
||||
max_retries?: number;
|
||||
metadata_?: VoiceCloneMetadata;
|
||||
metadata_?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
|
||||
@@ -202,8 +202,8 @@ const GeneratePage: React.FC = () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId);
|
||||
if (plan.name) setTitle(plan.name);
|
||||
const cfg = plan.config;
|
||||
if (cfg?.asset_ids) {
|
||||
const cfg = plan.config as Record<string, unknown>;
|
||||
if (cfg && Array.isArray(cfg.asset_ids)) {
|
||||
setSelectedMaterials(
|
||||
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
|
||||
);
|
||||
@@ -477,13 +477,7 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
| "voice_id"
|
||||
| "voice_clone_profile_id"
|
||||
| "custom_audio_url"
|
||||
| "custom_text"
|
||||
> = {};
|
||||
const voiceConfig: Record<string, unknown> = {};
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined;
|
||||
} else if (voiceMode === "clone") {
|
||||
@@ -508,7 +502,7 @@ const GeneratePage: React.FC = () => {
|
||||
bgm,
|
||||
generate_count: generateCount,
|
||||
material_mode: materialMode,
|
||||
},
|
||||
} as EditPlanConfig,
|
||||
total_duration: duration,
|
||||
source_edit_plan_id: editPlanId || undefined,
|
||||
});
|
||||
@@ -567,8 +561,7 @@ const GeneratePage: React.FC = () => {
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -628,8 +621,7 @@ const GeneratePage: React.FC = () => {
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -659,8 +651,7 @@ const GeneratePage: React.FC = () => {
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
const obj = val as Record<string, unknown>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
|
||||
@@ -99,20 +99,10 @@ const formatDuration = (seconds: number): string => {
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
|
||||
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
|
||||
interface ConfigDisplayFields {
|
||||
font_size?: string | number;
|
||||
font_family?: string;
|
||||
color?: string;
|
||||
position?: string;
|
||||
volume?: string | number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** 格式化配置对象为可读文本 */
|
||||
const formatConfig = (config?: object): string => {
|
||||
if (!config || Object.keys(config).length === 0) return "默认";
|
||||
const c = config as ConfigDisplayFields;
|
||||
const c = config as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (c.font_size) parts.push(`字号: ${c.font_size}`);
|
||||
if (c.font_family) parts.push(`字体: ${c.font_family}`);
|
||||
|
||||
@@ -130,20 +130,12 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
|
||||
};
|
||||
};
|
||||
|
||||
/** 配音素材上传元数据(传递给 createAsset 的 metadata) */
|
||||
interface VoiceAssetMetadata {
|
||||
gender: VoiceGender;
|
||||
description: string;
|
||||
duration: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style) */
|
||||
const buildMetadata = (data: {
|
||||
gender: VoiceGender;
|
||||
description: string;
|
||||
duration?: number;
|
||||
}): VoiceAssetMetadata => ({
|
||||
}): Record<string, unknown> => ({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration: data.duration || 0,
|
||||
|
||||
@@ -623,20 +623,12 @@ const getAudioDuration = (file: File): Promise<number> =>
|
||||
audio.src = url;
|
||||
});
|
||||
|
||||
/** 音色上传元数据(传递给 createAsset 的 metadata) */
|
||||
interface VoiceUploadMetadata {
|
||||
gender?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const buildVoiceMetadata = (data: {
|
||||
gender?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
}): VoiceUploadMetadata => {
|
||||
const metadata: VoiceUploadMetadata = {};
|
||||
}): Record<string, unknown> => {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
if (data.gender) metadata.gender = data.gender;
|
||||
if (data.description) metadata.description = data.description;
|
||||
if (data.duration) metadata.duration = Math.round(data.duration);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""ASR 服务工厂 — 根据环境配置创建对应 ASR 服务实例。
|
||||
|
||||
支持的后端:
|
||||
- mock: MockASRService(测试/开发用)
|
||||
- 后续可扩展:whisper / aliyun / tencent 等
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from packages.ports.asr_service import ASRService
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_asr_service() -> ASRService | None:
|
||||
"""获取全局 ASR 服务实例(单例)。
|
||||
|
||||
根据环境变量 ASR_PROVIDER 决定使用哪个后端:
|
||||
- mock / 空 / 未设置: 返回 None(不启用 ASR)
|
||||
- mock: 使用 MockASRService
|
||||
|
||||
Returns:
|
||||
ASRService 实例,未配置或不启用时返回 None
|
||||
"""
|
||||
provider = os.environ.get("ASR_PROVIDER", "").lower().strip()
|
||||
|
||||
if not provider:
|
||||
return None
|
||||
|
||||
if provider == "mock":
|
||||
from packages.adapters.asr.mock_asr_service import MockASRService
|
||||
|
||||
return MockASRService()
|
||||
|
||||
# 未知 provider,记录日志并返回 None(不启用 ASR,不阻断主流程)
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("未知的 ASR provider: %s,ASR 自动字幕功能未启用", provider)
|
||||
return None
|
||||
|
||||
|
||||
def reset_asr_service_cache() -> None:
|
||||
"""重置 ASR 服务缓存(测试用)。"""
|
||||
get_asr_service.cache_clear()
|
||||
@@ -1,66 +0,0 @@
|
||||
"""TTS 服务工厂.
|
||||
|
||||
根据配置创建对应的 TTS 服务实例。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from packages.ports.tts_service import TtsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 可用的 provider 映射
|
||||
_PROVIDERS: dict[str, type[TtsService]] = {}
|
||||
|
||||
|
||||
def register_provider(name: str, cls: type[TtsService]) -> None:
|
||||
"""注册 TTS 供应商."""
|
||||
_PROVIDERS[name] = cls
|
||||
|
||||
|
||||
def get_tts_service(provider: str | None = None, **kwargs) -> TtsService:
|
||||
"""获取 TTS 服务实例.
|
||||
|
||||
Args:
|
||||
provider: 供应商名称(None 则从环境变量读取 TTS_PROVIDER)
|
||||
**kwargs: 传递给服务构造函数的参数
|
||||
|
||||
Returns:
|
||||
TTS 服务实例
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的供应商
|
||||
"""
|
||||
if provider is None:
|
||||
provider = os.environ.get("TTS_PROVIDER", "mock")
|
||||
|
||||
provider = provider.lower()
|
||||
|
||||
if provider not in _PROVIDERS:
|
||||
# 延迟导入避免循环依赖
|
||||
if provider == "mock":
|
||||
from packages.adapters.tts.mock_tts_service import MockTtsService
|
||||
|
||||
_PROVIDERS["mock"] = MockTtsService
|
||||
else:
|
||||
logger.warning("未知 TTS provider: %s,回退到 mock", provider)
|
||||
from packages.adapters.tts.mock_tts_service import MockTtsService
|
||||
|
||||
_PROVIDERS["mock"] = MockTtsService
|
||||
provider = "mock"
|
||||
|
||||
cls = _PROVIDERS[provider]
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
def available_providers() -> list[str]:
|
||||
"""获取可用的供应商列表."""
|
||||
# 确保 mock 已注册
|
||||
if "mock" not in _PROVIDERS:
|
||||
from packages.adapters.tts.mock_tts_service import MockTtsService
|
||||
|
||||
_PROVIDERS["mock"] = MockTtsService
|
||||
return list(_PROVIDERS.keys())
|
||||
@@ -1,313 +0,0 @@
|
||||
"""BGM 混音模块 — 背景音乐与主音频混合.
|
||||
|
||||
基于 FFmpeg 实现:
|
||||
- BGM 音量调节
|
||||
- 淡入淡出(afade)
|
||||
- 循环播放(aloop,短 BGM 铺长视频)
|
||||
- 人声闪避(sidechaincompress,有人声时BGM自动降低音量)
|
||||
- amix 混音
|
||||
|
||||
作为 render_audio.py 的增强模块,在 mix_audio 后处理阶段被调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from video_processing.render_audio import RenderContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BGMConfig:
|
||||
"""BGM 混音配置(内部使用,从 plan.config.bgm 转换而来)"""
|
||||
|
||||
bgm_path: str # BGM 本地文件路径
|
||||
volume: float = 0.3 # 0.0 ~ 1.0
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
loop_enabled: bool = True # 是否循环铺满
|
||||
sidechain_enabled: bool = False # 人声闪避
|
||||
sidechain_ratio: float = 0.3 # 闪避时音量降低比例
|
||||
sidechain_attack: float = 0.02 # 攻击时间
|
||||
sidechain_release: float = 0.5 # 释放时间
|
||||
sidechain_threshold: float = -25.0 # 触发阈值(dB)
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, bgm_path: str, config: dict) -> "BGMConfig":
|
||||
"""从 plan.config.bgm 字典创建 BGMConfig。"""
|
||||
return cls(
|
||||
bgm_path=bgm_path,
|
||||
volume=float(config.get("volume", 0.3)),
|
||||
fade_in=float(config.get("fade_in", 0.0)),
|
||||
fade_out=float(config.get("fade_out", 0.0)),
|
||||
loop_enabled=bool(config.get("loop_enabled", True)),
|
||||
sidechain_enabled=bool(config.get("sidechain_enabled", False)),
|
||||
sidechain_ratio=float(config.get("sidechain_ratio", 0.3)),
|
||||
sidechain_attack=float(config.get("sidechain_attack", 0.02)),
|
||||
sidechain_release=float(config.get("sidechain_release", 0.5)),
|
||||
sidechain_threshold=float(config.get("sidechain_threshold", -25.0)),
|
||||
)
|
||||
|
||||
|
||||
# ── BGM 预处理 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def prepare_bgm_track(
|
||||
ctx: "RenderContext",
|
||||
bgm: BGMConfig,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""预处理 BGM 轨道:循环/截断 + 音量 + 淡入淡出.
|
||||
|
||||
生成一个时长精确等于 target_duration 的 BGM 音频文件。
|
||||
后续再与主音频混音。
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
bgm: BGM 配置
|
||||
target_duration: 目标时长(秒),通常等于视频总时长
|
||||
|
||||
Returns:
|
||||
处理后的 BGM 音频文件路径
|
||||
"""
|
||||
output_path = ctx.work_dir / f"bgm_processed_{ctx.plan_id}.aac"
|
||||
|
||||
if target_duration <= 0:
|
||||
target_duration = 5.0 # 兜底
|
||||
|
||||
bgm_dur = probe_duration(bgm.bgm_path)
|
||||
needs_loop = bgm.loop_enabled and bgm_dur > 0 and bgm_dur < target_duration * 0.9
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
input_looped: bool = False
|
||||
|
||||
if needs_loop:
|
||||
# 计算需要循环多少次才能铺满
|
||||
loop_count = max(1, int(target_duration / bgm_dur) + 2)
|
||||
# aloop 滤镜:循环指定次数
|
||||
filter_parts.append(f"aloop=loop={loop_count}:size=0")
|
||||
input_looped = True
|
||||
|
||||
# 音量调节
|
||||
volume = max(0.0, min(1.0, bgm.volume))
|
||||
if abs(volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={volume:.3f}")
|
||||
|
||||
# 淡入
|
||||
if bgm.fade_in > 0:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={bgm.fade_in:.3f}")
|
||||
|
||||
# 淡出(从 target_duration - fade_out 开始)
|
||||
if bgm.fade_out > 0 and target_duration > bgm.fade_out:
|
||||
fade_start = target_duration - bgm.fade_out
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={bgm.fade_out:.3f}")
|
||||
|
||||
# 最终截断到目标时长
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB") # 重置时间戳
|
||||
|
||||
filter_str = ",".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
bgm.bgm_path,
|
||||
"-filter:a",
|
||||
filter_str,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[bgm] prepare BGM track: path=%s dur=%.2f target=%.2f loop=%s fade_in=%.2f fade_out=%.2f",
|
||||
bgm.bgm_path[-40:],
|
||||
bgm_dur,
|
||||
target_duration,
|
||||
needs_loop,
|
||||
bgm.fade_in,
|
||||
bgm.fade_out,
|
||||
)
|
||||
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
|
||||
# ── BGM + 主音频混音 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mix_bgm_with_main(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
bgm: BGMConfig,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""将 BGM 与主音频混合.
|
||||
|
||||
两种模式:
|
||||
1. 普通混音(sidechain 关闭):amix 两路音频
|
||||
2. 人声闪避(sidechain 开启):用 sidechaincompress 让 BGM 跟随主音频音量自动调整
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
main_audio_path: 主音频文件路径(人声/原始音频)
|
||||
bgm: BGM 配置
|
||||
target_duration: 目标时长
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径
|
||||
"""
|
||||
output_path = ctx.work_dir / f"audio_with_bgm_{ctx.plan_id}.aac"
|
||||
|
||||
# 先预处理 BGM 轨道(循环/音量/淡入淡出/截断)
|
||||
bgm_processed = prepare_bgm_track(ctx, bgm, target_duration)
|
||||
|
||||
if not bgm.sidechain_enabled:
|
||||
# 普通 amix 混音
|
||||
_mix_simple(main_audio_path, bgm_processed, output_path)
|
||||
else:
|
||||
# sidechain 人声闪避混音
|
||||
_mix_sidechain(main_audio_path, bgm_processed, output_path, bgm)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def _mix_simple(main_path: Path, bgm_path: Path, output_path: Path) -> None:
|
||||
"""简单 amix 混音:主音频 + BGM = 输出.
|
||||
|
||||
主音频权重 1.0,BGM 已经在预处理阶段调好了音量。
|
||||
amix 会自动归一化,需要用 volume 补偿。
|
||||
"""
|
||||
# 使用 amix:inputs=2,duration=first(以主音频时长为准)
|
||||
# 然后用 volume=2 补偿 amix 的衰减(2路输入每路平均乘0.5)
|
||||
filter_complex = "[0:a][1:a]amix=inputs=2:duration=first:dropout_transition=0[outa];" "[outa]volume=2[final]"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(main_path),
|
||||
"-i",
|
||||
str(bgm_path),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[final]",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("[bgm] simple amix mix")
|
||||
run_ffmpeg(command)
|
||||
|
||||
|
||||
def _mix_sidechain(
|
||||
main_path: Path,
|
||||
bgm_path: Path,
|
||||
output_path: Path,
|
||||
bgm: BGMConfig,
|
||||
) -> None:
|
||||
"""sidechain 人声闪避混音.
|
||||
|
||||
原理:
|
||||
- 主音频作为 sidechain 信号源
|
||||
- BGM 轨道经过 sidechaincompress,根据主音频音量动态调整 BGM 音量
|
||||
- 最后 amix 混音
|
||||
|
||||
FFmpeg sidechaincompress 参数:
|
||||
- threshold: 触发阈值(dB),主音频超过此值时开始压缩
|
||||
- ratio: 压缩比,越高压缩越狠
|
||||
- attack: 攻击时间(秒)
|
||||
- release: 释放时间(秒)
|
||||
"""
|
||||
# sidechain_ratio 表示闪避时 BGM 音量降低比例
|
||||
# ratio = 1 / (1 - sidechain_ratio),但实际压缩比需要更精细调整
|
||||
# 简化处理:把 ratio 映射到 2:1 ~ 10:1 范围
|
||||
ratio = max(2.0, min(10.0, 1.0 / (1.0 - bgm.sidechain_ratio)))
|
||||
|
||||
filter_complex = (
|
||||
# BGM 经过 sidechain 压缩,用主音频做触发
|
||||
f"[1:a][0:a]sidechaincompress="
|
||||
f"threshold={bgm.sidechain_threshold}dB:"
|
||||
f"ratio={ratio:.1f}:"
|
||||
f"attack={bgm.sidechain_attack:.3f}:"
|
||||
f"release={bgm.sidechain_release:.3f}:"
|
||||
f"knee=6[bgm_comp];"
|
||||
# 主音频 + 压缩后的 BGM 混音
|
||||
f"[0:a][bgm_comp]amix=inputs=2:duration=first:dropout_transition=0[outa];"
|
||||
f"[outa]volume=1.5[final]" # 轻微补偿
|
||||
)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(main_path),
|
||||
"-i",
|
||||
str(bgm_path),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[final]",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[bgm] sidechain mix: threshold=%.1fdB ratio=%.1f attack=%.3f release=%.3f",
|
||||
bgm.sidechain_threshold,
|
||||
ratio,
|
||||
bgm.sidechain_attack,
|
||||
bgm.sidechain_release,
|
||||
)
|
||||
run_ffmpeg(command)
|
||||
|
||||
|
||||
# ── 纯 BGM 模式(无主音频) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_bgm_only(
|
||||
ctx: "RenderContext",
|
||||
bgm: BGMConfig,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""只有 BGM、没有主音频时,直接生成 BGM 音频.
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
bgm: BGM 配置
|
||||
target_duration: 目标时长
|
||||
|
||||
Returns:
|
||||
BGM 音频文件路径
|
||||
"""
|
||||
output_path = ctx.work_dir / f"bgm_only_{ctx.plan_id}.aac"
|
||||
|
||||
if target_duration <= 0:
|
||||
target_duration = 5.0
|
||||
|
||||
bgm_processed = prepare_bgm_track(ctx, bgm, target_duration)
|
||||
|
||||
# 直接复制
|
||||
import shutil
|
||||
|
||||
shutil.copy2(bgm_processed, output_path)
|
||||
return output_path
|
||||
@@ -1,248 +0,0 @@
|
||||
"""绿幕抠像引擎 — 基于 FFmpeg colorkey / chromakey 滤镜.
|
||||
|
||||
支持将指定颜色(默认绿色)变为透明,可用于虚拟背景、画中画背景替换等场景。
|
||||
|
||||
使用方式:
|
||||
config = ChromaKeyConfig(key_color="#00FF00", similarity=0.3, blend=0.1)
|
||||
engine = ChromaKeyEngine(config)
|
||||
filter_str = engine.build_filter(input_label, output_label)
|
||||
# 结果: [in]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[out]
|
||||
|
||||
降级策略:
|
||||
- 参数越界自动钳制
|
||||
- 素材格式不支持时跳过(调用方捕获异常)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 配置模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChromaKeyConfig:
|
||||
"""绿幕抠像配置。
|
||||
|
||||
Attributes:
|
||||
enabled: 是否启用抠像
|
||||
key_color: 要抠除的颜色,支持 hex 格式(如 "#00FF00")或颜色名
|
||||
similarity: 颜色相似度阈值 0.01~1.0,值越大抠除范围越大
|
||||
blend: 边缘平滑/混合度 0.0~1.0,值越大边缘越柔和
|
||||
spill_suppress: 溢色抑制 0.0~1.0,减少边缘的绿幕反光
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
key_color: str = "#00FF00"
|
||||
similarity: float = 0.3
|
||||
blend: float = 0.1
|
||||
spill_suppress: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "ChromaKeyConfig":
|
||||
"""从字典解析配置,参数越界自动钳制。"""
|
||||
if not data or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
key_color = str(data.get("key_color", "#00FF00")).strip()
|
||||
|
||||
def _safe_float(val, default):
|
||||
try:
|
||||
return float(val)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
similarity = _safe_float(data.get("similarity", 0.3), 0.3)
|
||||
blend = _safe_float(data.get("blend", 0.1), 0.1)
|
||||
spill_suppress = _safe_float(data.get("spill_suppress", 0.0), 0.0)
|
||||
|
||||
# 钳制到合法范围
|
||||
similarity = max(0.01, min(1.0, similarity))
|
||||
blend = max(0.0, min(1.0, blend))
|
||||
spill_suppress = max(0.0, min(1.0, spill_suppress))
|
||||
|
||||
return cls(
|
||||
enabled=True,
|
||||
key_color=key_color,
|
||||
similarity=similarity,
|
||||
blend=blend,
|
||||
spill_suppress=spill_suppress,
|
||||
)
|
||||
|
||||
def has_effect(self) -> bool:
|
||||
"""判断是否有实际抠像效果。"""
|
||||
return self.enabled and self.similarity > 0
|
||||
|
||||
|
||||
# ── 预设配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# 常见绿幕/蓝幕预设
|
||||
CHROMA_KEY_PRESETS = {
|
||||
"green_screen": {
|
||||
"key_color": "#00FF00",
|
||||
"similarity": 0.3,
|
||||
"blend": 0.1,
|
||||
"spill_suppress": 0.5,
|
||||
},
|
||||
"blue_screen": {
|
||||
"key_color": "#0000FF",
|
||||
"similarity": 0.3,
|
||||
"blend": 0.1,
|
||||
"spill_suppress": 0.5,
|
||||
},
|
||||
"red_screen": {
|
||||
"key_color": "#FF0000",
|
||||
"similarity": 0.3,
|
||||
"blend": 0.1,
|
||||
"spill_suppress": 0.0,
|
||||
},
|
||||
"precise_green": {
|
||||
"key_color": "#00FF00",
|
||||
"similarity": 0.2,
|
||||
"blend": 0.05,
|
||||
"spill_suppress": 0.3,
|
||||
},
|
||||
"soft_green": {
|
||||
"key_color": "#00FF00",
|
||||
"similarity": 0.45,
|
||||
"blend": 0.2,
|
||||
"spill_suppress": 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChromaKeyEngine:
|
||||
"""绿幕抠像引擎。
|
||||
|
||||
基于 FFmpeg colorkey 滤镜实现,将指定颜色变为透明。
|
||||
适用于绿幕/蓝幕视频的背景去除,配合画中画或 overlay 实现虚拟背景。
|
||||
"""
|
||||
|
||||
def __init__(self, config: ChromaKeyConfig):
|
||||
self.config = config
|
||||
|
||||
@staticmethod
|
||||
def _normalize_color(color_str: str) -> str:
|
||||
"""将颜色字符串转为 FFmpeg colorkey 接受的格式。
|
||||
|
||||
支持:
|
||||
- "#RRGGBB" / "#RRGGBBAA" → 0xRRGGBB
|
||||
- "0xRRGGBB" → 直接使用
|
||||
- 颜色名(green/blue/red/black/white 等)→ 直接透传
|
||||
"""
|
||||
color = color_str.strip()
|
||||
|
||||
# hex 格式
|
||||
hex_match = re.match(r"^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$", color)
|
||||
if hex_match:
|
||||
return f"0x{hex_match.group(1).upper()}"
|
||||
|
||||
# 已经是 0x 格式
|
||||
if color.lower().startswith("0x"):
|
||||
return color.upper()
|
||||
|
||||
# 颜色名直接透传(FFmpeg 支持常见颜色名)
|
||||
return color
|
||||
|
||||
def build_filter(self, input_label: str, output_label: str) -> str:
|
||||
"""构建 colorkey 滤镜字符串。
|
||||
|
||||
Args:
|
||||
input_label: 输入标签,如 "[0:v]" 或 "[v0]"
|
||||
output_label: 输出标签,如 "[ck0]"
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串,如 "[v0]colorkey=color=0x00FF00:similarity=0.3:blend=0.1[ck0]"
|
||||
|
||||
Raises:
|
||||
ValueError: 配置无效时抛出(调用方应捕获并降级)
|
||||
"""
|
||||
if not self.config.has_effect():
|
||||
# 无效果,直接直通
|
||||
return f"{input_label}copy{output_label}"
|
||||
|
||||
color = self._normalize_color(self.config.key_color)
|
||||
similarity = self.config.similarity
|
||||
blend = self.config.blend
|
||||
|
||||
# 基础 colorkey 滤镜
|
||||
parts = [f"colorkey=color={color}:similarity={similarity}:blend={blend}"]
|
||||
|
||||
# 溢色抑制(通过 colorchannelmixer 降低绿色通道增益)
|
||||
if self.config.spill_suppress > 0:
|
||||
# 降低绿通道增益,减少绿幕反光溢出
|
||||
spill = self.config.spill_suppress
|
||||
# 绿通道增益 = 1 - spill_factor
|
||||
g_gain = max(0.3, 1.0 - spill * 0.7)
|
||||
# 同时稍微提升红和蓝来补偿色偏
|
||||
r_gain = 1.0 + spill * 0.15
|
||||
b_gain = 1.0 + spill * 0.15
|
||||
parts.append(f"colorchannelmixer=" f"rr={r_gain}:" f"gg={g_gain}:" f"bb={b_gain}:" f"aa=1")
|
||||
|
||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
def build_filter_chromakey(self, input_label: str, output_label: str) -> str:
|
||||
"""使用 chromakey 滤镜(更高级的版本,支持更多参数)。
|
||||
|
||||
注意:并非所有 FFmpeg 版本都支持 chromakey 滤镜,
|
||||
优先使用 colorkey(兼容性更好)。
|
||||
|
||||
Args:
|
||||
input_label: 输入标签
|
||||
output_label: 输出标签
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串
|
||||
"""
|
||||
if not self.config.has_effect():
|
||||
return f"{input_label}copy{output_label}"
|
||||
|
||||
color = self._normalize_color(self.config.key_color)
|
||||
similarity = self.config.similarity
|
||||
blend = self.config.blend
|
||||
|
||||
return f"{input_label}" f"chromakey=color={color}:similarity={similarity}:blend={blend}" f"{output_label}"
|
||||
|
||||
|
||||
def apply_chroma_key_if_needed(
|
||||
clip_config: dict | None,
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
) -> Optional[str]:
|
||||
"""便捷函数:根据 clip 配置判断是否需要应用绿幕抠像。
|
||||
|
||||
Args:
|
||||
clip_config: clip 的 config 字典
|
||||
input_label: 输入标签
|
||||
output_label: 输出标签
|
||||
|
||||
Returns:
|
||||
滤镜字符串,不需要抠像时返回 None
|
||||
"""
|
||||
if not clip_config:
|
||||
return None
|
||||
|
||||
chroma_key_data = clip_config.get("chroma_key")
|
||||
if not chroma_key_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
config = ChromaKeyConfig.from_dict(chroma_key_data)
|
||||
if not config.has_effect():
|
||||
return None
|
||||
|
||||
engine = ChromaKeyEngine(config)
|
||||
return engine.build_filter(input_label, output_label)
|
||||
except Exception as e:
|
||||
logger.warning("[chroma-key] 应用抠像失败,跳过: %s", e)
|
||||
return None
|
||||
@@ -1,416 +0,0 @@
|
||||
"""滤镜调色引擎 — 基于 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)
|
||||
@@ -1,697 +0,0 @@
|
||||
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
|
||||
|
||||
基于 FFmpeg 实现两种拼接模式:
|
||||
1. **concat demuxer(stream copy)**:最快,所有视频编码参数必须一致
|
||||
2. **concat filter(重新编码)**:更灵活,支持不同分辨率/编码/帧率的视频
|
||||
|
||||
使用场景:
|
||||
- 多段素材按顺序合并成一个视频
|
||||
- 视频分割后重新拼接
|
||||
- 片头 + 正片 + 片尾拼接
|
||||
|
||||
降级策略:
|
||||
- 优先尝试 stream copy(速度快、无质量损失)
|
||||
- 参数不一致时自动降级到 concat filter
|
||||
- 某段视频失败时跳过,不阻断整体拼接
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
MAX_CONCAT_SEGMENTS = 50 # 最大拼接段数(安全上限,防止OOM)
|
||||
|
||||
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"}
|
||||
|
||||
# concat demuxer 要求一致的参数列表
|
||||
CONCAT_DEMUXER_REQUIRED_PARAMS = [
|
||||
"codec_name", # 视频编码
|
||||
"width", # 宽度
|
||||
"height", # 高度
|
||||
"r_frame_rate", # 帧率
|
||||
"pix_fmt", # 像素格式
|
||||
"sample_rate", # 音频采样率
|
||||
"channels", # 音频声道数
|
||||
"audio_codec", # 音频编码
|
||||
]
|
||||
|
||||
|
||||
# ── 拼接片段配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConcatSegment:
|
||||
"""单个拼接片段."""
|
||||
|
||||
video_path: str # 视频文件路径
|
||||
start_time: float = 0.0 # 开始时间(秒),从视频的哪个位置开始取
|
||||
duration: float = 0.0 # 持续时长(秒),0表示取到末尾
|
||||
has_audio: bool = True # 是否包含音频
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, seg: dict) -> "ConcatSegment":
|
||||
"""从字典创建拼接片段,带安全类型转换."""
|
||||
try:
|
||||
start_time = max(0.0, float(seg.get("start_time", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
start_time = 0.0
|
||||
|
||||
try:
|
||||
duration = max(0.0, float(seg.get("duration", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
return cls(
|
||||
video_path=str(seg.get("video_path", "")),
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
has_audio=bool(seg.get("has_audio", True)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConcatConfig:
|
||||
"""视频拼接配置."""
|
||||
|
||||
segments: list[ConcatSegment] = field(default_factory=list)
|
||||
output_width: int = 0 # 输出宽度(0=自动取第一段)
|
||||
output_height: int = 0 # 输出高度(0=自动取第一段)
|
||||
output_fps: float = 0.0 # 输出帧率(0=自动取第一段)
|
||||
force_reencode: bool = False # 强制重新编码(不用 stream copy)
|
||||
transition: str = "none" # 转场效果(none/crossfade)- 预留
|
||||
transition_duration: float = 0.3 # 转场时长
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, config: dict | None) -> "ConcatConfig":
|
||||
"""从配置字典创建 ConcatConfig."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
segments_raw = config.get("segments", [])
|
||||
segments: list[ConcatSegment] = []
|
||||
|
||||
if isinstance(segments_raw, list):
|
||||
for s in segments_raw:
|
||||
if isinstance(s, dict) and s.get("video_path"):
|
||||
try:
|
||||
seg = ConcatSegment.from_dict(s)
|
||||
if seg.video_path:
|
||||
segments.append(seg)
|
||||
except Exception:
|
||||
logger.warning("[concat] skip invalid segment: %s", s)
|
||||
continue
|
||||
|
||||
try:
|
||||
output_width = max(0, int(config.get("output_width", 0)))
|
||||
except (TypeError, ValueError):
|
||||
output_width = 0
|
||||
|
||||
try:
|
||||
output_height = max(0, int(config.get("output_height", 0)))
|
||||
except (TypeError, ValueError):
|
||||
output_height = 0
|
||||
|
||||
try:
|
||||
output_fps = max(0.0, float(config.get("output_fps", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
output_fps = 0.0
|
||||
|
||||
return cls(
|
||||
segments=segments,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
output_fps=output_fps,
|
||||
force_reencode=bool(config.get("force_reencode", False)),
|
||||
transition=str(config.get("transition", "none")),
|
||||
transition_duration=max(0.1, float(config.get("transition_duration", 0.3))),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_effect(self) -> bool:
|
||||
"""是否有有效片段需要拼接."""
|
||||
return len([s for s in self.segments if s.video_path]) >= 2
|
||||
|
||||
@property
|
||||
def total_segments(self) -> int:
|
||||
"""有效片段数量."""
|
||||
return len([s for s in self.segments if s.video_path])
|
||||
|
||||
|
||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_video_path(video_path: str, work_dir: Path) -> None:
|
||||
"""校验视频文件路径安全性.
|
||||
|
||||
规则:
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是视频格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not video_path or not isinstance(video_path, str):
|
||||
raise PathSecurityError("视频路径不能为空")
|
||||
|
||||
# 本地路径(local:// 或相对路径 / 绝对路径)
|
||||
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
|
||||
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
|
||||
resolved_path = safe_resolve_path(
|
||||
video_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
|
||||
)
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
path_part = video_path.split("?")[0].split("#")[0]
|
||||
ext = Path(path_part).suffix.lower()
|
||||
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
|
||||
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
|
||||
|
||||
|
||||
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ConcatEngine:
|
||||
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
|
||||
|
||||
def __init__(self, work_dir: Path):
|
||||
self.work_dir = work_dir
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── 主入口 ────────────────────────────────────────────────────────
|
||||
|
||||
def concat_videos(
|
||||
self,
|
||||
config: ConcatConfig,
|
||||
output_path: Path,
|
||||
) -> Path:
|
||||
"""拼接多段视频.
|
||||
|
||||
自动选择最优拼接策略:
|
||||
1. 所有片段参数一致 → concat demuxer(stream copy,最快)
|
||||
2. 参数不一致或有裁剪 → concat filter(重新编码)
|
||||
|
||||
Args:
|
||||
config: 拼接配置
|
||||
output_path: 输出文件路径
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
|
||||
if not valid_segments:
|
||||
raise ValueError("No valid video segments to concat")
|
||||
|
||||
# ── 安全校验:段数上限 ──
|
||||
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
|
||||
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
|
||||
|
||||
# ── 安全校验:所有视频路径白名单校验 ──
|
||||
safe_segments = []
|
||||
for seg in valid_segments:
|
||||
try:
|
||||
_validate_video_path(seg.video_path, self.work_dir)
|
||||
safe_segments.append(seg)
|
||||
except PathSecurityError as e:
|
||||
logger.warning("[concat] skip segment: path security check failed: %s", e)
|
||||
|
||||
if len(safe_segments) != len(valid_segments):
|
||||
valid_segments = safe_segments
|
||||
config.segments = safe_segments
|
||||
logger.info("[concat] %d segments passed security check", len(safe_segments))
|
||||
|
||||
if not valid_segments:
|
||||
raise ValueError("No valid video segments after security check")
|
||||
|
||||
if len(valid_segments) == 1:
|
||||
# 只有一段,直接复制
|
||||
import shutil
|
||||
|
||||
logger.info("[concat] single segment, copy directly")
|
||||
shutil.copy2(valid_segments[0].video_path, output_path)
|
||||
return output_path
|
||||
|
||||
# 判断能否用 stream copy
|
||||
can_stream_copy = self._can_use_stream_copy(config)
|
||||
|
||||
if can_stream_copy and not config.force_reencode:
|
||||
logger.info("[concat] using concat demuxer (stream copy)")
|
||||
try:
|
||||
return self._concat_demuxer(config, output_path)
|
||||
except Exception as e:
|
||||
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
|
||||
|
||||
# 降级到 concat filter
|
||||
logger.info("[concat] using concat filter (re-encode)")
|
||||
return self._concat_filter(config, output_path)
|
||||
|
||||
# ── 模式判断 ──────────────────────────────────────────────────────
|
||||
|
||||
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
|
||||
"""判断是否可以使用 concat demuxer(stream copy).
|
||||
|
||||
条件:
|
||||
1. 所有视频编码参数一致(分辨率、帧率、编码、像素格式)
|
||||
2. 所有音频参数一致(采样率、声道、编码)
|
||||
3. 没有设置 start_time 裁剪(或可以通过 concat demuxer 的 inpoint/outpoint 实现)
|
||||
4. 没有强制重新编码
|
||||
"""
|
||||
if config.force_reencode:
|
||||
return False
|
||||
|
||||
# 如果有转场效果,必须重新编码
|
||||
if config.transition != "none":
|
||||
return False
|
||||
|
||||
# 探测所有视频的参数
|
||||
video_infos = []
|
||||
for seg in config.segments:
|
||||
if not seg.video_path:
|
||||
continue
|
||||
try:
|
||||
info = probe_video_info(seg.video_path)
|
||||
video_infos.append(info)
|
||||
except Exception:
|
||||
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
|
||||
return False
|
||||
|
||||
if len(video_infos) < 2:
|
||||
return False
|
||||
|
||||
# 检查参数一致性
|
||||
base_info = video_infos[0]
|
||||
for info in video_infos[1:]:
|
||||
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
|
||||
base_val = base_info.get(param)
|
||||
curr_val = info.get(param)
|
||||
if base_val != curr_val:
|
||||
logger.debug(
|
||||
"[concat] param mismatch: %s (%s vs %s)",
|
||||
param,
|
||||
base_val,
|
||||
curr_val,
|
||||
)
|
||||
return False
|
||||
|
||||
# 检查是否有裁剪需求
|
||||
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
|
||||
# 但为了简单和稳定性,有裁剪时也用 filter 模式
|
||||
# (inpoint/outpoint 不是所有格式都支持得好)
|
||||
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
|
||||
if has_trimming:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# ── 模式1:concat demuxer(stream copy) ──────────────────────────
|
||||
|
||||
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
|
||||
"""使用 concat demuxer 拼接(stream copy).
|
||||
|
||||
优点:速度极快,无质量损失
|
||||
缺点:要求所有视频参数完全一致
|
||||
"""
|
||||
# 生成 concat 文件列表
|
||||
list_file = self.work_dir / "concat_list.txt"
|
||||
lines = []
|
||||
for seg in config.segments:
|
||||
if not seg.video_path:
|
||||
continue
|
||||
# 路径转义:单引号替换为 '\''
|
||||
safe_path = str(seg.video_path).replace("'", "'\\''")
|
||||
lines.append(f"file '{safe_path}'")
|
||||
|
||||
list_file.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
"-c",
|
||||
"copy",
|
||||
"-copyts",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("[concat] demuxer: %d segments", config.total_segments)
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
# ── 模式2:concat filter(重新编码) ──────────────────────────────
|
||||
|
||||
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
|
||||
"""使用 concat filter 拼接(重新编码).
|
||||
|
||||
优点:支持不同参数的视频,支持裁剪
|
||||
缺点:需要重新编码,较慢
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
num_segments = len(valid_segments)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
for seg in valid_segments:
|
||||
input_args.extend(["-i", seg.video_path])
|
||||
|
||||
# 确定输出参数
|
||||
output_width, output_height, output_fps = self._get_output_params(config)
|
||||
|
||||
# 构建 filter_complex
|
||||
filter_parts: list[str] = []
|
||||
concat_inputs = ""
|
||||
|
||||
for i, seg in enumerate(valid_segments):
|
||||
vid_label = f"v{i}"
|
||||
aud_label = f"a{i}"
|
||||
|
||||
seg_filters: list[str] = []
|
||||
|
||||
# 1. 裁剪(start_time + duration)
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
|
||||
else:
|
||||
seg_filters.append(f"trim=start={start:.3f}")
|
||||
seg_filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 音频同步裁剪
|
||||
if seg.has_audio:
|
||||
if seg.duration > 0:
|
||||
filter_parts.append(
|
||||
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
|
||||
else:
|
||||
# 无音频时生成静音轨
|
||||
filter_parts.append(
|
||||
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
|
||||
)
|
||||
else:
|
||||
# 无裁剪,直接用原始标签
|
||||
if not seg.has_audio:
|
||||
# 无音频时需要生成静音
|
||||
try:
|
||||
dur = probe_duration(seg.video_path)
|
||||
except Exception:
|
||||
dur = 10.0
|
||||
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
|
||||
|
||||
# 2. 缩放/帧率统一
|
||||
vf_parts = []
|
||||
if not seg_filters:
|
||||
vf_parts.append(f"[{i}:v]")
|
||||
else:
|
||||
vf_parts.append("")
|
||||
|
||||
# 分辨率统一
|
||||
if output_width and output_height:
|
||||
vf_parts.append(
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
|
||||
)
|
||||
|
||||
# 帧率统一
|
||||
if output_fps > 0:
|
||||
vf_parts.append(f"fps={output_fps}")
|
||||
|
||||
# 像素格式统一
|
||||
vf_parts.append("format=yuv420p")
|
||||
|
||||
if len(vf_parts) > 1 or (seg_filters and vf_parts):
|
||||
if seg_filters:
|
||||
# 先裁剪后缩放
|
||||
crop_str = "".join(seg_filters)
|
||||
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
|
||||
if scale_str:
|
||||
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
|
||||
else:
|
||||
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
|
||||
else:
|
||||
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
|
||||
else:
|
||||
if seg_filters:
|
||||
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
|
||||
else:
|
||||
# 什么都不需要,直接用输入
|
||||
pass
|
||||
|
||||
# 拼接 concat 的输入标签
|
||||
if seg_filters or (output_width and output_height) or output_fps > 0:
|
||||
concat_inputs += f"[{vid_label}]"
|
||||
else:
|
||||
concat_inputs += f"[{i}:v]"
|
||||
|
||||
# 音频标签
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
# 已经生成了 aud_label
|
||||
pass
|
||||
elif not seg.has_audio:
|
||||
# 已经生成了静音 aud_label
|
||||
pass
|
||||
else:
|
||||
# 使用原始音频
|
||||
pass
|
||||
|
||||
# 简化处理:用更直接的方式构建 filter
|
||||
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
|
||||
filter_parts.clear()
|
||||
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
|
||||
|
||||
for i, seg in enumerate(valid_segments):
|
||||
v_label = f"v{i}_in"
|
||||
a_label = f"a{i}_in"
|
||||
|
||||
# 视频处理链
|
||||
v_steps: list[str] = [f"[{i}:v]"]
|
||||
|
||||
# 裁剪
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
|
||||
else:
|
||||
v_steps.append(f"trim=start={start:.3f},")
|
||||
v_steps.append("setpts=PTS-STARTPTS,")
|
||||
|
||||
# 缩放
|
||||
if output_width and output_height:
|
||||
v_steps.append(
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||
)
|
||||
|
||||
# 帧率
|
||||
if output_fps > 0:
|
||||
v_steps.append(f"fps={output_fps},")
|
||||
|
||||
# 像素格式
|
||||
v_steps.append("format=yuv420p")
|
||||
|
||||
v_filter = "".join(v_steps) + f"[{v_label}]"
|
||||
filter_parts.append(v_filter)
|
||||
|
||||
# 音频处理链
|
||||
a_steps: list[str] = []
|
||||
if seg.has_audio:
|
||||
a_steps.append(f"[{i}:a]")
|
||||
|
||||
if seg.start_time > 0 or seg.duration > 0:
|
||||
start = seg.start_time
|
||||
if seg.duration > 0:
|
||||
end = start + seg.duration
|
||||
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
|
||||
else:
|
||||
a_steps.append(f"atrim=start={start:.3f},")
|
||||
a_steps.append("asetpts=PTS-STARTPTS,")
|
||||
|
||||
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
|
||||
else:
|
||||
# 生成静音音频
|
||||
try:
|
||||
dur = probe_duration(seg.video_path)
|
||||
except Exception:
|
||||
dur = 10.0
|
||||
# 减去裁剪
|
||||
if seg.start_time > 0:
|
||||
dur = max(0.1, dur - seg.start_time)
|
||||
if seg.duration > 0 and seg.duration < dur:
|
||||
dur = seg.duration
|
||||
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
|
||||
|
||||
a_filter = "".join(a_steps) + f"[{a_label}]"
|
||||
filter_parts.append(a_filter)
|
||||
|
||||
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
|
||||
concat_inputs += f"[{v_label}][{a_label}]"
|
||||
|
||||
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
|
||||
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"[aout]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"fast",
|
||||
"-crf",
|
||||
"23",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[concat] filter: %d segments, %dx%d, %.2f fps",
|
||||
num_segments,
|
||||
output_width,
|
||||
output_height,
|
||||
output_fps,
|
||||
)
|
||||
run_ffmpeg(command)
|
||||
return output_path
|
||||
|
||||
# ── 辅助方法 ──────────────────────────────────────────────────────
|
||||
|
||||
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
|
||||
"""获取输出参数(宽、高、帧率).
|
||||
|
||||
优先级:
|
||||
1. config 中显式指定的
|
||||
2. 第一段视频的参数
|
||||
"""
|
||||
valid_segments = [s for s in config.segments if s.video_path]
|
||||
|
||||
width = config.output_width
|
||||
height = config.output_height
|
||||
fps = config.output_fps
|
||||
|
||||
# 如果没有显式指定,用第一段的参数
|
||||
if (width == 0 or height == 0 or fps == 0) and valid_segments:
|
||||
try:
|
||||
info = probe_video_info(valid_segments[0].video_path)
|
||||
if width == 0:
|
||||
width = int(info.get("width", 1080))
|
||||
if height == 0:
|
||||
height = int(info.get("height", 1920))
|
||||
if fps == 0:
|
||||
fps_str = info.get("r_frame_rate", "30/1")
|
||||
if "/" in str(fps_str):
|
||||
num, den = str(fps_str).split("/")
|
||||
try:
|
||||
fps = float(num) / float(den)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
fps = 30.0
|
||||
else:
|
||||
fps = float(fps_str) if fps_str else 30.0
|
||||
except Exception:
|
||||
# 探测失败,用默认值
|
||||
if width == 0:
|
||||
width = 1080
|
||||
if height == 0:
|
||||
height = 1920
|
||||
if fps == 0:
|
||||
fps = 30.0
|
||||
|
||||
return width, height, fps
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def concat_video_files(
|
||||
video_paths: list[str],
|
||||
output_path: Path,
|
||||
*,
|
||||
work_dir: Path | None = None,
|
||||
force_reencode: bool = False,
|
||||
) -> Path:
|
||||
"""简单拼接多个视频文件.
|
||||
|
||||
Args:
|
||||
video_paths: 视频文件路径列表
|
||||
output_path: 输出路径
|
||||
work_dir: 工作目录(默认输出文件所在目录)
|
||||
force_reencode: 是否强制重新编码
|
||||
|
||||
Returns:
|
||||
输出文件路径
|
||||
"""
|
||||
if work_dir is None:
|
||||
work_dir = output_path.parent
|
||||
|
||||
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
|
||||
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
|
||||
|
||||
engine = ConcatEngine(work_dir)
|
||||
return engine.concat_videos(config, output_path)
|
||||
|
||||
|
||||
def concat_videos_from_config(
|
||||
config_dict: dict | None,
|
||||
output_path: Path,
|
||||
*,
|
||||
work_dir: Path,
|
||||
) -> Path | None:
|
||||
"""从配置字典执行视频拼接.
|
||||
|
||||
降级策略:配置无效或拼接失败时返回 None.
|
||||
"""
|
||||
config = ConcatConfig.from_config_dict(config_dict)
|
||||
if not config.has_effect:
|
||||
return None
|
||||
|
||||
try:
|
||||
engine = ConcatEngine(work_dir)
|
||||
return engine.concat_videos(config, output_path)
|
||||
except Exception as e:
|
||||
logger.error("[concat] concat failed: %s", e)
|
||||
return None
|
||||
@@ -1,431 +0,0 @@
|
||||
"""视频封面生成器 — 从视频中提取/生成封面图.
|
||||
|
||||
支持能力:
|
||||
- 指定时间点抽帧(默认第1秒)
|
||||
- 智能封面:抽取多帧选最清晰的一帧
|
||||
- 自定义上传封面图(直接返回路径)
|
||||
- 生成的封面图保存为 JPEG 格式,可复用
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_video_info, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 配置常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# 智能封面抽帧数量
|
||||
SMART_COVER_FRAME_COUNT = 3
|
||||
|
||||
# 默认抽帧时间点(秒)
|
||||
DEFAULT_COVER_TIME = 1.0
|
||||
|
||||
# 封面输出尺寸(宽x高)
|
||||
DEFAULT_COVER_WIDTH = 1080
|
||||
DEFAULT_COVER_HEIGHT = 1920
|
||||
|
||||
# 封面质量(JPEG quality 1-31,越小质量越高)
|
||||
DEFAULT_COVER_QUALITY = 5
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CoverGenerator:
|
||||
"""视频封面生成器.
|
||||
|
||||
三种模式:
|
||||
1. 指定时间点抽帧:从视频指定时间提取一帧
|
||||
2. 智能封面:抽取3帧,用 blur 检测选最清晰的
|
||||
3. 自定义上传:直接使用用户上传的图片
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def extract_frame(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
time_sec: float = DEFAULT_COVER_TIME,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""从视频指定时间点提取一帧作为封面.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径
|
||||
time_sec: 抽帧时间点(秒)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量(1-31,越小越好)
|
||||
|
||||
Returns:
|
||||
封面图片路径
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 视频文件不存在
|
||||
subprocess.CalledProcessError: FFmpeg 执行失败
|
||||
"""
|
||||
video_path = Path(video_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
|
||||
# 确保输出目录存在
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 安全钳制时间
|
||||
info = probe_video_info(str(video_path))
|
||||
duration = info.get("duration", 0.0)
|
||||
if duration > 0 and time_sec >= duration:
|
||||
# 超过视频长度,取中间帧
|
||||
time_sec = max(0, duration / 2)
|
||||
if time_sec < 0:
|
||||
time_sec = 0
|
||||
|
||||
# scale + crop 实现 cover 裁剪(铺满输出尺寸)
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-ss",
|
||||
f"{time_sec:.3f}",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("抽取视频封面: video=%s time=%.2fs output=%s", video_path.name, time_sec, output_path.name)
|
||||
run_ffmpeg(command)
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError(f"封面生成失败: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def extract_smart_cover(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
frame_count: int = SMART_COVER_FRAME_COUNT,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
work_dir: str | Path | None = None,
|
||||
) -> Path:
|
||||
"""智能封面:抽取多帧,选最清晰的一帧.
|
||||
|
||||
清晰度判断:使用拉普拉斯方差(Variance of Laplacian),
|
||||
方差越大表示图像边缘越丰富,越清晰。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 最终输出封面路径
|
||||
frame_count: 抽帧数量(均匀分布在视频中)
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
work_dir: 临时工作目录(默认输出目录的父目录)
|
||||
|
||||
Returns:
|
||||
最佳封面图片路径
|
||||
"""
|
||||
video_path = Path(video_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not video_path.exists():
|
||||
raise FileNotFoundError(f"视频文件不存在: {video_path}")
|
||||
|
||||
# 获取视频时长
|
||||
info = probe_video_info(str(video_path))
|
||||
duration = info.get("duration", 0.0)
|
||||
|
||||
if duration <= 0 or frame_count <= 1:
|
||||
# 无法获取时长或只有1帧,退化为普通抽帧
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=min(DEFAULT_COVER_TIME, max(0, duration / 2)),
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
# 临时目录
|
||||
if work_dir is None:
|
||||
work_dir = output_path.parent
|
||||
work_dir = Path(work_dir)
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 均匀分布抽帧时间点(跳过首尾5%)
|
||||
start_pct = 0.05
|
||||
end_pct = 0.95
|
||||
if frame_count == 1:
|
||||
time_points = [duration * 0.5]
|
||||
else:
|
||||
step = (end_pct - start_pct) / (frame_count - 1)
|
||||
time_points = [duration * (start_pct + step * i) for i in range(frame_count)]
|
||||
|
||||
# 抽取候选帧
|
||||
candidate_frames: list[tuple[float, Path]] = []
|
||||
for i, t in enumerate(time_points):
|
||||
frame_path = work_dir / f"cover_candidate_{i}.jpg"
|
||||
try:
|
||||
CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
frame_path,
|
||||
time_sec=t,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
candidate_frames.append((t, frame_path))
|
||||
except Exception as e:
|
||||
logger.warning("智能封面抽帧失败(t=%.2fs): %s", t, e)
|
||||
continue
|
||||
|
||||
if not candidate_frames:
|
||||
# 全部失败,退化到普通抽帧
|
||||
logger.warning("智能封面所有候选帧抽取失败,退化为普通抽帧")
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=min(DEFAULT_COVER_TIME, duration / 2),
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
if len(candidate_frames) == 1:
|
||||
# 只有一帧,直接用
|
||||
import shutil
|
||||
|
||||
shutil.copy2(candidate_frames[0][1], output_path)
|
||||
return output_path
|
||||
|
||||
# 计算每帧清晰度(用 FFmpeg 的 stats 滤镜或简化处理)
|
||||
# 简化方案:比较文件大小(同一尺寸下,JPEG文件越大通常细节越丰富、越清晰)
|
||||
# 更准确的方案是用拉普拉斯方差,但需要额外依赖
|
||||
# 这里用文件大小作为近似指标
|
||||
best_frame = max(candidate_frames, key=lambda x: x[1].stat().st_size)
|
||||
|
||||
# 复制最佳帧到输出路径
|
||||
import shutil
|
||||
|
||||
shutil.copy2(best_frame[1], output_path)
|
||||
|
||||
logger.info(
|
||||
"智能封面生成完成: 候选%d帧, 最佳t=%.2fs, 大小=%d字节",
|
||||
len(candidate_frames),
|
||||
best_frame[0],
|
||||
output_path.stat().st_size,
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
for _, fp in candidate_frames:
|
||||
try:
|
||||
fp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def process_custom_cover(
|
||||
image_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""处理用户自定义上传的封面图.
|
||||
|
||||
调整尺寸、格式转换为标准封面格式。
|
||||
|
||||
Args:
|
||||
image_path: 用户上传的图片路径
|
||||
output_path: 输出封面路径
|
||||
width: 目标宽度
|
||||
height: 目标高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
处理后的封面图片路径
|
||||
"""
|
||||
image_path = Path(image_path)
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not image_path.exists():
|
||||
raise FileNotFoundError(f"封面图片不存在: {image_path}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# scale + crop 实现 cover 裁剪
|
||||
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(image_path),
|
||||
"-vf",
|
||||
vf,
|
||||
"-q:v",
|
||||
str(quality),
|
||||
"-f",
|
||||
"mjpeg",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info("处理自定义封面: input=%s output=%s", image_path.name, output_path.name)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except subprocess.CalledProcessError:
|
||||
# 处理失败,直接复制原图
|
||||
logger.warning("自定义封面处理失败,使用原图")
|
||||
import shutil
|
||||
|
||||
shutil.copy2(image_path, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
@staticmethod
|
||||
def generate_cover(
|
||||
video_path: str | Path,
|
||||
output_path: str | Path,
|
||||
*,
|
||||
mode: str = "smart", # smart / time / custom
|
||||
time_sec: float = DEFAULT_COVER_TIME,
|
||||
custom_image: str | Path | None = None,
|
||||
width: int = DEFAULT_COVER_WIDTH,
|
||||
height: int = DEFAULT_COVER_HEIGHT,
|
||||
quality: int = DEFAULT_COVER_QUALITY,
|
||||
) -> Path:
|
||||
"""统一封面生成入口.
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出封面路径
|
||||
mode: 模式 - smart(智能选帧)/ time(指定时间)/ custom(自定义图片)
|
||||
time_sec: time 模式下的抽帧时间点
|
||||
custom_image: custom 模式下的自定义图片路径
|
||||
width: 输出宽度
|
||||
height: 输出高度
|
||||
quality: JPEG 质量
|
||||
|
||||
Returns:
|
||||
封面图片路径
|
||||
"""
|
||||
if mode == "custom" and custom_image:
|
||||
return CoverGenerator.process_custom_cover(
|
||||
custom_image,
|
||||
output_path,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
elif mode == "time":
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=time_sec,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
else:
|
||||
# 默认智能封面
|
||||
return CoverGenerator.extract_smart_cover(
|
||||
video_path,
|
||||
output_path,
|
||||
width=width,
|
||||
height=height,
|
||||
quality=quality,
|
||||
)
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_cover_from_plan(
|
||||
plan: Any,
|
||||
video_path: str | Path,
|
||||
output_dir: str | Path,
|
||||
) -> Path | None:
|
||||
"""从 EditPlan 配置生成封面图.
|
||||
|
||||
配置读取:plan.config.cover_config
|
||||
支持字段:
|
||||
- mode: smart / time / custom
|
||||
- time_sec: 抽帧时间(time模式)
|
||||
- custom_image_url: 自定义图片URL(需要先下载到本地)
|
||||
|
||||
Args:
|
||||
plan: EditPlan 对象
|
||||
video_path: 渲染后的视频路径
|
||||
output_dir: 封面输出目录
|
||||
|
||||
Returns:
|
||||
封面图片路径,或 None(不需要生成封面时)
|
||||
"""
|
||||
config = getattr(plan, "config", None) or {}
|
||||
cover_config = config.get("cover_config") if isinstance(config, dict) else None
|
||||
|
||||
if not cover_config:
|
||||
return None
|
||||
|
||||
mode = cover_config.get("mode", "smart")
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"cover_{plan.id}.jpg"
|
||||
|
||||
try:
|
||||
if mode == "custom":
|
||||
# 自定义封面:需要先有本地图片路径
|
||||
custom_path = cover_config.get("custom_image_path")
|
||||
if custom_path and Path(custom_path).exists():
|
||||
return CoverGenerator.process_custom_cover(
|
||||
custom_path,
|
||||
output_path,
|
||||
)
|
||||
else:
|
||||
logger.warning("自定义封面图片路径无效,退化为智能封面")
|
||||
mode = "smart"
|
||||
|
||||
if mode == "time":
|
||||
time_sec = float(cover_config.get("time_sec", DEFAULT_COVER_TIME))
|
||||
return CoverGenerator.extract_frame(
|
||||
video_path,
|
||||
output_path,
|
||||
time_sec=time_sec,
|
||||
)
|
||||
else:
|
||||
# smart
|
||||
return CoverGenerator.extract_smart_cover(
|
||||
video_path,
|
||||
output_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("封面生成失败: %s", e)
|
||||
return None
|
||||
Executable → Regular
-13
@@ -75,19 +75,6 @@ def create_video_record_and_dedup(
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
video_repo.create(generated_video)
|
||||
|
||||
# 生成封面缩略图
|
||||
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
|
||||
if thumbnail_url:
|
||||
generated_video.thumbnail_url = thumbnail_url
|
||||
video_repo.update_thumbnail(video_id, thumbnail_url)
|
||||
logger.info("Thumbnail generated for video %s: %s", video_id, thumbnail_url)
|
||||
except Exception as thumb_err:
|
||||
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
|
||||
|
||||
# 计算视频指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
try:
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
"""FFmpeg 工具函数 — Worker 层.
|
||||
"""FFmpeg 工具函数 — 共享原语.
|
||||
|
||||
业务相关的滤镜构建、视频探测、视频标准化等能力放在这里;
|
||||
底层原语(run_ffmpeg / 二进制路径 / 默认超时)已下沉到 packages/shared/ffmpeg_utils.py,
|
||||
本模块 re-export 保持向后兼容。
|
||||
提供 FFmpeg / FFprobe 调用、视频信息探测、视频标准化、xfade 转场滤镜构建
|
||||
等底层能力,供 UnifiedRenderService、VideoComposeService 等复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 底层原语从 shared 层导入,application 层和 worker 层共用同一份实现
|
||||
from shared.ffmpeg_utils import ( # noqa: F401
|
||||
DEFAULT_FFMPEG_TIMEOUT,
|
||||
FFMPEG_BIN,
|
||||
FFPROBE_BIN,
|
||||
run_ffmpeg,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量(Worker 层业务相关) ────────────────────────────────────────────────
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
@@ -30,14 +25,8 @@ DEFAULT_FPS = 25
|
||||
|
||||
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
|
||||
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
|
||||
# "cut" 为特殊值:硬切,不使用 xfade(由调用方特殊处理)
|
||||
XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
# 基础
|
||||
"fade": "fade",
|
||||
"dissolve": "dissolve",
|
||||
"crossfade": "dissolve",
|
||||
"crossdissolve": "dissolve",
|
||||
# 滑入系列
|
||||
"slideleft": "slideleft",
|
||||
"slide_left": "slideleft",
|
||||
"slideright": "slideright",
|
||||
@@ -46,49 +35,41 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
"slide_up": "slideup",
|
||||
"slidedown": "slidedown",
|
||||
"slide_down": "slidedown",
|
||||
"slide": "slideleft", # 默认向左滑
|
||||
# 缩放
|
||||
"zoom": "zoomin",
|
||||
"zoomin": "zoomin",
|
||||
"zoomout": "zoomout",
|
||||
# 擦除系列
|
||||
"wipe": "wipeleft", # 默认向左擦
|
||||
"dissolve": "dissolve",
|
||||
"wipe": "wipeleft",
|
||||
"wipeleft": "wipeleft",
|
||||
"wiperight": "wiperight",
|
||||
"wipeup": "wipeup",
|
||||
"wipedown": "wipedown",
|
||||
# 特殊效果
|
||||
"circlecrop": "circlecrop",
|
||||
"circle": "circlecrop",
|
||||
"rectcrop": "rectcrop",
|
||||
"rect": "rectcrop",
|
||||
}
|
||||
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
|
||||
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
|
||||
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
|
||||
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
|
||||
DEFAULT_FFMPEG_TIMEOUT = 1800
|
||||
|
||||
|
||||
def run_ffprobe(
|
||||
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_ffmpeg(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool = True,
|
||||
timeout: int = 30,
|
||||
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
|
||||
) -> tuple[str, str]:
|
||||
"""执行 FFprobe 命令。
|
||||
"""执行 FFmpeg 命令。
|
||||
|
||||
Args:
|
||||
command: 完整的 ffprobe 命令列表(含 "ffprobe" 本身)
|
||||
command: 完整的 ffmpeg 命令列表(含 "ffmpeg" 本身)
|
||||
capture_output: 是否捕获 stdout/stderr
|
||||
timeout: 超时时间(秒),默认 30s;None 表示不设超时
|
||||
timeout: 超时时间(秒),默认 1800s(30分钟);None 表示不设超时(不推荐)
|
||||
|
||||
Returns:
|
||||
(stdout, stderr) 元组
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出,
|
||||
异常信息包含完整 stderr 以便排查。
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出,FFmpeg 进程会被 kill。
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
@@ -102,18 +83,19 @@ def run_ffprobe(
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFprobe 命令超时 (%ds): command=%s",
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
timeout or -1,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
)
|
||||
raise
|
||||
except subprocess.CalledProcessError as e:
|
||||
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
|
||||
stderr_text = (e.stderr or "").strip()
|
||||
logger.error(
|
||||
"FFprobe 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
e.returncode,
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
stderr_text[:5000],
|
||||
" ".join(str(c) for c in command[:20]), # 截断过长的命令
|
||||
stderr_text[:5000], # 截断过长的 stderr
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
"""片头片尾引擎 — 视频包装与品牌标识.
|
||||
|
||||
支持:
|
||||
- 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题)
|
||||
- 片尾:视频片段 或 关注引导片尾
|
||||
- 自动与正片拼接(xfade 转场)
|
||||
- 时长可配置
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntroOutroConfig:
|
||||
"""片头片尾配置.
|
||||
|
||||
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
# 片头
|
||||
intro_type: str = "none" # none | video | text
|
||||
intro_video_path: str = "" # 视频片段路径
|
||||
intro_duration: float = 3.0 # 片头时长(秒)
|
||||
|
||||
# 文字片头配置
|
||||
intro_background: str = "#000000" # 背景色
|
||||
intro_title: str = ""
|
||||
intro_subtitle: str = ""
|
||||
intro_title_color: str = "white"
|
||||
intro_title_size: int = 48
|
||||
intro_subtitle_color: str = "gray"
|
||||
intro_subtitle_size: int = 24
|
||||
|
||||
# 片尾
|
||||
outro_type: str = "none" # none | video | text | follow
|
||||
outro_video_path: str = "" # 视频片段路径
|
||||
outro_duration: float = 3.0 # 片尾时长(秒)
|
||||
|
||||
# 文字片尾配置
|
||||
outro_background: str = "#000000"
|
||||
outro_title: str = "感谢观看"
|
||||
outro_subtitle: str = "点赞关注不迷路"
|
||||
outro_title_color: str = "white"
|
||||
outro_title_size: int = 48
|
||||
outro_subtitle_color: str = "gray"
|
||||
outro_subtitle_size: int = 24
|
||||
|
||||
# 转场
|
||||
transition_effect: str = "fade"
|
||||
transition_duration: float = 0.5
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
|
||||
"""从字典构造."""
|
||||
if not data:
|
||||
return cls()
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
if not enabled:
|
||||
return cls()
|
||||
|
||||
intro = data.get("intro", {}) or {}
|
||||
outro = data.get("outro", {}) or {}
|
||||
|
||||
return cls(
|
||||
enabled=True,
|
||||
# 片头
|
||||
intro_type=str(intro.get("type", "none")),
|
||||
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
|
||||
intro_duration=float(intro.get("duration", 3.0)),
|
||||
intro_background=str(intro.get("background", "#000000")),
|
||||
intro_title=str(intro.get("title", "") or ""),
|
||||
intro_subtitle=str(intro.get("subtitle", "") or ""),
|
||||
intro_title_color=str(intro.get("title_color", "white")),
|
||||
intro_title_size=int(intro.get("title_size", 48)),
|
||||
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
|
||||
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
|
||||
# 片尾
|
||||
outro_type=str(outro.get("type", "none")),
|
||||
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
|
||||
outro_duration=float(outro.get("duration", 3.0)),
|
||||
outro_background=str(outro.get("background", "#000000")),
|
||||
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
|
||||
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
|
||||
outro_title_color=str(outro.get("title_color", "white")),
|
||||
outro_title_size=int(outro.get("title_size", 48)),
|
||||
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
|
||||
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
|
||||
# 转场
|
||||
transition_effect=str(data.get("transition", "fade")),
|
||||
transition_duration=float(data.get("transition_duration", 0.5)),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_intro(self) -> bool:
|
||||
"""是否有片头."""
|
||||
return self.enabled and self.intro_type in ("video", "text")
|
||||
|
||||
@property
|
||||
def has_outro(self) -> bool:
|
||||
"""是否有片尾."""
|
||||
return self.enabled and self.outro_type in ("video", "text", "follow")
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""校验配置."""
|
||||
if not self.enabled:
|
||||
return True, ""
|
||||
|
||||
if self.intro_type == "video" and not self.intro_video_path:
|
||||
return False, "视频片头缺少 video_path"
|
||||
if self.intro_type == "text" and not self.intro_title:
|
||||
return False, "文字片头缺少 title"
|
||||
|
||||
if self.outro_type == "video" and not self.outro_video_path:
|
||||
return False, "视频片尾缺少 video_path"
|
||||
if self.outro_type in ("text", "follow") and not self.outro_title:
|
||||
return False, "文字片尾缺少 title"
|
||||
|
||||
if self.intro_duration <= 0:
|
||||
return False, "片头时长必须大于 0"
|
||||
if self.outro_duration <= 0:
|
||||
return False, "片尾时长必须大于 0"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
class IntroOutroEngine:
|
||||
"""片头片尾引擎 — 生成片头片尾视频并与正片拼接."""
|
||||
|
||||
@staticmethod
|
||||
def generate_text_intro(
|
||||
output_path: Path,
|
||||
config: IntroOutroConfig,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_fps: int,
|
||||
) -> bool:
|
||||
"""生成纯文字片头视频.
|
||||
|
||||
Args:
|
||||
output_path: 输出文件路径
|
||||
config: 片头片尾配置
|
||||
output_width: 输出宽度
|
||||
output_height: 输出高度
|
||||
output_fps: 输出帧率
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
duration = config.intro_duration
|
||||
bg = config.intro_background.lstrip("#")
|
||||
|
||||
# 转义文字
|
||||
title = config.intro_title.replace(":", "\\:").replace("'", "\\'")
|
||||
subtitle = config.intro_subtitle.replace(":", "\\:").replace("'", "\\'")
|
||||
|
||||
# 颜色(FFmpeg 颜色格式)
|
||||
title_color = config.intro_title_color
|
||||
subtitle_color = config.intro_subtitle_color
|
||||
|
||||
# 计算位置:标题在中心偏上,副标题在中心偏下
|
||||
title_y = f"(h-text_h)/2 - {config.intro_title_size // 2}"
|
||||
subtitle_y = f"(h-text_h)/2 + {config.intro_title_size}"
|
||||
|
||||
# 构建滤镜
|
||||
filter_parts = []
|
||||
|
||||
# 背景
|
||||
filter_parts.append(
|
||||
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}[bg]"
|
||||
)
|
||||
|
||||
# 标题
|
||||
if title:
|
||||
filter_parts.append(
|
||||
f"[bg]drawtext="
|
||||
f"text='{title}':"
|
||||
f"fontsize={config.intro_title_size}:"
|
||||
f"fontcolor={title_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={title_y}:"
|
||||
f"alpha='if(lt(t,0.5),t/0.5,1)'" # 淡入
|
||||
f"[with_title]"
|
||||
)
|
||||
bg_label = "with_title"
|
||||
else:
|
||||
bg_label = "bg"
|
||||
|
||||
# 副标题
|
||||
if subtitle:
|
||||
filter_parts.append(
|
||||
f"[{bg_label}]drawtext="
|
||||
f"text='{subtitle}':"
|
||||
f"fontsize={config.intro_subtitle_size}:"
|
||||
f"fontcolor={subtitle_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={subtitle_y}:"
|
||||
f"alpha='if(lt(t,0.8),0,if(lt(t,1.2),(t-0.8)/0.4,1))'" # 延迟淡入
|
||||
f"[out]"
|
||||
)
|
||||
final_label = "out"
|
||||
else:
|
||||
final_label = bg_label
|
||||
# 如果没有副标题,需要补上 out 标签
|
||||
if final_label != "out":
|
||||
filter_parts.append(f"[{bg_label}]copy[out]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}",
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-r",
|
||||
str(output_fps),
|
||||
"-t",
|
||||
str(duration),
|
||||
"-an", # 无音频
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return output_path.exists()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("生成文字片头失败: %s", e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def generate_text_outro(
|
||||
output_path: Path,
|
||||
config: IntroOutroConfig,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_fps: int,
|
||||
) -> bool:
|
||||
"""生成纯文字片尾视频."""
|
||||
duration = config.outro_duration
|
||||
|
||||
# 转义文字
|
||||
title = config.outro_title.replace(":", "\\:").replace("'", "\\'")
|
||||
subtitle = config.outro_subtitle.replace(":", "\\:").replace("'", "\\'")
|
||||
|
||||
title_color = config.outro_title_color
|
||||
subtitle_color = config.outro_subtitle_color
|
||||
|
||||
# 位置
|
||||
title_y = f"(h-text_h)/2 - {config.outro_title_size // 2}"
|
||||
subtitle_y = f"(h-text_h)/2 + {config.outro_title_size}"
|
||||
|
||||
filter_parts = []
|
||||
|
||||
# 背景
|
||||
bg_src = f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}"
|
||||
filter_parts.append(f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}[bg]")
|
||||
|
||||
# 标题 + 淡出
|
||||
if title:
|
||||
filter_parts.append(
|
||||
f"[bg]drawtext="
|
||||
f"text='{title}':"
|
||||
f"fontsize={config.outro_title_size}:"
|
||||
f"fontcolor={title_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={title_y}:"
|
||||
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
|
||||
f"[with_title]"
|
||||
)
|
||||
bg_label = "with_title"
|
||||
else:
|
||||
bg_label = "bg"
|
||||
|
||||
# 副标题
|
||||
if subtitle:
|
||||
filter_parts.append(
|
||||
f"[{bg_label}]drawtext="
|
||||
f"text='{subtitle}':"
|
||||
f"fontsize={config.outro_subtitle_size}:"
|
||||
f"fontcolor={subtitle_color}:"
|
||||
f"x=(w-text_w)/2:"
|
||||
f"y={subtitle_y}:"
|
||||
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
|
||||
f"[out]"
|
||||
)
|
||||
final_label = "out"
|
||||
else:
|
||||
final_label = bg_label
|
||||
if final_label != "out":
|
||||
filter_parts.append(f"[{bg_label}]copy[out]")
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
bg_src,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-r",
|
||||
str(output_fps),
|
||||
"-t",
|
||||
str(duration),
|
||||
"-an",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return output_path.exists()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("生成文字片尾失败: %s", e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def concat_with_intro_outro(
|
||||
main_video: Path,
|
||||
intro_video: Path | None,
|
||||
outro_video: Path | None,
|
||||
output_path: Path,
|
||||
transition_duration: float = 0.5,
|
||||
transition_effect: str = "fade",
|
||||
) -> bool:
|
||||
"""将片头 + 正片 + 片尾用 xfade 拼接.
|
||||
|
||||
只传了片头或片尾也可以,缺失的自动跳过。
|
||||
"""
|
||||
# 收集所有片段
|
||||
segments: list[tuple[Path, float]] = [] # (path, duration)
|
||||
|
||||
# 简单探测时长(用 ffprobe,这里简化处理:直接用 xfade 的 offset)
|
||||
# 先添加到列表
|
||||
has_intro = intro_video is not None and intro_video.exists()
|
||||
has_outro = outro_video is not None and outro_video.exists()
|
||||
|
||||
if not has_intro and not has_outro:
|
||||
# 没有片头片尾,直接复制
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_video, output_path)
|
||||
return True
|
||||
|
||||
# 构建输入和 xfade 链
|
||||
# 简单方式:用 concat demuxer(快速但无转场)
|
||||
# 高级方式:用 xfade 滤镜链(有转场但复杂)
|
||||
|
||||
# 用 concat demuxer 方式(性能好,过渡用硬切)
|
||||
# 后续可以加 xfade 转场
|
||||
concat_list = []
|
||||
if has_intro:
|
||||
concat_list.append(intro_video)
|
||||
concat_list.append(main_video)
|
||||
if has_outro:
|
||||
concat_list.append(outro_video)
|
||||
|
||||
# 生成 concat 列表文件
|
||||
list_file = output_path.parent / f"concat_list_{output_path.stem}.txt"
|
||||
with open(list_file, "w") as f:
|
||||
for seg in concat_list:
|
||||
f.write(f"file '{seg}'\n")
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
str(list_file),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
# 清理列表文件
|
||||
list_file.unlink(missing_ok=True)
|
||||
return output_path.exists()
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error("片头片尾拼接失败: %s", e)
|
||||
list_file.unlink(missing_ok=True)
|
||||
return False
|
||||
@@ -1,478 +0,0 @@
|
||||
"""多轨道混音引擎 — 支持多路音频独立音量调节与混合.
|
||||
|
||||
基于 FFmpeg amix / amerge 实现:
|
||||
- 支持任意数量音频轨道(原音、BGM、配音、音效等)
|
||||
- 每轨独立音量调节
|
||||
- 每轨独立淡入淡出
|
||||
- 每轨独立时间偏移(delay)
|
||||
- 总输出音量归一化补偿
|
||||
|
||||
作为 render_audio.py 的增强模块,在 mix_audio 后处理阶段被调用。
|
||||
与 bgm_mixer.py 的关系:
|
||||
- bgm_mixer 专注 BGM 单轨道的复杂处理(循环、人声闪避)
|
||||
- 本模块专注多路轨道的统一音量调节与混合
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from video_processing.render_audio import RenderContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
TRACK_TYPE_MAIN = "main" # 原音(视频原声)
|
||||
TRACK_TYPE_BGM = "bgm" # 背景音乐
|
||||
TRACK_TYPE_VOICEOVER = "voiceover" # 配音(TTS/人声)
|
||||
TRACK_TYPE_SFX = "sfx" # 音效
|
||||
TRACK_TYPE_AMBIENT = "ambient" # 环境音
|
||||
|
||||
MAX_AUDIO_TRACKS = 8 # 最大混音轨道数(安全上限,防止资源耗尽)
|
||||
|
||||
# 各轨道默认音量(相对主音频)
|
||||
DEFAULT_VOLUMES = {
|
||||
TRACK_TYPE_MAIN: 1.0,
|
||||
TRACK_TYPE_BGM: 0.3,
|
||||
TRACK_TYPE_VOICEOVER: 1.0,
|
||||
TRACK_TYPE_SFX: 0.7,
|
||||
TRACK_TYPE_AMBIENT: 0.2,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioTrack:
|
||||
"""单条音频轨道配置."""
|
||||
|
||||
track_id: str # 轨道唯一标识
|
||||
track_type: str # 轨道类型(main/bgm/voiceover/sfx/ambient)
|
||||
audio_path: str # 音频文件路径
|
||||
volume: float = 1.0 # 音量 0.0 ~ 2.0
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
start_time: float = 0.0 # 开始时间(相对于视频起点,秒)
|
||||
duration: float = 0.0 # 持续时长(0表示到文件末尾)
|
||||
enabled: bool = True # 是否启用
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, track: dict) -> "AudioTrack":
|
||||
"""从字典创建 AudioTrack,带安全类型转换."""
|
||||
track_type = str(track.get("track_type", TRACK_TYPE_SFX))
|
||||
default_vol = DEFAULT_VOLUMES.get(track_type, 1.0)
|
||||
|
||||
try:
|
||||
volume = float(track.get("volume", default_vol))
|
||||
except (TypeError, ValueError):
|
||||
volume = default_vol
|
||||
volume = max(0.0, min(2.0, volume))
|
||||
|
||||
try:
|
||||
fade_in = max(0.0, float(track.get("fade_in", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_in = 0.0
|
||||
|
||||
try:
|
||||
fade_out = max(0.0, float(track.get("fade_out", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
fade_out = 0.0
|
||||
|
||||
try:
|
||||
start_time = max(0.0, float(track.get("start_time", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
start_time = 0.0
|
||||
|
||||
try:
|
||||
duration = max(0.0, float(track.get("duration", 0.0)))
|
||||
except (TypeError, ValueError):
|
||||
duration = 0.0
|
||||
|
||||
return cls(
|
||||
track_id=str(track.get("track_id", "")),
|
||||
track_type=track_type,
|
||||
audio_path=str(track.get("audio_path", "")),
|
||||
volume=volume,
|
||||
fade_in=fade_in,
|
||||
fade_out=fade_out,
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
enabled=bool(track.get("enabled", True)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiTrackMixConfig:
|
||||
"""多轨道混音配置."""
|
||||
|
||||
tracks: list[AudioTrack] = field(default_factory=list)
|
||||
master_volume: float = 1.0 # 主输出音量
|
||||
normalize: bool = True # 是否自动归一化补偿
|
||||
max_output_volume: float = 1.5 # 最大输出音量(防止爆音)
|
||||
|
||||
@classmethod
|
||||
def from_config_dict(cls, config: dict | None) -> "MultiTrackMixConfig":
|
||||
"""从 plan.config.audio_tracks 字典创建配置."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
tracks_raw = config.get("tracks", [])
|
||||
tracks: list[AudioTrack] = []
|
||||
|
||||
if isinstance(tracks_raw, list):
|
||||
for t in tracks_raw:
|
||||
if isinstance(t, dict) and t.get("audio_path"):
|
||||
try:
|
||||
track = AudioTrack.from_dict(t)
|
||||
if track.enabled and track.audio_path:
|
||||
tracks.append(track)
|
||||
except Exception:
|
||||
logger.warning("[multi-track] skip invalid track config: %s", t)
|
||||
continue
|
||||
|
||||
try:
|
||||
master_volume = float(config.get("master_volume", 1.0))
|
||||
master_volume = max(0.0, min(2.0, master_volume))
|
||||
except (TypeError, ValueError):
|
||||
master_volume = 1.0
|
||||
|
||||
return cls(
|
||||
tracks=tracks,
|
||||
master_volume=master_volume,
|
||||
normalize=bool(config.get("normalize", True)),
|
||||
max_output_volume=float(config.get("max_output_volume", 1.5)),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_effect(self) -> bool:
|
||||
"""是否有有效轨道需要混音."""
|
||||
return len([t for t in self.tracks if t.enabled and t.audio_path]) > 0
|
||||
|
||||
|
||||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
ALLOWED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"}
|
||||
|
||||
|
||||
def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
|
||||
"""校验音频文件路径安全性.
|
||||
|
||||
规则:
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是音频格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not audio_path or not isinstance(audio_path, str):
|
||||
raise PathSecurityError("音频路径不能为空")
|
||||
|
||||
# 本地路径(local:// 或相对路径)
|
||||
if audio_path.startswith("local://") or not audio_path.startswith(("http://", "https://", "oss://")):
|
||||
is_abs = audio_path.startswith("/") and not audio_path.startswith("local://")
|
||||
resolved_path = safe_resolve_path(
|
||||
audio_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_AUDIO_EXTENSIONS,
|
||||
)
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
|
||||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||||
# 但检查扩展名
|
||||
else:
|
||||
# URL路径,检查扩展名白名单(取 ? 之前的部分)
|
||||
path_part = audio_path.split("?")[0].split("#")[0]
|
||||
from pathlib import Path as _P
|
||||
|
||||
ext = _P(path_part).suffix.lower()
|
||||
if ext and ext not in ALLOWED_AUDIO_EXTENSIONS:
|
||||
raise PathSecurityError(f"不允许的音频文件类型: {ext}")
|
||||
|
||||
|
||||
# ── 单轨道预处理 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _prepare_single_track(
|
||||
ctx: "RenderContext",
|
||||
track: AudioTrack,
|
||||
target_duration: float,
|
||||
output_path: Path,
|
||||
) -> bool:
|
||||
"""预处理单条轨道:音量 + 淡入淡出 + 时间偏移 + 截断.
|
||||
|
||||
生成一个精确对齐时间轴的音频文件,后续统一 amix 混音。
|
||||
|
||||
Returns:
|
||||
True 表示处理成功,False 表示失败(跳过)
|
||||
"""
|
||||
try:
|
||||
audio_dur = probe_duration(track.audio_path)
|
||||
except Exception:
|
||||
logger.warning("[multi-track] probe failed, skip track: %s", track.track_id)
|
||||
return False
|
||||
|
||||
if audio_dur <= 0:
|
||||
return False
|
||||
|
||||
# 计算实际有效时长
|
||||
effective_start = track.start_time
|
||||
if track.duration > 0:
|
||||
effective_dur = min(track.duration, audio_dur)
|
||||
else:
|
||||
effective_dur = audio_dur
|
||||
|
||||
# 如果轨道完全在视频时长之外,跳过
|
||||
if effective_start >= target_duration:
|
||||
return False
|
||||
if effective_start + effective_dur <= 0:
|
||||
return False
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 1. 先截断到有效范围
|
||||
trim_start = 0.0 # 从源文件的哪个位置开始取
|
||||
if effective_start < 0:
|
||||
trim_start = -effective_start
|
||||
effective_start = 0.0
|
||||
|
||||
# 实际需要的源时长
|
||||
need_dur = min(effective_dur, target_duration - effective_start)
|
||||
if need_dur <= 0:
|
||||
return False
|
||||
|
||||
filter_parts.append(f"atrim={trim_start:.3f}:{trim_start + need_dur:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
# 2. 音量调节
|
||||
if abs(track.volume - 1.0) > 0.001:
|
||||
filter_parts.append(f"volume={track.volume:.3f}")
|
||||
|
||||
# 3. 淡入
|
||||
if track.fade_in > 0 and track.fade_in < need_dur:
|
||||
filter_parts.append(f"afade=t=in:st=0:d={track.fade_in:.3f}")
|
||||
|
||||
# 4. 淡出
|
||||
if track.fade_out > 0 and track.fade_out < need_dur:
|
||||
fade_start = need_dur - track.fade_out
|
||||
if fade_start > 0:
|
||||
filter_parts.append(f"afade=t=out:st={fade_start:.3f}:d={track.fade_out:.3f}")
|
||||
|
||||
# 5. 时间偏移(用 adelay 实现开头静音填充)
|
||||
if effective_start > 0.01:
|
||||
delay_ms = int(effective_start * 1000)
|
||||
filter_parts.append(f"adelay={delay_ms}|{delay_ms}")
|
||||
|
||||
# 6. 最终截断到目标总时长
|
||||
filter_parts.append(f"atrim=0:{target_duration:.3f}")
|
||||
filter_parts.append("asetpts=N/SR/TB")
|
||||
|
||||
filter_str = ",".join(filter_parts)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
track.audio_path,
|
||||
"-filter:a",
|
||||
filter_str,
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[multi-track] prepare track: id=%s type=%s vol=%.2f start=%.2f dur=%.2f",
|
||||
track.track_id,
|
||||
track.track_type,
|
||||
track.volume,
|
||||
effective_start,
|
||||
need_dur,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("[multi-track] track prepare failed: %s, error=%s", track.track_id, e)
|
||||
return False
|
||||
|
||||
|
||||
# ── 多轨道混音主入口 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def mix_multi_track(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
config: MultiTrackMixConfig,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""多轨道混音:主音频 + 多条附加轨道.
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
main_audio_path: 主音频文件路径(原音)
|
||||
config: 多轨道混音配置
|
||||
target_duration: 目标总时长
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径
|
||||
"""
|
||||
output_path = ctx.work_dir / f"multi_track_mix_{ctx.plan_id}.aac"
|
||||
|
||||
if target_duration <= 0:
|
||||
target_duration = 5.0
|
||||
|
||||
# ── 安全校验:轨道数量上限 ──
|
||||
enabled_tracks = [t for t in config.tracks if t.enabled and t.audio_path]
|
||||
if len(enabled_tracks) > MAX_AUDIO_TRACKS:
|
||||
logger.warning(
|
||||
"[multi-track] too many tracks: %d > %d, truncating to max",
|
||||
len(enabled_tracks),
|
||||
MAX_AUDIO_TRACKS,
|
||||
)
|
||||
enabled_tracks = enabled_tracks[:MAX_AUDIO_TRACKS]
|
||||
# 更新 config.tracks 为截断后的列表
|
||||
config.tracks = enabled_tracks
|
||||
|
||||
# ── 安全校验:所有音频路径白名单校验 ──
|
||||
# 主音频路径
|
||||
try:
|
||||
_validate_audio_path(str(main_audio_path), ctx.work_dir)
|
||||
except PathSecurityError as e:
|
||||
logger.error("[multi-track] main audio path security check failed: %s", e)
|
||||
raise
|
||||
|
||||
# 各轨道音频路径
|
||||
valid_tracks = []
|
||||
for track in enabled_tracks:
|
||||
try:
|
||||
_validate_audio_path(track.audio_path, ctx.work_dir)
|
||||
valid_tracks.append(track)
|
||||
except PathSecurityError as e:
|
||||
logger.warning("[multi-track] skip track %s: path security check failed: %s", track.track_id, e)
|
||||
|
||||
if len(valid_tracks) != len(enabled_tracks):
|
||||
config.tracks = valid_tracks
|
||||
logger.info("[multi-track] %d tracks passed security check", len(valid_tracks))
|
||||
|
||||
# 收集所有有效轨道(已预处理好的)
|
||||
prepared_tracks: list[Path] = []
|
||||
|
||||
# 主音频作为第0轨
|
||||
prepared_tracks.append(main_audio_path)
|
||||
|
||||
# 预处理每条附加轨道
|
||||
for i, track in enumerate(config.tracks):
|
||||
if not track.enabled or not track.audio_path:
|
||||
continue
|
||||
|
||||
track_out = ctx.work_dir / f"track_{i}_{ctx.plan_id}.aac"
|
||||
if _prepare_single_track(ctx, track, target_duration, track_out):
|
||||
prepared_tracks.append(track_out)
|
||||
|
||||
# 如果只有主音频,直接返回(无需混音)
|
||||
if len(prepared_tracks) <= 1:
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_audio_path, output_path)
|
||||
return output_path
|
||||
|
||||
# 使用 amix 混音
|
||||
num_inputs = len(prepared_tracks)
|
||||
|
||||
# 构建输入参数
|
||||
input_args: list[str] = []
|
||||
for tp in prepared_tracks:
|
||||
input_args.extend(["-i", str(tp)])
|
||||
|
||||
# amix 的 duration=first 以第一个输入(主音频)时长为准
|
||||
# normalize 补偿:amix 会把每路音量除以 N,需要乘回来
|
||||
# 但如果所有轨道都同时有声,可能会爆音,所以用 master_volume 控制
|
||||
if config.normalize:
|
||||
# 经验值:不是所有轨道都同时有声,补偿系数取 N * 0.7
|
||||
compensate = num_inputs * 0.7
|
||||
else:
|
||||
compensate = 1.0
|
||||
|
||||
final_volume = compensate * config.master_volume
|
||||
final_volume = min(final_volume, config.max_output_volume)
|
||||
|
||||
# 构建 filter_complex
|
||||
inputs_label = "".join(f"[{i}:a]" for i in range(num_inputs))
|
||||
filter_complex = (
|
||||
f"{inputs_label}amix=inputs={num_inputs}:duration=first:dropout_transition=0[outa];"
|
||||
f"[outa]volume={final_volume:.3f}[final]"
|
||||
)
|
||||
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
*input_args,
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
"[final]",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"[multi-track] mix %d tracks, master_vol=%.2f compensate=%.2f final_vol=%.2f",
|
||||
num_inputs,
|
||||
config.master_volume,
|
||||
compensate,
|
||||
final_volume,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
except Exception as e:
|
||||
logger.error("[multi-track] mix failed, fallback to main audio only: %s", e)
|
||||
import shutil
|
||||
|
||||
shutil.copy2(main_audio_path, output_path)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
# ── 便捷函数:从 plan.config 快速混音 ───────────────────────────────────────
|
||||
|
||||
|
||||
def mix_audio_tracks_from_config(
|
||||
ctx: "RenderContext",
|
||||
main_audio_path: Path,
|
||||
audio_tracks_config: dict | None,
|
||||
target_duration: float,
|
||||
) -> Path:
|
||||
"""从 plan.config.audio_tracks 配置执行多轨道混音.
|
||||
|
||||
降级策略:配置无效或混音失败时返回主音频。
|
||||
"""
|
||||
config = MultiTrackMixConfig.from_config_dict(audio_tracks_config)
|
||||
if not config.has_effect:
|
||||
return main_audio_path
|
||||
|
||||
return mix_multi_track(ctx, main_audio_path, config, target_duration)
|
||||
@@ -1,229 +0,0 @@
|
||||
"""音频降噪引擎 — 基于 FFmpeg afftdn 滤镜.
|
||||
|
||||
支持对音频进行背景噪音消除、人声增强,适用于语音录制、采访等场景。
|
||||
|
||||
使用方式:
|
||||
config = NoiseReductionConfig(level="medium")
|
||||
engine = NoiseReductionEngine(config)
|
||||
filter_str = engine.build_filter(input_label, output_label)
|
||||
# 结果: [0:a]afftdn=nf=-25[out]
|
||||
|
||||
降级策略:
|
||||
- 参数越界自动钳制
|
||||
- FFmpeg 不支持 afftdn 时,调用方可捕获异常并跳过
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 降噪等级 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NoiseReductionLevel(str, Enum):
|
||||
"""降噪等级预设。"""
|
||||
|
||||
LOW = "low" # 轻度降噪,保留细节,适合轻微背景噪音
|
||||
MEDIUM = "medium" # 中度降噪,平衡效果和音质
|
||||
HIGH = "high" # 高度降噪,适合嘈杂环境,可能轻微影响音质
|
||||
CUSTOM = "custom" # 自定义参数
|
||||
|
||||
|
||||
# 各等级对应的降噪参数(afftdn 的 noise floor,单位 dB)
|
||||
# 值越大(越接近 0),降噪越强;值越小(越负),降噪越弱
|
||||
_LEVEL_PARAMS = {
|
||||
NoiseReductionLevel.LOW: {
|
||||
"nf": -35, # 噪音阈值(dB),越负越保守
|
||||
"tn": -10, # 噪音频谱平滑度
|
||||
"tr": 50, # 时间分辨率(ms)
|
||||
},
|
||||
NoiseReductionLevel.MEDIUM: {
|
||||
"nf": -25,
|
||||
"tn": -10,
|
||||
"tr": 50,
|
||||
},
|
||||
NoiseReductionLevel.HIGH: {
|
||||
"nf": -15,
|
||||
"tn": -5,
|
||||
"tr": 30,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 配置模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class NoiseReductionConfig:
|
||||
"""音频降噪配置。
|
||||
|
||||
Attributes:
|
||||
enabled: 是否启用降噪
|
||||
level: 降噪等级 low/medium/high/custom
|
||||
noise_floor: 自定义噪音阈值(dB),仅 level=custom 时有效,范围 -60 ~ -5
|
||||
voice_enhance: 是否启用人声增强
|
||||
output_format: 输出格式描述(内部使用)
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
level: NoiseReductionLevel = NoiseReductionLevel.MEDIUM
|
||||
noise_floor: float = -25.0 # dB
|
||||
voice_enhance: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict | None) -> "NoiseReductionConfig":
|
||||
"""从字典解析配置,参数越界自动钳制。"""
|
||||
if not data or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
level_str = str(data.get("level", "medium")).lower()
|
||||
try:
|
||||
level = NoiseReductionLevel(level_str)
|
||||
except ValueError:
|
||||
level = NoiseReductionLevel.MEDIUM
|
||||
|
||||
try:
|
||||
noise_floor = float(data.get("noise_floor", -25.0))
|
||||
except (TypeError, ValueError):
|
||||
noise_floor = -25.0
|
||||
|
||||
voice_enhance = bool(data.get("voice_enhance", False))
|
||||
|
||||
# 钳制到合法范围
|
||||
noise_floor = max(-60.0, min(-5.0, noise_floor))
|
||||
|
||||
return cls(
|
||||
enabled=True,
|
||||
level=level,
|
||||
noise_floor=noise_floor,
|
||||
voice_enhance=voice_enhance,
|
||||
)
|
||||
|
||||
def has_effect(self) -> bool:
|
||||
"""判断是否有实际降噪效果。"""
|
||||
return self.enabled
|
||||
|
||||
def get_effective_noise_floor(self) -> float:
|
||||
"""获取实际生效的噪音阈值(dB)。"""
|
||||
if self.level == NoiseReductionLevel.CUSTOM:
|
||||
return self.noise_floor
|
||||
params = _LEVEL_PARAMS.get(self.level, _LEVEL_PARAMS[NoiseReductionLevel.MEDIUM])
|
||||
return float(params["nf"])
|
||||
|
||||
|
||||
# ── 引擎实现 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NoiseReductionEngine:
|
||||
"""音频降噪引擎。
|
||||
|
||||
基于 FFmpeg afftdn(Audio FFt Denoiser)滤镜实现:
|
||||
- 使用短时傅里叶变换分析音频频谱
|
||||
- 识别并消除稳态背景噪音
|
||||
- 保留人声等非稳态信号
|
||||
"""
|
||||
|
||||
def __init__(self, config: NoiseReductionConfig):
|
||||
self.config = config
|
||||
|
||||
def build_filter(self, input_label: str, output_label: str) -> str:
|
||||
"""构建音频降噪滤镜字符串。
|
||||
|
||||
Args:
|
||||
input_label: 输入标签,如 "[0:a]" 或 "[a0]"
|
||||
output_label: 输出标签,如 "[nr0]"
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串,如 "[a0]afftdn=nf=-25:tn=-10:tr=50[nr0]"
|
||||
|
||||
Raises:
|
||||
ValueError: 配置无效时抛出(调用方应捕获并降级)
|
||||
"""
|
||||
if not self.config.has_effect():
|
||||
return f"{input_label}anull{output_label}"
|
||||
|
||||
# 获取参数
|
||||
if self.config.level == NoiseReductionLevel.CUSTOM:
|
||||
nf = self.config.noise_floor
|
||||
tn = -10 # 默认频谱平滑度
|
||||
tr = 50 # 默认时间分辨率
|
||||
else:
|
||||
params = _LEVEL_PARAMS.get(
|
||||
self.config.level,
|
||||
_LEVEL_PARAMS[NoiseReductionLevel.MEDIUM],
|
||||
)
|
||||
nf = float(params["nf"])
|
||||
tn = float(params["tn"])
|
||||
tr = float(params["tr"])
|
||||
|
||||
# 构建 afftdn 滤镜
|
||||
# nf: noise floor (dB)
|
||||
# tn: temporal noise floor smoothing (dB)
|
||||
# tr: time resolution (ms)
|
||||
filter_parts = [f"afftdn=nf={nf}:tn={tn}:tr={tr}"]
|
||||
|
||||
# 人声增强:通过 highpass + 轻微压缩实现
|
||||
if self.config.voice_enhance:
|
||||
# 1. 高通滤波,去除低频噪音
|
||||
filter_parts.append("highpass=f=80")
|
||||
# 2. 轻微压缩,提升人声清晰度
|
||||
filter_parts.append("acompressor=threshold=-20:ratio=2:attack=5:release=50")
|
||||
# 3. 响度归一化
|
||||
filter_parts.append("loudnorm=I=-16:TP=-1.5:LRA=11")
|
||||
|
||||
filter_str = f"{input_label}{','.join(filter_parts)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
def build_filter_arnndn(self, input_label: str, output_label: str, model_file: str) -> str:
|
||||
"""使用 RNN 降噪滤镜(arnndn,效果更好但需要模型文件)。
|
||||
|
||||
注意:需要额外下载 RNNNoise 模型文件,默认使用 afftdn(无需额外依赖)。
|
||||
|
||||
Args:
|
||||
input_label: 输入标签
|
||||
output_label: 输出标签
|
||||
model_file: RNNNoise 模型文件路径(.rnnn 格式)
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串
|
||||
"""
|
||||
if not self.config.has_effect():
|
||||
return f"{input_label}anull{output_label}"
|
||||
|
||||
return f"{input_label}arnndn=m={model_file}{output_label}"
|
||||
|
||||
|
||||
def apply_noise_reduction_if_needed(
|
||||
config_data: dict | None,
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
) -> Optional[str]:
|
||||
"""便捷函数:根据配置判断是否需要应用音频降噪。
|
||||
|
||||
Args:
|
||||
config_data: 降噪配置字典(从 plan.config.audio_noise_reduction 或 clip.config.noise_reduction 读取)
|
||||
input_label: 输入标签
|
||||
output_label: 输出标签
|
||||
|
||||
Returns:
|
||||
滤镜字符串,不需要降噪时返回 None
|
||||
"""
|
||||
if not config_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
config = NoiseReductionConfig.from_dict(config_data)
|
||||
if not config.has_effect():
|
||||
return None
|
||||
|
||||
engine = NoiseReductionEngine(config)
|
||||
return engine.build_filter(input_label, output_label)
|
||||
except Exception as e:
|
||||
logger.warning("[noise-reduction] 应用降噪失败,跳过: %s", e)
|
||||
return None
|
||||
@@ -220,64 +220,25 @@ def resolve_asset_path(asset_id: str, work_dir: Path) -> Path | None:
|
||||
"""从 asset_id 解析到本地文件路径。
|
||||
|
||||
策略(按优先级):
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 安全校验后返回
|
||||
1. 如果 asset_id 是本地绝对路径(/var/storage/...)→ 直接返回
|
||||
2. 如果 work_dir 下已有缓存文件 → 返回缓存路径
|
||||
3. 从 OSS 下载到 work_dir/{hash}.mp4 → 返回下载路径
|
||||
4. 下载失败 → 返回 None
|
||||
|
||||
缓存策略:以 asset_id 的 SHA256 前 16 位为文件名,避免重复下载。
|
||||
|
||||
安全:
|
||||
- 本地绝对路径必须在 ASSET_ALLOWED_DIRS 环境变量指定的目录内
|
||||
- 文件名经过 sanitize,防止路径遍历
|
||||
- 禁止空字节、控制字符
|
||||
"""
|
||||
from video_processing.path_security import (
|
||||
PathSecurityError,
|
||||
get_allowed_local_dirs,
|
||||
is_in_allowed_dirs,
|
||||
sanitize_filename,
|
||||
)
|
||||
|
||||
if not asset_id or not isinstance(asset_id, str):
|
||||
return None
|
||||
|
||||
# 空字节检测
|
||||
if "\x00" in asset_id:
|
||||
logger.warning("asset_id 包含空字节,拒绝: %s", asset_id[:50])
|
||||
return None
|
||||
|
||||
# 1. 本地绝对路径 — 必须在允许的目录内
|
||||
# 1. 本地绝对路径
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
try:
|
||||
resolved = Path(asset_id).resolve()
|
||||
if is_in_allowed_dirs(resolved, get_allowed_local_dirs()):
|
||||
return resolved
|
||||
else:
|
||||
logger.warning(
|
||||
"本地素材路径不在允许目录内,拒绝: %s (allowed=%s)",
|
||||
asset_id[:80],
|
||||
get_allowed_local_dirs(),
|
||||
)
|
||||
return None
|
||||
except (OSError, PathSecurityError):
|
||||
return None
|
||||
return Path(asset_id)
|
||||
|
||||
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
|
||||
# 2. 缓存命中
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
safe_name = sanitize_filename(cache_hash)
|
||||
cached_path = work_dir / f"{safe_name}.mp4"
|
||||
cached_path = work_dir / f"{cache_hash}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载(先标准化 key,防止路径遍历注入)
|
||||
safe_key = normalize_storage_key(asset_id)
|
||||
# 额外校验:存储键不能包含 ../ 或绝对路径
|
||||
if ".." in safe_key or safe_key.startswith("/"):
|
||||
logger.warning("asset_id 包含路径遍历模式,拒绝下载: %s", asset_id[:80])
|
||||
return None
|
||||
|
||||
if download_asset(safe_key, cached_path):
|
||||
# 3. 从 OSS 下载
|
||||
if download_asset(asset_id, cached_path):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
"""路径安全校验工具 — 路径遍历防护.
|
||||
|
||||
统一的文件路径安全校验方案,覆盖所有渲染管线中的路径处理场景:
|
||||
- 本地素材路径校验
|
||||
- local:// 路径 schema 校验
|
||||
- 工作目录内路径安全约束
|
||||
- 防止路径遍历攻击 (../)
|
||||
|
||||
防护要点:
|
||||
1. 所有用户可控路径必须在允许的目录内
|
||||
2. 解析符号链接后的真实路径仍需在允许目录内
|
||||
3. 禁止空路径、相对路径遍历、绝对路径逃逸
|
||||
4. 路径字符限制与规范化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最大路径长度
|
||||
MAX_PATH_LENGTH = 4096
|
||||
|
||||
# 允许的文件扩展名(渲染相关)
|
||||
ALLOWED_MEDIA_EXTENSIONS = {
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".webm",
|
||||
".flv",
|
||||
".wmv", # 视频
|
||||
".mp3",
|
||||
".wav",
|
||||
".aac",
|
||||
".ogg",
|
||||
".flac",
|
||||
".m4a",
|
||||
".wma", # 音频
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".bmp",
|
||||
".webp",
|
||||
".tiff", # 图片
|
||||
".srt",
|
||||
".ass",
|
||||
".vtt",
|
||||
".sub", # 字幕
|
||||
".txt",
|
||||
".json", # 文本/配置
|
||||
}
|
||||
|
||||
# local:// schema 前缀
|
||||
LOCAL_SCHEMA_PREFIX = "local://"
|
||||
|
||||
|
||||
class PathSecurityError(ValueError):
|
||||
"""路径安全校验失败."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def safe_resolve_path(
|
||||
input_path: str | Path,
|
||||
base_dir: str | Path,
|
||||
*,
|
||||
allow_outside: bool = False,
|
||||
allowed_extensions: set[str] | None = None,
|
||||
) -> Path:
|
||||
"""安全解析路径,确保最终路径在 base_dir 内.
|
||||
|
||||
Args:
|
||||
input_path: 输入路径(相对或绝对)
|
||||
base_dir: 基路径目录,解析后的路径必须在此目录内
|
||||
allow_outside: 是否允许路径在 base_dir 外(默认禁止)
|
||||
allowed_extensions: 允许的文件扩展名集合(None 表示不限制)
|
||||
|
||||
Returns:
|
||||
解析后的绝对路径 Path 对象
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if input_path is None:
|
||||
raise PathSecurityError("路径不能为空")
|
||||
|
||||
path_str = str(input_path).strip()
|
||||
if not path_str:
|
||||
raise PathSecurityError("路径不能为空")
|
||||
|
||||
if len(path_str) > MAX_PATH_LENGTH:
|
||||
raise PathSecurityError(f"路径过长 ({len(path_str)} > {MAX_PATH_LENGTH})")
|
||||
|
||||
# 空字节检测(必须在 Path() 之前)
|
||||
if "\x00" in path_str:
|
||||
raise PathSecurityError("路径包含空字节")
|
||||
|
||||
# 处理 local:// schema
|
||||
if path_str.startswith(LOCAL_SCHEMA_PREFIX):
|
||||
path_str = path_str[len(LOCAL_SCHEMA_PREFIX) :]
|
||||
# local:// 后必须是相对路径(相对于 base_dir),不能是绝对路径
|
||||
if os.path.isabs(path_str):
|
||||
raise PathSecurityError("local:// 路径不能是绝对路径")
|
||||
|
||||
# 规范化 base_dir
|
||||
base_dir = Path(base_dir).resolve()
|
||||
if not base_dir.is_dir():
|
||||
raise PathSecurityError(f"基路径不是有效目录: {base_dir}")
|
||||
|
||||
# 解析输入路径
|
||||
input_path_obj = Path(path_str)
|
||||
|
||||
# 如果是绝对路径且不允许外部路径
|
||||
if input_path_obj.is_absolute() and not allow_outside:
|
||||
raise PathSecurityError("禁止使用绝对路径(需在工作目录内)")
|
||||
|
||||
# 组合并解析为绝对路径
|
||||
if input_path_obj.is_absolute():
|
||||
full_path = input_path_obj.resolve()
|
||||
else:
|
||||
full_path = (base_dir / input_path_obj).resolve()
|
||||
|
||||
# 检查路径遍历 — 确保最终路径在 base_dir 内
|
||||
if not allow_outside:
|
||||
try:
|
||||
full_path.relative_to(base_dir)
|
||||
except ValueError:
|
||||
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
|
||||
|
||||
# 扩展名校验
|
||||
if allowed_extensions is not None:
|
||||
ext = full_path.suffix.lower()
|
||||
if ext and ext not in allowed_extensions:
|
||||
raise PathSecurityError(f"不允许的文件类型: {ext}")
|
||||
|
||||
# 检查危险路径模式
|
||||
_check_dangerous_patterns(full_path)
|
||||
|
||||
return full_path
|
||||
|
||||
|
||||
def _check_dangerous_patterns(path: Path) -> None:
|
||||
"""检查危险路径模式."""
|
||||
path_str = str(path)
|
||||
|
||||
# 检查空字节
|
||||
if "\x00" in path_str:
|
||||
raise PathSecurityError("路径包含空字节")
|
||||
|
||||
# 检查特殊设备文件(Linux)
|
||||
dangerous_prefixes = [
|
||||
"/proc/",
|
||||
"/sys/",
|
||||
"/dev/",
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
"/root/",
|
||||
"/boot/",
|
||||
"/var/run/",
|
||||
]
|
||||
for prefix in dangerous_prefixes:
|
||||
if path_str.startswith(prefix):
|
||||
raise PathSecurityError(f"禁止访问系统路径: {prefix}")
|
||||
|
||||
|
||||
def is_path_safe(
|
||||
input_path: str | Path,
|
||||
base_dir: str | Path,
|
||||
*,
|
||||
allow_outside: bool = False,
|
||||
) -> bool:
|
||||
"""便捷函数:检查路径是否安全,不抛异常."""
|
||||
try:
|
||||
safe_resolve_path(input_path, base_dir, allow_outside=allow_outside)
|
||||
return True
|
||||
except PathSecurityError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_local_schema_path(
|
||||
schema_path: str,
|
||||
work_dir: str | Path,
|
||||
) -> Path:
|
||||
"""校验 local:// schema 路径,返回安全的本地路径.
|
||||
|
||||
local:// 路径规则:
|
||||
- 必须以 local:// 开头
|
||||
- 后面必须是相对路径
|
||||
- 最终解析后必须在 work_dir 内
|
||||
- 不允许 ../ 遍历
|
||||
|
||||
Args:
|
||||
schema_path: local:// 开头的路径
|
||||
work_dir: 工作目录
|
||||
|
||||
Returns:
|
||||
解析后的安全路径
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not schema_path.startswith(LOCAL_SCHEMA_PREFIX):
|
||||
raise PathSecurityError(f"路径必须以 {LOCAL_SCHEMA_PREFIX} 开头")
|
||||
|
||||
return safe_resolve_path(schema_path, work_dir, allow_outside=False)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""清理文件名,移除危险字符.
|
||||
|
||||
保留:字母、数字、下划线、连字符、点、中文字符
|
||||
移除:路径分隔符、控制字符、特殊符号等
|
||||
"""
|
||||
import re
|
||||
|
||||
if not filename:
|
||||
return "unnamed"
|
||||
|
||||
# 移除路径分隔符和危险字符
|
||||
# 保留: 字母数字、中文字符、下划线、连字符、点、空格
|
||||
sanitized = re.sub(r'[\\/\x00-\x1f\x7f<>:"|?*]', "_", filename)
|
||||
|
||||
# 移除开头的点和连续的点(防止隐藏文件和路径遍历)
|
||||
while sanitized.startswith("."):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
# 限制长度
|
||||
if len(sanitized) > 255:
|
||||
name, ext = os.path.splitext(sanitized)
|
||||
sanitized = name[: 255 - len(ext)] + ext
|
||||
|
||||
# 空文件名兜底
|
||||
if not sanitized or sanitized == ".":
|
||||
sanitized = "unnamed"
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
# ── 允许目录配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_allowed_local_dirs() -> list[Path]:
|
||||
"""获取允许的本地素材目录列表(从环境变量读取).
|
||||
|
||||
环境变量 ASSET_ALLOWED_DIRS,多个目录用冒号分隔(Linux)或分号分隔(Windows)。
|
||||
默认包含 /tmp。
|
||||
|
||||
用于:
|
||||
- resolve_asset_path 本地绝对路径白名单
|
||||
- PiP local_path 类型白名单
|
||||
- 贴纸本地路径白名单
|
||||
"""
|
||||
env_dirs = os.environ.get("ASSET_ALLOWED_DIRS", "")
|
||||
dirs: list[Path] = []
|
||||
if env_dirs:
|
||||
import re
|
||||
|
||||
sep = ";" if os.name == "nt" else ":"
|
||||
for d in re.split(f"[{sep}]", env_dirs):
|
||||
d = d.strip()
|
||||
if d:
|
||||
try:
|
||||
dirs.append(Path(d).resolve())
|
||||
except OSError:
|
||||
pass
|
||||
# 默认允许 /tmp
|
||||
if not dirs:
|
||||
try:
|
||||
dirs.append(Path("/tmp").resolve()) # nosec B108
|
||||
except OSError:
|
||||
pass
|
||||
return dirs
|
||||
|
||||
|
||||
def is_in_allowed_dirs(path: str | Path, allowed_dirs: list[Path] | None = None) -> bool:
|
||||
"""检查路径是否在允许的目录列表内.
|
||||
|
||||
Args:
|
||||
path: 待检查的路径
|
||||
allowed_dirs: 允许的目录列表,None 则使用默认配置
|
||||
|
||||
Returns:
|
||||
True 表示在允许目录内
|
||||
"""
|
||||
if allowed_dirs is None:
|
||||
allowed_dirs = get_allowed_local_dirs()
|
||||
|
||||
try:
|
||||
resolved = Path(path).resolve()
|
||||
for allowed in allowed_dirs:
|
||||
try:
|
||||
resolved.relative_to(allowed)
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
@@ -1,509 +0,0 @@
|
||||
"""画中画(PiP)引擎 — 基于 FFmpeg overlay 滤镜实现多图层叠加.
|
||||
|
||||
支持能力:
|
||||
- 多图层叠加:主画面 + 多个副画面
|
||||
- 位置:9宫格 + 自由坐标(像素或百分比)
|
||||
- 大小:宽高缩放(像素或百分比)
|
||||
- 圆角裁剪:支持圆角矩形裁剪
|
||||
- 透明度:0-100%
|
||||
- 入场出场动画:淡入淡出、滑入滑出
|
||||
- 时间同步:每个副画面独立开始时间和持续时长
|
||||
- 降级策略:素材不存在时跳过,不阻断渲染
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 位置常量 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# 9宫格位置枚举
|
||||
POSITION_TOP_LEFT = "top_left"
|
||||
POSITION_TOP_CENTER = "top_center"
|
||||
POSITION_TOP_RIGHT = "top_right"
|
||||
POSITION_CENTER_LEFT = "center_left"
|
||||
POSITION_CENTER = "center"
|
||||
POSITION_CENTER_RIGHT = "center_right"
|
||||
POSITION_BOTTOM_LEFT = "bottom_left"
|
||||
POSITION_BOTTOM_CENTER = "bottom_center"
|
||||
POSITION_BOTTOM_RIGHT = "bottom_right"
|
||||
|
||||
_VALID_POSITIONS = {
|
||||
POSITION_TOP_LEFT,
|
||||
POSITION_TOP_CENTER,
|
||||
POSITION_TOP_RIGHT,
|
||||
POSITION_CENTER_LEFT,
|
||||
POSITION_CENTER,
|
||||
POSITION_CENTER_RIGHT,
|
||||
POSITION_BOTTOM_LEFT,
|
||||
POSITION_BOTTOM_CENTER,
|
||||
POSITION_BOTTOM_RIGHT,
|
||||
}
|
||||
|
||||
# 动画类型
|
||||
ANIMATION_FADE = "fade" # 淡入淡出
|
||||
ANIMATION_SLIDE_LEFT = "slide_left" # 从左滑入
|
||||
ANIMATION_SLIDE_RIGHT = "slide_right" # 从右滑入
|
||||
ANIMATION_SLIDE_TOP = "slide_top" # 从上滑入
|
||||
ANIMATION_SLIDE_BOTTOM = "slide_bottom" # 从下滑入
|
||||
|
||||
_VALID_ANIMATIONS = {
|
||||
ANIMATION_FADE,
|
||||
ANIMATION_SLIDE_LEFT,
|
||||
ANIMATION_SLIDE_RIGHT,
|
||||
ANIMATION_SLIDE_TOP,
|
||||
ANIMATION_SLIDE_BOTTOM,
|
||||
}
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PiPLayerConfig:
|
||||
"""单个画中画图层配置."""
|
||||
|
||||
# 素材来源
|
||||
source: str = "" # 素材ID或视频URL
|
||||
source_type: str = "asset_id" # "asset_id" | "url" | "local_path"
|
||||
|
||||
# 位置配置
|
||||
position: str = POSITION_BOTTOM_RIGHT # 9宫格位置或 "custom"
|
||||
x: int | str = 0 # 自定义x坐标(像素或百分比如 "30%")
|
||||
y: int | str = 0 # 自定义y坐标
|
||||
margin: int = 20 # 9宫格模式下的边距(像素)
|
||||
|
||||
# 大小配置
|
||||
width: int | str = "25%" # 宽度(像素或百分比)
|
||||
height: int | str = "" # 高度(空则按比例自适应)
|
||||
|
||||
# 样式
|
||||
opacity: float = 1.0 # 透明度 0.0-1.0
|
||||
corner_radius: int = 0 # 圆角半径(像素),0表示无圆角
|
||||
border_width: int = 0 # 边框宽度
|
||||
border_color: str = "white" # 边框颜色
|
||||
|
||||
# 时间控制
|
||||
start_time: float = 0.0 # 开始显示时间(秒)
|
||||
duration: float = 0.0 # 持续时长(秒),0表示全程显示
|
||||
|
||||
# 动画
|
||||
animation_in: str = "" # 入场动画类型
|
||||
animation_out: str = "" # 出场动画类型
|
||||
animation_duration: float = 0.5 # 动画时长(秒)
|
||||
|
||||
# 层级
|
||||
z_index: int = 1 # 图层顺序,数字越大越在上层
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""校验配置合法性,返回 (是否合法, 错误信息)."""
|
||||
if not self.source:
|
||||
return False, "source不能为空"
|
||||
|
||||
if self.position != "custom" and self.position not in _VALID_POSITIONS:
|
||||
return False, f"无效的position: {self.position}"
|
||||
|
||||
if self.opacity < 0 or self.opacity > 1:
|
||||
return False, "opacity必须在0-1之间"
|
||||
|
||||
if self.corner_radius < 0:
|
||||
return False, "corner_radius不能为负数"
|
||||
|
||||
if self.start_time < 0:
|
||||
return False, "start_time不能为负数"
|
||||
|
||||
if self.duration < 0:
|
||||
return False, "duration不能为负数"
|
||||
|
||||
if self.animation_in and self.animation_in not in _VALID_ANIMATIONS:
|
||||
return False, f"无效的入场动画: {self.animation_in}"
|
||||
|
||||
if self.animation_out and self.animation_out not in _VALID_ANIMATIONS:
|
||||
return False, f"无效的出场动画: {self.animation_out}"
|
||||
|
||||
if self.animation_duration < 0:
|
||||
return False, "animation_duration不能为负数"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PiPConfig:
|
||||
"""画中画整体配置."""
|
||||
|
||||
enabled: bool = False
|
||||
layers: list[PiPLayerConfig] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "PiPConfig":
|
||||
"""从字典解析配置."""
|
||||
if not data or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
layers_data = data.get("layers", [])
|
||||
layers = []
|
||||
for layer_data in layers_data:
|
||||
try:
|
||||
layer = PiPLayerConfig(
|
||||
source=layer_data.get("source", ""),
|
||||
source_type=layer_data.get("source_type", "asset_id"),
|
||||
position=layer_data.get("position", POSITION_BOTTOM_RIGHT),
|
||||
x=layer_data.get("x", 0),
|
||||
y=layer_data.get("y", 0),
|
||||
margin=int(layer_data.get("margin", 20)),
|
||||
width=layer_data.get("width", "25%"),
|
||||
height=layer_data.get("height", ""),
|
||||
opacity=float(layer_data.get("opacity", 1.0)),
|
||||
corner_radius=int(layer_data.get("corner_radius", 0)),
|
||||
border_width=int(layer_data.get("border_width", 0)),
|
||||
border_color=layer_data.get("border_color", "white"),
|
||||
start_time=float(layer_data.get("start_time", 0.0)),
|
||||
duration=float(layer_data.get("duration", 0.0)),
|
||||
animation_in=layer_data.get("animation_in", ""),
|
||||
animation_out=layer_data.get("animation_out", ""),
|
||||
animation_duration=float(layer_data.get("animation_duration", 0.5)),
|
||||
z_index=int(layer_data.get("z_index", 1)),
|
||||
)
|
||||
valid, err = layer.validate()
|
||||
if valid:
|
||||
layers.append(layer)
|
||||
else:
|
||||
logger.warning("PiP图层配置无效,跳过: %s", err)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning("PiP图层解析失败,跳过: %s", e)
|
||||
|
||||
# 按 z_index 排序
|
||||
layers.sort(key=lambda layer: layer.z_index)
|
||||
|
||||
return cls(enabled=bool(layers), layers=layers)
|
||||
|
||||
|
||||
# ── PiP 引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PiPEngine:
|
||||
"""画中画引擎 — 生成 FFmpeg 滤镜链实现多图层叠加."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_fps: int = 30,
|
||||
):
|
||||
self.output_width = output_width
|
||||
self.output_height = output_height
|
||||
self.output_fps = output_fps
|
||||
|
||||
def _parse_size(self, value: int | str, base: int) -> int:
|
||||
"""解析尺寸值(像素或百分比)."""
|
||||
if isinstance(value, int):
|
||||
return max(1, value)
|
||||
if isinstance(value, str) and value.endswith("%"):
|
||||
pct = float(value.rstrip("%")) / 100.0
|
||||
return max(1, int(base * pct))
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except (ValueError, TypeError):
|
||||
return int(base * 0.25) # 默认25%
|
||||
|
||||
def _parse_position(
|
||||
self,
|
||||
layer: PiPLayerConfig,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
) -> tuple[int, int]:
|
||||
"""计算画中画的实际位置 (x, y)."""
|
||||
W = self.output_width
|
||||
H = self.output_height
|
||||
m = layer.margin
|
||||
|
||||
if layer.position == "custom":
|
||||
x = self._parse_size(layer.x, W)
|
||||
y = self._parse_size(layer.y, H)
|
||||
return (x, y)
|
||||
|
||||
pos_map = {
|
||||
POSITION_TOP_LEFT: (m, m),
|
||||
POSITION_TOP_CENTER: ((W - pip_width) // 2, m),
|
||||
POSITION_TOP_RIGHT: (W - pip_width - m, m),
|
||||
POSITION_CENTER_LEFT: (m, (H - pip_height) // 2),
|
||||
POSITION_CENTER: ((W - pip_width) // 2, (H - pip_height) // 2),
|
||||
POSITION_CENTER_RIGHT: (W - pip_width - m, (H - pip_height) // 2),
|
||||
POSITION_BOTTOM_LEFT: (m, H - pip_height - m),
|
||||
POSITION_BOTTOM_CENTER: ((W - pip_width) // 2, H - pip_height - m),
|
||||
POSITION_BOTTOM_RIGHT: (W - pip_width - m, H - pip_height - m),
|
||||
}
|
||||
return pos_map.get(layer.position, pos_map[POSITION_BOTTOM_RIGHT])
|
||||
|
||||
def _build_pip_pre_filter(
|
||||
self,
|
||||
input_label: str,
|
||||
layer: PiPLayerConfig,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
output_label: str,
|
||||
) -> str:
|
||||
"""构建单个PiP图层的预处理滤镜链.
|
||||
|
||||
处理顺序:scale → 圆角裁剪(可选)→ 边框(可选)→ 透明度 → 动画(可选)
|
||||
"""
|
||||
filters: list[str] = []
|
||||
|
||||
# Step 1: scale
|
||||
filters.append(f"scale={pip_width}:{pip_height}")
|
||||
filters.append("setsar=1")
|
||||
|
||||
# Step 2: 圆角裁剪
|
||||
if layer.corner_radius > 0:
|
||||
r = min(layer.corner_radius, pip_width // 2, pip_height // 2)
|
||||
# 使用 geq + 圆形遮罩实现圆角
|
||||
# 更简单的方式:用 rounded 滤镜(FFmpeg 5.0+)或 format + alpha
|
||||
# 这里用更通用的方式:创建圆角遮罩 + overlay 到透明背景
|
||||
filters.append(
|
||||
f"format=yuva420p,"
|
||||
f"geq="
|
||||
f"lum='lum(X,Y)':"
|
||||
f"cb='cb(X,Y)':"
|
||||
f"cr='cr(X,Y)':"
|
||||
f"a='if(lt(X,{r})*lt(Y,{r}),"
|
||||
f"gt(hypot({r}-X,{r}-Y),{r})*0+1,"
|
||||
f"if(gt(X,W-{r})*lt(Y,{r}),"
|
||||
f"gt(hypot(X-(W-{r}),{r}-Y),{r})*0+1,"
|
||||
f"if(lt(X,{r})*gt(Y,H-{r}),"
|
||||
f"gt(hypot({r}-X,Y-(H-{r})),{r})*0+1,"
|
||||
f"if(gt(X,W-{r})*gt(Y,H-{r}),"
|
||||
f"gt(hypot(X-(W-{r}),Y-(H-{r})),{r})*0+1,1))))'"
|
||||
)
|
||||
|
||||
# Step 3: 边框
|
||||
if layer.border_width > 0:
|
||||
bw = layer.border_width
|
||||
color = layer.border_color
|
||||
filters.append(f"pad={pip_width + 2*bw}:{pip_height + 2*bw}:{bw}:{bw}:{color}")
|
||||
|
||||
# Step 4: 透明度
|
||||
if layer.opacity < 1.0:
|
||||
alpha = layer.opacity
|
||||
filters.append(f"format=yuva420p,colorchannelmixer=aa={alpha}")
|
||||
|
||||
# Step 5: 入场出场动画
|
||||
if layer.animation_in or layer.animation_out:
|
||||
filters.extend(self._build_animation_filters(layer, pip_width, pip_height))
|
||||
|
||||
filter_str = f"[{input_label}]{','.join(filters)}[{output_label}]"
|
||||
return filter_str
|
||||
|
||||
def _build_animation_filters(
|
||||
self,
|
||||
layer: PiPLayerConfig,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
) -> list[str]:
|
||||
"""构建入场出场动画滤镜."""
|
||||
filters: list[str] = []
|
||||
anim_dur = layer.animation_duration
|
||||
|
||||
if layer.animation_in == ANIMATION_FADE:
|
||||
# 淡入
|
||||
filters.append(f"fade=t=in:st=0:d={anim_dur}:alpha=1")
|
||||
elif layer.animation_in == ANIMATION_SLIDE_LEFT:
|
||||
# 从左滑入 — 用 overlay 动态x实现,这里先标记位置表达式
|
||||
pass # slide 动画在 overlay 表达式中处理
|
||||
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
|
||||
pass
|
||||
elif layer.animation_in == ANIMATION_SLIDE_TOP:
|
||||
pass
|
||||
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
|
||||
pass
|
||||
|
||||
if layer.animation_out == ANIMATION_FADE:
|
||||
# 淡出需要知道总时长,这里用表达式
|
||||
if layer.duration > 0:
|
||||
start_fade = layer.duration - anim_dur
|
||||
filters.append(f"fade=t=out:st={max(0, start_fade)}:d={anim_dur}:alpha=1")
|
||||
|
||||
return filters
|
||||
|
||||
def _build_overlay_expr(
|
||||
self,
|
||||
layer: PiPLayerConfig,
|
||||
base_x: int,
|
||||
base_y: int,
|
||||
pip_width: int,
|
||||
pip_height: int,
|
||||
) -> tuple[str, str]:
|
||||
"""构建 overlay 滤镜的 x/y 表达式(支持滑动动画).
|
||||
|
||||
Returns:
|
||||
(x_expr, y_expr) — FFmpeg表达式字符串
|
||||
"""
|
||||
W = self.output_width
|
||||
H = self.output_height
|
||||
anim_dur = layer.animation_duration
|
||||
|
||||
x_expr = str(base_x)
|
||||
y_expr = str(base_y)
|
||||
|
||||
# 入场滑入动画
|
||||
if layer.animation_in == ANIMATION_SLIDE_LEFT:
|
||||
# 从左侧滑入:x 从 -pip_width 变化到 base_x
|
||||
x_expr = f"'{base_x}+(X)*0+if(lt(t,{anim_dur}),{-pip_width}+t/{anim_dur}*({base_x}+{pip_width}),{base_x})'"
|
||||
elif layer.animation_in == ANIMATION_SLIDE_RIGHT:
|
||||
# 从右侧滑入:x 从 W 变化到 base_x
|
||||
x_expr = f"'{base_x}+if(lt(t,{anim_dur}),{W}-t/{anim_dur}*({W}-{base_x}),{base_x})'"
|
||||
elif layer.animation_in == ANIMATION_SLIDE_TOP:
|
||||
y_expr = f"'{base_y}+if(lt(t,{anim_dur}),{-pip_height}+t/{anim_dur}*({base_y}+{pip_height}),{base_y})'"
|
||||
elif layer.animation_in == ANIMATION_SLIDE_BOTTOM:
|
||||
y_expr = f"'{base_y}+if(lt(t,{anim_dur}),{H}-t/{anim_dur}*({H}-{base_y}),{base_y})'"
|
||||
|
||||
# 出场滑出动画(需要总时长)
|
||||
if layer.duration > 0 and anim_dur > 0:
|
||||
out_start = layer.duration - anim_dur
|
||||
if layer.animation_out == ANIMATION_SLIDE_LEFT:
|
||||
x_expr = f"'{base_x}+if(gt(t,{out_start}),{base_x}-(t-{out_start})/{anim_dur}*({base_x}+{pip_width}),{base_x})'"
|
||||
elif layer.animation_out == ANIMATION_SLIDE_RIGHT:
|
||||
x_expr = f"'{base_x}+if(gt(t,{out_start}),{base_x}+(t-{out_start})/{anim_dur}*({W}-{base_x}+{pip_width}),{base_x})'"
|
||||
elif layer.animation_out == ANIMATION_SLIDE_TOP:
|
||||
y_expr = f"'{base_y}+if(gt(t,{out_start}),{base_y}-(t-{out_start})/{anim_dur}*({base_y}+{pip_height}),{base_y})'"
|
||||
elif layer.animation_out == ANIMATION_SLIDE_BOTTOM:
|
||||
y_expr = f"'{base_y}+if(gt(t,{out_start}),{base_y}+(t-{out_start})/{anim_dur}*({H}-{base_y}+{pip_height}),{base_y})'"
|
||||
|
||||
return (x_expr, y_expr)
|
||||
|
||||
def build_pip_filters(
|
||||
self,
|
||||
base_label: str,
|
||||
pip_sources: list[tuple[str, PiPLayerConfig, Path]],
|
||||
*,
|
||||
base_input_idx: int = 0,
|
||||
) -> tuple[str, list[str], str]:
|
||||
"""构建完整的画中画滤镜链和输入参数.
|
||||
|
||||
Args:
|
||||
base_label: 底层视频的滤镜标签(如 "final_video" 或 "v0",不带方括号)
|
||||
pip_sources: [(input_label, layer_config, source_path), ...]
|
||||
base_input_idx: PiP 素材在整个 FFmpeg 输入中的起始索引
|
||||
|
||||
Returns:
|
||||
(filter_parts, input_args, final_label)
|
||||
- filter_parts: 滤镜字符串列表(用 ; 连接后成为 filter_complex)
|
||||
- input_args: 额外的输入参数列表 ["-i", path, "-i", path, ...]
|
||||
- final_label: 最终合成后的输出标签(不带方括号)
|
||||
"""
|
||||
if not pip_sources:
|
||||
return [], [], base_label
|
||||
|
||||
filter_parts: list[str] = []
|
||||
input_args: list[str] = []
|
||||
current_label = base_label
|
||||
|
||||
for i, (input_label, layer, path) in enumerate(pip_sources):
|
||||
# 添加输入
|
||||
input_args.extend(["-i", str(path)])
|
||||
|
||||
# 计算实际大小
|
||||
pip_w = self._parse_size(layer.width, self.output_width)
|
||||
if layer.height:
|
||||
pip_h = self._parse_size(layer.height, self.output_height)
|
||||
else:
|
||||
# 按宽度等比例(假设16:9,实际会scale时保持比例)
|
||||
pip_h = int(pip_w * 9 / 16)
|
||||
|
||||
# 实际输入索引 = 起始索引 + 当前偏移
|
||||
actual_input_idx = base_input_idx + i
|
||||
|
||||
# 预处理标签
|
||||
pre_label = f"pip_pre_{i}"
|
||||
|
||||
# 构建预处理滤镜
|
||||
pre_filter = self._build_pip_pre_filter(
|
||||
input_label=f"{actual_input_idx}:v",
|
||||
layer=layer,
|
||||
pip_width=pip_w,
|
||||
pip_height=pip_h,
|
||||
output_label=pre_label,
|
||||
)
|
||||
filter_parts.append(pre_filter)
|
||||
|
||||
# 计算位置
|
||||
base_x, base_y = self._parse_position(layer, pip_w, pip_h)
|
||||
|
||||
# 构建overlay表达式(支持滑动动画)
|
||||
x_expr, y_expr = self._build_overlay_expr(layer, base_x, base_y, pip_w, pip_h)
|
||||
|
||||
# 时间控制(enable表达式)
|
||||
enable_expr = ""
|
||||
if layer.start_time > 0 or layer.duration > 0:
|
||||
start = layer.start_time
|
||||
if layer.duration > 0:
|
||||
end = start + layer.duration
|
||||
enable_expr = f":enable='between(t,{start},{end})'"
|
||||
else:
|
||||
enable_expr = f":enable='gte(t,{start})'"
|
||||
|
||||
# 合成标签
|
||||
combined_label = f"pip_combined_{i}"
|
||||
|
||||
# overlay 滤镜
|
||||
overlay_filter = (
|
||||
f"[{current_label}][{pre_label}]" f"overlay={x_expr}:{y_expr}{enable_expr}" f"[{combined_label}]"
|
||||
)
|
||||
filter_parts.append(overlay_filter)
|
||||
|
||||
current_label = combined_label
|
||||
|
||||
return filter_parts, input_args, current_label
|
||||
|
||||
def validate_layer_source(
|
||||
self,
|
||||
layer: PiPLayerConfig,
|
||||
asset_path_map: dict[str, Path],
|
||||
) -> Path | None:
|
||||
"""验证图层素材是否可用,返回本地路径或None(降级跳过).
|
||||
|
||||
安全:
|
||||
- local_path 类型:必须在允许的目录内,防止路径遍历
|
||||
- url 类型:必须通过 SSRF 安全校验
|
||||
"""
|
||||
from video_processing.path_security import is_in_allowed_dirs
|
||||
from video_processing.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
try:
|
||||
if layer.source_type == "local_path":
|
||||
if not layer.source:
|
||||
return None
|
||||
# 路径安全校验:必须在允许目录内
|
||||
src_path = Path(layer.source)
|
||||
if not src_path.exists():
|
||||
return None
|
||||
if not is_in_allowed_dirs(src_path):
|
||||
logger.warning(
|
||||
"PiP local_path 不在允许目录内,拒绝: %s",
|
||||
layer.source[:80],
|
||||
)
|
||||
return None
|
||||
return src_path.resolve()
|
||||
elif layer.source_type == "asset_id":
|
||||
if layer.source in asset_path_map:
|
||||
return asset_path_map[layer.source]
|
||||
return None
|
||||
elif layer.source_type == "url":
|
||||
# URL类型:先做SSRF安全校验,由调用者负责实际下载
|
||||
try:
|
||||
validate_url_safety(layer.source, purpose="pip_source")
|
||||
logger.info("PiP URL 安全校验通过: %s", layer.source[:80])
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("PiP URL 安全校验失败: %s (error=%s)", layer.source[:80], e)
|
||||
return None
|
||||
# 暂时不支持直接URL下载,返回None表示降级跳过
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("PiP素材验证失败: %s", e)
|
||||
|
||||
return None
|
||||
Executable → Regular
+26
-214
@@ -22,8 +22,6 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_has_audio, run_ffmpeg
|
||||
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
|
||||
from video_processing.speed_engine import SpeedEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from video_processing.unified_render_service import RenderLayer, ResolvedClip
|
||||
@@ -37,8 +35,6 @@ class RenderContext:
|
||||
|
||||
work_dir: Path
|
||||
plan_id: str
|
||||
# 音频降噪配置(全局,对最终混音结果应用)
|
||||
noise_reduction_config: dict | None = None
|
||||
# 音频探测缓存(避免同一 clip 被多次 ffprobe)
|
||||
_audio_cache: dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
@@ -74,10 +70,6 @@ def mix_audio(
|
||||
ctx: RenderContext,
|
||||
layers: list[RenderLayer],
|
||||
video_duration: float,
|
||||
*,
|
||||
bgm_path: str | None = None,
|
||||
bgm_config: dict | None = None,
|
||||
audio_tracks_config: dict | None = None,
|
||||
) -> Path | None:
|
||||
"""音频后处理混音.
|
||||
|
||||
@@ -87,17 +79,11 @@ def mix_audio(
|
||||
3. 独立音频轨(audio role)用 amix 混入
|
||||
4. 输出时长截断到 video_duration
|
||||
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
|
||||
6. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
7. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
8. 如果配置了降噪,最后应用降噪
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
layers: 图层列表
|
||||
video_duration: 视频总时长(用于截断音频)
|
||||
bgm_path: BGM 音频本地路径,为 None 时不混入 BGM
|
||||
bgm_config: BGM 配置字典(volume/fade_in/fade_out/sidechain 等)
|
||||
audio_tracks_config: 多轨道音频配置(tracks/master_volume 等)
|
||||
|
||||
Returns:
|
||||
混音后的音频文件路径,无音频时返回 None
|
||||
@@ -130,15 +116,6 @@ def mix_audio(
|
||||
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
|
||||
|
||||
if not main_clips and not audio_clips:
|
||||
# 没有主音频也没有独立音频 → 检查是否有 BGM
|
||||
if bgm_path and bgm_config and bgm_config.get("enabled", False):
|
||||
from video_processing.bgm_mixer import BGMConfig, build_bgm_only
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
try:
|
||||
return build_bgm_only(ctx, bgm_cfg, video_duration)
|
||||
except Exception:
|
||||
logger.exception("[bgm] 纯BGM生成失败: plan_id=%s", ctx.plan_id)
|
||||
return None
|
||||
|
||||
# 构建音频处理命令
|
||||
@@ -147,83 +124,11 @@ def mix_audio(
|
||||
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
|
||||
if main_clips and not audio_clips:
|
||||
concat_main_audio(ctx, main_clips, output_path, video_duration)
|
||||
else:
|
||||
# 有独立音频轨 → amix 混音
|
||||
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
|
||||
return output_path
|
||||
|
||||
# ── BGM 混音 ──
|
||||
if bgm_path and bgm_config and bgm_config.get("enabled", False):
|
||||
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(bgm_path, bgm_config)
|
||||
bgm_output = ctx.work_dir / f"audio_with_bgm_{ctx.plan_id}.aac"
|
||||
|
||||
try:
|
||||
# 这里 main_audio 就是 output_path,先有主音频再混 BGM
|
||||
final_path = mix_bgm_with_main(ctx, output_path, bgm_cfg, video_duration)
|
||||
output_path = final_path
|
||||
except Exception:
|
||||
logger.exception("[bgm] BGM 混音失败,回退到无 BGM 音频: plan_id=%s", ctx.plan_id)
|
||||
|
||||
# ── 多轨道混音(配音/音效等) ──
|
||||
if audio_tracks_config and audio_tracks_config.get("enabled", False):
|
||||
from video_processing.multi_track_mixer import mix_audio_tracks_from_config
|
||||
|
||||
try:
|
||||
tracks_config = audio_tracks_config.get("tracks_config") or audio_tracks_config
|
||||
multi_output = mix_audio_tracks_from_config(ctx, output_path, tracks_config, video_duration)
|
||||
if multi_output and multi_output != output_path:
|
||||
output_path = multi_output
|
||||
except Exception:
|
||||
logger.exception("[multi-track] 多轨道混音失败,回退: plan_id=%s", ctx.plan_id)
|
||||
|
||||
return _apply_noise_reduction_if_needed(ctx, output_path)
|
||||
|
||||
|
||||
def _apply_noise_reduction_if_needed(ctx: RenderContext, audio_path: Path) -> Path:
|
||||
"""如果配置了音频降噪,对已生成的音频文件应用降噪。
|
||||
|
||||
作为后处理步骤,对最终混音结果统一降噪。
|
||||
失败时返回原始文件路径,不阻断主流程。
|
||||
"""
|
||||
if not ctx.noise_reduction_config:
|
||||
return audio_path
|
||||
|
||||
try:
|
||||
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
|
||||
|
||||
config = NoiseReductionConfig.from_dict(ctx.noise_reduction_config)
|
||||
if not config.has_effect():
|
||||
return audio_path
|
||||
|
||||
engine = NoiseReductionEngine(config)
|
||||
filter_str = engine.build_filter("[0:a]", "[out]")
|
||||
# 提取滤镜部分(不带标签)
|
||||
filter_part = filter_str[len("[0:a]") : -len("[out]")]
|
||||
|
||||
nr_output_path = audio_path.with_name(f"{audio_path.stem}_nr.aac")
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(audio_path),
|
||||
"-af",
|
||||
filter_part,
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(nr_output_path),
|
||||
]
|
||||
run_ffmpeg(command)
|
||||
|
||||
if nr_output_path.exists():
|
||||
return nr_output_path
|
||||
logger.warning("[noise-reduction] 降噪输出文件不存在,使用原始音频")
|
||||
return audio_path
|
||||
except Exception as e:
|
||||
logger.warning("[noise-reduction] 音频降噪失败,使用原始音频: %s", e)
|
||||
return audio_path
|
||||
# 有独立音频轨 → amix 混音
|
||||
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
|
||||
return output_path
|
||||
|
||||
|
||||
def concat_main_audio(
|
||||
@@ -240,130 +145,40 @@ def concat_main_audio(
|
||||
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
|
||||
clip = clips[0]
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
speed = 1.0
|
||||
|
||||
# 调速后时长
|
||||
adjusted_duration = effective_duration / speed if abs(speed - 1.0) >= 1e-6 else effective_duration
|
||||
# 最终时长:取调速后时长和视频总时长的较小值
|
||||
final_duration = adjusted_duration
|
||||
# 最终时长:取 clip 有效时长和视频总时长的较小值
|
||||
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
|
||||
final_duration = effective_duration
|
||||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||||
final_duration = video_duration
|
||||
|
||||
# 音频倒放
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
has_reverse = reverse_config.enabled and reverse_config.reverse_audio
|
||||
has_speed = abs(speed - 1.0) >= 1e-6
|
||||
|
||||
if not has_speed and not has_reverse:
|
||||
# 无调速无倒放:简单命令行,-ss 裁剪更高效
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
]
|
||||
if trim_start > 0:
|
||||
command.extend(["-ss", f"{trim_start:.3f}"])
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
run_ffmpeg(command)
|
||||
else:
|
||||
# 有调速或倒放:用 filter_complex
|
||||
speed_engine = SpeedEngine()
|
||||
audio_filters = []
|
||||
if effective_duration > 0:
|
||||
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
audio_filters.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
# 音频调速
|
||||
if has_speed:
|
||||
from video_processing.speed_engine import SpeedConfig
|
||||
|
||||
config = SpeedConfig(speed=float(speed))
|
||||
config.clamp()
|
||||
atempo_filter = speed_engine.build_audio_filter(config)
|
||||
if atempo_filter:
|
||||
audio_filters.append(atempo_filter)
|
||||
|
||||
# 音频倒放
|
||||
if has_reverse:
|
||||
reverse_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
|
||||
if reverse_filter:
|
||||
audio_filters.append(reverse_filter)
|
||||
|
||||
filter_parts: list[str] = [f"[0:a]{','.join(audio_filters)}[outa]"]
|
||||
if video_duration > 0 and final_duration < adjusted_duration:
|
||||
filter_parts.append(f"[outa]atrim=0:{final_duration:.3f}[final_audio]")
|
||||
final_label = "final_audio"
|
||||
else:
|
||||
final_label = "outa"
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-filter_complex",
|
||||
filter_complex,
|
||||
"-map",
|
||||
f"[{final_label}]",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(output_path),
|
||||
]
|
||||
run_ffmpeg(command)
|
||||
command = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(clip.local_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
]
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
command.append(str(output_path))
|
||||
run_ffmpeg(command)
|
||||
return
|
||||
|
||||
# 多 clip,用 filter_complex concat
|
||||
input_args: list[str] = []
|
||||
filter_parts: list[str] = []
|
||||
speed_engine = SpeedEngine()
|
||||
|
||||
for i, clip in enumerate(clips):
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
speed = getattr(clip, "playback_speed", 1.0) or 1.0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
speed = 1.0
|
||||
|
||||
audio_filters: list[str] = []
|
||||
if effective_duration > 0:
|
||||
audio_filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
audio_filters.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
# 音频调速 — atempo 多级串联
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
from video_processing.speed_engine import SpeedConfig
|
||||
|
||||
config = SpeedConfig(speed=float(speed))
|
||||
config.clamp()
|
||||
atempo_filter = speed_engine.build_audio_filter(config)
|
||||
if atempo_filter:
|
||||
audio_filters.append(atempo_filter)
|
||||
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
|
||||
else:
|
||||
audio_filters.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
# 音频倒放
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and reverse_config.reverse_audio:
|
||||
reverse_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
|
||||
if reverse_filter:
|
||||
audio_filters.append(reverse_filter)
|
||||
|
||||
filter_parts.append(f"[{i}:a]{','.join(audio_filters)}[a{i}]")
|
||||
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[a{i}]")
|
||||
|
||||
audio_labels = "".join(f"[a{i}]" for i in range(len(clips)))
|
||||
filter_parts.append(f"{audio_labels}concat=n={len(clips)}:v=0:a=1[outa]")
|
||||
@@ -421,11 +236,9 @@ def mix_with_independent_audio(
|
||||
for clip in main_clips:
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
if effective_duration > 0:
|
||||
filter_parts.append(
|
||||
f"[{input_idx}:a]atrim=start={trim_start:.3f}:duration={effective_duration:.3f},"
|
||||
f"asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
f"[{input_idx}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
|
||||
@@ -442,12 +255,11 @@ def mix_with_independent_audio(
|
||||
for j, clip in enumerate(audio_clips):
|
||||
input_args.extend(["-i", str(clip.local_path)])
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
volume = clip.config.get("volume", 1.0) if clip.config else 1.0
|
||||
label = f"ia{j}"
|
||||
filters = []
|
||||
if effective_duration > 0:
|
||||
filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
filters.append(f"atrim=0:{effective_duration:.3f}")
|
||||
filters.append("asetpts=PTS-STARTPTS")
|
||||
if volume != 1.0:
|
||||
filters.append(f"volume={volume}")
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""视频倒放引擎 — 基于 FFmpeg reverse + areverse 滤镜实现视频/音频倒放.
|
||||
|
||||
支持能力:
|
||||
- 视频倒放(reverse 滤镜)
|
||||
- 音频倒放(areverse 滤镜)
|
||||
- 按 clip 分段倒放,每个 clip 独立配置
|
||||
- 降级策略:不支持时跳过,不阻断渲染
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReverseConfig:
|
||||
"""视频倒放配置.
|
||||
|
||||
从 clip.config.reverse 读取,零侵入数据模型.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
reverse_video: bool = True # 是否倒放视频
|
||||
reverse_audio: bool = True # 是否倒放音频
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "ReverseConfig":
|
||||
"""从字典解析配置."""
|
||||
if not data:
|
||||
return cls(enabled=False)
|
||||
try:
|
||||
if not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
return cls(
|
||||
enabled=True,
|
||||
reverse_video=bool(data.get("reverse_video", True)),
|
||||
reverse_audio=bool(data.get("reverse_audio", True)),
|
||||
)
|
||||
except (AttributeError, TypeError) as e:
|
||||
logger.warning("倒放配置解析失败: %s,使用默认配置", e)
|
||||
return cls(enabled=False)
|
||||
|
||||
|
||||
# ── 倒放引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ReverseEngine:
|
||||
"""视频倒放引擎 — 生成 FFmpeg 倒放滤镜.
|
||||
|
||||
视频倒放:reverse 滤镜
|
||||
音频倒放:areverse 滤镜
|
||||
|
||||
注意事项:
|
||||
- reverse 滤镜需要将整个视频帧加载到内存,长视频可能占用大量内存
|
||||
- 建议对单 clip 时长做限制(如 < 60s),超长视频建议降级
|
||||
"""
|
||||
|
||||
# 安全限制:单 clip 超过此时长不启用倒放(防止内存溢出)
|
||||
MAX_SAFE_DURATION = 120.0 # 秒
|
||||
|
||||
@staticmethod
|
||||
def build_video_filter(config: ReverseConfig, duration: float = 0.0) -> str:
|
||||
"""构建视频倒放滤镜字符串.
|
||||
|
||||
Args:
|
||||
config: 倒放配置
|
||||
duration: clip 时长(秒),用于安全检查
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串,如 "reverse";无效果返回空字符串
|
||||
"""
|
||||
if not config.enabled or not config.reverse_video:
|
||||
return ""
|
||||
|
||||
# 安全检查:超长视频不启用倒放
|
||||
if duration > ReverseEngine.MAX_SAFE_DURATION:
|
||||
logger.warning(
|
||||
"视频倒放安全限制:clip 时长 %.1fs 超过上限 %.1fs,跳过倒放",
|
||||
duration,
|
||||
ReverseEngine.MAX_SAFE_DURATION,
|
||||
)
|
||||
return ""
|
||||
|
||||
return "reverse"
|
||||
|
||||
@staticmethod
|
||||
def build_audio_filter(config: ReverseConfig, duration: float = 0.0) -> str:
|
||||
"""构建音频倒放滤镜字符串.
|
||||
|
||||
Args:
|
||||
config: 倒放配置
|
||||
duration: clip 时长(秒),用于安全检查
|
||||
|
||||
Returns:
|
||||
FFmpeg 音频滤镜字符串,如 "areverse";无效果返回空字符串
|
||||
"""
|
||||
if not config.enabled or not config.reverse_audio:
|
||||
return ""
|
||||
|
||||
# 安全检查:超长音频不启用倒放
|
||||
if duration > ReverseEngine.MAX_SAFE_DURATION:
|
||||
logger.warning(
|
||||
"音频倒放安全限制:clip 时长 %.1fs 超过上限 %.1fs,跳过倒放",
|
||||
duration,
|
||||
ReverseEngine.MAX_SAFE_DURATION,
|
||||
)
|
||||
return ""
|
||||
|
||||
return "areverse"
|
||||
@@ -1,167 +0,0 @@
|
||||
"""视频调速引擎 — 基于 FFmpeg setpts + atempo 的速度调整能力。
|
||||
|
||||
支持:
|
||||
- 0.25x ~ 4x 变速范围
|
||||
- 视频调速(setpts)
|
||||
- 音频调速(atempo,多级串联处理超范围值)
|
||||
- 音调修正(pitch_correct,默认开启)
|
||||
- 边界自动钳制,不阻断渲染
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# ─── 常量 ───────────────────────────────────────────────
|
||||
MIN_SPEED = 0.25
|
||||
MAX_SPEED = 4.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
|
||||
# atempo 单级有效范围
|
||||
_ATEMPO_MIN = 0.5
|
||||
_ATEMPO_MAX = 2.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeedConfig:
|
||||
"""调速配置。
|
||||
|
||||
Attributes:
|
||||
speed: 播放速度,0.25~4.0,1.0 为原速
|
||||
pitch_correct: 是否保持音调(默认 True,用 atempo 时间拉伸算法)
|
||||
"""
|
||||
|
||||
speed: float = DEFAULT_SPEED
|
||||
pitch_correct: bool = True
|
||||
|
||||
@classmethod
|
||||
def parse(cls, data: Optional[dict]) -> "SpeedConfig":
|
||||
"""从 dict 解析配置,无效值回退到默认。"""
|
||||
if not data or not isinstance(data, dict):
|
||||
return cls()
|
||||
|
||||
speed = data.get("speed", DEFAULT_SPEED)
|
||||
if not isinstance(speed, (int, float)):
|
||||
speed = DEFAULT_SPEED
|
||||
|
||||
pitch_correct = data.get("pitch_correct", True)
|
||||
if not isinstance(pitch_correct, bool):
|
||||
pitch_correct = True
|
||||
|
||||
config = cls(speed=float(speed), pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return config
|
||||
|
||||
def clamp(self) -> None:
|
||||
"""将速度钳制到合法范围。"""
|
||||
if self.speed <= 0:
|
||||
self.speed = DEFAULT_SPEED
|
||||
elif self.speed < MIN_SPEED:
|
||||
self.speed = MIN_SPEED
|
||||
elif self.speed > MAX_SPEED:
|
||||
self.speed = MAX_SPEED
|
||||
|
||||
@property
|
||||
def is_original(self) -> bool:
|
||||
"""是否原速(无需调速)。"""
|
||||
return abs(self.speed - 1.0) < 1e-6
|
||||
|
||||
|
||||
class SpeedEngine:
|
||||
"""调速引擎 — 生成 FFmpeg 调速滤镜链。
|
||||
|
||||
用法:
|
||||
engine = SpeedEngine()
|
||||
video_filter = engine.build_video_filter(config)
|
||||
audio_filter = engine.build_audio_filter(config)
|
||||
new_duration = engine.adjust_duration(duration, config)
|
||||
"""
|
||||
|
||||
def build_video_filter(self, config: SpeedConfig) -> str:
|
||||
"""生成视频调速滤镜字符串。
|
||||
|
||||
返回 setpts 滤镜表达式,原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
# setpts=PTS/speed — speed>1 加速,speed<1 减速
|
||||
return f"setpts=PTS/{config.speed:.4f}"
|
||||
|
||||
def build_audio_filter(self, config: SpeedConfig) -> str:
|
||||
"""生成音频调速滤镜字符串。
|
||||
|
||||
atempo 单级范围 0.5~2.0,超出范围时自动多级串联:
|
||||
- 0.25x → atempo=0.5,atempo=0.5
|
||||
- 4x → atempo=2.0,atempo=2.0
|
||||
- 0.3x → atempo=0.5,atempo=0.6
|
||||
- 3x → atempo=2.0,atempo=1.5
|
||||
|
||||
原速时返回空字符串。
|
||||
"""
|
||||
if config.is_original:
|
||||
return ""
|
||||
|
||||
speed = config.speed
|
||||
stages: list[float] = self._split_atempo_stages(speed)
|
||||
return ",".join(f"atempo={s:.4f}" for s in stages)
|
||||
|
||||
@staticmethod
|
||||
def _split_atempo_stages(speed: float) -> list[float]:
|
||||
"""将速度拆分为多级 atempo 串联,每级都在 [0.5, 2.0] 范围内。"""
|
||||
if _ATEMPO_MIN <= speed <= _ATEMPO_MAX:
|
||||
return [speed]
|
||||
|
||||
stages: list[float] = []
|
||||
remaining = speed
|
||||
|
||||
# 加速场景(speed > 2.0)
|
||||
if speed > _ATEMPO_MAX:
|
||||
while remaining > _ATEMPO_MAX:
|
||||
stages.append(_ATEMPO_MAX)
|
||||
remaining /= _ATEMPO_MAX
|
||||
stages.append(remaining)
|
||||
|
||||
# 减速场景(speed < 0.5)
|
||||
else:
|
||||
while remaining < _ATEMPO_MIN:
|
||||
stages.append(_ATEMPO_MIN)
|
||||
remaining /= _ATEMPO_MIN
|
||||
stages.append(remaining)
|
||||
|
||||
return stages
|
||||
|
||||
def adjust_duration(self, original_duration: float, config: SpeedConfig) -> float:
|
||||
"""计算调速后的时长。
|
||||
|
||||
加速 → 时长变短;减速 → 时长变长。
|
||||
"""
|
||||
if config.is_original or original_duration <= 0:
|
||||
return original_duration
|
||||
return original_duration / config.speed
|
||||
|
||||
def build_clip_speed_filter(
|
||||
self,
|
||||
speed: float,
|
||||
pitch_correct: bool = True,
|
||||
) -> tuple[str, str, SpeedConfig]:
|
||||
"""便捷方法:从单一 speed 值生成视频+音频滤镜。
|
||||
|
||||
返回 (video_filter, audio_filter, config)。
|
||||
"""
|
||||
config = SpeedConfig(speed=speed, pitch_correct=pitch_correct)
|
||||
config.clamp()
|
||||
return (
|
||||
self.build_video_filter(config),
|
||||
self.build_audio_filter(config),
|
||||
config,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_clip_speed(
|
||||
clip_config: dict,
|
||||
global_speed: float = DEFAULT_SPEED,
|
||||
) -> float:
|
||||
"""从 clip config 中解析 playback_speed,0 或缺失则使用全局速度。"""
|
||||
speed = clip_config.get("playback_speed", 0) if clip_config else 0
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return global_speed
|
||||
return float(speed)
|
||||
@@ -1,612 +0,0 @@
|
||||
"""贴纸叠加引擎 — 基于 FFmpeg overlay + drawtext 实现图片/文字贴纸.
|
||||
|
||||
支持能力:
|
||||
- 图片贴纸(PNG/GIF):位置、大小、透明度、时间范围、淡入淡出
|
||||
- 文字贴纸(花字):字体、颜色、描边、阴影、位置、时间范围、动画
|
||||
- 9宫格位置 + 自由坐标(像素或百分比)
|
||||
- 多贴纸叠加,按 z_index 排序
|
||||
- 降级策略:素材不存在/无效时自动跳过,不阻断渲染
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 预设贴纸分类 ──────────────────────────────────────────────────────────────
|
||||
|
||||
# 预设贴纸分类(仅用于前端展示,后端不依赖具体素材)
|
||||
STICKER_CATEGORIES = [
|
||||
("emoji", "表情包"),
|
||||
("text", "文字花字"),
|
||||
("decoration", "装饰"),
|
||||
("arrow", "箭头指示"),
|
||||
("frame", "边框"),
|
||||
]
|
||||
|
||||
# 9宫格位置映射
|
||||
POSITION_PRESETS = {
|
||||
"top_left": (0.05, 0.05),
|
||||
"top_center": (0.5, 0.05),
|
||||
"top_right": (0.95, 0.05),
|
||||
"center_left": (0.05, 0.5),
|
||||
"center": (0.5, 0.5),
|
||||
"center_right": (0.95, 0.5),
|
||||
"bottom_left": (0.05, 0.95),
|
||||
"bottom_center": (0.5, 0.95),
|
||||
"bottom_right": (0.95, 0.95),
|
||||
}
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageStickerConfig:
|
||||
"""图片贴纸配置."""
|
||||
|
||||
enabled: bool = False
|
||||
type: str = "image" # image / text
|
||||
# 位置
|
||||
position: str = "top_right" # 9宫格预设
|
||||
x: float | None = None # 自定义x(像素或百分比)
|
||||
y: float | None = None # 自定义y
|
||||
x_unit: str = "percent" # pixel / percent
|
||||
y_unit: str = "percent"
|
||||
# 大小
|
||||
scale: float = 1.0 # 缩放比例(相对于原始大小)
|
||||
width: int | None = None # 指定宽度(像素)
|
||||
height: int | None = None # 指定高度(像素)
|
||||
# 透明度
|
||||
opacity: float = 1.0 # 0.0~1.0
|
||||
# 时间范围
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0 # 0 表示持续到结束
|
||||
# 动画
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长
|
||||
# 层级
|
||||
z_index: int = 10
|
||||
# 素材
|
||||
image_url: str = "" # 图片URL或本地路径
|
||||
preset_id: str = "" # 预设贴纸ID
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextStickerConfig:
|
||||
"""文字贴纸配置."""
|
||||
|
||||
enabled: bool = False
|
||||
type: str = "text"
|
||||
text: str = ""
|
||||
# 字体
|
||||
font_size: int = 36
|
||||
font_color: str = "#FFFFFF"
|
||||
font_family: str = "sans"
|
||||
# 描边
|
||||
stroke_color: str = "#000000"
|
||||
stroke_width: int = 2
|
||||
# 阴影
|
||||
shadow_color: str = "#000000"
|
||||
shadow_x: int = 2
|
||||
shadow_y: int = 2
|
||||
shadow_alpha: float = 0.5
|
||||
# 位置
|
||||
position: str = "center"
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
x_unit: str = "percent"
|
||||
y_unit: str = "percent"
|
||||
# 时间范围
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
# 动画
|
||||
fade_in: float = 0.0
|
||||
fade_out: float = 0.0
|
||||
# 层级
|
||||
z_index: int = 10
|
||||
# 背景框
|
||||
bg_color: str = "" # 空表示无背景
|
||||
bg_padding: int = 8
|
||||
bg_alpha: float = 0.8
|
||||
bg_corner_radius: int = 8
|
||||
|
||||
|
||||
@dataclass
|
||||
class StickerOverlayResult:
|
||||
"""贴纸叠加结果."""
|
||||
|
||||
filter_str: str # 滤镜字符串
|
||||
output_label: str # 输出标签
|
||||
extra_inputs: list[str] = field(default_factory=list) # 额外的输入文件路径
|
||||
|
||||
|
||||
# ── 贴纸引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class StickerEngine:
|
||||
"""贴纸叠加引擎 — 生成 FFmpeg overlay / drawtext 滤镜链.
|
||||
|
||||
支持图片贴纸(overlay)和文字贴纸(drawtext)。
|
||||
多贴纸按 z_index 排序依次叠加。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_position(
|
||||
config: ImageStickerConfig | TextStickerConfig,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
sticker_w: int = 0,
|
||||
sticker_h: int = 0,
|
||||
) -> tuple[float, float]:
|
||||
"""解析贴纸位置(像素坐标).
|
||||
|
||||
优先级:自定义坐标 > 9宫格预设
|
||||
"""
|
||||
# 先取预设的基准位置
|
||||
if config.position in POSITION_PRESETS:
|
||||
px, py = POSITION_PRESETS[config.position]
|
||||
else:
|
||||
px, py = 0.5, 0.5 # 默认居中
|
||||
|
||||
# 自定义坐标覆盖
|
||||
if config.x is not None:
|
||||
if config.x_unit == "percent":
|
||||
px = config.x / 100.0
|
||||
else:
|
||||
px = config.x / canvas_w if canvas_w > 0 else 0.5
|
||||
|
||||
if config.y is not None:
|
||||
if config.y_unit == "percent":
|
||||
py = config.y / 100.0
|
||||
else:
|
||||
py = config.y / canvas_h if canvas_h > 0 else 0.5
|
||||
|
||||
# 转换为像素坐标(考虑贴纸尺寸,使位置为贴纸中心点)
|
||||
x = px * canvas_w - sticker_w / 2
|
||||
y = py * canvas_h - sticker_h / 2
|
||||
|
||||
# 钳制在画布内
|
||||
x = max(0, min(x, canvas_w - sticker_w))
|
||||
y = max(0, min(y, canvas_h - sticker_h))
|
||||
|
||||
return x, y
|
||||
|
||||
@staticmethod
|
||||
def _build_overlay_filter(
|
||||
sticker: ImageStickerConfig,
|
||||
sticker_idx: int,
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
) -> str:
|
||||
"""构建单个图片贴纸的 overlay 滤镜.
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置
|
||||
sticker_idx: 贴纸索引(用于生成滤镜标签)
|
||||
input_label: 输入视频标签(如 "[base]")
|
||||
output_label: 输出视频标签
|
||||
canvas_w: 画布宽度
|
||||
canvas_h: 画布高度
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串
|
||||
"""
|
||||
sticker_label = f"sticker_{sticker_idx}_scaled"
|
||||
|
||||
# 1. 贴纸缩放预处理
|
||||
scale_parts = []
|
||||
if sticker.width and sticker.height:
|
||||
scale_parts.append(f"scale={sticker.width}:{sticker.height}")
|
||||
elif sticker.scale != 1.0:
|
||||
# 按比例缩放
|
||||
scale_parts.append(f"scale=iw*{sticker.scale}:ih*{sticker.scale}")
|
||||
# 透明度调整
|
||||
if sticker.opacity < 1.0:
|
||||
scale_parts.append(f"colorchannelmixer=aa={sticker.opacity}")
|
||||
|
||||
# 淡入淡出
|
||||
fade_parts = []
|
||||
if sticker.fade_in > 0:
|
||||
fade_parts.append(f"fade=in:st={sticker.start_time}:d={sticker.fade_in}:alpha=1")
|
||||
if sticker.fade_out > 0 and sticker.duration > 0:
|
||||
fade_out_start = sticker.start_time + sticker.duration - sticker.fade_out
|
||||
fade_parts.append(f"fade=out:st={max(0, fade_out_start)}:d={sticker.fade_out}:alpha=1")
|
||||
|
||||
pre_filters = scale_parts + fade_parts
|
||||
|
||||
# 2. overlay 位置
|
||||
# 先估算贴纸尺寸(假设原始尺寸 ~ canvas_w * 0.3)
|
||||
est_w = int(canvas_w * 0.3 * sticker.scale) if not sticker.width else sticker.width
|
||||
est_h = int(canvas_h * 0.3 * sticker.scale) if not sticker.height else sticker.height
|
||||
pos_x, pos_y = StickerEngine._resolve_position(sticker, canvas_w, canvas_h, est_w, est_h)
|
||||
|
||||
# 3. enable 表达式(时间范围)
|
||||
enable_expr = ""
|
||||
if sticker.duration > 0:
|
||||
enable_expr = f":enable='between(t,{sticker.start_time},{sticker.start_time + sticker.duration})'"
|
||||
|
||||
# 组合滤镜
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# 贴纸预处理
|
||||
if pre_filters:
|
||||
filter_parts.append(f"[{sticker_idx + 1}:v]{','.join(pre_filters)}[{sticker_label}]")
|
||||
sticker_source = f"[{sticker_label}]"
|
||||
else:
|
||||
sticker_source = f"[{sticker_idx + 1}:v]"
|
||||
|
||||
# overlay 合成
|
||||
filter_parts.append(f"{input_label}{sticker_source}overlay={pos_x:.0f}:{pos_y:.0f}{enable_expr}{output_label}")
|
||||
|
||||
return ";".join(filter_parts)
|
||||
|
||||
@staticmethod
|
||||
def _build_drawtext_filter(
|
||||
sticker: TextStickerConfig,
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
) -> str:
|
||||
"""构建单个文字贴纸的 drawtext 滤镜.
|
||||
|
||||
Args:
|
||||
sticker: 文字贴纸配置
|
||||
input_label: 输入视频标签
|
||||
output_label: 输出视频标签
|
||||
canvas_w: 画布宽度
|
||||
canvas_h: 画布高度
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串
|
||||
"""
|
||||
if not sticker.text:
|
||||
return f"{input_label}copy{output_label}"
|
||||
|
||||
# 估算文字尺寸(粗略)
|
||||
est_w = len(sticker.text) * sticker.font_size * 0.6
|
||||
est_h = sticker.font_size * 1.4
|
||||
|
||||
pos_x, pos_y = StickerEngine._resolve_position(sticker, canvas_w, canvas_h, int(est_w), int(est_h))
|
||||
|
||||
drawtext_params: list[str] = []
|
||||
|
||||
# 文字内容(转义特殊字符)
|
||||
escaped_text = sticker.text.replace(":", "\\:").replace("'", "\\'")
|
||||
drawtext_params.append(f"text='{escaped_text}'")
|
||||
|
||||
# 字体
|
||||
drawtext_params.append(f"fontsize={sticker.font_size}")
|
||||
drawtext_params.append(f"fontcolor={sticker.font_color}")
|
||||
|
||||
# 描边
|
||||
if sticker.stroke_width > 0:
|
||||
drawtext_params.append(f"borderw={sticker.stroke_width}")
|
||||
drawtext_params.append(f"bordercolor={sticker.stroke_color}")
|
||||
|
||||
# 阴影
|
||||
if sticker.shadow_alpha > 0:
|
||||
drawtext_params.append(f"shadowx={sticker.shadow_x}")
|
||||
drawtext_params.append(f"shadowy={sticker.shadow_y}")
|
||||
drawtext_params.append(f"shadowcolor={sticker.shadow_color}@{sticker.shadow_alpha}")
|
||||
|
||||
# 位置
|
||||
drawtext_params.append(f"x={pos_x:.0f}")
|
||||
drawtext_params.append(f"y={pos_y:.0f}")
|
||||
|
||||
# 时间范围
|
||||
if sticker.duration > 0:
|
||||
drawtext_params.append(f"enable='between(t,{sticker.start_time},{sticker.start_time + sticker.duration})'")
|
||||
|
||||
# 淡入淡出(drawtext 没有直接的淡入淡出,用 alpha 表达式模拟)
|
||||
if sticker.fade_in > 0 or sticker.fade_out > 0:
|
||||
alpha_expr = "1"
|
||||
parts: list[str] = []
|
||||
if sticker.fade_in > 0:
|
||||
parts.append(
|
||||
f"if(lt(t,{sticker.start_time + sticker.fade_in})," f"(t-{sticker.start_time})/{sticker.fade_in},1)"
|
||||
)
|
||||
if sticker.fade_out > 0 and sticker.duration > 0:
|
||||
fade_out_start = sticker.start_time + sticker.duration - sticker.fade_out
|
||||
parts.append(
|
||||
f"if(gt(t,{fade_out_start})," f"({sticker.start_time + sticker.duration}-t)/{sticker.fade_out},1)"
|
||||
)
|
||||
if parts:
|
||||
alpha_expr = "*".join(parts)
|
||||
drawtext_params.append(f"alpha='{alpha_expr}'")
|
||||
|
||||
filter_str = f"{input_label}drawtext={':'.join(drawtext_params)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
@classmethod
|
||||
def build_sticker_chain(
|
||||
cls,
|
||||
stickers: list[dict[str, Any]],
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
) -> StickerOverlayResult:
|
||||
"""构建多贴纸叠加滤镜链.
|
||||
|
||||
Args:
|
||||
stickers: 贴纸配置列表
|
||||
input_label: 初始输入标签
|
||||
output_label: 最终输出标签
|
||||
canvas_w: 画布宽度
|
||||
canvas_h: 画布高度
|
||||
|
||||
Returns:
|
||||
StickerOverlayResult,包含滤镜字符串、输出标签、额外输入
|
||||
"""
|
||||
if not stickers:
|
||||
return StickerOverlayResult(
|
||||
filter_str=f"{input_label}copy{output_label}",
|
||||
output_label=output_label,
|
||||
extra_inputs=[],
|
||||
)
|
||||
|
||||
# 解析配置
|
||||
parsed_stickers: list[tuple[int, ImageStickerConfig | TextStickerConfig]] = []
|
||||
image_stickers: list[ImageStickerConfig] = []
|
||||
image_paths: list[str] = []
|
||||
|
||||
for i, s in enumerate(stickers):
|
||||
try:
|
||||
sticker_type = s.get("type", "image")
|
||||
z = int(s.get("z_index", 10))
|
||||
|
||||
if sticker_type == "text":
|
||||
config = TextStickerConfig(
|
||||
enabled=True,
|
||||
text=str(s.get("text", "")),
|
||||
font_size=int(s.get("font_size", 36)),
|
||||
font_color=str(s.get("font_color", "#FFFFFF")),
|
||||
stroke_color=str(s.get("stroke_color", "#000000")),
|
||||
stroke_width=int(s.get("stroke_width", 2)),
|
||||
shadow_x=int(s.get("shadow_x", 2)),
|
||||
shadow_y=int(s.get("shadow_y", 2)),
|
||||
shadow_alpha=float(s.get("shadow_alpha", 0.5)),
|
||||
position=str(s.get("position", "center")),
|
||||
x=cls._safe_float(s.get("x")),
|
||||
y=cls._safe_float(s.get("y")),
|
||||
x_unit=str(s.get("x_unit", "percent")),
|
||||
y_unit=str(s.get("y_unit", "percent")),
|
||||
start_time=float(s.get("start_time", 0)),
|
||||
duration=float(s.get("duration", 0)),
|
||||
fade_in=float(s.get("fade_in", 0)),
|
||||
fade_out=float(s.get("fade_out", 0)),
|
||||
z_index=z,
|
||||
bg_color=str(s.get("bg_color", "")),
|
||||
bg_padding=int(s.get("bg_padding", 8)),
|
||||
bg_alpha=float(s.get("bg_alpha", 0.8)),
|
||||
bg_corner_radius=int(s.get("bg_corner_radius", 8)),
|
||||
)
|
||||
parsed_stickers.append((z, config))
|
||||
else:
|
||||
# 图片贴纸 — 安全校验:区分本地路径和URL
|
||||
image_path = s.get("image_path", "")
|
||||
image_url = s.get("image_url", "")
|
||||
|
||||
safe_image_path: Path | None = None
|
||||
|
||||
if image_path:
|
||||
# 本地路径:路径遍历防护
|
||||
from video_processing.path_security import is_in_allowed_dirs
|
||||
|
||||
try:
|
||||
p = Path(image_path)
|
||||
if not p.exists():
|
||||
logger.warning("贴纸素材不存在,跳过: %s", image_path[:80])
|
||||
continue
|
||||
if not is_in_allowed_dirs(p):
|
||||
logger.warning("贴纸路径不在允许目录内,拒绝: %s", image_path[:80])
|
||||
continue
|
||||
safe_image_path = p.resolve()
|
||||
except Exception as e:
|
||||
logger.warning("贴纸路径校验失败,跳过: %s error=%s", image_path[:80], e)
|
||||
continue
|
||||
elif image_url:
|
||||
# URL:SSRF 安全校验(暂不自动下载,仅校验安全性)
|
||||
from video_processing.url_security import (
|
||||
UrlSecurityError,
|
||||
validate_url_safety,
|
||||
)
|
||||
|
||||
try:
|
||||
validate_url_safety(image_url, purpose="sticker_image")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("贴纸URL安全校验失败,跳过: %s error=%s", image_url[:80], e)
|
||||
continue
|
||||
# URL 类型暂不支持自动下载,跳过
|
||||
logger.info("贴纸URL类型暂不支持自动下载,跳过: %s", image_url[:80])
|
||||
continue
|
||||
else:
|
||||
logger.warning("贴纸缺少 image_path 和 image_url,跳过")
|
||||
continue
|
||||
|
||||
if safe_image_path is None:
|
||||
continue
|
||||
|
||||
config = ImageStickerConfig(
|
||||
enabled=True,
|
||||
position=str(s.get("position", "top_right")),
|
||||
x=cls._safe_float(s.get("x")),
|
||||
y=cls._safe_float(s.get("y")),
|
||||
x_unit=str(s.get("x_unit", "percent")),
|
||||
y_unit=str(s.get("y_unit", "percent")),
|
||||
scale=float(s.get("scale", 1.0)),
|
||||
width=int(s["width"]) if s.get("width") else None,
|
||||
height=int(s["height"]) if s.get("height") else None,
|
||||
opacity=max(0.0, min(1.0, float(s.get("opacity", 1.0)))),
|
||||
start_time=float(s.get("start_time", 0)),
|
||||
duration=float(s.get("duration", 0)),
|
||||
fade_in=float(s.get("fade_in", 0)),
|
||||
fade_out=float(s.get("fade_out", 0)),
|
||||
z_index=z,
|
||||
image_url=image_url,
|
||||
)
|
||||
parsed_stickers.append((z, config))
|
||||
image_stickers.append(config)
|
||||
image_paths.append(str(safe_image_path))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("贴纸配置解析失败,跳过: %s", e)
|
||||
continue
|
||||
|
||||
if not parsed_stickers:
|
||||
return StickerOverlayResult(
|
||||
filter_str=f"{input_label}copy{output_label}",
|
||||
output_label=output_label,
|
||||
extra_inputs=[],
|
||||
)
|
||||
|
||||
# 按 z_index 排序
|
||||
parsed_stickers.sort(key=lambda x: x[0])
|
||||
|
||||
# 构建滤镜链
|
||||
filter_parts: list[str] = []
|
||||
current_label = input_label
|
||||
img_idx = 0 # 图片贴纸的输入索引偏移
|
||||
|
||||
for idx, (_, sticker) in enumerate(parsed_stickers):
|
||||
next_label = f"sticker_{idx}_out" if idx < len(parsed_stickers) - 1 else output_label
|
||||
|
||||
if isinstance(sticker, ImageStickerConfig):
|
||||
# 图片贴纸:使用额外的输入(输入索引 = 1 + img_idx,0 是主视频)
|
||||
# 注意:实际输入索引需要调用方根据输入列表确定
|
||||
# 这里我们按 image_stickers 的顺序分配索引
|
||||
# 主输入是 [0:v],贴纸输入从 [1:v] 开始
|
||||
single_filter = cls._build_single_image_sticker(
|
||||
sticker=sticker,
|
||||
sticker_input_idx=img_idx + 1, # +1 因为 0 是主视频
|
||||
input_label=current_label,
|
||||
output_label=next_label,
|
||||
canvas_w=canvas_w,
|
||||
canvas_h=canvas_h,
|
||||
)
|
||||
filter_parts.append(single_filter)
|
||||
img_idx += 1
|
||||
else:
|
||||
# 文字贴纸:drawtext,不需要额外输入
|
||||
single_filter = cls._build_drawtext_filter(
|
||||
sticker, # type: ignore
|
||||
current_label,
|
||||
next_label,
|
||||
canvas_w,
|
||||
canvas_h,
|
||||
)
|
||||
filter_parts.append(single_filter)
|
||||
|
||||
current_label = next_label
|
||||
|
||||
return StickerOverlayResult(
|
||||
filter_str=";".join(filter_parts),
|
||||
output_label=output_label,
|
||||
extra_inputs=image_paths,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_single_image_sticker(
|
||||
cls,
|
||||
sticker: ImageStickerConfig,
|
||||
sticker_input_idx: int,
|
||||
input_label: str,
|
||||
output_label: str,
|
||||
canvas_w: int,
|
||||
canvas_h: int,
|
||||
) -> str:
|
||||
"""构建单个图片贴纸的完整滤镜(预处理 + overlay).
|
||||
|
||||
Args:
|
||||
sticker: 贴纸配置
|
||||
sticker_input_idx: 贴纸在 FFmpeg 输入中的索引
|
||||
input_label: 输入视频标签
|
||||
output_label: 输出标签
|
||||
canvas_w: 画布宽
|
||||
canvas_h: 画布高
|
||||
"""
|
||||
scaled_label = f"sticker_s{sticker_input_idx}"
|
||||
|
||||
# 预处理滤镜(缩放 + 透明度 + 淡入淡出)
|
||||
pre_filters: list[str] = []
|
||||
|
||||
# 缩放
|
||||
if sticker.width and sticker.height:
|
||||
pre_filters.append(f"scale={sticker.width}:{sticker.height}")
|
||||
elif sticker.scale != 1.0:
|
||||
pre_filters.append(f"scale=iw*{sticker.scale}:ih*{sticker.scale}")
|
||||
|
||||
# 透明度
|
||||
if sticker.opacity < 1.0:
|
||||
pre_filters.append(f"format=rgba,colorchannelmixer=aa={sticker.opacity}")
|
||||
|
||||
# 淡入淡出(使用 fade 的 alpha 模式)
|
||||
fade_filters: list[str] = []
|
||||
if sticker.fade_in > 0:
|
||||
fade_filters.append(f"fade=in:st={sticker.start_time}:d={sticker.fade_in}:alpha=1")
|
||||
if sticker.fade_out > 0 and sticker.duration > 0:
|
||||
fade_out_start = sticker.start_time + sticker.duration - sticker.fade_out
|
||||
if fade_out_start > 0:
|
||||
fade_filters.append(f"fade=out:st={fade_out_start}:d={sticker.fade_out}:alpha=1")
|
||||
|
||||
# 估算贴纸尺寸用于位置计算
|
||||
est_w = int(canvas_w * 0.3 * sticker.scale) if not sticker.width else sticker.width
|
||||
est_h = int(canvas_h * 0.3 * sticker.scale) if not sticker.height else sticker.height
|
||||
pos_x, pos_y = cls._resolve_position(sticker, canvas_w, canvas_h, est_w, est_h)
|
||||
|
||||
# enable 表达式
|
||||
enable_expr = ""
|
||||
if sticker.duration > 0:
|
||||
enable_expr = f":enable='between(t,{sticker.start_time},{sticker.start_time + sticker.duration})'"
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# 贴纸预处理
|
||||
all_pre = pre_filters + fade_filters
|
||||
if all_pre:
|
||||
parts.append(f"[{sticker_input_idx}:v]{','.join(all_pre)}[{scaled_label}]")
|
||||
sticker_source = f"[{scaled_label}]"
|
||||
else:
|
||||
sticker_source = f"[{sticker_input_idx}:v]"
|
||||
|
||||
# overlay 合成
|
||||
parts.append(f"{input_label}{sticker_source}overlay={pos_x:.0f}:{pos_y:.0f}{enable_expr}{output_label}")
|
||||
|
||||
return ";".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(val: Any) -> float | None:
|
||||
"""安全转换 float."""
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_stickers_from_config(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""从 plan.config.stickers 解析贴纸列表."""
|
||||
if not config:
|
||||
return []
|
||||
stickers = config.get("stickers", [])
|
||||
if not isinstance(stickers, list):
|
||||
return []
|
||||
return stickers
|
||||
|
||||
|
||||
def get_sticker_categories() -> list[tuple[str, str]]:
|
||||
"""获取贴纸分类列表."""
|
||||
return list(STICKER_CATEGORIES)
|
||||
@@ -1,183 +0,0 @@
|
||||
"""字幕生成器 — 将字幕时间轴转换为 ASS 字幕文件。
|
||||
|
||||
与 render_subtitles.py 的区别:
|
||||
- render_subtitles.py 处理静态整段标题/字幕
|
||||
- 本模块处理带时间轴的多段 ASR 字幕
|
||||
|
||||
两者最终都输出 ASS 文件,供 FFmpeg 烧录。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.subtitle import SubtitleTimeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_MAX_CHARS_PER_LINE = 20 # 每行最多字符数
|
||||
DEFAULT_MIN_CHARS_PER_SEGMENT = 8 # 每段最少字符数
|
||||
|
||||
|
||||
# ── ASS 工具函数 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hex_to_ass_color(hex_color: str) -> str:
|
||||
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。"""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return "&H00FFFFFF"
|
||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||
return f"&H{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _position_to_ass_alignment(position: str) -> int:
|
||||
"""将文字位置映射为 ASS \\an 对齐编号。"""
|
||||
mapping = {
|
||||
"top": 8,
|
||||
"center": 5,
|
||||
"bottom": 2,
|
||||
}
|
||||
return mapping.get(position, 2)
|
||||
|
||||
|
||||
def _format_ass_time(seconds: float) -> str:
|
||||
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||
|
||||
|
||||
def _escape_ass_text(text: str) -> str:
|
||||
"""转义 ASS 文本中的特殊字符。"""
|
||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||||
text = text.replace("{", "(").replace("}", ")")
|
||||
return text
|
||||
|
||||
|
||||
def _wrap_text(text: str, max_chars: int) -> list[str]:
|
||||
"""将长文本按字数换行。
|
||||
|
||||
优先在标点处换行,没有合适标点时硬切。
|
||||
"""
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
lines: list[str] = []
|
||||
remaining = text
|
||||
|
||||
while len(remaining) > max_chars:
|
||||
# 在前 max_chars 个字符中找标点断开
|
||||
break_point = max_chars
|
||||
punctuations = ",。!?、;:,.;:!?"
|
||||
|
||||
for i in range(max_chars, max_chars // 2, -1):
|
||||
if i < len(remaining) and remaining[i] in punctuations:
|
||||
break_point = i + 1
|
||||
break
|
||||
|
||||
lines.append(remaining[:break_point])
|
||||
remaining = remaining[break_point:]
|
||||
|
||||
if remaining:
|
||||
lines.append(remaining)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ── 主生成器 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def generate_ass_from_timeline(
|
||||
output_path: Path,
|
||||
timeline: SubtitleTimeline,
|
||||
*,
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
subtitle_config: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""从字幕时间轴生成 ASS 字幕文件。
|
||||
|
||||
Args:
|
||||
output_path: 输出 ASS 文件路径
|
||||
timeline: 字幕时间轴
|
||||
video_width: 视频宽度
|
||||
video_height: 视频高度
|
||||
subtitle_config: 字幕样式配置(同 SubtitleConfig dict)
|
||||
|
||||
Returns:
|
||||
生成的 ASS 文件路径
|
||||
"""
|
||||
subtitle_config = subtitle_config or {}
|
||||
|
||||
if not timeline.segments:
|
||||
output_path.write_text("", encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
# 样式参数
|
||||
font_name = subtitle_config.get("font", "思源黑体")
|
||||
font_size = int(subtitle_config.get("size", 24))
|
||||
color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
|
||||
position = subtitle_config.get("position", "bottom")
|
||||
alignment = _position_to_ass_alignment(position)
|
||||
max_chars_per_line = int(subtitle_config.get("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE))
|
||||
|
||||
# 描边(默认黑色描边,保证可读性)
|
||||
outline_color = "&H00000000"
|
||||
outline_width = 1.5
|
||||
|
||||
# 边距
|
||||
margin_v = 60 if position == "bottom" else 60
|
||||
margin_l = 40
|
||||
margin_r = 40
|
||||
|
||||
# 生成样式行
|
||||
style_line = (
|
||||
f"Style: Default,{font_name},{font_size},{color},"
|
||||
f"&H000000FF,{outline_color},&H00000000,"
|
||||
f"-1,0,0,0,100,100,0,0,"
|
||||
f"1,{outline_width},0,{alignment},"
|
||||
f"{margin_l},{margin_r},{margin_v},1"
|
||||
)
|
||||
|
||||
# 生成事件行
|
||||
events: list[str] = []
|
||||
for seg in timeline.segments:
|
||||
start_time = _format_ass_time(seg.start)
|
||||
end_time = _format_ass_time(seg.end)
|
||||
|
||||
# 自动换行
|
||||
lines = _wrap_text(seg.text, max_chars_per_line)
|
||||
display_text = "\\N".join(lines)
|
||||
|
||||
safe_text = _escape_ass_text(display_text)
|
||||
|
||||
events.append(f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{safe_text}")
|
||||
|
||||
# 组装 ASS 文件
|
||||
ass_content = f"""[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: {video_width}
|
||||
PlayResY: {video_height}
|
||||
ScaledBorderAndShadow: yes
|
||||
WrapStyle: 2
|
||||
Encoding: UTF-8
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
{style_line}
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
{chr(10).join(events)}
|
||||
"""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(ass_content, encoding="utf-8")
|
||||
return output_path
|
||||
@@ -1,687 +0,0 @@
|
||||
"""字幕渲染引擎 — 统一管理字幕样式配置与视频烧录.
|
||||
|
||||
与现有模块的关系:
|
||||
- render_subtitles.py:生成静态整段标题/字幕的 ASS 文件
|
||||
- subtitle_generator.py:从 ASR 时间轴生成 ASS 文件
|
||||
- 本模块:统一的字幕样式配置 + 烧录滤镜生成 + 多源字幕合并
|
||||
|
||||
支持的字幕来源:
|
||||
1. 静态标题/字幕(title_config / subtitle_config)
|
||||
2. ASR 自动字幕(asr_subtitle_timeline)
|
||||
3. 手动字幕(manual_subtitles 时间轴)
|
||||
|
||||
支持的样式配置:
|
||||
- 字体、字号、颜色
|
||||
- 描边(颜色、宽度)
|
||||
- 阴影(偏移、模糊、颜色)
|
||||
- 背景框(颜色、透明度、圆角、边距)
|
||||
- 位置(9宫格 + 自定义坐标)
|
||||
- 对齐方式
|
||||
- 动画(淡入淡出、滑入滑出、打字机)
|
||||
- 多行/换行规则
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
from video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALLOWED_SUBTITLE_EXTENSIONS = {".srt", ".ass", ".vtt", ".sub"}
|
||||
|
||||
# 9宫格位置映射(ASS alignment 编号)
|
||||
POSITION_ALIGNMENT = {
|
||||
"top_left": 7,
|
||||
"top_center": 8,
|
||||
"top_right": 9,
|
||||
"middle_left": 4,
|
||||
"center": 5,
|
||||
"middle_right": 6,
|
||||
"bottom_left": 1,
|
||||
"bottom_center": 2,
|
||||
"bottom_right": 3,
|
||||
}
|
||||
|
||||
# 位置简称兼容
|
||||
POSITION_ALIASES = {
|
||||
"top": "top_center",
|
||||
"bottom": "bottom_center",
|
||||
"middle": "center",
|
||||
"left": "middle_left",
|
||||
"right": "middle_right",
|
||||
}
|
||||
|
||||
DEFAULT_FONT = "思源黑体"
|
||||
DEFAULT_FONT_SIZE = 24
|
||||
DEFAULT_COLOR = "#FFFFFF"
|
||||
DEFAULT_STROKE_COLOR = "#000000"
|
||||
DEFAULT_STROKE_WIDTH = 1.5
|
||||
DEFAULT_POSITION = "bottom_center"
|
||||
DEFAULT_MAX_CHARS_PER_LINE = 20
|
||||
|
||||
|
||||
# ── 字幕样式配置 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleStyle:
|
||||
"""字幕样式配置."""
|
||||
|
||||
font_name: str = DEFAULT_FONT
|
||||
font_size: int = DEFAULT_FONT_SIZE
|
||||
font_color: str = DEFAULT_COLOR
|
||||
bold: bool = False
|
||||
italic: bool = False
|
||||
|
||||
# 描边
|
||||
stroke_enabled: bool = True
|
||||
stroke_color: str = DEFAULT_STROKE_COLOR
|
||||
stroke_width: float = DEFAULT_STROKE_WIDTH
|
||||
|
||||
# 阴影
|
||||
shadow_enabled: bool = False
|
||||
shadow_color: str = "#000000"
|
||||
shadow_offset_x: int = 2
|
||||
shadow_offset_y: int = 2
|
||||
shadow_blur: float = 0.0
|
||||
|
||||
# 背景框
|
||||
background_enabled: bool = False
|
||||
background_color: str = "#000000"
|
||||
background_opacity: float = 0.5 # 0.0 ~ 1.0
|
||||
background_padding: int = 8
|
||||
background_radius: int = 4
|
||||
|
||||
# 位置
|
||||
position: str = DEFAULT_POSITION # 9宫格位置名
|
||||
margin_v: int = 60 # 垂直边距
|
||||
margin_l: int = 40 # 左边距
|
||||
margin_r: int = 40 # 右边距
|
||||
|
||||
# 多行
|
||||
max_chars_per_line: int = DEFAULT_MAX_CHARS_PER_LINE
|
||||
line_spacing: int = 0 # 行间距
|
||||
|
||||
# 动画
|
||||
fade_in: float = 0.0 # 淡入时长(秒)
|
||||
fade_out: float = 0.0 # 淡出时长(秒)
|
||||
animation_type: str = "none" # none/fade/slide/typewriter
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config: dict[str, Any] | None) -> "SubtitleStyle":
|
||||
"""从字典创建样式配置,带安全类型转换."""
|
||||
if not config or not isinstance(config, dict):
|
||||
return cls()
|
||||
|
||||
def safe_str(key: str, default: str) -> str:
|
||||
val = config.get(key, default)
|
||||
return str(val) if val is not None else default
|
||||
|
||||
def safe_int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(config.get(key, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def safe_bool(key: str, default: bool) -> bool:
|
||||
return bool(config.get(key, default))
|
||||
|
||||
position = safe_str("position", DEFAULT_POSITION)
|
||||
position = POSITION_ALIASES.get(position, position)
|
||||
if position not in POSITION_ALIGNMENT:
|
||||
position = DEFAULT_POSITION
|
||||
|
||||
return cls(
|
||||
font_name=safe_str("font", DEFAULT_FONT),
|
||||
font_size=safe_int("size", DEFAULT_FONT_SIZE),
|
||||
font_color=safe_str("color", DEFAULT_COLOR),
|
||||
bold=safe_bool("bold", False),
|
||||
italic=safe_bool("italic", False),
|
||||
stroke_enabled=safe_bool("stroke_enabled", True),
|
||||
stroke_color=safe_str("stroke_color", DEFAULT_STROKE_COLOR),
|
||||
stroke_width=safe_float("stroke_width", DEFAULT_STROKE_WIDTH),
|
||||
shadow_enabled=safe_bool("shadow_enabled", False),
|
||||
shadow_color=safe_str("shadow_color", "#000000"),
|
||||
shadow_offset_x=safe_int("shadow_offset_x", 2),
|
||||
shadow_offset_y=safe_int("shadow_offset_y", 2),
|
||||
shadow_blur=safe_float("shadow_blur", 0.0),
|
||||
background_enabled=safe_bool("background_enabled", False),
|
||||
background_color=safe_str("background_color", "#000000"),
|
||||
background_opacity=max(0.0, min(1.0, safe_float("background_opacity", 0.5))),
|
||||
background_padding=safe_int("background_padding", 8),
|
||||
background_radius=safe_int("background_radius", 4),
|
||||
position=position,
|
||||
margin_v=safe_int("margin_v", 60),
|
||||
margin_l=safe_int("margin_l", 40),
|
||||
margin_r=safe_int("margin_r", 40),
|
||||
max_chars_per_line=safe_int("max_chars_per_line", DEFAULT_MAX_CHARS_PER_LINE),
|
||||
line_spacing=safe_int("line_spacing", 0),
|
||||
fade_in=max(0.0, safe_float("fade_in", 0.0)),
|
||||
fade_out=max(0.0, safe_float("fade_out", 0.0)),
|
||||
animation_type=safe_str("animation_type", "none"),
|
||||
)
|
||||
|
||||
@property
|
||||
def alignment(self) -> int:
|
||||
"""获取 ASS alignment 编号."""
|
||||
return POSITION_ALIGNMENT.get(self.position, 2)
|
||||
|
||||
@property
|
||||
def ass_font_color(self) -> str:
|
||||
"""ASS 格式颜色 &HAABBGGRR."""
|
||||
return _hex_to_ass_color(self.font_color)
|
||||
|
||||
@property
|
||||
def ass_stroke_color(self) -> str:
|
||||
return _hex_to_ass_color(self.stroke_color)
|
||||
|
||||
@property
|
||||
def ass_shadow_color(self) -> str:
|
||||
return _hex_to_ass_color(self.shadow_color)
|
||||
|
||||
@property
|
||||
def ass_background_color(self) -> str:
|
||||
"""背景框颜色(ASS BackColour),带透明度."""
|
||||
alpha_hex = _opacity_to_ass_alpha(self.background_opacity)
|
||||
color_bgr = _hex_to_ass_bgr(self.background_color)
|
||||
return f"&H{alpha_hex}{color_bgr}"
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hex_to_ass_color(hex_color: str) -> str:
|
||||
"""HEX → ASS 颜色 &HAABBGGRR(默认不透明)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return "&H00FFFFFF"
|
||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||
return f"&H00{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _hex_to_ass_bgr(hex_color: str) -> str:
|
||||
"""HEX → ASS BGR 部分(不含 alpha)."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return "FFFFFF"
|
||||
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
|
||||
return f"{b.upper()}{g.upper()}{r.upper()}"
|
||||
|
||||
|
||||
def _opacity_to_ass_alpha(opacity: float) -> str:
|
||||
"""不透明度 → ASS alpha(00=不透明,FF=完全透明)."""
|
||||
alpha = 255 - int(opacity * 255)
|
||||
return f"{alpha:02X}"
|
||||
|
||||
|
||||
def _escape_ass_text(text: str) -> str:
|
||||
"""转义 ASS 文本特殊字符."""
|
||||
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
|
||||
text = text.replace("{", "(").replace("}", ")")
|
||||
return text
|
||||
|
||||
|
||||
def _format_ass_time(seconds: float) -> str:
|
||||
"""秒 → ASS 时间格式 H:MM:SS.cc."""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = seconds % 60
|
||||
return f"{hours}:{minutes:02d}:{secs:05.2f}"
|
||||
|
||||
|
||||
def _wrap_text(text: str, max_chars: int) -> list[str]:
|
||||
"""按字数换行,优先标点断开."""
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
lines: list[str] = []
|
||||
remaining = text
|
||||
|
||||
while len(remaining) > max_chars:
|
||||
break_point = max_chars
|
||||
punctuations = ",。!?、;:,.;:!?"
|
||||
|
||||
for i in range(max_chars, max_chars // 2, -1):
|
||||
if i < len(remaining) and remaining[i] in punctuations:
|
||||
break_point = i + 1
|
||||
break
|
||||
|
||||
lines.append(remaining[:break_point])
|
||||
remaining = remaining[break_point:]
|
||||
|
||||
if remaining:
|
||||
lines.append(remaining)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
# ── 字幕片段 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubtitleSegment:
|
||||
"""单个字幕片段."""
|
||||
|
||||
start: float # 开始时间(秒)
|
||||
end: float # 结束时间(秒)
|
||||
text: str # 字幕文本
|
||||
style_name: str = "Default" # 使用的样式名
|
||||
|
||||
|
||||
# ── 字幕渲染引擎 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class SubtitleRenderEngine:
|
||||
"""字幕渲染引擎 — 统一管理多源字幕的 ASS 文件生成.
|
||||
|
||||
支持合并多个字幕来源到同一个 ASS 文件:
|
||||
- 标题(顶部,单独样式)
|
||||
- 字幕(底部,单独样式)
|
||||
- ASR 时间轴字幕
|
||||
- 手动字幕
|
||||
|
||||
输出一个统一的 ASS 文件,供 FFmpeg subtitles filter 烧录。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
video_width: int = 1080,
|
||||
video_height: int = 1920,
|
||||
video_duration: float = 0.0,
|
||||
):
|
||||
self.video_width = video_width
|
||||
self.video_height = video_height
|
||||
self.video_duration = video_duration
|
||||
self._styles: dict[str, SubtitleStyle] = {}
|
||||
self._segments: list[SubtitleSegment] = []
|
||||
self._style_counter = 0
|
||||
|
||||
# ── 样式管理 ──────────────────────────────────────────────────────
|
||||
|
||||
def add_style(self, name: str, style: SubtitleStyle) -> str:
|
||||
"""注册一个样式,返回样式名."""
|
||||
self._styles[name] = style
|
||||
return name
|
||||
|
||||
def get_or_create_style(self, base_name: str, style: SubtitleStyle) -> str:
|
||||
"""获取或创建样式(避免重复)."""
|
||||
if base_name in self._styles:
|
||||
return base_name
|
||||
self._styles[base_name] = style
|
||||
return base_name
|
||||
|
||||
# ── 字幕源添加 ────────────────────────────────────────────────────
|
||||
|
||||
def add_title(self, text: str, style: SubtitleStyle | None = None) -> None:
|
||||
"""添加整段标题(显示整个视频时长)."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle(
|
||||
position="top_center",
|
||||
font_size=48,
|
||||
bold=True,
|
||||
stroke_enabled=True,
|
||||
stroke_width=2.0,
|
||||
)
|
||||
style_name = self.get_or_create_style("TitleStyle", style)
|
||||
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=0.0,
|
||||
end=self.video_duration if self.video_duration > 0 else 9999.0,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
def add_subtitle_text(self, text: str, style: SubtitleStyle | None = None) -> None:
|
||||
"""添加整段字幕(显示整个视频时长)."""
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("SubtitleStyle", style)
|
||||
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=0.0,
|
||||
end=self.video_duration if self.video_duration > 0 else 9999.0,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
def add_timeline_segments(
|
||||
self,
|
||||
segments: list[dict] | list[SubtitleSegment],
|
||||
style: SubtitleStyle | None = None,
|
||||
) -> None:
|
||||
"""添加时间轴字幕片段(ASR 或手动字幕).
|
||||
|
||||
segments 可以是:
|
||||
- SubtitleSegment 列表
|
||||
- dict 列表,每个 dict 含 start/end/text 字段
|
||||
"""
|
||||
if not segments:
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("Default", style)
|
||||
|
||||
for seg in segments:
|
||||
if isinstance(seg, SubtitleSegment):
|
||||
seg.style_name = style_name
|
||||
self._segments.append(seg)
|
||||
elif isinstance(seg, dict):
|
||||
try:
|
||||
start = float(seg.get("start", 0))
|
||||
end = float(seg.get("end", 0))
|
||||
text = str(seg.get("text", ""))
|
||||
if end > start and text.strip():
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=start,
|
||||
end=end,
|
||||
text=text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
def add_asr_timeline(self, timeline: Any, style: SubtitleStyle | None = None) -> None:
|
||||
"""从 SubtitleTimeline 对象添加 ASR 字幕."""
|
||||
if not timeline or not hasattr(timeline, "segments") or not timeline.segments:
|
||||
return
|
||||
|
||||
style = style or SubtitleStyle()
|
||||
style_name = self.get_or_create_style("ASRStyle", style)
|
||||
|
||||
for seg in timeline.segments:
|
||||
if hasattr(seg, "start") and hasattr(seg, "end") and hasattr(seg, "text"):
|
||||
if seg.end > seg.start and seg.text.strip():
|
||||
self._segments.append(
|
||||
SubtitleSegment(
|
||||
start=seg.start,
|
||||
end=seg.end,
|
||||
text=seg.text.strip(),
|
||||
style_name=style_name,
|
||||
)
|
||||
)
|
||||
|
||||
# ── ASS 文件生成 ──────────────────────────────────────────────────
|
||||
|
||||
def generate_ass(self, output_path: Path) -> Path:
|
||||
"""生成 ASS 字幕文件.
|
||||
|
||||
Returns:
|
||||
生成的文件路径;如果没有字幕内容,返回空文件。
|
||||
"""
|
||||
if not self._segments:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text("", encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
# 确保至少有 Default 样式
|
||||
if "Default" not in self._styles:
|
||||
self._styles["Default"] = SubtitleStyle()
|
||||
|
||||
# 生成样式行
|
||||
style_lines = []
|
||||
for name, style in self._styles.items():
|
||||
style_lines.append(self._build_ass_style_line(name, style))
|
||||
|
||||
# 生成事件行(按时间排序)
|
||||
self._segments.sort(key=lambda s: s.start)
|
||||
event_lines = []
|
||||
for seg in self._segments:
|
||||
event_lines.append(self._build_ass_event_line(seg))
|
||||
|
||||
# 组装文件
|
||||
ass_content = f"""[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: {self.video_width}
|
||||
PlayResY: {self.video_height}
|
||||
ScaledBorderAndShadow: yes
|
||||
WrapStyle: 2
|
||||
Encoding: UTF-8
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
{chr(10).join(style_lines)}
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
{chr(10).join(event_lines)}
|
||||
"""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(ass_content, encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
def _build_ass_style_line(self, name: str, style: SubtitleStyle) -> str:
|
||||
"""构建一条 ASS Style 行."""
|
||||
bold_val = -1 if style.bold else 0
|
||||
italic_val = -1 if style.italic else 0
|
||||
|
||||
# BorderStyle: 1=outline+shadow, 3=opaque box(背景框)
|
||||
if style.background_enabled:
|
||||
border_style = 3
|
||||
back_color = style.ass_background_color
|
||||
else:
|
||||
border_style = 1
|
||||
back_color = style.ass_shadow_color if style.shadow_enabled else style.ass_font_color
|
||||
|
||||
outline_val = style.stroke_width if style.stroke_enabled else 0.0
|
||||
shadow_val = style.shadow_offset_y if style.shadow_enabled else 0
|
||||
|
||||
return (
|
||||
f"Style: {name},{style.font_name},{style.font_size},{style.ass_font_color},"
|
||||
f"&H000000FF,{style.ass_stroke_color},{back_color},"
|
||||
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
||||
f"{border_style},{outline_val},{shadow_val},{style.alignment},"
|
||||
f"{style.margin_l},{style.margin_r},{style.margin_v},1"
|
||||
)
|
||||
|
||||
def _build_ass_event_line(self, seg: SubtitleSegment) -> str:
|
||||
"""构建一条 ASS Dialogue 事件行."""
|
||||
style = self._styles.get(seg.style_name, SubtitleStyle())
|
||||
max_chars = style.max_chars_per_line
|
||||
|
||||
# 自动换行
|
||||
lines = _wrap_text(seg.text, max_chars)
|
||||
display_text = "\\N".join(lines)
|
||||
|
||||
# 动画效果(淡入淡出)
|
||||
effect_tags = ""
|
||||
if style.fade_in > 0 or style.fade_out > 0:
|
||||
fade_in_ms = int(style.fade_in * 1000)
|
||||
fade_out_ms = int(style.fade_out * 1000)
|
||||
effect_tags = f"{{\\fad({fade_in_ms},{fade_out_ms})}}"
|
||||
|
||||
safe_text = _escape_ass_text(display_text)
|
||||
start_time = _format_ass_time(max(0, seg.start))
|
||||
end_time = _format_ass_time(max(seg.start + 0.1, seg.end))
|
||||
|
||||
return f"Dialogue: 0,{start_time},{end_time},{seg.style_name},,0,0,0,," f"{effect_tags}{safe_text}"
|
||||
|
||||
@property
|
||||
def has_subtitles(self) -> bool:
|
||||
"""是否有字幕内容."""
|
||||
return len(self._segments) > 0
|
||||
|
||||
|
||||
# ── 便捷函数:从 plan.config 快速生成 ASS ────────────────────────────────────
|
||||
|
||||
|
||||
def build_subtitles_from_plan(
|
||||
output_path: Path,
|
||||
plan_config: dict,
|
||||
*,
|
||||
video_width: int,
|
||||
video_height: int,
|
||||
video_duration: float,
|
||||
asr_timeline: Any = None,
|
||||
) -> Path | None:
|
||||
"""从 plan.config 构建字幕 ASS 文件.
|
||||
|
||||
支持的配置项:
|
||||
- title_config: 标题配置(含 text/style)
|
||||
- subtitle_config: 字幕配置(含 text/style)
|
||||
- asr_subtitles: ASR 字幕开关 + 样式
|
||||
- manual_subtitles: 手动字幕片段列表
|
||||
|
||||
Returns:
|
||||
生成的 ASS 文件路径;如果没有任何字幕,返回 None
|
||||
"""
|
||||
engine = SubtitleRenderEngine(
|
||||
video_width=video_width,
|
||||
video_height=video_height,
|
||||
video_duration=video_duration,
|
||||
)
|
||||
|
||||
has_any = False
|
||||
|
||||
# 1. 标题
|
||||
title_cfg = plan_config.get("title_config") or {}
|
||||
if isinstance(title_cfg, dict):
|
||||
title_text = str(title_cfg.get("text", ""))
|
||||
title_enabled = title_cfg.get("enabled", True)
|
||||
if title_enabled and title_text.strip():
|
||||
style_dict = title_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
# 标题默认样式:顶部、大字号、粗体
|
||||
if style.position == DEFAULT_POSITION and style.font_size == DEFAULT_FONT_SIZE:
|
||||
style.position = "top_center"
|
||||
style.font_size = 48
|
||||
style.bold = True
|
||||
engine.add_title(title_text, style)
|
||||
has_any = True
|
||||
|
||||
# 2. 静态字幕
|
||||
sub_cfg = plan_config.get("subtitle_config") or {}
|
||||
if isinstance(sub_cfg, dict):
|
||||
sub_text = str(sub_cfg.get("text", ""))
|
||||
sub_enabled = sub_cfg.get("enabled", True)
|
||||
if sub_enabled and sub_text.strip():
|
||||
style_dict = sub_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_subtitle_text(sub_text, style)
|
||||
has_any = True
|
||||
|
||||
# 3. ASR 自动字幕
|
||||
asr_cfg = plan_config.get("asr_subtitles") or {}
|
||||
if isinstance(asr_cfg, dict) and asr_cfg.get("enabled", False):
|
||||
if asr_timeline is not None:
|
||||
style_dict = asr_cfg.get("style") or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_asr_timeline(asr_timeline, style)
|
||||
has_any = has_any or engine.has_subtitles
|
||||
|
||||
# 4. 手动字幕
|
||||
manual_segs = plan_config.get("manual_subtitles") or []
|
||||
if isinstance(manual_segs, list) and manual_segs:
|
||||
style_dict = (plan_config.get("manual_subtitle_style") or {}) or {}
|
||||
style = SubtitleStyle.from_dict(style_dict)
|
||||
engine.add_timeline_segments(manual_segs, style)
|
||||
has_any = has_any or engine.has_subtitles
|
||||
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
return engine.generate_ass(output_path)
|
||||
|
||||
|
||||
# ── FFmpeg 烧录滤镜生成 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_subtitle_filter(
|
||||
ass_path: Path | str,
|
||||
*,
|
||||
video_input_label: str = "0:v",
|
||||
output_label: str = "subtitled",
|
||||
work_dir: Path | str | None = None,
|
||||
) -> str:
|
||||
"""生成 FFmpeg subtitles 滤镜字符串.
|
||||
|
||||
Args:
|
||||
ass_path: ASS 字幕文件路径
|
||||
video_input_label: 视频输入标签(如 "0:v" 或 "[v_out]")
|
||||
output_label: 输出标签
|
||||
work_dir: 工作目录(必填,用于路径安全校验,防止路径遍历绕过)
|
||||
|
||||
Returns:
|
||||
filter_complex 片段,如 "[0:v]subtitles=xxx.ass[subtitled]"
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 字幕路径不安全或 work_dir 未提供
|
||||
"""
|
||||
# ── 安全校验:字幕文件路径白名单 ──
|
||||
ass_path_str = str(ass_path)
|
||||
if work_dir is None or not str(work_dir).strip():
|
||||
raise PathSecurityError("work_dir 必须提供,不能为 None 或空")
|
||||
|
||||
_validate_subtitle_path(ass_path_str, Path(work_dir))
|
||||
|
||||
# FFmpeg subtitles filter 的路径需要转义:
|
||||
# - Windows 路径的 \ → /
|
||||
# - 冒号 : → \:
|
||||
# - 单引号 ' → '\''
|
||||
safe_path = ass_path_str.replace("\\", "/").replace(":", "\\:").replace("'", "'\\''")
|
||||
return f"{video_input_label}subtitles='{safe_path}'[{output_label}]"
|
||||
|
||||
|
||||
def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
|
||||
"""校验字幕文件路径安全性.
|
||||
|
||||
规则:
|
||||
- 必须是本地路径(不支持远程URL字幕)
|
||||
- local:// schema → 必须在 work_dir 内
|
||||
- 相对路径 → 必须在 work_dir 内
|
||||
- 绝对路径 → 必须在允许目录白名单内
|
||||
- 扩展名必须是字幕格式
|
||||
|
||||
Raises:
|
||||
PathSecurityError: 路径不安全
|
||||
"""
|
||||
if not subtitle_path or not isinstance(subtitle_path, str):
|
||||
raise PathSecurityError("字幕路径不能为空")
|
||||
|
||||
# 不允许远程URL字幕(subtitles滤镜不支持远程加载,且有SSRF风险)
|
||||
if subtitle_path.startswith(("http://", "https://", "oss://")):
|
||||
raise PathSecurityError("不允许使用远程URL字幕文件")
|
||||
|
||||
is_abs = subtitle_path.startswith("/") and not subtitle_path.startswith("local://")
|
||||
|
||||
resolved_path = safe_resolve_path(
|
||||
subtitle_path,
|
||||
work_dir,
|
||||
allow_outside=is_abs,
|
||||
allowed_extensions=ALLOWED_SUBTITLE_EXTENSIONS,
|
||||
)
|
||||
|
||||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||||
if is_abs:
|
||||
resolved_work_dir = work_dir.resolve()
|
||||
try:
|
||||
resolved_path.relative_to(resolved_work_dir)
|
||||
except ValueError:
|
||||
if not is_in_allowed_dirs(resolved_path):
|
||||
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
|
||||
@@ -1,123 +0,0 @@
|
||||
"""视频缩略图生成工具 — 抽取首帧上传到 OSS。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_first_frame(
|
||||
video_path: str,
|
||||
output_path: str | None = None,
|
||||
*,
|
||||
width: int = 640,
|
||||
height: int = -1,
|
||||
timeout: int = 30,
|
||||
) -> str:
|
||||
"""抽取视频第一帧作为封面图。
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
output_path: 输出图片路径,不传则用临时文件
|
||||
width: 输出宽度(默认 640,-1 表示按比例缩放)
|
||||
height: 输出高度(默认 -1,按比例缩放)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
生成的缩略图文件路径
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: ffmpeg 执行失败
|
||||
"""
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
if output_path is None:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.close()
|
||||
output_path = tmp.name
|
||||
|
||||
# -ss 00:00:01 取第1秒帧(避免首帧黑屏)
|
||||
# -vframes 1 只取一帧
|
||||
# -q:v 2 jpeg 高质量
|
||||
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
|
||||
except Exception:
|
||||
# 短视频可能没有第1秒,退回到第0帧
|
||||
cmd2 = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
video_path,
|
||||
"-ss",
|
||||
"00:00:00",
|
||||
"-vframes",
|
||||
"1",
|
||||
"-vf",
|
||||
scale_filter,
|
||||
"-q:v",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
|
||||
|
||||
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
|
||||
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def generate_and_upload_thumbnail(
|
||||
video_path: str,
|
||||
storage_key: str,
|
||||
) -> str | None:
|
||||
"""生成缩略图并上传到 OSS,返回 URL。
|
||||
|
||||
Args:
|
||||
video_path: 本地视频路径
|
||||
storage_key: OSS 存储 key(如 generated/projects/xxx/thumbnails/yyy.jpg)
|
||||
|
||||
Returns:
|
||||
上传成功返回 URL,失败返回 None
|
||||
"""
|
||||
thumbnail_path = None
|
||||
try:
|
||||
thumbnail_path = extract_first_frame(video_path)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract thumbnail from %s: %s", video_path, e)
|
||||
return None
|
||||
|
||||
try:
|
||||
from video_processing.oss_helpers import upload_to_oss
|
||||
|
||||
url = upload_to_oss(thumbnail_path, storage_key)
|
||||
return url
|
||||
except Exception as e:
|
||||
logger.warning("Failed to upload thumbnail to OSS: %s", e)
|
||||
return None
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if thumbnail_path:
|
||||
try:
|
||||
Path(thumbnail_path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,381 +0,0 @@
|
||||
"""转场特效引擎 — Phase 8 智能增强.
|
||||
|
||||
基于 FFmpeg xfade 滤镜的统一转场抽象层,提供:
|
||||
1. 转场类型枚举与预设管理
|
||||
2. 转场配置解析与边界校验
|
||||
3. 降级策略(不支持的转场自动 fallback 到硬切)
|
||||
4. xfade 滤镜链构建(封装底层 ffmpeg_utils)
|
||||
|
||||
新增转场只需在 TransitionType 中加一项 + 在 XFADE_TRANSITION_MAP 中映射。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from enum import StrEnum
|
||||
else:
|
||||
from enum import Enum
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
pass
|
||||
|
||||
|
||||
from video_processing.ffmpeg_utils import build_xfade_filter_chain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 转场时长范围(秒)
|
||||
MIN_TRANSITION_DURATION = 0.3
|
||||
MAX_TRANSITION_DURATION = 2.0
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# 硬切(无转场)
|
||||
CUT_TRANSITION = "cut"
|
||||
|
||||
|
||||
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionType(StrEnum):
|
||||
"""支持的转场效果类型.
|
||||
|
||||
每种类型对应 FFmpeg xfade filter 的一个 transition 值。
|
||||
新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。
|
||||
"""
|
||||
|
||||
# 硬切(无转场效果,直接拼接)
|
||||
CUT = "cut"
|
||||
|
||||
# 淡入淡出(最常用,默认 fallback)
|
||||
FADE = "fade"
|
||||
|
||||
# 溶解(交叉溶解)
|
||||
DISSOLVE = "dissolve"
|
||||
|
||||
# 滑入系列
|
||||
SLIDE_LEFT = "slideleft"
|
||||
SLIDE_RIGHT = "slideright"
|
||||
SLIDE_UP = "slideup"
|
||||
SLIDE_DOWN = "slidedown"
|
||||
|
||||
# 缩放
|
||||
ZOOM = "zoom"
|
||||
|
||||
# 擦除系列
|
||||
WIPE_LEFT = "wipeleft"
|
||||
WIPE_RIGHT = "wiperight"
|
||||
WIPE_UP = "wipeup"
|
||||
WIPE_DOWN = "wipedown"
|
||||
|
||||
# 圆形扩散
|
||||
CIRCLE_CROP = "circlecrop"
|
||||
|
||||
# 矩形覆盖
|
||||
RECT_CROP = "rectcrop"
|
||||
|
||||
@classmethod
|
||||
def all_supported(cls) -> list[str]:
|
||||
"""返回所有支持的转场类型名称列表."""
|
||||
return [t.value for t in cls if t != cls.CUT]
|
||||
|
||||
@classmethod
|
||||
def is_supported(cls, name: str) -> bool:
|
||||
"""检查转场类型是否支持(不区分大小写和下划线)."""
|
||||
normalized = _normalize_transition_name(name)
|
||||
return normalized in _NAME_TO_ENUM_MAP
|
||||
|
||||
|
||||
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
|
||||
|
||||
|
||||
def _normalize_transition_name(name: str) -> str:
|
||||
"""标准化转场名称:小写 + 去下划线."""
|
||||
return name.lower().replace("_", "").replace("-", "")
|
||||
|
||||
|
||||
# 构建别名映射
|
||||
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
|
||||
for _t in TransitionType:
|
||||
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
|
||||
|
||||
# 额外的别名
|
||||
_ALIASES: dict[str, TransitionType] = {
|
||||
"dissolve": TransitionType.DISSOLVE,
|
||||
"crossfade": TransitionType.DISSOLVE,
|
||||
"crossdissolve": TransitionType.DISSOLVE,
|
||||
"fadein": TransitionType.FADE,
|
||||
"fadeout": TransitionType.FADE,
|
||||
"fadeblack": TransitionType.FADE,
|
||||
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
|
||||
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
|
||||
"zoomin": TransitionType.ZOOM,
|
||||
"zoomout": TransitionType.ZOOM,
|
||||
"circle": TransitionType.CIRCLE_CROP,
|
||||
"rect": TransitionType.RECT_CROP,
|
||||
}
|
||||
for _alias, _type in _ALIASES.items():
|
||||
_key = _normalize_transition_name(_alias)
|
||||
if _key not in _NAME_TO_ENUM_MAP:
|
||||
_NAME_TO_ENUM_MAP[_key] = _type
|
||||
|
||||
|
||||
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
|
||||
|
||||
|
||||
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
|
||||
TransitionType.FADE: "fade",
|
||||
TransitionType.DISSOLVE: "dissolve",
|
||||
TransitionType.SLIDE_LEFT: "slideleft",
|
||||
TransitionType.SLIDE_RIGHT: "slideright",
|
||||
TransitionType.SLIDE_UP: "slideup",
|
||||
TransitionType.SLIDE_DOWN: "slidedown",
|
||||
TransitionType.ZOOM: "zoomin",
|
||||
TransitionType.WIPE_LEFT: "wipeleft",
|
||||
TransitionType.WIPE_RIGHT: "wiperight",
|
||||
TransitionType.WIPE_UP: "wipeup",
|
||||
TransitionType.WIPE_DOWN: "wipedown",
|
||||
TransitionType.CIRCLE_CROP: "circlecrop",
|
||||
TransitionType.RECT_CROP: "rectcrop",
|
||||
}
|
||||
|
||||
|
||||
# ── 转场配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TransitionConfig:
|
||||
"""转场效果配置.
|
||||
|
||||
Attributes:
|
||||
effect: 转场效果名称(见 TransitionType)
|
||||
duration: 转场时长(秒),范围 0.3~2.0,默认 0.5
|
||||
"""
|
||||
|
||||
effect: str = CUT_TRANSITION
|
||||
duration: float = DEFAULT_TRANSITION_DURATION
|
||||
|
||||
@classmethod
|
||||
def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig":
|
||||
"""解析并验证转场配置,自动处理边界和降级.
|
||||
|
||||
Args:
|
||||
effect: 转场效果名称(None 或空则使用默认 cut)
|
||||
duration: 转场时长(None 则使用默认值)
|
||||
|
||||
Returns:
|
||||
验证后的 TransitionConfig
|
||||
"""
|
||||
# 处理 effect
|
||||
final_effect = CUT_TRANSITION
|
||||
if effect and effect.strip():
|
||||
effect_clean = effect.strip()
|
||||
if TransitionType.is_supported(effect_clean):
|
||||
final_effect = _resolve_transition_enum(effect_clean).value
|
||||
elif effect_clean.lower() == CUT_TRANSITION:
|
||||
final_effect = CUT_TRANSITION
|
||||
else:
|
||||
# 降级:不支持的转场 → 硬切,不阻断渲染
|
||||
logger.warning(
|
||||
"不支持的转场效果 '%s',已降级为硬切(cut)",
|
||||
effect_clean,
|
||||
)
|
||||
final_effect = CUT_TRANSITION
|
||||
|
||||
# 处理 duration:边界钳制
|
||||
final_duration = DEFAULT_TRANSITION_DURATION
|
||||
if duration is not None:
|
||||
try:
|
||||
d = float(duration)
|
||||
if d < MIN_TRANSITION_DURATION:
|
||||
logger.warning(
|
||||
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
|
||||
d,
|
||||
MIN_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = MIN_TRANSITION_DURATION
|
||||
elif d > MAX_TRANSITION_DURATION:
|
||||
logger.warning(
|
||||
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
|
||||
d,
|
||||
MAX_TRANSITION_DURATION,
|
||||
)
|
||||
final_duration = MAX_TRANSITION_DURATION
|
||||
else:
|
||||
final_duration = d
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION)
|
||||
final_duration = DEFAULT_TRANSITION_DURATION
|
||||
|
||||
return cls(effect=final_effect, duration=final_duration)
|
||||
|
||||
@property
|
||||
def is_cut(self) -> bool:
|
||||
"""是否为硬切(无转场效果)."""
|
||||
return self.effect == CUT_TRANSITION
|
||||
|
||||
@property
|
||||
def ffmpeg_transition(self) -> str:
|
||||
"""获取对应的 FFmpeg xfade transition 名称."""
|
||||
if self.is_cut:
|
||||
return ""
|
||||
enum_type = _resolve_transition_enum(self.effect)
|
||||
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
|
||||
|
||||
|
||||
def _resolve_transition_enum(name: str) -> TransitionType:
|
||||
"""将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验."""
|
||||
normalized = _normalize_transition_name(name)
|
||||
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
|
||||
|
||||
|
||||
# ── 转场引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TransitionEngine:
|
||||
"""转场特效引擎.
|
||||
|
||||
封装转场配置验证、降级策略和 xfade 滤镜链构建,
|
||||
供 UnifiedRenderService 等上层调用。
|
||||
|
||||
用法::
|
||||
|
||||
engine = TransitionEngine(default_duration=0.5)
|
||||
config = engine.resolve_config("fade", 0.8)
|
||||
filter_str, total_dur = engine.build_xfade_chain(
|
||||
clip_durations=[3.0, 4.0, 5.0],
|
||||
clip_video_labels=["v0", "v1", "v2"],
|
||||
transitions=["cut", "fade", "dissolve"],
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, default_duration: float = DEFAULT_TRANSITION_DURATION) -> None:
|
||||
"""初始化转场引擎.
|
||||
|
||||
Args:
|
||||
default_duration: 默认转场时长(秒),用于未指定时长的 clip
|
||||
"""
|
||||
self._default_duration = default_duration
|
||||
|
||||
def resolve_config(
|
||||
self,
|
||||
effect: str | None = None,
|
||||
duration: float | None = None,
|
||||
) -> TransitionConfig:
|
||||
"""解析单个转场配置,应用验证和降级.
|
||||
|
||||
Args:
|
||||
effect: 转场效果名称
|
||||
duration: 转场时长
|
||||
|
||||
Returns:
|
||||
验证后的 TransitionConfig
|
||||
"""
|
||||
# 若未指定 duration,使用引擎默认值
|
||||
dur = duration if duration is not None else self._default_duration
|
||||
return TransitionConfig.parse(effect=effect, duration=dur)
|
||||
|
||||
def resolve_clip_transitions(
|
||||
self,
|
||||
clip_transitions: list[str],
|
||||
clip_durations: list[float] | None = None,
|
||||
) -> list[TransitionConfig]:
|
||||
"""批量解析 clip 级别的转场配置.
|
||||
|
||||
Args:
|
||||
clip_transitions: 每个 clip 的转场效果名称列表
|
||||
clip_durations: 每个 clip 的时长列表(用于验证转场时长不超过片段时长)
|
||||
|
||||
Returns:
|
||||
TransitionConfig 列表
|
||||
"""
|
||||
configs: list[TransitionConfig] = []
|
||||
for i, effect in enumerate(clip_transitions):
|
||||
cfg = self.resolve_config(effect=effect)
|
||||
# 额外校验:转场时长不能超过对应 clip 时长的一半(保守限制)
|
||||
if clip_durations and i < len(clip_durations) and not cfg.is_cut:
|
||||
max_safe_duration = max(MIN_TRANSITION_DURATION, clip_durations[i] * 0.5)
|
||||
if cfg.duration > max_safe_duration:
|
||||
cfg = TransitionConfig(effect=cfg.effect, duration=max_safe_duration)
|
||||
configs.append(cfg)
|
||||
return configs
|
||||
|
||||
def build_xfade_chain(
|
||||
self,
|
||||
clip_durations: list[float],
|
||||
clip_video_labels: list[str],
|
||||
transitions: list[str],
|
||||
*,
|
||||
transition_duration: float | None = None,
|
||||
output_label: str = "outv",
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链.
|
||||
|
||||
对每步转场应用验证和降级,然后调用底层 ffmpeg_utils 构建。
|
||||
|
||||
Args:
|
||||
clip_durations: 每个片段的时长
|
||||
clip_video_labels: 每个片段的视频流标签
|
||||
transitions: 每个片段对应的转场效果
|
||||
transition_duration: 统一转场时长,None 则使用引擎默认值
|
||||
output_label: 最终输出标签
|
||||
|
||||
Returns:
|
||||
(filter_string, estimated_total_duration)
|
||||
"""
|
||||
if len(clip_durations) <= 1:
|
||||
return build_xfade_filter_chain(
|
||||
clip_durations=clip_durations,
|
||||
clip_video_labels=clip_video_labels,
|
||||
transitions=transitions,
|
||||
transition_duration=transition_duration or self._default_duration,
|
||||
output_label=output_label,
|
||||
)
|
||||
|
||||
# 解析所有转场配置
|
||||
resolved = self.resolve_clip_transitions(transitions, clip_durations)
|
||||
resolved_effects = [c.effect for c in resolved]
|
||||
|
||||
# 使用统一的时长(取各转场中最大的时长作为基准,底层会做每步钳制)
|
||||
dur = transition_duration or self._default_duration
|
||||
if not dur:
|
||||
dur = max(c.duration for c in resolved) if resolved else DEFAULT_TRANSITION_DURATION
|
||||
|
||||
# 调用底层构建
|
||||
return build_xfade_filter_chain(
|
||||
clip_durations=clip_durations,
|
||||
clip_video_labels=clip_video_labels,
|
||||
transitions=resolved_effects,
|
||||
transition_duration=dur,
|
||||
output_label=output_label,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def supported_transitions() -> list[dict[str, str]]:
|
||||
"""获取所有支持的转场效果列表(用于 API 返回给前端).
|
||||
|
||||
Returns:
|
||||
[{name, display_name, category}, ...]
|
||||
"""
|
||||
return [
|
||||
{"name": "cut", "display_name": "硬切", "category": "basic"},
|
||||
{"name": "fade", "display_name": "淡入淡出", "category": "basic"},
|
||||
{"name": "dissolve", "display_name": "溶解", "category": "basic"},
|
||||
{"name": "slideleft", "display_name": "左滑入", "category": "slide"},
|
||||
{"name": "slideright", "display_name": "右滑入", "category": "slide"},
|
||||
{"name": "slideup", "display_name": "上滑入", "category": "slide"},
|
||||
{"name": "slidedown", "display_name": "下滑入", "category": "slide"},
|
||||
{"name": "zoom", "display_name": "缩放", "category": "zoom"},
|
||||
{"name": "wipeleft", "display_name": "左擦除", "category": "wipe"},
|
||||
{"name": "wiperight", "display_name": "右擦除", "category": "wipe"},
|
||||
{"name": "wipeup", "display_name": "上擦除", "category": "wipe"},
|
||||
{"name": "wipedown", "display_name": "下擦除", "category": "wipe"},
|
||||
{"name": "circlecrop", "display_name": "圆形扩散", "category": "special"},
|
||||
{"name": "rectcrop", "display_name": "矩形扩散", "category": "special"},
|
||||
]
|
||||
@@ -1,339 +0,0 @@
|
||||
"""裁剪引擎 — 基于 FFmpeg trim/atrim 的精确帧级裁剪.
|
||||
|
||||
支持:
|
||||
- 入点出点裁剪(start_time / end_time / duration 三选二)
|
||||
- 边界自动钳制(超出素材时长自动修正,不阻断渲染)
|
||||
- 多段裁剪(一个素材裁剪出多段)
|
||||
- 音画同步(视频 + 音频同步裁剪)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 最小裁剪时长(秒),低于此值视为无效
|
||||
MIN_TRIM_DURATION = 0.1
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimConfig:
|
||||
"""裁剪配置.
|
||||
|
||||
三选二规则:start_time / end_time / duration 中必须至少给出两个,
|
||||
第三个会被自动推导。如果三个都给了,以 start_time + duration 为准。
|
||||
|
||||
边界保护:
|
||||
- start_time < 0 → 钳制到 0
|
||||
- end_time > 素材时长 → 钳制到素材时长
|
||||
- 计算出的 duration < 最小阈值 → 标记为无效
|
||||
"""
|
||||
|
||||
start_time: float = 0.0 # 入点(素材内时间,秒)
|
||||
end_time: float = 0.0 # 出点(素材内时间,秒),0 表示未指定
|
||||
duration: float = 0.0 # 裁剪时长(秒),0 表示未指定
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从字典构造,无有效裁剪参数时返回 None(不裁剪)."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
start = float(data.get("start_time", 0) or 0)
|
||||
end = float(data.get("end_time", 0) or 0)
|
||||
dur = float(data.get("duration", 0) or 0)
|
||||
|
||||
# 三个参数都没有 → 不裁剪
|
||||
if start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
# 至少有两个参数(或一个合理的 start/duration)
|
||||
# 兼容:只传了 start_time → 从 start 开始取到末尾
|
||||
# 兼容:只传了 duration → 从 0 开始取 duration
|
||||
if start > 0 and end <= 0 and dur <= 0:
|
||||
# 只有 start,取到末尾 → 这是"从某点开始"的语义,算有效
|
||||
pass
|
||||
elif dur > 0 and start <= 0 and end <= 0:
|
||||
# 只有 duration → 从开头取 duration,算有效
|
||||
pass
|
||||
elif start <= 0 and end <= 0 and dur <= 0:
|
||||
return None
|
||||
|
||||
return cls(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
def validate_and_resolve(self, asset_duration: float) -> TrimConfig:
|
||||
"""根据素材实际时长,解析并钳制裁剪参数.
|
||||
|
||||
返回一个新的 TrimConfig,其中 start_time / end_time / duration 都已确定。
|
||||
如果裁剪无效(时长为0或负数),仍返回但调用方应检查 is_valid。
|
||||
"""
|
||||
start = self.start_time
|
||||
end = self.end_time
|
||||
dur = self.duration
|
||||
|
||||
# 边界:start 不能为负
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
|
||||
# 边界:asset_duration 为 0 时保守处理(不裁剪,取全部)
|
||||
if asset_duration <= 0:
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 三选二推导
|
||||
# 判断顺序很重要:先判断需要两个显式值的组合,最后判断含默认值的
|
||||
# 情况1:start + end 都有显式值
|
||||
if start > 0 and end > 0:
|
||||
if end <= start:
|
||||
# 出点 <= 入点,无效 → 返回 start 处一个极短片段(调用方会判无效)
|
||||
return TrimConfig(start_time=start, end_time=start, duration=0.0)
|
||||
dur = end - start
|
||||
# 情况2:end + duration 都有显式值
|
||||
elif end > 0 and dur > 0:
|
||||
start = end - dur
|
||||
if start < 0:
|
||||
start = 0.0
|
||||
dur = end # 重新计算
|
||||
# 情况3:start + duration 都有值(start 可以是 0)
|
||||
elif dur > 0:
|
||||
end = start + dur
|
||||
# 情况4:只有 start → 取到素材末尾
|
||||
elif start > 0 and end <= 0 and dur <= 0:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
# 情况5:只有 end → 从开头取到 end
|
||||
elif end > 0 and start <= 0 and dur <= 0:
|
||||
start = 0.0
|
||||
dur = end
|
||||
else:
|
||||
# 都没有 → 不裁剪
|
||||
return TrimConfig(start_time=0.0, end_time=0.0, duration=0.0)
|
||||
|
||||
# 边界钳制:end 不能超过素材时长
|
||||
if end > asset_duration:
|
||||
end = asset_duration
|
||||
dur = end - start
|
||||
|
||||
# 边界钳制:start 不能超过素材时长
|
||||
if start >= asset_duration:
|
||||
start = max(0.0, asset_duration - MIN_TRIM_DURATION)
|
||||
dur = asset_duration - start
|
||||
end = asset_duration
|
||||
|
||||
# 保证 duration 不为负
|
||||
if dur < 0:
|
||||
dur = 0.0
|
||||
|
||||
return TrimConfig(start_time=start, end_time=end, duration=dur)
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""裁剪是否有效(时长大于最小阈值)."""
|
||||
return self.duration >= MIN_TRIM_DURATION
|
||||
|
||||
@property
|
||||
def is_noop(self) -> bool:
|
||||
"""是否等价于不裁剪(从0开始取全部)."""
|
||||
return self.start_time <= 0 and self.duration <= 0
|
||||
|
||||
@property
|
||||
def trim_from_start(self) -> bool:
|
||||
"""是否从开头裁剪(start_time == 0)."""
|
||||
return self.start_time <= 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimSegment:
|
||||
"""多段裁剪中的一段."""
|
||||
|
||||
segment_id: str # 段 ID(用于生成唯一标签)
|
||||
trim: TrimConfig # 裁剪配置
|
||||
order: int = 0 # 排序
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any], default_order: int = 0) -> TrimSegment:
|
||||
"""从字典构造."""
|
||||
return cls(
|
||||
segment_id=str(data.get("segment_id", "") or f"seg_{default_order}"),
|
||||
trim=TrimConfig(
|
||||
start_time=float(data.get("start_time", 0) or 0),
|
||||
end_time=float(data.get("end_time", 0) or 0),
|
||||
duration=float(data.get("duration", 0) or 0),
|
||||
),
|
||||
order=int(data.get("order", default_order)),
|
||||
)
|
||||
|
||||
|
||||
class TrimEngine:
|
||||
"""裁剪引擎 — 生成 FFmpeg trim / atrim 滤镜."""
|
||||
|
||||
@staticmethod
|
||||
def build_video_trim_filter(
|
||||
input_label: str,
|
||||
trim: TrimConfig,
|
||||
output_label: str,
|
||||
) -> str:
|
||||
"""构建视频裁剪滤镜链.
|
||||
|
||||
Args:
|
||||
input_label: 输入视频标签,如 "[0:v]"
|
||||
trim: 裁剪配置(已解析钳制)
|
||||
output_label: 输出视频标签,如 "[v0_trimmed]"
|
||||
|
||||
Returns:
|
||||
FFmpeg filter 字符串,如 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
|
||||
"""
|
||||
if trim.is_noop:
|
||||
# 不裁剪,直接直通(仅重置时间戳)
|
||||
return f"{input_label}setpts=PTS-STARTPTS{output_label}"
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# trim 滤镜参数
|
||||
trim_args: list[str] = []
|
||||
if trim.start_time > 0:
|
||||
trim_args.append(f"start={trim.start_time:.3f}")
|
||||
if trim.duration > 0:
|
||||
trim_args.append(f"duration={trim.duration:.3f}")
|
||||
elif trim.end_time > 0:
|
||||
# end 用 duration 表示(start 到 end 的时长)
|
||||
# 但 validate_and_resolve 后应该已经有 duration 了
|
||||
pass
|
||||
|
||||
parts.append(f"trim={':'.join(trim_args)}")
|
||||
parts.append("setpts=PTS-STARTPTS")
|
||||
|
||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
@staticmethod
|
||||
def build_audio_trim_filter(
|
||||
input_label: str,
|
||||
trim: TrimConfig,
|
||||
output_label: str,
|
||||
) -> str:
|
||||
"""构建音频裁剪滤镜链.
|
||||
|
||||
Args:
|
||||
input_label: 输入音频标签,如 "[0:a]"
|
||||
trim: 裁剪配置(已解析钳制)
|
||||
output_label: 输出音频标签,如 "[a0_trimmed]"
|
||||
|
||||
Returns:
|
||||
FFmpeg filter 字符串,如 "[0:a]atrim=start=10:duration=5,asetpts=PTS-STARTPTS[a0_trimmed]"
|
||||
"""
|
||||
if trim.is_noop:
|
||||
return f"{input_label}asetpts=PTS-STARTPTS{output_label}"
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
trim_args: list[str] = []
|
||||
if trim.start_time > 0:
|
||||
trim_args.append(f"start={trim.start_time:.3f}")
|
||||
if trim.duration > 0:
|
||||
trim_args.append(f"duration={trim.duration:.3f}")
|
||||
|
||||
parts.append(f"atrim={':'.join(trim_args)}")
|
||||
parts.append("asetpts=PTS-STARTPTS")
|
||||
|
||||
filter_str = f"{input_label}{','.join(parts)}{output_label}"
|
||||
return filter_str
|
||||
|
||||
@staticmethod
|
||||
def resolve_segments(
|
||||
segments: list[TrimSegment],
|
||||
asset_duration: float,
|
||||
) -> list[TrimSegment]:
|
||||
"""解析并钳制多段裁剪配置,过滤无效段.
|
||||
|
||||
Args:
|
||||
segments: 原始段列表
|
||||
asset_duration: 素材实际时长
|
||||
|
||||
Returns:
|
||||
解析后的有效段列表,按 order 排序
|
||||
"""
|
||||
resolved: list[TrimSegment] = []
|
||||
for i, seg in enumerate(segments):
|
||||
resolved_trim = seg.trim.validate_and_resolve(asset_duration)
|
||||
if not resolved_trim.is_valid:
|
||||
logger.warning("裁剪段无效,跳过: segment_id=%s duration=%.3f", seg.segment_id, resolved_trim.duration)
|
||||
continue
|
||||
resolved.append(
|
||||
TrimSegment(
|
||||
segment_id=seg.segment_id,
|
||||
trim=resolved_trim,
|
||||
order=seg.order if seg.order >= 0 else i,
|
||||
)
|
||||
)
|
||||
|
||||
resolved.sort(key=lambda s: s.order)
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def parse_segments_from_config(config: dict[str, Any] | None) -> list[TrimSegment]:
|
||||
"""从 clip config 中解析多段裁剪配置.
|
||||
|
||||
config 中支持:
|
||||
- trim_segments: [ {segment_id, start_time, end_time, duration, order}, ... ]
|
||||
- trim_start / trim_end / trim_duration: 单段裁剪(兼容旧格式)
|
||||
"""
|
||||
if not config:
|
||||
return []
|
||||
|
||||
# 优先解析多段
|
||||
raw_segments = config.get("trim_segments", [])
|
||||
if raw_segments and isinstance(raw_segments, list):
|
||||
segments = []
|
||||
for i, raw in enumerate(raw_segments):
|
||||
if isinstance(raw, dict):
|
||||
segments.append(TrimSegment.from_dict(raw, default_order=i))
|
||||
return segments
|
||||
|
||||
# 单段裁剪兼容:从 trim_start/trim_end/trim_duration 构造
|
||||
has_single = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
||||
if has_single:
|
||||
seg = TrimSegment(
|
||||
segment_id="main",
|
||||
trim=TrimConfig(
|
||||
start_time=float(config.get("trim_start", 0) or 0),
|
||||
end_time=float(config.get("trim_end", 0) or 0),
|
||||
duration=float(config.get("trim_duration", 0) or 0),
|
||||
),
|
||||
order=0,
|
||||
)
|
||||
return [seg]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_trim_from_clip_config(config: dict[str, Any] | None) -> TrimConfig | None:
|
||||
"""从 clip config 中提取单段裁剪配置.
|
||||
|
||||
兼容以下字段名:
|
||||
- trim_start / trim_end / trim_duration
|
||||
- start_time / end_time / duration(在 trim 子字典里)
|
||||
"""
|
||||
if not config:
|
||||
return None
|
||||
|
||||
# trim 子字典
|
||||
if "trim" in config and isinstance(config["trim"], dict):
|
||||
return TrimConfig.from_dict(config["trim"])
|
||||
|
||||
# 扁平字段
|
||||
has_any = any(k in config for k in ("trim_start", "trim_end", "trim_duration"))
|
||||
if not has_any:
|
||||
return None
|
||||
|
||||
data = {
|
||||
"start_time": config.get("trim_start", 0),
|
||||
"end_time": config.get("trim_end", 0),
|
||||
"duration": config.get("trim_duration", 0),
|
||||
}
|
||||
return TrimConfig.from_dict(data)
|
||||
@@ -1,275 +0,0 @@
|
||||
"""TTS 配音引擎 — 集成到统一渲染管道的配音能力.
|
||||
|
||||
负责:
|
||||
- 根据 TtsConfig 生成配音音频
|
||||
- 字幕联动:按字幕片段分段合成,自动对齐时间轴
|
||||
- 整段配音:整段文本生成一条音频
|
||||
- 失败降级:TTS 失败不阻断渲染
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
from packages.ports.tts_service import TtsError, TtsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceoverSegment:
|
||||
"""配音片段.
|
||||
|
||||
Attributes:
|
||||
text: 文本内容
|
||||
start_time: 开始时间(秒)
|
||||
end_time: 结束时间(秒)
|
||||
audio_path: 合成后的音频文件路径
|
||||
duration: 音频实际时长
|
||||
"""
|
||||
|
||||
text: str
|
||||
start_time: float = 0.0
|
||||
end_time: float = 0.0
|
||||
audio_path: Path | None = None
|
||||
duration: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceoverResult:
|
||||
"""配音结果.
|
||||
|
||||
Attributes:
|
||||
success: 是否成功
|
||||
segments: 配音片段列表
|
||||
total_duration: 总时长
|
||||
error_message: 错误信息(失败时)
|
||||
"""
|
||||
|
||||
success: bool = False
|
||||
segments: list[VoiceoverSegment] = field(default_factory=list)
|
||||
total_duration: float = 0.0
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
class TtsEngine:
|
||||
"""TTS 配音引擎.
|
||||
|
||||
封装 TtsService 调用,支持:
|
||||
- 整段配音
|
||||
- 字幕联动配音
|
||||
- 失败降级
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tts_service: TtsService,
|
||||
work_dir: Path,
|
||||
) -> None:
|
||||
self._tts = tts_service
|
||||
self._work_dir = work_dir
|
||||
self._work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def generate_full_voiceover(
|
||||
self,
|
||||
config: TtsConfig,
|
||||
*,
|
||||
total_duration: float = 0.0,
|
||||
) -> VoiceoverResult:
|
||||
"""生成整段配音.
|
||||
|
||||
Args:
|
||||
config: TTS 配置
|
||||
total_duration: 视频总时长(用于调整配音速度适配)
|
||||
|
||||
Returns:
|
||||
配音结果
|
||||
"""
|
||||
if not config.enabled or not config.text.strip():
|
||||
return VoiceoverResult(success=False, error_message="配音未启用或文本为空")
|
||||
|
||||
try:
|
||||
output_path = self._work_dir / "voiceover_full.wav"
|
||||
|
||||
audio_path = self._tts.synthesize(
|
||||
text=config.text,
|
||||
voice_id=config.voice_id,
|
||||
speed=config.speed,
|
||||
pitch=config.pitch,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
# 探测实际时长
|
||||
duration = self._probe_duration(audio_path)
|
||||
|
||||
segment = VoiceoverSegment(
|
||||
text=config.text,
|
||||
start_time=0.0,
|
||||
end_time=duration,
|
||||
audio_path=audio_path,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
return VoiceoverResult(
|
||||
success=True,
|
||||
segments=[segment],
|
||||
total_duration=duration,
|
||||
)
|
||||
|
||||
except TtsError as e:
|
||||
logger.warning("TTS 整段配音失败,降级跳过: %s", e)
|
||||
return VoiceoverResult(success=False, error_message=str(e))
|
||||
except Exception as e:
|
||||
logger.warning("TTS 整段配音异常,降级跳过: %s", e)
|
||||
return VoiceoverResult(success=False, error_message=str(e))
|
||||
|
||||
def generate_subtitle_voiceover(
|
||||
self,
|
||||
config: TtsConfig,
|
||||
subtitles: list[dict[str, Any]],
|
||||
) -> VoiceoverResult:
|
||||
"""根据字幕生成配音(字幕联动).
|
||||
|
||||
每个字幕片段独立合成,按字幕时间轴对齐。
|
||||
|
||||
Args:
|
||||
config: TTS 配置
|
||||
subtitles: 字幕列表,每项含 text/start_time/end_time
|
||||
|
||||
Returns:
|
||||
配音结果
|
||||
"""
|
||||
if not config.enabled:
|
||||
return VoiceoverResult(success=False, error_message="配音未启用")
|
||||
|
||||
if not subtitles:
|
||||
return VoiceoverResult(success=False, error_message="字幕为空")
|
||||
|
||||
segments: list[VoiceoverSegment] = []
|
||||
total_duration = 0.0
|
||||
|
||||
for i, sub in enumerate(subtitles):
|
||||
text = sub.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
start_time = float(sub.get("start_time", 0))
|
||||
end_time = float(sub.get("end_time", 0))
|
||||
target_duration = max(0.1, end_time - start_time)
|
||||
|
||||
try:
|
||||
# 计算适配时长所需语速:让配音时长 ≈ 字幕时长
|
||||
estimated = self._tts.estimate_duration(text, speed=config.speed)
|
||||
adjusted_speed = config.speed
|
||||
if estimated > 0 and target_duration > 0:
|
||||
# 按目标时长调整语速,限制在 0.5~2.0 范围内
|
||||
speed_factor = estimated / target_duration
|
||||
adjusted_speed = max(0.5, min(2.0, config.speed * speed_factor))
|
||||
|
||||
output_path = self._work_dir / f"voiceover_seg_{i:03d}.wav"
|
||||
|
||||
audio_path = self._tts.synthesize(
|
||||
text=text,
|
||||
voice_id=config.voice_id,
|
||||
speed=adjusted_speed,
|
||||
pitch=config.pitch,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
actual_duration = self._probe_duration(audio_path)
|
||||
|
||||
segment = VoiceoverSegment(
|
||||
text=text,
|
||||
start_time=start_time,
|
||||
end_time=start_time + actual_duration,
|
||||
audio_path=audio_path,
|
||||
duration=actual_duration,
|
||||
)
|
||||
segments.append(segment)
|
||||
total_duration = max(total_duration, start_time + actual_duration)
|
||||
|
||||
except TtsError as e:
|
||||
logger.warning("TTS 字幕片段 %d 合成失败,跳过: %s", i, e)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning("TTS 字幕片段 %d 异常,跳过: %s", i, e)
|
||||
continue
|
||||
|
||||
if not segments:
|
||||
return VoiceoverResult(success=False, error_message="所有字幕片段合成失败")
|
||||
|
||||
return VoiceoverResult(
|
||||
success=True,
|
||||
segments=segments,
|
||||
total_duration=total_duration,
|
||||
)
|
||||
|
||||
def build_audio_mix_filter(
|
||||
self,
|
||||
result: VoiceoverResult,
|
||||
*,
|
||||
video_duration: float,
|
||||
base_label: str = "0:a",
|
||||
) -> tuple[str, list[Path]]:
|
||||
"""构建配音混音滤镜.
|
||||
|
||||
将配音片段按时间轴排列,生成 amix 混入。
|
||||
|
||||
Args:
|
||||
result: 配音结果
|
||||
video_duration: 视频总时长
|
||||
base_label: 基础音轨标签
|
||||
|
||||
Returns:
|
||||
(filter_complex 字符串, 配音音频文件列表)
|
||||
"""
|
||||
if not result.success or not result.segments:
|
||||
return "", []
|
||||
|
||||
filter_parts: list[str] = []
|
||||
audio_files: list[Path] = []
|
||||
delay_labels: list[str] = []
|
||||
|
||||
for i, seg in enumerate(result.segments):
|
||||
if seg.audio_path is None or not seg.audio_path.exists():
|
||||
continue
|
||||
|
||||
audio_files.append(seg.audio_path)
|
||||
seg_label = f"v{i}"
|
||||
|
||||
# 音量调整
|
||||
# 用 adelay 延迟到字幕开始时间
|
||||
delay_ms = int(max(0, int(seg.start_time * 1000)))
|
||||
filter_parts.append(f"[{i}:a]adelay={delay_ms}:all=1,volume=0.8[{seg_label}]")
|
||||
delay_labels.append(f"[{seg_label}]")
|
||||
|
||||
if not delay_labels:
|
||||
return "", []
|
||||
|
||||
# 所有片段 concat 成一条配音音轨(用 amix 叠加多个延时后的片段
|
||||
mix_inputs = "".join(delay_labels)
|
||||
n_inputs = len(delay_labels)
|
||||
tts_label = "tts_mixed"
|
||||
|
||||
if n_inputs == 1:
|
||||
# 单个片段直接用
|
||||
filter_parts.append(f"{delay_labels[0]}[{tts_label}]")
|
||||
else:
|
||||
# 多个片段 amix 叠加
|
||||
filter_parts.append(f"{mix_inputs}amix=inputs={n_inputs}:duration=longest[{tts_label}]")
|
||||
|
||||
return ";".join(filter_parts), audio_files
|
||||
|
||||
def _probe_duration(self, audio_path: Path) -> float:
|
||||
"""探测音频时长."""
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import probe_duration
|
||||
|
||||
return probe_duration(audio_path)
|
||||
except Exception:
|
||||
# 探测失败,按文件名估算
|
||||
return 0.0
|
||||
@@ -28,31 +28,19 @@ 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,
|
||||
DEFAULT_OUTPUT_WIDTH,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
FFMPEG_BIN,
|
||||
build_xfade_filter_chain,
|
||||
probe_duration,
|
||||
probe_video_info,
|
||||
run_ffmpeg,
|
||||
)
|
||||
from video_processing.intro_outro_engine import IntroOutroConfig, IntroOutroEngine
|
||||
from video_processing.pip_engine import PiPConfig, PiPEngine, PiPLayerConfig
|
||||
from video_processing.render_audio import RenderContext, merge_audio_video, mix_audio
|
||||
from video_processing.render_subtitles import generate_ass_subtitles
|
||||
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
|
||||
from video_processing.speed_engine import SpeedConfig, SpeedEngine
|
||||
from video_processing.sticker_engine import StickerEngine
|
||||
from video_processing.subtitle_generator import generate_ass_from_timeline
|
||||
from video_processing.transition_engine import TransitionEngine
|
||||
from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_from_clip_config
|
||||
from video_processing.tts_engine import TtsEngine
|
||||
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
|
||||
|
||||
from packages.domain.tts_config import TtsConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -72,13 +60,10 @@ class ResolvedClip:
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0 # 0 表示使用素材完整时长
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0 # 0 表示使用全局默认值
|
||||
playback_speed: float = 1.0 # 0 或 1.0 表示原速
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 运行时填充
|
||||
actual_duration: float = 0.0 # 素材实际时长(probe 后填充)
|
||||
trim_config: TrimConfig | None = None # 解析后的裁剪配置(运行时填充)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -173,8 +158,6 @@ class UnifiedRenderService:
|
||||
output_height: int = DEFAULT_OUTPUT_HEIGHT,
|
||||
output_fps: int = DEFAULT_FPS,
|
||||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||||
bgm_path: str | None = None, # BGM 本地文件路径
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -184,10 +167,6 @@ class UnifiedRenderService:
|
||||
self.output_height = output_height
|
||||
self.output_fps = output_fps
|
||||
self.transition_duration = transition_duration
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""执行渲染,返回 RenderResult.
|
||||
@@ -221,27 +200,18 @@ class UnifiedRenderService:
|
||||
# 3. 计算视频总时长(用于字幕显示时长)
|
||||
video_duration = self._estimate_total_duration(layers)
|
||||
|
||||
# 3.5 TTS 配音生成(如果配置了)
|
||||
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
|
||||
|
||||
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
|
||||
ass_path = self._maybe_generate_ass(video_duration)
|
||||
|
||||
# 4.5 解析画中画配置
|
||||
pip_config = PiPConfig.from_dict((self.plan.config or {}).get("pip_config"))
|
||||
pip_sources = self._resolve_pip_sources(pip_config) if pip_config.enabled else []
|
||||
has_pip = len(pip_sources) > 0
|
||||
|
||||
# 灰度埋点:开始渲染
|
||||
layer_roles = [layer.role for layer in layers]
|
||||
clip_counts = {layer.role: len(layer.clips) for layer in layers}
|
||||
logger.info(
|
||||
"[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s pip_layers=%d",
|
||||
"[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s",
|
||||
self.plan.id,
|
||||
len(resolved),
|
||||
layer_roles,
|
||||
clip_counts,
|
||||
len(pip_sources),
|
||||
)
|
||||
|
||||
# 5. 视频主渲染
|
||||
@@ -249,8 +219,7 @@ class UnifiedRenderService:
|
||||
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
|
||||
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
|
||||
|
||||
# 有画中画时不走直通(需要额外图层叠加)
|
||||
is_pass_through = self._can_use_pass_through(layers) and not has_pip
|
||||
is_pass_through = self._can_use_pass_through(layers)
|
||||
pass_through_has_audio = False
|
||||
used_stream_copy = False
|
||||
|
||||
@@ -276,11 +245,6 @@ class UnifiedRenderService:
|
||||
)
|
||||
else:
|
||||
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
|
||||
|
||||
# 追加画中画滤镜
|
||||
if has_pip:
|
||||
filter_complex, input_args = self._append_pip_filters(filter_complex, input_args, pip_sources)
|
||||
|
||||
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
|
||||
|
||||
t_video_end = time.time()
|
||||
@@ -301,62 +265,9 @@ class UnifiedRenderService:
|
||||
if is_pass_through:
|
||||
# 直通场景已在一次调用中完成视频+音频
|
||||
has_audio = pass_through_has_audio
|
||||
# 直通模式下也支持 BGM 混音:提取音频 → 混 BGM → 合并回视频
|
||||
if self.bgm_path and pass_through_has_audio:
|
||||
config = self.plan.config or {}
|
||||
bgm_config = config.get("bgm", {}) or {}
|
||||
if bgm_config.get("enabled", False):
|
||||
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
|
||||
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
|
||||
|
||||
bgm_cfg = BGMConfig.from_config_dict(self.bgm_path, bgm_config)
|
||||
# 从直通输出中提取音频
|
||||
main_audio_path = self.work_dir / f"pass_through_audio_{self.plan.id}.aac"
|
||||
extract_cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(output_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
str(main_audio_path),
|
||||
]
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(extract_cmd)
|
||||
final_audio = mix_bgm_with_main(ctx, main_audio_path, bgm_cfg, video_duration)
|
||||
# 合并回视频
|
||||
|
||||
bgm_output = self.work_dir / f"rendered_{self.plan.id}_bgm.mp4"
|
||||
merge_audio_video(ctx, output_path, final_audio, bgm_output)
|
||||
output_path = bgm_output
|
||||
logger.info("[unified-render] pass-through BGM mix done: plan_id=%s", self.plan.id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[unified-render] pass-through BGM mix failed, skipping: plan_id=%s", self.plan.id
|
||||
)
|
||||
else:
|
||||
config = self.plan.config or {}
|
||||
bgm_config = config.get("bgm", {}) or {}
|
||||
audio_tracks_config = config.get("audio_tracks") or {}
|
||||
noise_reduction_config = config.get("audio_noise_reduction")
|
||||
ctx = RenderContext(
|
||||
work_dir=self.work_dir,
|
||||
plan_id=self.plan.id,
|
||||
noise_reduction_config=noise_reduction_config,
|
||||
)
|
||||
audio_path = mix_audio(
|
||||
ctx,
|
||||
layers,
|
||||
video_duration,
|
||||
bgm_path=self.bgm_path,
|
||||
bgm_config=bgm_config,
|
||||
audio_tracks_config=audio_tracks_config,
|
||||
)
|
||||
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
|
||||
audio_path = mix_audio(ctx, layers, video_duration)
|
||||
t_audio_end = time.time()
|
||||
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
|
||||
has_audio = audio_path is not None
|
||||
@@ -377,85 +288,6 @@ class UnifiedRenderService:
|
||||
# 8. 探测输出
|
||||
duration, file_size, width, height = self._probe_output(output_path)
|
||||
|
||||
# 9. 片头片尾拼接(后处理)
|
||||
intro_outro_config = IntroOutroConfig.from_dict((self.plan.config or {}).get("intro_outro"))
|
||||
if intro_outro_config.has_intro or intro_outro_config.has_outro:
|
||||
io_valid, io_err = intro_outro_config.validate()
|
||||
if io_valid:
|
||||
final_with_io = self.work_dir / f"rendered_{self.plan.id}_with_io.mp4"
|
||||
intro_path = None
|
||||
outro_path = None
|
||||
|
||||
# 生成片头
|
||||
if intro_outro_config.has_intro:
|
||||
intro_path = self.work_dir / f"intro_{self.plan.id}.mp4"
|
||||
intro_ok = False
|
||||
if intro_outro_config.intro_type == "video":
|
||||
import shutil
|
||||
|
||||
src = Path(intro_outro_config.intro_video_path)
|
||||
if src.exists():
|
||||
shutil.copy2(src, intro_path)
|
||||
intro_ok = True
|
||||
else:
|
||||
logger.warning("片头视频不存在,跳过片头: %s", src)
|
||||
elif intro_outro_config.intro_type == "text":
|
||||
intro_ok = IntroOutroEngine.generate_text_intro(
|
||||
intro_path,
|
||||
intro_outro_config,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
self.output_fps,
|
||||
)
|
||||
|
||||
if not intro_ok:
|
||||
intro_path = None
|
||||
|
||||
# 生成片尾
|
||||
if intro_outro_config.has_outro:
|
||||
outro_path = self.work_dir / f"outro_{self.plan.id}.mp4"
|
||||
outro_ok = False
|
||||
if intro_outro_config.outro_type == "video":
|
||||
import shutil
|
||||
|
||||
src = Path(intro_outro_config.outro_video_path)
|
||||
if src.exists():
|
||||
shutil.copy2(src, outro_path)
|
||||
outro_ok = True
|
||||
else:
|
||||
logger.warning("片尾视频不存在,跳过片尾: %s", src)
|
||||
elif intro_outro_config.outro_type in ("text", "follow"):
|
||||
outro_ok = IntroOutroEngine.generate_text_outro(
|
||||
outro_path,
|
||||
intro_outro_config,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
self.output_fps,
|
||||
)
|
||||
|
||||
if not outro_ok:
|
||||
outro_path = None
|
||||
|
||||
# 拼接
|
||||
if intro_path or outro_path:
|
||||
concat_ok = IntroOutroEngine.concat_with_intro_outro(
|
||||
output_path,
|
||||
intro_path,
|
||||
outro_path,
|
||||
final_with_io,
|
||||
transition_duration=intro_outro_config.transition_duration,
|
||||
transition_effect=intro_outro_config.transition_effect,
|
||||
)
|
||||
if concat_ok and final_with_io.exists():
|
||||
output_path = final_with_io
|
||||
# 重新探测
|
||||
duration, file_size, width, height = self._probe_output(output_path)
|
||||
logger.info("[unified-render] 片头片尾拼接完成: plan_id=%s", self.plan.id)
|
||||
else:
|
||||
logger.warning("[unified-render] 片头片尾拼接失败,使用原视频: plan_id=%s", self.plan.id)
|
||||
else:
|
||||
logger.warning("[unified-render] 片头片尾配置无效,跳过: %s", io_err)
|
||||
|
||||
t_total = int((time.time() - t_start) * 1000)
|
||||
logger.info(
|
||||
"[unified-render] render done: plan_id=%s total_ms=%d video_ms=%d audio_ms=%d "
|
||||
@@ -497,7 +329,7 @@ class UnifiedRenderService:
|
||||
if not main_layer or not main_layer.clips:
|
||||
return 0.0
|
||||
|
||||
total = sum(UnifiedRenderService._clip_adjusted_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)
|
||||
@@ -509,10 +341,6 @@ class UnifiedRenderService:
|
||||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||||
"""根据 plan.config 生成 ASS 字幕文件。
|
||||
|
||||
支持两种字幕模式:
|
||||
1. 静态字幕 — title/subtitle 配置了 text 时,生成整段静态字幕
|
||||
2. ASR 自动字幕 — subtitle.auto_generated=true 时,从音频自动识别生成时间轴字幕
|
||||
|
||||
Returns:
|
||||
ASS 文件路径,没有字幕时返回 None
|
||||
"""
|
||||
@@ -524,46 +352,15 @@ class UnifiedRenderService:
|
||||
subtitle_enabled = subtitle_cfg.get("enabled", True)
|
||||
title_text = title_cfg.get("text", "") or ""
|
||||
subtitle_text = subtitle_cfg.get("text", "") or ""
|
||||
auto_generated = subtitle_cfg.get("auto_generated", False)
|
||||
|
||||
has_title = title_enabled and bool(title_text.strip())
|
||||
has_static_subtitle = subtitle_enabled and bool(subtitle_text.strip())
|
||||
has_auto_subtitle = subtitle_enabled and auto_generated and self.asr_service is not None
|
||||
has_subtitle = subtitle_enabled and bool(subtitle_text.strip())
|
||||
|
||||
if not has_title and not has_static_subtitle and not has_auto_subtitle:
|
||||
if not has_title and not has_subtitle:
|
||||
return None
|
||||
|
||||
ass_path = self.work_dir / f"subtitles_{self.plan.id}.ass"
|
||||
|
||||
# ASR 自动字幕模式
|
||||
if has_auto_subtitle:
|
||||
try:
|
||||
timeline = self._generate_asr_subtitles(video_duration, subtitle_cfg)
|
||||
if timeline and timeline.segments:
|
||||
generate_ass_from_timeline(
|
||||
ass_path,
|
||||
timeline,
|
||||
video_width=self.output_width,
|
||||
video_height=self.output_height,
|
||||
subtitle_config=subtitle_cfg,
|
||||
)
|
||||
logger.info(
|
||||
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
|
||||
self.plan.id,
|
||||
timeline.segment_count,
|
||||
video_duration,
|
||||
)
|
||||
return ass_path
|
||||
else:
|
||||
# ASR 无结果,不生成字幕
|
||||
logger.info("ASR自动字幕无识别结果,跳过字幕: plan_id=%s", self.plan.id)
|
||||
return None
|
||||
except Exception:
|
||||
# ASR 失败降级:不生成字幕,不阻断主流程
|
||||
logger.warning("ASR自动字幕生成失败,跳过字幕", exc_info=True)
|
||||
return None
|
||||
|
||||
# 静态字幕模式(原有逻辑)
|
||||
generate_ass_subtitles(
|
||||
ass_path,
|
||||
video_width=self.output_width,
|
||||
@@ -579,166 +376,11 @@ class UnifiedRenderService:
|
||||
"生成字幕: plan_id=%s title=%s subtitle=%s ass=%s",
|
||||
self.plan.id,
|
||||
has_title,
|
||||
has_static_subtitle,
|
||||
has_subtitle,
|
||||
ass_path,
|
||||
)
|
||||
return ass_path
|
||||
|
||||
def _generate_asr_subtitles(self, video_duration: float, subtitle_cfg: dict) -> Any: # SubtitleTimeline
|
||||
"""从视频素材音频中自动识别生成字幕时间轴。
|
||||
|
||||
MVP 版本:使用第一个有音频的素材做ASR,然后按比例映射到整个视频时长。
|
||||
后续优化:支持多片段拼接后的完整音频ASR。
|
||||
"""
|
||||
from packages.domain.subtitle import SubtitleTimeline
|
||||
|
||||
# 找第一个有本地路径的素材
|
||||
first_asset_path = None
|
||||
for clip in self.clips:
|
||||
asset_id = getattr(clip, "asset_id", None)
|
||||
if asset_id and asset_id in self.asset_path_map:
|
||||
first_asset_path = self.asset_path_map[asset_id]
|
||||
break
|
||||
|
||||
if first_asset_path is None:
|
||||
logger.warning("ASR字幕生成失败:找不到可用素材音频")
|
||||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
|
||||
# 提取素材音频为 wav(16kHz单声道,ASR友好格式)
|
||||
audio_path = self.work_dir / f"asr_audio_{self.plan.id}.wav"
|
||||
try:
|
||||
self._extract_audio(first_asset_path, audio_path)
|
||||
except Exception:
|
||||
logger.warning("ASR音频提取失败", exc_info=True)
|
||||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
|
||||
if not audio_path.exists():
|
||||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||||
|
||||
# 调用 ASR 服务
|
||||
language = subtitle_cfg.get("language", "") or None
|
||||
timeline = self.asr_service.transcribe(
|
||||
audio_path,
|
||||
language=language,
|
||||
with_word_timestamps=True,
|
||||
)
|
||||
|
||||
# 字幕后处理:合并短片段 + 拆分长片段
|
||||
min_chars = int(subtitle_cfg.get("min_chars_per_segment", 8))
|
||||
max_chars = int(subtitle_cfg.get("max_chars_per_line", 20))
|
||||
|
||||
if timeline.segments:
|
||||
timeline = timeline.merge_short_segments(min_chars=min_chars)
|
||||
timeline = timeline.split_long_segments(max_chars=max_chars)
|
||||
|
||||
# 清理临时音频文件
|
||||
try:
|
||||
audio_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return timeline
|
||||
|
||||
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
|
||||
"""从视频中提取音频为16kHz单声道wav(ASR友好格式)。"""
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=120)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"音频提取失败: {str(e)[:200]}") from e
|
||||
|
||||
def _maybe_add_voiceover_layer(
|
||||
self,
|
||||
layers: list[RenderLayer],
|
||||
*,
|
||||
video_duration: float,
|
||||
) -> bool:
|
||||
"""根据 plan.config 生成 TTS 配音,加到 audio 图层.
|
||||
|
||||
Returns:
|
||||
是否成功添加了配音音轨
|
||||
"""
|
||||
config = self.plan.config or {}
|
||||
tts_cfg = config.get("tts", {}) or {}
|
||||
|
||||
tts_config = TtsConfig.parse(tts_cfg)
|
||||
if not tts_config.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
from apps.worker.services.tts_service_factory import get_tts_service
|
||||
|
||||
tts_service = get_tts_service()
|
||||
tts_engine = TtsEngine(tts_service, self.work_dir / "tts")
|
||||
|
||||
# 整段配音模式
|
||||
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
|
||||
|
||||
if not result.success or not result.segments:
|
||||
logger.warning("TTS 配音生成失败,跳过: %s", result.error_message)
|
||||
return False
|
||||
|
||||
# 获取主音轨图层(用于判断 replace 模式下是否静音原音)
|
||||
# 这里只处理混音添加,replace 模式在外部处理
|
||||
|
||||
# 找到或创建 audio 图层
|
||||
audio_layer = None
|
||||
for layer in layers:
|
||||
if layer.role == "audio":
|
||||
audio_layer = layer
|
||||
break
|
||||
|
||||
if audio_layer is None:
|
||||
from video_processing.unified_render_service import _LAYER_Z_INDEX # type: ignore
|
||||
|
||||
z_index = _LAYER_Z_INDEX.get("audio", 2)
|
||||
audio_layer = RenderLayer(role="audio", z_index=z_index)
|
||||
layers.append(audio_layer)
|
||||
|
||||
# 把配音片段作为 audio clip 加入
|
||||
for seg in result.segments:
|
||||
if seg.audio_path is None:
|
||||
continue
|
||||
vo_clip = ResolvedClip(
|
||||
clip_id=f"tts_{seg.start_time:.3f}",
|
||||
asset_id="tts_voiceover",
|
||||
local_path=seg.audio_path,
|
||||
clip_type="audio",
|
||||
order=len(audio_layer.clips),
|
||||
start_time=seg.start_time,
|
||||
duration=seg.duration,
|
||||
config={"volume": tts_config.volume, "tts": True},
|
||||
actual_duration=seg.duration,
|
||||
)
|
||||
audio_layer.clips.append(vo_clip)
|
||||
|
||||
logger.info(
|
||||
"TTS 配音已添加: plan_id=%s voice_id=%s segments=%d total_%.2fs",
|
||||
self.plan.id,
|
||||
tts_config.voice_id,
|
||||
len(result.segments),
|
||||
result.total_duration,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("TTS 配音异常,跳过: %s", e)
|
||||
return False
|
||||
|
||||
def _can_use_pass_through(self, layers: list[RenderLayer]) -> bool:
|
||||
"""判断是否可以走直通优化路径。
|
||||
|
||||
@@ -746,7 +388,6 @@ class UnifiedRenderService:
|
||||
1. 只有 1 个图层
|
||||
2. 该图层是视频图层(main/broll/background),不是 overlay/corner_voice/audio
|
||||
3. 该图层只有 1 个 clip(无转场需求)
|
||||
4. 没有贴纸(贴纸需要 filter_complex 或额外输入)
|
||||
"""
|
||||
if len(layers) != 1:
|
||||
return False
|
||||
@@ -755,10 +396,6 @@ class UnifiedRenderService:
|
||||
return False
|
||||
if len(layer.clips) != 1:
|
||||
return False
|
||||
# 有贴纸时禁用直通(图片贴纸需要额外输入,统一走 filter_complex)
|
||||
plan_config = getattr(self.plan, "config", None) or {}
|
||||
if isinstance(plan_config, dict) and plan_config.get("stickers"):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _can_use_stream_copy(
|
||||
@@ -971,13 +608,6 @@ class UnifiedRenderService:
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 倒放滤镜
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and reverse_config.reverse_video:
|
||||
reverse_filter = ReverseEngine.build_video_filter(reverse_config, duration=effective_duration)
|
||||
if reverse_filter:
|
||||
filters.append(reverse_filter)
|
||||
|
||||
# scale + crop(铺满裁剪)
|
||||
if role in ("overlay", "corner_voice"):
|
||||
pip_w = int(self.output_width * _PIP_SCALE)
|
||||
@@ -988,25 +618,6 @@ class UnifiedRenderService:
|
||||
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)
|
||||
# chroma key 绿幕抠像
|
||||
try:
|
||||
from video_processing.chroma_key_engine import ChromaKeyConfig, ChromaKeyEngine
|
||||
|
||||
ck_config = ChromaKeyConfig.from_dict(clip.config.get("chroma_key"))
|
||||
if ck_config.has_effect():
|
||||
ck_engine = ChromaKeyEngine(ck_config)
|
||||
ck_full = ck_engine.build_filter("[in]", "[out]")
|
||||
ck_filter_part = ck_full[len("[in]") : -len("[out]")]
|
||||
filters.append(ck_filter_part)
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] chroma key 直通模式应用失败,跳过: %s", e)
|
||||
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
filters.append(f"fps={self.output_fps}")
|
||||
filters.append("format=yuv420p")
|
||||
@@ -1046,33 +657,8 @@ class UnifiedRenderService:
|
||||
# background 以外的视频素材,默认带音频
|
||||
has_audio = role != "background"
|
||||
if has_audio:
|
||||
# 检查是否需要音频降噪
|
||||
af_parts: list[str] = []
|
||||
try:
|
||||
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
|
||||
|
||||
plan_config = getattr(self.plan, "config", {}) or {}
|
||||
nr_config = NoiseReductionConfig.from_dict(plan_config.get("audio_noise_reduction"))
|
||||
if nr_config.has_effect():
|
||||
nr_engine = NoiseReductionEngine(nr_config)
|
||||
nr_full = nr_engine.build_filter("[in]", "[out]")
|
||||
nr_filter_part = nr_full[len("[in]") : -len("[out]")]
|
||||
af_parts.append(nr_filter_part)
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
|
||||
|
||||
if af_parts:
|
||||
command.extend(["-af", ",".join(af_parts)])
|
||||
|
||||
command.extend(["-c:a", "aac", "-b:a", "128k"])
|
||||
|
||||
# 音频倒放
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and reverse_config.reverse_audio:
|
||||
af_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
|
||||
if af_filter:
|
||||
command.extend(["-af", af_filter])
|
||||
|
||||
# 统一截断时长(同时作用于视频和音频)
|
||||
if final_duration > 0:
|
||||
command.extend(["-t", f"{final_duration:.3f}"])
|
||||
@@ -1107,7 +693,6 @@ class UnifiedRenderService:
|
||||
"""将 EditPlanClip 列表解析为 ResolvedClip 列表。
|
||||
|
||||
跳过 asset_id 为空或在 asset_path_map 中找不到的片段。
|
||||
支持多段裁剪:一个 clip 配置了 trim_segments 时会展开为多个 ResolvedClip。
|
||||
"""
|
||||
resolved: list[ResolvedClip] = []
|
||||
for clip in self.clips:
|
||||
@@ -1127,77 +712,17 @@ class UnifiedRenderService:
|
||||
except Exception:
|
||||
actual_duration = clip.duration or 5.0
|
||||
|
||||
# 检查是否有多段裁剪配置
|
||||
clip_config = clip.config or {}
|
||||
trim_segments = TrimEngine.parse_segments_from_config(clip_config)
|
||||
|
||||
if trim_segments and len(trim_segments) > 1:
|
||||
# 多段裁剪:展开为多个 clip
|
||||
resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration)
|
||||
for i, seg in enumerate(resolved_segments):
|
||||
# 每个段生成一个独立的 ResolvedClip
|
||||
seg_clip_id = f"{clip.id}_seg_{seg.segment_id}"
|
||||
seg_order = clip.order + seg.order * 0.001 + i * 0.0001 # 保持排序
|
||||
seg_start = seg.trim.start_time
|
||||
seg_duration = seg.trim.duration
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=seg_clip_id,
|
||||
asset_id=asset_id,
|
||||
local_path=local_path,
|
||||
clip_type=clip.clip_type,
|
||||
order=seg_order,
|
||||
start_time=seg_start,
|
||||
duration=seg_duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
config={**clip_config, "_segment_id": seg.segment_id},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=seg.trim,
|
||||
)
|
||||
resolved.append(rc)
|
||||
continue
|
||||
|
||||
# 单段裁剪(或无裁剪)
|
||||
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
|
||||
trim_config = extract_trim_from_clip_config(clip_config)
|
||||
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
|
||||
# 用旧字段构造
|
||||
trim_config = TrimConfig(
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
)
|
||||
|
||||
# 钳制到实际素材时长
|
||||
effective_trim: TrimConfig | None = None
|
||||
final_start = clip.start_time
|
||||
final_duration = clip.duration
|
||||
|
||||
if trim_config is not None and actual_duration > 0:
|
||||
effective_trim = trim_config.validate_and_resolve(actual_duration)
|
||||
if effective_trim.is_valid:
|
||||
final_start = effective_trim.start_time
|
||||
final_duration = effective_trim.duration
|
||||
else:
|
||||
# 裁剪无效 → 使用完整素材
|
||||
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
|
||||
effective_trim = None
|
||||
final_start = 0.0
|
||||
final_duration = actual_duration
|
||||
|
||||
rc = ResolvedClip(
|
||||
clip_id=clip.id,
|
||||
asset_id=asset_id,
|
||||
local_path=local_path,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
start_time=final_start,
|
||||
duration=final_duration,
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||||
config=clip_config,
|
||||
config=clip.config or {},
|
||||
actual_duration=actual_duration,
|
||||
trim_config=effective_trim,
|
||||
)
|
||||
resolved.append(rc)
|
||||
|
||||
@@ -1272,7 +797,7 @@ class UnifiedRenderService:
|
||||
|
||||
filter_parts: list[str] = []
|
||||
|
||||
# Step 1: 预处理每个 clip — trim + scale + setpts
|
||||
# Step 1: 预处理每个 clip — scale + setpts
|
||||
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
|
||||
preprocessed_labels: list[str] = []
|
||||
for i, clip in enumerate(all_clips):
|
||||
@@ -1281,29 +806,13 @@ class UnifiedRenderService:
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# trim — 裁剪到指定区间,精确到帧
|
||||
# trim — 始终将输出截断到有效时长,防止 xfade offset 与实际时长不匹配
|
||||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||||
trim_start = getattr(clip, "start_time", 0) or 0
|
||||
|
||||
if effective_duration > 0:
|
||||
if trim_start > 0:
|
||||
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
else:
|
||||
filters.append(f"trim=duration={effective_duration:.3f}")
|
||||
filters.append(f"trim=duration={effective_duration}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 调速 — 基于 setpts 改变播放速度
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) >= 1e-6:
|
||||
filters.append(f"setpts=PTS/{speed:.4f}")
|
||||
|
||||
# 倒放滤镜(在 trim 之后、scale 之前应用)
|
||||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||||
if reverse_config.enabled and reverse_config.reverse_video:
|
||||
reverse_filter = ReverseEngine.build_video_filter(reverse_config, duration=effective_duration)
|
||||
if reverse_filter:
|
||||
filters.append(reverse_filter)
|
||||
|
||||
# scale
|
||||
if role in ("overlay", "corner_voice"):
|
||||
pip_w = int(self.output_width * _PIP_SCALE)
|
||||
@@ -1322,26 +831,6 @@ class UnifiedRenderService:
|
||||
)
|
||||
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)
|
||||
# chroma key 绿幕抠像(在 scale 之后,fps 之前)
|
||||
try:
|
||||
from video_processing.chroma_key_engine import ChromaKeyConfig, ChromaKeyEngine
|
||||
|
||||
ck_config = ChromaKeyConfig.from_dict(clip.config.get("chroma_key"))
|
||||
if ck_config.has_effect():
|
||||
ck_engine = ChromaKeyEngine(ck_config)
|
||||
# 提取滤镜部分(不带输入输出标签)
|
||||
ck_full = ck_engine.build_filter("[in]", "[out]")
|
||||
ck_filter_part = ck_full[len("[in]") : -len("[out]")]
|
||||
filters.append(ck_filter_part)
|
||||
except Exception as e:
|
||||
logger.warning("[unified-render] chroma key 应用失败,跳过 clip=%s: %s", clip.clip_id, e)
|
||||
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
filters.append(f"fps={self.output_fps}")
|
||||
|
||||
@@ -1354,28 +843,21 @@ class UnifiedRenderService:
|
||||
for layer in layers:
|
||||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||||
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
|
||||
# 使用调速后的实际时长,与 Step 1 的调速处理保持一致
|
||||
layer_durations = [UnifiedRenderService._clip_adjusted_duration(all_clips[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_transition_durations = [all_clips[i].transition_duration for i in layer_clip_indices]
|
||||
|
||||
if len(layer_labels) == 1:
|
||||
# 单 clip 层,直接使用预处理标签
|
||||
layer_output_labels[layer.role] = layer_labels[0]
|
||||
else:
|
||||
# 多 clip 层,用 TransitionEngine 构建转场链
|
||||
# 多 clip 层,用 xfade 串联
|
||||
out_label = f"{layer.role}_merged"
|
||||
# 计算该层使用的转场时长(取首个非零值,否则用默认)
|
||||
layer_dur = 0.0
|
||||
for d in layer_transition_durations:
|
||||
if d > 0:
|
||||
layer_dur = d
|
||||
break
|
||||
xfade_filter, _ = self._transition_engine.build_xfade_chain(
|
||||
xfade_filter, _ = build_xfade_filter_chain(
|
||||
clip_durations=layer_durations,
|
||||
clip_video_labels=layer_labels,
|
||||
transitions=layer_transitions,
|
||||
transition_duration=layer_dur if layer_dur > 0 else None,
|
||||
transition_duration=self.transition_duration,
|
||||
output_label=out_label,
|
||||
)
|
||||
if xfade_filter:
|
||||
@@ -1422,74 +904,6 @@ class UnifiedRenderService:
|
||||
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
|
||||
final_video_label = combined_label
|
||||
|
||||
# 叠加水印(在字幕之前)
|
||||
watermark_config = WatermarkConfig.from_dict((self.plan.config or {}).get("watermark"))
|
||||
if watermark_config is not None:
|
||||
wm_valid, wm_err = watermark_config.validate()
|
||||
if wm_valid:
|
||||
wm_label = "watermarked"
|
||||
if watermark_config.mode == "image":
|
||||
# 图片水印:检查图片是否存在
|
||||
wm_path = Path(watermark_config.image_path)
|
||||
if wm_path.exists():
|
||||
# 图片水印需要额外输入,放在 filter 开头
|
||||
wm_idx = len(all_clips) # 水印图是最后一个输入
|
||||
wm_scale = int(self.output_width * watermark_config.scale)
|
||||
|
||||
# 透明度
|
||||
wm_filters = f"scale={wm_scale}:-1"
|
||||
if watermark_config.opacity < 1.0:
|
||||
wm_filters += f",format=rgba,colorchannelmixer=aa={watermark_config.opacity}"
|
||||
|
||||
filter_parts.insert(0, f"[{wm_idx}:v]{wm_filters}[wm_scaled]")
|
||||
input_args.extend(["-i", str(wm_path)])
|
||||
|
||||
# 位置计算(水印高度用 scale 后的宽度近似)
|
||||
wm_h = wm_scale # 近似(正方形假设)
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
watermark_config.position,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
wm_scale,
|
||||
wm_h,
|
||||
watermark_config.margin_x,
|
||||
watermark_config.margin_y,
|
||||
)
|
||||
|
||||
# 滚动水印
|
||||
if watermark_config.scroll:
|
||||
x_expr = f"W-mod({watermark_config.scroll_speed}*t\\,W+w)"
|
||||
overlay = f"[{final_video_label}][wm_scaled]overlay=x={x_expr}:y={y}[{wm_label}]"
|
||||
else:
|
||||
overlay = f"[{final_video_label}][wm_scaled]overlay=x={x}:y={y}[{wm_label}]"
|
||||
|
||||
filter_parts.append(overlay)
|
||||
final_video_label = wm_label
|
||||
else:
|
||||
logger.warning("水印图片不存在,跳过水印: %s", wm_path)
|
||||
elif watermark_config.mode == "text":
|
||||
# 文字水印
|
||||
try:
|
||||
text_wm = WatermarkEngine.build_text_watermark_filter(
|
||||
f"[{final_video_label}]",
|
||||
f"[{wm_label}]",
|
||||
watermark_config,
|
||||
self.output_width,
|
||||
self.output_height,
|
||||
)
|
||||
filter_parts.append(text_wm)
|
||||
final_video_label = wm_label
|
||||
except Exception as e:
|
||||
logger.warning("文字水印构建失败,跳过: %s", e)
|
||||
# 贴纸叠加(图片贴纸 + 文字贴纸)
|
||||
sticker_filter, sticker_extra_inputs = self._build_sticker_filters(final_video_label, "after_stickers")
|
||||
if sticker_filter:
|
||||
filter_parts.append(sticker_filter)
|
||||
# 图片贴纸需要额外输入
|
||||
for img_path in sticker_extra_inputs:
|
||||
input_args.extend(["-i", img_path])
|
||||
final_video_label = "after_stickers"
|
||||
|
||||
# 叠加字幕(如有)+ 最终像素格式
|
||||
if ass_path is not None:
|
||||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
|
||||
@@ -1549,40 +963,6 @@ class UnifiedRenderService:
|
||||
)
|
||||
raise
|
||||
|
||||
def _build_sticker_filters(self, input_label: str, output_label: str) -> tuple[str, list[str]]:
|
||||
"""构建贴纸叠加滤镜链.
|
||||
|
||||
Args:
|
||||
input_label: 输入视频标签
|
||||
output_label: 输出视频标签
|
||||
|
||||
Returns:
|
||||
(filter_str, extra_input_paths)
|
||||
filter_str: 贴纸滤镜字符串(空表示无贴纸)
|
||||
extra_input_paths: 额外需要的输入文件路径(图片贴纸)
|
||||
"""
|
||||
plan_config = getattr(self.plan, "config", None) or {}
|
||||
if isinstance(plan_config, dict):
|
||||
stickers_data = plan_config.get("stickers", [])
|
||||
else:
|
||||
stickers_data = []
|
||||
|
||||
if not stickers_data:
|
||||
return "", []
|
||||
|
||||
try:
|
||||
result = StickerEngine.build_sticker_chain(
|
||||
stickers=stickers_data,
|
||||
input_label=f"[{input_label}]",
|
||||
output_label=f"[{output_label}]",
|
||||
canvas_w=self.output_width,
|
||||
canvas_h=self.output_height,
|
||||
)
|
||||
return result.filter_str, result.extra_inputs
|
||||
except Exception as e:
|
||||
logger.warning("贴纸滤镜构建失败,跳过贴纸: %s", e)
|
||||
return "", []
|
||||
|
||||
def _probe_output(self, output_path: Path) -> tuple[float, int, int, int]:
|
||||
"""探测输出文件的时长、大小、宽高.
|
||||
|
||||
@@ -1600,117 +980,7 @@ class UnifiedRenderService:
|
||||
|
||||
@staticmethod
|
||||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||||
"""计算 clip 的有效时长(原速 trim 后时长)."""
|
||||
"""计算 clip 的有效时长."""
|
||||
if clip.duration > 0:
|
||||
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
|
||||
|
||||
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
||||
|
||||
def _resolve_pip_sources(self, pip_config: PiPConfig) -> list[tuple[str, PiPLayerConfig, Path]]:
|
||||
"""解析画中画图层的素材源,返回可用的图层列表.
|
||||
|
||||
降级策略:素材不存在或无效的图层自动跳过,不阻断渲染。
|
||||
|
||||
Returns:
|
||||
[(input_label_placeholder, layer_config, local_path), ...]
|
||||
input_label 在 build_pip_filters 中会用实际的输入索引替换
|
||||
"""
|
||||
if not pip_config.enabled:
|
||||
return []
|
||||
|
||||
engine = PiPEngine(
|
||||
output_width=self.output_width,
|
||||
output_height=self.output_height,
|
||||
output_fps=self.output_fps,
|
||||
)
|
||||
|
||||
result = []
|
||||
for i, layer in enumerate(pip_config.layers):
|
||||
path = engine.validate_layer_source(layer, self.asset_path_map)
|
||||
if path is None:
|
||||
logger.warning("PiP图层素材不可用,跳过: layer_index=%d source=%s", i, layer.source)
|
||||
continue
|
||||
# 标签占位,实际输入索引由 build_pip_filters 内部管理
|
||||
result.append((f"pip_src_{i}", layer, path))
|
||||
|
||||
return result
|
||||
|
||||
def _append_pip_filters(
|
||||
self,
|
||||
filter_complex: str,
|
||||
input_args: list[str],
|
||||
pip_sources: list[tuple[str, Any, Path]],
|
||||
) -> tuple[str, list[str]]:
|
||||
"""将画中画滤镜追加到 filter_complex 末尾.
|
||||
|
||||
处理逻辑:
|
||||
1. 将原 final_video 标签重命名为 pip_base(作为PiP的底层视频)
|
||||
2. 追加 PiP 预处理和 overlay 滤镜
|
||||
3. PiP 最终输出命名为 final_video
|
||||
|
||||
Args:
|
||||
filter_complex: 原 filter_complex 字符串
|
||||
input_args: 原输入参数列表
|
||||
pip_sources: PiP 素材列表 [(label, layer_config, path), ...]
|
||||
|
||||
Returns:
|
||||
(new_filter_complex, new_input_args)
|
||||
"""
|
||||
if not pip_sources:
|
||||
return filter_complex, input_args
|
||||
|
||||
pip_engine = PiPEngine(
|
||||
output_width=self.output_width,
|
||||
output_height=self.output_height,
|
||||
output_fps=self.output_fps,
|
||||
)
|
||||
|
||||
# 1. 将原 final_video 改为 pip_base
|
||||
new_filter = filter_complex.replace("[final_video]", "[pip_base]")
|
||||
|
||||
# 2. 构建 PiP 滤镜链
|
||||
# 主输入数量 = len(input_args) // 2(每个输入占 "-i path" 两个参数)
|
||||
base_input_idx = len(input_args) // 2
|
||||
pip_filter_parts, pip_input_args, final_label = pip_engine.build_pip_filters(
|
||||
base_label="pip_base",
|
||||
pip_sources=pip_sources,
|
||||
base_input_idx=base_input_idx,
|
||||
)
|
||||
|
||||
if not pip_filter_parts:
|
||||
# 没有有效PiP滤镜,恢复原标签
|
||||
return filter_complex, input_args
|
||||
|
||||
# 3. 追加 PiP 滤镜 + 最终格式转换(输出为 final_video)
|
||||
pip_filter_str = ";".join(pip_filter_parts)
|
||||
final_format = f"[{final_label}]format=yuv420p[final_video]"
|
||||
new_filter = f"{new_filter};{pip_filter_str};{final_format}"
|
||||
|
||||
# 4. 追加输入参数
|
||||
new_input_args = list(input_args) + pip_input_args
|
||||
|
||||
logger.info(
|
||||
"[unified-render] appended PiP filters: layers=%d new_inputs=%d",
|
||||
len(pip_sources),
|
||||
len(pip_input_args) // 2,
|
||||
)
|
||||
|
||||
return new_filter, new_input_args
|
||||
|
||||
@staticmethod
|
||||
def _clip_speed(clip: ResolvedClip) -> float:
|
||||
"""获取 clip 的播放速度,无效值回退到 1.0."""
|
||||
speed = getattr(clip, "playback_speed", 1.0)
|
||||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||||
return 1.0
|
||||
return float(speed)
|
||||
|
||||
@staticmethod
|
||||
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
|
||||
"""计算调速后的 clip 实际时长(用于拼接计算)."""
|
||||
base = UnifiedRenderService._clip_effective_duration(clip)
|
||||
speed = UnifiedRenderService._clip_speed(clip)
|
||||
if abs(speed - 1.0) < 1e-6:
|
||||
return base
|
||||
return base / speed
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""URL 安全校验工具 — SSRF 防护(向后兼容层).
|
||||
|
||||
本模块为向后兼容而保留,实际实现已迁移至 packages.shared.url_security。
|
||||
所有符号均从该模块重新导出,请新代码直接 import packages.shared.url_security。
|
||||
"""
|
||||
|
||||
from packages.shared.url_security import ( # noqa: F401
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
ALLOWED_IMAGE_MIME_TYPES,
|
||||
ALLOWED_PORTS,
|
||||
ALLOWED_SCHEMES,
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
DEFAULT_MAX_DOWNLOAD_SIZE,
|
||||
MAX_URL_LENGTH,
|
||||
TRUSTED_DOMAINS,
|
||||
UrlSecurityError,
|
||||
is_url_safe,
|
||||
safe_download_bytes,
|
||||
safe_download_file,
|
||||
validate_url_safety,
|
||||
)
|
||||
@@ -1,315 +0,0 @@
|
||||
"""水印引擎 — 基于 FFmpeg overlay 滤镜的水印叠加.
|
||||
|
||||
支持:
|
||||
- 图片水印(PNG/logo)
|
||||
- 文字水印(drawtext)
|
||||
- 9宫格位置 + 边距配置
|
||||
- 透明度/大小缩放
|
||||
- 滚动水印(跑马灯)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 9宫格位置枚举
|
||||
WATERMARK_POSITIONS = {
|
||||
"top_left": "左上",
|
||||
"top_center": "中上",
|
||||
"top_right": "右上",
|
||||
"center_left": "左中",
|
||||
"center": "中心",
|
||||
"center_right": "右中",
|
||||
"bottom_left": "左下",
|
||||
"bottom_center": "中下",
|
||||
"bottom_right": "右下",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatermarkConfig:
|
||||
"""水印配置.
|
||||
|
||||
mode: "image" 图片水印 | "text" 文字水印
|
||||
position: 9宫格位置
|
||||
opacity: 透明度 0.0-1.0
|
||||
scale: 缩放比例(图片水印),0.1-1.0
|
||||
margin: 边距(像素)
|
||||
scroll: 是否滚动(跑马灯)
|
||||
scroll_speed: 滚动速度(像素/秒)
|
||||
"""
|
||||
|
||||
mode: str = "text" # image | text
|
||||
position: str = "bottom_right"
|
||||
|
||||
# 图片水印
|
||||
image_path: str = "" # 本地图片路径
|
||||
scale: float = 0.2 # 相对输出宽度的比例
|
||||
opacity: float = 0.8 # 0.0-1.0
|
||||
|
||||
# 文字水印
|
||||
text: str = ""
|
||||
font_size: int = 24
|
||||
font_color: str = "white"
|
||||
font_path: str = "" # 字体文件路径
|
||||
|
||||
# 边距
|
||||
margin_x: int = 20
|
||||
margin_y: int = 20
|
||||
|
||||
# 滚动水印
|
||||
scroll: bool = False
|
||||
scroll_speed: int = 50 # 像素/秒
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> WatermarkConfig | None:
|
||||
"""从字典构造,空配置返回 None(不加水印)."""
|
||||
if not data:
|
||||
return None
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
if not enabled:
|
||||
return None
|
||||
|
||||
mode = data.get("mode", "text")
|
||||
|
||||
# 图片模式需要 image_path;文字模式需要 text
|
||||
if mode == "image":
|
||||
image_path = data.get("image_path", "") or data.get("image", "") or ""
|
||||
if not image_path:
|
||||
logger.warning("图片水印缺少 image_path,跳过水印")
|
||||
return None
|
||||
elif mode == "text":
|
||||
text = data.get("text", "") or ""
|
||||
if not text:
|
||||
logger.warning("文字水印缺少 text,跳过水印")
|
||||
return None
|
||||
|
||||
position = data.get("position", "bottom_right")
|
||||
if position not in WATERMARK_POSITIONS:
|
||||
position = "bottom_right"
|
||||
|
||||
return cls(
|
||||
mode=mode,
|
||||
position=position,
|
||||
image_path=str(data.get("image_path", data.get("image", "")) or ""),
|
||||
scale=float(data.get("scale", 0.2)),
|
||||
opacity=float(data.get("opacity", 0.8)),
|
||||
text=str(data.get("text", "") or ""),
|
||||
font_size=int(data.get("font_size", 24)),
|
||||
font_color=str(data.get("font_color", "white")),
|
||||
font_path=str(data.get("font_path", "") or ""),
|
||||
margin_x=int(data.get("margin_x", 20)),
|
||||
margin_y=int(data.get("margin_y", 20)),
|
||||
scroll=bool(data.get("scroll", False)),
|
||||
scroll_speed=int(data.get("scroll_speed", 50)),
|
||||
)
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""校验配置是否有效."""
|
||||
if self.position not in WATERMARK_POSITIONS:
|
||||
return False, f"不支持的位置: {self.position}"
|
||||
|
||||
if not (0.0 <= self.opacity <= 1.0):
|
||||
return False, "透明度必须在 0-1 之间"
|
||||
|
||||
if self.mode == "image":
|
||||
if not self.image_path:
|
||||
return False, "图片水印缺少图片路径"
|
||||
if not (0.01 <= self.scale <= 1.0):
|
||||
return False, "缩放比例必须在 0.01-1.0 之间"
|
||||
elif self.mode == "text":
|
||||
if not self.text:
|
||||
return False, "文字水印缺少文字内容"
|
||||
if self.font_size <= 0:
|
||||
return False, "字体大小必须大于 0"
|
||||
else:
|
||||
return False, f"不支持的水印模式: {self.mode}"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
class WatermarkEngine:
|
||||
"""水印引擎 — 生成 FFmpeg 水印滤镜."""
|
||||
|
||||
@staticmethod
|
||||
def calc_position(
|
||||
position: str,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
wm_width: int,
|
||||
wm_height: int,
|
||||
margin_x: int,
|
||||
margin_y: int,
|
||||
) -> tuple[int, int]:
|
||||
"""根据9宫格位置计算水印坐标 (x, y).
|
||||
|
||||
坐标系:左上角为 (0, 0)
|
||||
"""
|
||||
if position == "top_left":
|
||||
return margin_x, margin_y
|
||||
elif position == "top_center":
|
||||
return (output_width - wm_width) // 2, margin_y
|
||||
elif position == "top_right":
|
||||
return output_width - wm_width - margin_x, margin_y
|
||||
elif position == "center_left":
|
||||
return margin_x, (output_height - wm_height) // 2
|
||||
elif position == "center":
|
||||
return (output_width - wm_width) // 2, (output_height - wm_height) // 2
|
||||
elif position == "center_right":
|
||||
return output_width - wm_width - margin_x, (output_height - wm_height) // 2
|
||||
elif position == "bottom_left":
|
||||
return margin_x, output_height - wm_height - margin_y
|
||||
elif position == "bottom_center":
|
||||
return (output_width - wm_width) // 2, output_height - wm_height - margin_y
|
||||
elif position == "bottom_right":
|
||||
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
|
||||
else:
|
||||
# 默认右下角
|
||||
return output_width - wm_width - margin_x, output_height - wm_height - margin_y
|
||||
|
||||
@staticmethod
|
||||
def calc_scroll_x(position: str, output_width: int, wm_width: int, speed: int) -> str:
|
||||
"""生成滚动水印的 x 坐标表达式.
|
||||
|
||||
从右向左滚动(跑马灯效果)
|
||||
"""
|
||||
# x 从 W 到 -wm_width,整个宽度 + wm_width 的距离
|
||||
# 使用 overlay 的 enable 表达式
|
||||
# x = 'W - (t * speed)' → 不对,应该是持续滚动
|
||||
# 标准跑马灯:x = -w + (t * speed) % (W + w)
|
||||
# 但 FFmpeg overlay 支持表达式
|
||||
return f"mod({output_width}-mod({speed}*t\\,{output_width}+{wm_width})"
|
||||
|
||||
@staticmethod
|
||||
def build_image_watermark_filter(
|
||||
input_video_label: str,
|
||||
wm_image_path: str,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
output_label: str,
|
||||
config: WatermarkConfig,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""构建图片水印滤镜链.
|
||||
|
||||
Args:
|
||||
input_video_label: 输入视频标签,如 "[final_video]"
|
||||
wm_image_path: 水印图片本地路径
|
||||
output_width: 输出视频宽度
|
||||
output_height: 输出视频高度
|
||||
output_label: 输出标签
|
||||
config: 水印配置
|
||||
|
||||
Returns:
|
||||
(filter_complex_str, input_args_list)
|
||||
input_args 是 ["-i", wm_image_path] 格式
|
||||
"""
|
||||
# 计算水印尺寸(按输出宽度比例缩放)
|
||||
wm_width = int(output_width * config.scale)
|
||||
wm_height = -1 # 保持比例
|
||||
wm_filter = f"scale={wm_width}:{wm_height}"
|
||||
|
||||
# 透明度处理
|
||||
if config.opacity < 1.0:
|
||||
wm_filter += f",format=rgba,colorchannelmixer=aa={config.opacity}"
|
||||
|
||||
# 水印预处理标签
|
||||
wm_pre_label = "[wm_scaled]"
|
||||
|
||||
# 计算位置
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
config.position,
|
||||
output_width,
|
||||
output_height,
|
||||
wm_width,
|
||||
wm_width, # 高度未知,先用宽度估算
|
||||
config.margin_x,
|
||||
config.margin_y,
|
||||
)
|
||||
|
||||
# 滚动水印
|
||||
if config.scroll:
|
||||
# 从右向左滚动:x = W - (t * speed) mod (W + wm_w)
|
||||
# 使用 overlay 表达式
|
||||
x_expr = f"{output_width}-mod({config.scroll_speed}*t\\,{output_width}+{wm_width}"
|
||||
y_expr = str(y)
|
||||
overlay_expr = f"x={x_expr}:y={y_expr}"
|
||||
else:
|
||||
overlay_expr = f"x={x}:y={y}"
|
||||
|
||||
# 构建滤镜
|
||||
# 先缩放水印图
|
||||
wm_input_idx = 1 # 假设水印图是第二个输入(索引1
|
||||
filter_parts = [
|
||||
f"[1:v]{wm_filter}{wm_pre_label}",
|
||||
f"{input_video_label}{wm_pre_label}overlay={overlay_expr}{output_label}",
|
||||
]
|
||||
|
||||
filter_complex = ";".join(filter_parts)
|
||||
input_args = ["-i", wm_image_path]
|
||||
|
||||
return filter_complex, input_args
|
||||
|
||||
@staticmethod
|
||||
def build_text_watermark_filter(
|
||||
input_video_label: str,
|
||||
output_label: str,
|
||||
config: WatermarkConfig,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
) -> str:
|
||||
"""构建文字水印滤镜(drawtext).
|
||||
|
||||
Args:
|
||||
input_video_label: 输入视频标签
|
||||
output_label: 输出标签
|
||||
config: 水印配置
|
||||
output_width: 输出宽度
|
||||
output_height: 输出高度
|
||||
|
||||
Returns:
|
||||
FFmpeg filter 字符串
|
||||
"""
|
||||
# 转义文字中的特殊字符
|
||||
text = config.text.replace(":", "\\:").replace("'", "\\'")
|
||||
|
||||
# 字体配置
|
||||
font_config = []
|
||||
if config.font_path:
|
||||
font_path_escaped = config.font_path.replace(":", "\\:").replace("'", "\\'")
|
||||
font_config.append(f"fontfile='{font_path_escaped}'")
|
||||
font_config.append(f"fontsize={config.font_size}")
|
||||
font_config.append(f"fontcolor={config.font_color}@{config.opacity}")
|
||||
|
||||
# 估算文字宽高(粗略估算,用于位置计算)
|
||||
# 每个汉字约等于 font_size 宽高
|
||||
approx_w = len(config.text) * config.font_size
|
||||
approx_h = config.font_size
|
||||
|
||||
# 位置计算
|
||||
x, y = WatermarkEngine.calc_position(
|
||||
config.position,
|
||||
output_width,
|
||||
output_height,
|
||||
approx_w,
|
||||
approx_h,
|
||||
config.margin_x,
|
||||
config.margin_y,
|
||||
)
|
||||
|
||||
# 滚动水印
|
||||
if config.scroll:
|
||||
x_expr = f"w-mod({config.scroll_speed}*t\\,W+w)"
|
||||
pos_config = [f"x={x_expr}", f"y={y}"]
|
||||
else:
|
||||
pos_config = [f"x={x}", f"y={y}"]
|
||||
|
||||
# 组装 drawtext
|
||||
drawtext_parts = [f"text='{text}'"] + font_config + pos_config
|
||||
drawtext = "drawtext=" + ":".join(drawtext_parts)
|
||||
|
||||
return f"{input_video_label}{drawtext}{output_label}"
|
||||
@@ -16,6 +16,5 @@ celery_app.conf.imports = (
|
||||
"worker_app.tasks.tts_synthesis",
|
||||
"worker_app.tasks.edit_plan_generation",
|
||||
"worker_app.tasks.compose_video",
|
||||
"worker_app.tasks.batch_download",
|
||||
"apps.worker.video_processing.dedup",
|
||||
)
|
||||
|
||||
@@ -130,8 +130,6 @@ class AssetAnalyzer:
|
||||
info = VideoInfo()
|
||||
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffprobe
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
@@ -142,31 +140,38 @@ class AssetAnalyzer:
|
||||
"-show_streams",
|
||||
self.video_path,
|
||||
]
|
||||
stdout, _ = run_ffprobe(cmd, timeout=30)
|
||||
data = json.loads(stdout)
|
||||
streams = data.get("streams", [])
|
||||
format_info = data.get("format", {})
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
for stream in streams:
|
||||
if stream.get("codec_type") == "video":
|
||||
info.width = int(stream.get("width", 0))
|
||||
info.height = int(stream.get("height", 0))
|
||||
info.codec = stream.get("codec_name", "")
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get("streams", [])
|
||||
format_info = data.get("format", {})
|
||||
|
||||
# 解析帧率
|
||||
fps_str = stream.get("r_frame_rate", "0/1")
|
||||
if "/" in fps_str:
|
||||
num, denom = fps_str.split("/")
|
||||
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
|
||||
else:
|
||||
info.fps = float(fps_str)
|
||||
for stream in streams:
|
||||
if stream.get("codec_type") == "video":
|
||||
info.width = int(stream.get("width", 0))
|
||||
info.height = int(stream.get("height", 0))
|
||||
info.codec = stream.get("codec_name", "")
|
||||
|
||||
elif stream.get("codec_type") == "audio":
|
||||
info.has_audio = True
|
||||
# 解析帧率
|
||||
fps_str = stream.get("r_frame_rate", "0/1")
|
||||
if "/" in fps_str:
|
||||
num, denom = fps_str.split("/")
|
||||
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
|
||||
else:
|
||||
info.fps = float(fps_str)
|
||||
|
||||
info.duration = float(format_info.get("duration", 0))
|
||||
info.bitrate = int(format_info.get("bit_rate", 0))
|
||||
info.file_size = int(format_info.get("size", 0))
|
||||
elif stream.get("codec_type") == "audio":
|
||||
info.has_audio = True
|
||||
|
||||
info.duration = float(format_info.get("duration", 0))
|
||||
info.bitrate = int(format_info.get("bit_rate", 0))
|
||||
info.file_size = int(format_info.get("size", 0))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get video info: {e}")
|
||||
@@ -174,7 +179,7 @@ class AssetAnalyzer:
|
||||
self._video_info = info
|
||||
return info
|
||||
|
||||
def extract_frames(self, count: int = 10) -> list[np.ndarray]:
|
||||
def extract_frames(self, count: int = 10, max_frames: int = 30) -> list[np.ndarray]:
|
||||
"""
|
||||
从视频中均匀抽取帧
|
||||
|
||||
@@ -219,14 +224,14 @@ class AssetAnalyzer:
|
||||
output_path,
|
||||
]
|
||||
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=10)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if os.path.exists(output_path):
|
||||
if result.returncode == 0 and os.path.exists(output_path):
|
||||
# 读取帧并转换为 numpy 数组
|
||||
img = self._load_image_as_array(output_path)
|
||||
if img is not None:
|
||||
@@ -392,19 +397,14 @@ class AssetAnalyzer:
|
||||
audio_path,
|
||||
]
|
||||
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
result_audio = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=30)
|
||||
except Exception:
|
||||
# 音频提取失败,返回默认分析结果
|
||||
return AudioAnalysis(
|
||||
has_speech=False,
|
||||
speech_ratio=0.0,
|
||||
avg_volume=0.0,
|
||||
)
|
||||
|
||||
if os.path.exists(audio_path):
|
||||
if result_audio.returncode == 0 and os.path.exists(audio_path):
|
||||
# 读取音频数据
|
||||
import struct
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"""批量下载任务 — 将多个成片打包为 zip 上传到 OSS。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from worker_app.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.batch_download_videos", max_retries=1)
|
||||
def batch_download_videos(self, video_ids: list[str], user_id: str = "") -> dict:
|
||||
"""批量下载视频并打包为 zip。
|
||||
|
||||
Args:
|
||||
video_ids: 视频 ID 列表
|
||||
user_id: 发起用户 ID
|
||||
|
||||
Returns:
|
||||
{"download_url": "...", "file_count": N, "total_size": total_bytes}
|
||||
"""
|
||||
from video_processing.oss_helpers import download_asset, upload_to_oss
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
|
||||
SQLAlchemyGeneratedVideoRepository,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
videos = repo.get_by_ids(video_ids)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
if not videos:
|
||||
raise ValueError("No videos found for batch download")
|
||||
|
||||
# 创建临时工作目录
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
zip_filename = f"videos-{len(videos)}-{video_ids[0][:8]}.zip"
|
||||
zip_path = tmpdir_path / zip_filename
|
||||
|
||||
# 逐个下载视频并加入 zip
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf:
|
||||
for idx, video in enumerate(videos, 1):
|
||||
logger.info("Batch download: downloading %d/%d %s", idx, len(videos), video.id)
|
||||
try:
|
||||
# 下载视频到临时文件
|
||||
local_name = f"{idx:03d}_{video.name}"
|
||||
local_path = tmpdir_path / local_name
|
||||
|
||||
# 使用 oss_helpers 的 download_asset,或者直接从 URL 下载
|
||||
if video.file_url:
|
||||
_download_video_to_file(video.file_url, str(local_path))
|
||||
|
||||
if local_path.exists() and local_path.stat().st_size > 0:
|
||||
zf.write(str(local_path), arcname=local_name)
|
||||
local_path.unlink(missing_ok=True)
|
||||
else:
|
||||
logger.warning("Video %s download failed, skipping", video.id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download video %s: %s", video.id, e)
|
||||
continue
|
||||
|
||||
# 上传 zip 到 OSS
|
||||
if not zip_path.exists() or zip_path.stat().st_size == 0:
|
||||
raise RuntimeError("Batch download zip file is empty")
|
||||
zip_storage_key = f"batch-downloads/{uuid.uuid4().hex}/{zip_filename}"
|
||||
download_url = upload_to_oss(str(zip_path), zip_storage_key)
|
||||
|
||||
total_size = zip_path.stat().st_size
|
||||
file_count = len(zipfile.ZipFile(str(zip_path), "r").namelist())
|
||||
|
||||
logger.info(
|
||||
"Batch download complete: %d files, %d bytes, url=%s",
|
||||
file_count,
|
||||
total_size,
|
||||
download_url,
|
||||
)
|
||||
|
||||
return {
|
||||
"download_url": download_url,
|
||||
"file_count": file_count,
|
||||
"total_size": total_size,
|
||||
"video_count": len(videos),
|
||||
}
|
||||
|
||||
|
||||
def _download_video_to_file(url: str, dest_path: str) -> None:
|
||||
"""下载视频文件到本地路径。优先用 OSS SDK 走内网,回退到 HTTP 下载。"""
|
||||
from video_processing.oss_helpers import download_asset
|
||||
|
||||
try:
|
||||
# 尝试走 OSS 下载(如果是 OSS URL 的话)
|
||||
success = download_asset(url, dest_path)
|
||||
if success:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退到 HTTP 下载(含 SSRF 防护 + 大小限制 + 类型校验)
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_VIDEO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
safe_download_file(
|
||||
url,
|
||||
dest_path,
|
||||
purpose="batch_video_download",
|
||||
allowed_mime_types=ALLOWED_VIDEO_MIME_TYPES | {"application/octet-stream"},
|
||||
timeout=300.0,
|
||||
)
|
||||
@@ -68,12 +68,6 @@ def classify_asset(self, job_id: str) -> dict:
|
||||
|
||||
# Update asset with classification status and result
|
||||
asset.classification_status = ClassificationStatus.COMPLETED
|
||||
# 把分类结果写入 metadata,供列表筛选和智能视图使用
|
||||
asset.metadata = {
|
||||
**(asset.metadata or {}),
|
||||
"classification": classification,
|
||||
"classification_confidence": confidence,
|
||||
}
|
||||
asset_repo.update(asset)
|
||||
|
||||
session.commit()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -114,12 +115,16 @@ def _compose_with_legacy_engine(task, job_service, job, plan_id: str, db) -> dic
|
||||
logger.info("Executing FFmpeg for job %s, plan %s", job_id, plan_id)
|
||||
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(compose_cmd.command, timeout=3600)
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
|
||||
job_service.fail_job(job_id, error_msg)
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
job_service.fail_job(job_id, f"FFmpeg 执行失败: {e.stderr[:500]}")
|
||||
raise
|
||||
|
||||
# 上传结果
|
||||
|
||||
@@ -257,6 +257,7 @@ def _render_with_legacy(
|
||||
) -> dict:
|
||||
"""旧引擎路径(VideoComposeService + FFmpeg filter_complex)。"""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from apps.api.app.services.video_compose_service import VideoComposeService
|
||||
|
||||
@@ -277,11 +278,16 @@ def _render_with_legacy(
|
||||
|
||||
logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id)
|
||||
try:
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
run_ffmpeg(compose_cmd.command, timeout=3600)
|
||||
except Exception as e:
|
||||
error_msg = f"FFmpeg 执行失败: {str(e)[:500]}"
|
||||
subprocess.run(
|
||||
compose_cmd.command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=3600,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
error_msg = f"FFmpeg 执行失败: {e.stderr[:500]}"
|
||||
logger.error("FFmpeg 执行失败(legacy): %s — %s", plan_id, error_msg)
|
||||
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
|
||||
return {"status": "error", "message": error_msg}
|
||||
|
||||
Executable → Regular
+11
-340
@@ -86,36 +86,6 @@ def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _build_error_info(error: Exception, stage: str = "render") -> dict:
|
||||
"""构建结构化错误信息。
|
||||
|
||||
Args:
|
||||
error: 异常对象
|
||||
stage: 发生错误的阶段(download/render/merge/upload等)
|
||||
|
||||
Returns:
|
||||
包含 error_type, message, stack_trace, stage, failed_at 的字典
|
||||
"""
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
|
||||
tb_str = traceback.format_exc()
|
||||
# 截取堆栈前20行,避免字段过大
|
||||
tb_lines = tb_str.strip().splitlines()
|
||||
if len(tb_lines) > 20:
|
||||
tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)"
|
||||
else:
|
||||
tb_summary = tb_str
|
||||
|
||||
return {
|
||||
"error_type": type(error).__name__,
|
||||
"message": str(error),
|
||||
"stack_trace": tb_summary,
|
||||
"stage": stage,
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 日志持久化辅助 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -138,7 +108,6 @@ def _flush_logs(task_id: str, gen_task) -> None:
|
||||
|
||||
# ── 共享工具模块导入 ──────────────────────────────────────────────────────────
|
||||
|
||||
from services.asr_service_factory import get_asr_service
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
|
||||
from video_processing.oss_helpers import (
|
||||
@@ -335,191 +304,29 @@ def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
|
||||
return download_asset(storage_key, local_path)
|
||||
|
||||
|
||||
def _prepare_bgm_track(
|
||||
*,
|
||||
bgm_config: dict,
|
||||
temp_path: Path,
|
||||
task_id: str = "",
|
||||
) -> str | None:
|
||||
"""准备 BGM 音频文件(下载到本地).
|
||||
|
||||
支持 3 种来源(按优先级):
|
||||
1. audio_url — 外部直链 URL(最高优先级)
|
||||
2. asset_id — 素材库中的音频素材
|
||||
3. preset_id — 预设 BGM 库
|
||||
|
||||
Returns:
|
||||
BGM 本地文件路径,准备失败返回 None
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
audio_url = bgm_config.get("audio_url", "") or ""
|
||||
asset_id = bgm_config.get("asset_id", "") or ""
|
||||
preset_id = bgm_config.get("preset_id", "") or ""
|
||||
|
||||
bgm_file = temp_path / f"bgm_{task_id or 'track'}.mp3"
|
||||
|
||||
# 优先级1:外部直链 URL
|
||||
if audio_url:
|
||||
try:
|
||||
parsed = urlparse(audio_url)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从URL下载: %s", task_id, audio_url[:80])
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] URL下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级2:素材库素材
|
||||
if asset_id:
|
||||
try:
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
model = session.query(AssetModel).filter(AssetModel.id == asset_id).first()
|
||||
if model and model.file_url:
|
||||
storage_key = model.file_url
|
||||
logger.info("[task_id=%s] [BGM] 从素材库下载: asset_id=%s", task_id, asset_id)
|
||||
ok = download_asset(storage_key, bgm_file)
|
||||
if ok and bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 素材库下载失败: %s", task_id, e)
|
||||
|
||||
# 优先级3:预设 BGM 库
|
||||
if preset_id:
|
||||
try:
|
||||
from packages.domain.preset_bgm import get_preset_bgm
|
||||
|
||||
preset = get_preset_bgm(preset_id)
|
||||
if preset and preset.audio_url:
|
||||
from video_processing.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
safe_download_file,
|
||||
)
|
||||
|
||||
logger.info("[task_id=%s] [BGM] 从预设库下载: preset_id=%s", task_id, preset_id)
|
||||
safe_download_file(
|
||||
preset.audio_url,
|
||||
str(bgm_file),
|
||||
purpose="bgm_preset_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
if bgm_file.exists() and bgm_file.stat().st_size > 0:
|
||||
return str(bgm_file)
|
||||
except Exception as e:
|
||||
logger.warning("[task_id=%s] [BGM] 预设库下载失败: %s", task_id, e)
|
||||
|
||||
# 所有来源都失败
|
||||
logger.warning("[task_id=%s] [BGM] 所有来源都无法获取BGM,跳过", task_id)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_url_accessible(
|
||||
url: str,
|
||||
timeout: float = 10.0,
|
||||
retries: int = 2,
|
||||
max_redirects: int = 5,
|
||||
) -> bool:
|
||||
def _verify_url_accessible(url: str, timeout: float = 10.0, retries: int = 2) -> bool:
|
||||
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
|
||||
|
||||
安全增强:
|
||||
- 请求前先做 SSRF 安全校验(内网IP/回环地址/链路本地地址等)
|
||||
- scheme 仅允许 http/https
|
||||
- 端口仅允许 80/443
|
||||
- 手动跟随重定向,每一跳 URL 都做 SSRF 校验,避免重定向到内网地址绕过
|
||||
|
||||
Args:
|
||||
url: 待校验的 URL
|
||||
timeout: 单次请求超时时间(秒)
|
||||
retries: 最大重试次数(默认 2 次,首次失败后间隔 1s 重试)
|
||||
max_redirects: 最大重定向次数(默认 5 次)
|
||||
|
||||
Returns:
|
||||
True 表示 URL 可访问(HTTP 2xx/3xx),False 表示所有尝试均失败或安全校验不通过。
|
||||
True 表示 URL 可访问(HTTP 2xx/3xx),False 表示所有尝试均失败。
|
||||
"""
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from video_processing.url_security import UrlSecurityError, validate_url_safety
|
||||
|
||||
# P0-1 SSRF 防护:请求前先校验 URL 安全性
|
||||
try:
|
||||
validate_url_safety(url, purpose="url_verify")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning("URL 安全校验失败,拒绝访问: url=%s error=%s", url[:80], e)
|
||||
return False
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
def _do_verify(current_url: str) -> bool:
|
||||
"""单次校验:手动跟随重定向,每跳都做 SSRF 检查."""
|
||||
redirect_count = 0
|
||||
url_being_checked = current_url
|
||||
|
||||
# 禁止自动重定向的 handler,手动控制每一跳
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(NoRedirect())
|
||||
|
||||
while redirect_count <= max_redirects:
|
||||
# 每一跳都做 SSRF 安全校验
|
||||
try:
|
||||
safe_url = validate_url_safety(url_being_checked, purpose="url_verify")
|
||||
except UrlSecurityError as e:
|
||||
logger.warning(
|
||||
"URL校验跳转地址不安全: redirect=%d url=%s error=%s",
|
||||
redirect_count,
|
||||
url_being_checked,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
|
||||
req = urllib.request.Request(safe_url, method="HEAD")
|
||||
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
|
||||
|
||||
with opener.open(req, timeout=timeout) as resp: # noqa: S310
|
||||
if 200 <= resp.status < 300:
|
||||
return True
|
||||
if resp.status in (301, 302, 303, 307, 308):
|
||||
location = resp.headers.get("Location", "")
|
||||
if not location:
|
||||
raise Exception(f"HTTP {resp.status} 但无 Location 头")
|
||||
# 相对路径转绝对
|
||||
url_being_checked = urljoin(safe_url, location)
|
||||
redirect_count += 1
|
||||
continue
|
||||
if resp.status < 400:
|
||||
return True
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
|
||||
raise Exception(f"重定向次数超过上限 ({max_redirects})")
|
||||
|
||||
for attempt in range(1 + retries):
|
||||
try:
|
||||
if _do_verify(url):
|
||||
return True
|
||||
req = urllib.request.Request(url, method="HEAD")
|
||||
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
|
||||
if resp.status < 400:
|
||||
return True
|
||||
last_error = Exception(f"HTTP {resp.status}")
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
|
||||
@@ -543,6 +350,7 @@ def _download_library_assets(
|
||||
asset_library_id: str = "",
|
||||
project_id: str = "",
|
||||
asset_ids: list[str] | None = None,
|
||||
video_extensions: tuple = (".mp4", ".mov", ".avi", ".mkv", ".webm"),
|
||||
strict: bool = True,
|
||||
task_id: str = "",
|
||||
gen_task=None,
|
||||
@@ -561,6 +369,7 @@ def _download_library_assets(
|
||||
asset_library_id: 素材库 ID(可选,与 project_id 二选一)
|
||||
project_id: 项目 ID(可选,与 asset_library_id 二选一)
|
||||
asset_ids: 指定素材 ID 列表,为空则下载全部 ready 视频素材
|
||||
video_extensions: 支持的视频扩展名(保留兼容,当前按 file_type 过滤)
|
||||
strict: 严格模式(默认 True)。
|
||||
True — 任何素材下载失败立即抛 RuntimeError;
|
||||
False — 跳过失败素材,返回成功列表(调用方可通过日志感知失败)。
|
||||
@@ -1044,22 +853,6 @@ def _render_video(
|
||||
)
|
||||
else:
|
||||
logger.info("[task_id=%s] [渲染] unified 引擎 FFmpeg 渲染开始", task_id)
|
||||
|
||||
# ── 准备 BGM 音频 ──
|
||||
bgm_path: str | None = None
|
||||
plan_config = virtual_plan.config or {}
|
||||
bgm_config = plan_config.get("bgm", {}) or {}
|
||||
if bgm_config.get("enabled", False):
|
||||
try:
|
||||
bgm_path = _prepare_bgm_track(
|
||||
bgm_config=bgm_config,
|
||||
temp_path=temp_path,
|
||||
task_id=task_id,
|
||||
)
|
||||
except Exception as bgm_err:
|
||||
logger.warning("[task_id=%s] [BGM] 准备失败,跳过BGM: %s", task_id, bgm_err)
|
||||
bgm_path = None
|
||||
|
||||
render_service = UnifiedRenderService(
|
||||
plan=virtual_plan,
|
||||
clips=virtual_clips,
|
||||
@@ -1068,8 +861,6 @@ def _render_video(
|
||||
output_width=OUTPUT_WIDTH,
|
||||
output_height=OUTPUT_HEIGHT,
|
||||
output_fps=int(OUTPUT_FPS),
|
||||
asr_service=get_asr_service(),
|
||||
bgm_path=bgm_path,
|
||||
)
|
||||
render_result = render_service.render()
|
||||
render_output_path = render_result.output_path
|
||||
@@ -1301,70 +1092,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
# ── 5. 标记完成 ──────────────────────────────────────────────────
|
||||
_update_task_status(task_id, "mark_completed", result_count=video_count)
|
||||
|
||||
# 5.1 更新标题使用次数
|
||||
try:
|
||||
_title_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import (
|
||||
SQLAlchemyTitleLibraryRepository,
|
||||
)
|
||||
|
||||
_task_repo = SQLAlchemyGenerationTaskRepository(_title_session)
|
||||
_gen_task = _task_repo.get(task_id)
|
||||
if _gen_task and _gen_task.title_ids and _gen_task.created_by_user_id:
|
||||
_title_repo = SQLAlchemyTitleLibraryRepository(_title_session)
|
||||
for _tid in _gen_task.title_ids:
|
||||
try:
|
||||
_title_repo.increment_usage_count(_tid, _gen_task.created_by_user_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新标题使用次数失败: title_id=%s",
|
||||
task_id,
|
||||
_tid,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
_title_session.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 更新标题使用次数异常(不影响主流程)", task_id, exc_info=True)
|
||||
|
||||
# 5.2 更新素材使用次数 + 最近使用时间
|
||||
try:
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
|
||||
_asset_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
|
||||
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
|
||||
_gen_task = _task_repo.get(task_id)
|
||||
if _gen_task and _gen_task.asset_ids:
|
||||
for _aid in _gen_task.asset_ids:
|
||||
try:
|
||||
_asset = _asset_repo.get(_aid)
|
||||
if _asset:
|
||||
mark_asset_used_for_generation(_asset)
|
||||
_asset_repo.update(_asset)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
|
||||
task_id,
|
||||
_aid,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
_asset_session.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 更新素材使用次数异常(不影响主流程)", task_id, exc_info=True)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"任务完成",
|
||||
@@ -1395,9 +1122,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
except Exception as error:
|
||||
logger.error("[task_id=%s] [任务失败] %s", task_id, error, exc_info=True)
|
||||
|
||||
# 构建结构化错误信息
|
||||
error_info = _build_error_info(error, stage="render")
|
||||
|
||||
# 记录失败日志
|
||||
try:
|
||||
_session = SessionLocal()
|
||||
@@ -1410,7 +1134,6 @@ def generate_video(self, task_id: str) -> dict:
|
||||
str(error),
|
||||
level="ERROR",
|
||||
error_type=type(error).__name__,
|
||||
stage="render",
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
finally:
|
||||
@@ -1418,59 +1141,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 记录失败日志异常", task_id, exc_info=True)
|
||||
|
||||
_update_task_status(
|
||||
task_id,
|
||||
"mark_failed",
|
||||
error_message=str(error),
|
||||
error_info=error_info,
|
||||
)
|
||||
|
||||
# ── 自动重试逻辑 ──────────────────────────────────────────────────
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_s = SessionLocal()
|
||||
try:
|
||||
_r = SQLAlchemyGenerationTaskRepository(_s)
|
||||
_task = _r.get(task_id)
|
||||
if _task and _task.auto_retry_enabled and _task.auto_retry_max > 0:
|
||||
current_retry = _task.retry_count or 0
|
||||
if current_retry < _task.auto_retry_max:
|
||||
logger.info(
|
||||
"[task_id=%s] 触发自动重试: 当前重试次数=%d, 最大重试次数=%d",
|
||||
task_id,
|
||||
current_retry,
|
||||
_task.auto_retry_max,
|
||||
)
|
||||
# 计算退避延迟(指数退避,基础5s,最大60s)
|
||||
backoff_seconds = min(5 * (2**current_retry), 60)
|
||||
# 原地重试
|
||||
_task.mark_pending_from_failed()
|
||||
_r.update(_task)
|
||||
# 延迟重新入队
|
||||
celery_app.send_task(
|
||||
"worker.generate_video",
|
||||
args=[task_id],
|
||||
countdown=backoff_seconds,
|
||||
)
|
||||
logger.info(
|
||||
"[task_id=%s] 自动重试已入队: 延迟=%ds, 第%d次重试",
|
||||
task_id,
|
||||
backoff_seconds,
|
||||
current_retry + 1,
|
||||
)
|
||||
finally:
|
||||
_s.close()
|
||||
except Exception as retry_err:
|
||||
logger.warning(
|
||||
"[task_id=%s] 自动重试逻辑执行失败: %s",
|
||||
task_id,
|
||||
retry_err,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
_update_task_status(task_id, "mark_failed", error_message=str(error))
|
||||
return {
|
||||
"status": "failed",
|
||||
"task_id": task_id,
|
||||
|
||||
@@ -45,8 +45,6 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
try:
|
||||
if media_type == "video":
|
||||
# 使用 ffprobe 提取视频元数据
|
||||
from video_processing.ffmpeg_utils import run_ffprobe
|
||||
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v",
|
||||
@@ -57,11 +55,16 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
"-show_streams",
|
||||
file_url,
|
||||
]
|
||||
try:
|
||||
stdout, _ = run_ffprobe(cmd, timeout=30)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
import json as json_lib
|
||||
|
||||
probe_data = json_lib.loads(stdout)
|
||||
probe_data = json_lib.loads(result.stdout)
|
||||
|
||||
# 提取视频流信息
|
||||
for stream in probe_data.get("streams", []):
|
||||
@@ -80,9 +83,6 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||||
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("视频元数据提取失败: %s", e)
|
||||
|
||||
elif media_type == "image":
|
||||
# 使用 Pillow 提取图片元数据
|
||||
try:
|
||||
|
||||
@@ -19,12 +19,14 @@ class VoiceExtractor:
|
||||
"""Extract voice tracks and background music from videos using FFmpeg."""
|
||||
|
||||
@staticmethod
|
||||
def _run_ffmpeg(cmd: list[str]) -> None:
|
||||
"""Run FFmpeg command using 统一 run_ffmpeg 工具."""
|
||||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||||
|
||||
logger.info("Running FFmpeg: %s", " ".join(cmd[:10]))
|
||||
run_ffmpeg(cmd)
|
||||
def _run_ffmpeg(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
"""Run FFmpeg command and return result."""
|
||||
logger.info(f"Running FFmpeg: {chr(39).join(cmd)}")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg error: {result.stderr}")
|
||||
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
|
||||
return result
|
||||
|
||||
def extract_voice(
|
||||
self,
|
||||
|
||||
@@ -858,22 +858,6 @@
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "transition_duration",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "FLOAT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "playback_speed",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "FLOAT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "status",
|
||||
@@ -1509,38 +1493,6 @@
|
||||
"type": "TEXT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "error_info",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "JSON",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "retry_count",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "auto_retry_enabled",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "BOOLEAN",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "auto_retry_max",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "started_at",
|
||||
|
||||
@@ -1,104 +1,48 @@
|
||||
# ============================================================
|
||||
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
|
||||
# 优化项:
|
||||
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
|
||||
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
|
||||
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
|
||||
# Worker Dockerfile - 专门用于 Celery Worker
|
||||
# 优化:依赖分层缓存,基础大包和业务依赖分开
|
||||
# ============================================================
|
||||
|
||||
# ==================== Builder 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
|
||||
# 基础镜像:Python 3.12 + ffmpeg
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装编译工具(仅 builder 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
binutils \
|
||||
wget \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- 下载静态编译 ffmpeg ----
|
||||
# 使用 johnvansickle.com 的静态编译版本(业界标准)
|
||||
RUN cd /tmp \
|
||||
&& wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
|
||||
&& tar xf ffmpeg-release-amd64-static.tar.xz \
|
||||
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
|
||||
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
|
||||
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
|
||||
&& rm -rf ffmpeg-*
|
||||
|
||||
# ---- 安装 Python 依赖 ----
|
||||
WORKDIR /tmp
|
||||
|
||||
# 创建 venv
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# 基础依赖
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
|
||||
# Worker 专属大包
|
||||
COPY requirements-worker.txt /tmp/requirements-worker.txt
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements-worker.txt \
|
||||
&& rm /tmp/requirements-worker.txt
|
||||
|
||||
# 业务依赖
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
|
||||
-r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# ---- Python 依赖瘦身 ----
|
||||
# 1. strip .so 文件的调试符号(节省约 80-100MB)
|
||||
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
|
||||
|
||||
# 2. 清理测试文件(节省约 20MB)
|
||||
RUN find /opt/venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -type d -name "test" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "test_*.py" -delete 2>/dev/null || true
|
||||
|
||||
# 3. 清理 .pyc 缓存和 __pycache__(节省约 10MB,运行时按需生成)
|
||||
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
|
||||
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
# 4. 清理 dist-info 中的文档
|
||||
RUN find /opt/venv -name "*.dist-info" -type d -exec sh -c 'rm -f "$1"/DESCRIPTION.rst "$1"/INSTALLER "$1"/LICENSE* "$1"/WHEEL "$1"/entry_points.txt' _ {} \; 2>/dev/null || true
|
||||
|
||||
# ==================== Runtime 阶段 ====================
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
|
||||
|
||||
# 构建参数:版本号
|
||||
# 构建参数:版本号(CI 传入 commit hash)
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
|
||||
|
||||
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0)
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
ffmpeg \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
libgl1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 从 builder 复制 ffmpeg 静态二进制
|
||||
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||
|
||||
# 从 builder 复制 Python 虚拟环境
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
|
||||
COPY requirements-base.txt /tmp/requirements-base.txt
|
||||
|
||||
RUN python -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt \
|
||||
&& rm /tmp/requirements-base.txt
|
||||
|
||||
# ---- 依赖分层:Worker 专属大包(视频处理,变化极少)----
|
||||
COPY requirements-worker.txt /tmp/requirements-worker.txt
|
||||
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-worker.txt \
|
||||
&& rm /tmp/requirements-worker.txt
|
||||
|
||||
# ---- 依赖分层:业务依赖(变化频繁)----
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY apps/worker/ /app/apps/worker/
|
||||
COPY apps/api/app/config.py /app/apps/api/app/config.py
|
||||
@@ -107,13 +51,13 @@ COPY packages/ /app/packages/
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY migrations/ /app/migrations/
|
||||
|
||||
# 复制 Worker 启动脚本
|
||||
# 复制 Worker 启动脚本(支持 WORKER_CONCURRENCY 环境变量)
|
||||
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
|
||||
|
||||
# 设置 Python 路径
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
ENV PYTHONPATH=/app:/app/packages
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Mock ASR 服务 — 用于测试和开发环境。
|
||||
|
||||
生成模拟的字幕时间轴,不依赖真实ASR服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from packages.domain.subtitle import (
|
||||
SubtitleSegment,
|
||||
SubtitleTimeline,
|
||||
SubtitleWord,
|
||||
)
|
||||
from packages.ports.asr_service import ASRService, ASRServiceError
|
||||
|
||||
|
||||
class MockASRService(ASRService):
|
||||
"""Mock ASR 服务,生成模拟字幕数据。
|
||||
|
||||
如果 audio_path 对应的目录下有同名 .txt 文件,
|
||||
就读取该文件内容作为字幕文本,按时间均匀分段。
|
||||
否则生成默认的测试字幕。
|
||||
"""
|
||||
|
||||
def __init__(self, mock_text: Optional[str] = None):
|
||||
self._mock_text = mock_text
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio_path: Path,
|
||||
language: Optional[str] = None,
|
||||
with_word_timestamps: bool = True,
|
||||
) -> SubtitleTimeline:
|
||||
if not audio_path.exists():
|
||||
raise ASRServiceError(f"音频文件不存在: {audio_path}", provider="mock")
|
||||
|
||||
# 尝试读取同名 txt 文件作为字幕文本
|
||||
text = self._mock_text
|
||||
if text is None:
|
||||
txt_path = audio_path.with_suffix(".txt")
|
||||
if txt_path.exists():
|
||||
text = txt_path.read_text(encoding="utf-8").strip()
|
||||
else:
|
||||
text = "这是一段测试字幕。它用于验证ASR自动字幕功能是否正常工作。每一句话都会被正确地分段并显示在视频底部。字幕的样式可以根据用户的喜好进行自定义调整。"
|
||||
|
||||
# 估算音频时长(用ffmpeg probe或者直接假设)
|
||||
# mock模式下按字数估算,每秒4个字
|
||||
total_duration = max(5.0, len(text) / 4.0)
|
||||
|
||||
segments = self._text_to_segments(text, total_duration, with_word_timestamps)
|
||||
|
||||
return SubtitleTimeline(
|
||||
segments=segments,
|
||||
language=language or "zh",
|
||||
total_duration=total_duration,
|
||||
)
|
||||
|
||||
def _text_to_segments(
|
||||
self,
|
||||
text: str,
|
||||
total_duration: float,
|
||||
with_word_timestamps: bool,
|
||||
) -> list[SubtitleSegment]:
|
||||
"""将文本按句切分成带时间轴的字幕片段。"""
|
||||
# 按句末标点拆分
|
||||
sentences = re.split(r"(?<=[。!?!?])", text)
|
||||
sentences = [s.strip() for s in sentences if s.strip()]
|
||||
|
||||
if not sentences:
|
||||
sentences = [text]
|
||||
|
||||
total_chars = sum(len(s) for s in sentences)
|
||||
if total_chars == 0:
|
||||
return []
|
||||
|
||||
segments = []
|
||||
current_time = 0.0
|
||||
|
||||
for sentence in sentences:
|
||||
char_count = len(sentence)
|
||||
duration = total_duration * (char_count / total_chars)
|
||||
end_time = current_time + duration
|
||||
|
||||
words: list[SubtitleWord] = []
|
||||
if with_word_timestamps:
|
||||
# 每个字作为一个词级单元(中文按字,英文按词)
|
||||
word_time = current_time
|
||||
word_duration = duration / char_count
|
||||
|
||||
for char in sentence:
|
||||
words.append(
|
||||
SubtitleWord(
|
||||
text=char,
|
||||
start=word_time,
|
||||
end=word_time + word_duration,
|
||||
)
|
||||
)
|
||||
word_time += word_duration
|
||||
|
||||
segments.append(
|
||||
SubtitleSegment(
|
||||
text=sentence,
|
||||
start=current_time,
|
||||
end=end_time,
|
||||
words=words,
|
||||
)
|
||||
)
|
||||
current_time = end_time
|
||||
|
||||
return segments
|
||||
@@ -44,61 +44,11 @@ class InMemoryAssetRepository:
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from packages.domain import AssetStatus
|
||||
|
||||
"""批量删除素材,返回实际删除数量。"""
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset and asset.status != AssetStatus.DELETED:
|
||||
asset.status = AssetStatus.DELETED
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict[str, object]) -> int:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.metadata = {**asset.metadata, **metadata_patch}
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
changed = False
|
||||
for tid in tag_ids:
|
||||
if tid not in asset.tag_ids:
|
||||
asset.tag_ids.append(tid)
|
||||
changed = True
|
||||
if changed:
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
asset = self._assets.get(aid)
|
||||
if asset:
|
||||
asset.tag_ids = list(tag_ids)
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
if aid in self._assets:
|
||||
del self._assets[aid]
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
Executable → Regular
+2
-82
@@ -127,90 +127,10 @@ class SQLAlchemyAssetRepository:
|
||||
return False
|
||||
|
||||
def batch_delete(self, asset_ids: list[str]) -> int:
|
||||
"""批量删除素材(软删除,标记 status=deleted),返回实际影响数量。"""
|
||||
"""批量删除素材,返回实际删除数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
count = (
|
||||
self.session.query(AssetModel)
|
||||
.filter(AssetModel.id.in_(asset_ids), AssetModel.status != "deleted")
|
||||
.update({AssetModel.status: "deleted", AssetModel.updated_at: now}, synchronize_session=False)
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def batch_update_metadata(self, asset_ids: list[str], metadata_patch: dict[str, object]) -> int:
|
||||
"""批量更新素材 metadata(合并 patch),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
# 逐条读取 + 合并 + 更新,保证 JSON 合并正确
|
||||
models = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
|
||||
count = 0
|
||||
for model in models:
|
||||
existing = {}
|
||||
if model.classification_result:
|
||||
try:
|
||||
existing = json.loads(model.classification_result)
|
||||
except Exception:
|
||||
existing = {}
|
||||
merged = {**existing, **metadata_patch}
|
||||
model.classification_result = json.dumps(merged, ensure_ascii=False)
|
||||
model.updated_at = now
|
||||
count += 1
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def batch_add_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量给素材添加标签(合并去重),返回实际影响数量。"""
|
||||
if not asset_ids or not tag_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_tag_ids = list(set(tag_ids))
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
# 查询现有标签
|
||||
existing = {
|
||||
row.tag_id
|
||||
for row in self.session.query(AssetTagModel.tag_id).filter(AssetTagModel.asset_id == aid).all()
|
||||
}
|
||||
new_tags = [t for t in clean_tag_ids if t not in existing]
|
||||
if new_tags:
|
||||
for tid in new_tags:
|
||||
self.session.add(AssetTagModel(asset_id=aid, tag_id=tid))
|
||||
# 更新 updated_at
|
||||
self.session.query(AssetModel).filter(AssetModel.id == aid).update(
|
||||
{AssetModel.updated_at: now}, synchronize_session=False
|
||||
)
|
||||
count += 1
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
def batch_replace_tags(self, asset_ids: list[str], tag_ids: list[str]) -> int:
|
||||
"""批量替换素材标签(全量覆盖),返回实际影响数量。"""
|
||||
if not asset_ids:
|
||||
return 0
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
clean_tag_ids = list(set(tag_ids))
|
||||
count = 0
|
||||
for aid in asset_ids:
|
||||
# 先删再加
|
||||
self.session.query(AssetTagModel).filter(AssetTagModel.asset_id == aid).delete(synchronize_session=False)
|
||||
for tid in clean_tag_ids:
|
||||
self.session.add(AssetTagModel(asset_id=aid, tag_id=tid))
|
||||
# 更新 updated_at
|
||||
self.session.query(AssetModel).filter(AssetModel.id == aid).update(
|
||||
{AssetModel.updated_at: now}, synchronize_session=False
|
||||
)
|
||||
count += 1
|
||||
count = self.session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).delete(synchronize_session=False)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
Executable → Regular
-6
@@ -54,8 +54,6 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect,
|
||||
transition_duration=clip.transition_duration,
|
||||
playback_speed=clip.playback_speed,
|
||||
status=clip.status,
|
||||
config=clip.config,
|
||||
)
|
||||
@@ -78,8 +76,6 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
model.start_time = clip.start_time
|
||||
model.duration = clip.duration
|
||||
model.transition_effect = clip.transition_effect
|
||||
model.transition_duration = clip.transition_duration
|
||||
model.playback_speed = clip.playback_speed
|
||||
model.status = clip.status
|
||||
model.config = clip.config
|
||||
model.updated_at = clip.updated_at
|
||||
@@ -124,8 +120,6 @@ class SQLAlchemyEditPlanClipRepository:
|
||||
start_time=model.start_time or 0.0,
|
||||
duration=model.duration or 0.0,
|
||||
transition_effect=model.transition_effect or "cut",
|
||||
transition_duration=getattr(model, "transition_duration", 0.0) or 0.0,
|
||||
playback_speed=model.playback_speed or 1.0,
|
||||
status=EditPlanClipStatus(model.status) if model.status else EditPlanClipStatus.PENDING,
|
||||
config=model.config or {},
|
||||
created_at=model.created_at,
|
||||
|
||||
Executable → Regular
-57
@@ -100,63 +100,6 @@ class SQLAlchemyGeneratedVideoRepository:
|
||||
)
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def list_paginated(
|
||||
self,
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
review_status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[GeneratedVideo], int]:
|
||||
"""分页查询成片列表,支持按项目、状态、复核状态筛选。"""
|
||||
query = self.session.query(GeneratedVideoModel)
|
||||
|
||||
if project_id:
|
||||
query = query.filter(GeneratedVideoModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(GeneratedVideoModel.status == status)
|
||||
if review_status:
|
||||
query = query.filter(GeneratedVideoModel.review_status == review_status)
|
||||
|
||||
total = query.count()
|
||||
|
||||
models = (
|
||||
query.order_by(GeneratedVideoModel.generated_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [self._to_domain(model) for model in models], total
|
||||
|
||||
def update_review_status(self, video_id: str, review_status: str) -> GeneratedVideo | None:
|
||||
"""更新成片复核状态。"""
|
||||
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
model.review_status = review_status
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return self._to_domain(model)
|
||||
|
||||
def update_thumbnail(self, video_id: str, thumbnail_url: str) -> bool:
|
||||
"""更新成片封面图URL。"""
|
||||
model = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == video_id).first()
|
||||
if model is None:
|
||||
return False
|
||||
model.thumbnail_url = thumbnail_url
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def get_by_ids(self, video_ids: list[str]) -> list[GeneratedVideo]:
|
||||
"""批量获取成片记录。"""
|
||||
if not video_ids:
|
||||
return []
|
||||
models = self.session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id.in_(video_ids)).all()
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
@staticmethod
|
||||
def _to_domain(model: GeneratedVideoModel) -> GeneratedVideo:
|
||||
return GeneratedVideo(
|
||||
|
||||
@@ -21,10 +21,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
progress=model.progress,
|
||||
result_count=int(model.result_count or 0),
|
||||
error_message=model.error_message,
|
||||
error_info=dict(model.error_info) if model.error_info else {},
|
||||
retry_count=model.retry_count or 0,
|
||||
auto_retry_enabled=bool(model.auto_retry_enabled),
|
||||
auto_retry_max=model.auto_retry_max or 0,
|
||||
started_at=model.started_at,
|
||||
completed_at=model.completed_at,
|
||||
created_by_user_id=model.created_by_user_id,
|
||||
@@ -55,10 +51,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
progress=task.progress,
|
||||
result_count=task.result_count,
|
||||
error_message=task.error_message,
|
||||
error_info=task.error_info or None,
|
||||
retry_count=task.retry_count or 0,
|
||||
auto_retry_enabled=task.auto_retry_enabled,
|
||||
auto_retry_max=task.auto_retry_max or 0,
|
||||
started_at=task.started_at,
|
||||
completed_at=task.completed_at,
|
||||
created_by_user_id=task.created_by_user_id,
|
||||
@@ -135,68 +127,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
)
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def list_by_user_filtered(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[GenerationTask]:
|
||||
"""按用户+状态筛选任务列表。"""
|
||||
query = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id)
|
||||
if status:
|
||||
query = query.filter(GenerationTaskModel.status == status)
|
||||
query = query.order_by(GenerationTaskModel.created_at.desc())
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
return [_to_domain(m) for m in query.all()]
|
||||
|
||||
def count_by_user_filtered(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
) -> int:
|
||||
"""按用户+状态筛选计数。"""
|
||||
query = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id)
|
||||
if status:
|
||||
query = query.filter(GenerationTaskModel.status == status)
|
||||
return query.count()
|
||||
|
||||
def list_by_project_filtered(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[GenerationTask]:
|
||||
"""按项目+状态筛选任务列表。"""
|
||||
query = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(GenerationTaskModel.status == status)
|
||||
query = query.order_by(GenerationTaskModel.created_at.desc())
|
||||
if offset:
|
||||
query = query.offset(offset)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
return [_to_domain(m) for m in query.all()]
|
||||
|
||||
def count_by_project_filtered(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
) -> int:
|
||||
"""按项目+状态筛选计数。"""
|
||||
query = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(GenerationTaskModel.status == status)
|
||||
return query.count()
|
||||
|
||||
def update(self, task: GenerationTask) -> GenerationTask:
|
||||
model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first()
|
||||
if model is None:
|
||||
@@ -213,10 +143,6 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.progress = task.progress
|
||||
model.result_count = task.result_count
|
||||
model.error_message = task.error_message
|
||||
model.error_info = task.error_info or None
|
||||
model.retry_count = task.retry_count or 0
|
||||
model.auto_retry_enabled = task.auto_retry_enabled
|
||||
model.auto_retry_max = task.auto_retry_max or 0
|
||||
model.started_at = task.started_at
|
||||
model.completed_at = task.completed_at
|
||||
model.source_edit_plan_id = task.source_edit_plan_id or None
|
||||
|
||||
@@ -196,8 +196,6 @@ class EditPlanClipModel(Base):
|
||||
start_time = Column(Float, nullable=False, default=0.0)
|
||||
duration = Column(Float, nullable=False, default=0.0)
|
||||
transition_effect = Column(String(20), nullable=False, default="cut")
|
||||
transition_duration = Column(Float, nullable=False, default=0.0)
|
||||
playback_speed = Column(Float, nullable=False, default=1.0)
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
config = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -252,10 +250,6 @@ class GenerationTaskModel(Base):
|
||||
progress = Column(Float, nullable=False, default=0.0)
|
||||
result_count = Column(Float, nullable=False, default=0)
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
error_info = Column(JSON, nullable=True)
|
||||
retry_count = Column(Integer, nullable=False, default=0)
|
||||
auto_retry_enabled = Column(Boolean, nullable=False, default=False)
|
||||
auto_retry_max = Column(Integer, nullable=False, default=0)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_by_user_id = Column(String(36), nullable=False, default="", index=True)
|
||||
|
||||
Executable → Regular
+18
-118
@@ -2,14 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
EditPlanModel,
|
||||
TemplateCategoryModel,
|
||||
TemplateModel,
|
||||
TemplateSegmentModel,
|
||||
@@ -31,26 +28,18 @@ class SQLAlchemyTemplateRepository:
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
category: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
) -> List[Template]:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
models = (
|
||||
self.session.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(TemplateModel.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
query = query.filter(TemplateModel.mode == mode)
|
||||
if keyword:
|
||||
like_pattern = f"%{keyword}%"
|
||||
query = query.filter(TemplateModel.name.like(like_pattern))
|
||||
if tag:
|
||||
# JSON 数组包含指定标签(MySQL JSON_CONTAINS / SQLite json_each 兼容写法用 LIKE)
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
models = query.order_by(TemplateModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
templates = [self._model_to_entity(m) for m in models]
|
||||
# 批量加载所有 segments,避免 N+1 查询
|
||||
if templates:
|
||||
@@ -153,77 +142,15 @@ class SQLAlchemyTemplateRepository:
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def count_by_user(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
category: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
mode: Optional[str] = None,
|
||||
) -> int:
|
||||
query = self.session.query(TemplateModel).filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
if category:
|
||||
query = query.filter(TemplateModel.category == category)
|
||||
if mode:
|
||||
query = query.filter(TemplateModel.mode == mode)
|
||||
if keyword:
|
||||
query = query.filter(TemplateModel.name.like(f"%{keyword}%"))
|
||||
if tag:
|
||||
query = query.filter(TemplateModel.tags.like(f'%"{tag}"%'))
|
||||
return query.count()
|
||||
|
||||
def copy_template(self, template_id: str, user_id: str, new_name: str) -> Template:
|
||||
"""复制模板(含所有 segments)。"""
|
||||
source = self.get(template_id, user_id)
|
||||
if source is None:
|
||||
raise ValueError(f"Template {template_id} not found")
|
||||
|
||||
new_id = str(uuid.uuid4())
|
||||
new_template = Template(
|
||||
id=new_id,
|
||||
user_id=user_id,
|
||||
name=new_name,
|
||||
mode=source.mode,
|
||||
category=source.category,
|
||||
tags=list(source.tags),
|
||||
title_config=dict(source.title_config),
|
||||
subtitle_config=dict(source.subtitle_config),
|
||||
bgm_config=dict(source.bgm_config),
|
||||
estimated_duration=source.estimated_duration,
|
||||
is_active=True,
|
||||
)
|
||||
created = self.create(new_template)
|
||||
|
||||
# 复制 segments
|
||||
new_segments: List[TemplateSegment] = []
|
||||
for seg in source.segments:
|
||||
new_seg = TemplateSegment(
|
||||
id=str(uuid.uuid4()),
|
||||
template_id=new_id,
|
||||
segment_order=seg.segment_order,
|
||||
duration_min=seg.duration_min,
|
||||
duration_max=seg.duration_max,
|
||||
material_type=seg.material_type,
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return (
|
||||
self.session.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
)
|
||||
new_segments.append(new_seg)
|
||||
model = TemplateSegmentModel(
|
||||
id=new_seg.id,
|
||||
template_id=new_seg.template_id,
|
||||
segment_order=new_seg.segment_order,
|
||||
duration_min=new_seg.duration_min,
|
||||
duration_max=new_seg.duration_max,
|
||||
material_type=new_seg.material_type,
|
||||
)
|
||||
self.session.add(model)
|
||||
if new_segments:
|
||||
self.session.commit()
|
||||
|
||||
created.segments = new_segments
|
||||
return created
|
||||
.count()
|
||||
)
|
||||
|
||||
# ── Segments ──
|
||||
|
||||
@@ -307,33 +234,6 @@ class SQLAlchemyTemplateRepository:
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
def list_tags(self, user_id: str) -> List[str]:
|
||||
"""获取用户所有模板的标签(去重)。"""
|
||||
models = (
|
||||
self.session.query(TemplateModel)
|
||||
.filter(
|
||||
TemplateModel.user_id == user_id,
|
||||
TemplateModel.is_active.is_(True),
|
||||
TemplateModel.tags.isnot(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
tags_set: set[str] = set()
|
||||
for m in models:
|
||||
if m.tags:
|
||||
for t in m.tags:
|
||||
if t:
|
||||
tags_set.add(t)
|
||||
return sorted(tags_set)
|
||||
|
||||
# ── Usage Stats ──
|
||||
|
||||
def get_usage_count(self, template_id: str) -> int:
|
||||
"""获取模板被使用的次数(关联的剪辑计划数量)。"""
|
||||
return self.session.query(EditPlanModel).filter(EditPlanModel.template_id == template_id).count()
|
||||
|
||||
# ── Mapping helpers ──
|
||||
|
||||
@staticmethod
|
||||
|
||||
Executable → Regular
-19
@@ -103,25 +103,6 @@ class SQLAlchemyTitleLibraryRepository:
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def increment_usage_count(self, title_id: str, user_id: str, increment: int = 1) -> bool:
|
||||
"""递增标题使用次数。返回是否成功。"""
|
||||
from sqlalchemy import func
|
||||
|
||||
model = (
|
||||
self.session.query(TitleLibraryModel)
|
||||
.filter(
|
||||
TitleLibraryModel.id == title_id,
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return False
|
||||
model.usage_count = (model.usage_count or 0) + increment
|
||||
model.updated_at = func.now()
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
|
||||
return (
|
||||
self.session.query(TitleLibraryModel)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Mock TTS 服务实现.
|
||||
|
||||
使用 FFmpeg 合成简单音频模拟人声:
|
||||
- 不同音色用不同的基频(sine 波频率)
|
||||
- 语速通过 atempo 调整
|
||||
- 语调通过 asetrate 调整
|
||||
- 加一点 tremolo 效果让声音更自然
|
||||
|
||||
用于开发测试,不依赖外部 TTS 服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from packages.domain.voice_presets import get_voice, list_voices
|
||||
from packages.ports.tts_service import TtsError, TtsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mock 时长估算:每字约 0.3 秒(中文)
|
||||
_CHARS_PER_SECOND = 3.3
|
||||
|
||||
|
||||
class MockTtsService(TtsService):
|
||||
"""Mock TTS 服务 — 用 FFmpeg 合成测试音频."""
|
||||
|
||||
def __init__(self, ffmpeg_bin: str = "ffmpeg") -> None:
|
||||
self._ffmpeg_bin = ffmpeg_bin
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "mock"
|
||||
|
||||
def available_voices(self) -> list[str]:
|
||||
return [v.voice_id for v in list_voices(provider="mock")]
|
||||
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
voice_id: str = "",
|
||||
speed: float = 1.0,
|
||||
pitch: float = 0.0,
|
||||
output_path: Path | None = None,
|
||||
sample_rate: int = 22050,
|
||||
format: str = "wav",
|
||||
) -> Path:
|
||||
"""合成 Mock 音频.
|
||||
|
||||
用 FFmpeg sine 波合成带轻微调制的音频,模拟人声。
|
||||
时长根据文本长度估算。
|
||||
"""
|
||||
if not text.strip():
|
||||
raise TtsError("文本不能为空")
|
||||
|
||||
# 语速边界
|
||||
if speed <= 0:
|
||||
speed = 1.0
|
||||
speed = max(0.5, min(2.0, speed))
|
||||
|
||||
# 语调边界
|
||||
pitch = max(-12, min(12, pitch))
|
||||
|
||||
# 解析音色
|
||||
voice = get_voice(voice_id) if voice_id else get_voice("female_warm")
|
||||
if voice is None:
|
||||
voice = get_voice("female_warm")
|
||||
|
||||
# 计算基频(从 provider_voice_id 里提取,或者按音色默认)
|
||||
base_freq = self._extract_freq(voice.provider_voice_id, voice.gender.value)
|
||||
|
||||
# 计算时长(按文本长度)
|
||||
duration = self.estimate_duration(text, speed=speed)
|
||||
duration = max(0.5, duration) # 最短 0.5 秒
|
||||
|
||||
# 输出路径
|
||||
if output_path is None:
|
||||
suffix = f".{format}"
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
tmp.close()
|
||||
output_path = Path(tmp.name)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
self._synthesize_with_ffmpeg(
|
||||
output_path=output_path,
|
||||
base_freq=base_freq,
|
||||
duration=duration,
|
||||
speed=speed,
|
||||
pitch=pitch,
|
||||
sample_rate=sample_rate,
|
||||
format=format,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Mock TTS 合成失败: %s", e)
|
||||
raise TtsError(f"Mock TTS 合成失败: {e}") from e
|
||||
|
||||
return output_path
|
||||
|
||||
def estimate_duration(self, text: str, *, speed: float = 1.0) -> float:
|
||||
"""估算音频时长.
|
||||
|
||||
按中文字符数估算:每字约 0.3 秒。
|
||||
"""
|
||||
if not text:
|
||||
return 0.0
|
||||
# 去除空白后的字符数
|
||||
char_count = len([c for c in text if not c.isspace()])
|
||||
if char_count == 0:
|
||||
return 0.0
|
||||
base_duration = char_count / _CHARS_PER_SECOND
|
||||
return base_duration / max(0.1, speed)
|
||||
|
||||
def _extract_freq(self, provider_voice_id: str, gender: str) -> float:
|
||||
"""从 provider_voice_id 提取基频,或按性别给默认值."""
|
||||
if provider_voice_id.startswith("sine_"):
|
||||
try:
|
||||
return float(provider_voice_id.split("_")[1])
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
# 按性别给默认基频
|
||||
if gender == "male":
|
||||
return 120.0
|
||||
elif gender == "child":
|
||||
return 350.0
|
||||
else: # female
|
||||
return 220.0
|
||||
|
||||
def _synthesize_with_ffmpeg(
|
||||
self,
|
||||
*,
|
||||
output_path: Path,
|
||||
base_freq: float,
|
||||
duration: float,
|
||||
speed: float,
|
||||
pitch: float,
|
||||
sample_rate: int,
|
||||
format: str,
|
||||
) -> None:
|
||||
"""使用 FFmpeg 合成音频.
|
||||
|
||||
效果链:
|
||||
1. sine 波生成基频
|
||||
2. tremolo 增加轻微颤音
|
||||
3. aeval 模拟简单的音色变化(让声音不那么单调)
|
||||
4. atempo 调整语速
|
||||
5. asetrate 调整语调
|
||||
6. volume 调整音量
|
||||
"""
|
||||
# 语调频率偏移因子(每半音 = 2^(1/12) ≈ 1.05946)
|
||||
pitch_factor = 2 ** (pitch / 12)
|
||||
|
||||
# 颤音参数
|
||||
tremolo_freq = 5.0 # 5Hz 颤音
|
||||
tremolo_depth = 0.3 # 30% 深度
|
||||
|
||||
# 构建滤镜链
|
||||
filters: list[str] = []
|
||||
|
||||
# 生成基频 + 泛音(让声音更丰富)
|
||||
# 用多个 sine 波叠加模拟更自然的音色
|
||||
filter_parts = []
|
||||
|
||||
# 主音 + 轻微频率调制
|
||||
filter_parts.append(f"sine=frequency={base_freq}:duration={duration}:sample_rate={sample_rate}")
|
||||
|
||||
# 颤音效果
|
||||
filter_parts.append(f"tremolo=f={tremolo_freq}:d={tremolo_depth}")
|
||||
|
||||
# 语速调整(同时调整时长)
|
||||
if abs(speed - 1.0) > 0.01:
|
||||
filter_parts.append(f"atempo={speed:.3f}")
|
||||
|
||||
# 语调调整(通过采样率变化实现,同时补偿时长)
|
||||
if abs(pitch) > 0.01:
|
||||
new_rate = int(sample_rate * pitch_factor)
|
||||
filter_parts.append(f"asetrate={new_rate}")
|
||||
filter_parts.append(f"aresample={sample_rate}")
|
||||
|
||||
# 音量包络:淡入淡出
|
||||
fade_in = min(0.05, duration * 0.1)
|
||||
fade_out = min(0.1, duration * 0.2)
|
||||
filter_parts.append(f"afade=t=in:d={fade_in}")
|
||||
filter_parts.append(f"afade=t=out:st={max(0, duration - fade_out)}:d={fade_out}")
|
||||
|
||||
# 音量调整到合适大小
|
||||
filter_parts.append("volume=0.3")
|
||||
|
||||
filter_complex = ",".join(filter_parts)
|
||||
|
||||
# 编码参数
|
||||
if format == "mp3":
|
||||
codec_args = ["-acodec", "libmp3lame", "-b:a", "128k"]
|
||||
else:
|
||||
codec_args = ["-acodec", "pcm_s16le"]
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
filter_complex,
|
||||
*codec_args,
|
||||
"-ar",
|
||||
str(sample_rate),
|
||||
"-ac",
|
||||
"1",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
logger.debug("Mock TTS FFmpeg 命令: %s", " ".join(command))
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(30, duration * 2 + 10),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise TtsError(f"FFmpeg 合成失败: {result.stderr[-500:]}")
|
||||
|
||||
if not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise TtsError("输出文件为空或不存在")
|
||||
@@ -21,19 +21,13 @@ from .duplication import (
|
||||
from .generated_videos import (
|
||||
GetGeneratedVideoDownloadUrlUseCase,
|
||||
GetGeneratedVideoUseCase,
|
||||
GetVideosByIdsUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
ListGeneratedVideosPaginatedUseCase,
|
||||
ListGeneratedVideosUseCase,
|
||||
UpdateVideoReviewStatusUseCase,
|
||||
)
|
||||
from .generation_tasks import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
GetGenerationTaskUseCase,
|
||||
ListGenerationTasksResult,
|
||||
ListUserTasksFilteredUseCase,
|
||||
RetryGenerationTaskUseCase,
|
||||
)
|
||||
from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from .jobs import (
|
||||
@@ -71,9 +65,6 @@ __all__ = [
|
||||
"CreateGenerationTaskCommand",
|
||||
"CreateGenerationTaskUseCase",
|
||||
"GetGenerationTaskUseCase",
|
||||
"ListGenerationTasksResult",
|
||||
"ListUserTasksFilteredUseCase",
|
||||
"RetryGenerationTaskUseCase",
|
||||
"CreateJobCommand",
|
||||
"CreateJobUseCase",
|
||||
"CreateProjectCommand",
|
||||
@@ -85,7 +76,6 @@ __all__ = [
|
||||
"GetDuplicationDetailUseCase",
|
||||
"GetGeneratedVideoDownloadUrlUseCase",
|
||||
"GetGeneratedVideoUseCase",
|
||||
"GetVideosByIdsUseCase",
|
||||
"GetJobStatisticsUseCase",
|
||||
"GetJobUseCase",
|
||||
"GetProjectUseCase",
|
||||
@@ -93,7 +83,6 @@ __all__ = [
|
||||
"ListAssetsUseCase",
|
||||
"ListDuplicationRecordsUseCase",
|
||||
"ListGeneratedVideosByTaskUseCase",
|
||||
"ListGeneratedVideosPaginatedUseCase",
|
||||
"ListGeneratedVideosUseCase",
|
||||
"ListJobsUseCase",
|
||||
"ListProjectsUseCase",
|
||||
@@ -106,7 +95,6 @@ __all__ = [
|
||||
"SubmitJobUseCase",
|
||||
"UpdateJobProgressCommand",
|
||||
"UpdateJobProgressUseCase",
|
||||
"UpdateVideoReviewStatusUseCase",
|
||||
"UploadForDuplicationCommand",
|
||||
"UploadForDuplicationUseCase",
|
||||
]
|
||||
|
||||
Executable → Regular
-46
@@ -14,32 +14,6 @@ class ListGeneratedVideosUseCase:
|
||||
return self.generated_video_repository.list_by_project(project_id.strip())
|
||||
|
||||
|
||||
class ListGeneratedVideosPaginatedUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
project_id: str | None = None,
|
||||
status: str | None = None,
|
||||
review_status: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[GeneratedVideo], int]:
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1 or page_size > 100:
|
||||
page_size = 20
|
||||
return self.generated_video_repository.list_paginated(
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
review_status=review_status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
class GetGeneratedVideoUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
@@ -67,23 +41,3 @@ class GetGeneratedVideoDownloadUrlUseCase:
|
||||
if item is None:
|
||||
return None
|
||||
return item.file_url
|
||||
|
||||
|
||||
class UpdateVideoReviewStatusUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(self, video_id: str, review_status: str) -> GeneratedVideo | None:
|
||||
if not video_id.strip():
|
||||
raise ValueError("video_id 不能为空")
|
||||
if review_status not in ("pending_review", "approved", "rejected"):
|
||||
raise ValueError(f"无效的 review_status: {review_status}")
|
||||
return self.generated_video_repository.update_review_status(video_id.strip(), review_status)
|
||||
|
||||
|
||||
class GetVideosByIdsUseCase:
|
||||
def __init__(self, generated_video_repository: GeneratedVideoRepository):
|
||||
self.generated_video_repository = generated_video_repository
|
||||
|
||||
def execute(self, video_ids: list[str]) -> list[GeneratedVideo]:
|
||||
return self.generated_video_repository.get_by_ids(video_ids)
|
||||
|
||||
Executable → Regular
+2
-66
@@ -21,8 +21,6 @@ class CreateGenerationTaskCommand:
|
||||
source_edit_plan_id: str = ""
|
||||
asset_select_mode: str = ""
|
||||
batch_id: str = ""
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
@@ -44,12 +42,12 @@ class CreateGenerationTaskUseCase:
|
||||
progress=0.0,
|
||||
result_count=0,
|
||||
error_message="",
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
created_by_user_id=command.created_by_user_id,
|
||||
source_edit_plan_id=command.source_edit_plan_id,
|
||||
asset_select_mode=command.asset_select_mode,
|
||||
batch_id=command.batch_id,
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
@@ -60,65 +58,3 @@ class GetGenerationTaskUseCase:
|
||||
|
||||
def execute(self, task_id: str) -> GenerationTask | None:
|
||||
return self.generation_task_repository.get(task_id)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ListTasksFilter:
|
||||
"""任务列表筛选条件。"""
|
||||
|
||||
status: str | None = None # pending, running, completed, failed, cancelled
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ListGenerationTasksResult:
|
||||
"""带筛选和分页的任务列表结果。"""
|
||||
|
||||
items: list[GenerationTask]
|
||||
total: int
|
||||
|
||||
|
||||
class ListUserTasksFilteredUseCase:
|
||||
"""按用户+筛选条件查询任务列表。"""
|
||||
|
||||
def __init__(self, generation_task_repository: GenerationTaskRepository):
|
||||
self.generation_task_repository = generation_task_repository
|
||||
|
||||
def execute(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> ListGenerationTasksResult:
|
||||
items = self.generation_task_repository.list_by_user_filtered(
|
||||
user_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
total = self.generation_task_repository.count_by_user_filtered(
|
||||
user_id,
|
||||
status=status,
|
||||
)
|
||||
return ListGenerationTasksResult(items=items, total=total)
|
||||
|
||||
|
||||
class RetryGenerationTaskUseCase:
|
||||
"""原地重试失败的任务(重置状态+递增retry_count)。
|
||||
|
||||
与创建新任务不同:复用同一个 task_id,保留历史关联。
|
||||
"""
|
||||
|
||||
def __init__(self, generation_task_repository: GenerationTaskRepository):
|
||||
self.generation_task_repository = generation_task_repository
|
||||
|
||||
def execute(self, task_id: str) -> GenerationTask:
|
||||
task = self.generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise ValueError(f"任务不存在: {task_id}")
|
||||
if not task.is_failed:
|
||||
raise ValueError(f"只有失败状态的任务才能重试,当前状态: {task.status.value}")
|
||||
task.mark_pending_from_failed()
|
||||
self.generation_task_repository.update(task)
|
||||
return task
|
||||
|
||||
Executable → Regular
-15
@@ -49,21 +49,6 @@ class CreateCategoryCommand:
|
||||
name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CopyTemplateCommand:
|
||||
template_id: str
|
||||
user_id: str
|
||||
new_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListTemplatesFilter:
|
||||
category: Optional[str] = None
|
||||
tag: Optional[str] = None
|
||||
keyword: Optional[str] = None
|
||||
mode: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidateTemplateCommand:
|
||||
template_id: str
|
||||
|
||||
Executable → Regular
+1
-74
@@ -7,10 +7,8 @@ from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
from packages.application.template.commands import (
|
||||
CopyTemplateCommand,
|
||||
CreateCategoryCommand,
|
||||
CreateTemplateCommand,
|
||||
ListTemplatesFilter,
|
||||
UpdateTemplateCommand,
|
||||
ValidateTemplateCommand,
|
||||
)
|
||||
@@ -104,40 +102,8 @@ class ListTemplatesUseCase:
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
filter: Optional[ListTemplatesFilter] = None,
|
||||
) -> List[Template]:
|
||||
if filter is None:
|
||||
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
|
||||
return self.repository.list_by_user(
|
||||
user_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
category=filter.category,
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
)
|
||||
|
||||
|
||||
class CountTemplatesUseCase:
|
||||
def __init__(self, repository: TemplateRepositoryPort) -> None:
|
||||
self.repository = repository
|
||||
|
||||
def execute(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
filter: Optional[ListTemplatesFilter] = None,
|
||||
) -> int:
|
||||
if filter is None:
|
||||
return self.repository.count_by_user(user_id)
|
||||
return self.repository.count_by_user(
|
||||
user_id,
|
||||
category=filter.category,
|
||||
tag=filter.tag,
|
||||
keyword=filter.keyword,
|
||||
mode=filter.mode,
|
||||
)
|
||||
return self.repository.list_by_user(user_id, skip=skip, limit=limit)
|
||||
|
||||
|
||||
class GetTemplateUseCase:
|
||||
@@ -209,23 +175,6 @@ class DeleteTemplateUseCase:
|
||||
return self.repository.delete(template_id, user_id)
|
||||
|
||||
|
||||
class CopyTemplateUseCase:
|
||||
def __init__(self, repository: TemplateRepositoryPort) -> None:
|
||||
self.repository = repository
|
||||
|
||||
def execute(self, command: CopyTemplateCommand) -> Template:
|
||||
existing = self.repository.get(command.template_id, command.user_id)
|
||||
if existing is None:
|
||||
raise NotFoundError(f"Template {command.template_id} not found")
|
||||
if not command.new_name or not command.new_name.strip():
|
||||
raise ValidationError("新模板名称不能为空")
|
||||
return self.repository.copy_template(
|
||||
command.template_id,
|
||||
command.user_id,
|
||||
command.new_name.strip(),
|
||||
)
|
||||
|
||||
|
||||
# ── Validate template ──
|
||||
|
||||
|
||||
@@ -309,25 +258,3 @@ class DeleteCategoryUseCase:
|
||||
|
||||
def execute(self, category_id: str, user_id: str) -> bool:
|
||||
return self.repository.delete_category(category_id, user_id)
|
||||
|
||||
|
||||
# ── Tags ──
|
||||
|
||||
|
||||
class ListTagsUseCase:
|
||||
def __init__(self, repository: TemplateRepositoryPort) -> None:
|
||||
self.repository = repository
|
||||
|
||||
def execute(self, user_id: str) -> List[str]:
|
||||
return self.repository.list_tags(user_id)
|
||||
|
||||
|
||||
# ── Usage Stats ──
|
||||
|
||||
|
||||
class GetTemplateUsageUseCase:
|
||||
def __init__(self, repository: TemplateRepositoryPort) -> None:
|
||||
self.repository = repository
|
||||
|
||||
def execute(self, template_id: str) -> int:
|
||||
return self.repository.get_usage_count(template_id)
|
||||
|
||||
Executable → Regular
-7
@@ -1,14 +1,11 @@
|
||||
"""Title library application module."""
|
||||
|
||||
from packages.application.title_library.commands import IncrementTitleUsageCommand, PickTitleCommand
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
GetTitleLibraryUseCase,
|
||||
IncrementTitleUsageUseCase,
|
||||
ListTitleLibraryUseCase,
|
||||
NotFoundError,
|
||||
PickTitleUseCase,
|
||||
QuotaExceededError,
|
||||
UpdateTitleLibraryUseCase,
|
||||
)
|
||||
@@ -17,11 +14,7 @@ __all__ = [
|
||||
"CreateTitleLibraryUseCase",
|
||||
"DeleteTitleLibraryUseCase",
|
||||
"GetTitleLibraryUseCase",
|
||||
"IncrementTitleUsageUseCase",
|
||||
"IncrementTitleUsageCommand",
|
||||
"ListTitleLibraryUseCase",
|
||||
"PickTitleUseCase",
|
||||
"PickTitleCommand",
|
||||
"UpdateTitleLibraryUseCase",
|
||||
"QuotaExceededError",
|
||||
"NotFoundError",
|
||||
|
||||
Executable → Regular
-14
@@ -28,17 +28,3 @@ class UpdateTitleLibraryCommand:
|
||||
tags: Optional[List[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
metadata_: Optional[dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class IncrementTitleUsageCommand:
|
||||
title_id: str
|
||||
user_id: str
|
||||
increment: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class PickTitleCommand:
|
||||
user_id: str
|
||||
category: Optional[str] = None
|
||||
exclude_ids: List[str] = field(default_factory=list)
|
||||
|
||||
Executable → Regular
-64
@@ -8,8 +8,6 @@ from typing import List, Optional
|
||||
from packages.adapters.sqlalchemy_impl.title_library_repository import SQLAlchemyTitleLibraryRepository
|
||||
from packages.application.title_library.commands import (
|
||||
CreateTitleLibraryCommand,
|
||||
IncrementTitleUsageCommand,
|
||||
PickTitleCommand,
|
||||
UpdateTitleLibraryCommand,
|
||||
)
|
||||
from packages.domain.quota import QuotaDimension, quota_checker
|
||||
@@ -102,68 +100,6 @@ class DeleteTitleLibraryUseCase:
|
||||
return self.repository.delete(title_id, user_id)
|
||||
|
||||
|
||||
class IncrementTitleUsageUseCase:
|
||||
"""递增标题使用次数。用于生成视频成功后,更新标题的使用统计。"""
|
||||
|
||||
def __init__(self, repository: SQLAlchemyTitleLibraryRepository) -> None:
|
||||
self.repository = repository
|
||||
|
||||
def execute(self, command: IncrementTitleUsageCommand) -> bool:
|
||||
if command.increment <= 0:
|
||||
return False
|
||||
return self.repository.increment_usage_count(
|
||||
command.title_id,
|
||||
command.user_id,
|
||||
increment=command.increment,
|
||||
)
|
||||
|
||||
|
||||
class PickTitleUseCase:
|
||||
"""智能选择一个标题。
|
||||
|
||||
策略:
|
||||
1. 可选按 category 过滤
|
||||
2. 排除指定的 title_ids(如本轮已用过的)
|
||||
3. 按使用次数升序,取最少的前 5 个
|
||||
4. 从中随机选一个,增加多样性
|
||||
5. 无可用标题时返回 None
|
||||
"""
|
||||
|
||||
_CANDIDATE_POOL_SIZE = 5
|
||||
|
||||
def __init__(self, repository: SQLAlchemyTitleLibraryRepository) -> None:
|
||||
self.repository = repository
|
||||
|
||||
def execute(self, command: PickTitleCommand) -> TitleLibraryItem | None:
|
||||
import random
|
||||
|
||||
# 取该用户所有活跃标题(或指定分类)
|
||||
all_titles = self.repository.list_by_user(
|
||||
command.user_id,
|
||||
category=command.category,
|
||||
is_active=True,
|
||||
skip=0,
|
||||
limit=500, # 取足够多的候选
|
||||
)
|
||||
|
||||
if not all_titles:
|
||||
return None
|
||||
|
||||
# 排除已使用/指定排除的
|
||||
exclude_set = set(command.exclude_ids or [])
|
||||
candidates = [t for t in all_titles if t.id not in exclude_set]
|
||||
if not candidates:
|
||||
# 排除后没了,就从全部里选
|
||||
candidates = all_titles
|
||||
|
||||
# 按使用次数升序,取最少的前 N 个
|
||||
candidates.sort(key=lambda t: t.usage_count)
|
||||
pool = candidates[: self._CANDIDATE_POOL_SIZE]
|
||||
|
||||
# 随机选一个
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
class QuotaExceededError(Exception):
|
||||
def __init__(self, dimension: str, limit: float, used: float) -> None:
|
||||
self.dimension = dimension
|
||||
|
||||
Executable → Regular
+13
-10
@@ -8,10 +8,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from subprocess import CalledProcessError, TimeoutExpired
|
||||
|
||||
from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,7 +59,7 @@ class AudioMerger:
|
||||
output_path = os.path.join(temp_dir, f"merged.{output_format}")
|
||||
|
||||
cmd = [
|
||||
FFMPEG_BIN,
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
@@ -74,16 +72,21 @@ class AudioMerger:
|
||||
output_path,
|
||||
]
|
||||
|
||||
try:
|
||||
run_ffmpeg(cmd, timeout=120)
|
||||
except CalledProcessError as e:
|
||||
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
|
||||
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"FFmpeg 合并失败: stderr={result.stderr}")
|
||||
raise AudioMergeError(f"FFmpeg 合并失败: {result.stderr[:500]}")
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
except TimeoutExpired:
|
||||
except subprocess.TimeoutExpired:
|
||||
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
|
||||
except AudioMergeError:
|
||||
raise
|
||||
|
||||
@@ -15,7 +15,6 @@ import httpx
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
from packages.shared.url_security import ALLOWED_AUDIO_MIME_TYPES, safe_download_bytes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -225,13 +224,10 @@ class TTSStreamingService:
|
||||
# ── 工具方法 ────────────────────────────────────────────
|
||||
|
||||
def _download_audio(self, url: str) -> bytes:
|
||||
"""下载音频数据(含 SSRF 防护 + 大小限制 + 重定向校验)。"""
|
||||
return safe_download_bytes(
|
||||
url,
|
||||
purpose="tts_streaming_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
"""下载音频数据。"""
|
||||
resp = httpx.get(url, timeout=60.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
async def _stream_audio_chunks(self, websocket: Any, audio_data: bytes) -> int:
|
||||
"""将音频数据分块通过 WebSocket 推送。
|
||||
|
||||
Regular → Executable
+15
-23
@@ -19,19 +19,16 @@ from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceAuthError, CosyVoiceError, CosyVoiceService
|
||||
from packages.application.cosyvoice_service import (
|
||||
CosyVoiceAuthError,
|
||||
CosyVoiceError,
|
||||
CosyVoiceService,
|
||||
)
|
||||
from packages.application.tts_job.audio_merger import AudioMerger
|
||||
from packages.application.tts_job.text_splitter import split_text
|
||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
||||
from packages.ports.tts_job_repository import TTSJobRepository
|
||||
from packages.shared.storage import SharedStorageService, get_shared_storage_service
|
||||
from packages.shared.url_security import (
|
||||
ALLOWED_AUDIO_MIME_TYPES,
|
||||
UrlSecurityError,
|
||||
safe_download_bytes,
|
||||
safe_download_file,
|
||||
validate_url_safety,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,13 +96,10 @@ class TTSWorkflowService:
|
||||
content_type = content_type_map.get(audio_format, "application/octet-stream")
|
||||
|
||||
try:
|
||||
# 安全下载临时音频(SSRF 防护 + 大小限制 + 重定向校验)
|
||||
audio_data = safe_download_bytes(
|
||||
temp_url,
|
||||
purpose="tts_audio_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
# 下载临时音频
|
||||
resp = httpx.get(temp_url, timeout=60.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
audio_data = resp.content
|
||||
|
||||
# 上传到 OSS
|
||||
file_obj = io.BytesIO(audio_data)
|
||||
@@ -469,15 +463,13 @@ class TTSWorkflowService:
|
||||
|
||||
total_duration += result.get("duration", 0.0)
|
||||
|
||||
# 安全下载分段音频到临时文件(SSRF 防护 + 大小限制)
|
||||
# 下载分段音频到临时文件
|
||||
resp = httpx.get(audio_url, timeout=60.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
seg_path = os.path.join(temp_dir, f"seg_{idx:03d}.{job.format}")
|
||||
safe_download_file(
|
||||
audio_url,
|
||||
seg_path,
|
||||
purpose="tts_segment_download",
|
||||
allowed_mime_types=ALLOWED_AUDIO_MIME_TYPES,
|
||||
timeout=60.0,
|
||||
)
|
||||
with open(seg_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
audio_paths.append(seg_path)
|
||||
|
||||
# 合并
|
||||
|
||||
Executable → Regular
+2
-31
@@ -112,32 +112,14 @@ class SubtitleConfig(BaseModel):
|
||||
color: str = Field(default="#ffffff", description="文字颜色 (HEX)")
|
||||
size: int = Field(default=24, ge=12, le=60, description="字号")
|
||||
animation: TextAnimation = Field(default=TextAnimation.FADE_IN, description="入场动画")
|
||||
# ASR 自动字幕
|
||||
auto_generated: bool = Field(default=False, description="是否启用ASR自动生成字幕")
|
||||
language: str = Field(default="", description="字幕语言,空字符串表示自动检测(如 zh/en/ja)")
|
||||
max_chars_per_line: int = Field(default=20, ge=8, le=40, description="每行最多字符数")
|
||||
min_chars_per_segment: int = Field(default=8, ge=2, le=20, description="每段最少字符数(低于则合并)")
|
||||
|
||||
|
||||
class BGMConfig(BaseModel):
|
||||
"""BGM 配置"""
|
||||
|
||||
enabled: bool = Field(default=False, description="是否启用 BGM")
|
||||
source: BGMSource = Field(default=BGMSource.LIBRARY, description="BGM 来源")
|
||||
asset_id: str = Field(default="", description="BGM 素材 ID(来源为 library/upload 时使用)")
|
||||
preset_id: str = Field(default="", description="预设 BGM ID(来源为 ai_recommend 或使用内置库时使用)")
|
||||
audio_url: str = Field(default="", description="BGM 音频 URL(外部直链,优先级最高)")
|
||||
volume: float = Field(default=0.3, ge=0.0, le=1.0, description="BGM 音量 (0.0 ~ 1.0)")
|
||||
fade_in: float = Field(default=0.0, ge=0.0, le=30.0, description="淡入时长(秒)")
|
||||
fade_out: float = Field(default=0.0, ge=0.0, le=30.0, description="淡出时长(秒)")
|
||||
loop_enabled: bool = Field(default=True, description="BGM 是否循环播放以铺满整个视频时长")
|
||||
sidechain_enabled: bool = Field(default=False, description="是否启用人声闪避(有人声时 BGM 自动降低音量)")
|
||||
sidechain_ratio: float = Field(
|
||||
default=0.3, ge=0.0, le=1.0, description="人声闪避时 BGM 音量降低比例(0.3 = 降低30%)"
|
||||
)
|
||||
sidechain_attack: float = Field(default=0.02, ge=0.001, le=1.0, description="人声闪避攻击时间(秒)")
|
||||
sidechain_release: float = Field(default=0.5, ge=0.01, le=5.0, description="人声闪避释放时间(秒)")
|
||||
sidechain_threshold: float = Field(default=-25.0, ge=-60.0, le=0.0, description="人声闪避触发阈值(dB)")
|
||||
asset_id: str = Field(default="", description="BGM 素材 ID")
|
||||
volume: float = Field(default=0.3, ge=0.0, le=1.0, description="音量 (0.0 ~ 1.0)")
|
||||
|
||||
|
||||
# ── 完整 config 模型 ─────────────────────────────────────────────────────────
|
||||
@@ -203,20 +185,9 @@ DEFAULT_EDIT_PLAN_CONFIG: dict = {
|
||||
"animation": "fade_in",
|
||||
},
|
||||
"bgm": {
|
||||
"enabled": False,
|
||||
"source": "library",
|
||||
"asset_id": "",
|
||||
"preset_id": "",
|
||||
"audio_url": "",
|
||||
"volume": 0.3,
|
||||
"fade_in": 0.0,
|
||||
"fade_out": 0.0,
|
||||
"loop_enabled": True,
|
||||
"sidechain_enabled": False,
|
||||
"sidechain_ratio": 0.3,
|
||||
"sidechain_attack": 0.02,
|
||||
"sidechain_release": 0.5,
|
||||
"sidechain_threshold": -25.0,
|
||||
},
|
||||
"editing_mode": "one_take",
|
||||
}
|
||||
|
||||
Executable → Regular
-13
@@ -50,8 +50,6 @@ class EditPlanClip:
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0 # 0 表示使用全局默认值
|
||||
playback_speed: float = 1.0 # 0 或 1.0 表示原速,范围 0.25~4.0
|
||||
status: EditPlanClipStatus = EditPlanClipStatus.PENDING
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -70,8 +68,6 @@ class EditPlanClip:
|
||||
start_time: float = 0.0,
|
||||
duration: float = 0.0,
|
||||
transition_effect: str = "cut",
|
||||
transition_duration: float = 0.0,
|
||||
playback_speed: float = 1.0,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> EditPlanClip:
|
||||
"""创建剪辑计划片段"""
|
||||
@@ -83,13 +79,6 @@ class EditPlanClip:
|
||||
raise ValueError("start_time 不能为负数")
|
||||
if duration < 0:
|
||||
raise ValueError("duration 不能为负数")
|
||||
# 速度边界钳制
|
||||
if playback_speed <= 0:
|
||||
playback_speed = 1.0
|
||||
elif playback_speed < 0.25:
|
||||
playback_speed = 0.25
|
||||
elif playback_speed > 4.0:
|
||||
playback_speed = 4.0
|
||||
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
@@ -102,8 +91,6 @@ class EditPlanClip:
|
||||
start_time=start_time,
|
||||
duration=duration,
|
||||
transition_effect=transition_effect.strip() or "cut",
|
||||
transition_duration=max(0.0, transition_duration),
|
||||
playback_speed=playback_speed,
|
||||
status=EditPlanClipStatus.PENDING,
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
@@ -133,7 +133,6 @@ class AssetStatus(StrEnum):
|
||||
READY = "ready"
|
||||
PROCESSING = "processing"
|
||||
ERROR = "error"
|
||||
DELETED = "deleted"
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: object) -> "AssetStatus":
|
||||
|
||||
Executable → Regular
+3
-23
@@ -80,10 +80,6 @@ class GenerationTask:
|
||||
progress: float = 0.0
|
||||
result_count: int = 0
|
||||
error_message: str = ""
|
||||
error_info: dict = field(default_factory=dict)
|
||||
retry_count: int = 0
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
started_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
source_edit_plan_id: str = ""
|
||||
@@ -109,8 +105,6 @@ class GenerationTask:
|
||||
source_edit_plan_id: str = "",
|
||||
asset_select_mode: str = "",
|
||||
batch_id: str = "",
|
||||
auto_retry_enabled: bool = False,
|
||||
auto_retry_max: int = 0,
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
@@ -130,8 +124,6 @@ class GenerationTask:
|
||||
source_edit_plan_id=source_edit_plan_id.strip(),
|
||||
asset_select_mode=asset_select_mode,
|
||||
batch_id=batch_id,
|
||||
auto_retry_enabled=auto_retry_enabled,
|
||||
auto_retry_max=auto_retry_max,
|
||||
)
|
||||
|
||||
# ── 状态查询 ────────────────────────────────────────────────────────────
|
||||
@@ -211,14 +203,13 @@ class GenerationTask:
|
||||
self.result_count = result_count
|
||||
self.error_message = ""
|
||||
|
||||
def mark_failed(self, error_message: str, error_info: dict | None = None) -> None:
|
||||
def mark_failed(self, error_message: str) -> None:
|
||||
"""标记为失败(pending / running → failed)。
|
||||
|
||||
设置 error_message、error_info、completed_at。
|
||||
设置 error_message、completed_at。
|
||||
|
||||
Args:
|
||||
error_message: 错误信息
|
||||
error_info: 结构化错误信息(error_type, stack_trace, stage, failed_at等)
|
||||
|
||||
Raises:
|
||||
ValueError: 当前状态不允许转换到 failed
|
||||
@@ -226,14 +217,6 @@ class GenerationTask:
|
||||
self.transition_to(GenerationTaskStatus.FAILED)
|
||||
self.error_message = error_message
|
||||
self.completed_at = datetime.now(timezone.utc)
|
||||
if error_info is not None:
|
||||
self.error_info = error_info
|
||||
else:
|
||||
self.error_info = {
|
||||
"error_type": "UnknownError",
|
||||
"message": error_message,
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
def mark_cancelled(self) -> None:
|
||||
"""标记为已取消(pending / running → cancelled)。
|
||||
@@ -286,8 +269,7 @@ class GenerationTask:
|
||||
def mark_pending_from_failed(self) -> None:
|
||||
"""从失败状态重置为待处理(用于重试)。
|
||||
|
||||
清除 error_message、error_info、started_at、completed_at、progress,
|
||||
递增 retry_count。
|
||||
清除 error_message、started_at、completed_at、progress。
|
||||
|
||||
Raises:
|
||||
ValueError: 当前状态不是 failed
|
||||
@@ -296,9 +278,7 @@ class GenerationTask:
|
||||
raise ValueError(f"只有 failed 状态的任务可以重置为 pending,当前状态: {self.status.value}")
|
||||
self.transition_to(GenerationTaskStatus.PENDING)
|
||||
self.error_message = ""
|
||||
self.error_info = {}
|
||||
self.started_at = None
|
||||
self.completed_at = None
|
||||
self.progress = 0.0
|
||||
self.result_count = 0
|
||||
self.retry_count += 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user