Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cbd08f666 | |||
| 62ab361940 | |||
| ed3ab34028 | |||
| df016b18fb | |||
| c6aac862b1 | |||
| 6232300fb1 | |||
| fe2ab121e7 | |||
| 84db18a959 | |||
| 9501afe9b3 | |||
| 7407ec2957 | |||
| e45426fe7d | |||
| c540d88306 | |||
| 5fb49480e5 | |||
| d73fb2cf2b | |||
| 0eb1262db6 | |||
| 9a6136c9fd | |||
| 63f138e2d6 | |||
| d898856f50 | |||
| acffa364b3 | |||
| 1931cab504 | |||
| 9f9fd1ccb8 | |||
| 9fd12c7fb3 | |||
| 4805e961f0 | |||
| 1aeeb23621 | |||
| 4a21b89a0b | |||
| 2982ef5259 | |||
| 5fff7e518d | |||
| a93149b8e5 | |||
| 21aaf642ec | |||
| 8d86707e2e | |||
| 752de50557 | |||
| ba6c5f2598 | |||
| 78d1c88ca1 | |||
| c88be032c1 | |||
| 7f767e2dd1 | |||
| ea93387f98 | |||
| 7e172c0907 | |||
| 6db705a05d | |||
| 1c8cb20373 | |||
| 74c458e370 | |||
| a7d942f705 | |||
| f867897348 | |||
| 3d1b739e7f | |||
| f268e208de | |||
| b05966ff48 | |||
| 79b82978d8 | |||
| 08b51ffa1d | |||
| 23d2406c27 | |||
| def6ee2363 | |||
| 2b5b650b9e | |||
| 2b8326987e | |||
| 2081c72be6 | |||
| a681844a44 | |||
| 8a2d2df3cd | |||
| ff38ee0f2b | |||
| 368baf683b | |||
| 527eb61f19 | |||
| e9a6d19e00 | |||
| 241760ef39 | |||
| 3cd26e98db | |||
| c840f37a44 | |||
| edcd1a926f | |||
| 9ddaaf7f00 | |||
| 94aead4342 | |||
| 295d7f0765 | |||
| 7cffb193eb | |||
| e430d83f78 | |||
| f4b4f1fc4f | |||
| 58ff565c48 | |||
| eb4645314d | |||
| 17fbae13a8 | |||
| 41e421b44b | |||
| 9f86bd40ca |
@@ -0,0 +1 @@
|
||||
re-trigger
|
||||
+1
-1
@@ -1 +1 @@
|
||||
# CI trigger Fri Jun 26 09:53:28 PM CST 2026
|
||||
trigger: 1784009947
|
||||
|
||||
+1015
-1072
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""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,6 +19,7 @@ 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
|
||||
@@ -99,6 +100,10 @@ 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",
|
||||
|
||||
Regular → Executable
+3
-4
@@ -163,11 +163,10 @@ 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)
|
||||
if assets_in_library:
|
||||
asset_ids_to_delete = [a.id for a in assets_in_library]
|
||||
asset_repository.batch_delete(asset_ids_to_delete)
|
||||
for asset in assets_in_library:
|
||||
asset_repository.delete(asset.id)
|
||||
|
||||
# 删除素材库本身
|
||||
asset_library_repository.delete(library_id)
|
||||
|
||||
Regular → Executable
+181
-16
@@ -12,8 +12,11 @@ from app.dependencies import (
|
||||
)
|
||||
from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchDeleteResponse,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
UpdateAssetRequest,
|
||||
@@ -82,6 +85,15 @@ 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),
|
||||
@@ -101,11 +113,11 @@ def list_assets(
|
||||
if not filter_tag_ids:
|
||||
filter_tag_ids = None
|
||||
|
||||
# 需要内存过滤的标志(keyword/gender/style/tag_ids 无法在 DB 层过滤)
|
||||
needs_memory_filter = bool(keyword or gender or style or filter_tag_ids)
|
||||
# 需要内存过滤的标志(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)
|
||||
|
||||
def _apply_memory_filters(items):
|
||||
"""应用 keyword / gender / style / tag_ids 内存过滤。"""
|
||||
"""应用 keyword / gender / style / tag_ids / smart_view / classification 内存过滤。"""
|
||||
result = items
|
||||
if keyword:
|
||||
kw = keyword.lower()
|
||||
@@ -114,9 +126,38 @@ 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 级分页 ──
|
||||
@@ -260,33 +301,157 @@ def update_asset_review_status(
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("/batch-delete", response_model=BatchDeleteResponse)
|
||||
@router.post("/batch-delete", response_model=BatchOperationResponse)
|
||||
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),
|
||||
) -> BatchDeleteResponse:
|
||||
"""批量删除素材(配音素材等),需逐项校验项目权限。"""
|
||||
) -> BatchOperationResponse:
|
||||
"""批量删除素材(软删除,标记 status=deleted),需逐项校验项目权限。"""
|
||||
user_id = authenticated_user.user.id
|
||||
deleted_ids: list[str] = []
|
||||
failed_ids: list[str] = []
|
||||
success_ids: list[str] = []
|
||||
failed_details: dict[str, str] = {}
|
||||
|
||||
for asset_id in request.ids:
|
||||
for asset_id in request.asset_ids:
|
||||
item = asset_repository.find_by_id(asset_id)
|
||||
if item is None:
|
||||
failed_ids.append(asset_id)
|
||||
failed_details[asset_id] = "not_found"
|
||||
continue
|
||||
try:
|
||||
check_project_access(item.project_id, user_id, project_repository)
|
||||
deleted_ids.append(asset_id)
|
||||
success_ids.append(asset_id)
|
||||
except HTTPException:
|
||||
failed_ids.append(asset_id)
|
||||
failed_details[asset_id] = "access_denied"
|
||||
|
||||
if deleted_ids:
|
||||
asset_repository.batch_delete(deleted_ids)
|
||||
if success_ids:
|
||||
asset_repository.batch_delete(success_ids)
|
||||
|
||||
return BatchDeleteResponse(deleted_count=len(deleted_ids), failed_ids=failed_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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
|
||||
@@ -146,6 +146,7 @@ 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="片段额外配置")
|
||||
@@ -209,6 +210,8 @@ 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
|
||||
|
||||
Regular → Executable
+1
@@ -210,6 +210,7 @@ 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,6 +267,8 @@ 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,11 +21,12 @@ from app.schemas.task_center import (
|
||||
ProjectTaskResponse,
|
||||
UserTaskResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
CreateGenerationTaskUseCase,
|
||||
RetryGenerationTaskUseCase,
|
||||
SubmitIngestJobCommand,
|
||||
SubmitIngestJobUseCase,
|
||||
)
|
||||
@@ -34,6 +35,10 @@ 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:
|
||||
@@ -63,6 +68,8 @@ def _generation_step(task) -> str:
|
||||
return "生成完成"
|
||||
if s == "failed":
|
||||
return "生成失败"
|
||||
if s == "cancelled":
|
||||
return "已取消"
|
||||
return s
|
||||
|
||||
|
||||
@@ -79,6 +86,26 @@ 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}",
|
||||
@@ -88,8 +115,10 @@ 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,
|
||||
@@ -97,40 +126,66 @@ 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),合并 ingest + generation 任务。"""
|
||||
"""用户级任务列表(跨 project),支持状态/类型筛选和分页。"""
|
||||
status = _validate_status(status)
|
||||
page_size = _clamp_page_size(page_size)
|
||||
user_id = authenticated_user.user.id
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
items: list[UserTaskResponse] = []
|
||||
|
||||
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,
|
||||
)
|
||||
# 生成任务
|
||||
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 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)
|
||||
return ListTasksResponse(items=items)
|
||||
|
||||
# 总数(仅generation,ingest暂不计入总数以保持简单)
|
||||
total = generation_task_repository.count_by_user_filtered(user_id, status=status)
|
||||
|
||||
return ListTasksResponse(items=items[:page_size], total=total)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/retry", response_model=UserTaskResponse)
|
||||
@@ -139,7 +194,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 直接重试失败的生成任务。"""
|
||||
"""原地重试失败的生成任务(复用同一个task_id,retry_count+1)。"""
|
||||
task = generation_task_repository.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Generation task not found")
|
||||
@@ -149,6 +204,7 @@ 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()
|
||||
@@ -163,20 +219,11 @@ def retry_task_by_id(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
# 原地重试
|
||||
use_case = RetryGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(task_id)
|
||||
|
||||
# 重新入队
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
@@ -192,18 +239,8 @@ def retry_task_by_id(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
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,
|
||||
)
|
||||
|
||||
return _generation_task_to_user_response(retried)
|
||||
|
||||
|
||||
# ── 项目级端点 ──
|
||||
@@ -212,37 +249,64 @@ 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] = []
|
||||
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 == "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,
|
||||
)
|
||||
)
|
||||
|
||||
# 生成任务
|
||||
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 generation_task_repository.list_by_project(project_id):
|
||||
items.append(_generation_task_to_project_response(task))
|
||||
for task in gen_items:
|
||||
items.append(_generation_task_to_project_response(task))
|
||||
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at or "", reverse=True)
|
||||
return ListProjectTasksResponse(items=items)
|
||||
|
||||
total = generation_task_repository.count_by_project_filtered(project_id, status=status)
|
||||
|
||||
return ListProjectTasksResponse(items=items[:page_size], total=total)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_type}/{source_id}/retry", response_model=ProjectTaskResponse)
|
||||
@@ -253,6 +317,7 @@ 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:
|
||||
@@ -261,6 +326,7 @@ 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()
|
||||
@@ -275,20 +341,10 @@ def retry_project_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
# 原地重试
|
||||
use_case = RetryGenerationTaskUseCase(generation_task_repository)
|
||||
retried = use_case.execute(source_id)
|
||||
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
retried, generation_task_repository, user_id=user_id, log_prefix="[任务中心]"
|
||||
@@ -305,6 +361,7 @@ 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,13 +8,16 @@ 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,
|
||||
@@ -27,19 +30,24 @@ 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,
|
||||
@@ -67,7 +75,7 @@ def _segment_to_response(seg) -> SegmentResponse:
|
||||
)
|
||||
|
||||
|
||||
def _to_response(template) -> TemplateResponse:
|
||||
def _to_response(template, usage_count: int = 0) -> TemplateResponse:
|
||||
return TemplateResponse(
|
||||
id=template.id,
|
||||
user_id=template.user_id,
|
||||
@@ -81,6 +89,7 @@ def _to_response(template) -> 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,
|
||||
)
|
||||
@@ -93,19 +102,36 @@ def _to_response(template) -> 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)
|
||||
total = template_repository.count_by_user(user_id)
|
||||
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))
|
||||
except Exception:
|
||||
logger.exception("list_templates 查询失败: user_id=%s", user_id)
|
||||
return ListTemplatesResponse(items=[], total=0)
|
||||
return ListTemplatesResponse(
|
||||
items=[_to_response(t) for t in templates],
|
||||
items=items,
|
||||
total=total,
|
||||
)
|
||||
|
||||
@@ -120,12 +146,13 @@ 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)
|
||||
return _to_response(template, usage_count=usage)
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -220,6 +247,47 @@ 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,
|
||||
@@ -318,4 +386,23 @@ 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
|
||||
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)
|
||||
|
||||
Regular → Executable
+41
-1
@@ -17,13 +17,18 @@ 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, UpdateTitleLibraryCommand
|
||||
from packages.application.title_library.commands import (
|
||||
CreateTitleLibraryCommand,
|
||||
PickTitleCommand,
|
||||
UpdateTitleLibraryCommand,
|
||||
)
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
GetTitleLibraryUseCase,
|
||||
ListTitleLibraryUseCase,
|
||||
NotFoundError,
|
||||
PickTitleUseCase,
|
||||
QuotaExceededError,
|
||||
UpdateTitleLibraryUseCase,
|
||||
)
|
||||
@@ -70,6 +75,41 @@ 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,6 +46,7 @@ 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__)
|
||||
@@ -53,6 +54,34 @@ 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)
|
||||
|
||||
|
||||
Executable
+179
@@ -0,0 +1,179 @@
|
||||
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 = [] # 已下线的版本列表
|
||||
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
version = self._extract_version(request.url.path)
|
||||
|
||||
Regular → Executable
+34
-6
@@ -54,17 +54,45 @@ class AssetResponse(BaseModel):
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
MAX_BATCH_SIZE = 200
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求。"""
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=100, description="要删除的素材 ID 列表")
|
||||
asset_ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="要删除的素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteResponse(BaseModel):
|
||||
"""批量删除响应。"""
|
||||
class BatchOperationResponse(BaseModel):
|
||||
"""批量操作通用响应。"""
|
||||
|
||||
deleted_count: int = Field(..., ge=0, description="实际删除数量")
|
||||
failed_ids: list[str] = Field(default_factory=list, description="删除失败的 ID 列表")
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class ListAssetsResponse(BaseModel):
|
||||
|
||||
Regular → Executable
+15
@@ -33,6 +33,17 @@ 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":
|
||||
@@ -64,6 +75,10 @@ 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")
|
||||
|
||||
Regular → Executable
+6
@@ -11,8 +11,10 @@ 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
|
||||
@@ -21,6 +23,7 @@ class ProjectTaskResponse(BaseModel):
|
||||
|
||||
class ListProjectTasksResponse(BaseModel):
|
||||
items: list[ProjectTaskResponse] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class UserTaskResponse(BaseModel):
|
||||
@@ -34,8 +37,10 @@ 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
|
||||
@@ -45,3 +50,4 @@ class ListTasksResponse(BaseModel):
|
||||
"""用户级任务列表响应(GET /api/v1/tasks)。"""
|
||||
|
||||
items: list[UserTaskResponse] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
Regular → Executable
+23
@@ -45,6 +45,7 @@ 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
|
||||
|
||||
@@ -120,3 +121,25 @@ 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
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
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
|
||||
Regular → Executable
+19
@@ -281,6 +281,8 @@ 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:
|
||||
"""创建片段
|
||||
@@ -301,6 +303,8 @@ 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)
|
||||
@@ -324,6 +328,8 @@ 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:
|
||||
"""更新片段
|
||||
@@ -333,6 +339,15 @@ 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,
|
||||
@@ -346,6 +361,10 @@ 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,
|
||||
|
||||
@@ -86,7 +86,10 @@ async function createProject(
|
||||
): Promise<string> {
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" },
|
||||
data: {
|
||||
name: `Assets Test Proj ${suffix}`,
|
||||
description: "E2E assets test",
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
@@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-load");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-load",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
@@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-create",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击新建素材库
|
||||
await page.locator(".xx-asset-library-add").click();
|
||||
|
||||
// 弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" });
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 填写表单
|
||||
@@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-switch");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-switch",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
@@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"demo_video.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page
|
||||
@@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page
|
||||
@@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-search");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-search",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"apple_clip.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"banana_clip.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page
|
||||
@@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
@@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
// 清空搜索,两个都显示
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-filter");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-filter",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建视频素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"video_clip.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
@@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-detail");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-detail",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"play_test.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" });
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// 关闭弹窗
|
||||
@@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-delete");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-delete",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"to_delete.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await deleteBtn.click({ force: true });
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" });
|
||||
const confirmModal = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/assets/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
@@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-batch");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-batch",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
@@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
);
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4");
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4");
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_1.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_2.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_3.mp4",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
|
||||
@@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await batchDeleteBtn.click();
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" });
|
||||
const confirmPop = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
|
||||
// 确认删除
|
||||
@@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-empty");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
"assets-empty",
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const projectId = await createProject(request, headers, Date.now().toString());
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
@@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
await libItem.click({ force: true });
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -251,6 +251,9 @@ 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 }) => {
|
||||
|
||||
@@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => {
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||
{
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify asset card shows status
|
||||
const assetCard = page
|
||||
|
||||
@@ -121,7 +121,11 @@ test.describe("去重流程", () => {
|
||||
"dup-load",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
|
||||
@@ -146,7 +150,11 @@ test.describe("去重流程", () => {
|
||||
"dup-upload-zone",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -156,12 +164,12 @@ test.describe("去重流程", () => {
|
||||
await expect(uploadZone).toBeVisible();
|
||||
|
||||
// 上传图标和文字
|
||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible();
|
||||
await expect(
|
||||
uploadZone.getByText("点击或拖拽视频文件到此区域"),
|
||||
).toBeVisible();
|
||||
|
||||
// 格式提示
|
||||
await expect(
|
||||
uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/),
|
||||
).toBeVisible();
|
||||
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||
@@ -184,7 +192,11 @@ test.describe("去重流程", () => {
|
||||
"dup-info",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -215,7 +227,11 @@ test.describe("去重流程", () => {
|
||||
"dup-list",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
|
||||
@@ -239,7 +255,11 @@ test.describe("去重流程", () => {
|
||||
"dup-list-empty",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -257,7 +277,11 @@ test.describe("去重流程", () => {
|
||||
"dup-filter",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -287,7 +311,11 @@ test.describe("去重流程", () => {
|
||||
"dup-nav",
|
||||
);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
@@ -306,10 +334,8 @@ test.describe("去重流程", () => {
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-detail");
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -335,7 +361,11 @@ test.describe("去重流程", () => {
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`);
|
||||
@@ -353,10 +383,8 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -419,10 +447,8 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete-ui");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -443,14 +469,20 @@ test.describe("去重流程", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
@@ -467,12 +499,14 @@ test.describe("去重流程", () => {
|
||||
});
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
).catch(() => null);
|
||||
const deletePromise = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
await deleteBtn.click();
|
||||
|
||||
@@ -487,10 +521,8 @@ test.describe("去重流程", () => {
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-retry");
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -511,14 +543,20 @@ test.describe("去重流程", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false);
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" },
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 10,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
mode: "voice_over",
|
||||
estimated_duration: 60,
|
||||
segments: [
|
||||
{ segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" },
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 10,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+112
-29
@@ -93,7 +93,8 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
video_url:
|
||||
status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
@@ -218,7 +219,11 @@ test.describe("作品库页面", () => {
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
|
||||
@@ -255,12 +260,24 @@ test.describe("作品库页面", () => {
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` },
|
||||
{ ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` },
|
||||
{
|
||||
...mockProducts(1, ["processing"])[0],
|
||||
title: "处理中作品",
|
||||
id: `mock-prod-${Date.now()}-p`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["failed"])[0],
|
||||
title: "失败作品",
|
||||
id: `mock-prod-${Date.now()}-f`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
@@ -276,9 +293,9 @@ test.describe("作品库页面", () => {
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText(
|
||||
"已完成",
|
||||
);
|
||||
await expect(
|
||||
completedCard.locator(".xx-product-status.completed"),
|
||||
).toHaveText("已完成");
|
||||
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
@@ -309,7 +326,11 @@ test.describe("作品库页面", () => {
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
@@ -337,7 +358,11 @@ test.describe("作品库页面", () => {
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -356,7 +381,10 @@ test.describe("作品库页面", () => {
|
||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video");
|
||||
const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
const videoVisible = await videoEl
|
||||
.first()
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
@@ -380,7 +408,11 @@ test.describe("作品库页面", () => {
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -409,7 +441,11 @@ test.describe("作品库页面", () => {
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -484,7 +520,11 @@ test.describe("作品库页面", () => {
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -507,9 +547,12 @@ test.describe("作品库页面", () => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
});
|
||||
const resp = await request.delete(
|
||||
`${apiBase}/products/nonexistent-test-id`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
@@ -529,7 +572,11 @@ test.describe("作品库页面", () => {
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, []);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
@@ -553,12 +600,24 @@ test.describe("作品库页面", () => {
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` },
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` },
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "苹果宣传视频",
|
||||
id: `mock-prod-${Date.now()}-apple`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "香蕉推广视频",
|
||||
id: `mock-prod-${Date.now()}-banana`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -566,7 +625,9 @@ test.describe("作品库页面", () => {
|
||||
});
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
|
||||
// 搜索"苹果"
|
||||
@@ -576,7 +637,9 @@ test.describe("作品库页面", () => {
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
@@ -587,12 +650,24 @@ test.describe("作品库页面", () => {
|
||||
);
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` },
|
||||
{ ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` },
|
||||
{
|
||||
...mockProducts(1, ["completed"])[0],
|
||||
title: "已完成筛选",
|
||||
id: `mock-prod-${Date.now()}-done`,
|
||||
},
|
||||
{
|
||||
...mockProducts(1, ["processing"])[0],
|
||||
title: "处理中筛选",
|
||||
id: `mock-prod-${Date.now()}-proc`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -629,7 +704,11 @@ test.describe("作品库页面", () => {
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, { id: userId, email, username });
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
|
||||
await page.goto("/app/products");
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
@@ -653,8 +732,12 @@ test.describe("作品库页面", () => {
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量下载" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量删除" }),
|
||||
).toBeVisible();
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test("登出 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-logout");
|
||||
const { headers, email } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout",
|
||||
);
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||
headers,
|
||||
|
||||
@@ -49,7 +49,9 @@ test.describe("注册页面", () => {
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||
|
||||
// 标题/描述
|
||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("创建账户,开启智能视频创作之旅"),
|
||||
).toBeVisible();
|
||||
|
||||
// 表单字段
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
@@ -69,7 +71,10 @@ test.describe("注册页面", () => {
|
||||
await page.goto("/register");
|
||||
|
||||
// 直接点击注册按钮
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示必填错误
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||
@@ -86,7 +91,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
@@ -100,7 +108,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
@@ -114,7 +125,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
@@ -128,7 +142,10 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
@@ -154,10 +171,16 @@ test.describe("注册页面", () => {
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
const resp = await registerResponse;
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy();
|
||||
expect(
|
||||
resp.ok(),
|
||||
`注册请求应返回 2xx,实际: ${resp.status()}`,
|
||||
).toBeTruthy();
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
@@ -199,14 +222,19 @@ test.describe("注册页面", () => {
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click();
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible();
|
||||
const hasError = await page
|
||||
.getByText(/注册失败|已注册|已存在|exists/)
|
||||
.isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
@@ -254,7 +282,12 @@ test.describe("注册页面", () => {
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" },
|
||||
data: {
|
||||
email,
|
||||
password: PASSWORD,
|
||||
username,
|
||||
display_name: "Reg Auth Test",
|
||||
},
|
||||
});
|
||||
|
||||
// 登录
|
||||
@@ -293,6 +326,8 @@ test.describe("注册页面", () => {
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => {
|
||||
const url = page.url();
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") || url.includes("/subscription") ||
|
||||
(await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)),
|
||||
url.includes("/subscription/upgrade") ||
|
||||
url.includes("/subscription") ||
|
||||
(await page
|
||||
.locator(".ant-modal, [role='dialog']")
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)),
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => {
|
||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
@@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => {
|
||||
});
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => {
|
||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy();
|
||||
expect(
|
||||
data.error?.message || data.detail || data.message,
|
||||
"应返回错误信息",
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => {
|
||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal).toBeVisible();
|
||||
// 验证预览内容存在
|
||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible();
|
||||
await expect(
|
||||
modal.locator(".xx-template-modal-title-row"),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => {
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
expect(
|
||||
unfavResp.status() < 500,
|
||||
"取消收藏请求应返回 2xx 或 4xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
@@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => {
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/templates/${created.id}`,
|
||||
{ headers },
|
||||
);
|
||||
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||||
headers,
|
||||
});
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
|
||||
@@ -175,10 +175,9 @@ test.describe("认证流程", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[400, 422],
|
||||
"缺少用户名字段应返回 4xx 校验错误",
|
||||
).toContain(response.status());
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
@@ -230,7 +229,9 @@ test.describe("认证流程", () => {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`);
|
||||
console.log(
|
||||
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
@@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36));
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
@@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
const titles = [
|
||||
{ name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" },
|
||||
{ name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" },
|
||||
{ name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" },
|
||||
{
|
||||
name: `批量标题 1 ${suffix}`,
|
||||
text: `内容 1 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
{
|
||||
name: `批量标题 2 ${suffix}`,
|
||||
text: `内容 2 ${suffix}`,
|
||||
category: "种草",
|
||||
},
|
||||
{
|
||||
name: `批量标题 3 ${suffix}`,
|
||||
text: `内容 3 ${suffix}`,
|
||||
category: "知识",
|
||||
},
|
||||
];
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
@@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy();
|
||||
expect(
|
||||
Array.isArray(data) || data.success_count !== undefined,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
).toBeTruthy();
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
@@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => {
|
||||
});
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ });
|
||||
const cloneBtn = page.getByRole("button", {
|
||||
name: /克隆新音色|立即克隆|新建/,
|
||||
});
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click();
|
||||
// 弹窗应该出现
|
||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']");
|
||||
const modal = page.locator(
|
||||
".ant-modal, .vc-edit-dialog, [role='dialog']",
|
||||
);
|
||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal.first()).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => {
|
||||
});
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']");
|
||||
const searchInput = page.locator(
|
||||
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
|
||||
);
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,32 @@
|
||||
import apiClient from "./client";
|
||||
import { getOrCreateDefaultProject } from "./projects";
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number;
|
||||
/** 宽度(像素) */
|
||||
width?: number;
|
||||
/** 高度(像素) */
|
||||
height?: number;
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number;
|
||||
/** 编码格式 */
|
||||
codec?: string;
|
||||
/** 帧率 */
|
||||
fps?: number;
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
/** 声道数 */
|
||||
channels?: number;
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string;
|
||||
@@ -12,12 +38,14 @@ export interface AssetItem {
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata: Record<string, unknown>;
|
||||
metadata: AssetMetadata;
|
||||
file_size?: number;
|
||||
file_url?: string;
|
||||
thumbnail_url?: string;
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number;
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
classification_status?: AssetClassificationStatus | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
@@ -167,7 +195,7 @@ export const createAsset = async (data: {
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata?: AssetMetadata;
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
@@ -176,7 +204,7 @@ export const createAsset = async (data: {
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: Record<string, unknown> },
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
@@ -350,3 +378,52 @@ export const getClassificationJob = async (
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// ─── 批量操作 ───────────────────────────────────────────────
|
||||
|
||||
/** 批量操作结果 */
|
||||
export interface BatchOperationResult {
|
||||
succeeded: string[];
|
||||
failed: string[];
|
||||
total: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (
|
||||
assetIds: string[],
|
||||
): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
tags: string[];
|
||||
mode: "add" | "replace";
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
category: string;
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
smart_view: "recommended" | "caution" | "high_risk";
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* BGM 预设音乐 API
|
||||
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
/** BGM 风格分类 */
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||
|
||||
/** BGM 预设项 */
|
||||
export interface BgmPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: BgmCategory;
|
||||
/** 音频文件 URL */
|
||||
url: string;
|
||||
/** 时长(秒) */
|
||||
duration: number;
|
||||
/** 关键词标签 */
|
||||
tags: string[];
|
||||
/** 封面图 URL */
|
||||
cover_url?: string;
|
||||
}
|
||||
|
||||
/** BGM 预设列表查询参数 */
|
||||
export interface BgmPresetsQuery {
|
||||
category?: BgmCategory | string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean;
|
||||
/** 选中的 BGM ID */
|
||||
music_id: string;
|
||||
/** BGM 音量 0-100 */
|
||||
volume: number;
|
||||
/** 淡入时长(秒) 0-3 */
|
||||
fade_in: number;
|
||||
/** 淡出时长(秒) 0-3 */
|
||||
fade_out: number;
|
||||
/** 人声闪避(sidechain) */
|
||||
voice_dodge: boolean;
|
||||
}
|
||||
|
||||
/** 默认 BGM 混音配置 */
|
||||
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||
enabled: false,
|
||||
music_id: "",
|
||||
volume: 50,
|
||||
fade_in: 0.5,
|
||||
fade_out: 0.5,
|
||||
voice_dodge: true,
|
||||
};
|
||||
|
||||
/* ──────────── API ──────────── */
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (params?.category) searchParams.category = params.category;
|
||||
if (params?.keyword) searchParams.keyword = params.keyword;
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams });
|
||||
return res.data?.data ?? res.data ?? [];
|
||||
};
|
||||
@@ -129,7 +129,8 @@ apiClient.interceptors.response.use(
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
|
||||
+216
-32
@@ -4,6 +4,15 @@
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { AssetItem } from "./assets";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ============================================================
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
@@ -13,6 +22,107 @@ import type { AssetItem } from "./assets";
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed";
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
}
|
||||
|
||||
/** 片段 TTS 配置 */
|
||||
export interface SegmentTtsConfig {
|
||||
mode: string;
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
export interface SegmentTrimConfig {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
/** 片段转场配置 */
|
||||
export interface SegmentTransitionConfig {
|
||||
type: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string;
|
||||
transition?: SegmentTransitionConfig;
|
||||
playback_speed?: number;
|
||||
tts_config?: SegmentTtsConfig;
|
||||
trim_config?: SegmentTrimConfig;
|
||||
}
|
||||
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig;
|
||||
subtitle_config?: SubtitleConfig;
|
||||
bgm_config?: BgmConfig;
|
||||
estimated_duration?: number;
|
||||
segments?: EditPlanSegment[];
|
||||
watermark_config?: WatermarkConfig;
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
pip_config?: PipConfig;
|
||||
filter_config?: FilterConfig;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string;
|
||||
@@ -20,7 +130,7 @@ export interface EditPlan {
|
||||
name: string;
|
||||
status: EditPlanStatus;
|
||||
total_duration: number;
|
||||
config: Record<string, unknown>;
|
||||
config: EditPlanConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -29,7 +139,7 @@ export interface EditPlan {
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string;
|
||||
name: string;
|
||||
config?: Record<string, unknown>;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
@@ -38,7 +148,7 @@ export interface CreateEditPlanRequest {
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string;
|
||||
config?: Record<string, unknown>;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
status?: EditPlanStatus;
|
||||
}
|
||||
@@ -80,6 +190,26 @@ export interface GenerationStatusResponse {
|
||||
clips: ClipStatusItem[];
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id?: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* AI 推荐 & 封面生成(任务 3.09)
|
||||
* ============================================================ */
|
||||
@@ -100,14 +230,14 @@ export interface AIRecommendClipItem {
|
||||
transition_effect: string;
|
||||
asset_id: string;
|
||||
start_time: number;
|
||||
config: Record<string, unknown>;
|
||||
config: EditPlanConfig;
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: Record<string, unknown>;
|
||||
config: EditPlanConfig;
|
||||
total_duration: number;
|
||||
confidence: number;
|
||||
}
|
||||
@@ -122,7 +252,15 @@ export interface GenerateCoverRequest {
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string;
|
||||
cover: Record<string, unknown>;
|
||||
cover: CoverResult;
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -147,10 +285,27 @@ export interface EditPlanClip {
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** 转场效果 */
|
||||
/** 转场效果(14 种预设) */
|
||||
export interface TransitionEffect {
|
||||
type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide";
|
||||
type:
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
duration: number; // 转场时长(秒)
|
||||
/** 播放速度倍率 */
|
||||
playback_speed?: number;
|
||||
}
|
||||
|
||||
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
||||
@@ -177,15 +332,30 @@ export interface MediaAsset {
|
||||
* API 函数 — 严格对接后端
|
||||
* ============================================================ */
|
||||
|
||||
/** 获取剪辑计划列表 */
|
||||
export async function getEditPlans(params?: {
|
||||
/** 剪辑计划列表查询参数 */
|
||||
export interface EditPlanListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
template_id?: string;
|
||||
status?: string;
|
||||
}): Promise<EditPlan[]> {
|
||||
const response = await apiClient.get("/edit-plans", { params });
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(
|
||||
params?: EditPlanListParams,
|
||||
): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** 获取单个剪辑计划 */
|
||||
@@ -266,6 +436,14 @@ export async function getEditPlanGenerations(
|
||||
return response.data.items || [];
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items || response.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
@@ -297,26 +475,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
||||
}
|
||||
|
||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
const meta = (asset.metadata || {}) as Record<string, unknown>;
|
||||
const ext = asset as AssetItem & Record<string, unknown>;
|
||||
// 优先取顶层 duration,其次从 metadata 回退
|
||||
const metaDuration =
|
||||
typeof asset.metadata?.duration === "number"
|
||||
? asset.metadata.duration
|
||||
: undefined;
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: inferMediaType(asset.mime_type || ""),
|
||||
thumbnail_url:
|
||||
typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
|
||||
duration:
|
||||
typeof ext.duration === "number"
|
||||
? ext.duration
|
||||
: typeof meta.duration === "number"
|
||||
? (meta.duration as number)
|
||||
: undefined,
|
||||
thumbnail_url: asset.thumbnail_url,
|
||||
duration: asset.duration ?? metaDuration,
|
||||
size: asset.file_size ?? undefined,
|
||||
tags: [],
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: (asset.classification_status ??
|
||||
undefined) as MediaAsset["classification_status"],
|
||||
classification_status: asset.classification_status ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,17 +498,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
* 常量
|
||||
* ============================================================ */
|
||||
|
||||
/** 转场效果选项 */
|
||||
/** 转场效果选项(14 种预设) */
|
||||
export const TRANSITION_OPTIONS: {
|
||||
value: TransitionEffect["type"];
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ value: "none", label: "无转场" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "dissolve", label: "溶解" },
|
||||
{ value: "wipe", label: "擦除" },
|
||||
{ value: "zoom", label: "缩放" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "none", label: "无转场", icon: "⊘" },
|
||||
{ value: "cut", label: "硬切", icon: "✂" },
|
||||
{ value: "fade", label: "淡入淡出", icon: "◐" },
|
||||
{ value: "dissolve", label: "溶解", icon: "◈" },
|
||||
{ value: "zoom", label: "缩放", icon: "⊕" },
|
||||
{ value: "slide_left", label: "左滑", icon: "←" },
|
||||
{ value: "slide_right", label: "右滑", icon: "→" },
|
||||
{ value: "slide_up", label: "上滑", icon: "↑" },
|
||||
{ value: "slide_down", label: "下滑", icon: "↓" },
|
||||
{ value: "wipe_left", label: "左擦除", icon: "▸|" },
|
||||
{ value: "wipe_right", label: "右擦除", icon: "|◂" },
|
||||
{ value: "wipe_up", label: "上擦除", icon: "▴̄" },
|
||||
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||
];
|
||||
|
||||
/** 素材类型标签 */
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
@@ -72,6 +81,20 @@ export interface EditingTemplate {
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -95,6 +118,20 @@ export interface SaveTemplatePayload {
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, "id">[];
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
@@ -102,11 +139,23 @@ 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?: Record<string, unknown>;
|
||||
details?: ValidationWarningDetails;
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
|
||||
+113
-14
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 成品相关 API
|
||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
||||
* 成品 / 视频相关 API
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
@@ -14,33 +19,127 @@ export interface ProductItem {
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
project_id?: string;
|
||||
/** 所属项目名称 */
|
||||
project_name?: string;
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有成品 */
|
||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/products");
|
||||
return response.data.items || response.data || [];
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string;
|
||||
/** 进度百分比 */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 generation task 数据映射为 ProductItem 格式
|
||||
*/
|
||||
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
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,
|
||||
resolution:
|
||||
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
status:
|
||||
task.status === "completed"
|
||||
? "completed"
|
||||
: task.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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const tasks = response.data.items || response.data || [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
/** 获取单个成品详情 — 通过 task ID 获取结果 */
|
||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/products/${productId}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
};
|
||||
|
||||
/** 删除成品 */
|
||||
/** 删除成品 — 删除 generation task */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/products/${productId}`);
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
};
|
||||
|
||||
/** 获取成品下载链接 */
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const response = await apiClient.get(`/products/${productId}/download-url`);
|
||||
return response.data;
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
productId: string,
|
||||
status: ReviewStatus,
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
};
|
||||
|
||||
+58
-12
@@ -1,31 +1,67 @@
|
||||
/**
|
||||
* 任务相关 API
|
||||
* 对接后端方案 A 扩展后的端点(PR #109)
|
||||
* - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式)
|
||||
* - GET /api/v1/tasks — 用户级任务列表(跨 project)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 简化重试
|
||||
* 对接后端任务中心 API:
|
||||
* - POST /api/v1/generation/tasks — 创建生成任务
|
||||
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选)
|
||||
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 任务状态 */
|
||||
export type TaskStatus =
|
||||
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
|
||||
|
||||
/** 任务类型 */
|
||||
export type TaskType = "ingest" | "generation" | string;
|
||||
|
||||
/** 错误详情 */
|
||||
export interface TaskErrorInfo {
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
failed_step: string;
|
||||
stack_trace?: string;
|
||||
}
|
||||
|
||||
/** 任务条目(对应用户级 UserTaskResponse) */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: "ingest" | "generation" | string;
|
||||
task_type: TaskType;
|
||||
project_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
template_id?: string;
|
||||
status: TaskStatus;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
/** 错误详情(失败任务) */
|
||||
error_info?: TaskErrorInfo;
|
||||
/** 耗时(秒) */
|
||||
duration_seconds?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
/** 任务列表查询参数 */
|
||||
export interface TaskListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: TaskStatus | "all";
|
||||
task_type?: TaskType | "all";
|
||||
}
|
||||
|
||||
/** 任务列表分页响应 */
|
||||
export interface TaskListResponse {
|
||||
items: TaskItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 创建生成任务请求参数 */
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string;
|
||||
@@ -64,13 +100,23 @@ export const createGenerationTask = async (
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取当前用户的所有任务(跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || [];
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (
|
||||
params?: TaskListParams,
|
||||
): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取单个任务详情(用于轮询进度) */
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || data || [];
|
||||
};
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
||||
return data;
|
||||
|
||||
@@ -1,26 +1,112 @@
|
||||
/**
|
||||
* 模板相关 API
|
||||
* Phase 1 新增:全局模板库
|
||||
* 对接后端模板管理接口:
|
||||
* - GET /api/v1/templates — 模板列表(分页/筛选)
|
||||
* - GET /api/v1/templates/{id} — 模板详情
|
||||
* - POST /api/v1/templates/{id}/copy — 复制模板
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
|
||||
import type { EditPlanConfig } from "./editPlans";
|
||||
|
||||
/** 模板条目 */
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板条目(后端 TemplateResponse) */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
/** 使用次数 */
|
||||
usage_count?: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active: boolean;
|
||||
is_favorite?: boolean;
|
||||
/** 素材规则(片段配置) */
|
||||
segments?: TemplateSegment[];
|
||||
/** 字幕样式 */
|
||||
subtitle_config?: SubtitleConfig;
|
||||
/** BGM 配置 */
|
||||
bgm_config?: BgmConfig;
|
||||
/** 标题配置 */
|
||||
title_config?: TitleConfig;
|
||||
/** 视频比例 */
|
||||
aspect_ratio?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取全局模板列表 */
|
||||
export const getTemplates = async (): Promise<TemplateItem[]> => {
|
||||
/** 模板片段(素材规则) */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 模板列表查询参数 */
|
||||
export interface TemplateListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
keyword?: string;
|
||||
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||
duration_range?: "short" | "medium" | "long";
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
export interface TemplateListResponse {
|
||||
items: TemplateItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划请求 */
|
||||
export interface GenerateFromTemplateRequest {
|
||||
asset_ids?: string[];
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
plan_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** 复制模板响应 */
|
||||
export interface CopyTemplateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
source_template_id: string;
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
|
||||
/** 获取模板列表(支持分页和筛选) */
|
||||
export const getTemplates = async (
|
||||
params?: TemplateListParams,
|
||||
): Promise<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
@@ -42,3 +128,46 @@ export const toggleFavoriteTemplate = async (
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 复制模板(创建副本到我的模板) */
|
||||
export const copyTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(
|
||||
`/templates/${templateId}/copy`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 从模板生成剪辑计划 */
|
||||
export const generateFromTemplate = async (
|
||||
templateId: string,
|
||||
data?: GenerateFromTemplateRequest,
|
||||
): Promise<GenerateFromTemplateResponse> => {
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 模板分类选项 */
|
||||
export const TEMPLATE_CATEGORY_OPTIONS = [
|
||||
{ value: "", label: "全部分类" },
|
||||
{ value: "口播", label: "口播" },
|
||||
{ value: "种草", label: "种草" },
|
||||
{ value: "产品", label: "产品" },
|
||||
{ value: "品牌", label: "品牌" },
|
||||
{ value: "混剪", label: "混剪" },
|
||||
{ value: "Vlog", label: "Vlog" },
|
||||
];
|
||||
|
||||
/** 时长筛选选项 */
|
||||
export const TEMPLATE_DURATION_OPTIONS = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
|
||||
+63
-2
@@ -8,6 +8,18 @@ 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;
|
||||
@@ -18,7 +30,7 @@ export interface TTSSynthesizeRequest {
|
||||
voice_model?: string;
|
||||
voice_clone_profile_id?: string;
|
||||
format?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata?: TTSMetadata;
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
@@ -49,7 +61,7 @@ export interface TTSJob {
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: Record<string, unknown> | null;
|
||||
metadata_: TTSMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -140,3 +152,52 @@ export const saveTtsToLibrary = async (
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
};
|
||||
|
||||
/* ── 音色列表 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||
category?: string;
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
/** 试听 URL */
|
||||
preview_url?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/* ── TTS 试听 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed?: number;
|
||||
pitch?: number;
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (
|
||||
data: TTSPreviewRequest,
|
||||
): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>(
|
||||
"/tts/preview",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -36,6 +36,18 @@ 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;
|
||||
@@ -51,7 +63,7 @@ export interface VoiceCloneProfile {
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: Record<string, unknown> | null;
|
||||
metadata_: VoiceCloneMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -80,7 +92,7 @@ export interface CreateVoiceCloneRequestFull {
|
||||
language?: string;
|
||||
gender?: string;
|
||||
max_retries?: number;
|
||||
metadata_?: Record<string, unknown>;
|
||||
metadata_?: VoiceCloneMetadata;
|
||||
}
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
|
||||
@@ -384,7 +384,6 @@
|
||||
border-bottom: 1px solid var(--border-light) !important;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
@@ -409,7 +408,6 @@
|
||||
.xx-modal .ant-modal-header {
|
||||
padding: var(--space-md) !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -423,7 +421,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */
|
||||
/* AdminComingSoon 等页面使用 <Card className="xx-card"> 时需要 */
|
||||
/* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ScanOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
/** 导航项类型 */
|
||||
@@ -80,6 +81,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/my-templates",
|
||||
icon: React.createElement(FolderOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "generate",
|
||||
label: "一键生成",
|
||||
@@ -104,6 +111,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/duplication",
|
||||
icon: React.createElement(ScanOutlined),
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
label: "任务中心",
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
];
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
@@ -129,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/editing-planner",
|
||||
icon: React.createElement(EditOutlined),
|
||||
},
|
||||
{
|
||||
key: "edit-plans",
|
||||
label: "剪辑计划",
|
||||
path: "/app/edit-plans",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -181,6 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
path: "/app/history",
|
||||
icon: React.createElement(HistoryOutlined),
|
||||
},
|
||||
{
|
||||
key: "tasks",
|
||||
label: "任务中心",
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
{
|
||||
key: "duplication",
|
||||
label: "查重",
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
* 使用 useQuery 对接后端真实 API(api/assets.ts)
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Upload, Modal as AntModal, message, Popconfirm } from "antd";
|
||||
import {
|
||||
Upload,
|
||||
Modal as AntModal,
|
||||
message,
|
||||
Popconfirm,
|
||||
Drawer,
|
||||
Tag,
|
||||
Input as AntInput,
|
||||
Radio,
|
||||
Select as AntSelect,
|
||||
} from "antd";
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
@@ -17,6 +27,11 @@ import {
|
||||
ExperimentOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
TagsOutlined,
|
||||
FolderOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -27,8 +42,13 @@ import {
|
||||
deleteAsset,
|
||||
uploadAssetDirect,
|
||||
getAssetDiagnosis,
|
||||
batchDeleteAssets,
|
||||
batchTagAssets,
|
||||
batchClassifyAssets,
|
||||
batchMarkAssets,
|
||||
type AssetLibraryItem,
|
||||
type AssetItem as ApiAssetItem,
|
||||
type BatchOperationResult,
|
||||
} from "@/api/assets";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import "./assets.css";
|
||||
@@ -407,6 +427,33 @@ const AssetLibrary: React.FC = () => {
|
||||
/* 诊断中状态 — 记录正在诊断的素材 ID */
|
||||
const [diagnosingId, setDiagnosingId] = useState<string | null>(null);
|
||||
|
||||
/* ── 批量操作弹窗状态 ── */
|
||||
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false);
|
||||
const [markModalOpen, setMarkModalOpen] = useState(false);
|
||||
const [resultDrawerOpen, setResultDrawerOpen] = useState(false);
|
||||
|
||||
/* 批量打标签 */
|
||||
const [batchTagInput, setBatchTagInput] = useState("");
|
||||
const [batchTags, setBatchTags] = useState<string[]>([]);
|
||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add");
|
||||
|
||||
/* 批量改分类 */
|
||||
const [batchCategory, setBatchCategory] = useState("");
|
||||
|
||||
/* 批量智能标记 */
|
||||
const [batchSmartView, setBatchSmartView] = useState<
|
||||
"recommended" | "caution" | "high_risk"
|
||||
>("recommended");
|
||||
|
||||
/* 操作结果 */
|
||||
const [operationResult, setOperationResult] =
|
||||
useState<BatchOperationResult | null>(null);
|
||||
const [operationTitle, setOperationTitle] = useState("");
|
||||
|
||||
/* 批量操作 loading */
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
/* 派生数据 */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
@@ -563,19 +610,152 @@ const AssetLibrary: React.FC = () => {
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await deleteAsset(id);
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchDeleteAssets(ids);
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量删除");
|
||||
setResultDrawerOpen(true);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功删除 ${result.success_count} 个素材`);
|
||||
} else {
|
||||
message.warning(
|
||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量删除失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] });
|
||||
setSelectedIds(new Set());
|
||||
message.success(`已删除 ${successCount}/${ids.length} 个素材`);
|
||||
};
|
||||
|
||||
/* 批量打标签 */
|
||||
const handleBatchTag = async () => {
|
||||
if (batchTags.length === 0) {
|
||||
message.warning("请至少输入一个标签");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchTagAssets({
|
||||
asset_ids: ids,
|
||||
tags: batchTags,
|
||||
mode: tagMode,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量打标签");
|
||||
setResultDrawerOpen(true);
|
||||
setTagModalOpen(false);
|
||||
setBatchTags([]);
|
||||
setBatchTagInput("");
|
||||
setTagMode("add");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(`成功为 ${result.success_count} 个素材打标签`);
|
||||
} else {
|
||||
message.warning(
|
||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量打标签失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量改分类 */
|
||||
const handleBatchClassify = async () => {
|
||||
if (!batchCategory) {
|
||||
message.warning("请选择分类");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchClassifyAssets({
|
||||
asset_ids: ids,
|
||||
category: batchCategory,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量改分类");
|
||||
setResultDrawerOpen(true);
|
||||
setClassifyModalOpen(false);
|
||||
setBatchCategory("");
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材改为「${batchCategory}」`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量改分类失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 批量智能标记 */
|
||||
const handleBatchMark = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const result = await batchMarkAssets({
|
||||
asset_ids: ids,
|
||||
smart_view: batchSmartView,
|
||||
});
|
||||
setOperationResult(result);
|
||||
setOperationTitle("批量智能标记");
|
||||
setResultDrawerOpen(true);
|
||||
setMarkModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["assets"] });
|
||||
setSelectedIds(new Set());
|
||||
const labelMap = {
|
||||
recommended: "推荐",
|
||||
caution: "慎用",
|
||||
high_risk: "高风险",
|
||||
};
|
||||
if (result.failure_count === 0) {
|
||||
message.success(
|
||||
`成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`,
|
||||
);
|
||||
} else {
|
||||
message.warning(
|
||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
message.error("批量智能标记失败,请重试");
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 标签输入处理 */
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||
e.preventDefault();
|
||||
const tag = batchTagInput.trim();
|
||||
if (!batchTags.includes(tag)) {
|
||||
setBatchTags([...batchTags, tag]);
|
||||
}
|
||||
setBatchTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBatchTag = (tag: string) => {
|
||||
setBatchTags(batchTags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
// ── Loading 状态 ──
|
||||
@@ -769,6 +949,30 @@ const AssetLibrary: React.FC = () => {
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={deselectAll}>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<TagsOutlined />}
|
||||
onClick={() => setTagModalOpen(true)}
|
||||
>
|
||||
打标签
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<FolderOutlined />}
|
||||
onClick={() => setClassifyModalOpen(true)}
|
||||
>
|
||||
改分类
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setMarkModalOpen(true)}
|
||||
>
|
||||
智能标记
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确定删除 ${selectedIds.size} 个素材?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
@@ -898,6 +1102,207 @@ const AssetLibrary: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量打标签弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量打标签(${selectedIds.size} 个素材)`}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => {
|
||||
setTagModalOpen(false);
|
||||
setBatchTags([]);
|
||||
setBatchTagInput("");
|
||||
}}
|
||||
onOk={handleBatchTag}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认打标签"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-tag-modal">
|
||||
<div className="xx-batch-tag-mode">
|
||||
<span className="xx-batch-tag-mode-label">模式:</span>
|
||||
<Radio.Group
|
||||
value={tagMode}
|
||||
onChange={(e) => setTagMode(e.target.value)}
|
||||
>
|
||||
<Radio value="add">追加标签</Radio>
|
||||
<Radio value="replace">替换全部标签</Radio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="xx-batch-tag-input-row">
|
||||
<AntInput
|
||||
placeholder="输入标签后按 Enter 添加"
|
||||
value={batchTagInput}
|
||||
onChange={(e) => setBatchTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
{batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-list">
|
||||
{batchTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
closable
|
||||
onClose={() => removeBatchTag(tag)}
|
||||
color="blue"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tagMode === "replace" && batchTags.length > 0 && (
|
||||
<div className="xx-batch-tag-warning">
|
||||
<ExclamationCircleOutlined /> 替换模式将清除素材原有全部标签
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量改分类弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量改分类(${selectedIds.size} 个素材)`}
|
||||
open={classifyModalOpen}
|
||||
onCancel={() => {
|
||||
setClassifyModalOpen(false);
|
||||
setBatchCategory("");
|
||||
}}
|
||||
onOk={handleBatchClassify}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认修改"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-classify-modal">
|
||||
<p className="xx-batch-classify-hint">
|
||||
将选中的 {selectedIds.size} 个素材统一修改为以下分类:
|
||||
</p>
|
||||
<AntSelect
|
||||
value={batchCategory || undefined}
|
||||
onChange={(v) => setBatchCategory(v)}
|
||||
placeholder="请选择分类"
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ value: "person", label: "人物" },
|
||||
{ value: "scenic", label: "风景" },
|
||||
{ value: "product", label: "产品" },
|
||||
{ value: "food", label: "美食" },
|
||||
{ value: "animal", label: "动物" },
|
||||
{ value: "architecture", label: "建筑" },
|
||||
{ value: "other", label: "其他" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 批量智能标记弹窗 ─── */}
|
||||
<AntModal
|
||||
title={`批量智能标记(${selectedIds.size} 个素材)`}
|
||||
open={markModalOpen}
|
||||
onCancel={() => setMarkModalOpen(false)}
|
||||
onOk={handleBatchMark}
|
||||
confirmLoading={batchLoading}
|
||||
okText="确认标记"
|
||||
cancelText="取消"
|
||||
>
|
||||
<div className="xx-batch-mark-modal">
|
||||
<p className="xx-batch-mark-hint">
|
||||
将选中的 {selectedIds.size} 个素材标记为:
|
||||
</p>
|
||||
<Radio.Group
|
||||
value={batchSmartView}
|
||||
onChange={(e) => setBatchSmartView(e.target.value)}
|
||||
className="xx-batch-mark-options"
|
||||
>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="recommended">
|
||||
<Tag color="success">推荐</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
质量优良,可直接用于生产
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="caution">
|
||||
<Tag color="warning">慎用</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
存在一定问题,需人工审核后再使用
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
<div className="xx-batch-mark-option">
|
||||
<Radio value="high_risk">
|
||||
<Tag color="error">高风险</Tag>
|
||||
<span className="xx-batch-mark-desc">
|
||||
存在严重问题,不建议使用
|
||||
</span>
|
||||
</Radio>
|
||||
</div>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</AntModal>
|
||||
|
||||
{/* ─── 操作结果 Drawer ─── */}
|
||||
<Drawer
|
||||
title={`${operationTitle} — 操作结果`}
|
||||
open={resultDrawerOpen}
|
||||
onClose={() => {
|
||||
setResultDrawerOpen(false);
|
||||
setOperationResult(null);
|
||||
}}
|
||||
width={420}
|
||||
>
|
||||
{operationResult && (
|
||||
<div className="xx-batch-result">
|
||||
<div className="xx-batch-result-summary">
|
||||
<div className="xx-batch-result-stat">
|
||||
<span className="xx-batch-result-total">
|
||||
总计 {operationResult.total} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-batch-result-stat success">
|
||||
<CheckCircleOutlined />
|
||||
<span>成功 {operationResult.success_count} 个</span>
|
||||
</div>
|
||||
{operationResult.failure_count > 0 && (
|
||||
<div className="xx-batch-result-stat fail">
|
||||
<CloseCircleOutlined />
|
||||
<span>失败 {operationResult.failure_count} 个</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{operationResult.succeeded.length > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title success">
|
||||
<CheckCircleOutlined /> 成功列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult.succeeded.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{operationResult.failed.length > 0 && (
|
||||
<div className="xx-batch-result-section">
|
||||
<h4 className="xx-batch-result-section-title fail">
|
||||
<CloseCircleOutlined /> 失败列表
|
||||
</h4>
|
||||
<div className="xx-batch-result-ids">
|
||||
{operationResult.failed.map((id) => (
|
||||
<div key={id} className="xx-batch-result-id fail">
|
||||
{id}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -653,3 +653,175 @@
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ─── 批量打标签弹窗 ─── */
|
||||
|
||||
.xx-batch-tag-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-mode-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.xx-batch-tag-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-tag-warning {
|
||||
padding: 10px 12px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: #c2410c;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ─── 批量改分类弹窗 ─── */
|
||||
|
||||
.xx-batch-classify-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-classify-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ─── 批量智能标记弹窗 ─── */
|
||||
|
||||
.xx-batch-mark-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-mark-hint {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-batch-mark-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-batch-mark-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.xx-batch-mark-desc {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ─── 操作结果 Drawer ─── */
|
||||
|
||||
.xx-batch-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.xx-batch-result-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
.xx-batch-result-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.xx-batch-result-stat.success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.xx-batch-result-stat.fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.xx-batch-result-total {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-batch-result-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title.success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.xx-batch-result-section-title.fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.xx-batch-result-ids {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.xx-batch-result-id {
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.xx-batch-result-id.fail {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 剪辑计划管理页面
|
||||
* 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
FileTextOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
generateEditPlan,
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
} from "@/api/editPlans";
|
||||
import { getTemplatesList, type TemplateItem } from "@/api/templates";
|
||||
import "./edit-plans.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "editing", label: "编辑中" },
|
||||
{ key: "rendering", label: "渲染中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
EditPlanStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
draft: {
|
||||
label: "草稿",
|
||||
color: "default",
|
||||
icon: <FileTextOutlined />,
|
||||
},
|
||||
editing: {
|
||||
label: "编辑中",
|
||||
color: "processing",
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
rendering: {
|
||||
label: "渲染中",
|
||||
color: "warning",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "-";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function EditPlans() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
|
||||
"all",
|
||||
);
|
||||
const [templateFilter, setTemplateFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
// 查询参数
|
||||
const queryParams: EditPlanListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(templateFilter !== "all" && { template_id: templateFilter }),
|
||||
};
|
||||
|
||||
// 获取剪辑计划列表
|
||||
const {
|
||||
data: planData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["edit-plans", queryParams],
|
||||
queryFn: () => getEditPlans(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的计划时自动刷新
|
||||
const plans = query.state.data?.items ?? [];
|
||||
const hasRunning = plans.some(
|
||||
(p) => p.status === "rendering" || p.status === "editing",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 获取模板列表(用于筛选下拉)
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ["templates-list-simple"],
|
||||
queryFn: getTemplatesList,
|
||||
});
|
||||
|
||||
const plans = planData?.items ?? [];
|
||||
const total = planData?.total ?? 0;
|
||||
|
||||
// 模板名称映射
|
||||
const templateNameMap = new Map<string, string>();
|
||||
(templates ?? []).forEach((t: TemplateItem) => {
|
||||
templateNameMap.set(t.id, t.name);
|
||||
});
|
||||
|
||||
// 删除计划
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("剪辑计划已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 重新生成
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: generateEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("已重新提交生成");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重新生成失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
navigate(`/app/editing-planner?planId=${plan.id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<EditPlan> = [
|
||||
{
|
||||
title: "计划名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (name: string, record: EditPlan) => (
|
||||
<Tooltip title={name}>
|
||||
<span className="plan-name" onClick={() => handleEdit(record)}>
|
||||
{name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "模板",
|
||||
dataIndex: "template_id",
|
||||
key: "template_id",
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (templateId: string) => {
|
||||
const name = templateNameMap.get(templateId);
|
||||
return (
|
||||
<Tag color="blue" className="plan-template-tag">
|
||||
{name || templateId.slice(0, 8)}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: EditPlanStatus) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="plan-status-tag"
|
||||
>
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
dataIndex: "total_duration",
|
||||
key: "total_duration",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="plan-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 180,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: EditPlan) => (
|
||||
<div className="plan-actions">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{(record.status === "failed" || record.status === "completed") && (
|
||||
<Popconfirm
|
||||
title="确认重新生成"
|
||||
description="确定要重新生成这个剪辑计划吗?"
|
||||
onConfirm={() => regenerateMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={regenerateMutation.isPending}
|
||||
className="plan-action-btn plan-regenerate-btn"
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除"
|
||||
description="确定要删除这个剪辑计划吗?此操作不可恢复。"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteMutation.isPending}
|
||||
className="plan-action-btn"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
<div className="edit-plans-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载剪辑计划失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-plans-page">
|
||||
{/* 页面标题 */}
|
||||
<div className="edit-plans-header">
|
||||
<div className="edit-plans-header-text">
|
||||
<h2>剪辑计划</h2>
|
||||
<p>管理所有剪辑计划,支持重新生成和编辑</p>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate("/app/templates")}>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="edit-plans-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as EditPlanStatus | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="edit-plans-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 模板筛选 */}
|
||||
<Select
|
||||
value={templateFilter}
|
||||
onChange={(value) => {
|
||||
setTemplateFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部模板" },
|
||||
...(templates ?? []).map((t: TemplateItem) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
})),
|
||||
]}
|
||||
style={{ minWidth: 180 }}
|
||||
placeholder="选择模板"
|
||||
className="edit-plans-template-filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 计划表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={plans}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
className="edit-plans-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="edit-plans-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无剪辑计划</p>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => navigate("/app/templates")}
|
||||
>
|
||||
从模板创建
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 剪辑计划管理页面样式
|
||||
*/
|
||||
|
||||
/* ── 页面容器 ──────────────────────────────────────────── */
|
||||
.edit-plans-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── 页面头部 ──────────────────────────────────────────── */
|
||||
.edit-plans-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.edit-plans-header-text h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.edit-plans-header-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 筛选栏 ────────────────────────────────────────────── */
|
||||
.edit-plans-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab {
|
||||
padding: 8px 16px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs .ant-tabs-ink-bar {
|
||||
background: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* ── 表格 ──────────────────────────────────────────────── */
|
||||
.edit-plans-table {
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-tertiary, #f8fafc) !important;
|
||||
border-bottom: 1px solid var(--border-primary, #e2e8f0);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr > td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-light, #f1f5f9);
|
||||
}
|
||||
|
||||
.edit-plans-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover, #f8fafc) !important;
|
||||
}
|
||||
|
||||
/* ── 计划名称 ──────────────────────────────────────────── */
|
||||
.plan-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1e293b);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.plan-name:hover {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
/* ── 状态标签 ──────────────────────────────────────────── */
|
||||
.plan-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-default {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-processing {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-success {
|
||||
background: #f0fdf4;
|
||||
color: #16a34a;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-error {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.plan-status-tag.ant-tag-warning {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── 时长 ──────────────────────────────────────────────── */
|
||||
.plan-duration {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 时间 ──────────────────────────────────────────────── */
|
||||
.plan-time {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 操作按钮 ──────────────────────────────────────────── */
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plan-action-btn {
|
||||
padding: 4px 8px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link {
|
||||
color: var(--primary-500, #6366f1);
|
||||
}
|
||||
|
||||
.plan-action-btn.ant-btn-link:hover {
|
||||
color: var(--primary-600, #4f46e5);
|
||||
}
|
||||
|
||||
.plan-regenerate-btn {
|
||||
color: var(--primary-500, #6366f1) !important;
|
||||
}
|
||||
|
||||
.plan-regenerate-btn:hover {
|
||||
color: var(--primary-600, #4f46e5) !important;
|
||||
}
|
||||
|
||||
/* ── 空状态 ────────────────────────────────────────────── */
|
||||
.edit-plans-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edit-plans-empty .anticon {
|
||||
font-size: 48px;
|
||||
color: var(--text-disabled, #cbd5e1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ──────────────────────────────────────────── */
|
||||
.edit-plans-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
background: var(--bg-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.edit-plans-error .anticon {
|
||||
font-size: 48px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-error p {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
/* ── 响应式 ────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.edit-plans-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.edit-plans-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit-plans-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.edit-plans-status-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-plans-template-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
* 剪辑计划编辑器 — V8 原型 1:1 还原
|
||||
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { message } from "antd";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
createEditingTemplate,
|
||||
updateEditingTemplate,
|
||||
getTemplateCategories,
|
||||
generateFromTemplate,
|
||||
MODE_LABELS,
|
||||
} from "@/api/editingPlanner";
|
||||
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
|
||||
@@ -28,9 +27,34 @@ import {
|
||||
generateCover,
|
||||
} from "@/api/editPlans";
|
||||
import { useUndoRedo } from "./hooks/useUndoRedo";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import { createGenerationTask, getTask, retryTask } from "@/api/tasks";
|
||||
import type { ClipData, ClipType } from "./types";
|
||||
import type {
|
||||
ClipData,
|
||||
ClipType,
|
||||
TransitionConfig,
|
||||
SpeedConfig,
|
||||
TtsConfig,
|
||||
TrimConfig,
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
PipConfig,
|
||||
FilterConfig,
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
TitleSettings,
|
||||
} from "./types";
|
||||
import {
|
||||
DEFAULT_TRANSITION,
|
||||
DEFAULT_SPEED,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
DEFAULT_WATERMARK,
|
||||
DEFAULT_INTRO_OUTRO,
|
||||
DEFAULT_PIP_CONFIG,
|
||||
DEFAULT_FILTER_CONFIG,
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_COVER_CONFIG,
|
||||
} from "./types";
|
||||
import {
|
||||
ensureDefaultLibrary,
|
||||
getAssetsByKind,
|
||||
@@ -42,10 +66,23 @@ import MediaPanel from "./components/MediaPanel";
|
||||
import PreviewPlayer from "./components/PreviewPlayer";
|
||||
import TimelinePanel from "./components/TimelinePanel";
|
||||
import ClipPropertiesPanel from "./components/ClipPropertiesPanel";
|
||||
import BgmSelector from "./components/BgmSelector";
|
||||
import SubtitleStylePanel from "./components/SubtitleStylePanel";
|
||||
import type { SubtitleStyleConfig } from "./components/SubtitleStylePanel";
|
||||
import { DEFAULT_SUBTITLE_STYLE } from "./components/SubtitleStylePanel";
|
||||
import TransitionSelector from "./components/TransitionSelector";
|
||||
import SpeedPanel from "./components/SpeedPanel";
|
||||
import TtsPanel from "./components/TtsPanel";
|
||||
import WatermarkPanel from "./components/WatermarkPanel";
|
||||
import IntroOutroPanel from "./components/IntroOutroPanel";
|
||||
import PipConfigPanel from "./components/PipConfigPanel";
|
||||
import FilterPanel from "./components/FilterPanel";
|
||||
import GreenScreenPanel from "./components/GreenScreenPanel";
|
||||
import StickerPanel from "./components/StickerPanel";
|
||||
import CoverSelector from "./components/CoverSelector";
|
||||
import SaveModal from "./components/SaveModal";
|
||||
import GenerationProgressModal from "./components/GenerationProgressModal";
|
||||
import type { GenPhase } from "./components/GenerationProgressModal";
|
||||
import GenerationHistoryModal from "./components/GenerationHistoryModal";
|
||||
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm";
|
||||
import "./EditingPlanner.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
@@ -107,7 +144,7 @@ const EditingPlanner: React.FC = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
/* ── 标题/字幕/BGM 设置 ── */
|
||||
const [titleSettings, setTitleSettings] = useState({
|
||||
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
|
||||
aiAutoSelect: false,
|
||||
title: "",
|
||||
position: "top",
|
||||
@@ -120,17 +157,72 @@ const EditingPlanner: React.FC = () => {
|
||||
color: "#ffffff",
|
||||
});
|
||||
|
||||
const [subtitleSettings, setSubtitleSettings] = useState({
|
||||
enabled: true,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
size: 16,
|
||||
animation: "none",
|
||||
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>(
|
||||
{
|
||||
...DEFAULT_SUBTITLE_STYLE,
|
||||
},
|
||||
);
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState<BgmMixConfig>({
|
||||
...DEFAULT_BGM_MIX_CONFIG,
|
||||
});
|
||||
|
||||
const [bgmSettings, setBgmSettings] = useState({
|
||||
music: "none",
|
||||
/* ── Drawer 开关 ── */
|
||||
const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false);
|
||||
const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false);
|
||||
const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false);
|
||||
const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false);
|
||||
/** 当前正在编辑转场的片段 ID(null = 全局默认转场) */
|
||||
const [transitionTargetClipId, setTransitionTargetClipId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
/** 当前正在调速的片段 ID */
|
||||
const [speedTargetClipId, setSpeedTargetClipId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
/** TTS 配音面板是否打开 */
|
||||
const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false);
|
||||
/** 当前正在配置 TTS 的片段 ID */
|
||||
const [ttsTargetClipId, setTtsTargetClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── 水印 / 片头片尾 ── */
|
||||
const [watermarkSettings, setWatermarkSettings] = useState<WatermarkConfig>({
|
||||
...DEFAULT_WATERMARK,
|
||||
});
|
||||
const [introOutroSettings, setIntroOutroSettings] =
|
||||
useState<IntroOutroConfig>({ ...DEFAULT_INTRO_OUTRO });
|
||||
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false);
|
||||
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 画中画 ── */
|
||||
const [pipSettings, setPipSettings] = useState<PipConfig>({
|
||||
...DEFAULT_PIP_CONFIG,
|
||||
});
|
||||
const [pipDrawerOpen, setPipDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 滤镜调色 ── */
|
||||
const [filterSettings, setFilterSettings] = useState<FilterConfig>({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
});
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 绿幕抠像 ── */
|
||||
const [chromaKeySettings, setChromaKeySettings] = useState<ChromaKeyConfig>({
|
||||
...DEFAULT_CHROMA_KEY_CONFIG,
|
||||
});
|
||||
const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 贴纸 ── */
|
||||
const [stickerSettings, setStickerSettings] = useState<StickerConfig>({
|
||||
...DEFAULT_STICKER_CONFIG,
|
||||
});
|
||||
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 封面 ── */
|
||||
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
|
||||
...DEFAULT_COVER_CONFIG,
|
||||
});
|
||||
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false);
|
||||
|
||||
/* ── 保存弹窗 ── */
|
||||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||||
@@ -139,15 +231,6 @@ const EditingPlanner: React.FC = () => {
|
||||
const [draftTags, setDraftTags] = useState("");
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
|
||||
/* ── 生成弹窗 ── */
|
||||
const [genModalOpen, setGenModalOpen] = useState(false);
|
||||
const [genPhase, setGenPhase] = useState<GenPhase>("setup");
|
||||
const [genTask, setGenTask] = useState<TaskItem | null>(null);
|
||||
const [genSubmitting, setGenSubmitting] = useState(false);
|
||||
const [voiceoverDuration, setVoiceoverDuration] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
@@ -163,6 +246,19 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
/* ── 播放 ── */
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [pixelsPerSecond, setPixelsPerSecond] = useState(40);
|
||||
const prevFrameTimeRef = useRef<number | null>(null);
|
||||
|
||||
/** 播放头跳转 */
|
||||
const handleSeek = useCallback((time: number) => {
|
||||
setCurrentTime(Math.max(0, time));
|
||||
}, []);
|
||||
|
||||
/** 轨道缩放 */
|
||||
const handleZoomChange = useCallback((pps: number) => {
|
||||
setPixelsPerSecond(pps);
|
||||
}, []);
|
||||
|
||||
/* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */
|
||||
const voiceMaterialsQuery = useQuery({
|
||||
@@ -256,16 +352,21 @@ const EditingPlanner: React.FC = () => {
|
||||
size: tpl.title_config.font_size,
|
||||
color: tpl.title_config.font_color || "#ffffff",
|
||||
}));
|
||||
setSubtitleSettings({
|
||||
setSubtitleSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.subtitle_config.enabled,
|
||||
position: tpl.subtitle_config.position,
|
||||
position: (tpl.subtitle_config.position ||
|
||||
"bottom") as SubtitleStyleConfig["position"],
|
||||
font: tpl.subtitle_config.font,
|
||||
size: tpl.subtitle_config.size,
|
||||
fontSize: tpl.subtitle_config.size,
|
||||
fontColor: tpl.subtitle_config.color || "#ffffff",
|
||||
animation: tpl.subtitle_config.animation,
|
||||
});
|
||||
setBgmSettings({
|
||||
music: tpl.bgm_config.music_id || "none",
|
||||
});
|
||||
}));
|
||||
setBgmSettings((prev) => ({
|
||||
...prev,
|
||||
enabled: tpl.bgm_config.enabled,
|
||||
music_id: tpl.bgm_config.music_id || "",
|
||||
}));
|
||||
setDraftName(tpl.name);
|
||||
setDraftCategory(tpl.category);
|
||||
setDraftTags(tpl.tags.join(", "));
|
||||
@@ -279,6 +380,31 @@ const EditingPlanner: React.FC = () => {
|
||||
const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0);
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId) || null;
|
||||
|
||||
/* rAF 帧推进 — 播放时平滑更新播放头位置 */
|
||||
useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
prevFrameTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
let rafId: number;
|
||||
const tick = (timestamp: number) => {
|
||||
if (prevFrameTimeRef.current !== null) {
|
||||
const delta = (timestamp - prevFrameTimeRef.current) / 1000;
|
||||
setCurrentTime((prev) => {
|
||||
const next = prev + delta;
|
||||
return next >= totalDuration ? totalDuration : next;
|
||||
});
|
||||
}
|
||||
prevFrameTimeRef.current = timestamp;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
prevFrameTimeRef.current = null;
|
||||
};
|
||||
}, [isPlaying, totalDuration]);
|
||||
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
if (currentFilter !== "全部" && t.category !== currentFilter) return false;
|
||||
if (
|
||||
@@ -349,6 +475,175 @@ const EditingPlanner: React.FC = () => {
|
||||
[clips.length, setClips],
|
||||
);
|
||||
|
||||
/* ── 裁剪更新:调整片段的 trim_config 和 duration ── */
|
||||
const handleClipTrim = useCallback(
|
||||
(clipId: string, trimConfig: TrimConfig, newDuration: number) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === clipId
|
||||
? { ...c, trim_config: trimConfig, duration: newDuration }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 片段分割:在指定比例位置将片段一分为二 ── */
|
||||
const handleClipSplit = useCallback(
|
||||
(clipId: string, splitRatio: number) => {
|
||||
setClips((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === clipId);
|
||||
if (idx === -1) return prev;
|
||||
const clip = prev[idx];
|
||||
const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10;
|
||||
if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev;
|
||||
|
||||
// 前半段
|
||||
const firstHalf: ClipData = {
|
||||
...clip,
|
||||
duration: splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
end_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 后半段
|
||||
const secondHalf: ClipData = {
|
||||
...clip,
|
||||
id: `clip-${Date.now()}`,
|
||||
duration: clip.duration - splitPoint,
|
||||
startOffset: clip.startOffset + splitPoint,
|
||||
trim_config: clip.trim_config
|
||||
? {
|
||||
...clip.trim_config,
|
||||
start_time: clip.trim_config.start_time + splitPoint,
|
||||
}
|
||||
: undefined,
|
||||
order: (clip.order ?? idx) + 1,
|
||||
};
|
||||
|
||||
const updated = [...prev];
|
||||
updated[idx] = firstHalf;
|
||||
updated.splice(idx + 1, 0, secondHalf);
|
||||
return updated.map((c, i) => ({ ...c, order: i }));
|
||||
});
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 恢复片段原始长度 ── */
|
||||
const handleClipResetTrim = useCallback(
|
||||
(clipId: string) => {
|
||||
setClips((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== clipId || !c.trim_config) return c;
|
||||
const originalDuration =
|
||||
c.trim_config.original_duration ?? c.duration;
|
||||
return {
|
||||
...c,
|
||||
duration: originalDuration,
|
||||
trim_config: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[setClips],
|
||||
);
|
||||
|
||||
/* ── 转场特效变更 ── */
|
||||
const handleTransitionChange = useCallback(
|
||||
(config: TransitionConfig) => {
|
||||
if (transitionTargetClipId) {
|
||||
// 更新指定片段的转场
|
||||
handleClipUpdate(transitionTargetClipId, { transition: config });
|
||||
}
|
||||
// 同时更新全局默认转场(供新片段使用)
|
||||
},
|
||||
[transitionTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开转场选择器 ── */
|
||||
const handleOpenTransitionDrawer = useCallback((clipId?: string) => {
|
||||
setTransitionTargetClipId(clipId ?? null);
|
||||
setTransitionDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── 调速变更 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(config: SpeedConfig) => {
|
||||
if (speedTargetClipId) {
|
||||
handleClipUpdate(speedTargetClipId, { speed: config });
|
||||
}
|
||||
},
|
||||
[speedTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开调速面板 ── */
|
||||
const handleOpenSpeedDrawer = useCallback((clipId: string) => {
|
||||
setSpeedTargetClipId(clipId);
|
||||
setSpeedDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── TTS 配音变更 ── */
|
||||
const handleTtsChange = useCallback(
|
||||
(ttsConfig: TtsConfig) => {
|
||||
if (!ttsTargetClipId) return;
|
||||
handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig });
|
||||
},
|
||||
[ttsTargetClipId],
|
||||
);
|
||||
|
||||
/* ── 打开 TTS 配音面板 ── */
|
||||
const handleOpenTtsDrawer = useCallback((clipId: string) => {
|
||||
setTtsTargetClipId(clipId);
|
||||
setTtsDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── 调速应用到所有片段 ── */
|
||||
const handleApplySpeedAll = useCallback((config: SpeedConfig) => {
|
||||
setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } })));
|
||||
message.success("已应用到所有片段");
|
||||
}, []);
|
||||
|
||||
/* ── 水印配置变更 ── */
|
||||
const handleWatermarkChange = useCallback((config: WatermarkConfig) => {
|
||||
setWatermarkSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 片头片尾配置变更 ── */
|
||||
const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => {
|
||||
setIntroOutroSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 画中画配置变更 ── */
|
||||
const handlePipChange = useCallback((config: PipConfig) => {
|
||||
setPipSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 滤镜调色配置变更 ── */
|
||||
const handleFilterChange = useCallback((config: FilterConfig) => {
|
||||
setFilterSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 绿幕抠像配置变更 ── */
|
||||
const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => {
|
||||
setChromaKeySettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 贴纸配置变更 ── */
|
||||
const handleStickerChange = useCallback((config: StickerConfig) => {
|
||||
setStickerSettings(config);
|
||||
}, []);
|
||||
|
||||
/* ── 封面配置变更 ── */
|
||||
const handleCoverChange = useCallback((config: CoverConfig) => {
|
||||
setCoverSettings(config);
|
||||
}, []);
|
||||
|
||||
/* AI 封面生成 */
|
||||
const handleAiGenerateCover = async (
|
||||
coverType: "ai_frame" | "ai_regenerate",
|
||||
@@ -408,13 +703,13 @@ const EditingPlanner: React.FC = () => {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
color: "#ffffff",
|
||||
size: subtitleSettings.size,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.music !== "none",
|
||||
music_id: bgmSettings.music,
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
estimated_duration: totalDuration,
|
||||
segments: clips.map((c, i) => ({
|
||||
@@ -422,7 +717,35 @@ const EditingPlanner: React.FC = () => {
|
||||
duration_min: Math.max(1, c.duration - 2),
|
||||
duration_max: c.duration + 2,
|
||||
material_type: c.type === "voice" ? "voiceover" : "video",
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
};
|
||||
if (loadedTemplateId) {
|
||||
await updateEditingTemplate(loadedTemplateId, payload);
|
||||
@@ -462,12 +785,13 @@ const EditingPlanner: React.FC = () => {
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.size,
|
||||
color: subtitleSettings.fontColor,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
},
|
||||
bgm_config: {
|
||||
enabled: bgmSettings.music !== "none",
|
||||
music_id: bgmSettings.music,
|
||||
enabled: bgmSettings.enabled,
|
||||
music_id: bgmSettings.music_id,
|
||||
},
|
||||
mode: currentMode,
|
||||
total_duration: totalDuration,
|
||||
@@ -479,7 +803,35 @@ const EditingPlanner: React.FC = () => {
|
||||
script_text: c.script_text,
|
||||
voice_asset_id: c.voice_asset_id,
|
||||
voice_file_url: c.voice_file_url,
|
||||
transition: c.transition
|
||||
? { type: c.transition.type, duration: c.transition.duration }
|
||||
: undefined,
|
||||
playback_speed: c.speed ? c.speed.rate : undefined,
|
||||
tts_config: c.tts_config
|
||||
? {
|
||||
mode: c.tts_config.mode,
|
||||
text: c.tts_config.text,
|
||||
voice_id: c.tts_config.voice_id,
|
||||
speed: c.tts_config.speed,
|
||||
pitch: c.tts_config.pitch,
|
||||
volume: c.tts_config.volume,
|
||||
subtitle_sync: c.tts_config.subtitle_sync,
|
||||
}
|
||||
: undefined,
|
||||
trim_config: c.trim_config
|
||||
? {
|
||||
start_time: c.trim_config.start_time,
|
||||
end_time: c.trim_config.end_time,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
watermark_config: { ...watermarkSettings },
|
||||
intro_outro_config: { ...introOutroSettings },
|
||||
pip_config: { ...pipSettings },
|
||||
filter_config: { ...filterSettings },
|
||||
green_screen_config: { ...chromaKeySettings },
|
||||
sticker_config: { ...stickerSettings },
|
||||
cover_config: { ...coverSettings },
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
if (loadedTemplateId) {
|
||||
@@ -489,87 +841,6 @@ const EditingPlanner: React.FC = () => {
|
||||
navigate(`/app/generate?${params.toString()}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建生成任务(两步)
|
||||
* 1. generateFromTemplate — 通知后端基于模板生成视频
|
||||
* 2. createGenerationTask — 创建任务记录,返回精简响应
|
||||
* 再用 getTask 查询完整 TaskItem 供轮询使用
|
||||
*/
|
||||
const handleGenerate = async () => {
|
||||
if (!loadedTemplateId) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
await generateFromTemplate(loadedTemplateId, {
|
||||
voiceover_duration: voiceoverDuration || totalDuration,
|
||||
});
|
||||
// 收集所有 voice 类型片段的配音素材 ID
|
||||
const voiceIds = clips
|
||||
.filter((c) => c.type === "voice" && c.voice_asset_id)
|
||||
.map((c) => c.voice_asset_id as string);
|
||||
const res = await createGenerationTask({
|
||||
template_id: loadedTemplateId,
|
||||
asset_ids: [],
|
||||
title_ids: [],
|
||||
voice_ids: voiceIds,
|
||||
});
|
||||
/* 创建接口返回的是精简响应,需查询完整 TaskItem 用于轮询 */
|
||||
const task = await getTask(res.id);
|
||||
setGenTask(task);
|
||||
setGenPhase("progress");
|
||||
message.info("生成任务已创建");
|
||||
} catch {
|
||||
message.error("创建生成任务失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 轮询生成任务状态(每 3 秒)
|
||||
* 仅在 genPhase === "progress" 且有任务 ID 时启动
|
||||
* 任务完成/失败时自动停止轮询
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (genPhase !== "progress" || !genTask?.id) return;
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const t = await getTask(genTask.id);
|
||||
setGenTask(t);
|
||||
if (t.status === "completed") {
|
||||
setGenPhase("completed");
|
||||
clearInterval(timer);
|
||||
} else if (t.status === "failed") {
|
||||
setGenPhase("failed");
|
||||
clearInterval(timer);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [genPhase, genTask?.id]);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!genTask?.id) return;
|
||||
setGenSubmitting(true);
|
||||
try {
|
||||
const t = await retryTask(genTask.id);
|
||||
setGenTask(t);
|
||||
setGenPhase("progress");
|
||||
} catch {
|
||||
message.error("重试失败");
|
||||
} finally {
|
||||
setGenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelGen = () => {
|
||||
setGenModalOpen(false);
|
||||
setGenPhase("setup");
|
||||
setGenTask(null);
|
||||
setVoiceoverDuration(null);
|
||||
};
|
||||
|
||||
/* 查看生成历史 */
|
||||
const handleViewGenHistory = async () => {
|
||||
if (!loadedTemplateId) {
|
||||
@@ -678,7 +949,13 @@ const EditingPlanner: React.FC = () => {
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
subtitleSettings={{
|
||||
enabled: subtitleSettings.enabled,
|
||||
position: subtitleSettings.position,
|
||||
font: subtitleSettings.font,
|
||||
size: subtitleSettings.fontSize,
|
||||
animation: subtitleSettings.animation,
|
||||
}}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
@@ -694,6 +971,14 @@ const EditingPlanner: React.FC = () => {
|
||||
onClipReorder={handleClipReorder}
|
||||
onClipRemove={handleClipRemove}
|
||||
onAddClip={handleAddClip}
|
||||
onClipTrim={handleClipTrim}
|
||||
onClipSplit={handleClipSplit}
|
||||
onClipResetTrim={handleClipResetTrim}
|
||||
currentTime={currentTime}
|
||||
pixelsPerSecond={pixelsPerSecond}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={handleSeek}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -710,16 +995,30 @@ const EditingPlanner: React.FC = () => {
|
||||
setTitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onSubtitleSettingsChange={(partial) =>
|
||||
setSubtitleSettings((prev) => ({ ...prev, ...partial }))
|
||||
setSubtitleSettings(
|
||||
(prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig,
|
||||
)
|
||||
}
|
||||
onBgmSettingsChange={(partial) =>
|
||||
setBgmSettings((prev) => ({ ...prev, ...partial }))
|
||||
}
|
||||
onClipUpdate={handleClipUpdate}
|
||||
onOpenBgmDrawer={() => setBgmDrawerOpen(true)}
|
||||
onOpenSubtitleDrawer={() => setSubtitleDrawerOpen(true)}
|
||||
voiceMaterials={voiceMaterials}
|
||||
voiceMaterialsLoading={voiceMaterialsQuery.isLoading}
|
||||
onRefreshVoiceMaterials={() => voiceMaterialsQuery.refetch()}
|
||||
onClipVoiceSelect={handleClipVoiceSelect}
|
||||
onOpenTransitionDrawer={handleOpenTransitionDrawer}
|
||||
onOpenSpeedDrawer={handleOpenSpeedDrawer}
|
||||
onOpenTtsDrawer={handleOpenTtsDrawer}
|
||||
onOpenWatermarkDrawer={() => setWatermarkDrawerOpen(true)}
|
||||
onOpenIntroOutroDrawer={() => setIntroOutroDrawerOpen(true)}
|
||||
onOpenPipDrawer={() => setPipDrawerOpen(true)}
|
||||
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
|
||||
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
|
||||
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
|
||||
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -758,20 +1057,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onCancel={() => setSaveModalOpen(false)}
|
||||
/>
|
||||
|
||||
<GenerationProgressModal
|
||||
open={genModalOpen}
|
||||
phase={genPhase}
|
||||
voiceoverDuration={voiceoverDuration}
|
||||
estimatedDuration={totalDuration}
|
||||
onDurationChange={setVoiceoverDuration}
|
||||
onGenerate={handleGenerate}
|
||||
task={genTask}
|
||||
submitting={genSubmitting}
|
||||
onCancel={handleCancelGen}
|
||||
onRetry={handleRetry}
|
||||
onClose={handleCancelGen}
|
||||
/>
|
||||
|
||||
{/* ═══ 生成历史弹窗 ═══ */}
|
||||
<GenerationHistoryModal
|
||||
open={genHistoryOpen}
|
||||
@@ -779,6 +1064,122 @@ const EditingPlanner: React.FC = () => {
|
||||
history={genHistory}
|
||||
onClose={() => setGenHistoryOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ═══ BGM 选择器 Drawer ═══ */}
|
||||
<BgmSelector
|
||||
open={bgmDrawerOpen}
|
||||
onClose={() => setBgmDrawerOpen(false)}
|
||||
config={bgmSettings}
|
||||
onChange={setBgmSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 字幕样式配置 Drawer ═══ */}
|
||||
<SubtitleStylePanel
|
||||
open={subtitleDrawerOpen}
|
||||
onClose={() => setSubtitleDrawerOpen(false)}
|
||||
config={subtitleSettings}
|
||||
onChange={setSubtitleSettings}
|
||||
/>
|
||||
|
||||
{/* ═══ 转场特效选择器 Drawer ═══ */}
|
||||
<TransitionSelector
|
||||
open={transitionDrawerOpen}
|
||||
onClose={() => setTransitionDrawerOpen(false)}
|
||||
config={
|
||||
transitionTargetClipId
|
||||
? (clips.find((c) => c.id === transitionTargetClipId)?.transition ??
|
||||
DEFAULT_TRANSITION)
|
||||
: DEFAULT_TRANSITION
|
||||
}
|
||||
onChange={handleTransitionChange}
|
||||
title={transitionTargetClipId ? "片段转场设置" : "全局默认转场"}
|
||||
/>
|
||||
|
||||
{/* ═══ 片段调速面板 Drawer ═══ */}
|
||||
{speedTargetClipId && (
|
||||
<SpeedPanel
|
||||
open={speedDrawerOpen}
|
||||
onClose={() => setSpeedDrawerOpen(false)}
|
||||
config={
|
||||
clips.find((c) => c.id === speedTargetClipId)?.speed ??
|
||||
DEFAULT_SPEED
|
||||
}
|
||||
onChange={handleSpeedChange}
|
||||
onApplyAll={handleApplySpeedAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ TTS 配音面板 Drawer ═══ */}
|
||||
{ttsTargetClipId && (
|
||||
<TtsPanel
|
||||
open={ttsDrawerOpen}
|
||||
onClose={() => setTtsDrawerOpen(false)}
|
||||
config={
|
||||
clips.find((c) => c.id === ttsTargetClipId)?.tts_config ??
|
||||
DEFAULT_TTS_CONFIG
|
||||
}
|
||||
onChange={handleTtsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ═══ 水印配置面板 ═══ */}
|
||||
<WatermarkPanel
|
||||
open={watermarkDrawerOpen}
|
||||
onClose={() => setWatermarkDrawerOpen(false)}
|
||||
config={watermarkSettings}
|
||||
onChange={handleWatermarkChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 片头片尾配置面板 ═══ */}
|
||||
<IntroOutroPanel
|
||||
open={introOutroDrawerOpen}
|
||||
onClose={() => setIntroOutroDrawerOpen(false)}
|
||||
config={introOutroSettings}
|
||||
onChange={handleIntroOutroChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 画中画配置面板 ═══ */}
|
||||
<PipConfigPanel
|
||||
open={pipDrawerOpen}
|
||||
onClose={() => setPipDrawerOpen(false)}
|
||||
config={pipSettings}
|
||||
onChange={handlePipChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 滤镜调色面板 ═══ */}
|
||||
<FilterPanel
|
||||
open={filterDrawerOpen}
|
||||
onClose={() => setFilterDrawerOpen(false)}
|
||||
config={filterSettings}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 绿幕抠像面板 ═══ */}
|
||||
<GreenScreenPanel
|
||||
open={chromaKeyDrawerOpen}
|
||||
onClose={() => setChromaKeyDrawerOpen(false)}
|
||||
config={chromaKeySettings}
|
||||
onChange={handleChromaKeyChange}
|
||||
/>
|
||||
|
||||
{/* ═══ 贴纸面板 ═══ */}
|
||||
<StickerPanel
|
||||
open={stickerDrawerOpen}
|
||||
onClose={() => setStickerDrawerOpen(false)}
|
||||
config={stickerSettings}
|
||||
onChange={handleStickerChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
|
||||
{/* ═══ 封面选择器 ═══ */}
|
||||
<CoverSelector
|
||||
open={coverDrawerOpen}
|
||||
onClose={() => setCoverDrawerOpen(false)}
|
||||
config={coverSettings}
|
||||
onChange={handleCoverChange}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Drawer, Slider, Input, Tag, message } from "antd";
|
||||
import {
|
||||
getBgmPresets,
|
||||
type BgmPreset,
|
||||
type BgmCategory,
|
||||
type BgmMixConfig,
|
||||
DEFAULT_BGM_MIX_CONFIG,
|
||||
} from "@/api/bgm";
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all";
|
||||
label: string;
|
||||
icon: string;
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface BgmSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: BgmMixConfig;
|
||||
onChange: (config: BgmMixConfig) => void;
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
|
||||
"all",
|
||||
);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {};
|
||||
if (activeCategory !== "all") params.category = activeCategory;
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
const data = await getBgmPresets(params);
|
||||
setPresets(data);
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeCategory, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets();
|
||||
}, [open, loadPresets]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
}
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(bgm.url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(bgm.id);
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgm.id,
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎵 BGM 音乐选择"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="bgm-selector-drawer"
|
||||
>
|
||||
{/* ── 搜索框 ── */}
|
||||
<div className="bgm-search-row">
|
||||
<Search
|
||||
placeholder="搜索 BGM 名称..."
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onSearch={() => loadPresets()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 分类标签 ── */}
|
||||
<div className="bgm-category-bar">
|
||||
{CATEGORY_LIST.map((cat) => (
|
||||
<Tag
|
||||
key={cat.key}
|
||||
className={`bgm-category-tag${activeCategory === cat.key ? " active" : ""}`}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
>
|
||||
{cat.icon} {cat.label}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── BGM 列表 ── */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && (
|
||||
<div className="bgm-empty">暂无 BGM 数据</div>
|
||||
)}
|
||||
{presets.map((bgm) => {
|
||||
const isSelected = config.music_id === bgm.id;
|
||||
const isPlaying = previewingId === bgm.id;
|
||||
return (
|
||||
<div
|
||||
key={bgm.id}
|
||||
className={`bgm-item${isSelected ? " selected" : ""}`}
|
||||
onClick={() => handleSelect(bgm)}
|
||||
>
|
||||
<div className="bgm-item-cover">
|
||||
{bgm.cover_url ? (
|
||||
<img src={bgm.cover_url} alt={bgm.name} />
|
||||
) : (
|
||||
<span className="bgm-item-cover-icon">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bgm-item-info">
|
||||
<div className="bgm-item-name">{bgm.name}</div>
|
||||
<div className="bgm-item-meta">
|
||||
<span className="bgm-item-category">{bgm.category}</span>
|
||||
<span className="bgm-item-duration">
|
||||
{Math.floor(bgm.duration / 60)}:
|
||||
{String(Math.floor(bgm.duration % 60)).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
{bgm.tags.length > 0 && (
|
||||
<div className="bgm-item-tags">
|
||||
{bgm.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="bgm-item-tag">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview(bgm);
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
{isPlaying ? "⏸" : "▶️"}
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 混音配置 ── */}
|
||||
{config.enabled && config.music_id && (
|
||||
<div className="bgm-mix-config">
|
||||
<div className="bgm-mix-header">
|
||||
<span>混音配置</span>
|
||||
<button className="bgm-mix-clear" onClick={handleClear}>
|
||||
移除 BGM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm
|
||||
? `当前:${selectedBgm.name}`
|
||||
: `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
音量 <span className="bgm-mix-value">{config.volume}%</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.volume}
|
||||
onChange={(v) => onChange({ ...config, volume: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_in.toFixed(1)}s
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_in}
|
||||
onChange={(v) => onChange({ ...config, fade_in: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_out.toFixed(1)}s
|
||||
</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={config.fade_out}
|
||||
onChange={(v) => onChange({ ...config, fade_out: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 人声闪避 */}
|
||||
<div className="bgm-mix-field bgm-mix-toggle-row">
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
onChange({ ...config, voice_dodge: !config.voice_dodge })
|
||||
}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BgmSelector;
|
||||
@@ -5,32 +5,30 @@
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
animation: string;
|
||||
mode?: string;
|
||||
stroke?: boolean;
|
||||
shadow?: boolean;
|
||||
asrLanguage?: string;
|
||||
}
|
||||
|
||||
interface BgmSettings {
|
||||
music: string;
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
volume?: number;
|
||||
fade_in?: number;
|
||||
fade_out?: number;
|
||||
voice_dodge?: boolean;
|
||||
}
|
||||
|
||||
interface ClipPropertiesPanelProps {
|
||||
@@ -45,6 +43,10 @@ interface ClipPropertiesPanelProps {
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
/** 打开 BGM 选择器 Drawer */
|
||||
onOpenBgmDrawer?: () => void;
|
||||
/** 打开字幕样式配置 Drawer */
|
||||
onOpenSubtitleDrawer?: () => void;
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
/** 配音素材加载中 */
|
||||
@@ -53,6 +55,26 @@ interface ClipPropertiesPanelProps {
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
/** 打开转场特效选择器 Drawer */
|
||||
onOpenTransitionDrawer?: (clipId: string) => void;
|
||||
/** 打开片段调速面板 Drawer */
|
||||
onOpenSpeedDrawer?: (clipId: string) => void;
|
||||
/** 打开 TTS 配音面板 Drawer */
|
||||
onOpenTtsDrawer?: (clipId: string) => void;
|
||||
/** 打开水印设置面板 Drawer */
|
||||
onOpenWatermarkDrawer?: () => void;
|
||||
/** 打开片头片尾设置面板 Drawer */
|
||||
onOpenIntroOutroDrawer?: () => void;
|
||||
/** 打开画中画设置面板 Drawer */
|
||||
onOpenPipDrawer?: () => void;
|
||||
/** 打开滤镜调色面板 Drawer */
|
||||
onOpenFilterDrawer?: () => void;
|
||||
/** 打开绿幕抠像面板 Drawer */
|
||||
onOpenGreenScreenDrawer?: () => void;
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void;
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void;
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
@@ -78,14 +100,6 @@ const ANIMATION_OPTIONS = [
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const BGM_OPTIONS = [
|
||||
{ value: "none", label: "无背景音乐" },
|
||||
{ value: "bgm_01", label: "🎵 轻快节奏" },
|
||||
{ value: "bgm_02", label: "🎵 温馨舒缓" },
|
||||
{ value: "bgm_03", label: "🎵 动感活力" },
|
||||
{ value: "bgm_04", label: "🎵 科技感" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
|
||||
* 不绑定字体,用户可自由搭配任意字体
|
||||
@@ -272,12 +286,24 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
currentMode,
|
||||
onTitleSettingsChange,
|
||||
onSubtitleSettingsChange,
|
||||
onBgmSettingsChange,
|
||||
onBgmSettingsChange: _onBgmSettingsChange,
|
||||
onClipUpdate,
|
||||
onOpenBgmDrawer,
|
||||
onOpenSubtitleDrawer,
|
||||
voiceMaterials = [],
|
||||
voiceMaterialsLoading = false,
|
||||
onRefreshVoiceMaterials,
|
||||
onClipVoiceSelect,
|
||||
onOpenTransitionDrawer,
|
||||
onOpenSpeedDrawer,
|
||||
onOpenTtsDrawer,
|
||||
onOpenWatermarkDrawer,
|
||||
onOpenIntroOutroDrawer,
|
||||
onOpenPipDrawer,
|
||||
onOpenFilterDrawer,
|
||||
onOpenGreenScreenDrawer,
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -534,15 +560,17 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<input
|
||||
className="ep-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={32}
|
||||
value={subtitleSettings.size}
|
||||
min={12}
|
||||
max={48}
|
||||
value={subtitleSettings.fontSize}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ size: Number(e.target.value) })
|
||||
onSubtitleSettingsChange({
|
||||
fontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.size}px
|
||||
{subtitleSettings.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -563,6 +591,16 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 高级配置按钮 */}
|
||||
{onOpenSubtitleDrawer && (
|
||||
<button
|
||||
className="ep-advanced-btn"
|
||||
onClick={onOpenSubtitleDrawer}
|
||||
>
|
||||
🎨 高级字幕样式配置
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -574,20 +612,130 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
BGM 设置
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">背景音乐</label>
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={bgmSettings.music}
|
||||
onChange={(e) => onBgmSettingsChange({ music: e.target.value })}
|
||||
>
|
||||
{BGM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{bgmSettings.enabled && bgmSettings.music_id ? (
|
||||
<div className="ep-bgm-current">
|
||||
<span className="ep-bgm-current-label">🎵 已选择 BGM</span>
|
||||
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
|
||||
{bgmSettings.volume !== undefined && (
|
||||
<span className="ep-bgm-current-vol">
|
||||
音量 {bgmSettings.volume}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-bgm-empty">未选择背景音乐</div>
|
||||
)}
|
||||
|
||||
{onOpenBgmDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenBgmDrawer}>
|
||||
🎵 {bgmSettings.enabled ? "更换 BGM / 调整混音" : "选择 BGM 音乐"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 水印设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🔖</span>
|
||||
水印设置
|
||||
</div>
|
||||
{onOpenWatermarkDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenWatermarkDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🔖</span>
|
||||
<span className="ep-advanced-btn-label">水印配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片头片尾设置 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎬</span>
|
||||
片头片尾
|
||||
</div>
|
||||
{onOpenIntroOutroDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenIntroOutroDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">片头片尾配置</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 画中画 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
画中画
|
||||
</div>
|
||||
{onOpenPipDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">配置画中画图层</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 滤镜调色 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🎨</span>
|
||||
滤镜调色
|
||||
</div>
|
||||
{onOpenFilterDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenFilterDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🎨</span>
|
||||
<span className="ep-advanced-btn-label">配置滤镜调色</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 绿幕抠像 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🟩</span>
|
||||
绿幕抠像
|
||||
</div>
|
||||
{onOpenGreenScreenDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenGreenScreenDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🟩</span>
|
||||
<span className="ep-advanced-btn-label">配置绿幕抠像</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 贴纸 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🏷️</span>
|
||||
贴纸
|
||||
</div>
|
||||
{onOpenStickerDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenStickerDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🏷️</span>
|
||||
<span className="ep-advanced-btn-label">配置贴纸花字</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 封面 ═══ */}
|
||||
<div className="ep-settings-section">
|
||||
<div className="ep-section-title">
|
||||
<span className="ep-section-icon">🖼️</span>
|
||||
封面
|
||||
</div>
|
||||
{onOpenCoverDrawer && (
|
||||
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
|
||||
<span className="ep-advanced-btn-icon">🖼️</span>
|
||||
<span className="ep-advanced-btn-label">选择视频封面</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片段详情(选中时显示) ═══ */}
|
||||
@@ -649,6 +797,71 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 转场效果入口 */}
|
||||
{onOpenTransitionDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--transition"
|
||||
onClick={() => onOpenTransitionDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎬</span>
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const t = selectedClip.transition;
|
||||
if (!t || t.type === "none") return "无转场";
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === t.type,
|
||||
);
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`;
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 播放速度入口 */}
|
||||
{onOpenSpeedDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--speed"
|
||||
onClick={() => onOpenSpeedDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{selectedClip.speed
|
||||
? `${selectedClip.speed.rate.toFixed(2)}x`
|
||||
: "1.00x"}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TTS 配音入口 */}
|
||||
{onOpenTtsDrawer && (
|
||||
<div className="ep-clip-detail-field">
|
||||
<button
|
||||
className="ep-advanced-btn ep-advanced-btn--tts"
|
||||
onClick={() => onOpenTtsDrawer(selectedClip.id)}
|
||||
>
|
||||
<span className="ep-advanced-btn-icon">🎙️</span>
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const tts = selectedClip.tts_config;
|
||||
if (!tts || tts.mode === "none") return "无配音";
|
||||
if (tts.mode === "upload") return "上传配音";
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`;
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 素材起始时间 — 仅 voice 类型显示 */}
|
||||
{selectedClip.type === "voice" && (
|
||||
<div className="ep-clip-detail-field">
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type { CoverConfig, CoverMode } from "../types";
|
||||
import { DEFAULT_COVER_CONFIG } from "../types";
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: CoverConfig;
|
||||
onChange: (config: CoverConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/** 封面模式标签 */
|
||||
const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
};
|
||||
|
||||
/** 封面模式图标 */
|
||||
const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
};
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 切换模式 */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 处理文件上传 */
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string;
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 拖拽上传 */
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileUpload(file);
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
/** 使用 AI 推荐时间 */
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" });
|
||||
}
|
||||
}, [config.ai_suggested_time, update]);
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 10);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="封面选择"
|
||||
placement="right"
|
||||
width={440}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="cover-selector-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="cover-header">
|
||||
<span className="cover-header-label">启用自定义封面</span>
|
||||
<label className="cover-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => update({ enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="cover-switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 模式选择 */}
|
||||
<div className="cover-mode-section">
|
||||
<div className="cover-section-title">封面来源</div>
|
||||
<div className="cover-mode-tabs">
|
||||
{(["auto", "frame", "upload"] as CoverMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`cover-mode-tab${config.mode === m ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m)}
|
||||
>
|
||||
<span className="cover-mode-icon">{MODE_ICONS[m]}</span>
|
||||
<span className="cover-mode-label">{MODE_LABELS[m]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模式内容区 */}
|
||||
<div className="cover-mode-content">
|
||||
{/* 智能封面 */}
|
||||
{config.mode === "auto" && (
|
||||
<div className="cover-auto-section">
|
||||
<div className="cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
{config.ai_suggested_time !== null ? (
|
||||
<div className="cover-auto-suggestion">
|
||||
<div className="cover-auto-badge">AI 推荐</div>
|
||||
<div className="cover-auto-time">
|
||||
推荐时间点:{formatTime(config.ai_suggested_time)}
|
||||
</div>
|
||||
<button
|
||||
className="cover-auto-use-btn"
|
||||
onClick={handleUseAiSuggestion}
|
||||
>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-auto-pending">
|
||||
<div className="cover-auto-spinner" />
|
||||
<span>AI 分析中...(生成视频后自动推荐)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 抽帧选封面 */}
|
||||
{config.mode === "frame" && (
|
||||
<div className="cover-frame-section">
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="cover-frame-slider"
|
||||
min={0}
|
||||
max={Math.max(totalDuration, 1)}
|
||||
step={0.1}
|
||||
value={config.frame_time}
|
||||
onChange={(e) => update({ frame_time: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="cover-frame-range">
|
||||
<span>00:00</span>
|
||||
<span>{formatTime(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 快捷时间点 */}
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio;
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
className="cover-quick-btn"
|
||||
onClick={() => update({ frame_time: t })}
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传封面 */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="cover-upload-section">
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{config.upload_url ? (
|
||||
<div className="cover-upload-preview">
|
||||
<img src={config.upload_url} alt="封面预览" />
|
||||
<div className="cover-upload-overlay">点击更换</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">
|
||||
点击或拖拽上传封面图片
|
||||
</span>
|
||||
<span className="cover-upload-hint">
|
||||
支持 JPG / PNG,建议 16:9 比例
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 封面预览 */}
|
||||
<div className="cover-preview-section">
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img
|
||||
src={config.upload_url}
|
||||
alt="封面预览"
|
||||
className="cover-preview-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
<span className="cover-preview-text">
|
||||
{config.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
: config.mode === "frame"
|
||||
? `帧 ${formatTime(config.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cover-preview-ratio">16:9</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部 */}
|
||||
<div className="cover-footer">
|
||||
<button className="cover-reset-btn" onClick={handleReset}>
|
||||
重置封面
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoverSelector;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 滤镜调色配置面板
|
||||
* 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度)
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { FilterConfig, FilterPreset } from "../types";
|
||||
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types";
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: FilterConfig;
|
||||
onChange: (config: FilterConfig) => void;
|
||||
}
|
||||
|
||||
/** 所有预设列表 */
|
||||
const PRESET_LIST: FilterPreset[] = [
|
||||
"none",
|
||||
"original",
|
||||
"fresh",
|
||||
"warm",
|
||||
"cool",
|
||||
"vintage",
|
||||
"cinema",
|
||||
"bw",
|
||||
"sunshine",
|
||||
"film",
|
||||
];
|
||||
|
||||
/** 预设对应的示例渐变色(用于视觉预览) */
|
||||
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
|
||||
none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)",
|
||||
warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)",
|
||||
cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)",
|
||||
vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)",
|
||||
cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)",
|
||||
bw: "linear-gradient(135deg, #434343 0%, #000000 100%)",
|
||||
sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)",
|
||||
film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)",
|
||||
};
|
||||
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 选择预设时重置手动参数 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: FilterPreset) => {
|
||||
if (preset === "none") {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
} else {
|
||||
onChange({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
enabled: config.enabled,
|
||||
preset,
|
||||
});
|
||||
}
|
||||
},
|
||||
[config.enabled, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="滤镜调色"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="filter-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="filter-header">
|
||||
<span className="filter-header-label">启用滤镜</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 预设滤镜选择 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">预设滤镜</div>
|
||||
<div className="filter-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<div
|
||||
className="filter-preset-preview"
|
||||
style={{ background: PRESET_GRADIENTS[p] }}
|
||||
/>
|
||||
<span className="filter-preset-label">
|
||||
{FILTER_PRESET_LABELS[p]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手动调节 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">手动调节</div>
|
||||
|
||||
{/* 亮度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">亮度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.brightness}
|
||||
onChange={(e) => update({ brightness: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.brightness}</span>
|
||||
</div>
|
||||
|
||||
{/* 对比度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">对比度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.contrast}
|
||||
onChange={(e) => update({ contrast: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.contrast}</span>
|
||||
</div>
|
||||
|
||||
{/* 饱和度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">饱和度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.saturation}
|
||||
onChange={(e) => update({ saturation: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.saturation}</span>
|
||||
</div>
|
||||
|
||||
{/* 色温 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">色温</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.temperature}
|
||||
onChange={(e) => update({ temperature: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.temperature}</span>
|
||||
</div>
|
||||
|
||||
{/* 色调 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">色调</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={-100}
|
||||
max={100}
|
||||
value={config.tint}
|
||||
onChange={(e) => update({ tint: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.tint}</span>
|
||||
</div>
|
||||
|
||||
{/* 锐度 */}
|
||||
<div className="filter-slider-row">
|
||||
<span className="filter-slider-label">锐度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="filter-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.sharpness}
|
||||
onChange={(e) => update({ sharpness: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="filter-slider-value">{config.sharpness}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览色块 */}
|
||||
<div className="filter-section">
|
||||
<div className="filter-section-title">效果预览</div>
|
||||
<div
|
||||
className="filter-preview-block"
|
||||
style={{
|
||||
background: PRESET_GRADIENTS[config.preset],
|
||||
filter: [
|
||||
`brightness(${100 + config.brightness}%)`,
|
||||
`contrast(${100 + config.contrast}%)`,
|
||||
`saturate(${100 + config.saturation}%)`,
|
||||
].join(" "),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="filter-footer">
|
||||
<button className="filter-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPanel;
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 绿幕抠像配置面板
|
||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
|
||||
import {
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
} from "../types";
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: ChromaKeyConfig;
|
||||
onChange: (config: ChromaKeyConfig) => void;
|
||||
}
|
||||
|
||||
/** 预设列表 */
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = [
|
||||
"green",
|
||||
"blue",
|
||||
"red",
|
||||
"pure_green",
|
||||
"soft_green",
|
||||
];
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 选择颜色预设时同步更新 color 字段 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: ChromaKeyColorPreset) => {
|
||||
update({
|
||||
color_preset: preset,
|
||||
color: CHROMA_KEY_PRESET_COLORS[preset],
|
||||
});
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
/** 自定义颜色变化时清除预设标记 */
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
update({ color: e.target.value });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="绿幕抠像"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="green-screen-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="green-header">
|
||||
<span className="green-header-label">启用绿幕抠像</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => update({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色预设 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">颜色预设</div>
|
||||
<div className="green-presets">
|
||||
{PRESET_LIST.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<span
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">
|
||||
{CHROMA_KEY_PRESET_LABELS[p]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 自定义颜色 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">自定义颜色</div>
|
||||
<div className="green-color-row">
|
||||
<input
|
||||
type="color"
|
||||
className="green-color-picker"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="green-color-hex"
|
||||
value={config.color}
|
||||
onChange={handleColorChange}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div
|
||||
className="green-color-swatch"
|
||||
style={{ background: config.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 参数调节 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">参数调节</div>
|
||||
|
||||
{/* 相似度 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">相似度</span>
|
||||
<span className="green-slider-value">{config.similarity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.similarity}
|
||||
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
||||
</div>
|
||||
|
||||
{/* 边缘平滑 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">边缘平滑</span>
|
||||
<span className="green-slider-value">{config.blend}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.blend}
|
||||
onChange={(e) => update({ blend: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
||||
</div>
|
||||
|
||||
{/* 溢色抑制 */}
|
||||
<div className="green-slider-row">
|
||||
<div className="green-slider-header">
|
||||
<span className="green-slider-label">溢色抑制</span>
|
||||
<span className="green-slider-value">{config.spill}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="green-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.spill}
|
||||
onChange={(e) => update({ spill: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div
|
||||
className="green-preview-bg"
|
||||
style={{ background: config.color, opacity: 0.3 }}
|
||||
/>
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
</div>
|
||||
<div
|
||||
className="green-preview-edge"
|
||||
style={{
|
||||
borderColor: config.color,
|
||||
filter: `blur(${config.blend / 10}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="green-footer">
|
||||
<button className="green-reset-btn" onClick={handleReset}>
|
||||
重置参数
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default GreenScreenPanel;
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 片头片尾配置面板 — Drawer 形式
|
||||
* 两个区块:片头(Intro)/ 片尾(Outro)
|
||||
* 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
IntroOutroConfig,
|
||||
IntroOutroItem,
|
||||
IntroOutroKind,
|
||||
TransitionType,
|
||||
} from "../types";
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
|
||||
{ value: "none", label: "无", icon: "🚫" },
|
||||
{ value: "video", label: "视频", icon: "🎬" },
|
||||
{ value: "image", label: "图片", icon: "🖼️" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface IntroOutroPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: IntroOutroConfig;
|
||||
onChange: (config: IntroOutroConfig) => void;
|
||||
}
|
||||
|
||||
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 更新片头 ── */
|
||||
const handleIntroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, intro: { ...config.intro, ...partial } });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 更新片尾 ── */
|
||||
const handleOutroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, outro: { ...config.outro, ...partial } });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换片头类型 ── */
|
||||
const handleIntroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
},
|
||||
[handleIntroChange],
|
||||
);
|
||||
|
||||
/* ── 切换片尾类型 ── */
|
||||
const handleOutroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
},
|
||||
[handleOutroChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_INTRO_OUTRO });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎬 片头片尾设置"
|
||||
placement="right"
|
||||
width={420}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="intro-outro-panel-drawer"
|
||||
>
|
||||
{/* ═══ 片头区块 ═══ */}
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">🎞️</span>
|
||||
<span className="iop-block-title">片头</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${config.intro.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleIntroKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{config.intro.kind !== "none" && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">
|
||||
{config.intro.kind === "video" ? "视频" : "图片"} URL
|
||||
</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
config.intro.kind === "video"
|
||||
? "https://example.com/intro.mp4"
|
||||
: "https://example.com/intro.png"
|
||||
}
|
||||
value={config.intro.url ?? ""}
|
||||
onChange={(e) => handleIntroChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.intro.duration}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.intro.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">进入过渡动画</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={config.intro.transition ?? "none"}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({
|
||||
transition: e.target.value as TransitionType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.intro.transition && config.intro.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.intro.transition_duration ?? 0.5}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({
|
||||
transition_duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(config.intro.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 片尾区块 ═══ */}
|
||||
<div className="iop-block">
|
||||
<div className="iop-block-header">
|
||||
<span className="iop-block-icon">🏁</span>
|
||||
<span className="iop-block-title">片尾</span>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div className="iop-kind-row">
|
||||
{KIND_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`iop-kind-btn${config.outro.kind === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleOutroKindChange(opt.value)}
|
||||
>
|
||||
<span className="iop-kind-icon">{opt.icon}</span>
|
||||
<span className="iop-kind-label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 视频/图片配置 */}
|
||||
{config.outro.kind !== "none" && (
|
||||
<>
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">
|
||||
{config.outro.kind === "video" ? "视频" : "图片"} URL
|
||||
</label>
|
||||
<input
|
||||
className="iop-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
config.outro.kind === "video"
|
||||
? "https://example.com/outro.mp4"
|
||||
: "https://example.com/outro.png"
|
||||
}
|
||||
value={config.outro.url ?? ""}
|
||||
onChange={(e) => handleOutroChange({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">显示时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={1}
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.outro.duration}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.outro.duration}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">退出过渡动画</label>
|
||||
<select
|
||||
className="iop-select"
|
||||
value={config.outro.transition ?? "none"}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({
|
||||
transition: e.target.value as TransitionType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TRANSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{config.outro.transition && config.outro.transition !== "none" && (
|
||||
<div className="iop-field">
|
||||
<label className="iop-field-label">过渡时长</label>
|
||||
<div className="iop-slider-row">
|
||||
<input
|
||||
className="iop-slider"
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.outro.transition_duration ?? 0.5}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({
|
||||
transition_duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{(config.outro.transition_duration ?? 0.5).toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="iop-footer">
|
||||
<button className="iop-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroOutroPanel;
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* 画中画配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
PipConfig,
|
||||
PipLayer,
|
||||
PipGridPosition,
|
||||
PipAnimType,
|
||||
PipSlideDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 九宫格位置 → 百分比坐标映射 */
|
||||
const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
top_left: { x: 5, y: 5 },
|
||||
top_center: { x: 37.5, y: 5 },
|
||||
top_right: { x: 70, y: 5 },
|
||||
center_left: { x: 5, y: 37.5 },
|
||||
center: { x: 37.5, y: 37.5 },
|
||||
center_right: { x: 70, y: 37.5 },
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
};
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"top_left",
|
||||
"top_center",
|
||||
"top_right",
|
||||
"center_left",
|
||||
"center",
|
||||
"center_right",
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
];
|
||||
|
||||
/** 入场动画选项 */
|
||||
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
];
|
||||
|
||||
/** 滑入方向选项 */
|
||||
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "left", label: "← 左" },
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
];
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
const LAYER_COLORS = [
|
||||
"rgba(22,119,255,0.5)",
|
||||
"rgba(82,196,26,0.5)",
|
||||
"rgba(250,173,20,0.5)",
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface PipConfigPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: PipConfig;
|
||||
onChange: (config: PipConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/* ──────────── 辅助函数 ──────────── */
|
||||
|
||||
let layerIdCounter = 0;
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
/** 当前选中图层 ID */
|
||||
const [selectedId, setSelectedId] = React.useState<string>("");
|
||||
|
||||
/** 当前选中图层 */
|
||||
const selectedLayer = useMemo(
|
||||
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
||||
[config.layers, selectedId],
|
||||
);
|
||||
|
||||
/* ── 添加图层 ── */
|
||||
const handleAddLayer = useCallback(() => {
|
||||
const newLayer: PipLayer = {
|
||||
...DEFAULT_PIP_LAYER,
|
||||
id: genLayerId(),
|
||||
name: `图层 ${config.layers.length + 1}`,
|
||||
z_index: config.layers.length + 1,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
layers: [...config.layers, newLayer],
|
||||
});
|
||||
setSelectedId(newLayer.id);
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 删除图层 ── */
|
||||
const handleDeleteLayer = useCallback(
|
||||
(id: string) => {
|
||||
const newLayers = config.layers.filter((l) => l.id !== id);
|
||||
onChange({ ...config, layers: newLayers });
|
||||
if (selectedId === id) {
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "");
|
||||
}
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
|
||||
/* ── 更新图层 ── */
|
||||
const updateLayer = useCallback(
|
||||
(id: string, partial: Partial<PipLayer>) => {
|
||||
onChange({
|
||||
...config,
|
||||
layers: config.layers.map((l) =>
|
||||
l.id === id ? { ...l, ...partial } : l,
|
||||
),
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换启用 ── */
|
||||
const handleEnableToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange({ ...config, enabled: checked });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_PIP_CONFIG });
|
||||
setSelectedId("");
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 九宫格点击 ── */
|
||||
const handleGridClick = useCallback(
|
||||
(pos: PipGridPosition) => {
|
||||
if (!selectedLayer) return;
|
||||
const coords = GRID_POSITION_MAP[pos];
|
||||
updateLayer(selectedLayer.id, {
|
||||
grid_position: pos,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
});
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
/* ── 宽高比锁定 ── */
|
||||
const handleWidthChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { width: val };
|
||||
if (selectedLayer.aspect_lock) {
|
||||
// 保持宽高比 1:1(百分比相同)
|
||||
partial.height = val;
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { height: val };
|
||||
if (selectedLayer.aspect_lock) {
|
||||
partial.width = val;
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🖼️ 画中画设置"
|
||||
placement="right"
|
||||
width={520}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="pip-config-panel-drawer"
|
||||
>
|
||||
{/* ═══ 顶部工具栏 ═══ */}
|
||||
<div className="pip-toolbar">
|
||||
<div className="pip-toolbar-left">
|
||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
||||
+ 添加图层
|
||||
</button>
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={handleEnableToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||
<div className="pip-body">
|
||||
{/* 左侧图层列表 */}
|
||||
<div className="pip-layer-list">
|
||||
{config.layers.length === 0 ? (
|
||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
||||
) : (
|
||||
config.layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(layer.id)}
|
||||
>
|
||||
{layer.thumbnail_url || layer.material_url ? (
|
||||
<img
|
||||
className="pip-layer-thumb"
|
||||
src={layer.thumbnail_url || layer.material_url}
|
||||
alt={layer.name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="pip-layer-thumb"
|
||||
style={{
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="pip-layer-name">{layer.name}</span>
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLayer(layer.id);
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧配置区 */}
|
||||
<div className="pip-config-area">
|
||||
{!selectedLayer ? (
|
||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── 迷你预览 ── */}
|
||||
<div className="pip-preview-box">
|
||||
{config.layers.map((layer, idx) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
||||
style={{
|
||||
left: `${layer.x}%`,
|
||||
top: `${layer.y}%`,
|
||||
width: `${layer.width}%`,
|
||||
height: `${layer.height}%`,
|
||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
||||
opacity: layer.opacity / 100,
|
||||
borderRadius: `${layer.border_radius}%`,
|
||||
}}
|
||||
>
|
||||
<span className="pip-preview-label">{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 素材类型 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">素材类型</label>
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "image" })
|
||||
}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "video" })
|
||||
}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{selectedLayer.material_type === "image" ? "图片" : "视频"}{" "}
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
selectedLayer.material_type === "image"
|
||||
? "https://example.com/image.png"
|
||||
: "https://example.com/video.mp4"
|
||||
}
|
||||
value={selectedLayer.material_url}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
material_url: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div
|
||||
style={{ display: "flex", gap: 16, alignItems: "flex-start" }}
|
||||
>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
className={`pip-grid-btn${selectedLayer.grid_position === pos ? " active" : ""}`}
|
||||
onClick={() => handleGridClick(pos)}
|
||||
>
|
||||
<span className="pip-grid-dot" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
||||
<div>
|
||||
<label className="pip-field-label">X (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.x}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
x: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">Y (%)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.y}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
y: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 尺寸 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
宽
|
||||
</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selectedLayer.width}
|
||||
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.width}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
高
|
||||
</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={80}
|
||||
value={selectedLayer.height}
|
||||
onChange={(e) => handleHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.height}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
aspect_lock: !selectedLayer.aspect_lock,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="pip-lock-icon">
|
||||
{selectedLayer.aspect_lock ? "🔒" : "🔓"}
|
||||
</span>
|
||||
<span>
|
||||
{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 圆角 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">圆角</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={selectedLayer.border_radius}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
border_radius: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.border_radius}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 透明度 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">透明度</label>
|
||||
<div className="pip-slider-row">
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedLayer.opacity}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
opacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.opacity}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 时间 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">时间</label>
|
||||
<div className="pip-field-row">
|
||||
<div>
|
||||
<label className="pip-field-label">开始 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={selectedLayer.start_time}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
start_time: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="pip-field-label">持续 (s)</label>
|
||||
<input
|
||||
className="pip-number"
|
||||
type="number"
|
||||
min={0.1}
|
||||
max={totalDuration || 999}
|
||||
step={0.1}
|
||||
value={selectedLayer.duration}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 入场动画 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">入场动画</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={selectedLayer.animation}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
animation: e.target.value as PipAnimType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{ANIM_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
||||
{selectedLayer.animation === "slide_in" && (
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">滑入方向</label>
|
||||
<select
|
||||
className="pip-select"
|
||||
value={selectedLayer.slide_direction}
|
||||
onChange={(e) =>
|
||||
updateLayer(selectedLayer.id, {
|
||||
slide_direction: e.target.value as PipSlideDirection,
|
||||
})
|
||||
}
|
||||
>
|
||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="pip-footer">
|
||||
<button className="pip-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PipConfigPanel;
|
||||
@@ -4,25 +4,13 @@
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 片段调速面板 — Drawer 形式
|
||||
* 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关
|
||||
* 支持应用到当前片段 / 所有片段
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Slider } from "antd";
|
||||
import type { SpeedConfig } from "../types";
|
||||
import { DEFAULT_SPEED } from "../types";
|
||||
|
||||
/* ──────────── 预设速度 ──────────── */
|
||||
const SPEED_PRESETS: { rate: number; label: string }[] = [
|
||||
{ rate: 0.5, label: "0.5x" },
|
||||
{ rate: 1.0, label: "1x" },
|
||||
{ rate: 1.5, label: "1.5x" },
|
||||
{ rate: 2.0, label: "2x" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface SpeedPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前片段调速配置 */
|
||||
config: SpeedConfig;
|
||||
onChange: (config: SpeedConfig) => void;
|
||||
/** 应用到所有片段 */
|
||||
onApplyAll?: (config: SpeedConfig) => void;
|
||||
}
|
||||
|
||||
const SpeedPanel: React.FC<SpeedPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
onApplyAll,
|
||||
}) => {
|
||||
/* ── 修改速度 ── */
|
||||
const handleChangeRate = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 切换音调修正 ── */
|
||||
const handleTogglePitch = useCallback(() => {
|
||||
onChange({ ...config, pitchCorrection: !config.pitchCorrection });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 选择预设 ── */
|
||||
const handlePreset = useCallback(
|
||||
(rate: number) => {
|
||||
onChange({ ...config, rate });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 应用到所有片段 ── */
|
||||
const handleApplyAll = useCallback(() => {
|
||||
onApplyAll?.(config);
|
||||
}, [config, onApplyAll]);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_SPEED });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 速度描述文字 ── */
|
||||
const speedLabel =
|
||||
config.rate < 1
|
||||
? "慢速(慢动作)"
|
||||
: config.rate === 1
|
||||
? "原速"
|
||||
: config.rate < 2
|
||||
? "快速"
|
||||
: "极速";
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="⚡ 片段调速"
|
||||
placement="right"
|
||||
width={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="speed-panel-drawer"
|
||||
>
|
||||
{/* ── 速度滑块 ── */}
|
||||
<div className="sp-speed-section">
|
||||
<div className="sp-speed-header">
|
||||
<span className="sp-speed-label">播放速度</span>
|
||||
<span className="sp-speed-value">{config.rate.toFixed(2)}x</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.05}
|
||||
value={config.rate}
|
||||
onChange={handleChangeRate}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="sp-speed-marks">
|
||||
<span>0.25x</span>
|
||||
<span>1x</span>
|
||||
<span>2x</span>
|
||||
<span>4x</span>
|
||||
</div>
|
||||
<div className="sp-speed-desc">{speedLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* ── 预设快捷按钮 ── */}
|
||||
<div className="sp-presets">
|
||||
<div className="sp-presets-label">快捷预设</div>
|
||||
<div className="sp-presets-row">
|
||||
{SPEED_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.rate}
|
||||
className={`sp-preset-btn${Math.abs(config.rate - p.rate) < 0.01 ? " active" : ""}`}
|
||||
onClick={() => handlePreset(p.rate)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 音调修正开关 ── */}
|
||||
<div className="sp-pitch-section">
|
||||
<div className="sp-pitch-info">
|
||||
<span className="sp-pitch-label">音调修正</span>
|
||||
<span className="sp-pitch-desc">
|
||||
{config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.pitchCorrection ? " active" : ""}`}
|
||||
onClick={handleTogglePitch}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="sp-footer">
|
||||
<button className="sp-reset-btn" onClick={handleReset}>
|
||||
重置原速
|
||||
</button>
|
||||
{onApplyAll && (
|
||||
<button className="sp-apply-all-btn" onClick={handleApplyAll}>
|
||||
应用到所有片段
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpeedPanel;
|
||||
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 贴纸配置面板
|
||||
* 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
StickerConfig,
|
||||
StickerItem,
|
||||
StickerType,
|
||||
TextStickerPreset,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_STICKER_CONFIG,
|
||||
DEFAULT_STICKER_ITEM,
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
} from "../types";
|
||||
|
||||
interface StickerPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: StickerConfig;
|
||||
onChange: (config: StickerConfig) => void;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
/** 常用 emoji 素材 */
|
||||
const EMOJI_LIST = [
|
||||
"😀",
|
||||
"😂",
|
||||
"🥰",
|
||||
"😎",
|
||||
"🤩",
|
||||
"😱",
|
||||
"🤔",
|
||||
"😴",
|
||||
"🥳",
|
||||
"😍",
|
||||
"❤️",
|
||||
"🔥",
|
||||
"⭐",
|
||||
"✨",
|
||||
"💯",
|
||||
"👍",
|
||||
"👏",
|
||||
"🎉",
|
||||
"🎵",
|
||||
"💪",
|
||||
"📌",
|
||||
"💡",
|
||||
"🎯",
|
||||
"✅",
|
||||
"❌",
|
||||
"⬆️",
|
||||
"⬇️",
|
||||
"➡️",
|
||||
"⭕",
|
||||
"🔔",
|
||||
];
|
||||
|
||||
/** 文字花字预设对应的 CSS 样式预览 */
|
||||
const TEXT_PRESET_STYLES: Record<TextStickerPreset, React.CSSProperties> = {
|
||||
normal: { color: "#fff", textShadow: "none" },
|
||||
highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" },
|
||||
bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 },
|
||||
neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" },
|
||||
shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" },
|
||||
outline: { color: "#fff", WebkitTextStroke: "1px #000" },
|
||||
gradient: {
|
||||
color: "transparent",
|
||||
background: "linear-gradient(90deg,#f093fb,#f5576c)",
|
||||
WebkitBackgroundClip: "text",
|
||||
},
|
||||
handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" },
|
||||
};
|
||||
|
||||
/** 生成唯一 ID */
|
||||
const genId = () =>
|
||||
`sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
const StickerPanel: React.FC<StickerPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<StickerType>("emoji");
|
||||
|
||||
const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null;
|
||||
|
||||
/** 更新单个贴纸 */
|
||||
const updateItem = useCallback(
|
||||
(id: string, partial: Partial<StickerItem>) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.map((s) =>
|
||||
s.id === id ? { ...s, ...partial } : s,
|
||||
),
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/** 添加贴纸 */
|
||||
const addSticker = useCallback(
|
||||
(type: StickerType, content: string) => {
|
||||
const newItem: StickerItem = {
|
||||
...DEFAULT_STICKER_ITEM,
|
||||
id: genId(),
|
||||
type,
|
||||
content,
|
||||
duration: totalDuration > 0 ? totalDuration : 5,
|
||||
z_index: config.items.length + 1,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
enabled: true,
|
||||
items: [...config.items, newItem],
|
||||
});
|
||||
setSelectedId(newItem.id);
|
||||
},
|
||||
[config, onChange, totalDuration],
|
||||
);
|
||||
|
||||
/** 删除贴纸 */
|
||||
const removeSticker = useCallback(
|
||||
(id: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
items: config.items.filter((s) => s.id !== id),
|
||||
});
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
|
||||
/** 重置所有 */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled });
|
||||
setSelectedId(null);
|
||||
}, [config.enabled, onChange]);
|
||||
|
||||
/** 文字花字输入 */
|
||||
const [textInput, setTextInput] = useState("");
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="贴纸"
|
||||
placement="right"
|
||||
width={460}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="sticker-panel-drawer"
|
||||
>
|
||||
{/* 顶部开关 */}
|
||||
<div className="sticker-header">
|
||||
<span className="sticker-header-label">启用贴纸</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={(checked) => onChange({ ...config, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 Tab */}
|
||||
<div className="sticker-tabs">
|
||||
{(["emoji", "image", "text"] as StickerType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`sticker-tab${activeTab === t ? " active" : ""}`}
|
||||
onClick={() => setActiveTab(t)}
|
||||
>
|
||||
{t === "emoji"
|
||||
? "表情贴纸"
|
||||
: t === "image"
|
||||
? "图片贴纸"
|
||||
: "文字花字"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab 内容区 */}
|
||||
<div className="sticker-tab-content">
|
||||
{/* Emoji 素材库 */}
|
||||
{activeTab === "emoji" && (
|
||||
<div className="sticker-emoji-grid">
|
||||
{EMOJI_LIST.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
className="sticker-emoji-btn"
|
||||
onClick={() => addSticker("emoji", emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片贴纸 */}
|
||||
{activeTab === "image" && (
|
||||
<div className="sticker-image-input">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-url-input"
|
||||
placeholder="输入图片 URL 添加贴纸..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && e.currentTarget.value.trim()) {
|
||||
addSticker("image", e.currentTarget.value.trim());
|
||||
e.currentTarget.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="sticker-url-add-btn"
|
||||
onClick={() => {
|
||||
const input =
|
||||
document.querySelector<HTMLInputElement>(
|
||||
".sticker-url-input",
|
||||
);
|
||||
if (input?.value.trim()) {
|
||||
addSticker("image", input.value.trim());
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文字花字 */}
|
||||
{activeTab === "text" && (
|
||||
<div className="sticker-text-section">
|
||||
<div className="sticker-text-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="sticker-text-input"
|
||||
placeholder="输入文字内容..."
|
||||
value={textInput}
|
||||
onChange={(e) => setTextInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="sticker-text-add-btn"
|
||||
disabled={!textInput.trim()}
|
||||
onClick={() => {
|
||||
if (textInput.trim()) {
|
||||
addSticker("text", textInput.trim());
|
||||
setTextInput("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="sticker-text-presets">
|
||||
<div className="sticker-preset-title">花字预设预览</div>
|
||||
<div className="sticker-preset-grid">
|
||||
{(
|
||||
Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]
|
||||
).map((p) => (
|
||||
<div
|
||||
key={p}
|
||||
className="sticker-preset-preview"
|
||||
style={{
|
||||
...TEXT_PRESET_STYLES[p],
|
||||
background:
|
||||
p === "bubble"
|
||||
? "rgba(0,0,0,0.5)"
|
||||
: p === "gradient"
|
||||
? "linear-gradient(90deg,#f093fb,#f5576c)"
|
||||
: "#1a1a2e",
|
||||
}}
|
||||
>
|
||||
<span style={TEXT_PRESET_STYLES[p]}>示例</span>
|
||||
<div className="sticker-preset-name">
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 已添加贴纸列表 */}
|
||||
{config.items.length > 0 && (
|
||||
<div className="sticker-list-section">
|
||||
<div className="sticker-section-title">
|
||||
已添加贴纸 ({config.items.length})
|
||||
</div>
|
||||
<div className="sticker-list">
|
||||
{config.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sticker-list-item${selectedId === item.id ? " active" : ""}`}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
>
|
||||
<span className="sticker-list-icon">
|
||||
{item.type === "emoji"
|
||||
? item.content
|
||||
: item.type === "text"
|
||||
? "T"
|
||||
: "🖼"}
|
||||
</span>
|
||||
<span className="sticker-list-name">
|
||||
{item.type === "text"
|
||||
? item.content.slice(0, 10)
|
||||
: item.type === "emoji"
|
||||
? "表情贴纸"
|
||||
: "图片贴纸"}
|
||||
</span>
|
||||
<button
|
||||
className="sticker-list-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSticker(item.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中贴纸的属性编辑 */}
|
||||
{selectedSticker && (
|
||||
<div className="sticker-props-section">
|
||||
<div className="sticker-section-title">属性调整</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 X</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.x}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, { x: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.x}%</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">位置 Y</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.y}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, { y: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.y}%</span>
|
||||
</div>
|
||||
|
||||
{/* 尺寸 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">大小</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={5}
|
||||
max={50}
|
||||
value={selectedSticker.width}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
width: Number(e.target.value),
|
||||
height: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">{selectedSticker.width}%</span>
|
||||
</div>
|
||||
|
||||
{/* 旋转 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">旋转</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={-180}
|
||||
max={180}
|
||||
value={selectedSticker.rotation}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
rotation: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.rotation}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">透明度</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={selectedSticker.opacity}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
opacity: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.opacity}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 时间 */}
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">开始</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={selectedSticker.start_time}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
start_time: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-label">时长</span>
|
||||
<input
|
||||
type="number"
|
||||
className="sticker-prop-number"
|
||||
min={0}
|
||||
max={totalDuration}
|
||||
step={0.1}
|
||||
value={selectedSticker.duration}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
duration: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字贴纸特有属性 */}
|
||||
{selectedSticker.type === "text" && (
|
||||
<>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">花字</span>
|
||||
<select
|
||||
className="sticker-prop-select"
|
||||
value={selectedSticker.text_preset}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
text_preset: e.target.value as TextStickerPreset,
|
||||
})
|
||||
}
|
||||
>
|
||||
{(
|
||||
Object.keys(
|
||||
TEXT_STICKER_PRESET_LABELS,
|
||||
) as TextStickerPreset[]
|
||||
).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{TEXT_STICKER_PRESET_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">字号</span>
|
||||
<input
|
||||
type="range"
|
||||
className="sticker-prop-slider"
|
||||
min={12}
|
||||
max={72}
|
||||
value={selectedSticker.font_size}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
font_size: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="sticker-prop-value">
|
||||
{selectedSticker.font_size}px
|
||||
</span>
|
||||
</div>
|
||||
<div className="sticker-prop-row">
|
||||
<span className="sticker-prop-label">颜色</span>
|
||||
<input
|
||||
type="color"
|
||||
className="sticker-prop-color"
|
||||
value={selectedSticker.text_color}
|
||||
onChange={(e) =>
|
||||
updateItem(selectedSticker.id, {
|
||||
text_color: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="sticker-preview-box">
|
||||
<div
|
||||
className="sticker-preview-item"
|
||||
style={{
|
||||
left: `${selectedSticker.x}%`,
|
||||
top: `${selectedSticker.y}%`,
|
||||
width: `${selectedSticker.width}%`,
|
||||
height: `${selectedSticker.width}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${selectedSticker.rotation}deg)`,
|
||||
opacity: selectedSticker.opacity / 100,
|
||||
fontSize:
|
||||
selectedSticker.type === "text"
|
||||
? `${selectedSticker.font_size}px`
|
||||
: undefined,
|
||||
...TEXT_PRESET_STYLES[selectedSticker.text_preset],
|
||||
}}
|
||||
>
|
||||
{selectedSticker.type === "emoji" && selectedSticker.content}
|
||||
{selectedSticker.type === "text" && selectedSticker.content}
|
||||
{selectedSticker.type === "image" && (
|
||||
<img
|
||||
src={selectedSticker.content}
|
||||
alt="sticker"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部重置 */}
|
||||
<div className="sticker-footer">
|
||||
<button className="sticker-reset-btn" onClick={handleReset}>
|
||||
清空所有贴纸
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickerPanel;
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 字幕样式配置面板 — Drawer 形式
|
||||
* 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言
|
||||
*/
|
||||
import React from "react";
|
||||
import { Drawer, Slider, ColorPicker, Select } from "antd";
|
||||
import type { Color } from "antd/es/color-picker";
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type SubtitleMode = "manual" | "asr";
|
||||
|
||||
export interface SubtitleStyleConfig {
|
||||
/** 是否启用字幕 */
|
||||
enabled: boolean;
|
||||
/** 字幕模式:手动输入 / ASR 自动识别 */
|
||||
mode: SubtitleMode;
|
||||
/** 字体大小 px */
|
||||
fontSize: number;
|
||||
/** 字体颜色 */
|
||||
fontColor: string;
|
||||
/** 描边 */
|
||||
stroke: boolean;
|
||||
/** 阴影 */
|
||||
shadow: boolean;
|
||||
/** 字幕位置 */
|
||||
position: "top" | "center" | "bottom";
|
||||
/** 字体 */
|
||||
font: string;
|
||||
/** 动画效果 */
|
||||
animation: string;
|
||||
/** ASR 语言(仅 ASR 模式) */
|
||||
asrLanguage: "zh" | "en";
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = {
|
||||
enabled: true,
|
||||
mode: "asr",
|
||||
fontSize: 16,
|
||||
fontColor: "#ffffff",
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
position: "bottom",
|
||||
font: "思源黑体",
|
||||
animation: "none",
|
||||
asrLanguage: "zh",
|
||||
};
|
||||
|
||||
/* ──────────── 选项常量 ──────────── */
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
];
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
|
||||
const ASR_LANGUAGE_OPTIONS = [
|
||||
{ value: "zh", label: "中文" },
|
||||
{ value: "en", label: "English" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface SubtitleStylePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: SubtitleStyleConfig;
|
||||
onChange: (config: SubtitleStyleConfig) => void;
|
||||
}
|
||||
|
||||
const SubtitleStylePanel: React.FC<SubtitleStylePanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const update = (partial: Partial<SubtitleStyleConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="💬 字幕样式配置"
|
||||
placement="right"
|
||||
width={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="subtitle-style-drawer"
|
||||
>
|
||||
{/* ── 字幕开关 ── */}
|
||||
<div className="sub-field">
|
||||
<div className="sub-toggle-row">
|
||||
<span className="sub-label">启用字幕</span>
|
||||
<div
|
||||
className={`ep-toggle${config.enabled ? " active" : ""}`}
|
||||
onClick={() => update({ enabled: !config.enabled })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* ── 模式切换 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字幕来源</label>
|
||||
<div className="sub-mode-switch">
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "manual" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "manual" })}
|
||||
>
|
||||
✏️ 手动输入
|
||||
</button>
|
||||
<button
|
||||
className={`sub-mode-btn${config.mode === "asr" ? " active" : ""}`}
|
||||
onClick={() => update({ mode: "asr" })}
|
||||
>
|
||||
🤖 ASR 识别
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── ASR 语言(仅 ASR 模式) ── */}
|
||||
{config.mode === "asr" && (
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">识别语言</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.asrLanguage}
|
||||
onChange={(v) => update({ asrLanguage: v })}
|
||||
options={ASR_LANGUAGE_OPTIONS}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 字体大小 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">
|
||||
字体大小 <span className="sub-value">{config.fontSize}px</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={12}
|
||||
max={48}
|
||||
value={config.fontSize}
|
||||
onChange={(v) => update({ fontSize: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字体颜色 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体颜色</label>
|
||||
<div className="sub-color-row">
|
||||
<ColorPicker
|
||||
value={config.fontColor}
|
||||
onChange={(_color: Color, hex: string) =>
|
||||
update({ fontColor: hex })
|
||||
}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 字体 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">字体</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.font}
|
||||
onChange={(v) => update({ font: v })}
|
||||
options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 字幕位置 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">位置</label>
|
||||
<div className="sub-position-group">
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`sub-position-btn${config.position === opt.value ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
update({
|
||||
position: opt.value as SubtitleStyleConfig["position"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 描边 / 阴影 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">效果</label>
|
||||
<div className="sub-effect-btns">
|
||||
<button
|
||||
className={`sub-effect-btn${config.stroke ? " active" : ""}`}
|
||||
onClick={() => update({ stroke: !config.stroke })}
|
||||
>
|
||||
S 描边
|
||||
</button>
|
||||
<button
|
||||
className={`sub-effect-btn${config.shadow ? " active" : ""}`}
|
||||
onClick={() => update({ shadow: !config.shadow })}
|
||||
>
|
||||
☁ 阴影
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 动画 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">动画效果</label>
|
||||
<Select
|
||||
className="sub-select"
|
||||
value={config.animation}
|
||||
onChange={(v) => update({ animation: v })}
|
||||
options={ANIMATION_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 预览 ── */}
|
||||
<div className="sub-field">
|
||||
<label className="sub-label">预览</label>
|
||||
<div className="sub-preview-box">
|
||||
<span
|
||||
className="sub-preview-text"
|
||||
style={{
|
||||
fontSize: `${Math.min(config.fontSize, 28)}px`,
|
||||
color: config.fontColor,
|
||||
fontFamily: config.font,
|
||||
WebkitTextStroke: config.stroke ? "1px #000" : undefined,
|
||||
textShadow: config.shadow
|
||||
? "2px 2px 4px rgba(0,0,0,0.8)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
这是一段字幕预览
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleStylePanel;
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* 水平轨道时间线 — 片段 = 时间规划 + 类型标记,不绑定素材
|
||||
* 水平轨道时间线 — 支持裁剪手柄、分割、右键菜单
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* "+" 卡片 → 类型+时长选择器
|
||||
*
|
||||
* 裁剪交互:
|
||||
* - 鼠标悬停片段两端显示拖拽手柄,拖动调整入点/出点
|
||||
* - 拖动时实时显示裁剪预览(入点/出点/时长)
|
||||
* - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除
|
||||
*/
|
||||
import React, {
|
||||
useState,
|
||||
@@ -11,7 +16,8 @@ import React, {
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
import type { ClipData, ClipType, TrimConfig } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
|
||||
interface TimelinePanelProps {
|
||||
clips: ClipData[];
|
||||
@@ -21,6 +27,26 @@ interface TimelinePanelProps {
|
||||
onClipReorder: (fromIdx: number, toIdx: number) => void;
|
||||
onClipRemove: (clipId: string) => void;
|
||||
onAddClip: (type: ClipType, duration: number) => void;
|
||||
/** 裁剪更新:调整片段的 trim_config 和 duration */
|
||||
onClipTrim?: (
|
||||
clipId: string,
|
||||
trimConfig: TrimConfig,
|
||||
newDuration: number,
|
||||
) => void;
|
||||
/** 在指定位置分割片段 */
|
||||
onClipSplit?: (clipId: string, splitRatio: number) => void;
|
||||
/** 恢复片段原始长度 */
|
||||
onClipResetTrim?: (clipId: string) => void;
|
||||
/** 当前播放时间(秒) */
|
||||
currentTime?: number;
|
||||
/** 缩放:每秒像素数 */
|
||||
pixelsPerSecond?: number;
|
||||
/** 缩放变更回调 */
|
||||
onZoomChange?: (pps: number) => void;
|
||||
/** 播放头跳转回调 */
|
||||
onSeek?: (time: number) => void;
|
||||
/** 总时长(秒),可选(默认由 clips 计算) */
|
||||
totalDuration?: number;
|
||||
}
|
||||
|
||||
/** 片段类型图标 */
|
||||
@@ -35,6 +61,25 @@ const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
pip: "画中画",
|
||||
};
|
||||
|
||||
/** 裁剪拖拽方向 */
|
||||
type TrimDirection = "left" | "right";
|
||||
|
||||
/** 裁剪拖拽状态 */
|
||||
interface TrimDragState {
|
||||
clipId: string;
|
||||
direction: TrimDirection;
|
||||
startX: number;
|
||||
originalTrim: TrimConfig;
|
||||
originalDuration: number;
|
||||
}
|
||||
|
||||
/** 右键菜单状态 */
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
clipId: string;
|
||||
}
|
||||
|
||||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
clips,
|
||||
selectedClipId,
|
||||
@@ -43,6 +88,14 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onClipReorder,
|
||||
onClipRemove,
|
||||
onAddClip,
|
||||
onClipTrim,
|
||||
onClipSplit,
|
||||
onClipResetTrim,
|
||||
currentTime = 0,
|
||||
pixelsPerSecond = 40,
|
||||
onZoomChange,
|
||||
onSeek,
|
||||
totalDuration: totalDurationProp,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
@@ -55,6 +108,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
right: 0,
|
||||
});
|
||||
|
||||
/* ── 裁剪拖拽状态 ── */
|
||||
const [trimDrag, setTrimDrag] = useState<TrimDragState | null>(null);
|
||||
const [trimPreview, setTrimPreview] = useState<{
|
||||
clipId: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
duration: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 悬停的片段 ID(显示裁剪手柄) ── */
|
||||
const [hoveredClipId, setHoveredClipId] = useState<string | null>(null);
|
||||
|
||||
/* ── 播放头拖拽状态 ── */
|
||||
const [playheadDragging, setPlayheadDragging] = useState(false);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 根据模式决定可选类型 ── */
|
||||
const availableTypes: ClipType[] = useMemo(
|
||||
() =>
|
||||
@@ -66,7 +141,7 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
[currentMode],
|
||||
);
|
||||
|
||||
/* ── 默认添加类型:跟随模式(纯单类型模式直接用该类型,混合模式默认 voice) ── */
|
||||
/* ── 默认添加类型:跟随模式 ── */
|
||||
const defaultAddType: ClipType = useMemo(() => {
|
||||
if (currentMode === "voice_over") return "voice";
|
||||
if (currentMode === "pip") return "pip";
|
||||
@@ -83,33 +158,28 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setAddType(defaultAddType);
|
||||
}
|
||||
}, [currentMode, addType, availableTypes, defaultAddType]);
|
||||
/* ── 面板尺寸(宽度固定,高度由 useLayoutEffect 实测) ── */
|
||||
const PICKER_W = 240; // 面板宽度(与 CSS 一致)
|
||||
const GAP = 6; // 面板与"+"卡片的间距
|
||||
|
||||
/* ── 计算 picker 初始位置(默认从"+"按钮上方弹出) ── */
|
||||
/* ── 面板尺寸 ── */
|
||||
const PICKER_W = 240;
|
||||
const GAP = 6;
|
||||
|
||||
/* ── 计算 picker 初始位置 ── */
|
||||
const updatePickerPosition = useCallback(() => {
|
||||
if (!addCardRef.current) return;
|
||||
const rect = addCardRef.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 垂直方向:默认向上弹出(上方空间永远比下方大) */
|
||||
const roughHeight = 180; // 粗略估算,useLayoutEffect 会用实际高度校正
|
||||
const roughHeight = 180;
|
||||
let top = rect.top - GAP - roughHeight;
|
||||
if (top < 8) top = 8;
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;太靠右超出视口则左移 */
|
||||
let right = vw - rect.right;
|
||||
if (rect.right - PICKER_W < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, []);
|
||||
|
||||
const handleTogglePicker = () => {
|
||||
if (!showAddPicker) {
|
||||
// 打开面板时,默认选中当前模式下的第一个可用类型
|
||||
const defaultType =
|
||||
currentMode === "pip"
|
||||
? "pip"
|
||||
@@ -122,35 +192,27 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
setShowAddPicker((v) => !v);
|
||||
};
|
||||
|
||||
/* ── 渲染后用实际 offsetHeight 做精确边界校正(useLayoutEffect 确保 paint 前完成) ── */
|
||||
/* ── 渲染后精确边界校正 ── */
|
||||
useLayoutEffect(() => {
|
||||
if (!showAddPicker || !pickerRef.current || !addCardRef.current) return;
|
||||
const pickerEl = pickerRef.current;
|
||||
const addRect = addCardRef.current.getBoundingClientRect();
|
||||
const pickerH = pickerEl.offsetHeight; // 实际高度,不用硬编码
|
||||
const pickerH = pickerEl.offsetHeight;
|
||||
const vh = window.innerHeight;
|
||||
const vw = window.innerWidth;
|
||||
|
||||
/* 默认:面板在"+"按钮上方 */
|
||||
let top = addRect.top - GAP - pickerH;
|
||||
|
||||
/* 上方空间也不够(极端情况)→ 翻转到下方 */
|
||||
if (top < 8) {
|
||||
top = addRect.bottom + GAP;
|
||||
/* 下方也溢出 → clamp */
|
||||
if (top + pickerH > vh - 8) {
|
||||
top = vh - 8 - pickerH;
|
||||
if (top < 8) top = 8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 水平方向:右对齐"+"卡片;左侧溢出保护 */
|
||||
let right = vw - addRect.right;
|
||||
const pickerRect = pickerEl.getBoundingClientRect();
|
||||
if (pickerRect.left < 8) {
|
||||
right = vw - PICKER_W - 8;
|
||||
}
|
||||
|
||||
setPickerPos({ top, right });
|
||||
}, [showAddPicker]);
|
||||
|
||||
@@ -167,6 +229,70 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddPicker]);
|
||||
|
||||
/* ── 点击外部关闭右键菜单 ── */
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
contextMenuRef.current &&
|
||||
!contextMenuRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setContextMenu(null);
|
||||
}
|
||||
};
|
||||
if (contextMenu) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [contextMenu]);
|
||||
|
||||
/* ── 缩放 & 时长 ── */
|
||||
const pps = pixelsPerSecond ?? 40;
|
||||
const totalDuration =
|
||||
totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0);
|
||||
|
||||
/* ── 播放头拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!playheadDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const trackEl = trackRef.current;
|
||||
if (!trackEl) return;
|
||||
const rect = trackEl.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + trackEl.scrollLeft;
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration));
|
||||
onSeek?.(Math.round(time * 10) / 10);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setPlayheadDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [playheadDragging, pps, totalDuration, onSeek]);
|
||||
|
||||
/* ── 标尺点击跳转播放头 ── */
|
||||
const handleRulerClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const time = Math.max(0, Math.min(x / pps, totalDuration));
|
||||
onSeek?.(Math.round(time * 10) / 10);
|
||||
},
|
||||
[pps, totalDuration, onSeek],
|
||||
);
|
||||
|
||||
/* ── 播放头拖拽开始 ── */
|
||||
const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPlayheadDragging(true);
|
||||
}, []);
|
||||
|
||||
/* ── 确认添加片段 ── */
|
||||
const handleConfirmAdd = () => {
|
||||
onAddClip(addType, addDuration);
|
||||
@@ -175,6 +301,8 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
// 如果正在裁剪拖拽,不允许排序拖拽
|
||||
if (trimDrag) return;
|
||||
dragRef.current = idx;
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.setData("application/x-clip-drag", String(idx));
|
||||
@@ -210,8 +338,132 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/* ── 裁剪手柄拖拽 ── */
|
||||
const handleTrimHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, clipId: string, direction: TrimDirection) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const clip = clips.find((c) => c.id === clipId);
|
||||
if (!clip) return;
|
||||
|
||||
const trim: TrimConfig = clip.trim_config ?? {
|
||||
start_time: 0,
|
||||
end_time: clip.duration,
|
||||
original_duration: clip.duration,
|
||||
};
|
||||
|
||||
setTrimDrag({
|
||||
clipId,
|
||||
direction,
|
||||
startX: e.clientX,
|
||||
originalTrim: { ...trim },
|
||||
originalDuration: clip.duration,
|
||||
});
|
||||
},
|
||||
[clips],
|
||||
);
|
||||
|
||||
/* ── 裁剪拖拽全局 mousemove/mouseup ── */
|
||||
useEffect(() => {
|
||||
if (!trimDrag) return;
|
||||
|
||||
const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - trimDrag.startX;
|
||||
const dtSec = dx / PX_PER_SECOND;
|
||||
const clip = clips.find((c) => c.id === trimDrag.clipId);
|
||||
if (!clip) return;
|
||||
|
||||
const origTrim = trimDrag.originalTrim;
|
||||
const origDur = origTrim.original_duration ?? trimDrag.originalDuration;
|
||||
let newStart = origTrim.start_time;
|
||||
let newEnd = origTrim.end_time;
|
||||
|
||||
if (trimDrag.direction === "left") {
|
||||
// 左手柄:调整入点
|
||||
newStart = Math.max(
|
||||
0,
|
||||
Math.min(origTrim.start_time + dtSec, newEnd - 1),
|
||||
);
|
||||
} else {
|
||||
// 右手柄:调整出点
|
||||
newEnd = Math.max(
|
||||
origTrim.start_time + 1,
|
||||
Math.min(origTrim.end_time + dtSec, origDur),
|
||||
);
|
||||
}
|
||||
|
||||
const newDuration = Math.round((newEnd - newStart) * 10) / 10;
|
||||
|
||||
setTrimPreview({
|
||||
clipId: trimDrag.clipId,
|
||||
startTime: Math.round(newStart * 10) / 10,
|
||||
endTime: Math.round(newEnd * 10) / 10,
|
||||
duration: newDuration,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) {
|
||||
const newTrim: TrimConfig = {
|
||||
start_time: trimPreview.startTime,
|
||||
end_time: trimPreview.endTime,
|
||||
original_duration:
|
||||
trimDrag.originalTrim.original_duration ??
|
||||
trimDrag.originalDuration,
|
||||
};
|
||||
onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration);
|
||||
}
|
||||
setTrimDrag(null);
|
||||
setTrimPreview(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]);
|
||||
|
||||
/* ── 右键菜单 ── */
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent, clipId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, clipId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/* ── 右键菜单操作 ── */
|
||||
const handleContextSplit = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
if (onClipSplit) {
|
||||
onClipSplit(contextMenu.clipId, 0.5); // 在中间分割
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipSplit]);
|
||||
|
||||
const handleContextResetTrim = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
if (onClipResetTrim) {
|
||||
onClipResetTrim(contextMenu.clipId);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipResetTrim]);
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
onClipRemove(contextMenu.clipId);
|
||||
setContextMenu(null);
|
||||
}, [contextMenu, onClipRemove]);
|
||||
|
||||
/* ── 时间标尺 ── */
|
||||
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
|
||||
const trackWidth = Math.max(totalDuration * pps, 300);
|
||||
const rulerMarks: number[] = [];
|
||||
const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15;
|
||||
for (let t = 0; t <= totalDuration + step; t += step) {
|
||||
@@ -224,6 +476,11 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** 格式化裁剪时间(精确到0.1秒) */
|
||||
const formatTrimTime = (sec: number) => {
|
||||
return `${sec.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ep-timeline-area">
|
||||
{/* 时间线头部 */}
|
||||
@@ -235,6 +492,33 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-timeline-actions">
|
||||
{/* 缩放控件 */}
|
||||
<div className="ep-timeline-zoom">
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.max(10, pps - 10))}
|
||||
title="缩小"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className="ep-zoom-slider"
|
||||
min={10}
|
||||
max={120}
|
||||
step={5}
|
||||
value={pps}
|
||||
onChange={(e) => onZoomChange?.(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="ep-zoom-btn"
|
||||
onClick={() => onZoomChange?.(Math.min(120, pps + 10))}
|
||||
title="放大"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ep-zoom-label">{pps}px/s</span>
|
||||
</div>
|
||||
<button
|
||||
className="ep-timeline-action-btn"
|
||||
onClick={() => {
|
||||
@@ -260,21 +544,13 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 时间标尺 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-time-ruler">
|
||||
<div
|
||||
className="ep-time-ruler-inner"
|
||||
style={{ width: Math.max(clips.length * 108, 300) }}
|
||||
>
|
||||
<div className="ep-time-ruler" onClick={handleRulerClick}>
|
||||
<div className="ep-time-ruler-inner" style={{ width: trackWidth }}>
|
||||
{rulerMarks.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="ep-time-mark"
|
||||
style={{
|
||||
left:
|
||||
totalDuration > 0
|
||||
? `${(t / totalDuration) * clips.length * 108}px`
|
||||
: `${t * 20}px`,
|
||||
}}
|
||||
style={{ left: `${t * pps}px` }}
|
||||
>
|
||||
{t}s
|
||||
</span>
|
||||
@@ -285,52 +561,151 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
|
||||
{/* 水平片段轨道 */}
|
||||
{currentMode !== "one_take" && (
|
||||
<div className="ep-clip-track" onDragOver={handleEmptyDragOver}>
|
||||
<div
|
||||
className="ep-clip-track"
|
||||
ref={trackRef}
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{/* 播放头 */}
|
||||
{currentMode !== "one_take" && totalDuration > 0 && (
|
||||
<div
|
||||
className="ep-playhead"
|
||||
style={{ left: currentTime * pps }}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
>
|
||||
<div className="ep-playhead-handle" />
|
||||
</div>
|
||||
)}
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">点击右侧 + 添加片段</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
>
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
clips.map((clip, idx) => {
|
||||
/* 转场指示器 */
|
||||
const trans = clip.transition;
|
||||
const showTransition = idx > 0 && trans && trans.type !== "none";
|
||||
const transOpt = showTransition
|
||||
? TRANSITION_OPTIONS.find((o) => o.value === trans!.type)
|
||||
: undefined;
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">{clip.duration}s</span>
|
||||
</div>
|
||||
/* 速度徽章 */
|
||||
const speed = clip.speed;
|
||||
const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01;
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
/* 裁剪状态 */
|
||||
const hasTrim = !!clip.trim_config;
|
||||
const isHovered = hoveredClipId === clip.id;
|
||||
|
||||
return (
|
||||
<React.Fragment key={clip.id}>
|
||||
{/* 转场指示器 */}
|
||||
{showTransition && transOpt && (
|
||||
<div
|
||||
className="ep-transition-indicator"
|
||||
title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`}
|
||||
>
|
||||
<span className="ep-trans-icon">{transOpt.icon}</span>
|
||||
<span className="ep-trans-duration">
|
||||
{trans!.duration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`ep-clip-card ${selectedClipId === clip.id ? "selected" : ""} ${dragIdx === idx ? "dragging" : ""} ${dragOverIdx === idx ? "drag-over" : ""} ${hasTrim ? "trimmed" : ""}`}
|
||||
style={{ width: Math.max(clip.duration * pps, 60) }}
|
||||
draggable={!trimDrag}
|
||||
onDragStart={(e) => handleDragStart(e, idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDrop={(e) => handleDrop(e, idx)}
|
||||
onClick={() => onClipSelect(clip.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, clip.id)}
|
||||
onMouseEnter={() => setHoveredClipId(clip.id)}
|
||||
onMouseLeave={() => setHoveredClipId(null)}
|
||||
>
|
||||
{/* 左裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-left"
|
||||
onMouseDown={(e) =>
|
||||
handleTrimHandleMouseDown(e, clip.id, "left")
|
||||
}
|
||||
title="拖动调整入点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型图标 */}
|
||||
<div className="ep-clip-thumbnail">
|
||||
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
|
||||
</div>
|
||||
|
||||
{/* 片段信息 */}
|
||||
<div className="ep-clip-info">
|
||||
<span className="ep-clip-name">
|
||||
{CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1}
|
||||
</span>
|
||||
<span className="ep-clip-duration">
|
||||
{clip.duration}s
|
||||
{hasTrim && (
|
||||
<span className="ep-trim-indicator" title="已裁剪">
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 速度徽章 */}
|
||||
{showSpeed && (
|
||||
<span className="ep-speed-badge">
|
||||
{speed!.rate.toFixed(1)}x
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 裁剪徽章 */}
|
||||
{hasTrim && (
|
||||
<span
|
||||
className="ep-trim-badge"
|
||||
title={`入点 ${clip.trim_config!.start_time.toFixed(1)}s / 出点 ${clip.trim_config!.end_time.toFixed(1)}s`}
|
||||
>
|
||||
✂
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 右裁剪手柄 */}
|
||||
{isHovered && onClipTrim && (
|
||||
<div
|
||||
className="ep-trim-handle ep-trim-handle-right"
|
||||
onMouseDown={(e) =>
|
||||
handleTrimHandleMouseDown(e, clip.id, "right")
|
||||
}
|
||||
title="拖动调整出点"
|
||||
>
|
||||
<div className="ep-trim-handle-line" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="ep-clip-remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClipRemove(clip.id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */}
|
||||
{/* ── 轨道末尾 "+" 添加卡片 ── */}
|
||||
<div className="ep-track-add-card-wrapper">
|
||||
<div
|
||||
ref={addCardRef}
|
||||
@@ -344,7 +719,73 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */}
|
||||
{/* 裁剪预览 tooltip */}
|
||||
{trimPreview && (
|
||||
<div
|
||||
className="ep-trim-preview"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: trimPreview.x + 12,
|
||||
top: trimPreview.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">入点</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.startTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row">
|
||||
<span className="ep-trim-preview-label">出点</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ep-trim-preview-row ep-trim-preview-duration">
|
||||
<span className="ep-trim-preview-label">时长</span>
|
||||
<span className="ep-trim-preview-value">
|
||||
{formatTrimTime(trimPreview.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="ep-context-menu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<div className="ep-context-menu-item" onClick={handleContextSplit}>
|
||||
<span className="ep-context-menu-icon">✂️</span>
|
||||
<span>分割片段</span>
|
||||
</div>
|
||||
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && (
|
||||
<div
|
||||
className="ep-context-menu-item"
|
||||
onClick={handleContextResetTrim}
|
||||
>
|
||||
<span className="ep-context-menu-icon">↩️</span>
|
||||
<span>恢复原始长度</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ep-context-menu-divider" />
|
||||
<div
|
||||
className="ep-context-menu-item ep-context-menu-item-danger"
|
||||
onClick={handleContextDelete}
|
||||
>
|
||||
<span className="ep-context-menu-icon">🗑️</span>
|
||||
<span>删除片段</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 类型+时长选择面板 */}
|
||||
{showAddPicker && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 转场特效选择器 — Drawer 形式
|
||||
* 14 种转场预设卡片网格 + 转场时长滑块
|
||||
* 支持全局默认转场 + 单个片段间独立设置
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Slider } from "antd";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { TransitionConfig, TransitionType } from "../types";
|
||||
import { DEFAULT_TRANSITION } from "../types";
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TransitionSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前转场配置 */
|
||||
config: TransitionConfig;
|
||||
onChange: (config: TransitionConfig) => void;
|
||||
/** 标题提示(区分全局 / 片段间) */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const TransitionSelector: React.FC<TransitionSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
title = "转场特效",
|
||||
}) => {
|
||||
/* ── 选择转场类型 ── */
|
||||
const handleSelectType = useCallback(
|
||||
(type: TransitionType) => {
|
||||
onChange({ ...config, type });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 修改时长 ── */
|
||||
const handleChangeDuration = useCallback(
|
||||
(duration: number) => {
|
||||
onChange({ ...config, duration });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置为无转场 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TRANSITION });
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`🎬 ${title}`}
|
||||
placement="right"
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="transition-selector-drawer"
|
||||
>
|
||||
{/* ── 时长滑块 ── */}
|
||||
<div className="ts-duration-section">
|
||||
<div className="ts-duration-header">
|
||||
<span className="ts-duration-label">转场时长</span>
|
||||
<span className="ts-duration-value">
|
||||
{config.duration.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.3}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={config.duration}
|
||||
onChange={handleChangeDuration}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(1)}s` }}
|
||||
/>
|
||||
<div className="ts-duration-marks">
|
||||
<span>0.3s</span>
|
||||
<span>1.0s</span>
|
||||
<span>2.0s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 转场类型卡片网格 ── */}
|
||||
<div className="ts-grid">
|
||||
{TRANSITION_OPTIONS.map((opt) => {
|
||||
const isActive = config.type === opt.value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`ts-card${isActive ? " active" : ""}`}
|
||||
onClick={() => handleSelectType(opt.value)}
|
||||
>
|
||||
<div className="ts-card-icon">{opt.icon}</div>
|
||||
<div className="ts-card-name">{opt.label}</div>
|
||||
{isActive && <span className="ts-card-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="ts-footer">
|
||||
<button className="ts-reset-btn" onClick={handleReset}>
|
||||
重置为无转场
|
||||
</button>
|
||||
<div className="ts-current">
|
||||
当前:
|
||||
{TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ??
|
||||
"无转场"}
|
||||
{" · "}
|
||||
{config.duration.toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransitionSelector;
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* TTS 配音面板 — Drawer 形式
|
||||
* 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Drawer, Slider, message } from "antd";
|
||||
import type { TtsConfig, TtsMode } from "../types";
|
||||
import { DEFAULT_TTS_CONFIG } from "../types";
|
||||
import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts";
|
||||
|
||||
/* ──────────── 音色卡片分类图标 ──────────── */
|
||||
const VOICE_CATEGORY_MAP: Record<string, { icon: string; label: string }> = {
|
||||
male: { icon: "👨", label: "男声" },
|
||||
female: { icon: "👩", label: "女声" },
|
||||
young: { icon: "🧑", label: "少年" },
|
||||
service: { icon: "🎧", label: "客服" },
|
||||
news: { icon: "📰", label: "新闻" },
|
||||
emotion: { icon: "🎭", label: "情感" },
|
||||
};
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface TtsPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 当前片段 TTS 配置 */
|
||||
config: TtsConfig;
|
||||
onChange: (config: TtsConfig) => void;
|
||||
}
|
||||
|
||||
const TtsPanel: React.FC<TtsPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 音色列表 ── */
|
||||
const [voices, setVoices] = useState<TTSVoice[]>([]);
|
||||
const [voicesLoading, setVoicesLoading] = useState(false);
|
||||
|
||||
/* ── 试听状态 ── */
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
/* ── 加载音色列表 ── */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setVoicesLoading(true);
|
||||
getTtsVoices()
|
||||
.then((v) => setVoices(v))
|
||||
.catch(() => message.error("加载音色列表失败"))
|
||||
.finally(() => setVoicesLoading(false));
|
||||
}, [open]);
|
||||
|
||||
/* ── 切换配音模式 ── */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: TtsMode) => {
|
||||
onChange({ ...config, mode });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 文本输入 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = e.target.value.slice(0, 5000);
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 选择音色 ── */
|
||||
const handleVoiceSelect = useCallback(
|
||||
(voiceId: string) => {
|
||||
onChange({ ...config, voice_id: voiceId });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 语速 ── */
|
||||
const handleSpeedChange = useCallback(
|
||||
(speed: number) => {
|
||||
onChange({ ...config, speed });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 语调 ── */
|
||||
const handlePitchChange = useCallback(
|
||||
(pitch: number) => {
|
||||
onChange({ ...config, pitch });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 音量 ── */
|
||||
const handleVolumeChange = useCallback(
|
||||
(volume: number) => {
|
||||
onChange({ ...config, volume });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 字幕联动 ── */
|
||||
const handleSubtitleSyncToggle = useCallback(() => {
|
||||
onChange({ ...config, subtitle_sync: !config.subtitle_sync });
|
||||
}, [config, onChange]);
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!config.text.trim()) {
|
||||
message.warning("请先输入合成文本");
|
||||
return;
|
||||
}
|
||||
if (!config.voice_id) {
|
||||
message.warning("请先选择音色");
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const res = await previewTts({
|
||||
text: config.text.slice(0, 200), // 试听截取前200字
|
||||
voice_id: config.voice_id,
|
||||
speed: config.speed,
|
||||
pitch: config.pitch,
|
||||
});
|
||||
// 停止上一个
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(res.audio_url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => message.error("播放失败"));
|
||||
audio.onended = () => {
|
||||
audioRef.current = null;
|
||||
};
|
||||
message.success("试听播放中");
|
||||
} catch {
|
||||
message.error("试听生成失败");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_TTS_CONFIG });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 关闭时停止音频 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
audioRef.current = null;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
/* ── 音色分类分组 ── */
|
||||
const voiceCategories = Object.entries(VOICE_CATEGORY_MAP);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🎙️ TTS 配音"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
className="tts-panel-drawer"
|
||||
>
|
||||
{/* ── 配音模式切换 ── */}
|
||||
<div className="tts-mode-section">
|
||||
<div className="tts-mode-label">配音模式</div>
|
||||
<div className="tts-mode-group">
|
||||
{[
|
||||
{ mode: "none" as TtsMode, icon: "🔇", label: "无配音" },
|
||||
{ mode: "upload" as TtsMode, icon: "📁", label: "上传配音" },
|
||||
{ mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" },
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
className={`tts-mode-btn${config.mode === m.mode ? " active" : ""}`}
|
||||
onClick={() => handleModeChange(m.mode)}
|
||||
>
|
||||
<span className="tts-mode-icon">{m.icon}</span>
|
||||
<span className="tts-mode-text">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── TTS 配置(仅 tts 模式显示) ── */}
|
||||
{config.mode === "tts" && (
|
||||
<>
|
||||
{/* 文本输入 */}
|
||||
<div className="tts-text-section">
|
||||
<div className="tts-text-header">
|
||||
<span className="tts-text-label">合成文本</span>
|
||||
<span className="tts-text-count">{config.text.length}/5000</span>
|
||||
</div>
|
||||
<textarea
|
||||
className="tts-text-input"
|
||||
placeholder="请输入需要合成的文本内容..."
|
||||
value={config.text}
|
||||
onChange={handleTextChange}
|
||||
maxLength={5000}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 音色选择 */}
|
||||
<div className="tts-voice-section">
|
||||
<div className="tts-voice-label">
|
||||
选择音色
|
||||
{voicesLoading && (
|
||||
<span className="tts-voice-loading">加载中...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tts-voice-grid">
|
||||
{voiceCategories.map(([cat, info]) => {
|
||||
const voice = voices.find((v) => v.category === cat);
|
||||
const isSelected = voice && config.voice_id === voice.id;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
className={`tts-voice-card${isSelected ? " active" : ""}`}
|
||||
onClick={() => voice && handleVoiceSelect(voice.id)}
|
||||
disabled={!voice || voicesLoading}
|
||||
>
|
||||
<span className="tts-voice-card-icon">{info.icon}</span>
|
||||
<span className="tts-voice-card-name">
|
||||
{voice?.name || info.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语速滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">语速</span>
|
||||
<span className="tts-slider-value">
|
||||
{config.speed.toFixed(2)}x
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.05}
|
||||
value={config.speed}
|
||||
onChange={handleSpeedChange}
|
||||
tooltip={{ formatter: (v) => `${(v as number).toFixed(2)}x` }}
|
||||
/>
|
||||
<div className="tts-slider-marks">
|
||||
<span>0.5x</span>
|
||||
<span>1.0x</span>
|
||||
<span>2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 语调滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">语调</span>
|
||||
<span className="tts-slider-value">
|
||||
{config.pitch > 0 ? "+" : ""}
|
||||
{config.pitch} 半音
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
value={config.pitch}
|
||||
onChange={handlePitchChange}
|
||||
tooltip={{ formatter: (v) => `${v}半音` }}
|
||||
/>
|
||||
<div className="tts-slider-marks">
|
||||
<span>-12</span>
|
||||
<span>0</span>
|
||||
<span>+12</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 音量滑块 */}
|
||||
<div className="tts-slider-section">
|
||||
<div className="tts-slider-header">
|
||||
<span className="tts-slider-label">音量</span>
|
||||
<span className="tts-slider-value">{config.volume}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={config.volume}
|
||||
onChange={handleVolumeChange}
|
||||
tooltip={{ formatter: (v) => `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
<div className="tts-preview-section">
|
||||
<button
|
||||
className="tts-preview-btn"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? "⏳ 生成中..." : "🔊 试听"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 字幕联动 */}
|
||||
<div className="tts-subtitle-section">
|
||||
<div className="tts-subtitle-info">
|
||||
<span className="tts-subtitle-label">字幕联动</span>
|
||||
<span className="tts-subtitle-desc">
|
||||
{config.subtitle_sync
|
||||
? "TTS 文本自动同步到字幕"
|
||||
: "字幕需手动编辑"}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`ep-toggle${config.subtitle_sync ? " active" : ""}`}
|
||||
onClick={handleSubtitleSyncToggle}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 上传配音模式提示 ── */}
|
||||
{config.mode === "upload" && (
|
||||
<div className="tts-upload-hint">
|
||||
<p>请在右侧面板的「配音素材」中选择已上传的配音文件。</p>
|
||||
<p>如需上传新配音,请前往配音素材库页面。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
{config.mode === "tts" && (
|
||||
<div className="tts-footer">
|
||||
<button className="tts-reset-btn" onClick={handleReset}>
|
||||
重置默认
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TtsPanel;
|
||||
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* 水印配置面板 — Drawer 形式
|
||||
* 三个 Tab:图片水印 / 文字水印 / 滚动水印
|
||||
* 通用设置:位置、不透明度
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
WatermarkType,
|
||||
WatermarkPosition,
|
||||
ScrollDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_WATERMARK } from "../types";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [
|
||||
{ key: "none", label: "无水印", icon: "🚫" },
|
||||
{ key: "image", label: "图片水印", icon: "🖼️" },
|
||||
{ key: "text", label: "文字水印", icon: "📝" },
|
||||
{ key: "scroll", label: "滚动水印", icon: "📜" },
|
||||
];
|
||||
|
||||
const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [
|
||||
{ value: "top_left", label: "左上角" },
|
||||
{ value: "top_right", label: "右上角" },
|
||||
{ value: "bottom_left", label: "左下角" },
|
||||
{ value: "bottom_right", label: "右下角" },
|
||||
{ value: "center", label: "居中" },
|
||||
];
|
||||
|
||||
const SCROLL_DIRECTION_OPTIONS: {
|
||||
value: ScrollDirection;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "horizontal", label: "水平滚动" },
|
||||
{ value: "vertical", label: "垂直滚动" },
|
||||
{ value: "diagonal", label: "对角滚动" },
|
||||
];
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface WatermarkPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: WatermarkConfig;
|
||||
onChange: (config: WatermarkConfig) => void;
|
||||
}
|
||||
|
||||
const WatermarkPanel: React.FC<WatermarkPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
/* ── 图片上传预览 URL(本地预览用) ── */
|
||||
const [localImageUrl, setLocalImageUrl] = useState<string>("");
|
||||
|
||||
/* ── 切换水印类型 ── */
|
||||
const handleTypeChange = useCallback(
|
||||
(type: WatermarkType) => {
|
||||
onChange({ ...DEFAULT_WATERMARK, type });
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
/* ── 通用设置变更 ── */
|
||||
const handlePositionChange = useCallback(
|
||||
(position: WatermarkPosition) => {
|
||||
onChange({ ...config, position });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleOpacityChange = useCallback(
|
||||
(opacity: number) => {
|
||||
onChange({ ...config, opacity });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 图片水印设置 ── */
|
||||
const handleImageUrlChange = useCallback(
|
||||
(url: string) => {
|
||||
setLocalImageUrl(url);
|
||||
onChange({ ...config, image_url: url });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleImageWidthChange = useCallback(
|
||||
(width: number) => {
|
||||
onChange({ ...config, width });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleImageHeightChange = useCallback(
|
||||
(height: number) => {
|
||||
onChange({ ...config, height });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 文字水印设置 ── */
|
||||
const handleTextChange = useCallback(
|
||||
(text: string) => {
|
||||
onChange({ ...config, text });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleFontSizeChange = useCallback(
|
||||
(font_size: number) => {
|
||||
onChange({ ...config, font_size });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(color: string) => {
|
||||
onChange({ ...config, color });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 滚动水印设置 ── */
|
||||
const handleScrollDirectionChange = useCallback(
|
||||
(scroll_direction: ScrollDirection) => {
|
||||
onChange({ ...config, scroll_direction });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
const handleScrollSpeedChange = useCallback(
|
||||
(scroll_speed: number) => {
|
||||
onChange({ ...config, scroll_speed });
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
setLocalImageUrl("");
|
||||
onChange({ ...DEFAULT_WATERMARK });
|
||||
}, [onChange]);
|
||||
|
||||
/* ── 当前激活的 Tab ── */
|
||||
const activeTab = config.type;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="🔖 水印设置"
|
||||
placement="right"
|
||||
width={400}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
className="watermark-panel-drawer"
|
||||
>
|
||||
{/* ── Tab 切换 ── */}
|
||||
<div className="wp-tabs">
|
||||
{WATERMARK_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`wp-tab${activeTab === tab.key ? " active" : ""}`}
|
||||
onClick={() => handleTypeChange(tab.key)}
|
||||
>
|
||||
<span className="wp-tab-icon">{tab.icon}</span>
|
||||
<span className="wp-tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── 无水印提示 ── */}
|
||||
{config.type === "none" && (
|
||||
<div className="wp-empty-hint">
|
||||
<span className="wp-empty-icon">🚫</span>
|
||||
<p>当前未启用水印</p>
|
||||
<p className="wp-empty-desc">选择上方标签启用水印功能</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 图片水印配置 ── */}
|
||||
{config.type === "image" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印图片 URL</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="text"
|
||||
placeholder="https://example.com/logo.png"
|
||||
value={localImageUrl || config.image_url || ""}
|
||||
onChange={(e) => handleImageUrlChange(e.target.value)}
|
||||
/>
|
||||
{(localImageUrl || config.image_url) && (
|
||||
<div className="wp-image-preview">
|
||||
<img
|
||||
src={localImageUrl || config.image_url}
|
||||
alt="水印预览"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">宽度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={config.width ?? 0}
|
||||
onChange={(e) => handleImageWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">高度(像素,0 表示自适应)</label>
|
||||
<input
|
||||
className="wp-input"
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={config.height ?? 0}
|
||||
onChange={(e) => handleImageHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 文字水印配置 ── */}
|
||||
{config.type === "text" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入水印文字内容"
|
||||
rows={3}
|
||||
value={config.text ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size ?? 24}
|
||||
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.font_size ?? 24}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={config.color ?? "#ffffff"}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">
|
||||
{config.color ?? "#ffffff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 滚动水印配置 ── */}
|
||||
{config.type === "scroll" && (
|
||||
<div className="wp-section">
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动文字</label>
|
||||
<textarea
|
||||
className="wp-textarea"
|
||||
placeholder="输入滚动水印文字"
|
||||
rows={2}
|
||||
value={config.text ?? ""}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动方向</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={config.scroll_direction ?? "horizontal"}
|
||||
onChange={(e) =>
|
||||
handleScrollDirectionChange(e.target.value as ScrollDirection)
|
||||
}
|
||||
>
|
||||
{SCROLL_DIRECTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">滚动速度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={10}
|
||||
max={200}
|
||||
value={config.scroll_speed ?? 50}
|
||||
onChange={(e) =>
|
||||
handleScrollSpeedChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.scroll_speed ?? 50}px/s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">字号</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={12}
|
||||
max={72}
|
||||
value={config.font_size ?? 24}
|
||||
onChange={(e) => handleFontSizeChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{config.font_size ?? 24}px
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">文字颜色</label>
|
||||
<div className="wp-color-row">
|
||||
<input
|
||||
className="wp-color-input"
|
||||
type="color"
|
||||
value={config.color ?? "#ffffff"}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
/>
|
||||
<span className="wp-color-value">
|
||||
{config.color ?? "#ffffff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 通用设置(非 none 时显示) ── */}
|
||||
{config.type !== "none" && (
|
||||
<div className="wp-section wp-common-section">
|
||||
<div className="wp-section-divider" />
|
||||
<div className="wp-common-title">通用设置</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">水印位置</label>
|
||||
<select
|
||||
className="wp-select"
|
||||
value={config.position}
|
||||
onChange={(e) =>
|
||||
handlePositionChange(e.target.value as WatermarkPosition)
|
||||
}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="wp-field">
|
||||
<label className="wp-field-label">不透明度</label>
|
||||
<div className="wp-slider-row">
|
||||
<input
|
||||
className="wp-slider"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={config.opacity}
|
||||
onChange={(e) => handleOpacityChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="wp-slider-value">
|
||||
{Math.round(config.opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 底部操作 ── */}
|
||||
<div className="wp-footer">
|
||||
<button className="wp-reset-btn" onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkPanel;
|
||||
@@ -5,6 +5,508 @@
|
||||
|
||||
export type ClipType = "voice" | "pip";
|
||||
|
||||
/* ──────── 转场特效 ──────── */
|
||||
|
||||
/** 14 种转场类型 */
|
||||
export type TransitionType =
|
||||
| "none"
|
||||
| "cut"
|
||||
| "fade"
|
||||
| "dissolve"
|
||||
| "zoom"
|
||||
| "slide_left"
|
||||
| "slide_right"
|
||||
| "slide_up"
|
||||
| "slide_down"
|
||||
| "wipe_left"
|
||||
| "wipe_right"
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
|
||||
/** 片段间转场配置 */
|
||||
export interface TransitionConfig {
|
||||
/** 转场类型 */
|
||||
type: TransitionType;
|
||||
/** 转场时长(秒),0.3 ~ 2.0 */
|
||||
duration: number;
|
||||
}
|
||||
|
||||
/** 默认转场配置 */
|
||||
export const DEFAULT_TRANSITION: TransitionConfig = {
|
||||
type: "none",
|
||||
duration: 0.5,
|
||||
};
|
||||
|
||||
/* ──────── 片段调速 ──────── */
|
||||
|
||||
/** 片段调速配置 */
|
||||
export interface SpeedConfig {
|
||||
/** 播放速度,0.25 ~ 4.0 */
|
||||
rate: number;
|
||||
/** 音调修正(变速不变调) */
|
||||
pitchCorrection: boolean;
|
||||
}
|
||||
|
||||
/** 默认调速配置 */
|
||||
export const DEFAULT_SPEED: SpeedConfig = {
|
||||
rate: 1.0,
|
||||
pitchCorrection: true,
|
||||
};
|
||||
|
||||
/* ──────── TTS 配音 ──────── */
|
||||
|
||||
/** 配音模式 */
|
||||
export type TtsMode = "none" | "upload" | "tts";
|
||||
|
||||
/** TTS 配音配置 */
|
||||
export interface TtsConfig {
|
||||
/** 配音模式 */
|
||||
mode: TtsMode;
|
||||
/** TTS 合成文本 */
|
||||
text: string;
|
||||
/** 音色 ID */
|
||||
voice_id: string;
|
||||
/** 语速 0.5 ~ 2.0 */
|
||||
speed: number;
|
||||
/** 语调(半音)-12 ~ +12 */
|
||||
pitch: number;
|
||||
/** 音量 0 ~ 100 */
|
||||
volume: number;
|
||||
/** 字幕联动 */
|
||||
subtitle_sync: boolean;
|
||||
}
|
||||
|
||||
/** 默认 TTS 配置 */
|
||||
export const DEFAULT_TTS_CONFIG: TtsConfig = {
|
||||
mode: "none",
|
||||
text: "",
|
||||
voice_id: "",
|
||||
speed: 1.0,
|
||||
pitch: 0,
|
||||
volume: 100,
|
||||
subtitle_sync: true,
|
||||
};
|
||||
|
||||
/* ──────── 裁剪配置 ──────── */
|
||||
|
||||
/** 片段裁剪配置 — 定义素材的入点/出点 */
|
||||
export interface TrimConfig {
|
||||
/** 入点(秒),素材原始时间轴上的起始位置 */
|
||||
start_time: number;
|
||||
/** 出点(秒),素材原始时间轴上的结束位置 */
|
||||
end_time: number;
|
||||
/** 素材原始总时长(秒),用于"恢复原始长度" */
|
||||
original_duration?: number;
|
||||
}
|
||||
|
||||
/* ──────── 水印配置 ──────── */
|
||||
|
||||
/** 水印类型 */
|
||||
export type WatermarkType = "none" | "image" | "text" | "scroll";
|
||||
|
||||
/** 水印位置 */
|
||||
export type WatermarkPosition =
|
||||
"top_left" | "top_right" | "bottom_left" | "bottom_right" | "center";
|
||||
|
||||
/** 滚动水印方向 */
|
||||
export type ScrollDirection = "horizontal" | "vertical" | "diagonal";
|
||||
|
||||
/** 水印配置 */
|
||||
export interface WatermarkConfig {
|
||||
/** 水印类型 */
|
||||
type: WatermarkType;
|
||||
/** 图片水印 URL */
|
||||
image_url?: string;
|
||||
/** 水印宽度(像素或百分比 0~1) */
|
||||
width?: number;
|
||||
/** 水印高度(像素或百分比 0~1) */
|
||||
height?: number;
|
||||
/** 水印位置 */
|
||||
position: WatermarkPosition;
|
||||
/** 水印不透明度 0~1 */
|
||||
opacity: number;
|
||||
/** 文字水印内容 */
|
||||
text?: string;
|
||||
/** 文字水印字号 */
|
||||
font_size?: number;
|
||||
/** 文字水印颜色 */
|
||||
color?: string;
|
||||
/** 滚动水印方向 */
|
||||
scroll_direction?: ScrollDirection;
|
||||
/** 滚动水印速度(像素/秒) */
|
||||
scroll_speed?: number;
|
||||
}
|
||||
|
||||
/** 默认水印配置 */
|
||||
export const DEFAULT_WATERMARK: WatermarkConfig = {
|
||||
type: "none",
|
||||
position: "bottom_right",
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/* ──────── 片头片尾配置 ──────── */
|
||||
|
||||
/** 片头片尾素材类型 */
|
||||
export type IntroOutroKind = "none" | "video" | "image";
|
||||
|
||||
/** 片头/片尾单项配置 */
|
||||
export interface IntroOutroItem {
|
||||
/** 素材类型 */
|
||||
kind: IntroOutroKind;
|
||||
/** 素材 URL */
|
||||
url?: string;
|
||||
/** 显示时长(秒) */
|
||||
duration: number;
|
||||
/** 过渡动画 */
|
||||
transition?: TransitionType;
|
||||
/** 过渡时长(秒) */
|
||||
transition_duration?: number;
|
||||
}
|
||||
|
||||
/** 片头片尾完整配置 */
|
||||
export interface IntroOutroConfig {
|
||||
intro: IntroOutroItem;
|
||||
outro: IntroOutroItem;
|
||||
}
|
||||
|
||||
/** 默认片头片尾配置 */
|
||||
export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
|
||||
intro: { kind: "none", duration: 3 },
|
||||
outro: { kind: "none", duration: 3 },
|
||||
};
|
||||
|
||||
/* ──────── 画中画配置 ──────── */
|
||||
|
||||
/** 九宫格位置 */
|
||||
export type PipGridPosition =
|
||||
| "top_left"
|
||||
| "top_center"
|
||||
| "top_right"
|
||||
| "center_left"
|
||||
| "center"
|
||||
| "center_right"
|
||||
| "bottom_left"
|
||||
| "bottom_center"
|
||||
| "bottom_right";
|
||||
|
||||
/** 入场动画类型 */
|
||||
export type PipAnimType = "none" | "fade_in" | "slide_in";
|
||||
|
||||
/** 入场方向 */
|
||||
export type PipSlideDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
/** 画中画图层 */
|
||||
export interface PipLayer {
|
||||
id: string;
|
||||
/** 图层名称(用户可编辑) */
|
||||
name: string;
|
||||
/** 素材类型 */
|
||||
material_type: "image" | "video";
|
||||
/** 素材 URL */
|
||||
material_url: string;
|
||||
/** 素材缩略图 */
|
||||
thumbnail_url?: string;
|
||||
/** 九宫格快捷位置 */
|
||||
grid_position: PipGridPosition;
|
||||
/** 精确 X 坐标(百分比 0~100) */
|
||||
x: number;
|
||||
/** 精确 Y 坐标(百分比 0~100) */
|
||||
y: number;
|
||||
/** 宽度(百分比 0~100,相对主画面) */
|
||||
width: number;
|
||||
/** 高度(百分比 0~100,相对主画面) */
|
||||
height: number;
|
||||
/** 锁定宽高比 */
|
||||
aspect_lock: boolean;
|
||||
/** 圆角(百分比 0~50) */
|
||||
border_radius: number;
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number;
|
||||
/** 开始时间(秒) */
|
||||
start_time: number;
|
||||
/** 持续时长(秒) */
|
||||
duration: number;
|
||||
/** 入场动画 */
|
||||
animation: PipAnimType;
|
||||
/** 入场方向 */
|
||||
slide_direction: PipSlideDirection;
|
||||
/** 图层顺序(z-index) */
|
||||
z_index: number;
|
||||
}
|
||||
|
||||
/** 画中画配置 */
|
||||
export interface PipConfig {
|
||||
/** 是否启用画中画 */
|
||||
enabled: boolean;
|
||||
/** 图层列表 */
|
||||
layers: PipLayer[];
|
||||
}
|
||||
|
||||
/** 默认 PiP 图层 */
|
||||
export const DEFAULT_PIP_LAYER: PipLayer = {
|
||||
id: "",
|
||||
name: "图层",
|
||||
material_type: "image",
|
||||
material_url: "",
|
||||
grid_position: "top_right",
|
||||
x: 70,
|
||||
y: 5,
|
||||
width: 25,
|
||||
height: 25,
|
||||
aspect_lock: true,
|
||||
border_radius: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
animation: "none",
|
||||
slide_direction: "right",
|
||||
z_index: 1,
|
||||
};
|
||||
|
||||
/** 默认 PiP 配置 */
|
||||
export const DEFAULT_PIP_CONFIG: PipConfig = {
|
||||
enabled: false,
|
||||
layers: [],
|
||||
};
|
||||
|
||||
/* ──────── 滤镜调色 ──────── */
|
||||
|
||||
/** 预设滤镜 */
|
||||
export type FilterPreset =
|
||||
| "none"
|
||||
| "original"
|
||||
| "fresh"
|
||||
| "warm"
|
||||
| "cool"
|
||||
| "vintage"
|
||||
| "cinema"
|
||||
| "bw"
|
||||
| "sunshine"
|
||||
| "film";
|
||||
|
||||
/** 预设滤镜标签 */
|
||||
export const FILTER_PRESET_LABELS: Record<FilterPreset, string> = {
|
||||
none: "无",
|
||||
original: "原片",
|
||||
fresh: "清新",
|
||||
warm: "暖调",
|
||||
cool: "冷色",
|
||||
vintage: "复古",
|
||||
cinema: "电影",
|
||||
bw: "黑白",
|
||||
sunshine: "暖阳",
|
||||
film: "胶片",
|
||||
};
|
||||
|
||||
/** 滤镜调色配置 */
|
||||
export interface FilterConfig {
|
||||
/** 是否启用滤镜 */
|
||||
enabled: boolean;
|
||||
/** 预设滤镜 */
|
||||
preset: FilterPreset;
|
||||
/** 亮度(-100 ~ 100) */
|
||||
brightness: number;
|
||||
/** 对比度(-100 ~ 100) */
|
||||
contrast: number;
|
||||
/** 饱和度(-100 ~ 100) */
|
||||
saturation: number;
|
||||
/** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */
|
||||
temperature: number;
|
||||
/** 色调(-100 ~ 100,负值偏绿,正值偏品红) */
|
||||
tint: number;
|
||||
/** 锐度(0 ~ 100) */
|
||||
sharpness: number;
|
||||
}
|
||||
|
||||
/** 默认滤镜调色配置 */
|
||||
export const DEFAULT_FILTER_CONFIG: FilterConfig = {
|
||||
enabled: false,
|
||||
preset: "none",
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
temperature: 0,
|
||||
tint: 0,
|
||||
sharpness: 0,
|
||||
};
|
||||
|
||||
/* ──────── 绿幕抠像 ──────── */
|
||||
|
||||
/** 绿幕抠像颜色预设 */
|
||||
export type ChromaKeyColorPreset =
|
||||
"green" | "blue" | "red" | "pure_green" | "soft_green";
|
||||
|
||||
/** 颜色预设标签 */
|
||||
export const CHROMA_KEY_PRESET_LABELS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "绿",
|
||||
blue: "蓝",
|
||||
red: "红",
|
||||
pure_green: "精绿",
|
||||
soft_green: "柔绿",
|
||||
};
|
||||
|
||||
/** 颜色预设对应的默认色值 */
|
||||
export const CHROMA_KEY_PRESET_COLORS: Record<ChromaKeyColorPreset, string> = {
|
||||
green: "#00FF00",
|
||||
blue: "#0000FF",
|
||||
red: "#FF0000",
|
||||
pure_green: "#00C800",
|
||||
soft_green: "#40E040",
|
||||
};
|
||||
|
||||
/** 绿幕抠像配置 */
|
||||
export interface ChromaKeyConfig {
|
||||
/** 是否启用绿幕抠像 */
|
||||
enabled: boolean;
|
||||
/** 颜色预设 */
|
||||
color_preset: ChromaKeyColorPreset;
|
||||
/** 抠像目标颜色(HEX) */
|
||||
color: string;
|
||||
/** 相似度(0 ~ 100,越大容忍的色差范围越广) */
|
||||
similarity: number;
|
||||
/** 边缘平滑(0 ~ 100,越大边缘越柔和) */
|
||||
blend: number;
|
||||
/** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */
|
||||
spill: number;
|
||||
}
|
||||
|
||||
/** 默认绿幕抠像配置 */
|
||||
export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = {
|
||||
enabled: false,
|
||||
color_preset: "green",
|
||||
color: "#00FF00",
|
||||
similarity: 30,
|
||||
blend: 10,
|
||||
spill: 20,
|
||||
};
|
||||
|
||||
/* ──────── 贴纸配置 ──────── */
|
||||
|
||||
/** 贴纸类型 */
|
||||
export type StickerType = "emoji" | "image" | "text";
|
||||
|
||||
/** 文字花字预设 */
|
||||
export type TextStickerPreset =
|
||||
| "normal" // 普通
|
||||
| "highlight" // 高亮
|
||||
| "bubble" // 气泡
|
||||
| "neon" // 霓虹
|
||||
| "shadow" // 投影
|
||||
| "outline" // 描边
|
||||
| "gradient" // 渐变
|
||||
| "handwrite"; // 手写
|
||||
|
||||
/** 贴纸项 */
|
||||
export interface StickerItem {
|
||||
id: string;
|
||||
/** 贴纸类型 */
|
||||
type: StickerType;
|
||||
/** 内容(emoji 字符 / 图片 URL / 文字内容) */
|
||||
content: string;
|
||||
/** X 坐标(百分比 0~100) */
|
||||
x: number;
|
||||
/** Y 坐标(百分比 0~100) */
|
||||
y: number;
|
||||
/** 宽度(百分比 0~100) */
|
||||
width: number;
|
||||
/** 高度(百分比 0~100) */
|
||||
height: number;
|
||||
/** 旋转角度(度 -180~180) */
|
||||
rotation: number;
|
||||
/** 不透明度(0~100) */
|
||||
opacity: number;
|
||||
/** 开始时间(秒) */
|
||||
start_time: number;
|
||||
/** 持续时长(秒,0 表示全程显示) */
|
||||
duration: number;
|
||||
/** 图层顺序 */
|
||||
z_index: number;
|
||||
/** 文字花字预设(仅 type=text 时有效) */
|
||||
text_preset: TextStickerPreset;
|
||||
/** 文字颜色(仅 type=text 时有效) */
|
||||
text_color: string;
|
||||
/** 文字大小(px,仅 type=text 时有效) */
|
||||
font_size: number;
|
||||
}
|
||||
|
||||
/** 贴纸配置 */
|
||||
export interface StickerConfig {
|
||||
enabled: boolean;
|
||||
items: StickerItem[];
|
||||
}
|
||||
|
||||
/** 默认贴纸项 */
|
||||
export const DEFAULT_STICKER_ITEM: StickerItem = {
|
||||
id: "",
|
||||
type: "emoji",
|
||||
content: "😀",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 15,
|
||||
height: 15,
|
||||
rotation: 0,
|
||||
opacity: 100,
|
||||
start_time: 0,
|
||||
duration: 0,
|
||||
z_index: 1,
|
||||
text_preset: "normal",
|
||||
text_color: "#FFFFFF",
|
||||
font_size: 24,
|
||||
};
|
||||
|
||||
/** 默认贴纸配置 */
|
||||
export const DEFAULT_STICKER_CONFIG: StickerConfig = {
|
||||
enabled: false,
|
||||
items: [],
|
||||
};
|
||||
|
||||
/** 文字花字预设标签 */
|
||||
export const TEXT_STICKER_PRESET_LABELS: Record<TextStickerPreset, string> = {
|
||||
normal: "普通",
|
||||
highlight: "高亮",
|
||||
bubble: "气泡",
|
||||
neon: "霓虹",
|
||||
shadow: "投影",
|
||||
outline: "描边",
|
||||
gradient: "渐变",
|
||||
handwrite: "手写",
|
||||
};
|
||||
|
||||
/* ──────── 封面配置 ──────── */
|
||||
|
||||
/** 封面来源模式 */
|
||||
export type CoverMode = "auto" | "frame" | "upload";
|
||||
|
||||
/** 封面配置 */
|
||||
export interface CoverConfig {
|
||||
/** 是否启用自定义封面 */
|
||||
enabled: boolean;
|
||||
/** 封面来源模式 */
|
||||
mode: CoverMode;
|
||||
/** 抽帧时间点(秒,mode=frame 时使用) */
|
||||
frame_time: number;
|
||||
/** 上传的封面 URL(mode=upload 时使用) */
|
||||
upload_url: string;
|
||||
/** AI 智能推荐的抽帧时间(由后端分析得出) */
|
||||
ai_suggested_time: number | null;
|
||||
/** 封面缩略图 URL */
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
/** 默认封面配置 */
|
||||
export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
enabled: false,
|
||||
mode: "auto",
|
||||
frame_time: 0,
|
||||
upload_url: "",
|
||||
ai_suggested_time: null,
|
||||
thumbnail_url: "",
|
||||
};
|
||||
|
||||
/* ──────── 片段数据 ──────── */
|
||||
|
||||
export interface ClipData {
|
||||
id: string;
|
||||
type: ClipType; // 片段类型:voice(口播)或 pip(画中画)
|
||||
@@ -18,4 +520,31 @@ export interface ClipData {
|
||||
voice_asset_id?: string;
|
||||
/** 配音素材文件 URL(voice 类型片段使用) */
|
||||
voice_file_url?: string;
|
||||
/** 与前一片段之间的转场效果 */
|
||||
transition?: TransitionConfig;
|
||||
/** 播放速度配置 */
|
||||
speed?: SpeedConfig;
|
||||
/** TTS 配音配置 */
|
||||
tts_config?: TtsConfig;
|
||||
/** 裁剪配置 — 定义素材入点/出点 */
|
||||
trim_config?: TrimConfig;
|
||||
}
|
||||
|
||||
/* ──────── 标题设置 ──────── */
|
||||
|
||||
/**
|
||||
* 标题设置 — 对齐后端 title_config 字段
|
||||
* 前端 UI 使用 camelCase,发送到后端时映射为 snake_case
|
||||
*/
|
||||
export interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
createEditPlan,
|
||||
generateEditPlan,
|
||||
updateEditPlan,
|
||||
getGenerationTaskResults,
|
||||
} from "@/api/editPlans";
|
||||
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
@@ -152,6 +154,9 @@ const GeneratePage: React.FC = () => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
const [generateError, setGenerateError] = useState<string | null>(null);
|
||||
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
|
||||
|
||||
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@@ -197,8 +202,8 @@ const GeneratePage: React.FC = () => {
|
||||
try {
|
||||
const plan = await getEditPlan(editPlanId);
|
||||
if (plan.name) setTitle(plan.name);
|
||||
const cfg = plan.config as Record<string, unknown>;
|
||||
if (cfg && Array.isArray(cfg.asset_ids)) {
|
||||
const cfg = plan.config;
|
||||
if (cfg?.asset_ids) {
|
||||
setSelectedMaterials(
|
||||
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
|
||||
);
|
||||
@@ -472,7 +477,13 @@ const GeneratePage: React.FC = () => {
|
||||
setGenerateError(null);
|
||||
|
||||
try {
|
||||
const voiceConfig: Record<string, unknown> = {};
|
||||
const voiceConfig: Pick<
|
||||
EditPlanConfig,
|
||||
| "voice_id"
|
||||
| "voice_clone_profile_id"
|
||||
| "custom_audio_url"
|
||||
| "custom_text"
|
||||
> = {};
|
||||
if (voiceMode === "preset") {
|
||||
voiceConfig.voice_id = selectedVoice || undefined;
|
||||
} else if (voiceMode === "clone") {
|
||||
@@ -518,6 +529,25 @@ const GeneratePage: React.FC = () => {
|
||||
setProgress(100);
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
|
||||
// 获取生成的视频结果
|
||||
if (data.generation_task_id) {
|
||||
try {
|
||||
const videos = await getGenerationTaskResults(
|
||||
data.generation_task_id,
|
||||
);
|
||||
setGeneratedVideos(videos);
|
||||
if (videos.length > 0) {
|
||||
setVideoUrl(
|
||||
videos[0].file_url || videos[0].download_url || "",
|
||||
);
|
||||
setThumbnailUrl(videos[0].thumbnail_url || "");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[获取生成结果失败]", err);
|
||||
}
|
||||
}
|
||||
|
||||
message.success("视频生成完成!");
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +567,8 @@ const GeneratePage: React.FC = () => {
|
||||
const safeExtract = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -597,7 +628,8 @@ const GeneratePage: React.FC = () => {
|
||||
const extractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -627,7 +659,8 @@ const GeneratePage: React.FC = () => {
|
||||
const safeExtractErr = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "object" && val !== null) {
|
||||
const obj = val as Record<string, unknown>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
@@ -708,6 +741,42 @@ const GeneratePage: React.FC = () => {
|
||||
materialMode,
|
||||
]);
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
try {
|
||||
// 优先使用 download_url(签名 URL),回退到 file_url
|
||||
const url = video.download_url || video.file_url;
|
||||
if (url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = video.name || "generated-video.mp4";
|
||||
a.target = "_blank";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[下载失败]", err);
|
||||
message.error("下载失败,请重试");
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!generatedVideos.length) return;
|
||||
const video = generatedVideos[0];
|
||||
const shareUrl = video.file_url || window.location.href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
message.success("视频链接已复制到剪贴板");
|
||||
} catch {
|
||||
// fallback: 显示 URL 让用户手动复制
|
||||
message.info(`视频链接: ${shareUrl}`);
|
||||
}
|
||||
}, [generatedVideos]);
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
const goNext = useCallback(() => {
|
||||
if (currentStep === 1 && !selectedTemplate) {
|
||||
@@ -1724,8 +1793,25 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-generate-preview">
|
||||
{/* 视频预览 */}
|
||||
<div className="xx-preview-video">
|
||||
{generated ? (
|
||||
<video src="" controls preload="none" />
|
||||
{generated && videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
poster={thumbnailUrl || undefined}
|
||||
style={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
/>
|
||||
) : generated ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: 24,
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
<LoadingOutlined style={{ fontSize: 24, marginBottom: 8 }} />
|
||||
<div>视频处理中,请稍候…</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="xx-play-btn" type="button">
|
||||
<PlayCircleOutlined />
|
||||
@@ -1778,12 +1864,18 @@ const GeneratePage: React.FC = () => {
|
||||
{/* 生成完成后显示下载/分享 */}
|
||||
{generated && (
|
||||
<div className="xx-generate-actions" style={{ marginTop: 8 }}>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleDownload}>
|
||||
<DownloadOutlined /> 下载视频
|
||||
</button>
|
||||
<button className="xx-btn xx-btn-ghost">
|
||||
<button className="xx-btn xx-btn-ghost" onClick={handleShare}>
|
||||
<ShareAltOutlined /> 分享
|
||||
</button>
|
||||
<button
|
||||
className="xx-btn xx-btn-ghost"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
前往成片库 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
updateReviewStatus,
|
||||
batchDownload,
|
||||
getBatchDownloadStatus,
|
||||
type ProductItem as ApiProductItem,
|
||||
type ReviewStatus,
|
||||
} from "@/api/products";
|
||||
import "./products.css";
|
||||
|
||||
@@ -53,6 +57,12 @@ interface ProductItem {
|
||||
fileSize: number; // MB
|
||||
videoUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
/** 复核状态 */
|
||||
reviewStatus?: ReviewStatus;
|
||||
/** 所属项目 ID */
|
||||
projectId?: string;
|
||||
/** 所属项目名称 */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
/** 将后端 status 映射为前端 ProductStatus */
|
||||
@@ -108,6 +118,9 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({
|
||||
fileSize: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0,
|
||||
videoUrl: item.video_url,
|
||||
thumbnailUrl: item.thumbnail_url,
|
||||
reviewStatus: item.review_status,
|
||||
projectId: item.project_id,
|
||||
projectName: item.project_name,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
@@ -135,6 +148,30 @@ const formatSize = (mb: number): string => {
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** 复核状态配置 */
|
||||
const reviewStatusConfig: Record<
|
||||
ReviewStatus,
|
||||
{ text: string; className: string }
|
||||
> = {
|
||||
pending_review: { text: "待复核", className: "review-pending" },
|
||||
approved: { text: "已通过", className: "review-approved" },
|
||||
rejected: { text: "需修改", className: "review-rejected" },
|
||||
};
|
||||
|
||||
/** 复核状态循环顺序 */
|
||||
const REVIEW_STATUS_CYCLE: ReviewStatus[] = [
|
||||
"pending_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
];
|
||||
|
||||
/** 获取下一个复核状态 */
|
||||
const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => {
|
||||
if (!current) return "approved";
|
||||
const idx = REVIEW_STATUS_CYCLE.indexOf(current);
|
||||
return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length];
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* ProductCard 组件
|
||||
* ============================================================ */
|
||||
@@ -148,6 +185,7 @@ const ProductCard: React.FC<{
|
||||
onShare: (product: ProductItem) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPublish: (product: ProductItem) => void;
|
||||
onReviewStatusChange: (id: string) => void;
|
||||
}> = ({
|
||||
product,
|
||||
isSelected,
|
||||
@@ -158,6 +196,7 @@ const ProductCard: React.FC<{
|
||||
onShare,
|
||||
onDelete,
|
||||
onPublish,
|
||||
onReviewStatusChange,
|
||||
}) => {
|
||||
const st = statusConfig[product.status];
|
||||
|
||||
@@ -201,6 +240,33 @@ const ProductCard: React.FC<{
|
||||
{/* 已发布徽章(右上角) */}
|
||||
{product.isPublished && <div className="xx-product-badge">✅ 已发布</div>}
|
||||
|
||||
{/* 复核状态标签(右上角) */}
|
||||
{product.reviewStatus && (
|
||||
<div
|
||||
className={`xx-product-review-tag ${reviewStatusConfig[product.reviewStatus].className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`}
|
||||
>
|
||||
{reviewStatusConfig[product.reviewStatus].text}
|
||||
</div>
|
||||
)}
|
||||
{/* 无复核状态时显示"待复核"入口 */}
|
||||
{!product.reviewStatus && (
|
||||
<div
|
||||
className="xx-product-review-tag review-pending"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReviewStatusChange(product.id);
|
||||
}}
|
||||
title="点击设置复核状态"
|
||||
>
|
||||
待复核
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-product-thumb">
|
||||
{product.thumbnailUrl ? (
|
||||
@@ -517,7 +583,7 @@ const ProductLibrary: React.FC = () => {
|
||||
refetch,
|
||||
} = useQuery<ApiProductItem[], Error>({
|
||||
queryKey: ["products"],
|
||||
queryFn: getProducts,
|
||||
queryFn: () => getProducts(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -536,11 +602,26 @@ const ProductLibrary: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 复核状态 mutation ── */
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) =>
|
||||
updateReviewStatus(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
message.success("复核状态已更新");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("更新复核状态失败");
|
||||
},
|
||||
});
|
||||
|
||||
/* 筛选 */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
const [filterTime, setFilterTime] = useState<string>("all");
|
||||
const [filterDuration, setFilterDuration] = useState<string>("all");
|
||||
const [filterProject, setFilterProject] = useState<string>("all");
|
||||
const [filterReviewStatus, setFilterReviewStatus] = useState<string>("all");
|
||||
|
||||
/* 批量操作 */
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
@@ -596,6 +677,20 @@ const ProductLibrary: React.FC = () => {
|
||||
});
|
||||
}
|
||||
|
||||
/* 项目筛选 */
|
||||
if (filterProject !== "all") {
|
||||
list = list.filter((p) => p.projectId === filterProject);
|
||||
}
|
||||
|
||||
/* 复核状态筛选 */
|
||||
if (filterReviewStatus !== "all") {
|
||||
if (filterReviewStatus === "none") {
|
||||
list = list.filter((p) => !p.reviewStatus);
|
||||
} else {
|
||||
list = list.filter((p) => p.reviewStatus === filterReviewStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索 */
|
||||
if (searchText.trim()) {
|
||||
const q = searchText.trim().toLowerCase();
|
||||
@@ -603,7 +698,15 @@ const ProductLibrary: React.FC = () => {
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [products, filterStatus, filterTime, filterDuration, searchText]);
|
||||
}, [
|
||||
products,
|
||||
filterStatus,
|
||||
filterTime,
|
||||
filterDuration,
|
||||
filterProject,
|
||||
filterReviewStatus,
|
||||
searchText,
|
||||
]);
|
||||
|
||||
/* 全选 */
|
||||
const allSelected =
|
||||
@@ -679,24 +782,56 @@ const ProductLibrary: React.FC = () => {
|
||||
message.info("发布功能待后端 API 补齐");
|
||||
};
|
||||
|
||||
/* 批量下载 */
|
||||
/* 切换复核状态 */
|
||||
const handleReviewStatusChange = (id: string) => {
|
||||
const current = products.find((p) => p.id === id)?.reviewStatus;
|
||||
const nextStatus = getNextReviewStatus(current);
|
||||
reviewMutation.mutate({ id, status: nextStatus });
|
||||
};
|
||||
|
||||
/* 批量下载 — 使用 batch-download API + 轮询 */
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
successCount++;
|
||||
} catch {
|
||||
// 忽略单个失败
|
||||
}
|
||||
if (ids.length === 0) return;
|
||||
setBatchDownloading(true);
|
||||
try {
|
||||
// 发起批量下载任务
|
||||
const { job_id } = await batchDownload(ids);
|
||||
message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`);
|
||||
|
||||
// 轮询下载状态(最多 60 次,每次 2 秒)
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
const poll = async (): Promise<void> => {
|
||||
if (attempts >= maxAttempts) {
|
||||
message.warning("打包超时,请稍后在消息中心查看");
|
||||
return;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const status = await getBatchDownloadStatus(job_id);
|
||||
if (status.status === "completed" && status.download_url) {
|
||||
const a = document.createElement("a");
|
||||
a.href = status.download_url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
message.success(`已打包下载 ${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
} else if (status.status === "failed") {
|
||||
message.error("批量下载失败,请重试");
|
||||
} else {
|
||||
// 继续轮询
|
||||
await poll();
|
||||
}
|
||||
};
|
||||
await poll();
|
||||
} catch {
|
||||
message.error("发起批量下载失败");
|
||||
} finally {
|
||||
setBatchDownloading(false);
|
||||
}
|
||||
message.success(`已下载 ${successCount}/${ids.length} 个视频`);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
/* 批量删除 */
|
||||
@@ -823,8 +958,9 @@ const ProductLibrary: React.FC = () => {
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleBatchDownload}
|
||||
disabled={batchDownloading}
|
||||
>
|
||||
批量下载
|
||||
{batchDownloading ? "打包中..." : "批量下载"}
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
@@ -904,6 +1040,36 @@ const ProductLibrary: React.FC = () => {
|
||||
{ value: "long", label: ">3分钟" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部项目" },
|
||||
...Array.from(
|
||||
new Map(
|
||||
products
|
||||
.filter((p) => p.projectId && p.projectName)
|
||||
.map((p) => [p.projectId!, p.projectName!] as const),
|
||||
),
|
||||
).map(([id, name]) => ({
|
||||
value: id as string,
|
||||
label: name as string,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={filterReviewStatus}
|
||||
onChange={setFilterReviewStatus}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: "all", label: "全部复核" },
|
||||
{ value: "none", label: "未设置" },
|
||||
{ value: "pending_review", label: "待复核" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "rejected", label: "需修改" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-products-filters-right">
|
||||
<span
|
||||
@@ -932,6 +1098,7 @@ const ProductLibrary: React.FC = () => {
|
||||
onShare={handleShare}
|
||||
onDelete={handleDelete}
|
||||
onPublish={handlePublish}
|
||||
onReviewStatusChange={handleReviewStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -222,6 +222,42 @@
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* 复核状态标签(右上角,位于已发布徽章下方) */
|
||||
.xx-product-review-tag {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-all);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-product-review-tag:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-pending {
|
||||
background: rgba(156, 163, 175, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-approved {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-product-review-tag.review-rejected {
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* 缩略图区域 */
|
||||
.xx-product-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* 任务中心页面
|
||||
* 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
CloseCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
MinusCircleOutlined,
|
||||
RedoOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
getTasks,
|
||||
retryTask,
|
||||
type TaskItem,
|
||||
type TaskStatus,
|
||||
type TaskListParams,
|
||||
} from "@/api/tasks";
|
||||
import "./tasks.css";
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
/** 状态 Tab 配置 */
|
||||
const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "waiting", label: "等待中" },
|
||||
{ key: "running", label: "进行中" },
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
/** 类型筛选选项 */
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "all", label: "全部类型" },
|
||||
{ value: "generation", label: "生成任务" },
|
||||
{ value: "ingest", label: "素材导入" },
|
||||
];
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
TaskStatus,
|
||||
{ label: string; color: string; icon: React.ReactNode }
|
||||
> = {
|
||||
pending: {
|
||||
label: "等待中",
|
||||
color: "default",
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
waiting: {
|
||||
label: "排队中",
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
running: {
|
||||
label: "进行中",
|
||||
color: "processing",
|
||||
icon: <SyncOutlined spin />,
|
||||
},
|
||||
completed: {
|
||||
label: "已完成",
|
||||
color: "success",
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
failed: {
|
||||
label: "失败",
|
||||
color: "error",
|
||||
icon: <CloseCircleOutlined />,
|
||||
},
|
||||
cancelled: {
|
||||
label: "已取消",
|
||||
color: "default",
|
||||
icon: <MinusCircleOutlined />,
|
||||
},
|
||||
};
|
||||
|
||||
/** 任务类型标签 */
|
||||
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
generation: { label: "生成任务", color: "blue" },
|
||||
ingest: { label: "素材导入", color: "green" },
|
||||
};
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化耗时 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return "-";
|
||||
if (seconds < 60) return `${Math.round(seconds)}秒`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
if (minutes < 60) return `${minutes}分${secs}秒`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}小时${mins}分`;
|
||||
};
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function TaskCenter() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<TaskStatus | "all">("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
const [expandedTaskDetail, setExpandedTaskDetail] = useState<TaskItem | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// 查询参数
|
||||
const queryParams: TaskListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(typeFilter !== "all" && { task_type: typeFilter }),
|
||||
};
|
||||
|
||||
// 获取任务列表
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["tasks", queryParams],
|
||||
queryFn: () => getTasks(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的任务时自动刷新
|
||||
const tasks = query.state.data?.items ?? [];
|
||||
const hasRunning = tasks.some(
|
||||
(t) => t.status === "running" || t.status === "waiting",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 重试任务
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
message.success("任务已重新提交");
|
||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重试失败,请检查任务状态");
|
||||
},
|
||||
});
|
||||
|
||||
// 展开查看详情
|
||||
const handleExpand = async (expanded: boolean, record: TaskItem) => {
|
||||
if (!expanded) {
|
||||
setExpandedTaskId(null);
|
||||
setExpandedTaskDetail(null);
|
||||
return;
|
||||
}
|
||||
setExpandedTaskId(record.id);
|
||||
// 如果是失败任务,获取详情(含 error_info)
|
||||
if (record.status === "failed" && record.error_info) {
|
||||
setExpandedTaskDetail(record);
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<TaskItem> = [
|
||||
{
|
||||
title: "任务ID",
|
||||
dataIndex: "id",
|
||||
key: "id",
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<span className="task-id">{id.slice(0, 8)}...</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "task_type",
|
||||
key: "task_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const config = TYPE_LABELS[type] || { label: type, color: "default" };
|
||||
return <Tag color={config.color}>{config.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 120,
|
||||
render: (status: TaskStatus, record: TaskItem) => {
|
||||
const config = STATUS_CONFIG[status] || {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="task-status-tag"
|
||||
>
|
||||
{config.label}
|
||||
{status === "running" && record.progress > 0 && (
|
||||
<span className="task-progress"> {record.progress}%</span>
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "当前步骤",
|
||||
dataIndex: "current_step",
|
||||
key: "current_step",
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (step: string) => (
|
||||
<span className="task-step">{step || "-"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "耗时",
|
||||
dataIndex: "duration_seconds",
|
||||
key: "duration_seconds",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="task-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 120,
|
||||
render: (time: string) => (
|
||||
<span className="task-time">{formatTime(time)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 100,
|
||||
fixed: "right",
|
||||
render: (_: unknown, record: TaskItem) => {
|
||||
if (record.status === "failed" && record.retryable) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认重试"
|
||||
description="确定要重试这个失败的任务吗?"
|
||||
onConfirm={() => retryMutation.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
loading={retryMutation.isPending}
|
||||
className="task-retry-btn"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
if (record.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => {
|
||||
setExpandedTaskId(record.id);
|
||||
setExpandedTaskDetail(record);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <span className="task-action-placeholder">-</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 展开行渲染(错误详情)
|
||||
const expandedRowRender = (record: TaskItem) => {
|
||||
const detail = expandedTaskDetail || record;
|
||||
const errorInfo = detail.error_info;
|
||||
|
||||
if (!errorInfo && !detail.error_message) {
|
||||
return <div className="task-expand-empty">暂无错误详情</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-error-detail">
|
||||
<div className="task-error-header">
|
||||
<ExclamationCircleOutlined className="task-error-icon" />
|
||||
<span>错误详情</span>
|
||||
</div>
|
||||
<div className="task-error-body">
|
||||
{errorInfo?.error_type && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">错误类型:</span>
|
||||
<Tag color="error">{errorInfo.error_type}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{(errorInfo?.error_message || detail.error_message) && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">错误信息:</span>
|
||||
<span className="task-error-message">
|
||||
{errorInfo?.error_message || detail.error_message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo?.failed_step && (
|
||||
<div className="task-error-row">
|
||||
<span className="task-error-label">失败阶段:</span>
|
||||
<span>{errorInfo.failed_step}</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo?.stack_trace && (
|
||||
<div className="task-error-row task-error-stack">
|
||||
<span className="task-error-label">堆栈信息:</span>
|
||||
<pre>{errorInfo.stack_trace}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
return (
|
||||
<div className="task-center">
|
||||
<div className="task-error">
|
||||
<CloseCircleOutlined />
|
||||
<p>加载任务列表失败</p>
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="task-center">
|
||||
{/* 页面标题 */}
|
||||
<div className="task-header">
|
||||
<h1 className="task-title">任务中心</h1>
|
||||
<p className="task-subtitle">查看和管理所有生成任务与素材导入任务</p>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="task-filters">
|
||||
{/* 状态 Tab */}
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as TaskStatus | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
}))}
|
||||
className="task-status-tabs"
|
||||
/>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div className="task-type-filter">
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(value) => {
|
||||
setTypeFilter(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={TYPE_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
placeholder="选择类型"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务表格 */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender,
|
||||
expandedRowKeys: expandedTaskId ? [expandedTaskId] : [],
|
||||
onExpand: handleExpand,
|
||||
rowExpandable: (record) =>
|
||||
record.status === "failed" &&
|
||||
(!!record.error_info || !!record.error_message),
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
className="task-table"
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="task-empty">
|
||||
<ClockCircleOutlined />
|
||||
<p>暂无任务记录</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/* ──────────── 任务中心 ──────────── */
|
||||
|
||||
.task-center {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 页面标题 */
|
||||
.task-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.task-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.task-filters {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-status-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-tab {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.task-type-filter {
|
||||
flex-shrink: 0;
|
||||
margin-left: var(--space-md);
|
||||
}
|
||||
|
||||
/* 表格 */
|
||||
.task-table {
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-table .ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-table .ant-table-thead > tr > th {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr > td {
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.task-table .ant-table-tbody > tr:hover > td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 任务 ID */
|
||||
.task-id {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.task-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
font-size: var(--font-size-xs);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 当前步骤 */
|
||||
.task-step {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 耗时 */
|
||||
.task-duration {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 时间 */
|
||||
.task-time {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.task-retry-btn {
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.task-retry-btn:hover {
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.task-action-placeholder {
|
||||
color: var(--text-disabled, var(--text-secondary));
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 展开行 - 错误详情 */
|
||||
.task-error-detail {
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.task-error-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.task-error-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-error-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.task-error-label {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.task-error-message {
|
||||
color: var(--error-500, #ef4444);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-error-stack {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-error-stack pre {
|
||||
margin: var(--space-xs) 0 0 0;
|
||||
padding: var(--space-sm);
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
overflow-x: auto;
|
||||
max-height: 200px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
}
|
||||
|
||||
.task-expand-empty {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.task-empty {
|
||||
padding: var(--space-2xl) 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.task-empty .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.task-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.task-error {
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
color: var(--error-500, #ef4444);
|
||||
}
|
||||
|
||||
.task-error .anticon {
|
||||
font-size: 48px;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.task-error p {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.task-center {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-type-filter {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-type-filter .ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-nav {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.task-status-tabs .ant-tabs-tab {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
@@ -1,106 +1,42 @@
|
||||
/**
|
||||
* 模板库页面(升级版)— V21 设计系统
|
||||
* 任务 2.13:模板类型分类展示、模板预览功能、创建 EditPlan 入口
|
||||
*
|
||||
* - 按 EditTemplate 类型分组展示
|
||||
* - 缩略图 + 预览弹窗
|
||||
* - 创建 EditPlan 入口 UI
|
||||
* - 使用 useQuery 对接后端真实 API(api/templates.ts, api/editPlans.ts)
|
||||
* 对接后端模板管理 API:
|
||||
* - 分页查询(page/page_size/category/keyword/duration_range)
|
||||
* - 模板详情(素材规则、字幕样式、BGM、比例等参数配置)
|
||||
* - 复制模板 / 从模板生成剪辑计划
|
||||
* - 卡片网格布局 + 类型筛选 + 搜索 + 收藏
|
||||
*/
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Button, message, Pagination, Tooltip, Tag, Descriptions } from "antd";
|
||||
import {
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
InboxOutlined,
|
||||
SearchOutlined,
|
||||
CopyOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getTemplates,
|
||||
getTemplate,
|
||||
toggleFavoriteTemplate,
|
||||
copyTemplate,
|
||||
generateFromTemplate,
|
||||
type TemplateItem,
|
||||
type TemplateListParams,
|
||||
type TemplateSegment,
|
||||
} from "@/api/templates";
|
||||
import { createEditPlan } from "@/api/editPlans";
|
||||
import "./templates.css";
|
||||
|
||||
/* ============================================================
|
||||
* 类型定义(对齐后端 EditTemplate / EditPlan / TemplateClipConfig)
|
||||
* 类型定义
|
||||
* ============================================================ */
|
||||
|
||||
/** 模板片段配置 */
|
||||
interface TemplateClipConfig {
|
||||
id: string;
|
||||
order: number;
|
||||
clipType: string;
|
||||
description: string;
|
||||
duration: number; // 秒
|
||||
}
|
||||
|
||||
/** 模板类型 */
|
||||
type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog";
|
||||
|
||||
/** 模板数据(UI 层,映射自后端 TemplateItem) */
|
||||
interface EditTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
type: EditTemplateType;
|
||||
description: string;
|
||||
usageCount: number;
|
||||
isFavorite: boolean;
|
||||
thumbnailGradient: string;
|
||||
scriptContent: string;
|
||||
clipConfigs: TemplateClipConfig[];
|
||||
recommendedDuration: number; // 秒
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 映射:后端 TemplateItem → 前端 EditTemplate
|
||||
* ============================================================ */
|
||||
|
||||
/** 根据 category 推断模板类型 */
|
||||
const inferTemplateType = (category: string): EditTemplateType => {
|
||||
const map: Record<string, EditTemplateType> = {
|
||||
口播: "口播",
|
||||
种草: "种草",
|
||||
产品: "产品",
|
||||
品牌: "品牌",
|
||||
混剪: "混剪",
|
||||
Vlog: "Vlog",
|
||||
};
|
||||
return map[category] ?? "口播";
|
||||
};
|
||||
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
};
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
};
|
||||
|
||||
/** 将后端 TemplateItem 映射为前端 EditTemplate */
|
||||
const mapTemplateItemToEditTemplate = (item: TemplateItem): EditTemplate => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: inferTemplateType(item.category),
|
||||
description: item.description ?? "",
|
||||
usageCount: 0,
|
||||
isFavorite: item.is_favorite ?? false,
|
||||
thumbnailGradient: gradientForCategory(item.category),
|
||||
scriptContent: "",
|
||||
clipConfigs: [],
|
||||
recommendedDuration: item.target_duration ?? 0,
|
||||
tags: [item.category],
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
* 模板类型配置
|
||||
* ============================================================ */
|
||||
@@ -120,98 +56,116 @@ const TEMPLATE_TYPES: Array<{
|
||||
{ type: "Vlog", label: "Vlog", icon: "📹", color: "#ec4899" },
|
||||
];
|
||||
|
||||
/** 时长筛选选项 */
|
||||
const DURATION_OPTIONS: Array<{
|
||||
value: "" | "short" | "medium" | "long";
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "", label: "全部时长" },
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
* 辅助函数
|
||||
* ============================================================ */
|
||||
|
||||
/** 获取类型对应颜色 */
|
||||
const getTypeColor = (type: EditTemplateType): string => {
|
||||
const getTypeColor = (type: string): string => {
|
||||
const found = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return found?.color ?? "#6366f1";
|
||||
};
|
||||
|
||||
/** 获取片段类型标签 */
|
||||
const getClipTypeLabel = (clipType: string): string => clipType;
|
||||
|
||||
/** 获取片段类型颜色 */
|
||||
const getClipTypeColor = (clipType: string): string => {
|
||||
const colorMap: Record<string, string> = {
|
||||
开场: "#6366f1",
|
||||
产品展示: "#0ea5e9",
|
||||
卖点讲解: "#10b981",
|
||||
结尾: "#f59e0b",
|
||||
场景引入: "#8b5cf6",
|
||||
产品体验: "#ec4899",
|
||||
效果对比: "#14b8a6",
|
||||
总结推荐: "#f59e0b",
|
||||
悬念开场: "#6366f1",
|
||||
产品全景: "#0ea5e9",
|
||||
功能演示: "#10b981",
|
||||
技术规格: "#64748b",
|
||||
品牌起源: "#f59e0b",
|
||||
发展历程: "#0ea5e9",
|
||||
核心理念: "#8b5cf6",
|
||||
未来展望: "#10b981",
|
||||
知识点1: "#6366f1",
|
||||
知识点2: "#8b5cf6",
|
||||
痛点: "#ef4444",
|
||||
产品引入: "#10b981",
|
||||
使用展示: "#0ea5e9",
|
||||
效果: "#f59e0b",
|
||||
产品亮相: "#6366f1",
|
||||
外观对比: "#0ea5e9",
|
||||
性能测试: "#10b981",
|
||||
总结: "#f59e0b",
|
||||
悬念: "#6366f1",
|
||||
亮点: "#10b981",
|
||||
福利: "#f59e0b",
|
||||
引导: "#0ea5e9",
|
||||
高能开场: "#ef4444",
|
||||
过渡: "#64748b",
|
||||
高潮: "#ec4899",
|
||||
早安: "#f59e0b",
|
||||
出门: "#10b981",
|
||||
日常: "#0ea5e9",
|
||||
晚安: "#8b5cf6",
|
||||
/** 根据 category 生成占位渐变色 */
|
||||
const gradientForCategory = (category: string): string => {
|
||||
const gradients: Record<string, string> = {
|
||||
口播: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
种草: "linear-gradient(135deg, #10b981, #059669)",
|
||||
产品: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||
品牌: "linear-gradient(135deg, #f59e0b, #d97706)",
|
||||
混剪: "linear-gradient(135deg, #8b5cf6, #6d28d9)",
|
||||
Vlog: "linear-gradient(135deg, #ec4899, #db2777)",
|
||||
};
|
||||
return colorMap[clipType] ?? "#6366f1";
|
||||
return gradients[category] ?? "linear-gradient(135deg, #6366f1, #8b5cf6)";
|
||||
};
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "0秒";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
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 parts: string[] = [];
|
||||
if (c.font_size) parts.push(`字号: ${c.font_size}`);
|
||||
if (c.font_family) parts.push(`字体: ${c.font_family}`);
|
||||
if (c.color) parts.push(`颜色: ${c.color}`);
|
||||
if (c.position) parts.push(`位置: ${c.position}`);
|
||||
if (c.volume !== undefined) parts.push(`音量: ${c.volume}%`);
|
||||
if (c.name) parts.push(String(c.name));
|
||||
return parts.length > 0 ? parts.join(" / ") : JSON.stringify(config);
|
||||
};
|
||||
|
||||
/** 素材类型标签 */
|
||||
const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
subtitle: "字幕",
|
||||
null: "不限",
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 预览弹窗组件
|
||||
* 模板详情弹窗组件
|
||||
* ============================================================ */
|
||||
|
||||
interface TemplatePreviewModalProps {
|
||||
template: EditTemplate;
|
||||
interface TemplateDetailModalProps {
|
||||
template: TemplateItem;
|
||||
isFavorite: boolean;
|
||||
onClose: () => void;
|
||||
onToggleFavorite: (id: string) => void;
|
||||
onUse: (template: EditTemplate) => void;
|
||||
onUse: (template: TemplateItem) => void;
|
||||
onCopy: (template: TemplateItem) => void;
|
||||
}
|
||||
|
||||
const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
|
||||
template,
|
||||
isFavorite,
|
||||
onClose,
|
||||
onToggleFavorite,
|
||||
onUse,
|
||||
onCopy,
|
||||
}) => {
|
||||
const totalDuration = template.clipConfigs.reduce(
|
||||
(sum, c) => sum + c.duration,
|
||||
const segments = template.segments ?? [];
|
||||
const totalSegmentDuration = segments.reduce(
|
||||
(sum, s) => sum + (s.duration_min + s.duration_max) / 2,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="xx-template-modal-overlay" onClick={onClose}>
|
||||
<div className="xx-template-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="xx-template-modal xx-template-modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<button
|
||||
className="xx-template-modal-close"
|
||||
@@ -224,15 +178,23 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
{/* 预览区域 */}
|
||||
<div
|
||||
className="xx-template-modal-preview"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon ??
|
||||
"📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-modal-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-template-modal-preview-content">
|
||||
<span className="xx-template-preview-icon">
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)
|
||||
?.icon ?? "📋"}
|
||||
</span>
|
||||
<span className="xx-template-preview-title">{template.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
@@ -243,12 +205,12 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
<span
|
||||
className="xx-template-modal-type-badge"
|
||||
style={{
|
||||
color: getTypeColor(template.type),
|
||||
background: `${getTypeColor(template.type)}18`,
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.type)?.icon}{" "}
|
||||
{template.type}
|
||||
{TEMPLATE_TYPES.find((t) => t.type === template.category)?.icon}{" "}
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -256,66 +218,128 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
<p className="xx-template-modal-desc">{template.description}</p>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 脚本内容 */}
|
||||
{template.scriptContent && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>📝 脚本内容</h4>
|
||||
<pre className="xx-template-modal-script">
|
||||
{template.scriptContent}
|
||||
</pre>
|
||||
{(template.tags?.length ?? 0) > 0 && (
|
||||
<div className="xx-template-modal-tags">
|
||||
{template.tags!.map((tag) => (
|
||||
<span key={tag} className="xx-template-modal-tag">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频结构 */}
|
||||
{template.clipConfigs.length > 0 && (
|
||||
{/* 基本信息 */}
|
||||
<Descriptions
|
||||
column={2}
|
||||
size="small"
|
||||
className="xx-template-modal-desc-table"
|
||||
items={[
|
||||
{
|
||||
key: "duration",
|
||||
label: "目标时长",
|
||||
children: formatDuration(template.target_duration),
|
||||
},
|
||||
{
|
||||
key: "clips",
|
||||
label: "片段数量",
|
||||
children: `${template.clip_count} 个`,
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
label: "视频比例",
|
||||
children: template.aspect_ratio ?? "16:9",
|
||||
},
|
||||
{
|
||||
key: "usage",
|
||||
label: "使用次数",
|
||||
children: `${template.usage_count ?? 0} 次`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 素材规则(片段配置) */}
|
||||
{segments.length > 0 && (
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎬 视频结构</h4>
|
||||
<h4>🎬 素材规则</h4>
|
||||
<div className="xx-template-modal-clip-list">
|
||||
{template.clipConfigs
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((clip) => (
|
||||
<div key={clip.id} className="xx-template-modal-clip-item">
|
||||
{segments
|
||||
.sort((a, b) => a.segment_order - b.segment_order)
|
||||
.map((seg: TemplateSegment, idx: number) => (
|
||||
<div
|
||||
key={seg.id ?? idx}
|
||||
className="xx-template-modal-clip-item"
|
||||
>
|
||||
<span className="xx-template-modal-clip-order">
|
||||
#{seg.segment_order}
|
||||
</span>
|
||||
<span
|
||||
className="xx-template-modal-clip-badge"
|
||||
style={{
|
||||
color: getClipTypeColor(clip.clipType),
|
||||
background: `${getClipTypeColor(clip.clipType)}18`,
|
||||
color: seg.material_type
|
||||
? getTypeColor(seg.material_type)
|
||||
: "#64748b",
|
||||
background: seg.material_type
|
||||
? `${getTypeColor(seg.material_type)}18`
|
||||
: "#f1f5f9",
|
||||
}}
|
||||
>
|
||||
{getClipTypeLabel(clip.clipType)}
|
||||
{MATERIAL_TYPE_LABELS[seg.material_type ?? "null"] ??
|
||||
seg.material_type ??
|
||||
"不限"}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-desc">
|
||||
{clip.description}
|
||||
</span>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{clip.duration}秒
|
||||
{seg.description || `片段 ${seg.segment_order}`}
|
||||
</span>
|
||||
<Tooltip
|
||||
title={`时长范围: ${seg.duration_min}秒 - ${seg.duration_max}秒`}
|
||||
>
|
||||
<span className="xx-template-modal-clip-duration">
|
||||
{seg.duration_min}-{seg.duration_max}秒
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xx-template-modal-total-duration">
|
||||
总时长:{formatDuration(totalDuration)}
|
||||
{template.recommendedDuration !== totalDuration && (
|
||||
<span>
|
||||
{" "}
|
||||
· 推荐时长:{formatDuration(template.recommendedDuration)}
|
||||
</span>
|
||||
)}
|
||||
预估总时长:{formatDuration(Math.round(totalSegmentDuration))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 样式配置 */}
|
||||
<div className="xx-template-modal-section">
|
||||
<h4>🎨 样式配置</h4>
|
||||
<div className="xx-template-modal-style-grid">
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">字幕样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.subtitle_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">标题样式</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.title_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">BGM 配置</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{formatConfig(template.bgm_config)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-modal-style-item">
|
||||
<span className="xx-template-modal-style-label">视频比例</span>
|
||||
<span className="xx-template-modal-style-value">
|
||||
{template.aspect_ratio ?? "16:9"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="xx-template-modal-stats">
|
||||
<span>已使用 {template.usageCount} 次</span>
|
||||
<span>已使用 {template.usage_count ?? 0} 次</span>
|
||||
<button
|
||||
className={`xx-template-modal-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={() => onToggleFavorite(template.id)}
|
||||
@@ -326,15 +350,15 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="xx-template-modal-actions">
|
||||
<Button buttonType="ghost" buttonSize="md" onClick={onClose}>
|
||||
取消
|
||||
<Button icon={<CopyOutlined />} onClick={() => onCopy(template)}>
|
||||
复制模板
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => onUse(template)}
|
||||
>
|
||||
使用此模板
|
||||
使用此模板生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,11 +372,11 @@ const TemplatePreviewModal: React.FC<TemplatePreviewModalProps> = ({
|
||||
* ============================================================ */
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: EditTemplate;
|
||||
template: TemplateItem;
|
||||
isFavorite: boolean;
|
||||
onPreview: (template: EditTemplate) => void;
|
||||
onPreview: (template: TemplateItem) => void;
|
||||
onToggleFavorite: (id: string, e: React.MouseEvent) => void;
|
||||
onUse: (template: EditTemplate) => void;
|
||||
onUse: (template: TemplateItem) => void;
|
||||
}
|
||||
|
||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
@@ -366,15 +390,29 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<div className="xx-template-card" onClick={() => onPreview(template)}>
|
||||
{/* 缩略图 */}
|
||||
<div className="xx-template-thumb">
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: template.thumbnailGradient }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}...
|
||||
</div>
|
||||
{template.thumbnail_url ? (
|
||||
<img
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
className="xx-template-thumb-img"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="xx-template-thumb-bg"
|
||||
style={{ background: gradientForCategory(template.category) }}
|
||||
>
|
||||
{(template.description ?? "").slice(0, 80)}
|
||||
{(template.description ?? "").length > 80 ? "..." : ""}
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-template-thumb-overlay" />
|
||||
<div className="xx-template-thumb-name">{template.name}</div>
|
||||
<div className="xx-template-preview-hint">点击预览</div>
|
||||
<div className="xx-template-thumb-meta">
|
||||
<span className="xx-template-thumb-duration">
|
||||
{formatDuration(template.target_duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-template-preview-hint">点击查看详情</div>
|
||||
<button
|
||||
className={`xx-template-fav-btn${isFavorite ? " is-favorite" : ""}`}
|
||||
onClick={(e) => onToggleFavorite(template.id, e)}
|
||||
@@ -390,17 +428,22 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
<span
|
||||
className="xx-template-category-pill"
|
||||
style={{
|
||||
color: getTypeColor(template.type),
|
||||
background: `${getTypeColor(template.type)}18`,
|
||||
color: getTypeColor(template.category),
|
||||
background: `${getTypeColor(template.category)}18`,
|
||||
}}
|
||||
>
|
||||
{template.type}
|
||||
{template.category}
|
||||
</span>
|
||||
{(template.tags ?? []).slice(0, 2).map((tag) => (
|
||||
<Tag key={tag} className="xx-template-tag-pill" bordered={false}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<p className="xx-template-desc">{template.description ?? ""}</p>
|
||||
<div className="xx-template-meta">
|
||||
<span className="xx-template-usage">
|
||||
已使用 {template.usageCount} 次
|
||||
已使用 {template.usage_count ?? 0} 次
|
||||
</span>
|
||||
<button
|
||||
className="xx-template-use-btn"
|
||||
@@ -424,98 +467,158 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
const TemplateLibrary: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 筛选状态
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [activeType, setActiveType] = useState<EditTemplateType | "全部">(
|
||||
"全部",
|
||||
);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<EditTemplate | null>(
|
||||
const [durationRange, setDurationRange] = useState<
|
||||
"" | "short" | "medium" | "long"
|
||||
>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(12);
|
||||
|
||||
// 弹窗状态
|
||||
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
|
||||
null,
|
||||
);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// ── 获取模板列表 ──
|
||||
// ── 构建查询参数 ──
|
||||
const queryParams: TemplateListParams = useMemo(() => {
|
||||
const params: TemplateListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
if (activeType !== "全部") params.category = activeType;
|
||||
if (searchText.trim()) params.keyword = searchText.trim();
|
||||
if (durationRange) params.duration_range = durationRange;
|
||||
return params;
|
||||
}, [page, pageSize, activeType, searchText, durationRange]);
|
||||
|
||||
// ── 获取模板列表(后端分页 + 筛选) ──
|
||||
const {
|
||||
data: apiTemplates = [],
|
||||
data: templateData,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<TemplateItem[], Error>({
|
||||
queryKey: ["templates"],
|
||||
queryFn: getTemplates,
|
||||
staleTime: 60_000,
|
||||
} = useQuery({
|
||||
queryKey: ["templates", queryParams],
|
||||
queryFn: () => getTemplates(queryParams),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// 将后端数据映射为前端 EditTemplate
|
||||
const templates = useMemo(
|
||||
() => apiTemplates.map(mapTemplateItemToEditTemplate),
|
||||
[apiTemplates],
|
||||
);
|
||||
const templates = templateData?.items ?? [];
|
||||
const totalTemplates = templateData?.total ?? 0;
|
||||
|
||||
// ── 收藏 mutation ──
|
||||
const favMutation = useMutation({
|
||||
mutationFn: toggleFavoriteTemplate,
|
||||
onSuccess: (_data, templateId) => {
|
||||
// 乐观更新:刷新模板列表
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
// 同时更新当前预览(如果有)
|
||||
if (previewTemplate && previewTemplate.id === templateId) {
|
||||
setPreviewTemplate((prev) =>
|
||||
prev ? { ...prev, isFavorite: !prev.isFavorite } : prev,
|
||||
prev ? { ...prev, is_favorite: !prev.is_favorite } : prev,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── 创建 EditPlan mutation ──
|
||||
const createPlanMutation = useMutation({
|
||||
mutationFn: (params: { template_id: string; name: string }) =>
|
||||
createEditPlan(params),
|
||||
// ── 复制模板 mutation ──
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: copyTemplate,
|
||||
onSuccess: (data) => {
|
||||
message.success(`模板「${data.name}」已复制到「我的模板」`);
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制模板失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
// ── 从模板生成剪辑计划 mutation ──
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
|
||||
generateFromTemplate(templateId, { name }),
|
||||
onSuccess: (data) => {
|
||||
message.success(`剪辑计划「${data.name}」已创建`);
|
||||
navigate("/app/edit-plans");
|
||||
},
|
||||
onError: () => {
|
||||
message.error("生成剪辑计划失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
/** 切换收藏 */
|
||||
const toggleFavorite = (id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
favMutation.mutate(id);
|
||||
};
|
||||
const toggleFavorite = useCallback(
|
||||
(id: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
favMutation.mutate(id);
|
||||
},
|
||||
[favMutation],
|
||||
);
|
||||
|
||||
/** 过滤模板 */
|
||||
const filtered = useMemo(() => {
|
||||
return templates.filter((t) => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description ?? "")
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()) ||
|
||||
t.tags.some((tag) =>
|
||||
tag.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
const matchType = activeType === "全部" || t.type === activeType;
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
}, [searchText, activeType, templates]);
|
||||
|
||||
/** 按类型分组 */
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const groups: Record<string, EditTemplate[]> = {};
|
||||
for (const tpl of filtered) {
|
||||
if (!groups[tpl.type]) groups[tpl.type] = [];
|
||||
groups[tpl.type].push(tpl);
|
||||
}
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
/** 使用模板 → 创建 EditPlan */
|
||||
const handleUseTemplate = async (template: EditTemplate) => {
|
||||
/** 点击卡片 → 获取详情并展示弹窗 */
|
||||
const handlePreview = useCallback(async (template: TemplateItem) => {
|
||||
setDetailLoading(true);
|
||||
setPreviewTemplate(template);
|
||||
try {
|
||||
await createPlanMutation.mutateAsync({
|
||||
template_id: template.id,
|
||||
const detail = await getTemplate(template.id);
|
||||
setPreviewTemplate(detail);
|
||||
} catch {
|
||||
// 详情加载失败时使用列表数据
|
||||
message.warning("模板详情加载失败,显示摘要信息");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 复制模板 */
|
||||
const handleCopy = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
copyMutation.mutate(template.id);
|
||||
},
|
||||
[copyMutation],
|
||||
);
|
||||
|
||||
/** 使用模板 → 生成剪辑计划 */
|
||||
const handleUse = useCallback(
|
||||
(template: TemplateItem) => {
|
||||
generateMutation.mutate({
|
||||
templateId: template.id,
|
||||
name: `基于「${template.name}」的剪辑计划`,
|
||||
});
|
||||
navigate("/app/editing-planner");
|
||||
} catch (err) {
|
||||
console.error("[TemplateLibrary] createEditPlan failed:", err);
|
||||
}
|
||||
};
|
||||
},
|
||||
[generateMutation, navigate],
|
||||
);
|
||||
|
||||
/** 搜索防抖处理 */
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchText(e.target.value);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 切换分类 */
|
||||
const handleCategoryChange = useCallback(
|
||||
(type: EditTemplateType | "全部") => {
|
||||
setActiveType(type);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 切换时长筛选 */
|
||||
const handleDurationChange = useCallback(
|
||||
(value: "" | "short" | "medium" | "long") => {
|
||||
setDurationRange(value);
|
||||
setPage(1);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Loading 状态 ──
|
||||
if (isLoading) {
|
||||
@@ -554,16 +657,12 @@ const TemplateLibrary: React.FC = () => {
|
||||
<h2>模板库</h2>
|
||||
<p>选择模板快速创建剪辑计划,支持自定义修改</p>
|
||||
</div>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/editing-planner")}
|
||||
>
|
||||
<Button type="primary" onClick={() => navigate("/app/editing-planner")}>
|
||||
+ 创建模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 ─────────────────────────── */}
|
||||
{/* ── 工具栏:搜索 + 类型按钮组 + 时长筛选 ─────────────── */}
|
||||
<div className="xx-templates-toolbar">
|
||||
<div className="xx-templates-search">
|
||||
<span className="xx-templates-search-icon">
|
||||
@@ -574,7 +673,7 @@ const TemplateLibrary: React.FC = () => {
|
||||
type="text"
|
||||
placeholder="搜索模板名称、描述或标签..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-templates-categories">
|
||||
@@ -582,90 +681,94 @@ const TemplateLibrary: React.FC = () => {
|
||||
<button
|
||||
key={cat.type}
|
||||
className={`xx-templates-cat-btn${activeType === cat.type ? " active" : ""}`}
|
||||
onClick={() => setActiveType(cat.type)}
|
||||
onClick={() => handleCategoryChange(cat.type)}
|
||||
>
|
||||
<span className="xx-templates-cat-icon">{cat.icon}</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 时长筛选 */}
|
||||
<div className="xx-templates-duration-filter">
|
||||
{DURATION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={`xx-templates-duration-btn${durationRange === opt.value ? " active" : ""}`}
|
||||
onClick={() => handleDurationChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 模板展示区 ────────────────────────────────────────── */}
|
||||
{filtered.length === 0 ? (
|
||||
{templates.length === 0 ? (
|
||||
<div className="xx-templates-empty">
|
||||
<div className="xx-templates-empty-icon">
|
||||
<InboxOutlined />
|
||||
</div>
|
||||
<h3>
|
||||
{searchText || activeType !== "全部"
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
? "未找到匹配的模板"
|
||||
: "暂无模板"}
|
||||
</h3>
|
||||
<p>
|
||||
{searchText || activeType !== "全部"
|
||||
{searchText || activeType !== "全部" || durationRange
|
||||
? "试试调整搜索条件或切换类型"
|
||||
: "点击上方「创建模板」开始创作"}
|
||||
</p>
|
||||
</div>
|
||||
) : activeType === "全部" ? (
|
||||
/* 全部类型 → 按类型分组展示 */
|
||||
<div className="xx-templates-grouped">
|
||||
{Object.entries(groupedTemplates).map(([type, tpls]) => {
|
||||
const typeConfig = TEMPLATE_TYPES.find((t) => t.type === type);
|
||||
return (
|
||||
<div key={type} className="xx-templates-group">
|
||||
<div className="xx-templates-group-header">
|
||||
<span className="xx-templates-group-icon">
|
||||
{typeConfig?.icon ?? "📋"}
|
||||
</span>
|
||||
<h3>{type}</h3>
|
||||
<span className="xx-templates-group-count">
|
||||
{tpls.length} 个模板
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-templates-grid">
|
||||
{tpls.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* 单类型 → 平铺网格 */
|
||||
<div className="xx-templates-grid">
|
||||
{filtered.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.isFavorite}
|
||||
onPreview={setPreviewTemplate}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="xx-templates-grid">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
isFavorite={tpl.is_favorite ?? false}
|
||||
onPreview={handlePreview}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUse}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalTemplates > pageSize && (
|
||||
<div className="xx-templates-pagination">
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={totalTemplates}
|
||||
showSizeChanger={false}
|
||||
showQuickJumper
|
||||
showTotal={(total) => `共 ${total} 个模板`}
|
||||
onChange={(p) => setPage(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 预览弹窗 ──────────────────────────────────────────── */}
|
||||
{/* ── 详情弹窗 ──────────────────────────────────────────── */}
|
||||
{previewTemplate && (
|
||||
<TemplatePreviewModal
|
||||
<TemplateDetailModal
|
||||
template={previewTemplate}
|
||||
isFavorite={previewTemplate.isFavorite}
|
||||
isFavorite={previewTemplate.is_favorite ?? false}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onUse={handleUseTemplate}
|
||||
onUse={handleUse}
|
||||
onCopy={handleCopy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情加载中的提示(可选覆盖层) */}
|
||||
{detailLoading && previewTemplate && (
|
||||
<div className="xx-template-detail-loading">
|
||||
<LoadingOutlined /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -130,12 +130,20 @@ 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;
|
||||
}): Record<string, unknown> => ({
|
||||
}): VoiceAssetMetadata => ({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration: data.duration || 0,
|
||||
|
||||
@@ -623,12 +623,20 @@ 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;
|
||||
}): Record<string, unknown> => {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
}): VoiceUploadMetadata => {
|
||||
const metadata: VoiceUploadMetadata = {};
|
||||
if (data.gender) metadata.gender = data.gender;
|
||||
if (data.description) metadata.description = data.description;
|
||||
if (data.duration) metadata.duration = Math.round(data.duration);
|
||||
|
||||
@@ -135,6 +135,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
lazy: () =>
|
||||
import("@/pages/tasks/TaskCenter").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: () =>
|
||||
@@ -149,6 +156,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "edit-plans",
|
||||
lazy: () =>
|
||||
import("@/pages/edit-plans/EditPlans").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
"""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()
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
"""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())
|
||||
Executable
+313
@@ -0,0 +1,313 @@
|
||||
"""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
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
"""绿幕抠像引擎 — 基于 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
|
||||
+416
@@ -0,0 +1,416 @@
|
||||
"""滤镜调色引擎 — 基于 FFmpeg eq + colorbalance + hue + curves 滤镜组合实现画面色彩调整.
|
||||
|
||||
支持能力:
|
||||
- 基础调色参数:亮度、对比度、饱和度、色温、色调
|
||||
- 8种风格预设:清新、日系、复古、电影、胶片、黑白、暖色、冷色
|
||||
- 分段应用:每个 clip 可独立设置不同滤镜
|
||||
- 降级策略:参数越界自动钳制,不阻断渲染
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 预设滤镜包 ────────────────────────────────────────────────────────────────
|
||||
|
||||
# 预设名称常量
|
||||
PRESET_FRESH = "fresh" # 清新
|
||||
PRESET_JAPANESE = "japanese" # 日系
|
||||
PRESET_VINTAGE = "vintage" # 复古
|
||||
PRESET_CINEMA = "cinema" # 电影
|
||||
PRESET_FILM = "film" # 胶片
|
||||
PRESET_BW = "black_white" # 黑白
|
||||
PRESET_WARM = "warm" # 暖色
|
||||
PRESET_COOL = "cool" # 冷色
|
||||
|
||||
VALID_PRESETS = {
|
||||
PRESET_FRESH,
|
||||
PRESET_JAPANESE,
|
||||
PRESET_VINTAGE,
|
||||
PRESET_CINEMA,
|
||||
PRESET_FILM,
|
||||
PRESET_BW,
|
||||
PRESET_WARM,
|
||||
PRESET_COOL,
|
||||
}
|
||||
|
||||
# 预设名称 → 中文显示名
|
||||
PRESET_DISPLAY_NAMES = {
|
||||
PRESET_FRESH: "清新",
|
||||
PRESET_JAPANESE: "日系",
|
||||
PRESET_VINTAGE: "复古",
|
||||
PRESET_CINEMA: "电影",
|
||||
PRESET_FILM: "胶片",
|
||||
PRESET_BW: "黑白",
|
||||
PRESET_WARM: "暖色",
|
||||
PRESET_COOL: "冷色",
|
||||
}
|
||||
|
||||
# 预设参数配置
|
||||
# 每个预设包含:brightness, contrast, saturation, temperature, hue
|
||||
# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180
|
||||
PRESET_PARAMS: dict[str, dict[str, float]] = {
|
||||
PRESET_FRESH: {
|
||||
# 清新:提亮、高饱和、偏冷、微微调
|
||||
"brightness": 8,
|
||||
"contrast": 10,
|
||||
"saturation": 120,
|
||||
"temperature": -8,
|
||||
"hue": 5,
|
||||
},
|
||||
PRESET_JAPANESE: {
|
||||
# 日系:低对比、低饱和、偏暖、偏黄绿
|
||||
"brightness": 12,
|
||||
"contrast": -15,
|
||||
"saturation": 70,
|
||||
"temperature": 10,
|
||||
"hue": -5,
|
||||
},
|
||||
PRESET_VINTAGE: {
|
||||
# 复古:低饱和、偏黄、对比度适中、偏暖
|
||||
"brightness": -5,
|
||||
"contrast": 5,
|
||||
"saturation": 60,
|
||||
"temperature": 25,
|
||||
"hue": -8,
|
||||
},
|
||||
PRESET_CINEMA: {
|
||||
# 电影:高对比、低饱和、偏冷蓝、暗角感
|
||||
"brightness": -8,
|
||||
"contrast": 20,
|
||||
"saturation": 75,
|
||||
"temperature": -15,
|
||||
"hue": -3,
|
||||
},
|
||||
PRESET_FILM: {
|
||||
# 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟)
|
||||
"brightness": -3,
|
||||
"contrast": 12,
|
||||
"saturation": 95,
|
||||
"temperature": 15,
|
||||
"hue": -2,
|
||||
},
|
||||
PRESET_BW: {
|
||||
# 黑白:饱和度为0,对比度略高
|
||||
"brightness": 0,
|
||||
"contrast": 15,
|
||||
"saturation": 0,
|
||||
"temperature": 0,
|
||||
"hue": 0,
|
||||
},
|
||||
PRESET_WARM: {
|
||||
# 暖色:高色温、偏红黄
|
||||
"brightness": 5,
|
||||
"contrast": 8,
|
||||
"saturation": 110,
|
||||
"temperature": 30,
|
||||
"hue": -5,
|
||||
},
|
||||
PRESET_COOL: {
|
||||
# 冷色:低色温、偏蓝青
|
||||
"brightness": 3,
|
||||
"contrast": 8,
|
||||
"saturation": 105,
|
||||
"temperature": -25,
|
||||
"hue": 8,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 参数范围 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
PARAM_RANGES = {
|
||||
"brightness": (-100.0, 100.0),
|
||||
"contrast": (-100.0, 100.0),
|
||||
"saturation": (0.0, 200.0),
|
||||
"temperature": (-100.0, 100.0),
|
||||
"hue": (-180.0, 180.0),
|
||||
}
|
||||
|
||||
# 默认值(零调整)
|
||||
DEFAULT_PARAMS = {
|
||||
"brightness": 0.0,
|
||||
"contrast": 0.0,
|
||||
"saturation": 100.0,
|
||||
"temperature": 0.0,
|
||||
"hue": 0.0,
|
||||
}
|
||||
|
||||
|
||||
# ── 数据模型 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorGradeConfig:
|
||||
"""色彩调色配置.
|
||||
|
||||
优先级:自定义参数 > 预设参数
|
||||
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
preset: str = "" # 预设名称,空表示不使用预设
|
||||
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
|
||||
brightness: float | None = None
|
||||
contrast: float | None = None
|
||||
saturation: float | None = None
|
||||
temperature: float | None = None
|
||||
hue: float | None = None
|
||||
|
||||
def resolve_params(self) -> dict[str, float]:
|
||||
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
|
||||
|
||||
Returns:
|
||||
包含 brightness, contrast, saturation, temperature, hue 的参数字典
|
||||
"""
|
||||
# 1. 从默认值开始
|
||||
params = dict(DEFAULT_PARAMS)
|
||||
|
||||
# 2. 应用预设
|
||||
if self.preset and self.preset in PRESET_PARAMS:
|
||||
params.update(PRESET_PARAMS[self.preset])
|
||||
|
||||
# 3. 应用自定义覆盖
|
||||
if self.brightness is not None:
|
||||
params["brightness"] = self.brightness
|
||||
if self.contrast is not None:
|
||||
params["contrast"] = self.contrast
|
||||
if self.saturation is not None:
|
||||
params["saturation"] = self.saturation
|
||||
if self.temperature is not None:
|
||||
params["temperature"] = self.temperature
|
||||
if self.hue is not None:
|
||||
params["hue"] = self.hue
|
||||
|
||||
# 4. 边界钳制
|
||||
for key, (min_val, max_val) in PARAM_RANGES.items():
|
||||
params[key] = max(min_val, min(max_val, params[key]))
|
||||
|
||||
return params
|
||||
|
||||
def has_effect(self) -> bool:
|
||||
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
|
||||
|
||||
用于优化:无效果时跳过滤镜,不浪费性能。
|
||||
"""
|
||||
params = self.resolve_params()
|
||||
for key, default in DEFAULT_PARAMS.items():
|
||||
if abs(params[key] - default) > 0.001:
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
|
||||
"""从字典解析配置."""
|
||||
if not data or not data.get("enabled", False):
|
||||
return cls(enabled=False)
|
||||
|
||||
preset = data.get("preset", "")
|
||||
if preset and preset not in VALID_PRESETS:
|
||||
logger.warning("未知的调色预设: %s,忽略预设", preset)
|
||||
preset = ""
|
||||
|
||||
def _get_float(key: str) -> float | None:
|
||||
val = data.get(key)
|
||||
if val is None:
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
try:
|
||||
return cls(
|
||||
enabled=True,
|
||||
preset=preset,
|
||||
brightness=_get_float("brightness"),
|
||||
contrast=_get_float("contrast"),
|
||||
saturation=_get_float("saturation"),
|
||||
temperature=_get_float("temperature"),
|
||||
hue=_get_float("hue"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("调色配置解析失败: %s,使用默认配置", e)
|
||||
return cls(enabled=False)
|
||||
|
||||
|
||||
# ── 调色引擎 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ColorGradeEngine:
|
||||
"""滤镜调色引擎 — 生成 FFmpeg 调色滤镜链.
|
||||
|
||||
滤镜组合策略:
|
||||
1. eq 滤镜:调整亮度(brightness)、对比度(contrast)、饱和度(saturation)
|
||||
2. colorbalance 滤镜:调整色温(通过调整红/青、黄/蓝平衡)
|
||||
3. hue 滤镜:调整色调
|
||||
|
||||
所有参数转换公式:
|
||||
- brightness: 用户值 -100~100 → FFmpeg eq brightness -1.0~1.0
|
||||
- contrast: 用户值 -100~100 → FFmpeg eq contrast -1000~1000(非线性映射)
|
||||
- saturation: 用户值 0~200 → FFmpeg eq saturation 0.0~2.0
|
||||
- temperature: 用户值 -100~100 → colorbalance 红/蓝通道偏移
|
||||
- hue: 用户值 -180~180 → FFmpeg hue H -180~180(度)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _map_brightness(value: float) -> float:
|
||||
"""用户亮度值 → FFmpeg eq brightness.
|
||||
|
||||
用户范围 -100~100 → FFmpeg范围 -1.0~1.0
|
||||
"""
|
||||
return value / 100.0
|
||||
|
||||
@staticmethod
|
||||
def _map_contrast(value: float) -> float:
|
||||
"""用户对比度值 → FFmpeg eq contrast.
|
||||
|
||||
用户范围 -100~100 → FFmpeg范围 -2.0~2.0
|
||||
注:FFmpeg eq 的 contrast 公式为 linear gain,1.0 为原始
|
||||
-2 ~ 2 的范围对应 ~-1000 ~ 1000 的老式定义的约 -66% ~ +100%
|
||||
"""
|
||||
if value >= 0:
|
||||
# 正向:0~100 → 1.0~2.0
|
||||
return 1.0 + value / 100.0
|
||||
else:
|
||||
# 负向:-100~0 → 0.0~1.0
|
||||
return 1.0 + value / 100.0 # value为负数,相当于 1.0 - |value|/100
|
||||
|
||||
@staticmethod
|
||||
def _map_saturation(value: float) -> float:
|
||||
"""用户饱和度 → FFmpeg eq saturation.
|
||||
|
||||
用户范围 0~200 → FFmpeg范围 0.0~2.0
|
||||
"""
|
||||
return value / 100.0
|
||||
|
||||
@staticmethod
|
||||
def _map_temperature(value: float) -> tuple[float, float, float]:
|
||||
"""用户色温值 → colorbalance 三个通道参数.
|
||||
|
||||
返回:(red, green, blue) — 每个通道 -1.0~1.0 的偏移
|
||||
|
||||
色温为正(暖):增加红、减蓝
|
||||
色温为负(冷):减红、加蓝
|
||||
"""
|
||||
# -100~100 → -0.5~0.5
|
||||
normalized = value / 200.0
|
||||
|
||||
if normalized >= 0:
|
||||
# 暖色调:红+,绿微+,蓝-
|
||||
red = normalized * 0.8
|
||||
green = normalized * 0.3
|
||||
blue = -normalized * 0.8
|
||||
else:
|
||||
# 冷色调:红-,绿微+,蓝+
|
||||
red = normalized * 0.8 # 负数
|
||||
green = -normalized * 0.2 # 正数(冷色也加点绿让它偏青)
|
||||
blue = -normalized * 0.8 # 正数
|
||||
|
||||
return (red, green, blue)
|
||||
|
||||
@staticmethod
|
||||
def _map_hue(value: float) -> float:
|
||||
"""用户色调值 → FFmpeg hue滤镜角度.
|
||||
|
||||
用户范围 -180~180 → FFmpeg H -180~180
|
||||
"""
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def build_filter(cls, config: ColorGradeConfig, input_label: str = "", output_label: str = "") -> str:
|
||||
"""构建调色滤镜字符串.
|
||||
|
||||
Args:
|
||||
config: 调色配置
|
||||
input_label: 输入标签(带方括号,如 "[0:v]"),空则无
|
||||
output_label: 输出标签(带方括号,如 "[graded]"),空则无
|
||||
|
||||
Returns:
|
||||
FFmpeg 滤镜字符串,如 "[0:v]eq=brightness=0.1:contrast=1.2,hue=H=10[graded]"
|
||||
"""
|
||||
if not config.enabled or not config.has_effect():
|
||||
# 无效果时直通
|
||||
if input_label and output_label:
|
||||
return f"{input_label}copy{output_label}"
|
||||
return ""
|
||||
|
||||
params = config.resolve_params()
|
||||
filters: list[str] = []
|
||||
|
||||
# 1. eq 滤镜:亮度 + 对比度 + 饱和度
|
||||
eq_parts: list[str] = []
|
||||
brightness = cls._map_brightness(params["brightness"])
|
||||
contrast = cls._map_contrast(params["contrast"])
|
||||
saturation = cls._map_saturation(params["saturation"])
|
||||
|
||||
if abs(brightness) > 0.001:
|
||||
eq_parts.append(f"brightness={brightness:.3f}")
|
||||
if abs(contrast - 1.0) > 0.001:
|
||||
eq_parts.append(f"contrast={contrast:.3f}")
|
||||
if abs(saturation - 1.0) > 0.001:
|
||||
eq_parts.append(f"saturation={saturation:.3f}")
|
||||
|
||||
if eq_parts:
|
||||
filters.append(f"eq={':'.join(eq_parts)}")
|
||||
|
||||
# 2. colorbalance 滤镜:色温
|
||||
if abs(params["temperature"]) > 0.001:
|
||||
red, green, blue = cls._map_temperature(params["temperature"])
|
||||
cb_parts = []
|
||||
# 调整阴影/中间调/高光的平衡(简化:全部统一调整)
|
||||
if abs(red) > 0.001:
|
||||
cb_parts.append(f"rs={red:.3f}")
|
||||
cb_parts.append(f"rm={red:.3f}")
|
||||
cb_parts.append(f"rh={red:.3f}")
|
||||
if abs(green) > 0.001:
|
||||
cb_parts.append(f"gs={green:.3f}")
|
||||
cb_parts.append(f"gm={green:.3f}")
|
||||
cb_parts.append(f"gh={green:.3f}")
|
||||
if abs(blue) > 0.001:
|
||||
cb_parts.append(f"bs={blue:.3f}")
|
||||
cb_parts.append(f"bm={blue:.3f}")
|
||||
cb_parts.append(f"bh={blue:.3f}")
|
||||
if cb_parts:
|
||||
filters.append(f"colorbalance={':'.join(cb_parts)}")
|
||||
|
||||
# 3. hue 滤镜:色调
|
||||
if abs(params["hue"]) > 0.001:
|
||||
hue_val = cls._map_hue(params["hue"])
|
||||
filters.append(f"hue=h={hue_val:.1f}")
|
||||
|
||||
if not filters:
|
||||
# 理论上不会到这里(has_effect 已判断),保险起见
|
||||
if input_label and output_label:
|
||||
return f"{input_label}copy{output_label}"
|
||||
return ""
|
||||
|
||||
filter_str = ",".join(filters)
|
||||
if input_label:
|
||||
filter_str = f"{input_label}{filter_str}"
|
||||
if output_label:
|
||||
filter_str = f"{filter_str}{output_label}"
|
||||
|
||||
return filter_str
|
||||
|
||||
|
||||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_preset_names() -> list[tuple[str, str]]:
|
||||
"""获取所有预设名称列表.
|
||||
|
||||
Returns:
|
||||
[(preset_key, display_name), ...]
|
||||
"""
|
||||
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
|
||||
|
||||
|
||||
def get_preset_params(preset: str) -> dict[str, float] | None:
|
||||
"""获取指定预设的参数."""
|
||||
return PRESET_PARAMS.get(preset)
|
||||
+697
@@ -0,0 +1,697 @@
|
||||
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
|
||||
|
||||
基于 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
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
"""视频封面生成器 — 从视频中提取/生成封面图.
|
||||
|
||||
支持能力:
|
||||
- 指定时间点抽帧(默认第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
|
||||
Regular → Executable
+13
@@ -75,6 +75,19 @@ 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,23 +1,28 @@
|
||||
"""FFmpeg 工具函数 — 共享原语.
|
||||
"""FFmpeg 工具函数 — Worker 层.
|
||||
|
||||
提供 FFmpeg / FFprobe 调用、视频信息探测、视频标准化、xfade 转场滤镜构建
|
||||
等底层能力,供 UnifiedRenderService、VideoComposeService 等复用。
|
||||
业务相关的滤镜构建、视频探测、视频标准化等能力放在这里;
|
||||
底层原语(run_ffmpeg / 二进制路径 / 默认超时)已下沉到 packages/shared/ffmpeg_utils.py,
|
||||
本模块 re-export 保持向后兼容。
|
||||
"""
|
||||
|
||||
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__)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||||
# ── 常量(Worker 层业务相关) ────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
@@ -25,8 +30,14 @@ 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",
|
||||
@@ -35,41 +46,49 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
"slide_up": "slideup",
|
||||
"slidedown": "slidedown",
|
||||
"slide_down": "slidedown",
|
||||
"dissolve": "dissolve",
|
||||
"wipe": "wipeleft",
|
||||
"slide": "slideleft", # 默认向左滑
|
||||
# 缩放
|
||||
"zoom": "zoomin",
|
||||
"zoomin": "zoomin",
|
||||
"zoomout": "zoomout",
|
||||
# 擦除系列
|
||||
"wipe": "wipeleft", # 默认向左擦
|
||||
"wipeleft": "wipeleft",
|
||||
"wiperight": "wiperight",
|
||||
"wipeup": "wipeup",
|
||||
"wipedown": "wipedown",
|
||||
# 特殊效果
|
||||
"circlecrop": "circlecrop",
|
||||
"circle": "circlecrop",
|
||||
"rectcrop": "rectcrop",
|
||||
"rect": "rectcrop",
|
||||
}
|
||||
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
|
||||
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
|
||||
DEFAULT_FFMPEG_TIMEOUT = 1800
|
||||
|
||||
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_ffmpeg(
|
||||
def run_ffprobe(
|
||||
command: list[str],
|
||||
*,
|
||||
capture_output: bool = True,
|
||||
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
|
||||
timeout: int = 30,
|
||||
) -> tuple[str, str]:
|
||||
"""执行 FFmpeg 命令。
|
||||
"""执行 FFprobe 命令。
|
||||
|
||||
Args:
|
||||
command: 完整的 ffmpeg 命令列表(含 "ffmpeg" 本身)
|
||||
command: 完整的 ffprobe 命令列表(含 "ffprobe" 本身)
|
||||
capture_output: 是否捕获 stdout/stderr
|
||||
timeout: 超时时间(秒),默认 1800s(30分钟);None 表示不设超时(不推荐)
|
||||
timeout: 超时时间(秒),默认 30s;None 表示不设超时
|
||||
|
||||
Returns:
|
||||
(stdout, stderr) 元组
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出,
|
||||
异常信息包含完整 stderr 以便排查。
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出,FFmpeg 进程会被 kill。
|
||||
subprocess.CalledProcessError: 命令执行失败时抛出
|
||||
subprocess.TimeoutExpired: 超时未完成时抛出
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run( # nosec B603
|
||||
@@ -83,19 +102,18 @@ def run_ffmpeg(
|
||||
return (result.stdout or "", result.stderr or "")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(
|
||||
"FFmpeg 命令超时 (%ds): command=%s",
|
||||
"FFprobe 命令超时 (%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(
|
||||
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
"FFprobe 命令失败: exit_code=%d command=%s\nstderr:\n%s",
|
||||
e.returncode,
|
||||
" ".join(str(c) for c in command[:20]), # 截断过长的命令
|
||||
stderr_text[:5000], # 截断过长的 stderr
|
||||
" ".join(str(c) for c in command[:20]),
|
||||
stderr_text[:5000],
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
"""片头片尾引擎 — 视频包装与品牌标识.
|
||||
|
||||
支持:
|
||||
- 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题)
|
||||
- 片尾:视频片段 或 关注引导片尾
|
||||
- 自动与正片拼接(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
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
"""多轨道混音引擎 — 支持多路音频独立音量调节与混合.
|
||||
|
||||
基于 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)
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
"""音频降噪引擎 — 基于 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,25 +220,64 @@ 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 位为文件名,避免重复下载。
|
||||
"""
|
||||
# 1. 本地绝对路径
|
||||
if asset_id.startswith("/") and os.path.exists(asset_id):
|
||||
return Path(asset_id)
|
||||
|
||||
# 2. 缓存命中
|
||||
安全:
|
||||
- 本地绝对路径必须在 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. 本地绝对路径 — 必须在允许的目录内
|
||||
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
|
||||
|
||||
# 2. 缓存命中(使用 hash 而非原始 ID,防止路径遍历)
|
||||
cache_hash = hashlib.sha256(asset_id.encode()).hexdigest()[:16]
|
||||
cached_path = work_dir / f"{cache_hash}.mp4"
|
||||
safe_name = sanitize_filename(cache_hash)
|
||||
cached_path = work_dir / f"{safe_name}.mp4"
|
||||
if cached_path.exists() and cached_path.stat().st_size > 0:
|
||||
return cached_path
|
||||
|
||||
# 3. 从 OSS 下载
|
||||
if download_asset(asset_id, 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):
|
||||
return cached_path
|
||||
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""路径安全校验工具 — 路径遍历防护.
|
||||
|
||||
统一的文件路径安全校验方案,覆盖所有渲染管线中的路径处理场景:
|
||||
- 本地素材路径校验
|
||||
- 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
|
||||
Executable
+509
@@ -0,0 +1,509 @@
|
||||
"""画中画(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
|
||||
Regular → Executable
+214
-26
@@ -22,6 +22,8 @@ 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
|
||||
@@ -35,6 +37,8 @@ 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)
|
||||
|
||||
@@ -70,6 +74,10 @@ 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:
|
||||
"""音频后处理混音.
|
||||
|
||||
@@ -79,11 +87,17 @@ 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
|
||||
@@ -116,6 +130,15 @@ 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
|
||||
|
||||
# 构建音频处理命令
|
||||
@@ -124,11 +147,83 @@ def mix_audio(
|
||||
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
|
||||
if main_clips and not audio_clips:
|
||||
concat_main_audio(ctx, main_clips, output_path, video_duration)
|
||||
return output_path
|
||||
else:
|
||||
# 有独立音频轨 → amix 混音
|
||||
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
|
||||
|
||||
# 有独立音频轨 → 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
|
||||
|
||||
|
||||
def concat_main_audio(
|
||||
@@ -145,40 +240,130 @@ def concat_main_audio(
|
||||
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
|
||||
clip = clips[0]
|
||||
effective_duration = clip_effective_duration(clip)
|
||||
# 最终时长:取 clip 有效时长和视频总时长的较小值
|
||||
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
|
||||
final_duration = effective_duration
|
||||
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
|
||||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||||
final_duration = video_duration
|
||||
|
||||
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)
|
||||
# 音频倒放
|
||||
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)
|
||||
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:
|
||||
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
|
||||
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)
|
||||
else:
|
||||
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[a{i}]")
|
||||
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}]")
|
||||
|
||||
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]")
|
||||
@@ -236,9 +421,11 @@ 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=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
f"[{input_idx}:a]atrim=start={trim_start:.3f}:duration={effective_duration:.3f},"
|
||||
f"asetpts=PTS-STARTPTS[ma{input_idx}]"
|
||||
)
|
||||
else:
|
||||
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
|
||||
@@ -255,11 +442,12 @@ 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=0:{effective_duration:.3f}")
|
||||
filters.append(f"atrim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||||
filters.append("asetpts=PTS-STARTPTS")
|
||||
if volume != 1.0:
|
||||
filters.append(f"volume={volume}")
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
"""视频倒放引擎 — 基于 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"
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
"""视频调速引擎 — 基于 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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user