diff --git a/.env.example b/.env.example index dc55a01ae..3bcd412c9 100755 --- a/.env.example +++ b/.env.example @@ -42,3 +42,11 @@ OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com OSS_ACCESS_KEY_ID=your-access-key-id OSS_ACCESS_KEY_SECRET=your-access-key-secret OSS_BUCKET_NAME=xiaoxia-autocut + +# ==================== CosyVoice 语音合成配置 ==================== +COSYVOICE_API_KEY=your-cosyvoice-api-key +COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio +COSYVOICE_MODEL=cosyvoice-v1 +COSYVOICE_VOICE=longxiaochun +COSYVOICE_SAMPLE_RATE=22050 +COSYVOICE_FORMAT=mp3 diff --git a/.env.production.example b/.env.production.example index 833bbbdc8..6082c3926 100644 --- a/.env.production.example +++ b/.env.production.example @@ -41,6 +41,14 @@ OSS_BUCKET_NAME=xiaoxia-autocut OSS_DIRECT_UPLOAD_MAX_MB=2000 OSS_DIRECT_UPLOAD_EXPIRE_SECONDS=900 +# ==================== CosyVoice 语音合成(必须配置)==================== +COSYVOICE_API_KEY=CHANGE_ME_COSYVOICE_API_KEY +COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio +COSYVOICE_MODEL=cosyvoice-v1 +COSYVOICE_VOICE=longxiaochun +COSYVOICE_SAMPLE_RATE=22050 +COSYVOICE_FORMAT=mp3 + # ==================== 生成文件 ==================== GENERATED_FILES_DIR=/app/generated GENERATED_FILES_URL_PREFIX=/generated-files diff --git a/alembic/versions/016_phase8_edit_template_plan.py b/alembic/versions/016_phase8_edit_template_plan.py new file mode 100644 index 000000000..0c83a8f7a --- /dev/null +++ b/alembic/versions/016_phase8_edit_template_plan.py @@ -0,0 +1,115 @@ +"""phase8 edit template plan + +Revision ID: 016 +Revises: 015 +Create Date: 2026-07-01 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "016" +down_revision = "015" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- edit_templates: 替换为 Phase 8 新 schema --- + # 删除旧列 + op.drop_column("edit_templates", "project_id") + op.drop_column("edit_templates", "target_duration") + op.drop_column("edit_templates", "clip_count") + op.drop_column("edit_templates", "is_active") + op.drop_column("edit_templates", "created_by_user_id") + op.drop_column("edit_templates", "metadata") + + # 添加新列 + op.add_column( + "edit_templates", + sa.Column("template_type", sa.String(50), nullable=False, server_default="default"), + ) + op.add_column( + "edit_templates", + sa.Column("config", sa.JSON(), nullable=False, server_default="{}"), + ) + op.add_column( + "edit_templates", + sa.Column("preview_url", sa.String(1000), nullable=False, server_default=""), + ) + op.add_column( + "edit_templates", + sa.Column("sort_weight", sa.Integer(), nullable=False, server_default="0"), + ) + op.add_column( + "edit_templates", + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + ) + + # 添加索引 + op.create_index("ix_edit_templates_template_type", "edit_templates", ["template_type"]) + op.create_index("ix_edit_templates_sort_weight", "edit_templates", ["sort_weight"]) + op.create_index("ix_edit_templates_status", "edit_templates", ["status"]) + + # --- edit_plans: 重建表(在 011 中被删除) --- + op.create_table( + "edit_plans", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("template_id", sa.String(32), nullable=False, index=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="draft", index=True), + sa.Column("total_duration", sa.Float(), nullable=False, server_default="0"), + sa.Column("config", sa.JSON(), nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("edit_plans") + + op.drop_index("ix_edit_templates_status", "edit_templates") + op.drop_index("ix_edit_templates_sort_weight", "edit_templates") + op.drop_index("ix_edit_templates_template_type", "edit_templates") + + op.drop_column("edit_templates", "status") + op.drop_column("edit_templates", "sort_weight") + op.drop_column("edit_templates", "preview_url") + op.drop_column("edit_templates", "config") + op.drop_column("edit_templates", "template_type") + + # 恢复旧列 + op.add_column( + "edit_templates", + sa.Column("project_id", sa.String(32), nullable=False, server_default=""), + ) + op.add_column( + "edit_templates", + sa.Column("target_duration", sa.Float(), nullable=False, server_default="30"), + ) + op.add_column( + "edit_templates", + sa.Column("clip_count", sa.Integer(), nullable=False, server_default="3"), + ) + op.add_column( + "edit_templates", + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "edit_templates", + sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""), + ) + op.add_column( + "edit_templates", + sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"), + ) diff --git a/alembic/versions/017_phase8_clip_config_plan_clip.py b/alembic/versions/017_phase8_clip_config_plan_clip.py new file mode 100644 index 000000000..054327d2b --- /dev/null +++ b/alembic/versions/017_phase8_clip_config_plan_clip.py @@ -0,0 +1,81 @@ +"""Phase 8: Create template_clip_configs and edit_plan_clips tables + +Revision ID: 017 +Revises: 016 +Create Date: 2026-07-01 + +新增两张表: +- template_clip_configs: 模板片段配置(定义模板中每个片段的规则) +- edit_plan_clips: 剪辑计划片段(剪辑计划中的具体片段实例) +""" + +from alembic import op +import sqlalchemy as sa + +revision = "017" +down_revision = "016" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # template_clip_configs: 模板片段配置表 + op.create_table( + "template_clip_configs", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("template_id", sa.String(32), nullable=False, index=True), + sa.Column("clip_type", sa.String(20), nullable=False, index=True), + sa.Column("order", sa.Integer, nullable=False), + sa.Column("min_duration", sa.Float, nullable=False, server_default="0.0"), + sa.Column("max_duration", sa.Float, nullable=False, server_default="0.0"), + sa.Column("text_template", sa.Text, nullable=False, server_default=""), + sa.Column("material_requirements", sa.JSON, nullable=False, server_default="{}"), + sa.Column("transition_effect", sa.String(20), nullable=False, server_default="cut"), + sa.Column("config", sa.JSON, nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + ) + + # edit_plan_clips: 剪辑计划片段表 + op.create_table( + "edit_plan_clips", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("plan_id", sa.String(32), nullable=False, index=True), + sa.Column("clip_type", sa.String(20), nullable=False, index=True), + sa.Column("order", sa.Integer, nullable=False), + sa.Column("template_clip_config_id", sa.String(32), nullable=False, server_default="", index=True), + sa.Column("asset_id", sa.String(32), nullable=False, server_default="", index=True), + sa.Column("text_content", sa.Text, nullable=False, server_default=""), + sa.Column("start_time", sa.Float, nullable=False, server_default="0.0"), + sa.Column("duration", sa.Float, nullable=False, server_default="0.0"), + sa.Column("transition_effect", sa.String(20), nullable=False, server_default="cut"), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("config", sa.JSON, nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("edit_plan_clips") + op.drop_table("template_clip_configs") diff --git a/alembic/versions/018_add_jobs_table.py b/alembic/versions/018_add_jobs_table.py new file mode 100755 index 000000000..6a58b14a8 --- /dev/null +++ b/alembic/versions/018_add_jobs_table.py @@ -0,0 +1,54 @@ +"""Phase 8 任务 2.10: Create jobs table for unified async task management + +Revision ID: 018 +Revises: 017 +Create Date: 2026-07-01 + +新增 jobs 表,用于统一管理异步任务(视频合成、渲染等)的生命周期。 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "018" +down_revision = "017" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "jobs", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("project_id", sa.String(32), nullable=False, index=True), + sa.Column("job_type", sa.String(30), nullable=False, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("progress", sa.Float, nullable=False, server_default="0.0"), + sa.Column("current_stage", sa.String(200), nullable=False, server_default=""), + sa.Column("payload", sa.JSON, nullable=False, server_default="{}"), + sa.Column("result", sa.JSON, nullable=False, server_default="{}"), + sa.Column("error_message", sa.Text, nullable=False, server_default=""), + sa.Column("retry_count", sa.Integer, nullable=False, server_default="0"), + sa.Column("max_retries", sa.Integer, nullable=False, server_default="3"), + sa.Column("celery_task_id", sa.String(100), nullable=False, server_default=""), + sa.Column("source_id", sa.String(32), nullable=False, server_default="", index=True), + sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default="", index=True), + sa.Column("started_at", sa.DateTime, nullable=True), + sa.Column("completed_at", sa.DateTime, nullable=True), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("jobs") diff --git a/alembic/versions/019_add_voice_clone_profiles_table.py b/alembic/versions/019_add_voice_clone_profiles_table.py new file mode 100644 index 000000000..0d2c6d342 --- /dev/null +++ b/alembic/versions/019_add_voice_clone_profiles_table.py @@ -0,0 +1,52 @@ +"""Task 3.05: Create voice_clone_profiles table + +Revision ID: 019 +Revises: 018 +Create Date: 2026-07-02 + +新增 voice_clone_profiles 表,用于存储音色克隆档案。 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "019" +down_revision = "018" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "voice_clone_profiles", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False, index=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.Text(), nullable=False, server_default=""), + sa.Column("source_audio_url", sa.Text(), nullable=False, server_default=""), + sa.Column("voice_id", sa.String(100), nullable=False, server_default=""), + sa.Column("voice_model", sa.String(100), nullable=False, server_default=""), + sa.Column("language", sa.String(20), nullable=False, server_default="zh-CN"), + sa.Column("gender", sa.String(20), nullable=False, server_default="unknown"), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("error_message", sa.Text(), nullable=False, server_default=""), + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"), + sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("voice_clone_profiles") diff --git a/alembic/versions/020_add_tts_jobs_table.py b/alembic/versions/020_add_tts_jobs_table.py new file mode 100644 index 000000000..5b854e955 --- /dev/null +++ b/alembic/versions/020_add_tts_jobs_table.py @@ -0,0 +1,58 @@ +"""Task 3.06: Create tts_jobs table + +Revision ID: 020 +Revises: 019 +Create Date: 2026-07-02 + +新增 tts_jobs 表,用于存储 TTS 合成任务。 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "020" +down_revision = "019" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "tts_jobs", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), nullable=False, index=True), + sa.Column("input_text", sa.Text(), nullable=False), + sa.Column("voice_id", sa.String(100), nullable=False, server_default=""), + sa.Column("voice_model", sa.String(100), nullable=False, server_default=""), + sa.Column("project_id", sa.String(36), nullable=False, server_default=""), + sa.Column("voice_clone_profile_id", sa.String(36), nullable=False, server_default=""), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("output_audio_url", sa.Text(), nullable=False, server_default=""), + sa.Column("output_audio_key", sa.String(500), nullable=False, server_default=""), + sa.Column("duration", sa.Float(), nullable=False, server_default="0"), + sa.Column("file_size", sa.Integer(), nullable=False, server_default="0"), + sa.Column("sample_rate", sa.Integer(), nullable=False, server_default="22050"), + sa.Column("format", sa.String(20), nullable=False, server_default="mp3"), + sa.Column("error_message", sa.Text(), nullable=False, server_default=""), + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("max_retries", sa.Integer(), nullable=False, server_default="3"), + sa.Column("metadata", sa.JSON(), nullable=False, server_default="{}"), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.func.now(), + ), + ) + + +def downgrade() -> None: + op.drop_table("tts_jobs") diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py old mode 100644 new mode 100755 index 74e0653d6..39a41ba47 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -6,9 +6,12 @@ from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.dashboard import router as dashboard_router from app.api.routes.duplication import router as duplication_router +from app.api.routes.edit_plans import router as edit_plans_router +from app.api.routes.edit_templates import router as edit_templates_router from app.api.routes.generated_videos import router as generated_videos_router from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.health import router as health_check_router +from app.api.routes.jobs import router as jobs_router from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.projects import router as projects_router from app.api.routes.recipes import router as recipes_router @@ -17,6 +20,8 @@ from app.api.routes.task_center import router as task_center_router from app.api.routes.templates import router as templates_router from app.api.routes.titles import router as titles_router from app.api.routes.upload import router as upload_router +from app.api.routes.tts import router as tts_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 @@ -76,6 +81,10 @@ api_router.include_router( prefix="/generation", tags=["Generation"], ) +api_router.include_router( + jobs_router, + tags=["Job"], +) api_router.include_router( generated_videos_router, prefix="/generated-videos", @@ -91,6 +100,11 @@ api_router.include_router( prefix="/voices", tags=["VoiceLibrary"], ) +api_router.include_router( + voice_clones_router, + prefix="/voice-clones", + tags=["VoiceClone"], +) api_router.include_router( duplication_router, prefix="/duplication", @@ -116,3 +130,18 @@ api_router.include_router( prefix="/dashboard", tags=["Dashboard"], ) +api_router.include_router( + edit_templates_router, + prefix="/edit-templates", + tags=["EditTemplate"], +) +api_router.include_router( + edit_plans_router, + prefix="/edit-plans", + tags=["EditPlan"], +) +api_router.include_router( + tts_router, + prefix="/tts", + tags=["TTS"], +) diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index 39c94649f..e494a1328 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -15,7 +15,7 @@ from app.schemas.duplication import ( DuplicationRecordResponse, DuplicationUploadResponse, ) -from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from packages.application import ( DeleteDuplicationRecordUseCase, @@ -29,7 +29,9 @@ from packages.domain.duplication import DuplicationRecord logger = logging.getLogger(__name__) -router = APIRouter() +router = APIRouter( + tags=["查重"], +) # 查重功能只接受视频文件 ALLOWED_VIDEO_MIME_TYPES = frozenset( @@ -199,12 +201,19 @@ async def upload_for_duplication( @router.get("/records", response_model=list[DuplicationRecordResponse]) def list_duplication_records( + offset: int = Query(0, ge=0, description="分页偏移量"), + limit: int = Query(50, ge=1, le=200, description="每页数量,最大 200"), authenticated_user: AuthenticatedUser = Depends(get_current_user), duplication_repository: Any = Depends(get_duplication_repository), ) -> list[DuplicationRecordResponse]: - """获取当前用户的查重记录列表。""" + """ + 获取当前用户的查重记录列表。 + + 支持分页:通过 offset 和 limit 参数控制。 + 返回按创建时间倒序排列的记录。 + """ use_case = ListDuplicationRecordsUseCase(duplication_repository) - records = use_case.execute(authenticated_user.user.id) + records = use_case.execute(user_id=authenticated_user.user.id, offset=offset, limit=limit) return [_to_record_response(r) for r in records] @@ -257,7 +266,11 @@ def retry_duplication( authenticated_user: AuthenticatedUser = Depends(get_current_user), duplication_repository: Any = Depends(get_duplication_repository), ) -> DuplicationUploadResponse: - """重新提交查重。""" + """ + 重新提交查重。 + + 仅 failed 状态的记录允许重试,其他状态返回 400。 + """ # 检查记录存在且属于当前用户 detail_uc = GetDuplicationDetailUseCase(duplication_repository) record = detail_uc.execute(record_id) @@ -268,7 +281,13 @@ def retry_duplication( ) use_case = RetryDuplicationUseCase(duplication_repository) - updated = use_case.execute(record_id) + try: + updated = use_case.execute(record_id) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) if updated is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py new file mode 100644 index 000000000..5b074fce4 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans.py @@ -0,0 +1,412 @@ +"""剪辑计划管理 API — Phase 8 模板编排引擎. + +RESTful CRUD for EditPlan: +- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选) +- GET /api/v1/edit-plans/{id} 详情 +- POST /api/v1/edit-plans 创建 +- PUT /api/v1/edit-plans/{id} 更新(含状态机流转) +- DELETE /api/v1/edit-plans/{id} 删除 +- POST /api/v1/edit-plans/{id}/generate 触发剪辑渲染生成(任务 2.05) +- GET /api/v1/edit-plans/{id}/generation-status 查询生成进度(任务 2.05) + +业务逻辑委托给 EditPlanService 服务层。 +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, List, Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.core.celery_app import celery_app +from app.dependencies import get_db_session +from app.services import EditPlanService +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.generation_task_repository import ( + SQLAlchemyGenerationTaskRepository, +) +from packages.application.generation_tasks import ( + CreateGenerationTaskCommand, + CreateGenerationTaskUseCase, +) +from packages.domain.edit_plan import EditPlan, EditPlanStatus + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Pydantic Schemas ───────────────────────────────────────────────────────── + + +class EditPlanCreateRequest(BaseModel): + """创建剪辑计划请求体""" + + template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID") + name: str = Field(..., min_length=1, max_length=200, description="计划名称") + config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)") + total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)") + + +class EditPlanUpdateRequest(BaseModel): + """更新剪辑计划请求体""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称") + config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)") + total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)") + status: Optional[str] = Field( + default=None, + description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft", + ) + + +class EditPlanResponse(BaseModel): + """剪辑计划响应体""" + + id: str + template_id: str + name: str + status: str + total_duration: float + config: dict[str, Any] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class EditPlanListResponse(BaseModel): + """剪辑计划列表响应体""" + + items: List[EditPlanResponse] + total: int + page: int + page_size: int + + +class ClipStatusItem(BaseModel): + """片段生成状态""" + + clip_id: str + clip_type: str + order: int + status: str + asset_id: str + text_content: str + duration: float + + +class EditPlanGenerationStatusResponse(BaseModel): + """剪辑计划生成进度响应体""" + + plan_id: str + plan_status: str + generation_task_id: Optional[str] = None + clips: List[ClipStatusItem] + + +class EditPlanGenerateResponse(BaseModel): + """剪辑计划触发生成响应体""" + + plan_id: str + plan_status: str + generation_task_id: str + clip_count: int + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _to_response(p: EditPlan) -> EditPlanResponse: + return EditPlanResponse( + id=p.id, + template_id=p.template_id, + name=p.name, + status=p.status.value if hasattr(p.status, "value") else p.status, + total_duration=p.total_duration, + config=p.config, + created_at=p.created_at, + updated_at=p.updated_at, + ) + + +# ── Routes ──────────────────────────────────────────────────────────────────── + + +@router.get("", response_model=EditPlanListResponse) +def list_plans( + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), + template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"), + status_filter: Optional[str] = Query( + default=None, + alias="status", + description="按状态筛选: draft / editing / rendering / completed / failed", + ), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanListResponse: + """获取剪辑计划列表(支持分页、按模板/状态筛选)""" + svc = EditPlanService(db) + + # 解析状态筛选 + status_enum: Optional[EditPlanStatus] = None + if status_filter: + try: + status_enum = EditPlanStatus(status_filter) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"无效的状态值: {status_filter}," + f"可选值: draft, editing, rendering, completed, failed" + ), + ) + + skip = (page - 1) * page_size + plans = svc.list_plans( + template_id=template_id, + status=status_enum, + skip=skip, + limit=page_size, + ) + total = svc.count_plans( + template_id=template_id, + status=status_enum, + ) + + return EditPlanListResponse( + items=[_to_response(p) for p in plans], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{plan_id}", response_model=EditPlanResponse) +def get_plan( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanResponse: + """获取单个剪辑计划详情""" + svc = EditPlanService(db) + try: + plan = svc.get_plan_or_raise(plan_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) + return _to_response(plan) + + +@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED) +def create_plan( + body: EditPlanCreateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanResponse: + """创建剪辑计划""" + svc = EditPlanService(db) + try: + created = svc.create_plan( + template_id=body.template_id, + name=body.name, + config=body.config, + total_duration=body.total_duration, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + logger.info( + "创建剪辑计划: id=%s name=%s by user=%s", + created.id, + created.name, + current_user.user.id, + ) + return _to_response(created) + + +@router.put("/{plan_id}", response_model=EditPlanResponse) +def update_plan( + plan_id: str, + body: EditPlanUpdateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanResponse: + """更新剪辑计划(支持状态机流转)""" + svc = EditPlanService(db) + + # 基础字段更新 + try: + if body.name is not None or body.config is not None or body.total_duration is not None: + svc.update_plan( + plan_id, + name=body.name, + config=body.config, + total_duration=body.total_duration, + ) + + # 状态机流转 + if body.status is not None: + try: + target_status = EditPlanStatus(body.status) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"无效的状态值: {body.status}," + f"可选值: draft, editing, rendering, completed, failed" + ), + ) + svc.transition_status(plan_id, target_status) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + # 返回最新状态 + result = svc.get_plan_or_raise(plan_id) + logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id) + return _to_response(result) + + +@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_plan( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> None: + """删除剪辑计划""" + svc = EditPlanService(db) + deleted = svc.delete_plan(plan_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + logger.info( + "删除剪辑计划: id=%s by user=%s", + plan_id, + current_user.user.id, + ) + + +# ── 生成相关端点(任务 2.05) ───────────────────────────────────────────────── + + +@router.post("/{plan_id}/generate", response_model=EditPlanGenerateResponse) +def generate_plan( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanGenerateResponse: + """触发剪辑计划渲染生成 + + 前置条件:计划状态必须为 editing,且至少有一个片段。 + 流程: + 1. 验证计划状态为 editing + 2. 将 pending 片段标记为 ready + 3. 创建 GenerationTask + 4. 调度 Celery 任务 worker.render_edit_plan + 5. 将计划状态流转为 rendering + """ + svc = EditPlanService(db) + + # 检查是否可生成 + can_gen, reason = svc.can_generate(plan_id) + if not can_gen: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=reason, + ) + + # 将 pending 片段标记为 ready + clip_count = svc.mark_clips_ready(plan_id) + + # 创建 GenerationTask + gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo) + plan = svc.get_plan_or_raise(plan_id) + gen_task = gen_task_use_case.execute( + CreateGenerationTaskCommand( + project_id="", + template_id=plan.template_id, + created_by_user_id=current_user.user.id, + ) + ) + + # 将 generation_task_id 存入 plan config + svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id}) + + # 流转状态为 rendering + svc.transition_status(plan_id, EditPlanStatus.RENDERING) + + # 调度 Celery 任务 + celery_app.send_task("worker.render_edit_plan", args=[plan_id]) + + # 获取最新状态 + updated_plan = svc.get_plan_or_raise(plan_id) + + logger.info( + "触发剪辑计划生成: plan_id=%s gen_task_id=%s clips=%d by user=%s", + plan_id, + gen_task.id, + clip_count, + current_user.user.id, + ) + + return EditPlanGenerateResponse( + plan_id=plan_id, + plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status, + generation_task_id=gen_task.id, + clip_count=clip_count, + ) + + +@router.get( + "/{plan_id}/generation-status", + response_model=EditPlanGenerationStatusResponse, +) +def get_generation_status( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanGenerationStatusResponse: + """查询剪辑计划生成进度 + + 返回计划状态、关联的 GenerationTask ID、以及每个片段的状态。 + """ + svc = EditPlanService(db) + gen_status = svc.get_generation_status(plan_id) + + plan = gen_status["plan"] + clips = gen_status["clips"] + + clip_items = [ + ClipStatusItem( + clip_id=c.id, + clip_type=c.clip_type, + order=c.order, + status=c.status.value if hasattr(c.status, "value") else c.status, + asset_id=c.asset_id or "", + text_content=c.text_content or "", + duration=c.duration, + ) + for c in clips + ] + + return EditPlanGenerationStatusResponse( + plan_id=plan_id, + plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status, + generation_task_id=gen_status["generation_task_id"], + clips=clip_items, + ) diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py new file mode 100644 index 000000000..c331a23c4 --- /dev/null +++ b/apps/api/app/api/routes/edit_templates.py @@ -0,0 +1,253 @@ +"""模板管理 API — Phase 8 模板编排引擎. + +RESTful CRUD for EditTemplate: +- GET /api/v1/edit-templates 列表(分页 + 类型筛选) +- GET /api/v1/edit-templates/{id} 详情 +- POST /api/v1/edit-templates 创建(管理员) +- PUT /api/v1/edit-templates/{id} 更新 +- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive) + +业务逻辑委托给 EditTemplateService 服务层。 +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, List, Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from app.services import EditTemplateService +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from packages.domain.edit_template import EditTemplate, EditTemplateStatus + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Pydantic Schemas ───────────────────────────────────────────────────────── + + +class EditTemplateCreateRequest(BaseModel): + """创建模板请求体""" + + name: str = Field(..., min_length=1, max_length=200, description="模板名称") + description: str = Field(default="", max_length=2000, description="模板描述") + template_type: str = Field(default="default", max_length=50, description="模板类型") + config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)") + preview_url: str = Field(default="", max_length=500, description="预览地址") + sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重") + + +class EditTemplateUpdateRequest(BaseModel): + """更新模板请求体""" + + name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称") + description: Optional[str] = Field(default=None, max_length=2000, description="模板描述") + template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型") + config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)") + preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址") + sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重") + status: Optional[str] = Field(default=None, description="状态: active / inactive") + + +class EditTemplateResponse(BaseModel): + """模板响应体""" + + id: str + name: str + description: str + template_type: str + config: dict[str, Any] + preview_url: str + sort_weight: int + status: str + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class EditTemplateListResponse(BaseModel): + """模板列表响应体""" + + items: List[EditTemplateResponse] + total: int + page: int + page_size: int + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _to_response(t: EditTemplate) -> EditTemplateResponse: + return EditTemplateResponse( + id=t.id, + name=t.name, + description=t.description, + template_type=t.template_type, + config=t.config, + preview_url=t.preview_url, + sort_weight=t.sort_weight, + status=t.status.value if hasattr(t.status, "value") else t.status, + created_at=t.created_at, + updated_at=t.updated_at, + ) + + +# ── Routes ──────────────────────────────────────────────────────────────────── + + +@router.get("", response_model=EditTemplateListResponse) +def list_templates( + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), + template_type: Optional[str] = Query(default=None, description="按类型筛选"), + status_filter: Optional[str] = Query( + default=None, + alias="status", + description="按状态筛选: active / inactive", + ), + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditTemplateListResponse: + """获取模板列表(支持分页、按类型/状态筛选)""" + svc = EditTemplateService(db) + + # 解析状态筛选 + status_enum: Optional[EditTemplateStatus] = None + if status_filter: + try: + status_enum = EditTemplateStatus(status_filter) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无效的状态值: {status_filter},可选值: active, inactive", + ) + + skip = (page - 1) * page_size + templates = svc.list_templates( + template_type=template_type, + status=status_enum, + skip=skip, + limit=page_size, + ) + total = svc.count_templates( + template_type=template_type, + status=status_enum, + ) + + return EditTemplateListResponse( + items=[_to_response(t) for t in templates], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{template_id}", response_model=EditTemplateResponse) +def get_template( + template_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditTemplateResponse: + """获取单个模板详情""" + svc = EditTemplateService(db) + try: + template = svc.get_template_or_raise(template_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) + return _to_response(template) + + +@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED) +def create_template( + body: EditTemplateCreateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditTemplateResponse: + """创建模板(管理员)""" + svc = EditTemplateService(db) + try: + created = svc.create_template( + name=body.name, + description=body.description, + template_type=body.template_type, + config=body.config, + preview_url=body.preview_url, + sort_weight=body.sort_weight, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id) + return _to_response(created) + + +@router.put("/{template_id}", response_model=EditTemplateResponse) +def update_template( + template_id: str, + body: EditTemplateUpdateRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditTemplateResponse: + """更新模板""" + svc = EditTemplateService(db) + + # 解析状态 + status_enum: Optional[EditTemplateStatus] = None + if body.status is not None: + try: + status_enum = EditTemplateStatus(body.status) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无效的状态值: {body.status},可选值: active, inactive", + ) + + try: + result = svc.update_template( + template_id, + name=body.name, + description=body.description, + template_type=body.template_type, + config=body.config, + preview_url=body.preview_url, + sort_weight=body.sort_weight, + status=status_enum, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id) + return _to_response(result) + + +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_template( + template_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> None: + """删除模板(软删除 → 设为 inactive)""" + svc = EditTemplateService(db) + try: + svc.deactivate_template(template_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) + logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id) diff --git a/apps/api/app/api/routes/jobs.py b/apps/api/app/api/routes/jobs.py new file mode 100755 index 000000000..84b72163f --- /dev/null +++ b/apps/api/app/api/routes/jobs.py @@ -0,0 +1,334 @@ +"""Job API 路由 — Phase 8 任务 2.10. + +提供统一异步任务管理 RESTful 接口: +- POST /api/v1/jobs 创建任务 +- GET /api/v1/jobs/{job_id} 任务详情 +- GET /api/v1/projects/{project_id}/jobs 项目任务列表 +- GET /api/v1/projects/{project_id}/jobs/stats 任务统计 +- PUT /api/v1/jobs/{job_id}/progress 更新进度 +- POST /api/v1/jobs/{job_id}/complete 标记完成 +- POST /api/v1/jobs/{job_id}/fail 标记失败 +- POST /api/v1/jobs/{job_id}/retry 重试任务 +- POST /api/v1/jobs/{job_id}/cancel 取消任务 +- POST /api/v1/jobs/{job_id}/submit 提交执行 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.auth import AuthenticatedUser, get_current_user +from app.core.celery_app import celery_app +from app.dependencies import get_db_session, get_job_repository, get_project_repository +from app.schemas.job import ( + CompleteJobRequest, + CreateJobRequest, + FailJobRequest, + JobResponse, + JobStatisticsResponse, + ListJobsResponse, + UpdateProgressRequest, + job_to_response, +) +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from packages.application.jobs import ( + CancelJobUseCase, + CompleteJobCommand, + CompleteJobUseCase, + CreateJobCommand, + CreateJobUseCase, + FailJobCommand, + FailJobUseCase, + GetJobStatisticsUseCase, + GetJobUseCase, + ListJobsUseCase, + RetryJobUseCase, + SubmitJobUseCase, + UpdateJobProgressCommand, + UpdateJobProgressUseCase, +) +from packages.domain.job import JobType + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# 任务类型 → Celery task name 映射 +_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = { + JobType.VIDEO_COMPOSE: "worker.compose_video", + JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan", + JobType.ASSET_INGEST: "worker.ingest_asset", + JobType.CLASSIFICATION: "worker.classify_asset", + JobType.VOICE_EXTRACTION: "worker.extract_voice", + JobType.GENERATION: "worker.generate_video", +} + + +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限。""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=403, detail="Access denied to project") + + +# ── 创建任务 ────────────────────────────────────────────────────────────────── + + +@router.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED) +def create_job( + request: CreateJobRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), + project_repository: Any = Depends(get_project_repository), +) -> JobResponse: + """创建异步任务。 + + 创建后任务处于 pending 状态,需要调用 /submit 提交执行。 + """ + _check_project_access(request.project_id, authenticated_user.user.id, project_repository) + + # 校验 job_type + try: + JobType(request.job_type) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"不支持的任务类型: {request.job_type}," + f"可选值: {[t.value for t in JobType]}", + ) + + use_case = CreateJobUseCase(job_repo) + job = use_case.execute( + CreateJobCommand( + project_id=request.project_id, + job_type=request.job_type, + payload=request.payload, + source_id=request.source_id, + created_by_user_id=authenticated_user.user.id, + max_retries=request.max_retries, + ) + ) + + return job_to_response(job) + + +# ── 提交执行 ────────────────────────────────────────────────────────────────── + + +@router.post("/jobs/{job_id}/submit", response_model=JobResponse) +def submit_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """提交任务执行。 + + 将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。 + """ + # 权限检查:先获取任务并验证权限,再执行状态变更 + job = job_repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="Access denied to this job") + + use_case = SubmitJobUseCase(job_repo) + + try: + job = use_case.execute(job_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Dispatch Celery 任务 + celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value) + if celery_task_name: + result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload) + job.celery_task_id = result.id + job_repo.update(job) + logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id) + + return job_to_response(job) + + +# ── 查询接口 ────────────────────────────────────────────────────────────────── + + +@router.get("/jobs/{job_id}", response_model=JobResponse) +def get_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """获取任务详情。""" + use_case = GetJobUseCase(job_repo) + job = use_case.execute(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + return job_to_response(job) + + +@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse) +def list_project_jobs( + project_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), + project_repository: Any = Depends(get_project_repository), + job_type: str | None = Query(default=None, description="按任务类型过滤"), + status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> ListJobsResponse: + """获取项目下的任务列表。""" + _check_project_access(project_id, authenticated_user.user.id, project_repository) + + use_case = ListJobsUseCase(job_repo) + jobs = use_case.execute( + project_id=project_id, + job_type=job_type, + status=status_filter, + limit=limit, + offset=offset, + ) + items = [job_to_response(j) for j in jobs] + return ListJobsResponse(items=items, total=len(items)) + + +@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse) +def get_job_statistics( + project_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), + project_repository: Any = Depends(get_project_repository), +) -> JobStatisticsResponse: + """获取项目任务统计摘要。""" + _check_project_access(project_id, authenticated_user.user.id, project_repository) + + use_case = GetJobStatisticsUseCase(job_repo) + stats = use_case.execute(project_id) + return JobStatisticsResponse(**stats) + + +# ── 进度更新 ────────────────────────────────────────────────────────────────── + + +@router.put("/jobs/{job_id}/progress", response_model=JobResponse) +def update_job_progress( + job_id: str, + request: UpdateProgressRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """更新任务进度。""" + use_case = UpdateJobProgressUseCase(job_repo) + + try: + job = use_case.execute( + UpdateJobProgressCommand( + job_id=job_id, + progress=request.progress, + current_stage=request.current_stage, + ) + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return job_to_response(job) + + +# ── 完成 / 失败 ──────────────────────────────────────────────────────────────── + + +@router.post("/jobs/{job_id}/complete", response_model=JobResponse) +def complete_job( + job_id: str, + request: CompleteJobRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """标记任务完成。""" + use_case = CompleteJobUseCase(job_repo) + + try: + job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return job_to_response(job) + + +@router.post("/jobs/{job_id}/fail", response_model=JobResponse) +def fail_job( + job_id: str, + request: FailJobRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """标记任务失败。""" + use_case = FailJobUseCase(job_repo) + + try: + job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return job_to_response(job) + + +# ── 重试 / 取消 ──────────────────────────────────────────────────────────────── + + +@router.post("/jobs/{job_id}/retry", response_model=JobResponse) +def retry_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """重试失败任务。 + + 将任务重置为 pending,retry_count + 1,但不自动 dispatch。 + 需要再次调用 /submit 提交执行。 + """ + # 权限检查:先获取任务并验证权限,再执行状态变更 + job = job_repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="Access denied to this job") + + use_case = RetryJobUseCase(job_repo) + + try: + job = use_case.execute(job_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return job_to_response(job) + + +@router.post("/jobs/{job_id}/cancel", response_model=JobResponse) +def cancel_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + job_repo: Any = Depends(get_job_repository), +) -> JobResponse: + """取消任务。""" + # 权限检查:先获取任务并验证权限,再执行状态变更 + job = job_repo.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id: + raise HTTPException(status_code=403, detail="Access denied to this job") + + use_case = CancelJobUseCase(job_repo) + + try: + job = use_case.execute(job_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return job_to_response(job) diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py new file mode 100644 index 000000000..2f6a8da30 --- /dev/null +++ b/apps/api/app/api/routes/tts.py @@ -0,0 +1,191 @@ +"""TTS 合成 API 路由。""" + +from __future__ import annotations + +from typing import Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_cosyvoice_service, get_db_session +from app.schemas.tts import ( + ListTTSJobResponse, + TTSSynthesizeRequest, + TTSSynthesizeResponse, + TTSJobResponse, + TTSStatusResponse, +) +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.tts_job_repository import ( + SQLAlchemyTTSJobRepository, +) +from packages.application.cosyvoice_service import CosyVoiceService +from packages.application.tts_job.use_cases import ( + CreateTTSJobUseCase, + DeleteTTSJobUseCase, + GetTTSJobStatusUseCase, + GetTTSJobUseCase, + ListTTSJobsUseCase, + TTSJobNotFoundError, +) +from packages.application.tts_job.workflow import TTSWorkflowService + +router = APIRouter() + + +def _get_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyTTSJobRepository: + return SQLAlchemyTTSJobRepository(session) + + +def _to_response(job) -> TTSJobResponse: + return TTSJobResponse( + id=job.id, + user_id=job.user_id, + input_text=job.input_text, + voice_id=job.voice_id, + voice_model=job.voice_model, + project_id=job.project_id, + voice_clone_profile_id=job.voice_clone_profile_id, + status=job.status, + output_audio_url=job.output_audio_url, + output_audio_key=job.output_audio_key, + duration=job.duration, + file_size=job.file_size, + sample_rate=job.sample_rate, + format=job.format, + error_message=job.error_message, + retry_count=job.retry_count, + max_retries=job.max_retries, + metadata=job.metadata, + started_at=job.started_at, + completed_at=job.completed_at, + created_at=job.created_at, + updated_at=job.updated_at, + ) + + +@router.post("/synthesize", response_model=TTSSynthesizeResponse, status_code=status.HTTP_201_CREATED) +def synthesize( + request: TTSSynthesizeRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), + cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), +) -> TTSSynthesizeResponse: + """发起 TTS 合成任务。 + + 创建 TTS 任务 → 提交 CosyVoice 合成 → 触发 Celery 异步轮询。 + """ + user_id = authenticated_user.user.id + use_case = CreateTTSJobUseCase(repository) + job = use_case.execute( + user_id=user_id, + input_text=request.text, + voice_id=request.voice_id, + voice_model=request.voice_model, + voice_clone_profile_id=request.voice_clone_profile_id, + metadata=request.metadata_, + ) + + # 提交 CosyVoice 合成任务 + workflow = TTSWorkflowService( + repository=repository, cosyvoice_service=cosyvoice_service, + ) + job = workflow.start_synthesis(job.id) + + # 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询 + if job.status.value == "processing": + task_id = (job.metadata or {}).get("cosyvoice_task_id", "") + if task_id: + try: + from worker_app.tasks import process_tts_synthesis + process_tts_synthesis.delay(job.id) + except Exception as e: + # Celery 调度失败,标记 job 为 failed + workflow.process_synthesis_failure( + job.id, f"Celery 任务调度失败: {e}" + ) + + return TTSSynthesizeResponse( + job_id=job.id, + status=job.status, + message="合成任务已创建", + ) + + +@router.get("/jobs", response_model=ListTTSJobResponse) +def list_tts_jobs( + page: int = Query(default=1, ge=1, description="页码"), + page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), + status_filter: Optional[str] = Query(None, alias="status"), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), +) -> ListTTSJobResponse: + """列出用户的 TTS 合成任务。""" + user_id = authenticated_user.user.id + use_case = ListTTSJobsUseCase(repository) + skip = (page - 1) * page_size + items, total = use_case.execute( + user_id, status=status_filter, skip=skip, limit=page_size + ) + return ListTTSJobResponse( + items=[_to_response(j) for j in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/jobs/{job_id}", response_model=TTSJobResponse) +def get_tts_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), +) -> TTSJobResponse: + """获取 TTS 任务详情。""" + user_id = authenticated_user.user.id + use_case = GetTTSJobUseCase(repository) + try: + job = use_case.execute(job_id, user_id) + except TTSJobNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") + return _to_response(job) + + +@router.get("/jobs/{job_id}/status", response_model=TTSStatusResponse) +def get_tts_job_status( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), +) -> TTSStatusResponse: + """查询 TTS 合成状态(用于前端轮询)。""" + user_id = authenticated_user.user.id + use_case = GetTTSJobStatusUseCase(repository) + try: + job = use_case.execute(job_id, user_id) + except TTSJobNotFoundError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") + return TTSStatusResponse( + id=job.id, + status=job.status, + output_audio_url=job.output_audio_url, + error_message=job.error_message, + duration=job.duration, + retry_count=job.retry_count, + created_at=job.created_at, + updated_at=job.updated_at, + ) + + +@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response) +def delete_tts_job( + job_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), +) -> Response: + """删除 TTS 合成任务。""" + user_id = authenticated_user.user.id + use_case = DeleteTTSJobUseCase(repository) + deleted = use_case.execute(job_id, user_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") + return Response(status_code=204) diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py new file mode 100644 index 000000000..66ef938fd --- /dev/null +++ b/apps/api/app/api/routes/voice_clones.py @@ -0,0 +1,257 @@ +"""音色克隆 API 路由。""" + +from __future__ import annotations + +import logging +from typing import Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository +from app.schemas.voice_clone import ( + CreateVoiceCloneRequest, + ListVoiceCloneResponse, + VoiceCloneProfileResponse, + VoiceCloneStatusResponse, +) +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status + +from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import ( + SQLAlchemyVoiceCloneProfileRepository, +) +from packages.application.cosyvoice_service import CosyVoiceService +from packages.application.voice_clone.use_cases import ( + DeleteVoiceCloneUseCase, + GetVoiceCloneStatusUseCase, + GetVoiceCloneUseCase, + ListVoiceClonesUseCase, + VoiceCloneNotFoundError, + VoiceCloneNotRetryableError, +) +from packages.application.voice_clone.workflow import ( + VoiceCloneWorkflowError, + VoiceCloneWorkflowService, +) + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _to_response(profile) -> VoiceCloneProfileResponse: + return VoiceCloneProfileResponse( + id=profile.id, + user_id=profile.user_id, + name=profile.name, + description=profile.description, + source_audio_url=profile.source_audio_url, + voice_id=profile.voice_id, + voice_model=profile.voice_model, + language=profile.language, + gender=profile.gender, + status=profile.status, + error_message=profile.error_message, + retry_count=profile.retry_count, + max_retries=profile.max_retries, + metadata=profile.metadata, + created_at=profile.created_at, + updated_at=profile.updated_at, + ) + + +def _get_workflow_service( + repository: SQLAlchemyVoiceCloneProfileRepository = Depends( + get_voice_clone_profile_repository + ), + cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), +) -> VoiceCloneWorkflowService: + return VoiceCloneWorkflowService( + repository=repository, cosyvoice_service=cosyvoice_service + ) + + +@router.post( + "", + response_model=VoiceCloneProfileResponse, + status_code=status.HTTP_201_CREATED, +) +def create_voice_clone( + request: CreateVoiceCloneRequest, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service), +) -> VoiceCloneProfileResponse: + """创建音色克隆任务。 + + 创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。 + 如果有 source_audio_url,状态会变为 processing;否则保持 pending。 + """ + user_id = authenticated_user.user.id + profile = workflow.start_clone( + user_id=user_id, + name=request.name, + description=request.description, + source_audio_url=request.source_audio_url, + voice_model=request.voice_model, + language=request.language, + gender=request.gender, + max_retries=request.max_retries, + metadata=request.metadata_, + ) + + # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 + task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") + if profile.status == "processing" and task_id: + try: + from worker_app.tasks import process_voice_clone + + process_voice_clone.delay(profile.id) + logger.info(f"Celery task dispatched for voice clone {profile.id}") + except Exception as e: + logger.error(f"Failed to dispatch Celery task: {e}") + # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing + try: + workflow.process_clone_failure( + profile.id, f"Celery 任务调度失败: {e}" + ) + except Exception as inner_e: + logger.error( + f"Failed to mark profile as failed after dispatch error: {inner_e}" + ) + + return _to_response(profile) + + +@router.get("", response_model=ListVoiceCloneResponse) +def list_voice_clones( + status_filter: Optional[str] = Query(None, alias="status"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends( + get_voice_clone_profile_repository + ), +) -> ListVoiceCloneResponse: + """获取用户的音色克隆列表。""" + user_id = authenticated_user.user.id + use_case = ListVoiceClonesUseCase(repository) + items, total = use_case.execute( + user_id, status=status_filter, skip=skip, limit=limit + ) + return ListVoiceCloneResponse( + items=[_to_response(p) for p in items], + total=total, + ) + + +@router.get("/{clone_id}", response_model=VoiceCloneProfileResponse) +def get_voice_clone( + clone_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends( + get_voice_clone_profile_repository + ), +) -> VoiceCloneProfileResponse: + """获取音色克隆详情。""" + user_id = authenticated_user.user.id + use_case = GetVoiceCloneUseCase(repository) + try: + profile = use_case.execute(clone_id, user_id) + except VoiceCloneNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" + ) + return _to_response(profile) + + +@router.get("/{clone_id}/status", response_model=VoiceCloneStatusResponse) +def get_voice_clone_status( + clone_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends( + get_voice_clone_profile_repository + ), +) -> VoiceCloneStatusResponse: + """查询音色克隆状态(用于前端轮询)。""" + user_id = authenticated_user.user.id + use_case = GetVoiceCloneStatusUseCase(repository) + try: + profile = use_case.execute(clone_id, user_id) + except VoiceCloneNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" + ) + return VoiceCloneStatusResponse( + id=profile.id, + status=profile.status, + error_message=profile.error_message, + voice_id=profile.voice_id, + retry_count=profile.retry_count, + ) + + +@router.delete( + "/{clone_id}", + status_code=status.HTTP_204_NO_CONTENT, + response_class=Response, +) +def delete_voice_clone( + clone_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends( + get_voice_clone_profile_repository + ), +) -> Response: + """删除音色克隆档案。""" + user_id = authenticated_user.user.id + use_case = DeleteVoiceCloneUseCase(repository) + deleted = use_case.execute(clone_id, user_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" + ) + return Response(status_code=204) + + +@router.post("/{clone_id}/retry", response_model=VoiceCloneProfileResponse) +def retry_voice_clone( + clone_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service), +) -> VoiceCloneProfileResponse: + """重试失败的音色克隆。 + + 仅当状态为 failed 时可重试,重试后重新提交 CosyVoice 克隆任务。 + """ + user_id = authenticated_user.user.id + try: + profile = workflow.retry_clone(clone_id, user_id) + except VoiceCloneNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" + ) + except VoiceCloneNotRetryableError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Voice clone is not retryable (only failed clones can be retried)", + ) + + # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 + task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") + if profile.status == "processing" and task_id: + try: + from worker_app.tasks import process_voice_clone + + process_voice_clone.delay(profile.id) + logger.info(f"Celery task dispatched for voice clone retry {profile.id}") + except Exception as e: + logger.error(f"Failed to dispatch Celery task: {e}") + # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing + try: + workflow.process_clone_failure( + profile.id, f"Celery 任务调度失败: {e}" + ) + except Exception as inner_e: + logger.error( + f"Failed to mark profile as failed after dispatch error: {inner_e}" + ) + + return _to_response(profile) diff --git a/apps/api/app/api/routes/voices.py b/apps/api/app/api/routes/voices.py index d2f41482e..a91b4b4da 100644 --- a/apps/api/app/api/routes/voices.py +++ b/apps/api/app/api/routes/voices.py @@ -1,11 +1,20 @@ -"""Voice library CRUD routes.""" +"""Voice library CRUD routes — Phase 3 增强版. + +支持预置音色和克隆音色的统一列表。 +""" from __future__ import annotations -from typing import Optional +from typing import Literal, Optional from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_user_repository +from app.schemas.voice import ( + PresetVoiceItemResponse, + PresetVoiceListResponse, + UnifiedVoiceItemResponse, + UnifiedVoiceListResponse, +) from app.schemas.voice_library import ( CreateVoiceLibraryRequest, ListVoiceLibraryResponse, @@ -15,6 +24,7 @@ from app.schemas.voice_library import ( from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session +from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand from packages.application.voice_library.use_cases import ( @@ -26,6 +36,7 @@ from packages.application.voice_library.use_cases import ( QuotaExceededError, UpdateVoiceLibraryUseCase, ) +from packages.domain.preset_voices import PRESET_VOICES from packages.ports.user_repository import UserRepository router = APIRouter() @@ -35,6 +46,10 @@ def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlch return SQLAlchemyVoiceLibraryRepository(session) +def _get_clone_profile_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceCloneProfileRepository: + return SQLAlchemyVoiceCloneProfileRepository(session) + + def _to_response(item) -> VoiceLibraryItemResponse: return VoiceLibraryItemResponse( id=item.id, @@ -55,6 +70,54 @@ def _to_response(item) -> VoiceLibraryItemResponse: ) +def _to_unified_response(item, profile_id_map: dict | None = None) -> UnifiedVoiceItemResponse: + """将数据库音色转换为统一响应格式。 + + Args: + item: VoiceLibraryItem + profile_id_map: voice_id → profile_id 映射,用于填充 voice_clone_profile_id + """ + profile_id = None + if profile_id_map and item.voice_id: + profile_id = profile_id_map.get(item.voice_id) + return UnifiedVoiceItemResponse( + id=item.id, + type="clone", + name=item.name, + description=item.text, + gender="unknown", + language="zh-CN", + voice_id=item.voice_id, + voice_provider=item.voice_provider or "cosyvoice", + audio_url=item.audio_url, + duration=item.duration, + file_size=item.file_size, + status=item.status, + tags=item.tags, + user_id=item.user_id, + project_id=item.project_id, + voice_clone_profile_id=profile_id, + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse: + """将预置音色转换为统一响应格式。""" + return UnifiedVoiceItemResponse( + id=preset.voice_id, + type="preset", + name=preset.name, + description=preset.description, + gender=preset.gender, + language=preset.language, + voice_id=preset.voice_id, + voice_provider="cosyvoice", + preview_url=preset.preview_url, + tags=preset.tags or [], + ) + + def _get_user_plan(user_id: str, user_repository: UserRepository) -> str: user = user_repository.find_by_id(user_id) if user is None: @@ -62,18 +125,113 @@ def _get_user_plan(user_id: str, user_repository: UserRepository) -> str: return getattr(user, "subscription_plan", "free") or "free" -@router.get("", response_model=ListVoiceLibraryResponse) -def list_voices( +# ==================== 统一配音列表(预置 + 克隆)==================== + + +@router.get("", response_model=UnifiedVoiceListResponse) +def list_voices_unified( + type: Optional[Literal["preset", "clone"]] = Query( + None, + description="音色类型过滤:preset=仅预置,clone=仅克隆,不传=全部", + ), + status_filter: Optional[str] = Query(None, alias="status"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + authenticated_user: AuthenticatedUser = Depends(get_current_user), + voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), + clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository), +) -> UnifiedVoiceListResponse: + """获取配音列表(预置音色 + 用户克隆音色)。 + + - 不传 type:返回预置音色 + 用户克隆音色,预置音色在前 + - type=preset:仅返回预置音色 + - type=clone:仅返回用户克隆音色 + """ + user_id = authenticated_user.user.id + items: list[UnifiedVoiceItemResponse] = [] + preset_count = 0 + clone_count = 0 + + has_preset = type is None or type == "preset" + has_clone = type is None or type == "clone" + + # 获取预置音色 + if has_preset: + preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES] + preset_count = len(preset_items) + + # 获取克隆音色 + if has_clone: + use_case = ListVoiceLibraryUseCase(voice_repository) + clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) + # 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id + voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id] + profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {} + clone_items = [_to_unified_response(i, profile_id_map) for i in clone_items_raw] + + # 组装结果 + if type == "preset": + items = preset_items[skip : skip + limit] + total = preset_count + elif type == "clone": + items = clone_items + total = clone_count + else: + # 全量模式:预置在前,克隆补位 + all_items = preset_items + clone_items + total = preset_count + clone_count + items = all_items[skip : skip + limit] + + return UnifiedVoiceListResponse( + items=items, + total=total, + preset_count=preset_count if has_preset else 0, + clone_count=clone_count if has_clone else 0, + ) + + +# ==================== 预置音色专用端点 ==================== + + +@router.get("/presets", response_model=PresetVoiceListResponse) +def list_preset_voices() -> PresetVoiceListResponse: + """获取预置音色列表。 + + 不需要认证,返回所有系统预置的 CosyVoice 音色。 + """ + items = [ + PresetVoiceItemResponse( + voice_id=p.voice_id, + name=p.name, + description=p.description, + gender=p.gender, + language=p.language, + preview_url=p.preview_url, + tags=p.tags or [], + ) + for p in PRESET_VOICES + ] + return PresetVoiceListResponse(items=items, total=len(items)) + + +# ==================== 原有 CRUD 端点(保持向后兼容)==================== + + +@router.get("/legacy", response_model=ListVoiceLibraryResponse) +def list_voices_legacy( status_filter: Optional[str] = Query(None, alias="status"), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), authenticated_user: AuthenticatedUser = Depends(get_current_user), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), ) -> ListVoiceLibraryResponse: + """原有配音列表接口(仅返回用户克隆音色)。 + + 保留用于向后兼容,新客户端请使用 GET /api/v1/voices。 + """ user_id = authenticated_user.user.id use_case = ListVoiceLibraryUseCase(voice_repository) - items = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) - total = voice_repository.count_by_user(user_id) + items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) return ListVoiceLibraryResponse( items=[_to_response(i) for i in items], total=total, diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py old mode 100644 new mode 100755 index 58f859960..775dfef1e --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -34,6 +34,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import ( from packages.adapters.sqlalchemy_impl.ingest_job_repository import ( SQLAlchemyIngestJobRepository, ) +from packages.adapters.sqlalchemy_impl.job_repository import SQLAlchemyJobRepository from packages.adapters.sqlalchemy_impl.project_repository import ( SQLAlchemyProjectRepository, ) @@ -42,6 +43,9 @@ from packages.adapters.sqlalchemy_impl.title_library_repository import ( SQLAlchemyTitleLibraryRepository, ) from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository +from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import ( + SQLAlchemyVoiceCloneProfileRepository, +) from packages.adapters.sqlalchemy_impl.voice_library_repository import ( SQLAlchemyVoiceLibraryRepository, ) @@ -52,9 +56,11 @@ from packages.ports.duplication_repository import DuplicationRecordRepository from packages.ports.generated_video_repository import GeneratedVideoRepository from packages.ports.generation_task_repository import GenerationTaskRepository from packages.ports.ingest_job_repository import IngestJobRepository +from packages.ports.job_repository import JobRepository from packages.ports.project_repository import ProjectRepository from packages.ports.title_library_repository import TitleLibraryRepository from packages.ports.user_repository import UserRepository +from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository from packages.ports.voice_library_repository import VoiceLibraryRepository _engine, _SessionLocal = build_session_factory(settings.DATABASE_URL) @@ -104,6 +110,13 @@ def get_generation_task_repository( return SQLAlchemyGenerationTaskRepository(session) +def get_job_repository( + session: Session = Depends(get_db_session), +) -> SQLAlchemyJobRepository: + """Provide the SQLAlchemy job repository implementation.""" + return SQLAlchemyJobRepository(session) + + def get_generated_video_repository( session: Session = Depends(get_db_session), ) -> SQLAlchemyGeneratedVideoRepository: @@ -169,3 +182,17 @@ def get_voice_library_repository( ) -> SQLAlchemyVoiceLibraryRepository: """Provide the SQLAlchemy voice library repository implementation.""" return SQLAlchemyVoiceLibraryRepository(session) + + +def get_voice_clone_profile_repository( + session: Session = Depends(get_db_session), +) -> SQLAlchemyVoiceCloneProfileRepository: + """Provide the SQLAlchemy voice clone profile repository implementation.""" + return SQLAlchemyVoiceCloneProfileRepository(session) + + +def get_cosyvoice_service() -> "CosyVoiceService": + """Provide the CosyVoice service instance.""" + from packages.application.cosyvoice_service import CosyVoiceService + + return CosyVoiceService() diff --git a/apps/api/app/schemas/job.py b/apps/api/app/schemas/job.py new file mode 100755 index 000000000..f678ddc89 --- /dev/null +++ b/apps/api/app/schemas/job.py @@ -0,0 +1,109 @@ +"""Job API schemas — Phase 8 任务 2.10.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class CreateJobRequest(BaseModel): + """创建任务请求体。""" + + project_id: str = Field(..., min_length=1, description="项目 ID") + job_type: str = Field( + ..., + description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation", + ) + payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数") + source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id)") + max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数") + + +class UpdateProgressRequest(BaseModel): + """更新任务进度请求体。""" + + progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比") + current_stage: str = Field(default="", description="当前阶段描述") + + +class CompleteJobRequest(BaseModel): + """完成任务请求体。""" + + result: dict[str, Any] = Field(default_factory=dict, description="任务结果") + + +class FailJobRequest(BaseModel): + """标记任务失败请求体。""" + + error_message: str = Field(..., min_length=1, description="错误信息") + + +class JobResponse(BaseModel): + """任务响应体。""" + + id: str + project_id: str + job_type: str + status: str + progress: float + current_stage: str + payload: dict[str, Any] + result: dict[str, Any] + error_message: str + retry_count: int + max_retries: int + celery_task_id: str + source_id: str + created_by_user_id: str + is_retryable: bool + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ListJobsResponse(BaseModel): + """任务列表响应体。""" + + items: list[JobResponse] + total: int + + +class JobStatisticsResponse(BaseModel): + """任务统计响应体。""" + + project_id: str + total: int + pending: int + running: int + success: int + failed: int + + +def job_to_response(job) -> JobResponse: + """将 Job 领域对象转换为 API 响应。""" + return JobResponse( + id=job.id, + project_id=job.project_id, + job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type), + status=job.status.value if hasattr(job.status, "value") else str(job.status), + progress=job.progress, + current_stage=job.current_stage, + payload=job.payload, + result=job.result, + error_message=job.error_message, + retry_count=job.retry_count, + max_retries=job.max_retries, + celery_task_id=job.celery_task_id, + source_id=job.source_id, + created_by_user_id=job.created_by_user_id, + is_retryable=job.is_retryable, + started_at=job.started_at, + completed_at=job.completed_at, + created_at=job.created_at, + updated_at=job.updated_at, + ) diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py new file mode 100644 index 000000000..b681c310c --- /dev/null +++ b/apps/api/app/schemas/tts.py @@ -0,0 +1,89 @@ +"""TTS 合成 API Schema。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class TTSSynthesizeRequest(BaseModel): + """TTS 合成请求。""" + + text: str = Field(..., min_length=1, max_length=10000, description="合成文本") + voice_id: str = Field("", description="音色 ID") + output_name: str = Field("", description="输出文件名") + language: str = Field("zh-CN", description="语言") + speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") + voice_model: str = Field("", description="语音模型名称") + voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID") + format: str = Field("mp3", description="输出格式(mp3/wav/pcm)") + metadata_: Optional[Dict[str, Any]] = Field( + default=None, alias="metadata", description="额外元数据" + ) + + class Config: + populate_by_name = True + + +class TTSJobResponse(BaseModel): + """TTS 任务响应。""" + + id: str + user_id: str + input_text: str + voice_id: str = "" + voice_model: str = "" + project_id: str = "" + voice_clone_profile_id: str = "" + status: str + output_audio_url: str = "" + output_audio_key: str = "" + duration: float = 0.0 + file_size: int = 0 + sample_rate: int = 22050 + format: str = "mp3" + error_message: str = "" + retry_count: int = 0 + max_retries: int = 3 + metadata_: Optional[Dict[str, Any]] = Field( + default=None, alias="metadata", description="额外元数据" + ) + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + populate_by_name = True + + +class TTSStatusResponse(BaseModel): + """TTS 任务状态响应(用于轮询)。""" + + id: str + status: str + output_audio_url: str = "" + error_message: str = "" + duration: float = 0.0 + retry_count: int = 0 + created_at: datetime + updated_at: datetime + + +class TTSSynthesizeResponse(BaseModel): + """TTS 合成创建响应。""" + + job_id: str + status: str + message: str = "合成任务已创建" + + +class ListTTSJobResponse(BaseModel): + """TTS 任务列表响应。""" + + items: List[TTSJobResponse] + total: int + page: int + page_size: int diff --git a/apps/api/app/schemas/voice.py b/apps/api/app/schemas/voice.py new file mode 100644 index 000000000..41d4424c1 --- /dev/null +++ b/apps/api/app/schemas/voice.py @@ -0,0 +1,127 @@ +"""统一配音响应 Schema — Phase 3 CosyVoice 集成. + +支持预置音色和克隆音色的统一响应格式。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +class UnifiedVoiceItemResponse(BaseModel): + """统一配音项响应。 + + 同时支持预置音色(type=preset)和克隆音色(type=clone)。 + """ + + id: str + """音色 ID(预置音色为 voice_id,克隆音色为数据库 ID)""" + + type: Literal["preset", "clone"] + """音色类型:preset=预置音色,clone=用户克隆音色""" + + name: str + """音色展示名称""" + + description: str = "" + """音色描述""" + + gender: str = "unknown" + """性别:male/female/unknown""" + + language: str = "zh-CN" + """语言代码""" + + voice_id: str = "" + """CosyVoice 模型音色名""" + + voice_provider: str = "cosyvoice" + """语音服务商""" + + audio_url: str = "" + """音频 URL(克隆音色为上传的音频,预置音色为空)""" + + preview_url: str = "" + """预览音频 URL(预置音色可能有)""" + + duration: float = 0 + """音频时长(秒)""" + + file_size: int = 0 + """文件大小(字节)""" + + status: str = "completed" + """状态""" + + tags: List[str] = Field(default_factory=list) + """标签列表""" + + # 克隆音色特有字段 + user_id: Optional[str] = None + """所属用户 ID(仅克隆音色)""" + + project_id: Optional[str] = None + """所属项目 ID(仅克隆音色)""" + + voice_clone_profile_id: Optional[str] = None + """关联的音色克隆档案 ID(仅克隆音色)""" + + created_at: Optional[datetime] = None + """创建时间(仅克隆音色)""" + + updated_at: Optional[datetime] = None + """更新时间(仅克隆音色)""" + + +class UnifiedVoiceListResponse(BaseModel): + """统一配音列表响应。""" + + items: list[UnifiedVoiceItemResponse] + """音色列表(预置音色在前)""" + + total: int = 0 + """总数""" + + preset_count: int = 0 + """预置音色数量""" + + clone_count: int = 0 + """克隆音色数量""" + + +class PresetVoiceItemResponse(BaseModel): + """预置音色项响应。""" + + voice_id: str + """CosyVoice 模型音色名""" + + name: str + """中文展示名""" + + description: str + """音色描述""" + + gender: str + """性别""" + + language: str = "zh-CN" + """语言代码""" + + preview_url: str = "" + """预览音频 URL""" + + tags: List[str] = Field(default_factory=list) + """标签列表""" + + +class PresetVoiceListResponse(BaseModel): + """预置音色列表响应。""" + + items: list[PresetVoiceItemResponse] + """预置音色列表""" + + total: int = 0 + """总数""" diff --git a/apps/api/app/schemas/voice_clone.py b/apps/api/app/schemas/voice_clone.py new file mode 100644 index 000000000..a1f7b7de5 --- /dev/null +++ b/apps/api/app/schemas/voice_clone.py @@ -0,0 +1,69 @@ +"""音色克隆 API Schema。""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class CreateVoiceCloneRequest(BaseModel): + """创建音色克隆请求。""" + + name: str = Field(..., min_length=1, max_length=100, description="音色名称") + description: str = Field("", description="音色描述") + source_audio_url: str = Field("", description="参考音频 URL") + voice_model: str = Field("", description="语音模型名称") + language: str = Field("zh-CN", description="语言") + gender: str = Field("unknown", description="性别") + max_retries: int = Field(3, ge=1, le=10, description="最大重试次数") + metadata_: Optional[Dict[str, Any]] = Field( + default=None, alias="metadata", description="额外元数据" + ) + + class Config: + populate_by_name = True + + +class VoiceCloneProfileResponse(BaseModel): + """音色克隆档案响应。""" + + id: str + user_id: str + name: str + description: str = "" + source_audio_url: str = "" + voice_id: str = "" + voice_model: str = "" + language: str = "zh-CN" + gender: str = "unknown" + status: str + error_message: str = "" + retry_count: int = 0 + max_retries: int = 3 + metadata_: Optional[Dict[str, Any]] = Field( + default=None, alias="metadata", description="额外元数据" + ) + created_at: datetime + updated_at: datetime + + class Config: + populate_by_name = True + + +class VoiceCloneStatusResponse(BaseModel): + """音色克隆状态响应(用于轮询)。""" + + id: str + status: str + error_message: str = "" + voice_id: str = "" + retry_count: int = 0 + + +class ListVoiceCloneResponse(BaseModel): + """音色克隆列表响应。""" + + items: List[VoiceCloneProfileResponse] + total: int diff --git a/apps/api/app/services/__init__.py b/apps/api/app/services/__init__.py new file mode 100755 index 000000000..b245a6023 --- /dev/null +++ b/apps/api/app/services/__init__.py @@ -0,0 +1,15 @@ +"""Service layer exports for Phase 8 模板编排引擎.""" + +from .auto_clip_service import AutoClipService +from .edit_plan_service import EditPlanService +from .edit_template_service import EditTemplateService +from .job_service import JobService +from .video_compose_service import VideoComposeService + +__all__ = [ + "AutoClipService", + "EditPlanService", + "EditTemplateService", + "JobService", + "VideoComposeService", +] diff --git a/apps/api/app/services/auto_clip_service.py b/apps/api/app/services/auto_clip_service.py new file mode 100644 index 000000000..bb945b16b --- /dev/null +++ b/apps/api/app/services/auto_clip_service.py @@ -0,0 +1,340 @@ +"""AutoClipService — 智能选片服务. + +根据模板片段配置 (TemplateClipConfig) 的素材需求 (material_requirements), +自动从项目素材库中筛选、评分并分配最佳素材到剪辑计划片段 (EditPlanClip)。 + +评分规则: +- 质量分 (quality_score):权重 0.5 +- 时长匹配度:权重 0.3(越接近目标时长得分越高) +- 分类匹配度:权重 0.2(分类完全匹配得满分,部分匹配按比例得分) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl import ( + SQLAlchemyAssetRepository, + SQLAlchemyEditPlanClipRepository, + SQLAlchemyEditPlanRepository, + SQLAlchemyTemplateClipConfigRepository, +) +from packages.domain.asset import AssetType +from packages.domain.classification import AssetClassification +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus + +logger = logging.getLogger(__name__) + +# ── 评分权重 ────────────────────────────────────────────────────────────────── +_WEIGHT_QUALITY = 0.5 +_WEIGHT_DURATION = 0.3 +_WEIGHT_CLASSIFICATION = 0.2 + + +@dataclass +class AutoSelectResult: + """智能选片结果。""" + + plan_id: str + total_clips: int + assigned_clips: int + unassigned_clips: int + details: list[ClipAssignDetail] + + +@dataclass +class ClipAssignDetail: + """单个片段的分配详情。""" + + clip_id: str + clip_type: str + assigned_asset_id: str | None + candidate_count: int + score: float | None + reason: str + + +class AutoClipService: + """智能选片服务 — 自动为剪辑计划片段分配最佳素材。""" + + def __init__(self, db: Session) -> None: + self._plan_repo = SQLAlchemyEditPlanRepository(db) + self._clip_repo = SQLAlchemyEditPlanClipRepository(db) + self._config_repo = SQLAlchemyTemplateClipConfigRepository(db) + self._asset_repo = SQLAlchemyAssetRepository(db) + + # ── 公开方法 ────────────────────────────────────────────────────────────── + + def auto_select_assets(self, plan_id: str, project_id: str) -> AutoSelectResult: + """为剪辑计划的所有片段自动分配素材。 + + 流程: + 1. 获取剪辑计划 → 读取 template_id + 2. 获取模板的所有片段配置 (TemplateClipConfig) + 3. 获取计划的所有片段 (EditPlanClip) + 4. 对每个片段,根据其关联的 config 筛选候选素材并评分 + 5. 将最佳素材分配给片段,标记为 READY + + Args: + plan_id: 剪辑计划 ID + project_id: 项目 ID(素材所属项目) + + Returns: + AutoSelectResult 包含分配统计和每个片段的详情 + + Raises: + ValueError: 计划不存在 + """ + plan = self._plan_repo.get(plan_id) + if plan is None: + raise ValueError(f"剪辑计划不存在: {plan_id}") + + # 获取模板片段配置(按 order 排序) + configs = self._config_repo.list_by_template(plan.template_id) + config_map = {c.id: c for c in configs} + + # 获取计划的所有片段 + clips = self._clip_repo.list_by_plan(plan_id) + + details: list[ClipAssignDetail] = [] + assigned_count = 0 + + for clip in clips: + detail = self._assign_single_clip(clip, project_id, config_map) + details.append(detail) + if detail.assigned_asset_id is not None: + assigned_count += 1 + + result = AutoSelectResult( + plan_id=plan_id, + total_clips=len(clips), + assigned_clips=assigned_count, + unassigned_clips=len(clips) - assigned_count, + details=details, + ) + logger.info( + "智能选片完成: plan=%s total=%d assigned=%d unassigned=%d", + plan_id, + result.total_clips, + result.assigned_clips, + result.unassigned_clips, + ) + return result + + def select_for_clip(self, clip_id: str, project_id: str) -> ClipAssignDetail: + """为单个片段选择并分配最佳素材。 + + Args: + clip_id: 片段 ID + project_id: 项目 ID(素材所属项目) + + Returns: + ClipAssignDetail 分配详情 + + Raises: + ValueError: 片段不存在或缺少关联配置 + """ + clip = self._clip_repo.get(clip_id) + if clip is None: + raise ValueError(f"片段不存在: {clip_id}") + + # 获取关联的模板配置 + config = None + if clip.template_clip_config_id: + config = self._config_repo.get(clip.template_clip_config_id) + + config_map = {config.id: config} if config else {} + return self._assign_single_clip(clip, project_id, config_map) + + # ── 内部方法 ────────────────────────────────────────────────────────────── + + def _assign_single_clip( + self, + clip: EditPlanClip, + project_id: str, + config_map: dict[str, object], + ) -> ClipAssignDetail: + """为单个片段分配素材。""" + config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None + + # 解析素材需求 + requirements = self._parse_material_requirements(config) + + # 搜索候选素材 + candidates = self._asset_repo.search_candidates( + project_id=project_id, + file_type=requirements.get("file_type"), + min_quality_score=requirements.get("min_quality_score"), + min_duration=requirements.get("min_duration"), + max_duration=requirements.get("max_duration"), + classification_category=requirements.get("classification_category"), + tags=requirements.get("tags"), + status="completed", + limit=50, + ) + + if not candidates: + return ClipAssignDetail( + clip_id=clip.id, + clip_type=requirements.get("clip_type", "unknown"), + assigned_asset_id=None, + candidate_count=0, + score=None, + reason="无符合条件的候选素材", + ) + + # 评分并选择最佳素材 + target_duration = requirements.get("target_duration") + target_category = requirements.get("classification_category") + + best_asset = None + best_score = -1.0 + for asset in candidates: + score = self._score_candidate( + asset, + target_duration=target_duration, + target_category=target_category, + ) + if score > best_score: + best_score = score + best_asset = asset + + if best_asset is None: + return ClipAssignDetail( + clip_id=clip.id, + clip_type=requirements.get("clip_type", "unknown"), + assigned_asset_id=None, + candidate_count=len(candidates), + score=None, + reason="候选素材评分均不合格", + ) + + # 分配素材并标记就绪 + clip.assign_asset(best_asset.id) + clip.mark_ready() + self._clip_repo.update(clip) + + return ClipAssignDetail( + clip_id=clip.id, + clip_type=requirements.get("clip_type", "unknown"), + assigned_asset_id=best_asset.id, + candidate_count=len(candidates), + score=round(best_score, 4), + reason=f"最佳匹配 (score={best_score:.4f})", + ) + + @staticmethod + def _score_candidate( + asset: object, + *, + target_duration: float | None = None, + target_category: str | None = None, + ) -> float: + """对候选素材评分 (0.0 ~ 1.0)。 + + 评分维度: + - 质量分 (quality_score):归一化到 0-1,权重 0.5 + - 时长匹配度:越接近目标时长得分越高,权重 0.3 + - 分类匹配度:完全匹配得 1.0,无分类得 0.0,权重 0.2 + """ + # 质量分 (0-100 → 0-1) + quality = getattr(asset, "quality_score", None) + quality_score = (quality / 100.0) if quality is not None else 0.5 + + # 时长匹配度 + duration_score = 0.5 # 无目标时长的默认分 + if target_duration is not None and target_duration > 0: + asset_duration = getattr(asset, "duration", None) + if asset_duration is not None and asset_duration > 0: + ratio = asset_duration / target_duration + # 比率越接近 1.0 得分越高,使用高斯衰减 + duration_score = max(0.0, 1.0 - abs(1.0 - ratio) * 2) + # 无时长的素材得 0 分 + else: + duration_score = 0.0 + + # 分类匹配度 + classification_score = 0.0 + if target_category is not None: + metadata = getattr(asset, "metadata", {}) or {} + asset_category = metadata.get("category", "") + if asset_category == target_category: + classification_score = 1.0 + elif asset_category: + # 部分匹配(同大类)给 0.5 + classification_score = 0.3 + else: + # 无分类要求,所有素材得满分 + classification_score = 1.0 + + total = ( + _WEIGHT_QUALITY * quality_score + + _WEIGHT_DURATION * duration_score + + _WEIGHT_CLASSIFICATION * classification_score + ) + return total + + @staticmethod + def _parse_material_requirements(config: object | None) -> dict: + """从 TemplateClipConfig 解析素材筛选条件。 + + 将 material_requirements JSON 和 config 自身的时长/类型字段 + 统一转换为 search_candidates 可用的筛选参数。 + """ + result: dict = {} + if config is None: + return result + + # 从 material_requirements 提取筛选条件 + requirements = getattr(config, "material_requirements", {}) or {} + # 素材类型: material_requirements 中的 "type" 字段 + req_type = requirements.get("type") + if req_type and req_type in (AssetType.VIDEO, AssetType.IMAGE, AssetType.AUDIO): + result["file_type"] = req_type + + # 最低质量分 + min_quality = requirements.get("min_quality_score") or requirements.get("min_quality") + if min_quality is not None: + try: + result["min_quality_score"] = float(min_quality) + except (TypeError, ValueError): + pass + + # 分类筛选 + category = requirements.get("category") or requirements.get("classification") + if category: + # 验证是否为有效分类 + valid_categories = {c.value for c in AssetClassification} + if category in valid_categories: + result["classification_category"] = category + + # 标签筛选 + tags = requirements.get("tags") + if isinstance(tags, list) and tags: + result["tags"] = tags + + # 时长范围:优先使用 config 的 min/max_duration,其次 material_requirements + min_dur = getattr(config, "min_duration", None) or requirements.get("min_duration") + max_dur = getattr(config, "max_duration", None) or requirements.get("max_duration") + if min_dur is not None and min_dur > 0: + result["min_duration"] = float(min_dur) + if max_dur is not None and max_dur > 0: + result["max_duration"] = float(max_dur) + + # 目标时长(用于评分) + if min_dur and max_dur: + result["target_duration"] = (float(min_dur) + float(max_dur)) / 2 + elif min_dur: + result["target_duration"] = float(min_dur) * 1.2 + elif max_dur: + result["target_duration"] = float(max_dur) * 0.8 + + # 片段类型(用于日志) + clip_type = getattr(config, "clip_type", None) + if clip_type: + result["clip_type"] = clip_type.value if hasattr(clip_type, "value") else str(clip_type) + + return result diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py new file mode 100644 index 000000000..f4a91ccc4 --- /dev/null +++ b/apps/api/app/services/edit_plan_service.py @@ -0,0 +1,473 @@ +"""EditPlanService — 剪辑计划管理业务逻辑. + +封装 EditPlan 和 EditPlanClip 的 CRUD 操作、状态机流转、 +以及渲染生成流程,提供统一的业务接口供 API 路由层调用。 +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl import ( + SQLAlchemyEditPlanClipRepository, + SQLAlchemyEditPlanRepository, + SQLAlchemyGenerationTaskRepository, +) +from packages.domain.edit_plan import EditPlan, EditPlanStatus +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus +from packages.domain.generation_task import GenerationTaskStatus + +logger = logging.getLogger(__name__) + + +class EditPlanService: + """剪辑计划管理服务 + + 职责: + - 剪辑计划 CRUD(创建、查询、更新、删除) + - 剪辑片段管理(增删改查、分配素材) + - 状态机流转(draft → editing → rendering → completed/failed) + - 渲染生成流程(触发 Celery 任务、查询进度) + """ + + def __init__(self, db: Session) -> None: + self._plan_repo = SQLAlchemyEditPlanRepository(db) + self._clip_repo = SQLAlchemyEditPlanClipRepository(db) + self._generation_task_repo = SQLAlchemyGenerationTaskRepository(db) + + # ── 剪辑计划 CRUD ────────────────────────────────────────────────────── + + def list_plans( + self, + *, + template_id: Optional[str] = None, + status: Optional[EditPlanStatus] = None, + skip: int = 0, + limit: int = 50, + ) -> List[EditPlan]: + """列出剪辑计划 + + Args: + template_id: 按模板 ID 筛选 + status: 按状态筛选 + skip: 分页偏移 + limit: 每页数量 + """ + if template_id: + return self._plan_repo.list_by_template( + template_id, + status=status, + skip=skip, + limit=limit, + ) + return self._plan_repo.list_all(status=status, skip=skip, limit=limit) + + def count_plans( + self, + *, + template_id: Optional[str] = None, + status: Optional[EditPlanStatus] = None, + ) -> int: + """统计计划数量 + + Note: + 当指定 template_id 时,通过全量查询计算 total(repo 限制)。 + """ + if template_id: + all_matching = self._plan_repo.list_by_template( + template_id, + status=status, + skip=0, + limit=10000, + ) + return len(all_matching) + return self._plan_repo.count(status=status) + + def get_plan(self, plan_id: str) -> Optional[EditPlan]: + """获取计划详情""" + return self._plan_repo.get(plan_id) + + def get_plan_or_raise(self, plan_id: str) -> EditPlan: + """获取计划,不存在则抛出 ValueError""" + plan = self._plan_repo.get(plan_id) + if plan is None: + raise ValueError(f"剪辑计划不存在: {plan_id}") + return plan + + def create_plan( + self, + template_id: str, + name: str, + *, + config: Optional[dict[str, Any]] = None, + total_duration: float = 0.0, + ) -> EditPlan: + """创建剪辑计划 + + Raises: + ValueError: 参数校验失败 + """ + plan = EditPlan.create( + template_id=template_id, + name=name, + config=config, + total_duration=total_duration, + ) + created = self._plan_repo.create(plan) + logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) + return created + + def update_plan( + self, + plan_id: str, + *, + name: Optional[str] = None, + config: Optional[dict[str, Any]] = None, + total_duration: Optional[float] = None, + ) -> EditPlan: + """更新计划基础字段 + + Raises: + ValueError: 计划不存在 + """ + existing = self.get_plan_or_raise(plan_id) + + updated = EditPlan( + id=existing.id, + template_id=existing.template_id, + name=name.strip() if name is not None else existing.name, + status=existing.status, + total_duration=total_duration if total_duration is not None else existing.total_duration, + config=config if config is not None else existing.config, + created_at=existing.created_at, + updated_at=existing.updated_at, + ) + result = self._plan_repo.update(updated) + logger.info("更新剪辑计划: id=%s", plan_id) + return result + + def delete_plan(self, plan_id: str) -> bool: + """删除剪辑计划及其所有片段 + + Returns: + bool: 是否删除成功 + """ + existing = self._plan_repo.get(plan_id) + if existing is None: + return False + + # 先删除所有片段 + self._clip_repo.delete_by_plan(plan_id) + # 再删除计划 + self._plan_repo.delete(plan_id) + logger.info("删除剪辑计划: id=%s", plan_id) + return True + + # ── 状态机流转 ────────────────────────────────────────────────────────── + + def transition_status(self, plan_id: str, target_status: EditPlanStatus) -> EditPlan: + """流转计划状态 + + 状态流转规则: + - draft → editing (start_editing) + - editing → rendering (start_rendering) + - rendering → completed (mark_completed) + - rendering → failed (mark_failed) + - failed → draft (reset_to_draft) + + Raises: + ValueError: 计划不存在或状态流转非法 + """ + plan = self.get_plan_or_raise(plan_id) + + # 如果已是目标状态,直接返回 + if plan.status == target_status: + return plan + + # 根据目标状态调用对应的状态机方法 + transition_map = { + EditPlanStatus.EDITING: plan.start_editing, + EditPlanStatus.RENDERING: plan.start_rendering, + EditPlanStatus.COMPLETED: plan.mark_completed, + EditPlanStatus.FAILED: plan.mark_failed, + EditPlanStatus.DRAFT: plan.reset_to_draft, + } + + transition_fn = transition_map.get(target_status) + if transition_fn is None: + raise ValueError(f"无效的目标状态: {target_status}") + + transition_fn() + result = self._plan_repo.update(plan) + logger.info( + "状态流转: plan_id=%s %s → %s", + plan_id, + plan.status, + target_status, + ) + return result + + # ── 剪辑片段管理 ──────────────────────────────────────────────────────── + + def list_clips( + self, + plan_id: str, + *, + status: Optional[EditPlanClipStatus] = None, + skip: int = 0, + limit: int = 100, + ) -> List[EditPlanClip]: + """列出计划的片段""" + # 确保计划存在 + self.get_plan_or_raise(plan_id) + return self._clip_repo.list_by_plan(plan_id, status=status, skip=skip, limit=limit) + + def count_clips( + self, + plan_id: str, + *, + status: Optional[EditPlanClipStatus] = None, + ) -> int: + """统计片段数量""" + return self._clip_repo.count(plan_id=plan_id, status=status) + + def get_clip(self, clip_id: str) -> Optional[EditPlanClip]: + """获取片段详情""" + return self._clip_repo.get(clip_id) + + def get_clip_or_raise(self, clip_id: str) -> EditPlanClip: + """获取片段,不存在则抛出 ValueError""" + clip = self._clip_repo.get(clip_id) + if clip is None: + raise ValueError(f"片段不存在: {clip_id}") + return clip + + def create_clip( + self, + plan_id: str, + clip_type: str, + order: int, + *, + template_clip_config_id: str = "", + asset_id: str = "", + text_content: str = "", + start_time: float = 0.0, + duration: float = 0.0, + transition_effect: str = "cut", + config: Optional[dict[str, Any]] = None, + ) -> EditPlanClip: + """创建片段 + + Raises: + ValueError: 计划不存在或参数校验失败 + """ + # 确保计划存在 + self.get_plan_or_raise(plan_id) + + clip = EditPlanClip.create( + plan_id=plan_id, + clip_type=clip_type, + order=order, + template_clip_config_id=template_clip_config_id, + asset_id=asset_id, + text_content=text_content, + start_time=start_time, + duration=duration, + transition_effect=transition_effect, + config=config, + ) + created = self._clip_repo.create(clip) + logger.info( + "创建片段: id=%s plan_id=%s clip_type=%s order=%d", + created.id, + plan_id, + created.clip_type, + created.order, + ) + return created + + def update_clip( + self, + clip_id: str, + *, + clip_type: Optional[str] = None, + order: Optional[int] = None, + asset_id: Optional[str] = None, + text_content: Optional[str] = None, + start_time: Optional[float] = None, + duration: Optional[float] = None, + transition_effect: Optional[str] = None, + config: Optional[dict[str, Any]] = None, + ) -> EditPlanClip: + """更新片段 + + Raises: + ValueError: 片段不存在 + """ + existing = self.get_clip_or_raise(clip_id) + + updated = EditPlanClip( + id=existing.id, + plan_id=existing.plan_id, + clip_type=clip_type.strip() if clip_type is not None else existing.clip_type, + order=order if order is not None else existing.order, + template_clip_config_id=existing.template_clip_config_id, + asset_id=asset_id.strip() if asset_id is not None else existing.asset_id, + text_content=text_content.strip() if text_content is not None else existing.text_content, + start_time=start_time if start_time is not None else existing.start_time, + duration=duration if duration is not None else existing.duration, + transition_effect=transition_effect.strip() if transition_effect is not None else existing.transition_effect, + status=existing.status, + config=config if config is not None else existing.config, + created_at=existing.created_at, + updated_at=existing.updated_at, + ) + result = self._clip_repo.update(updated) + logger.info("更新片段: id=%s", clip_id) + return result + + def assign_asset(self, clip_id: str, asset_id: str) -> EditPlanClip: + """为片段分配素材 + + Raises: + ValueError: 片段不存在或 asset_id 为空 + """ + clip = self.get_clip_or_raise(clip_id) + clip.assign_asset(asset_id) + result = self._clip_repo.update(clip) + logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id) + return result + + def delete_clip(self, clip_id: str) -> bool: + """删除片段 + + Returns: + bool: 是否删除成功 + """ + deleted = self._clip_repo.delete(clip_id) + if deleted: + logger.info("删除片段: id=%s", clip_id) + return deleted + + def delete_all_clips(self, plan_id: str) -> int: + """删除计划下所有片段 + + Returns: + int: 删除的片段数量 + """ + count = self._clip_repo.delete_by_plan(plan_id) + logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) + return count + + # ── 渲染生成流程 ──────────────────────────────────────────────────────── + + def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]: + """获取计划及其所有片段 + + Returns: + dict: {"plan": EditPlan, "clips": List[EditPlanClip]} + """ + plan = self.get_plan_or_raise(plan_id) + clips = self._clip_repo.list_by_plan(plan_id) + return { + "plan": plan, + "clips": clips, + } + + def get_generation_status(self, plan_id: str) -> Dict[str, Any]: + """获取渲染进度状态 + + Returns: + dict: { + "plan": EditPlan, + "clips": List[EditPlanClip], + "generation_task_id": Optional[str], + "generation_task_status": Optional[str], + } + + Raises: + ValueError: 计划不存在 + """ + plan = self.get_plan_or_raise(plan_id) + clips = self._clip_repo.list_by_plan(plan_id) + + # 从 plan.config 中获取 generation_task_id + generation_task_id = plan.config.get("generation_task_id") + generation_task_status = None + + if generation_task_id: + task = self._generation_task_repo.get(generation_task_id) + if task: + generation_task_status = task.status.value if hasattr(task.status, "value") else task.status + + return { + "plan": plan, + "clips": clips, + "generation_task_id": generation_task_id, + "generation_task_status": generation_task_status, + } + + def can_generate(self, plan_id: str) -> tuple[bool, str]: + """检查是否可以触发渲染 + + Returns: + tuple: (can_generate, reason) + """ + plan = self.get_plan_or_raise(plan_id) + + # 检查状态 + if plan.status != EditPlanStatus.EDITING: + return False, f"只有 editing 状态的计划可以触发渲染,当前状态: {plan.status}" + + # 检查是否有片段 + clips = self._clip_repo.list_by_plan(plan_id) + if not clips: + return False, "计划下没有片段,无法触发渲染" + + return True, "" + + def mark_clips_ready(self, plan_id: str) -> int: + """将所有 pending 状态的片段标记为 ready + + Returns: + int: 标记的片段数量 + """ + clips = self._clip_repo.list_by_plan( + plan_id, + status=EditPlanClipStatus.PENDING, + ) + count = 0 + for clip in clips: + clip.mark_ready() + self._clip_repo.update(clip) + count += 1 + logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count) + return count + + def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan: + """更新计划配置(合并更新) + + Args: + plan_id: 计划 ID + config_updates: 要合并的配置 + + Returns: + 更新后的计划 + """ + plan = self.get_plan_or_raise(plan_id) + new_config = {**plan.config, **config_updates} + + updated = EditPlan( + id=plan.id, + template_id=plan.template_id, + name=plan.name, + status=plan.status, + total_duration=plan.total_duration, + config=new_config, + created_at=plan.created_at, + updated_at=plan.updated_at, + ) + return self._plan_repo.update(updated) diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py new file mode 100644 index 000000000..0ee6ea35a --- /dev/null +++ b/apps/api/app/services/edit_template_service.py @@ -0,0 +1,396 @@ +"""EditTemplateService — 模板管理业务逻辑. + +封装 EditTemplate 和 TemplateClipConfig 的 CRUD 操作, +提供统一的业务接口供 API 路由层调用。 +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl import ( + SQLAlchemyEditTemplateRepository, + SQLAlchemyTemplateClipConfigRepository, +) +from packages.domain.edit_template import EditTemplate, EditTemplateStatus +from packages.domain.template_clip_config import ( + ClipType, + TemplateClipConfig, + TransitionEffect, +) + +logger = logging.getLogger(__name__) + + +class EditTemplateService: + """模板管理服务 + + 职责: + - 模板 CRUD(创建、查询、更新、软删除) + - 模板片段配置管理(增删改查) + - 业务校验(名称去重、状态合法性等) + """ + + def __init__(self, db: Session) -> None: + self._template_repo = SQLAlchemyEditTemplateRepository(db) + self._clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db) + + # ── 模板 CRUD ────────────────────────────────────────────────────────── + + def list_templates( + self, + *, + template_type: Optional[str] = None, + status: Optional[EditTemplateStatus] = None, + active_only: bool = False, + skip: int = 0, + limit: int = 50, + ) -> List[EditTemplate]: + """列出模板 + + Args: + template_type: 按类型筛选 + status: 按状态筛选 + active_only: 仅返回激活模板 + skip: 分页偏移 + limit: 每页数量 + """ + if active_only: + return self._template_repo.list_active( + template_type=template_type, + skip=skip, + limit=limit, + ) + return self._template_repo.list_all( + template_type=template_type, + status=status, + skip=skip, + limit=limit, + ) + + def count_templates( + self, + *, + template_type: Optional[str] = None, + status: Optional[EditTemplateStatus] = None, + ) -> int: + """统计模板数量""" + return self._template_repo.count( + template_type=template_type, + status=status, + ) + + def get_template(self, template_id: str) -> Optional[EditTemplate]: + """获取模板详情""" + return self._template_repo.get(template_id) + + def get_template_or_raise(self, template_id: str) -> EditTemplate: + """获取模板,不存在则抛出 ValueError""" + template = self._template_repo.get(template_id) + if template is None: + raise ValueError(f"模板不存在: {template_id}") + return template + + def create_template( + self, + name: str, + *, + description: str = "", + template_type: str = "default", + config: Optional[dict[str, Any]] = None, + preview_url: str = "", + sort_weight: int = 0, + ) -> EditTemplate: + """创建模板 + + Raises: + ValueError: 名称为空或重复 + """ + # 名称校验 + clean_name = name.strip() + if not clean_name: + raise ValueError("模板名称不能为空") + + # 名称重复检查 + existing = self._template_repo.list_all(skip=0, limit=1000) + for t in existing: + if t.name == clean_name and t.status == EditTemplateStatus.ACTIVE: + raise ValueError(f"模板名称已存在: {clean_name}") + + template = EditTemplate.create( + name=clean_name, + description=description, + template_type=template_type, + config=config, + preview_url=preview_url, + sort_weight=sort_weight, + ) + created = self._template_repo.create(template) + logger.info("创建模板: id=%s name=%s", created.id, created.name) + return created + + def update_template( + self, + template_id: str, + *, + name: Optional[str] = None, + description: Optional[str] = None, + template_type: Optional[str] = None, + config: Optional[dict[str, Any]] = None, + preview_url: Optional[str] = None, + sort_weight: Optional[int] = None, + status: Optional[EditTemplateStatus] = None, + ) -> EditTemplate: + """更新模板 + + Raises: + ValueError: 模板不存在或名称重复 + """ + existing = self.get_template_or_raise(template_id) + + # 名称重复检查(排除自身) + new_name = name.strip() if name is not None else existing.name + if name is not None and new_name != existing.name: + all_templates = self._template_repo.list_all(skip=0, limit=1000) + for t in all_templates: + if ( + t.id != template_id + and t.name == new_name + and t.status == EditTemplateStatus.ACTIVE + ): + raise ValueError(f"模板名称已存在: {new_name}") + + # 构建更新后的实体 + updated = EditTemplate( + id=existing.id, + name=new_name, + description=description.strip() if description is not None else existing.description, + template_type=template_type.strip() if template_type is not None else existing.template_type, + config=config if config is not None else existing.config, + preview_url=preview_url.strip() if preview_url is not None else existing.preview_url, + sort_weight=sort_weight if sort_weight is not None else existing.sort_weight, + status=status if status is not None else existing.status, + created_at=existing.created_at, + updated_at=existing.updated_at, + ) + result = self._template_repo.update(updated) + logger.info("更新模板: id=%s", template_id) + return result + + def deactivate_template(self, template_id: str) -> EditTemplate: + """软删除模板(设为 inactive) + + Raises: + ValueError: 模板不存在 + """ + existing = self.get_template_or_raise(template_id) + existing.deactivate() + result = self._template_repo.update(existing) + logger.info("停用模板: id=%s", template_id) + return result + + # ── 模板片段配置管理 ──────────────────────────────────────────────────── + + def list_clip_configs( + self, + template_id: str, + *, + clip_type: Optional[ClipType] = None, + skip: int = 0, + limit: int = 100, + ) -> List[TemplateClipConfig]: + """列出模板的片段配置""" + # 确保模板存在 + self.get_template_or_raise(template_id) + return self._clip_config_repo.list_by_template( + template_id, + clip_type=clip_type, + skip=skip, + limit=limit, + ) + + def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]: + """获取片段配置详情""" + return self._clip_config_repo.get(config_id) + + def get_clip_config_or_raise(self, config_id: str) -> TemplateClipConfig: + """获取片段配置,不存在则抛出 ValueError""" + config = self._clip_config_repo.get(config_id) + if config is None: + raise ValueError(f"片段配置不存在: {config_id}") + return config + + def create_clip_config( + self, + template_id: str, + clip_type: ClipType | str, + order: int, + *, + min_duration: float = 0.0, + max_duration: float = 0.0, + text_template: str = "", + material_requirements: Optional[dict[str, Any]] = None, + transition_effect: TransitionEffect | str = TransitionEffect.CUT, + config: Optional[dict[str, Any]] = None, + ) -> TemplateClipConfig: + """创建片段配置 + + Raises: + ValueError: 模板不存在或参数校验失败 + """ + # 确保模板存在 + self.get_template_or_raise(template_id) + + clip_config = TemplateClipConfig.create( + template_id=template_id, + clip_type=clip_type, + order=order, + min_duration=min_duration, + max_duration=max_duration, + text_template=text_template, + material_requirements=material_requirements, + transition_effect=transition_effect, + config=config, + ) + created = self._clip_config_repo.create(clip_config) + logger.info( + "创建片段配置: id=%s template_id=%s clip_type=%s order=%d", + created.id, + template_id, + created.clip_type, + created.order, + ) + return created + + def update_clip_config( + self, + config_id: str, + *, + clip_type: Optional[ClipType | str] = None, + order: Optional[int] = None, + min_duration: Optional[float] = None, + max_duration: Optional[float] = None, + text_template: Optional[str] = None, + material_requirements: Optional[dict[str, Any]] = None, + transition_effect: Optional[TransitionEffect | str] = None, + config: Optional[dict[str, Any]] = None, + ) -> TemplateClipConfig: + """更新片段配置 + + Raises: + ValueError: 配置不存在或参数校验失败 + """ + existing = self.get_clip_config_or_raise(config_id) + + # 解析枚举类型 + new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type + new_transition = ( + TransitionEffect(transition_effect) + if transition_effect is not None + else existing.transition_effect + ) + + updated = TemplateClipConfig( + id=existing.id, + template_id=existing.template_id, + clip_type=new_clip_type, + order=order if order is not None else existing.order, + min_duration=min_duration if min_duration is not None else existing.min_duration, + max_duration=max_duration if max_duration is not None else existing.max_duration, + text_template=text_template.strip() if text_template is not None else existing.text_template, + material_requirements=material_requirements if material_requirements is not None else existing.material_requirements, + transition_effect=new_transition, + config=config if config is not None else existing.config, + created_at=existing.created_at, + updated_at=existing.updated_at, + ) + result = self._clip_config_repo.update(updated) + logger.info("更新片段配置: id=%s", config_id) + return result + + def delete_clip_config(self, config_id: str) -> bool: + """删除片段配置 + + Returns: + bool: 是否删除成功 + """ + deleted = self._clip_config_repo.delete(config_id) + if deleted: + logger.info("删除片段配置: id=%s", config_id) + return deleted + + def reorder_clip_configs( + self, + template_id: str, + config_ids: List[str], + ) -> List[TemplateClipConfig]: + """重新排序片段配置 + + Args: + template_id: 模板 ID + config_ids: 按新顺序排列的配置 ID 列表 + + Returns: + 更新后的配置列表 + + Raises: + ValueError: 模板不存在或配置 ID 不匹配 + """ + # 确保模板存在 + self.get_template_or_raise(template_id) + + # 获取当前配置 + current_configs = self._clip_config_repo.list_by_template(template_id) + current_ids = {c.id for c in current_configs} + + # 校验 ID 列表 + if set(config_ids) != current_ids: + raise ValueError("配置 ID 列表与模板下的配置不匹配") + + # 更新 order + results = [] + for new_order, config_id in enumerate(config_ids): + config = self._clip_config_repo.get(config_id) + if config is None: + continue + updated = TemplateClipConfig( + id=config.id, + template_id=config.template_id, + clip_type=config.clip_type, + order=new_order, + min_duration=config.min_duration, + max_duration=config.max_duration, + text_template=config.text_template, + material_requirements=config.material_requirements, + transition_effect=config.transition_effect, + config=config.config, + created_at=config.created_at, + updated_at=config.updated_at, + ) + results.append(self._clip_config_repo.update(updated)) + + logger.info( + "重排序片段配置: template_id=%s count=%d", + template_id, + len(config_ids), + ) + return results + + def get_template_with_configs( + self, + template_id: str, + ) -> dict: + """获取模板及其所有片段配置 + + Returns: + dict: {"template": EditTemplate, "clip_configs": List[TemplateClipConfig]} + """ + template = self.get_template_or_raise(template_id) + clip_configs = self._clip_config_repo.list_by_template(template_id) + return { + "template": template, + "clip_configs": clip_configs, + } diff --git a/apps/api/app/services/job_service.py b/apps/api/app/services/job_service.py new file mode 100755 index 000000000..f8800fd60 --- /dev/null +++ b/apps/api/app/services/job_service.py @@ -0,0 +1,268 @@ +"""JobService 服务层 — Phase 8 任务 2.10. + +将 JobService 与 VideoComposeService 集成,提供视频合成的完整异步工作流: +1. 创建 Job(记录任务元数据) +2. 提交执行(dispatch Celery 任务) +3. Celery 任务中更新进度、处理完成/失败 + +同时也提供通用的 Job 管理能力,供 ClipPlanService、RenderOrchestrator 等使用。 +""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy.orm import Session + +from packages.application.jobs import ( + CancelJobUseCase, + CompleteJobCommand, + CompleteJobUseCase, + CreateJobCommand, + CreateJobUseCase, + FailJobCommand, + FailJobUseCase, + GetJobStatisticsUseCase, + GetJobUseCase, + ListJobsUseCase, + RetryJobUseCase, + SubmitJobUseCase, + UpdateJobProgressCommand, + UpdateJobProgressUseCase, +) +from packages.domain.job import Job, JobStatus, JobType +from packages.ports.job_repository import JobRepository + +logger = logging.getLogger(__name__) + + +class JobService: + """统一异步任务管理服务。 + + 职责: + - 为视频合成等耗时操作提供统一的异步任务管理 + - 封装 Use Case 的调用,提供简洁的服务接口 + - 与 VideoComposeService 集成,支持视频合成工作流 + + 用法:: + + job_service = JobService(db) + job = job_service.create_compose_job( + project_id="xxx", + plan_id="yyy", + user_id="zzz", + ) + job_service.submit_job(job.id, celery_task_id="celery-xxx") + """ + + def __init__(self, job_repo: JobRepository): + self._job_repo = job_repo + + # ── 创建任务 ────────────────────────────────────────────────────────── + + def create_compose_job( + self, + project_id: str, + plan_id: str, + user_id: str, + *, + max_retries: int = 3, + ) -> Job: + """创建视频合成任务。 + + Args: + project_id: 项目 ID + plan_id: EditPlan ID + user_id: 创建人 ID + max_retries: 最大重试次数 + + Returns: + 创建的 Job 实例 + """ + use_case = CreateJobUseCase(self._job_repo) + return use_case.execute( + CreateJobCommand( + project_id=project_id, + job_type=JobType.VIDEO_COMPOSE, + payload={"plan_id": plan_id}, + source_id=plan_id, + created_by_user_id=user_id, + max_retries=max_retries, + ) + ) + + def create_render_job( + self, + project_id: str, + plan_id: str, + user_id: str, + *, + max_retries: int = 3, + ) -> Job: + """创建剪辑计划渲染任务。""" + use_case = CreateJobUseCase(self._job_repo) + return use_case.execute( + CreateJobCommand( + project_id=project_id, + job_type=JobType.RENDER_EDIT_PLAN, + payload={"plan_id": plan_id}, + source_id=plan_id, + created_by_user_id=user_id, + max_retries=max_retries, + ) + ) + + def create_job( + self, + project_id: str, + job_type: JobType | str, + *, + payload: dict | None = None, + source_id: str = "", + user_id: str = "", + max_retries: int = 3, + ) -> Job: + """创建通用任务。""" + use_case = CreateJobUseCase(self._job_repo) + return use_case.execute( + CreateJobCommand( + project_id=project_id, + job_type=job_type, + payload=payload or {}, + source_id=source_id, + created_by_user_id=user_id, + max_retries=max_retries, + ) + ) + + # ── 提交执行 ────────────────────────────────────────────────────────── + + def submit_job(self, job_id: str, celery_task_id: str = "") -> Job: + """提交任务执行。""" + use_case = SubmitJobUseCase(self._job_repo) + return use_case.execute(job_id, celery_task_id) + + # ── 进度更新 ────────────────────────────────────────────────────────── + + def update_progress(self, job_id: str, progress: float, stage: str = "") -> Job: + """更新任务进度。""" + use_case = UpdateJobProgressUseCase(self._job_repo) + return use_case.execute( + UpdateJobProgressCommand( + job_id=job_id, + progress=progress, + current_stage=stage, + ) + ) + + # ── 完成 / 失败 ──────────────────────────────────────────────────────── + + def complete_job(self, job_id: str, result: dict | None = None) -> Job: + """标记任务完成。""" + use_case = CompleteJobUseCase(self._job_repo) + return use_case.execute(CompleteJobCommand(job_id=job_id, result=result or {})) + + def fail_job(self, job_id: str, error_message: str) -> Job: + """标记任务失败。""" + use_case = FailJobUseCase(self._job_repo) + return use_case.execute(FailJobCommand(job_id=job_id, error_message=error_message)) + + # ── 重试 / 取消 ──────────────────────────────────────────────────────── + + def retry_job(self, job_id: str) -> Job: + """重试失败任务。""" + use_case = RetryJobUseCase(self._job_repo) + return use_case.execute(job_id) + + def cancel_job(self, job_id: str) -> Job: + """取消任务。""" + use_case = CancelJobUseCase(self._job_repo) + return use_case.execute(job_id) + + # ── 查询 ───────────────────────────────────────────────────────────── + + def get_job(self, job_id: str) -> Job | None: + """获取任务详情。""" + use_case = GetJobUseCase(self._job_repo) + return use_case.execute(job_id) + + def list_project_jobs( + self, + project_id: str, + *, + job_type: JobType | str | None = None, + status: JobStatus | str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[Job]: + """获取项目下的任务列表。""" + use_case = ListJobsUseCase(self._job_repo) + return use_case.execute( + project_id=project_id, + job_type=job_type, + status=status, + limit=limit, + offset=offset, + ) + + def list_user_jobs( + self, + user_id: str, + *, + job_type: JobType | str | None = None, + status: JobStatus | str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[Job]: + """获取用户的任务列表。""" + use_case = ListJobsUseCase(self._job_repo) + return use_case.execute( + user_id=user_id, + job_type=job_type, + status=status, + limit=limit, + offset=offset, + ) + + def get_statistics(self, project_id: str) -> dict[str, Any]: + """获取项目任务统计。""" + use_case = GetJobStatisticsUseCase(self._job_repo) + return use_case.execute(project_id) + + # ── 防重复检查 ──────────────────────────────────────────────────────── + + def has_active_job_for_source(self, source_id: str, job_type: JobType | str) -> bool: + """检查是否已有活跃任务(防止重复提交)。 + + Args: + source_id: 关联的业务实体 ID + job_type: 任务类型 + + Returns: + True 如果存在活跃任务 + """ + return self._job_repo.find_active_by_source(source_id, job_type) is not None + + # ── 便捷方法:带防重的视频合成提交 ────────────────────────────────────── + + def submit_compose_if_not_exists( + self, + project_id: str, + plan_id: str, + user_id: str, + celery_task_id: str = "", + ) -> tuple[Job, bool]: + """创建并提交视频合成任务(防重复)。 + + Returns: + (job, created): job 实例和是否新创建的标志 + """ + if self.has_active_job_for_source(plan_id, JobType.VIDEO_COMPOSE): + existing = self._job_repo.find_active_by_source(plan_id, JobType.VIDEO_COMPOSE) + logger.info("已存在活跃的视频合成任务: job_id=%s plan_id=%s", existing.id, plan_id) + return existing, False + + job = self.create_compose_job(project_id, plan_id, user_id) + job = self.submit_job(job.id, celery_task_id) + return job, True diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py new file mode 100644 index 000000000..b7befdd87 --- /dev/null +++ b/apps/api/app/services/video_compose_service.py @@ -0,0 +1,627 @@ +"""VideoComposeService — Phase 8 任务 2.09. + +FFmpeg 视频合成编排服务: + 1. 根据 EditPlan + EditPlanClips 生成 FFmpeg filter_complex 命令 + 2. 支持逐片段 scale / crop / trim / setpts 滤镜 + 3. 支持转场效果(fade / slide / dissolve / wipe) + 4. 支持音频流合并 + 5. 提供合成前校验逻辑 + +设计原则: + - 本服务只负责 **命令生成 + 校验**,不执行 FFmpeg + - Worker 层(Celery task)调用本服务生成命令后执行 + - API 层可调用 build_compose_command 做预览 / 调试 +""" + +from __future__ import annotations + +import logging +import shutil +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import ( + SQLAlchemyEditPlanClipRepository, +) +from packages.adapters.sqlalchemy_impl.edit_plan_repository import ( + SQLAlchemyEditPlanRepository, +) +from packages.domain.edit_plan import EditPlan, EditPlanStatus +from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus +from packages.domain.template_clip_config import TransitionEffect + +logger = logging.getLogger(__name__) + +# ── 常量 ────────────────────────────────────────────────────────────────────── + +DEFAULT_OUTPUT_WIDTH = 1280 +DEFAULT_OUTPUT_HEIGHT = 720 +DEFAULT_FPS = 25 +DEFAULT_CODEC = "libx264" +DEFAULT_CRF = 23 +DEFAULT_PRESET = "medium" + +# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称 +_XFADE_TRANSITION_MAP: dict[str, str] = { + TransitionEffect.FADE: "fade", + TransitionEffect.SLIDE_LEFT: "slideleft", + TransitionEffect.SLIDE_RIGHT: "slideright", + TransitionEffect.DISSOLVE: "dissolve", + TransitionEffect.WIPE: "wipeleft", +} + +# 转场默认时长(秒) +DEFAULT_TRANSITION_DURATION = 0.5 + + +# ── 数据结构 ────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class ClipFilterChain: + """单个片段的滤镜链描述。""" + + clip_id: str + input_index: int + video_label: str + audio_label: str | None + filters: list[str] + duration: float + + +@dataclass(frozen=True) +class ComposeCommand: + """完整的 FFmpeg 合成命令描述。""" + + command: list[str] + """可直接传给 subprocess.run 的命令列表。""" + + filter_complex: str + """-filter_complex 参数值(方便调试 / 日志)。""" + + input_paths: list[str] + """输入文件路径列表。""" + + output_path: str + """输出文件路径。""" + + estimated_duration: float + """预估输出时长(秒)。""" + + clip_chains: list[ClipFilterChain] + """每个片段的滤镜链描述。""" + + +@dataclass(frozen=True) +class ComposeValidation: + """合成前校验结果。""" + + valid: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + ready_clip_count: int = 0 + total_clip_count: int = 0 + + +# ── 服务主体 ────────────────────────────────────────────────────────────────── + + +class VideoComposeService: + """FFmpeg 视频合成编排服务。 + + 职责: + - 根据 EditPlan 及其 Clips 生成 FFmpeg filter_complex 命令 + - 校验合成前置条件 + - 提供合成状态查询 + + 用法:: + + svc = VideoComposeService(db) + validation = svc.validate_compose(plan_id) + if validation.valid: + cmd = svc.build_compose_command(plan_id, output_path="/tmp/out.mp4") + subprocess.run(cmd.command, check=True) + """ + + def __init__(self, db: Session) -> None: + self._db = db + self._plan_repo = SQLAlchemyEditPlanRepository(db) + self._clip_repo = SQLAlchemyEditPlanClipRepository(db) + + # ── 公开方法 ────────────────────────────────────────────────────────── + + def validate_compose(self, plan_id: str) -> ComposeValidation: + """校验剪辑计划是否可以合成。 + + 检查项: + 1. 计划存在 + 2. 计划状态为 editing 或 rendering + 3. 至少有一个 ready 状态的片段 + 4. 每个 ready 片段都有 asset_id + 5. 每个 ready 片段都有 duration > 0 + """ + errors: list[str] = [] + warnings: list[str] = [] + + plan = self._plan_repo.get(plan_id) + if plan is None: + return ComposeValidation( + valid=False, + errors=[f"剪辑计划不存在: {plan_id}"], + ) + + # 状态检查 + if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING): + errors.append( + f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}" + ) + + # 加载片段 + clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000) + if not clips: + errors.append("计划没有任何片段") + return ComposeValidation( + valid=False, + errors=errors, + total_clip_count=0, + ) + + # 按 order 排序 + clips.sort(key=lambda c: c.order) + + ready_count = 0 + pending_count = 0 + no_asset_count = 0 + no_duration_count = 0 + + for clip in clips: + if clip.status == EditPlanClipStatus.READY: + ready_count += 1 + if not clip.asset_id: + errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材") + no_asset_count += 1 + if clip.duration <= 0: + warnings.append( + f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长" + ) + no_duration_count += 1 + elif clip.status == EditPlanClipStatus.PENDING: + pending_count += 1 + elif clip.status == EditPlanClipStatus.FAILED: + warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过") + + if ready_count == 0: + errors.append("没有就绪(ready)的片段可以合成") + + if pending_count > 0: + warnings.append(f"有 {pending_count} 个片段仍处于 pending 状态") + + return ComposeValidation( + valid=len(errors) == 0, + errors=errors, + warnings=warnings, + ready_clip_count=ready_count, + total_clip_count=len(clips), + ) + + def build_compose_command( + self, + plan_id: str, + output_path: str, + *, + output_width: int = DEFAULT_OUTPUT_WIDTH, + output_height: int = DEFAULT_OUTPUT_HEIGHT, + fps: int = DEFAULT_FPS, + codec: str = DEFAULT_CODEC, + crf: int = DEFAULT_CRF, + preset: str = DEFAULT_PRESET, + transition_duration: float = DEFAULT_TRANSITION_DURATION, + ) -> ComposeCommand: + """构建 FFmpeg 合成命令。 + + 根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。 + + 滤镜链逻辑: + - 每个片段:scale → crop → setpts → trim → atrim + - 多片段之间:concat 滤镜 或 xfade 转场 + - 最终输出:-map '[outv]' -map '[outa]'(如有音频) + """ + plan = self._plan_repo.get(plan_id) + if plan is None: + raise ValueError(f"剪辑计划不存在: {plan_id}") + + clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000) + if not clips: + raise ValueError(f"剪辑计划没有片段: {plan_id}") + + # 只处理 ready 且有 asset_id 的片段 + ready_clips = [ + c for c in clips + if c.status == EditPlanClipStatus.READY and c.asset_id + ] + ready_clips.sort(key=lambda c: c.order) + + if not ready_clips: + raise ValueError(f"剪辑计划没有可合成的片段: {plan_id}") + + # 构建每个片段的滤镜链 + clip_chains: list[ClipFilterChain] = [] + input_paths: list[str] = [] + + for idx, clip in enumerate(ready_clips): + chain = self._build_clip_filter( + clip=clip, + input_index=idx, + output_width=output_width, + output_height=output_height, + fps=fps, + ) + clip_chains.append(chain) + input_paths.append(clip.asset_id) # asset_id 存储的是 storage_key / URL + + # 构建 filter_complex + filter_complex, estimated_duration = self._build_filter_complex( + clip_chains=clip_chains, + output_width=output_width, + output_height=output_height, + transition_duration=transition_duration, + transitions=[c.transition_effect for c in ready_clips], + ) + + # 构建完整命令 + command: list[str] = ["ffmpeg", "-y"] + + # 输入文件 + for path in input_paths: + command.extend(["-i", path]) + + # filter_complex + command.extend(["-filter_complex", filter_complex]) + + # 映射输出流 + command.extend(["-map", "[outv]"]) + if self._has_audio(clip_chains): + command.extend(["-map", "[outa]"]) + + # 编码参数 + command.extend([ + "-c:v", codec, + "-crf", str(crf), + "-preset", preset, + "-c:a", "aac", + "-b:a", "192k", + ]) + + # 输出 + command.append(output_path) + + return ComposeCommand( + command=command, + filter_complex=filter_complex, + input_paths=input_paths, + output_path=output_path, + estimated_duration=estimated_duration, + clip_chains=clip_chains, + ) + + def build_single_clip_command( + self, + clip_id: str, + output_path: str, + *, + output_width: int = DEFAULT_OUTPUT_WIDTH, + output_height: int = DEFAULT_OUTPUT_HEIGHT, + fps: int = DEFAULT_FPS, + ) -> ComposeCommand: + """为单个片段构建 FFmpeg 命令(预览 / 调试用)。""" + clip = self._clip_repo.get(clip_id) + if clip is None: + raise ValueError(f"片段不存在: {clip_id}") + if not clip.asset_id: + raise ValueError(f"片段没有分配素材: {clip_id}") + + chain = self._build_clip_filter( + clip=clip, + input_index=0, + output_width=output_width, + output_height=output_height, + fps=fps, + ) + + # 简单命令:input → filter → output + filter_str = ",".join(chain.filters) + command = [ + "ffmpeg", "-y", + "-i", clip.asset_id, + "-filter_complex", f"{filter_str}[outv]", + "-map", "[outv]", + "-c:v", DEFAULT_CODEC, + "-crf", str(DEFAULT_CRF), + "-preset", DEFAULT_PRESET, + output_path, + ] + + return ComposeCommand( + command=command, + filter_complex=filter_str, + input_paths=[clip.asset_id], + output_path=output_path, + estimated_duration=clip.duration, + clip_chains=[chain], + ) + + def get_compose_status(self, plan_id: str) -> dict[str, Any]: + """获取合成状态摘要。""" + plan = self._plan_repo.get(plan_id) + if plan is None: + raise ValueError(f"剪辑计划不存在: {plan_id}") + + clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000) + clips.sort(key=lambda c: c.order) + + total_duration = sum(c.duration for c in clips if c.duration > 0) + ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY] + pending_clips = [c for c in clips if c.status == EditPlanClipStatus.PENDING] + rendered_clips = [c for c in clips if c.status == EditPlanClipStatus.RENDERED] + failed_clips = [c for c in clips if c.status == EditPlanClipStatus.FAILED] + + return { + "plan_id": plan_id, + "plan_status": plan.status.value, + "total_clips": len(clips), + "ready_clips": len(ready_clips), + "pending_clips": len(pending_clips), + "rendered_clips": len(rendered_clips), + "failed_clips": len(failed_clips), + "total_duration": total_duration, + "can_compose": len(ready_clips) > 0 and plan.status in ( + EditPlanStatus.EDITING, + EditPlanStatus.RENDERING, + ), + "rendered_url": plan.config.get("rendered_url", ""), + } + + # ── 内部方法 ────────────────────────────────────────────────────────── + + @staticmethod + def _build_clip_filter( + clip: EditPlanClip, + input_index: int, + output_width: int, + output_height: int, + fps: int, + ) -> ClipFilterChain: + """为单个片段构建滤镜链。 + + 滤镜顺序: + 1. scale — 等比缩放到目标分辨率(保证覆盖) + 2. crop — 居中裁剪到目标分辨率 + 3. setpts — 重置时间戳 + 偏移 + 4. trim — 视频时长裁剪 + 5. atrim — 音频时长裁剪(如有音频流) + """ + duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒 + start = clip.start_time + + filters: list[str] = [] + + # 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop) + filters.append( + f"scale={output_width}:{output_height}" + f":force_original_aspect_ratio=increase" + ) + + # 2. crop: 居中裁剪 + filters.append(f"crop={output_width}:{output_height}") + + # 3. setpts: 重置时间戳 + if start > 0: + filters.append(f"setpts=PTS-STARTPTS+{start}/TB") + else: + filters.append("setpts=PTS-STARTPTS") + + # 4. trim: 视频时长 + filters.append(f"trim=0:{duration}") + filters.append(f"setpts=PTS-STARTPTS") # trim 后需要重置 PTS + + video_label = f"v{input_index}" + + # 5. 音频标签:仅当片段类型可能有音频时才设置 + # title/subtitle 是纯文字/图片卡片,没有音频流 + clip_type = clip.clip_type.lower() if clip.clip_type else "" + has_audio_stream = clip_type not in ("title", "subtitle") + audio_label = f"a{input_index}" if has_audio_stream else None + + return ClipFilterChain( + clip_id=clip.id, + input_index=input_index, + video_label=video_label, + audio_label=audio_label, + filters=filters, + duration=duration, + ) + + @staticmethod + def _build_filter_complex( + clip_chains: list[ClipFilterChain], + output_width: int, + output_height: int, + transition_duration: float, + transitions: list[str], + ) -> tuple[str, float]: + """构建完整的 filter_complex 字符串。 + + 策略: + - 单片段:直接输出 + - 多片段 + 全 cut:使用 concat 滤镜(高效) + - 多片段 + 有转场:使用 xfade 滤镜链 + + 返回 (filter_complex_string, estimated_total_duration)。 + """ + n = len(clip_chains) + + if n == 0: + return "", 0.0 + + # ── 单片段 ───────────────────────────────────────────────────── + if n == 1: + chain = clip_chains[0] + filter_str = _chain_filters(chain.filters, chain.video_label) + # 音频 + if chain.audio_label: + filter_str += f";[0:a]{chain.audio_label}" + total_duration = chain.duration + return filter_str, total_duration + + # ── 检查是否有转场 ───────────────────────────────────────────── + has_transitions = any( + t != TransitionEffect.CUT and t != "cut" + for t in transitions + ) + + if not has_transitions: + return _build_concat_filter(clip_chains) + + # ── 有转场:使用 xfade ───────────────────────────────────────── + return _build_xfade_filter( + clip_chains=clip_chains, + transition_duration=transition_duration, + transitions=transitions, + ) + + @staticmethod + def _has_audio(clip_chains: list[ClipFilterChain]) -> bool: + """是否有任何片段包含音频流。""" + return any(c.audio_label is not None for c in clip_chains) + + +# ── 模块级辅助函数 ──────────────────────────────────────────────────────────── + + +def _chain_filters(filters: list[str], output_label: str) -> str: + """将滤镜列表串联为 FFmpeg 滤镜字符串。""" + filter_body = ",".join(filters) + return f"[0:v]{filter_body}[{output_label}]" + + +def _build_concat_filter( + clip_chains: list[ClipFilterChain], +) -> tuple[str, float]: + """构建 concat 滤镜(无转场,高效拼接)。 + + 格式: + [0:v]filters[v0]; [1:v]filters[v1]; ... + [v0][v1]...[vN]concat=n=N:v=1:a=0[outv] + """ + n = len(clip_chains) + parts: list[str] = [] + total_duration = 0.0 + + # 每个片段的滤镜链 + for idx, chain in enumerate(clip_chains): + filter_body = ",".join(chain.filters) + parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]") + total_duration += chain.duration + + # concat 滤镜 + concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains) + concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]" + parts.append(concat_filter) + + # 音频 concat(如果有) + audio_parts: list[str] = [] + for idx, chain in enumerate(clip_chains): + if chain.audio_label: + audio_parts.append( + f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]" + ) + + if audio_parts: + parts.extend(audio_parts) + audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label) + audio_count = sum(1 for c in clip_chains if c.audio_label) + if audio_count > 0: + parts.append( + f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]" + ) + + return ";".join(parts), total_duration + + +def _build_xfade_filter( + clip_chains: list[ClipFilterChain], + transition_duration: float, + transitions: list[str], +) -> tuple[str, float]: + """构建 xfade 转场滤镜链。 + + 每两个相邻片段之间插入 xfade 转场。 + offset = 前一个片段的累积时长 - 转场时长。 + + 格式(2 片段): + [0:v]filters[v0]; [1:v]filters[v1]; + [v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv] + + 格式(3+ 片段): + [v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv] + """ + n = len(clip_chains) + parts: list[str] = [] + total_duration = 0.0 + + # 每个片段的滤镜链 + for idx, chain in enumerate(clip_chains): + filter_body = ",".join(chain.filters) + parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]") + total_duration += chain.duration + + # xfade 链 + if n == 1: + # 单片段不需要 xfade + parts.append(f"[{clip_chains[0].video_label}]copy[outv]") + return ";".join(parts), total_duration + + # 计算每个转场的 offset + cumulative = 0.0 + prev_label = clip_chains[0].video_label + + for i in range(1, n): + cumulative += clip_chains[i - 1].duration + offset = max(0.0, cumulative - transition_duration * i) + + # 获取转场类型 + transition = transitions[i] if i < len(transitions) else "cut" + xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade") + + if i == n - 1: + # 最后一个转场,输出到 [outv] + out_label = "outv" + else: + out_label = f"xf{i}" + + parts.append( + f"[{prev_label}][{clip_chains[i].video_label}]" + f"xfade=transition={xfade_transition}" + f":duration={transition_duration}" + f":offset={offset:.3f}" + f"[{out_label}]" + ) + prev_label = out_label + + # 总时长需要减去转场重叠部分 + total_duration -= transition_duration * (n - 1) + + # 音频 crossfade(简化处理:使用 adelay + amix) + audio_labels = [c.audio_label for c in clip_chains if c.audio_label] + if len(audio_labels) >= 2: + # 简单拼接音频(不做 crossfade) + audio_inputs = "".join(f"[{label}]" for label in audio_labels) + parts.append( + f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]" + ) + elif len(audio_labels) == 1: + parts.append(f"[{audio_labels[0]}]acopy[outa]") + + return ";".join(parts), max(0.0, total_duration) diff --git a/apps/web/src/api/accounts.ts b/apps/web/src/api/accounts.ts new file mode 100644 index 000000000..24209fcd2 --- /dev/null +++ b/apps/web/src/api/accounts.ts @@ -0,0 +1,161 @@ +/** + * 账号管理 Mock API + * + * 模拟多平台账号绑定/解绑操作 + * 支持平台:抖音、快手、小红书、微信视频号 + */ + +/* ── 类型定义 ───────────────────────────────────────────── */ + +/** 平台 ID */ +export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat"; + +/** 账号状态 */ +export type AccountStatus = "active" | "expired" | "limited"; + +/** 已绑定的账号 */ +export interface Account { + id: string; + platform_id: PlatformId; + name: string; + avatar?: string; + status: AccountStatus; + bound_at: string; +} + +/** 平台信息 */ +export interface Platform { + id: PlatformId; + name: string; + subName: string; + icon: string; + gradient: string; +} + +/** 绑定账号请求 */ +export interface BindAccountRequest { + platform_id: PlatformId; + name: string; +} + +/* ── 平台配置 ───────────────────────────────────────────── */ + +export const PLATFORMS: Platform[] = [ + { + id: "douyin", + name: "抖音", + subName: "短视频发布平台", + icon: "📱", + gradient: "linear-gradient(135deg, #fe2c55, #25f4ee)", + }, + { + id: "kuaishou", + name: "快手", + subName: "短视频发布平台", + icon: "🎬", + gradient: "linear-gradient(135deg, #ff4906, #ffba00)", + }, + { + id: "xiaohongshu", + name: "小红书", + subName: "种草笔记发布平台", + icon: "📕", + gradient: "linear-gradient(135deg, #ff2442, #ff6b6b)", + }, + { + id: "wechat", + name: "微信视频号", + subName: "视频号发布平台", + icon: "💬", + gradient: "linear-gradient(135deg, #07c160, #4cd964)", + }, +]; + +/* ── Mock 数据 ───────────────────────────────────────────── */ + +let MOCK_ACCOUNTS: Account[] = [ + { + id: "acc-001", + platform_id: "douyin", + name: "小虾官方号", + avatar: "🦐", + status: "active", + bound_at: "2025-12-01T10:00:00Z", + }, + { + id: "acc-002", + platform_id: "douyin", + name: "小虾日常", + avatar: "🐟", + status: "active", + bound_at: "2025-12-15T14:30:00Z", + }, + { + id: "acc-003", + platform_id: "kuaishou", + name: "小虾剪辑", + avatar: "🎬", + status: "active", + bound_at: "2026-01-05T09:00:00Z", + }, + { + id: "acc-004", + platform_id: "xiaohongshu", + name: "小虾种草", + avatar: "📕", + status: "limited", + bound_at: "2026-02-20T16:00:00Z", + }, +]; + +/* ── 模拟延迟 ───────────────────────────────────────────── */ + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/* ── API 函数 ───────────────────────────────────────────── */ + +/** 获取指定平台的账号列表 */ +export async function getAccountsByPlatform( + platformId: PlatformId, +): Promise { + await delay(300); + return MOCK_ACCOUNTS.filter((a) => a.platform_id === platformId); +} + +/** 获取所有平台的账号总数 */ +export async function getAllAccounts(): Promise { + await delay(200); + return [...MOCK_ACCOUNTS]; +} + +/** 绑定新账号 */ +export async function bindAccount(data: BindAccountRequest): Promise { + await delay(500); + const newAccount: Account = { + id: `acc-${Date.now()}`, + platform_id: data.platform_id, + name: data.name, + avatar: undefined, + status: "active", + bound_at: new Date().toISOString(), + }; + MOCK_ACCOUNTS = [...MOCK_ACCOUNTS, newAccount]; + return newAccount; +} + +/** 解绑账号 */ +export async function unbindAccount(accountId: string): Promise { + await delay(400); + MOCK_ACCOUNTS = MOCK_ACCOUNTS.filter((a) => a.id !== accountId); +} + +/* ── 状态配置 ───────────────────────────────────────────── */ + +export const ACCOUNT_STATUS_CONFIG: Record< + AccountStatus, + { label: string; className: string } +> = { + active: { label: "正常", className: "acc-status--active" }, + expired: { label: "已过期", className: "acc-status--expired" }, + limited: { label: "受限", className: "acc-status--limited" }, +}; diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts new file mode 100644 index 000000000..453412d32 --- /dev/null +++ b/apps/web/src/api/editPlans.ts @@ -0,0 +1,289 @@ +/** + * 剪辑计划 API — 对接后端 Edit Plans Schema + * 字段名严格匹配后端 API 响应 + */ +import apiClient from "./client"; +import type { AssetItem } from "./assets"; + +/* ============================================================ + * 后端 API 类型(严格匹配后端 Schema) + * ============================================================ */ + +/** 剪辑计划状态枚举 */ +export type EditPlanStatus = + | "draft" + | "editing" + | "rendering" + | "completed" + | "failed"; + +/** 剪辑计划(后端响应) */ +export interface EditPlan { + id: string; + template_id: string; + name: string; + status: EditPlanStatus; + total_duration: number; + config: Record; + created_at: string; + updated_at: string; +} + +/** 创建剪辑计划请求(后端要求 template_id + name 必填) */ +export interface CreateEditPlanRequest { + template_id: string; + name: string; + config?: Record; + total_duration?: number; +} + +/** 更新剪辑计划请求 */ +export interface UpdateEditPlanRequest { + name?: string; + config?: Record; + total_duration?: number; + status?: EditPlanStatus; +} + +/** 生成响应 */ +export interface GenerateResponse { + plan_id: string; + plan_status: EditPlanStatus; + generation_task_id: string; + clip_count: number; +} + +/** 片段生成状态 */ +export interface ClipStatusItem { + clip_id: string; + clip_type: string; + order: number; + status: string; + asset_id?: string; + text_content?: string; + duration?: number; +} + +/** 生成状态轮询响应 */ +export interface GenerationStatusResponse { + plan_id: string; + plan_status: EditPlanStatus; + generation_task_id?: string; + clips: ClipStatusItem[]; +} + +/* ============================================================ + * 前端 UI 类型(EditingPlanner 组件依赖,保留兼容) + * ============================================================ */ + +/** 剪辑计划中的片段(UI 层类型) */ +export interface EditPlanClip { + id: string; + template_segment_id: string; + /** 素材库中的素材 ID */ + media_asset_id?: string; + /** 素材类型 */ + material_type: "video" | "image" | "audio" | "voiceover"; + /** 片段文案 */ + script_text: string; + /** 实际时长(秒) */ + duration: number; + /** 转场效果 */ + transition?: TransitionEffect; + /** 排序 */ + order: number; +} + +/** 转场效果 */ +export interface TransitionEffect { + type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide"; + duration: number; // 转场时长(秒) +} + +/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */ +export interface MediaAsset { + id: string; + name: string; + type: "video" | "image" | "audio"; + /** 缩略图 URL */ + thumbnail_url?: string; + /** 时长(秒),仅 video/audio */ + duration?: number; + /** 文件大小(字节) */ + size?: number; + /** 标签 */ + tags: string[]; + created_at: string; + /** 质量分 0-100 */ + quality_score?: number; + /** 分类状态 */ + classification_status?: "pending" | "processing" | "completed" | "failed"; +} + +/* ============================================================ + * API 函数 — 严格对接后端 + * ============================================================ */ + +/** 获取剪辑计划列表 */ +export async function getEditPlans(params?: { + page?: number; + page_size?: number; + template_id?: string; + status?: string; +}): Promise { + const response = await apiClient.get("/edit-plans", { params }); + return response.data.items || []; +} + +/** 获取单个剪辑计划 */ +export async function getEditPlan(planId: string): Promise { + const response = await apiClient.get(`/edit-plans/${planId}`); + return response.data; +} + +/** 创建剪辑计划 */ +export async function createEditPlan( + data: CreateEditPlanRequest, +): Promise { + const response = await apiClient.post("/edit-plans", data); + return response.data; +} + +/** 更新剪辑计划 */ +export async function updateEditPlan( + planId: string, + data: UpdateEditPlanRequest, +): Promise { + const response = await apiClient.put(`/edit-plans/${planId}`, data); + return response.data; +} + +/** 删除剪辑计划 */ +export async function deleteEditPlan(planId: string): Promise { + await apiClient.delete(`/edit-plans/${planId}`); +} + +/** 触发剪辑计划生成 */ +export async function generateEditPlan( + planId: string, +): Promise { + const response = await apiClient.post(`/edit-plans/${planId}/generate`); + return response.data; +} + +/** 获取剪辑计划生成状态(轮询用) */ +export async function getGenerationStatus( + planId: string, +): Promise { + const response = await apiClient.get( + `/edit-plans/${planId}/generation-status`, + ); + return response.data; +} + +/** + * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx + * 将后端 AssetResponse 映射为前端 MediaAsset 类型 + */ +export async function getMediaAssets( + libraryId?: string, +): Promise { + const response = await apiClient.get("/assets", { + params: libraryId ? { library_id: libraryId } : undefined, + }); + const items: AssetItem[] = response.data.items || []; + return items.map(mapAssetToMediaAsset); +} + +/** 获取单个素材 */ +export async function getMediaAsset(id: string): Promise { + const response = await apiClient.get(`/assets/${id}`); + return mapAssetToMediaAsset(response.data); +} + +/* ============================================================ + * 映射函数:AssetResponse → MediaAsset + * ============================================================ */ + +function inferMediaType(mimeType: string): "video" | "image" | "audio" { + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("image/")) return "image"; + return "audio"; +} + +function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { + const meta = (asset.metadata || {}) as Record; + const ext = asset as AssetItem & Record; + 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, + 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"], + }; +} + +/* ============================================================ + * 常量 + * ============================================================ */ + +/** 转场效果选项 */ +export const TRANSITION_OPTIONS: { + value: TransitionEffect["type"]; + label: string; +}[] = [ + { value: "none", label: "无转场" }, + { value: "fade", label: "淡入淡出" }, + { value: "dissolve", label: "溶解" }, + { value: "wipe", label: "擦除" }, + { value: "zoom", label: "缩放" }, + { value: "slide", label: "滑动" }, +]; + +/** 素材类型标签 */ +export const MATERIAL_TYPE_LABELS: Record = { + video: "视频", + image: "图片", + audio: "音频", + voiceover: "配音", +}; + +/** 素材类型图标 */ +export const MATERIAL_TYPE_ICONS: Record = { + video: "🎬", + image: "🖼️", + audio: "🎵", + voiceover: "🎙️", +}; + +/** 计划状态标签 */ +export const PLAN_STATUS_LABELS: Record = { + draft: "草稿", + editing: "编辑中", + rendering: "渲染中", + completed: "已完成", + failed: "失败", +}; + +/** 质量分筛选选项 */ +export const QUALITY_OPTIONS: { + value: string; + label: string; + min?: number; + max?: number; +}[] = [ + { value: "all", label: "全部质量" }, + { value: "high", label: "高质量 (80-100)", min: 80, max: 100 }, + { value: "medium", label: "中质量 (50-79)", min: 50, max: 79 }, + { value: "low", label: "低质量 (0-49)", min: 0, max: 49 }, +]; diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 23276f5c7..4e80832f5 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -70,6 +70,12 @@ export const getUserTasks = async (): Promise => { return data.items || []; }; +/** 获取单个任务详情(用于轮询进度) */ +export const getTask = async (taskId: string): Promise => { + const { data } = await apiClient.get(`/tasks/${taskId}`); + return data; +}; + /** 重试失败的任务 */ export const retryTask = async (taskId: string): Promise => { const { data } = await apiClient.post(`/tasks/${taskId}/retry`); diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts new file mode 100644 index 000000000..6010d8c84 --- /dev/null +++ b/apps/web/src/api/tts.ts @@ -0,0 +1,127 @@ +/** + * TTS 语音合成 API + * 对接后端 /api/v1/tts/* 端点 + * + * 任务 3.14 新增 + */ +import apiClient from "./client"; + +/* ── 类型定义 ──────────────────────────────────── */ + +/** TTS 合成请求参数 */ +export interface TTSSynthesizeRequest { + text: string; + voice_id?: string; + output_name?: string; + language?: string; + speed?: number; + voice_model?: string; + voice_clone_profile_id?: string; + format?: string; + metadata?: Record; +} + +/** TTS 合成创建响应 */ +export interface TTSSynthesizeResponse { + job_id: string; + status: string; + message: string; +} + +/** TTS 任务详情 */ +export interface TTSJob { + id: string; + user_id: string; + project_id: string | null; + text: string; + voice_id: string | null; + voice_model: string | null; + voice_clone_profile_id: string | null; + language: string; + speed: number; + output_name: string | null; + output_audio_url: string | null; + output_format: string; + duration_seconds: number | null; + file_size_bytes: number | null; + sample_rate: number | null; + status: string; + error_message: string | null; + retry_count: number; + max_retries: number; + metadata_: Record | null; + created_at: string; + updated_at: string; +} + +/** TTS 任务状态(轻量轮询用) */ +export interface TTSJobStatus { + id: string; + status: string; + output_audio_url: string | null; + error_message: string | null; + duration_seconds: number | null; + retry_count: number; +} + +/** TTS 任务列表响应 */ +export interface TTSJobListResponse { + items: TTSJob[]; + total: number; + skip: number; + limit: number; +} + +/** TTS 任务列表查询参数 */ +export interface TTSJobListParams { + status?: string; + skip?: number; + limit?: number; +} + +/* ── API 函数 ──────────────────────────────────── */ + +/** 创建 TTS 合成任务 */ +export const synthesizeSpeech = async ( + data: TTSSynthesizeRequest, +): Promise => { + const response = await apiClient.post( + "/tts/synthesize", + data, + ); + return response.data; +}; + +/** 获取 TTS 任务详情 */ +export const getTTSJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/tts/jobs/${jobId}`); + return response.data; +}; + +/** 获取 TTS 任务状态(轻量轮询) */ +export const getTTSJobStatus = async (jobId: string): Promise => { + const response = await apiClient.get( + `/tts/jobs/${jobId}/status`, + ); + return response.data; +}; + +/** 获取 TTS 任务列表 */ +export const getTTSJobs = async ( + params?: TTSJobListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/tts/jobs${qs ? `?${qs}` : ""}`, + ); + return response.data; +}; + +/** 删除 TTS 任务 */ +export const deleteTTSJob = async (jobId: string): Promise => { + await apiClient.delete(`/tts/jobs/${jobId}`); +}; diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts new file mode 100644 index 000000000..19c4998bb --- /dev/null +++ b/apps/web/src/api/voiceClone.ts @@ -0,0 +1,211 @@ +/** + * 音色克隆 API + * 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05) + * 任务 3.15:新增 progress 字段用于进度展示 + */ +import apiClient from "./client"; + +/* ── 前端兼容类型 ─────────────────────────────────────── */ + +/** 克隆音色状态(前端展示用) */ +export type VoiceCloneStatus = "ready" | "processing" | "failed"; + +/** 克隆音色条目(前端展示用) */ +export interface VoiceClone { + id: string; + name: string; + description: string; + duration_seconds: number; + status: VoiceCloneStatus; + /** 克隆进度 0-100,仅 processing 状态时有值 */ + progress: number; + sample_url?: string; + language: string; + gender: string; + error_message: string | null; + created_at: string; + updated_at: string; +} + +/** 创建克隆请求(前端简化版) */ +export interface CreateVoiceCloneRequest { + name: string; + audio_url: string; + description?: string; +} + +/* ── 后端 API 类型 ────────────────────────────────────── */ + +/** 后端克隆档案响应 */ +export interface VoiceCloneProfile { + id: string; + user_id: string; + name: string; + description: string; + source_audio_url: string; + voice_id: string | null; + voice_model: string; + language: string; + gender: string; + status: "pending" | "processing" | "ready" | "failed"; + error_message: string | null; + retry_count: number; + max_retries: number; + metadata_: Record | null; + created_at: string; + updated_at: string; +} + +/** 后端克隆列表响应 */ +export interface ListVoiceCloneResponse { + items: VoiceCloneProfile[]; + total: number; +} + +/** 后端克隆状态响应 */ +export interface VoiceCloneStatusResponse { + id: string; + status: "pending" | "processing" | "ready" | "failed"; + error_message: string | null; + voice_id: string | null; + retry_count: number; +} + +/** 后端创建克隆请求(完整版) */ +export interface CreateVoiceCloneRequestFull { + name: string; + description?: string; + source_audio_url: string; + voice_model?: string; + language?: string; + gender?: string; + max_retries?: number; + metadata_?: Record; +} + +/* ── 辅助函数 ─────────────────────────────────────────── */ + +/** + * 将后端 VoiceCloneProfile 转换为前端 VoiceClone + * 后端 status "pending" 映射为前端 "processing" + */ +export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ + id: profile.id, + name: profile.name, + description: profile.description || "", + duration_seconds: 0, + status: profile.status === "pending" ? "processing" : profile.status, + progress: 0, + sample_url: profile.source_audio_url || undefined, + language: profile.language || "", + gender: profile.gender || "", + error_message: profile.error_message || null, + created_at: profile.created_at, + updated_at: profile.updated_at, +}); + +/** 格式化时长 */ +export const formatDuration = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, "0")}`; +}; + +/* ── 查询参数 ─────────────────────────────────────────── */ + +export interface VoiceCloneListParams { + status?: string; + skip?: number; + limit?: number; +} + +/* ── API 函数 ─────────────────────────────────────────── */ + +/** 获取克隆音色列表(返回前端兼容数组) */ +export const getVoiceClones = async ( + params?: VoiceCloneListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/voice-clones${qs ? `?${qs}` : ""}`, + ); + return response.data.items.map(toVoiceClone); +}; + +/** 获取克隆音色列表(返回完整响应含 total) */ +export const getVoiceClonesWithTotal = async ( + params?: VoiceCloneListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/voice-clones${qs ? `?${qs}` : ""}`, + ); + return response.data; +}; + +/** 获取单个克隆音色详情 */ +export const getVoiceCloneDetail = async ( + id: string, +): Promise => { + const response = await apiClient.get(`/voice-clones/${id}`); + return response.data; +}; + +/** 创建克隆音色 */ +export const createVoiceClone = async ( + data: CreateVoiceCloneRequest, +): Promise => { + const payload: CreateVoiceCloneRequestFull = { + name: data.name, + description: data.description, + source_audio_url: data.audio_url, + }; + const response = await apiClient.post( + "/voice-clones", + payload, + ); + return response.data; +}; + +/** 删除克隆音色 */ +export const deleteVoiceClone = async (id: string): Promise => { + await apiClient.delete(`/voice-clones/${id}`); +}; + +/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */ +export const updateVoiceClone = async ( + id: string, + data: Partial>, +): Promise => { + // 后端暂未提供更新端点,暂用详情接口模拟 + const response = await apiClient.get(`/voice-clones/${id}`); + return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() }); +}; + +/** 获取克隆状态 */ +export const getVoiceCloneStatus = async ( + id: string, +): Promise => { + const response = await apiClient.get( + `/voice-clones/${id}/status`, + ); + return response.data; +}; + +/** 重试克隆 */ +export const retryVoiceClone = async ( + id: string, +): Promise => { + const response = await apiClient.post( + `/voice-clones/${id}/retry`, + ); + return response.data; +}; diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices.ts index a0fd1b1f1..1c3aa2095 100644 --- a/apps/web/src/api/voices.ts +++ b/apps/web/src/api/voices.ts @@ -1,10 +1,94 @@ /** * 配音相关 API * Phase 1 新增:全局配音库 + * + * 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容 */ import apiClient from "./client"; -/** 配音条目 */ +/* ── 统一音色 API(后端 3.04) ─────────────────────────── */ + +/** 统一音色条目(preset + clone 混合) */ +export interface UnifiedVoiceItem { + id: string; + type: "preset" | "clone"; + name: string; + description: string; + gender: string; + language: string; + voice_id: string; + voice_provider: string; + audio_url: string | null; + preview_url: string | null; + duration: number | null; + file_size: number | null; + status: string; + tags: string[]; + user_id: string | null; + project_id: string | null; + voice_clone_profile_id: string | null; + created_at: string | null; + updated_at: string | null; +} + +/** 统一音色列表响应 */ +export interface UnifiedVoiceListResponse { + items: UnifiedVoiceItem[]; + total: number; + preset_count: number; + clone_count: number; +} + +/** 预设音色条目 */ +export interface PresetVoiceItem { + voice_id: string; + name: string; + description: string; + gender: string; + language: string; + preview_url: string | null; + tags: string[]; +} + +/** 预设音色列表响应 */ +export interface PresetVoiceListResponse { + items: PresetVoiceItem[]; + total: number; +} + +/** 统一列表查询参数 */ +export interface UnifiedVoiceListParams { + type?: "preset" | "clone"; + status?: string; + skip?: number; + limit?: number; +} + +/** 获取统一音色列表(推荐) */ +export const fetchVoices = async ( + params?: UnifiedVoiceListParams, +): Promise => { + const searchParams = new URLSearchParams(); + if (params?.type) searchParams.set("type", params.type); + if (params?.status) searchParams.set("status", params.status); + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + const qs = searchParams.toString(); + const response = await apiClient.get( + `/voices${qs ? `?${qs}` : ""}`, + ); + return response.data; +}; + +/** 获取预设音色列表(无需鉴权) */ +export const fetchPresetVoices = async (): Promise => { + const response = await apiClient.get("/voices/presets"); + return response.data; +}; + +/* ── 向后兼容(旧接口) ────────────────────────────────── */ + +/** 配音条目(旧) */ export interface VoiceItem { id: string; name: string; @@ -19,16 +103,16 @@ export interface VoiceItem { updated_at?: string; } -/** 创建配音请求 */ +/** 创建配音请求(旧) */ export interface CreateVoiceRequest { name: string; text: string; voice_type?: string; } -/** 获取当前用户的所有配音 */ +/** 获取当前用户的所有配音(旧 → /voices/legacy) */ export const getVoices = async (): Promise => { - const response = await apiClient.get("/voices"); + const response = await apiClient.get("/voices/legacy"); return response.data.items || response.data || []; }; @@ -45,7 +129,7 @@ export const updateVoice = async ( voiceId: string, data: Partial, ): Promise => { - const response = await apiClient.patch(`/voices/${voiceId}`, data); + const response = await apiClient.put(`/voices/${voiceId}`, data); return response.data; }; diff --git a/apps/web/src/components/AssetSelector/AssetSelector.css b/apps/web/src/components/AssetSelector/AssetSelector.css new file mode 100644 index 000000000..3e19910da --- /dev/null +++ b/apps/web/src/components/AssetSelector/AssetSelector.css @@ -0,0 +1,527 @@ +/** + * 素材选择器 - V21 设计系统样式 + * 支持批量选择、拖拽排序、预览缩略图、质量分筛选 + * 前缀: as- (AssetSelector) + */ +@import "../../styles/global.css"; + +/* ============================================================ + 容器 + ============================================================ */ +.as-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* ── 工具栏 ─────────────────────────────────────────────────── */ +.as-toolbar { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + flex-wrap: wrap; +} + +.as-toolbar-left { + display: flex; + align-items: center; + gap: var(--space-sm); + flex: 1; + min-width: 0; +} + +.as-toolbar-right { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +/* 搜索框 */ +.as-search { + flex: 1; + min-width: 120px; + max-width: 200px; +} + +/* 视图切换按钮 */ +.as-view-btn { + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + transition: var(--transition-all); + font-size: 14px; +} + +.as-view-btn:hover { + color: var(--primary-color); + border-color: var(--primary-color); +} + +.as-view-btn.active { + background: var(--primary-soft); + color: var(--primary-color); + border-color: var(--primary-color); +} + +/* ── 批量操作栏 ─────────────────────────────────────────────── */ +.as-batch-bar { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 6px var(--space-md); + background: var(--primary-soft); + border-bottom: 1px solid var(--color-primary-200); + font-size: var(--font-size-sm); + color: var(--primary-color); + flex-shrink: 0; +} + +.as-batch-bar-count { + font-weight: 600; +} + +.as-batch-bar-actions { + display: flex; + gap: var(--space-xs); + margin-left: auto; +} + +/* ── 素材列表区域 ───────────────────────────────────────────── */ +.as-body { + flex: 1; + overflow-y: auto; + padding: var(--space-sm); +} + +.as-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-2xl) var(--space-md); + text-align: center; + color: var(--text-secondary); +} + +.as-empty-icon { + font-size: 36px; + margin-bottom: var(--space-sm); + opacity: 0.4; +} + +.as-empty p { + margin: 0; + font-size: var(--font-size-sm); +} + +/* ── 网格视图 ───────────────────────────────────────────────── */ +.as-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: var(--space-sm); +} + +.as-grid.compact { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 6px; +} + +/* ── 列表视图 ───────────────────────────────────────────────── */ +.as-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* ============================================================ + 素材卡片 — 网格模式 + ============================================================ */ +.as-card { + position: relative; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: var(--transition-all); + background: var(--bg-primary); + user-select: none; +} + +.as-card:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-sm); +} + +.as-card.selected { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-soft); +} + +.as-card.dragging { + opacity: 0.4; +} + +.as-card.drag-over { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-color); +} + +/* 缩略图区域 */ +.as-card-thumb { + position: relative; + aspect-ratio: 9 / 16; + background: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.as-card-thumb-icon { + font-size: 28px; + opacity: 0.4; +} + +.as-card-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 类型角标 */ +.as-card-type-badge { + position: absolute; + top: 4px; + left: 4px; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 10px; + line-height: 1.4; + backdrop-filter: blur(4px); +} + +/* 时长角标 */ +.as-card-duration { + position: absolute; + bottom: 4px; + right: 4px; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 10px; + font-weight: 500; + line-height: 1.4; +} + +/* 质量分角标 */ +.as-card-quality { + position: absolute; + top: 4px; + right: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 700; + color: #fff; +} + +.as-card-quality.excellent { + background: var(--success-color, #10b981); +} + +.as-card-quality.good { + background: var(--primary-color, #6366f1); +} + +.as-card-quality.fair { + background: var(--warning-color, #f59e0b); +} + +.as-card-quality.poor { + background: var(--error-color, #ef4444); +} + +/* 选择 checkbox */ +.as-card-checkbox { + position: absolute; + top: 4px; + left: 4px; + z-index: 2; + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + border: 2px solid rgba(255, 255, 255, 0.7); + background: rgba(0, 0, 0, 0.2); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-all); + backdrop-filter: blur(4px); +} + +.as-card-checkbox.checked { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.as-card-checkbox.checked::after { + content: "✓"; + color: #fff; + font-size: 11px; + font-weight: 700; +} + +/* 有 checkbox 时,类型角标右移 */ +.as-card.has-checkbox .as-card-type-badge { + left: 26px; +} + +/* 卡片信息 */ +.as-card-info { + padding: 6px 8px; +} + +.as-card-name { + font-size: 11px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + line-height: 1.3; +} + +.as-card-meta { + font-size: 10px; + color: var(--text-secondary); + margin-top: 2px; +} + +/* ============================================================ + 素材卡片 — 列表模式 + ============================================================ */ +.as-list-item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 6px 8px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition-all); + user-select: none; +} + +.as-list-item:hover { + background: var(--bg-secondary); + border-color: var(--border-color); +} + +.as-list-item.selected { + background: var(--primary-soft); + border-color: var(--primary-color); +} + +.as-list-item.dragging { + opacity: 0.4; +} + +.as-list-item.drag-over { + border-top: 2px solid var(--primary-color); +} + +.as-list-item-checkbox { + width: 16px; + height: 16px; + border-radius: 3px; + border: 2px solid var(--border-color); + flex-shrink: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-all); +} + +.as-list-item-checkbox.checked { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.as-list-item-checkbox.checked::after { + content: "✓"; + color: #fff; + font-size: 10px; + font-weight: 700; +} + +.as-list-item-drag { + cursor: grab; + color: var(--text-secondary); + font-size: 14px; + opacity: 0.4; + flex-shrink: 0; +} + +.as-list-item-drag:hover { + opacity: 1; +} + +.as-list-item-drag:active { + cursor: grabbing; +} + +.as-list-item-icon { + font-size: 20px; + flex-shrink: 0; + width: 28px; + text-align: center; +} + +.as-list-item-info { + flex: 1; + min-width: 0; +} + +.as-list-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.as-list-item-meta { + font-size: 11px; + color: var(--text-secondary); +} + +.as-list-item-quality { + width: 32px; + text-align: center; + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +/* ============================================================ + 悬浮预览 + ============================================================ */ +.as-preview-overlay { + position: fixed; + z-index: 1000; + pointer-events: none; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: 8px; + min-width: 180px; + max-width: 260px; + animation: as-preview-in 0.15s ease; +} + +.as-preview-overlay-thumb { + width: 100%; + aspect-ratio: 9 / 16; + max-height: 200px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + margin-bottom: 8px; +} + +.as-preview-overlay-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.as-preview-overlay-thumb-icon { + font-size: 48px; + opacity: 0.3; +} + +.as-preview-overlay-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; + word-break: break-all; +} + +.as-preview-overlay-meta { + font-size: 11px; + color: var(--text-secondary); + display: flex; + flex-direction: column; + gap: 2px; +} + +@keyframes as-preview-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* ============================================================ + 响应式 + ============================================================ */ +@media (max-width: 768px) { + .as-grid { + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + } + + .as-toolbar { + padding: var(--space-xs) var(--space-sm); + } + + .as-search { + max-width: 140px; + } +} + +@media (max-width: 480px) { + .as-grid { + grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); + gap: 4px; + } + + .as-card-info { + padding: 4px 6px; + } + + .as-card-name { + font-size: 10px; + } +} diff --git a/apps/web/src/components/AssetSelector/AssetSelector.tsx b/apps/web/src/components/AssetSelector/AssetSelector.tsx new file mode 100644 index 000000000..3e926a78b --- /dev/null +++ b/apps/web/src/components/AssetSelector/AssetSelector.tsx @@ -0,0 +1,552 @@ +/** + * 素材选择器 — V21 设计系统 + * 支持批量选择、拖拽排序、预览缩略图、质量分筛选、视图切换 + * + * 功能: + * - 批量选择:checkbox 多选 + 全选/反选 + Shift 连选 + * - 拖拽排序:HTML5 DnD,已选素材可拖拽调整顺序 + * - 预览缩略图:悬浮放大预览(视频显示时长角标) + * - 筛选增强:类型筛选 + 质量分筛选 + * - 视图切换:网格视图 / 列表视图 + */ +import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"; +import "./AssetSelector.css"; +import { Input, Select, Button } from "@/components/ui"; +import type { MediaAsset } from "@/api/editPlans"; +import { + MATERIAL_TYPE_LABELS, + MATERIAL_TYPE_ICONS, + QUALITY_OPTIONS, +} from "@/api/editPlans"; + +/* ──────────── 类型 ──────────── */ + +export interface AssetSelectorProps { + assets: MediaAsset[]; + selectedIds?: string[]; + onSelectionChange?: (ids: string[]) => void; + onAssetDragStart?: (asset: MediaAsset) => void; + onReorder?: (fromIdx: number, toIdx: number) => void; + showQualityFilter?: boolean; + showBatchSelect?: boolean; + compact?: boolean; +} + +type ViewMode = "grid" | "list"; + +/* ──────────── 工具函数 ──────────── */ + +/** 格式化文件大小 */ +const formatSize = (bytes?: number): string => { + if (!bytes) return ""; + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +}; + +/** 格式化时长 */ +const formatDuration = (seconds?: number): string => { + if (!seconds) return ""; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`; +}; + +/** 获取质量分等级 */ +const getQualityLevel = (score?: number): string => { + if (score == null) return "none"; + if (score >= 90) return "excellent"; + if (score >= 70) return "good"; + if (score >= 50) return "fair"; + return "poor"; +}; + +/** 质量分颜色 */ +const getQualityColor = (score?: number): string => { + if (score == null) return "var(--text-secondary)"; + if (score >= 90) return "var(--success-color, #10b981)"; + if (score >= 70) return "var(--primary-color, #6366f1)"; + if (score >= 50) return "var(--warning-color, #f59e0b)"; + return "var(--error-color, #ef4444)"; +}; + +/* ──────────── 类型筛选选项 ──────────── */ + +const TYPE_OPTIONS = [ + { value: "", label: "全部类型" }, + { value: "video", label: "🎬 视频" }, + { value: "image", label: "🖼️ 图片" }, + { value: "audio", label: "🎵 音频" }, +]; + +/* ──────────── 组件 ──────────── */ + +const AssetSelector: React.FC = ({ + assets, + selectedIds = [], + onSelectionChange, + onAssetDragStart, + onReorder, + showQualityFilter = true, + showBatchSelect = true, + compact = false, +}) => { + /* ── 搜索 & 筛选 ── */ + const [searchText, setSearchText] = useState(""); + const [filterType, setFilterType] = useState(""); + const [filterQuality, setFilterQuality] = useState(""); + const [viewMode, setViewMode] = useState("grid"); + + /* ── 拖拽状态 ── */ + const [dragIdx, setDragIdx] = useState(null); + const [dragOverIdx, setDragOverIdx] = useState(null); + + /* ── 悬浮预览 ── */ + const [previewAsset, setPreviewAsset] = useState(null); + const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }); + const previewTimer = useRef | null>(null); + + /* ── Shift 连选 ── */ + const lastClickedIdx = useRef(null); + + /* ── 过滤后的素材列表 ── */ + const filteredAssets = useMemo(() => { + let list = assets; + if (searchText) { + const q = searchText.toLowerCase(); + list = list.filter( + (a) => + a.name.toLowerCase().includes(q) || + a.tags.some((t) => t.toLowerCase().includes(q)), + ); + } + if (filterType) { + list = list.filter((a) => a.type === filterType); + } + if (filterQuality) { + const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality); + if (opt?.min != null && opt?.max != null) { + list = list.filter( + (a) => + a.quality_score != null && + a.quality_score >= opt.min! && + a.quality_score <= opt.max!, + ); + } + } + return list; + }, [assets, searchText, filterType, filterQuality]); + + /* ── 选中状态 ── */ + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + + /* ── 选择操作 ── */ + const toggleSelect = useCallback( + (asset: MediaAsset, idx: number, shiftKey: boolean) => { + if (!onSelectionChange) return; + + if (shiftKey && lastClickedIdx.current !== null) { + // Shift 连选 + const start = Math.min(lastClickedIdx.current, idx); + const end = Math.max(lastClickedIdx.current, idx); + const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id); + const newSet = new Set(selectedIds); + rangeIds.forEach((id) => newSet.add(id)); + onSelectionChange(Array.from(newSet)); + } else { + const newSet = new Set(selectedIds); + if (newSet.has(asset.id)) { + newSet.delete(asset.id); + } else { + newSet.add(asset.id); + } + onSelectionChange(Array.from(newSet)); + } + lastClickedIdx.current = idx; + }, + [onSelectionChange, selectedIds, filteredAssets], + ); + + + const clearSelection = useCallback(() => { + onSelectionChange?.([]); + }, [onSelectionChange]); + + /* ── 拖拽排序 ── */ + const handleDragStart = useCallback( + (e: React.DragEvent, idx: number) => { + setDragIdx(idx); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + // 设置素材数据,供 TimelinePanel 接收(P1-2 修复) + e.dataTransfer.setData( + "application/x-media-asset", + JSON.stringify(filteredAssets[idx]), + ); + // 批量拖拽:如果有多个选中素材,一起携带 + if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) { + const batchAssets = filteredAssets.filter((a) => + selectedIds.includes(a.id), + ); + e.dataTransfer.setData( + "application/x-media-assets", + JSON.stringify(batchAssets), + ); + } + // 通知父组件素材拖拽开始 + if (onAssetDragStart) { + onAssetDragStart(filteredAssets[idx]); + } + }, + [filteredAssets, selectedIds, onAssetDragStart], + ); + + const handleDragOver = useCallback( + (e: React.DragEvent, idx: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragIdx === null || dragIdx === idx) return; + setDragOverIdx(idx); + }, + [dragIdx], + ); + + const handleDrop = useCallback( + (e: React.DragEvent, toIdx: number) => { + e.preventDefault(); + if (dragIdx !== null && dragIdx !== toIdx && onReorder) { + onReorder(dragIdx, toIdx); + } + setDragIdx(null); + setDragOverIdx(null); + }, + [dragIdx, onReorder], + ); + + const handleDragEnd = useCallback(() => { + setDragIdx(null); + setDragOverIdx(null); + }, []); + + /* ── 悬浮预览 ── */ + const handleMouseEnter = useCallback( + (asset: MediaAsset, e: React.MouseEvent) => { + if (previewTimer.current) clearTimeout(previewTimer.current); + previewTimer.current = setTimeout(() => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setPreviewAsset(asset); + setPreviewPos({ + x: rect.right + 12, + y: Math.max(8, rect.top - 20), + }); + }, 400); + }, + [], + ); + + const handleMouseLeave = useCallback(() => { + if (previewTimer.current) { + clearTimeout(previewTimer.current); + previewTimer.current = null; + } + setPreviewAsset(null); + }, []); + + // 清理定时器 + useEffect(() => { + return () => { + if (previewTimer.current) clearTimeout(previewTimer.current); + }; + }, []); + + /* ── 点击卡片 ── */ + const handleCardClick = useCallback( + (asset: MediaAsset, idx: number, e: React.MouseEvent) => { + // 如果点击的是 checkbox 区域,不触发卡片点击 + const target = e.target as HTMLElement; + if (target.closest("[data-checkbox]")) return; + toggleSelect(asset, idx, e.shiftKey); + }, + [toggleSelect], + ); + + /* ──────────── 渲染 ──────────── */ + + const hasSelection = selectedIds.length > 0; + + return ( +
+ {/* ═══ 工具栏 ═══ */} +
+
+
+ setSearchText(e.target.value)} + prefix="🔍" + /> +
+ setFilterQuality(v)} + options={QUALITY_OPTIONS} + /> + )} +
+
+ + +
+
+ + {/* ═══ 批量操作栏 ═══ */} + {showBatchSelect && hasSelection && ( +
+ + 已选 {selectedIds.length} 项 + +
+ +
+
+ )} + + {/* ═══ 素材列表 ═══ */} +
+ {filteredAssets.length === 0 ? ( +
+
📂
+

暂无素材

+
+ ) : viewMode === "grid" ? ( + /* ── 网格视图 ── */ +
+ {filteredAssets.map((asset, idx) => { + const isSelected = selectedSet.has(asset.id); + const isDragging = dragIdx === idx; + const isDragOver = dragOverIdx === idx; + const qualityLevel = getQualityLevel(asset.quality_score); + + return ( +
handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + onClick={(e) => handleCardClick(asset, idx, e)} + onMouseEnter={(e) => handleMouseEnter(asset, e)} + onMouseLeave={handleMouseLeave} + > + {/* 缩略图 */} +
+ {asset.thumbnail_url ? ( + {asset.name} + ) : ( + + {MATERIAL_TYPE_ICONS[asset.type]} + + )} + + {/* Checkbox */} + {showBatchSelect && ( + { + e.stopPropagation(); + toggleSelect(asset, idx, e.shiftKey); + }} + /> + )} + + {/* 类型角标 */} + + {MATERIAL_TYPE_LABELS[asset.type]} + + + {/* 时长角标 */} + {asset.duration != null && ( + + {formatDuration(asset.duration)} + + )} + + {/* 质量分角标 */} + {asset.quality_score != null && ( + + {asset.quality_score} + + )} +
+ + {/* 信息 */} +
+

+ {asset.name} +

+
+ {formatSize(asset.size)} +
+
+
+ ); + })} +
+ ) : ( + /* ── 列表视图 ── */ +
+ {filteredAssets.map((asset, idx) => { + const isSelected = selectedSet.has(asset.id); + const isDragging = dragIdx === idx; + const isDragOver = dragOverIdx === idx; + + return ( +
handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDrop={(e) => handleDrop(e, idx)} + onDragEnd={handleDragEnd} + onClick={(e) => handleCardClick(asset, idx, e)} + onMouseEnter={(e) => handleMouseEnter(asset, e)} + onMouseLeave={handleMouseLeave} + > + {/* 拖拽手柄 */} + + ⠿ + + + {/* Checkbox */} + {showBatchSelect && ( + { + e.stopPropagation(); + toggleSelect(asset, idx, e.shiftKey); + }} + /> + )} + + {/* 图标 */} + + {MATERIAL_TYPE_ICONS[asset.type]} + + + {/* 信息 */} +
+
{asset.name}
+
+ {MATERIAL_TYPE_LABELS[asset.type]} + {asset.duration != null && ` · ${formatDuration(asset.duration)}`} + {asset.size != null && ` · ${formatSize(asset.size)}`} +
+
+ + {/* 质量分 */} + {asset.quality_score != null && ( + + {asset.quality_score}分 + + )} +
+ ); + })} +
+ )} +
+ + {/* ═══ 悬浮预览 ═══ */} + {previewAsset && ( +
+
+ {previewAsset.thumbnail_url ? ( + {previewAsset.name} + ) : ( + + {MATERIAL_TYPE_ICONS[previewAsset.type]} + + )} +
+

{previewAsset.name}

+
+ 类型: {MATERIAL_TYPE_LABELS[previewAsset.type]} + {previewAsset.duration != null && ( + 时长: {formatDuration(previewAsset.duration)} + )} + {previewAsset.size != null && ( + 大小: {formatSize(previewAsset.size)} + )} + {previewAsset.quality_score != null && ( + + 质量分: {previewAsset.quality_score} + + )} + {previewAsset.tags.length > 0 && ( + 标签: {previewAsset.tags.join(", ")} + )} +
+
+ )} +
+ ); +}; + +export default AssetSelector; diff --git a/apps/web/src/components/AssetSelector/index.ts b/apps/web/src/components/AssetSelector/index.ts new file mode 100644 index 000000000..e99b3d568 --- /dev/null +++ b/apps/web/src/components/AssetSelector/index.ts @@ -0,0 +1,2 @@ +export { default as AssetSelector } from "./AssetSelector"; +export type { AssetSelectorProps } from "./AssetSelector"; diff --git a/apps/web/src/components/business/business.css b/apps/web/src/components/business/business.css index 43ae2d92b..40d3fb6a8 100644 --- a/apps/web/src/components/business/business.css +++ b/apps/web/src/components/business/business.css @@ -2,52 +2,52 @@ /* ==================== 按钮 ==================== */ .xx-primary-btn { - background: linear-gradient(135deg, #6366f1, #4f46e5) !important; - color: white !important; + background: var(--gradient-primary) !important; + color: var(--text-inverse) !important; border: none !important; - border-radius: 14px !important; + border-radius: var(--radius-md) !important; padding: 10px 20px !important; - font-weight: 600 !important; - box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important; - transition: all 0.2s !important; + font-weight: var(--font-weight-bold) !important; + box-shadow: var(--shadow-primary) !important; + transition: var(--transition-all) !important; cursor: pointer; height: auto !important; } .xx-primary-btn:hover { - box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important; + box-shadow: var(--shadow-hover) !important; transform: translateY(-1px); } .xx-ghost-btn { background: transparent !important; - color: #4f46e5 !important; - border: 2px solid #4f46e5 !important; - border-radius: 14px !important; - padding: 8px 18px !important; - font-weight: 600 !important; - transition: all 0.2s !important; + color: var(--primary-color) !important; + border: 2px solid var(--primary-color) !important; + border-radius: var(--radius-md) !important; + padding: var(--space-sm) 18px !important; + font-weight: var(--font-weight-bold) !important; + transition: var(--transition-all) !important; cursor: pointer; height: auto !important; } .xx-ghost-btn:hover { - background: #eef2ff !important; + background: var(--primary-soft) !important; } /* ==================== 卡片 ==================== */ .xx-card { - background: rgba(255, 255, 255, 0.94); - border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 22px; - box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); - padding: 24px; + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-card); + padding: var(--space-lg); margin-bottom: 20px; - transition: all 0.25s; + transition: all var(--transition-slow); } .xx-card:hover { - box-shadow: 0 26px 64px rgba(15, 23, 42, 0.14); + box-shadow: var(--shadow-md); transform: translateY(-2px); } @@ -55,7 +55,7 @@ .xx-page { max-width: 1200px; margin: 0 auto; - padding: 24px; + padding: var(--space-lg); } .xx-page-head { @@ -68,63 +68,63 @@ .xx-page-head h2 { font-size: 26px; - font-weight: 850; - color: #0f172a; - margin: 0 0 8px; + font-weight: var(--font-weight-extrabold); + color: var(--text-primary); + margin: 0 0 var(--space-sm); } .xx-page-head p { - font-size: 14px; - color: #64748b; + font-size: var(--font-size-base); + color: var(--text-secondary); margin: 0; } /* ==================== 表格样式 ==================== */ .xx-table-card { - background: rgba(255, 255, 255, 0.94); - border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 22px; - box-shadow: 0 24px 70px rgba(15, 23, 42, 0.09); + background: var(--bg-elevated); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-card); padding: 20px; overflow: hidden; } /* 表格包装器 */ .xx-table-wrapper { - border-radius: 16px; + border-radius: var(--radius-lg); overflow: hidden; } /* ==================== 标签/Tag ==================== */ .xx-tag { - padding: 4px 12px; - border-radius: 10px; + padding: var(--space-xs) 12px; + border-radius: var(--radius-xs); font-size: 13px; - font-weight: 500; + font-weight: var(--font-weight-medium); } .xx-tag-indigo { - background: #eef2ff; - color: #4f46e5; - border: 1px solid #c7d2fe; + background: var(--primary-soft); + color: var(--primary-color); + border: 1px solid var(--color-primary-200); } .xx-tag-success { - background: #f0fdf4; - color: #16a34a; - border: 1px solid #bbf7d0; + background: var(--success-soft); + color: var(--color-secondary-500); + border: 1px solid var(--success-border); } .xx-tag-warning { - background: #fffbeb; - color: #d97706; - border: 1px solid #fde68a; + background: var(--warning-soft); + color: var(--accent-dark); + border: 1px solid var(--color-accent-200); } .xx-tag-error { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fecaca; + background: var(--error-soft); + color: var(--error-color); + border: 1px solid var(--error-border); } /* ==================== 搜索栏 ==================== */ @@ -135,52 +135,53 @@ .xx-search-input { width: 100%; padding: 12px 18px; - border: 2px solid #e2e8f0; - border-radius: 14px; - font-size: 14px; - background: white; - transition: all 0.2s; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + font-size: var(--font-size-base); + background: var(--bg-primary); + transition: var(--transition-all); outline: none; } .xx-search-input:focus { - border-color: #4f46e5; - box-shadow: 0 0 0 4px rgba(79, 70, 229, 0.1); + border-color: var(--primary-color); + box-shadow: 0 0 0 4px + color-mix(in srgb, var(--primary-color) 10%, transparent); } /* ==================== Modal ==================== */ .xx-modal .ant-modal-content { - border-radius: 22px; - padding: 24px; + border-radius: var(--radius-xl); + padding: var(--space-lg); } .xx-modal .ant-modal-header { - border-radius: 22px 22px 0 0; - padding: 20px 24px; - border-bottom: 1px solid #e2e8f0; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + padding: 20px var(--space-lg); + border-bottom: 1px solid var(--border-color); } .xx-modal .ant-modal-title { - font-size: 18px; - font-weight: 700; - color: #0f172a; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--text-primary); } .xx-modal .ant-modal-footer { - border-top: 1px solid #e2e8f0; - padding: 16px 24px; + border-top: 1px solid var(--border-color); + padding: var(--space-md) var(--space-lg); } /* ==================== 空状态 ==================== */ .xx-empty-state { text-align: center; - padding: 48px 24px; - color: #64748b; + padding: var(--space-3xl) var(--space-lg); + color: var(--text-secondary); } .xx-empty-state-icon { font-size: 48px; - margin-bottom: 16px; + margin-bottom: var(--space-md); } /* ==================== 网格布局 ==================== */ @@ -213,15 +214,16 @@ /* ==================== 配额展示 ==================== */ .xx-quota-item { padding: 20px; - background: white; - border: 1px solid #e2e8f0; - border-radius: 16px; - transition: all 0.2s; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + transition: var(--transition-all); } .xx-quota-item:hover { - border-color: #4f46e5; - box-shadow: 0 8px 24px rgba(79, 70, 229, 0.1); + border-color: var(--primary-color); + box-shadow: 0 8px 24px + color-mix(in srgb, var(--primary-color) 10%, transparent); } /* ==================== 进度条 ==================== */ @@ -232,130 +234,132 @@ /* ==================== Ant Design 覆盖样式 ==================== */ /* Table overrides */ .ant-table-wrapper .ant-table-thead > tr > th { - background: #f8fafc !important; - font-weight: 700 !important; - color: #0f172a !important; - border-bottom: 2px solid #e2e8f0 !important; - padding: 14px 16px !important; + background: var(--bg-secondary) !important; + font-weight: var(--font-weight-bold) !important; + color: var(--text-primary) !important; + border-bottom: 2px solid var(--border-color) !important; + padding: 14px var(--space-md) !important; } .ant-table-wrapper .ant-table-tbody > tr > td { - padding: 14px 16px !important; - border-bottom: 1px solid #f1f5f9 !important; + padding: 14px var(--space-md) !important; + border-bottom: 1px solid var(--color-gray-100) !important; } .ant-table-wrapper .ant-table-tbody > tr:hover > td { - background: #fafbfc !important; + background: var(--color-gray-50) !important; } /* Card overrides */ .ant-card { - border-radius: 22px !important; - border: 1px solid rgba(226, 232, 240, 0.95) !important; + border-radius: var(--radius-xl) !important; + border: 1px solid var(--border-color) !important; } .ant-card-head { - border-bottom: 1px solid #e2e8f0 !important; + border-bottom: 1px solid var(--border-color) !important; min-height: 52px !important; - padding: 0 24px !important; + padding: 0 var(--space-lg) !important; } .ant-card-head-title { - font-weight: 700 !important; - font-size: 16px !important; - color: #0f172a !important; + font-weight: var(--font-weight-bold) !important; + font-size: var(--font-size-md) !important; + color: var(--text-primary) !important; } .ant-card-body { - padding: 20px 24px !important; + padding: 20px var(--space-lg) !important; } /* Modal overrides */ .ant-modal-content { - border-radius: 22px !important; + border-radius: var(--radius-xl) !important; overflow: hidden; } .ant-modal-header { - padding: 20px 24px !important; - background: white !important; + padding: 20px var(--space-lg) !important; + background: var(--bg-primary) !important; } .ant-modal-title { - font-weight: 700 !important; - font-size: 18px !important; - color: #0f172a !important; + font-weight: var(--font-weight-bold) !important; + font-size: var(--font-size-lg) !important; + color: var(--text-primary) !important; } .ant-modal-body { - padding: 24px !important; + padding: var(--space-lg) !important; } .ant-modal-footer { - padding: 16px 24px !important; + padding: var(--space-md) var(--space-lg) !important; } /* Button overrides */ .ant-btn-primary { - background: linear-gradient(135deg, #6366f1, #4f46e5) !important; + background: var(--gradient-primary) !important; border: none !important; - border-radius: 14px !important; - box-shadow: 0 14px 26px rgba(79, 70, 229, 0.22) !important; + border-radius: var(--radius-md) !important; + box-shadow: var(--shadow-primary) !important; height: auto !important; padding: 10px 20px !important; - font-weight: 600 !important; + font-weight: var(--font-weight-bold) !important; } .ant-btn-primary:hover { - background: linear-gradient(135deg, #6366f1, #4f46e5) !important; - box-shadow: 0 18px 34px rgba(79, 70, 229, 0.28) !important; + background: var(--gradient-primary) !important; + box-shadow: var(--shadow-hover) !important; transform: translateY(-1px); } /* Tag overrides */ .ant-tag { - border-radius: 10px !important; - padding: 4px 12px !important; - font-weight: 500 !important; + border-radius: var(--radius-xs) !important; + padding: var(--space-xs) 12px !important; + font-weight: var(--font-weight-medium) !important; } /* Select overrides */ .ant-select-selector { - border-radius: 14px !important; - border-color: #e2e8f0 !important; + border-radius: var(--radius-md) !important; + border-color: var(--border-color) !important; } .ant-select:not(.ant-select-disabled):hover .ant-select-selector { - border-color: #4f46e5 !important; + border-color: var(--primary-color) !important; } .ant-select-focused .ant-select-selector { - border-color: #4f46e5 !important; - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important; + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--primary-color) 10%, transparent) !important; } /* Input overrides */ .ant-input { - border-radius: 14px !important; - border-color: #e2e8f0 !important; + border-radius: var(--radius-md) !important; + border-color: var(--border-color) !important; padding: 10px 14px !important; } .ant-input:hover { - border-color: #4f46e5 !important; + border-color: var(--primary-color) !important; } .ant-input:focus { - border-color: #4f46e5 !important; - box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1) !important; + border-color: var(--primary-color) !important; + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--primary-color) 10%, transparent) !important; } /* Progress overrides */ .ant-progress-inner { - background: #f1f5f9 !important; - border-radius: 10px !important; + background: var(--color-gray-100) !important; + border-radius: var(--radius-xs) !important; } .ant-progress-bg { - border-radius: 10px !important; + border-radius: var(--radius-xs) !important; } diff --git a/apps/web/src/components/layout/Header.css b/apps/web/src/components/layout/Header.css index f6dbaed88..e74730978 100644 --- a/apps/web/src/components/layout/Header.css +++ b/apps/web/src/components/layout/Header.css @@ -3,17 +3,17 @@ height: 68px; position: sticky; top: 0; - z-index: 30; - background: rgba(255, 255, 255, 0.86); + z-index: var(--z-fixed); + background: var(--bg-glass); backdrop-filter: blur(16px); - border-bottom: 1px solid rgba(226, 232, 240, 0.92); + border-bottom: 1px solid var(--border-color); } .xx-top-nav-inner { max-width: 1440px; margin: 0 auto; height: 100%; - padding: 0 24px; + padding: 0 var(--space-lg); display: flex; align-items: center; justify-content: space-between; @@ -23,12 +23,12 @@ display: flex; gap: 10px; align-items: center; - font-size: 20px; - font-weight: 900; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-extrabold); border: none; background: none; cursor: pointer; - color: #0f172a; + color: var(--text-primary); padding: 0; flex-shrink: 0; } @@ -36,41 +36,41 @@ .xx-logo { width: 38px; height: 38px; - border-radius: 14px; - background: linear-gradient(135deg, #818cf8, #4f46e5); + border-radius: var(--radius-md); + background: var(--gradient-primary); display: grid; place-items: center; - color: #fff; - box-shadow: 0 12px 26px rgba(79, 70, 229, 0.24); - font-size: 20px; + color: var(--text-inverse); + box-shadow: var(--shadow-primary); + font-size: var(--font-size-xl); } /* 桌面端导航链接 */ .xx-nav-links { display: flex; - gap: 16px; - color: #64748b; - font-weight: 750; + gap: var(--space-md); + color: var(--text-secondary); + font-weight: var(--font-weight-bold); } .xx-nav-links button { border: none; background: none; cursor: pointer; - color: #64748b; + color: var(--text-secondary); padding: 6px 4px; - font-size: 14px; - font-weight: 750; - transition: color 0.2s; + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + transition: color var(--transition-fast); white-space: nowrap; } .xx-nav-links button:hover { - color: #4f46e5; + color: var(--primary-color); } .xx-nav-links button.active { - color: #4f46e5; + color: var(--primary-color); } /* 右侧区域 */ @@ -84,17 +84,17 @@ .xx-user-menu { display: flex; align-items: center; - gap: 8px; + gap: var(--space-sm); cursor: pointer; - color: #0f172a; - font-size: 14px; + color: var(--text-primary); + font-size: var(--font-size-base); } .xx-avatar { width: 34px; height: 34px; - background: #eef2ff; - color: #4338ca; + background: var(--primary-soft); + color: var(--primary-dark); } /* 汉堡菜单按钮(默认隐藏) */ @@ -103,42 +103,42 @@ border: none; background: none; cursor: pointer; - font-size: 20px; - color: #64748b; - padding: 8px; + font-size: var(--font-size-xl); + color: var(--text-secondary); + padding: var(--space-sm); } /* 手机端导航抽屉内容 */ .xx-mobile-nav { display: flex; flex-direction: column; - gap: 4px; + gap: var(--space-xs); } .xx-mobile-nav-item { display: flex; align-items: center; gap: 12px; - padding: 12px 16px; + padding: 12px var(--space-md); border: none; background: none; cursor: pointer; - font-size: 16px; - color: #334155; - border-radius: 8px; - transition: background 0.2s; + font-size: var(--font-size-md); + color: var(--color-gray-700); + border-radius: var(--radius-sm); + transition: background var(--transition-fast); width: 100%; text-align: left; } .xx-mobile-nav-item:hover { - background: #f1f5f9; + background: var(--bg-tertiary); } .xx-mobile-nav-item.active { - background: #eef2ff; - color: #4f46e5; - font-weight: 600; + background: var(--primary-soft); + color: var(--primary-color); + font-weight: var(--font-weight-semibold); } .xx-mobile-nav-icon { @@ -182,7 +182,7 @@ /* 小屏幕平板:导航文字缩小 */ @media (min-width: 769px) and (max-width: 1024px) { .xx-nav-links { - gap: 8px; + gap: var(--space-sm); } .xx-nav-links button { diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 2e3d3afd7..cb1223282 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -9,92 +9,14 @@ import { SettingOutlined, UserOutlined, MenuOutlined, - DashboardOutlined, - FileOutlined, - FileTextOutlined, - AudioOutlined, - AppstoreOutlined, - VideoCameraOutlined, - HistoryOutlined, - TrophyOutlined, - ScanOutlined, - EditOutlined, - FolderOutlined, } from "@ant-design/icons"; import { useLocation, useNavigate } from "react-router-dom"; import { useAuthStore } from "@/store/authStore"; import { useLogout } from "@/hooks/useAuth"; import type { MenuProps } from "antd"; +import { NAV_ITEMS } from "@/config/navigation"; import "./Header.css"; -/** 导航项定义 */ -interface NavItem { - key: string; - label: string; - path: string; - icon: React.ReactNode; -} - -/** 固定导航菜单 */ -const NAV_ITEMS: NavItem[] = [ - { - key: "dashboard", - label: "概览", - path: "/dashboard", - icon: , - }, - { key: "assets", label: "素材库", path: "/assets", icon: }, - { - key: "titles", - label: "标题库", - path: "/titles", - icon: , - }, - { key: "voices", label: "配音库", path: "/voices", icon: }, - { - key: "templates", - label: "模板库", - path: "/templates", - icon: , - }, - { - key: "editing-planner", - label: "剪辑编辑器", - path: "/editing-planner", - icon: , - }, - { - key: "my-templates", - label: "我的模板", - path: "/my-templates", - icon: , - }, - { - key: "generate", - label: "一键生成", - path: "/generate", - icon: , - }, - { - key: "history", - label: "任务历史", - path: "/history", - icon: , - }, - { - key: "products", - label: "成品库", - path: "/products", - icon: , - }, - { - key: "duplication", - label: "查重", - path: "/duplication", - icon: , - }, -]; - const Header: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); diff --git a/apps/web/src/components/layout/MainLayout.css b/apps/web/src/components/layout/MainLayout.css index 1a9c2dc47..369e4ddd0 100644 --- a/apps/web/src/components/layout/MainLayout.css +++ b/apps/web/src/components/layout/MainLayout.css @@ -142,7 +142,6 @@ .xx-app-sidebar:not(.xx-collapsed) .xx-sidebar-toggle-label { display: inline; } - } /* ============================================================ diff --git a/apps/web/src/components/layout/PageHead.tsx b/apps/web/src/components/layout/PageHead.tsx index 191e4e685..1036cf3e7 100644 --- a/apps/web/src/components/layout/PageHead.tsx +++ b/apps/web/src/components/layout/PageHead.tsx @@ -30,7 +30,7 @@ export interface PageHeadProps { /** 页面标题 */ title: string; /** 页面描述(可选,显示在标题下方) */ - description?: string; + description?: React.ReactNode; /** 面包屑项(可选,不传则自动根据路由生成) */ breadcrumb?: BreadcrumbItem[]; /** 右侧操作区内容(按钮等) */ @@ -42,37 +42,39 @@ export interface PageHeadProps { /* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */ const ROUTE_TITLE_MAP: Record = { - "/dashboard": "首页", - "/generate": "一键生成", - "/assets": "素材库", - "/voices": "配音库", - "/titles": "标题库", - "/products": "成片库", - "/templates": "模板库", - "/history": "任务历史", - "/admin": "控制台", - "/admin/users": "用户管理", - "/admin/analytics": "数据分析", - "/admin/monitor": "系统监控", - "/admin/logs": "系统日志", - "/subscription": "订阅管理", - "/subscription/upgrade": "升级订阅", - "/subscription/billing": "账单管理", - "/profile": "个人设置", - "/editing-planner": "剪辑规划", - "/my-templates": "我的模板", - "/duplication": "查重", - "/duplication/results": "查重结果", + "/app/dashboard": "首页", + "/app/generate": "一键生成", + "/app/assets": "素材库", + "/app/voices": "配音库", + "/app/titles": "标题库", + "/app/products": "成片库", + "/app/templates": "模板库", + "/app/history": "任务历史", + "/app/admin": "控制台", + "/app/admin/users": "用户管理", + "/app/admin/analytics": "数据分析", + "/app/admin/monitor": "系统监控", + "/app/admin/logs": "系统日志", + "/app/subscription": "订阅管理", + "/app/subscription/upgrade": "升级订阅", + "/app/subscription/billing": "账单管理", + "/app/profile": "个人设置", + "/app/editing-planner": "剪辑规划", + "/app/my-templates": "我的模板", + "/app/voice-clone": "我的音色", + "/app/accounts": "账号管理", + "/app/duplication": "查重", + "/app/duplication/results": "查重结果", }; /* ── 自动生成面包屑 ─────────────────────────────────────── */ /** 根据当前路径生成面包屑 */ const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => { - const items: BreadcrumbItem[] = [{ label: "首页", path: "/dashboard" }]; + const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]; // 首页本身不需要面包屑 - if (pathname === "/" || pathname === "/dashboard") { + if (pathname === "/app" || pathname === "/app/dashboard") { return items; } @@ -123,8 +125,8 @@ const PageHead: React.FC = ({ const showBreadcrumb = !hideBreadcrumb && breadcrumbItems.length > 1 && - location.pathname !== "/" && - location.pathname !== "/dashboard"; + location.pathname !== "/app" && + location.pathname !== "/app/dashboard"; return (
@@ -136,7 +138,10 @@ const PageHead: React.FC = ({ {breadcrumbItems.map((item, index) => { const isLast = index === breadcrumbItems.length - 1; return ( -
  • +
  • {index > 0 && ( )} @@ -155,7 +160,10 @@ const PageHead: React.FC = ({ {item.label} ) : ( - + {index === 0 ? ( ) : null} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 84dd32400..6d887f142 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -11,114 +11,10 @@ */ import React, { useContext } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { - DashboardOutlined, - VideoCameraOutlined, - FileOutlined, - AudioOutlined, - FileTextOutlined, - TrophyOutlined, - AppstoreOutlined, - HistoryOutlined, - ControlOutlined, - CrownOutlined, -} from "@ant-design/icons"; import { SidebarContext } from "./MainLayout"; +import { NAV_GROUPS } from "@/config/navigation"; import "./Sidebar.css"; -/** 导航项定义 */ -interface SidebarMenuItem { - key: string; - label: string; - path: string; - icon: React.ReactNode; -} - -/** 导航分组定义 */ -interface SidebarMenuGroup { - title: string; - items: SidebarMenuItem[]; -} - -/** 侧边栏导航分组 */ -const MENU_GROUPS: SidebarMenuGroup[] = [ - { - title: "创作工具", - items: [ - { - key: "dashboard", - label: "首页", - path: "/dashboard", - icon: , - }, - { - key: "generate", - label: "一键生成", - path: "/generate", - icon: , - }, - ], - }, - { - title: "资源管理", - items: [ - { - key: "assets", - label: "素材库", - path: "/assets", - icon: , - }, - { - key: "voices", - label: "配音库", - path: "/voices", - icon: , - }, - { - key: "titles", - label: "标题库", - path: "/titles", - icon: , - }, - { - key: "products", - label: "成片库", - path: "/products", - icon: , - }, - { - key: "templates", - label: "模板库", - path: "/templates", - icon: , - }, - ], - }, - { - title: "系统", - items: [ - { - key: "history", - label: "任务历史", - path: "/history", - icon: , - }, - { - key: "admin", - label: "控制台", - path: "/admin", - icon: , - }, - { - key: "subscription", - label: "订阅管理", - path: "/subscription", - icon: , - }, - ], - }, -]; - /** 判断菜单项是否激活 */ const isMenuItemActive = (pathname: string, path: string): boolean => { if (path === "/dashboard") { @@ -137,8 +33,10 @@ const Sidebar: React.FC = () => { }; return ( -
    - {MENU_GROUPS.map((group) => ( +
    + {NAV_GROUPS.map((group) => (
    {!collapsed && (
    {group.title}
    diff --git a/apps/web/src/components/modals/CloneVoiceModal.tsx b/apps/web/src/components/modals/CloneVoiceModal.tsx new file mode 100644 index 000000000..9e9650d21 --- /dev/null +++ b/apps/web/src/components/modals/CloneVoiceModal.tsx @@ -0,0 +1,277 @@ +/** + * CloneVoiceModal — 音色克隆弹窗 + * + * 三步骤状态:input → uploading → success + * 支持上传音频文件或直接录制(mock,无真实录音) + * + * V21 Design System — 零 antd 直接导入 + */ +import React, { useState, useCallback, useRef } from "react"; +import { Modal, Button } from "@/components/ui"; +import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; +import type { VoiceClone } from "@/api/voiceClone"; +import "./clone-voice-modal.css"; + +/* ── 类型定义 ───────────────────────────────────────────── */ + +type ModalStep = "input" | "uploading" | "success"; + +export interface CloneVoiceModalProps { + /** 弹窗是否可见 */ + open: boolean; + /** 关闭弹窗回调 */ + onClose: () => void; + /** 克隆成功回调(返回新创建的音色) */ + onSuccess?: (voice: VoiceClone) => void; +} + +/* ── 默认音色名称计数器 ─────────────────────────────────── */ + +let cloneCounter = 1; + +const getNextDefaultName = (): string => { + const name = `我的声音 ${cloneCounter}`; + cloneCounter += 1; + return name; +}; + +/* ── 组件 ───────────────────────────────────────────────── */ + +const CloneVoiceModal: React.FC = ({ + open, + onClose, + onSuccess, +}) => { + const [step, setStep] = useState("input"); + const [voiceName, setVoiceName] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + const fileInputRef = useRef(null); + + /** 重置弹窗状态 */ + const resetState = useCallback(() => { + setStep("input"); + setVoiceName(""); + setSelectedFile(null); + setIsRecording(false); + setDragActive(false); + }, []); + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + /** 上传区域点击 */ + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + /** 文件选择 */ + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + // 清除之前的录制状态 + setIsRecording(false); + } + // 清空 input 以允许重复选择同一文件 + e.target.value = ""; + }; + + /** 拖拽事件 */ + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file) { + const ext = file.name.split(".").pop()?.toLowerCase(); + if (ext === "mp3" || ext === "wav") { + setSelectedFile(file); + setIsRecording(false); + } + } + }; + + /** 录制按钮(mock) */ + const handleRecord = () => { + setIsRecording((prev) => !prev); + if (!isRecording) { + // 开始录制 — 清除已选文件 + setSelectedFile(null); + } + }; + + /** 开始克隆 */ + const handleStartClone = async () => { + const name = voiceName.trim() || getNextDefaultName(); + setStep("uploading"); + + try { + // Mock:模拟上传 + 克隆过程 + const result = await createVoiceClone({ + name, + audio_url: selectedFile + ? `mock://${selectedFile.name}` + : "mock://recorded-audio", + }); + + setStep("success"); + + // 2秒后自动关闭 + setTimeout(() => { + onSuccess?.(toVoiceClone(result)); + handleClose(); + }, 2000); + } catch { + setStep("input"); + } + }; + + /** 弹窗打开时初始化默认名称 */ + const handleAfterOpenChange = (visible: boolean) => { + if (visible) { + setVoiceName(getNextDefaultName()); + } + }; + + const canStart = selectedFile || isRecording; + + return ( + + {/* ── 输入步骤 ──────────────────────────────────── */} + {step === "input" && ( +
    + {/* 音色名称 */} +
    + + setVoiceName(e.target.value)} + placeholder="输入音色名称" + /> +
    + + {/* 上传区域 */} +
    + +
    +
    🎵
    +

    + {selectedFile ? selectedFile.name : "拖拽音频文件到此处"} +

    +

    支持 MP3、WAV 格式

    + +
    +
    + + {/* 或分隔 */} +
    +
    + +
    +
    + + {/* 录制区域 */} +
    + +
    +

    + {isRecording + ? "录制中…再次点击停止" + : "点击按钮开始录制你的声音"} +

    + +
    +
    + + {/* 提示 */} +
    + 💡 + + 建议上传10秒~3分钟的清晰语音,环境安静、语速均匀效果最佳 + +
    + + {/* 底部按钮 */} +
    + + +
    +
    + )} + + {/* ── 上传中步骤 ────────────────────────────────── */} + {step === "uploading" && ( +
    +
    +

    正在克隆你的音色…

    +

    AI 正在分析你的声音特征,请稍候

    +
    + )} + + {/* ── 成功步骤 ──────────────────────────────────── */} + {step === "success" && ( +
    +
    +

    克隆已提交

    +

    + 音色正在生成中,完成后将出现在列表中 +

    +
    + )} + + ); +}; + +export default CloneVoiceModal; diff --git a/apps/web/src/components/modals/VoiceCloneModal.tsx b/apps/web/src/components/modals/VoiceCloneModal.tsx new file mode 100644 index 000000000..12337b69b --- /dev/null +++ b/apps/web/src/components/modals/VoiceCloneModal.tsx @@ -0,0 +1,441 @@ +/** + * VoiceCloneModal — 音色克隆弹窗 + * + * 功能:上传音频文件 / 录制音频、填写音色名称、提交克隆任务 + * 进度展示:上传中 → 克隆中 → 完成(三阶段可视化) + * 对接 API(Mock):POST /api/v1/voice-clones + * + * CSS 变量统一,与配音库 / 我的音色页面风格一致 + * V21 Design System — 零 antd 直接导入 + */ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { Modal, Button } from "@/components/ui"; +import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; +import type { VoiceClone } from "@/api/voiceClone"; +import "./voice-clone-modal.css"; + +/* ── 类型定义 ───────────────────────────────────────────── */ + +/** 弹窗阶段:input=输入 | uploading=上传中 | cloning=克隆中 | done=完成 */ +type ModalPhase = "input" | "uploading" | "cloning" | "done"; + +export interface VoiceCloneModalProps { + /** 弹窗是否可见 */ + open: boolean; + /** 关闭弹窗回调 */ + onClose: () => void; + /** 克隆成功回调(返回新创建的音色) */ + onSuccess?: (voice: VoiceClone) => void; +} + +/* ── 进度阶段配置 ─────────────────────────────────────────── */ + +const PROGRESS_STEPS: { key: ModalPhase; label: string; icon: string }[] = [ + { key: "uploading", label: "上传中", icon: "📤" }, + { key: "cloning", label: "克隆中", icon: "🧬" }, + { key: "done", label: "完成", icon: "✅" }, +]; + +/* ── 默认音色名称计数器 ─────────────────────────────────── */ + +let cloneCounter = 1; + +const getNextDefaultName = (): string => { + const name = `我的声音 ${cloneCounter}`; + cloneCounter += 1; + return name; +}; + +/* ── 支持的文件扩展名 ─────────────────────────────────── */ + +const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"]; +const ACCEPTED_MIME = ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg"; + +/* ── 组件 ───────────────────────────────────────────────── */ + +const VoiceCloneModal: React.FC = ({ + open, + onClose, + onSuccess, +}) => { + const [phase, setPhase] = useState("input"); + const [voiceName, setVoiceName] = useState(""); + const [isRecording, setIsRecording] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [recordTime, setRecordTime] = useState(0); + const fileInputRef = useRef(null); + const recordTimerRef = useRef | null>(null); + + /** 重置弹窗状态 */ + const resetState = useCallback(() => { + setPhase("input"); + setVoiceName(""); + setSelectedFile(null); + setIsRecording(false); + setDragActive(false); + setRecordTime(0); + if (recordTimerRef.current) { + clearInterval(recordTimerRef.current); + recordTimerRef.current = null; + } + }, []); + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + /** 弹窗打开时初始化默认名称 */ + useEffect(() => { + if (open) { + setVoiceName(getNextDefaultName()); + } + }, [open]); + + /** 清理录制计时器 */ + useEffect(() => { + return () => { + if (recordTimerRef.current) { + clearInterval(recordTimerRef.current); + } + }; + }, []); + + /* ── 文件上传 ──────────────────────────────────────── */ + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedFile(file); + setIsRecording(false); + setRecordTime(0); + } + e.target.value = ""; + }; + + /* ── 拖拽 ──────────────────────────────────────────── */ + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file) { + const ext = file.name.split(".").pop()?.toLowerCase(); + if (ext && ACCEPTED_EXTENSIONS.includes(ext)) { + setSelectedFile(file); + setIsRecording(false); + setRecordTime(0); + } + } + }; + + /* ── 录制(mock) ──────────────────────────────────── */ + + const handleRecord = () => { + if (isRecording) { + // 停止录制 + setIsRecording(false); + if (recordTimerRef.current) { + clearInterval(recordTimerRef.current); + recordTimerRef.current = null; + } + } else { + // 开始录制 + setIsRecording(true); + setSelectedFile(null); + setRecordTime(0); + recordTimerRef.current = setInterval(() => { + setRecordTime((prev) => prev + 1); + }, 1000); + } + }; + + /** 格式化录制时间 mm:ss */ + const formatRecordTime = (seconds: number): string => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + }; + + /* ── 提交克隆 ──────────────────────────────────────── */ + + const handleStartClone = async () => { + const name = voiceName.trim() || getNextDefaultName(); + + // 阶段 1:上传中 + setPhase("uploading"); + await new Promise((r) => setTimeout(r, 1200)); + + // 阶段 2:克隆中 + setPhase("cloning"); + try { + const result = await createVoiceClone({ + name, + audio_url: selectedFile + ? `mock://${selectedFile.name}` + : "mock://recorded-audio", + }); + + // 阶段 3:完成 + setPhase("done"); + + // 2秒后自动关闭 + setTimeout(() => { + onSuccess?.(toVoiceClone(result)); + handleClose(); + }, 2000); + } catch { + setPhase("input"); + } + }; + + /** 当前进度索引(用于进度条展示) */ + const getProgressIndex = (): number => { + switch (phase) { + case "uploading": + return 0; + case "cloning": + return 1; + case "done": + return 2; + default: + return -1; + } + }; + + const canStart = selectedFile || isRecording; + const progressIndex = getProgressIndex(); + const isProcessing = phase === "uploading" || phase === "cloning"; + + return ( + + {/* ── 输入阶段 ──────────────────────────────────── */} + {phase === "input" && ( +
    + {/* 音色名称 */} +
    + + setVoiceName(e.target.value)} + placeholder="输入音色名称" + maxLength={30} + /> +
    + + {/* 上传区域 */} +
    + +
    +
    + {selectedFile ? "📄" : "🎵"} +
    +

    + {selectedFile + ? selectedFile.name + : "拖拽音频文件到此处,或点击上传"} +

    +

    + 支持 MP3、WAV、M4A、AAC、OGG 格式 +

    + +
    +
    + + {/* 或分隔 */} +
    +
    + +
    +
    + + {/* 录制区域 */} +
    + +
    +
    +

    + {isRecording + ? `录制中 ${formatRecordTime(recordTime)}` + : "点击按钮开始录制你的声音"} +

    + {isRecording && ( +
    + + + + + +
    + )} +
    + +
    +
    + + {/* 提示 */} +
    + 💡 + + 建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳 + +
    + + {/* 底部按钮 */} +
    + + +
    +
    + )} + + {/* ── 进度阶段(上传中 / 克隆中 / 完成) ─────── */} + {isProcessing && ( +
    + {/* 步骤指示器 */} +
    + {PROGRESS_STEPS.map((step, idx) => { + const isActive = idx === progressIndex; + const isDone = idx < progressIndex; + const stepClass = [ + "xx-vcmodal-step", + isActive ? "xx-vcmodal-step--active" : "", + isDone ? "xx-vcmodal-step--done" : "", + ] + .filter(Boolean) + .join(" "); + + return ( + + {idx > 0 && ( +
    + )} +
    +
    + {isDone ? "✓" : step.icon} +
    + + {step.label} + +
    + + ); + })} +
    + + {/* 当前阶段描述 */} +
    + {phase === "uploading" && ( + <> +
    +

    正在上传音频文件…

    +

    + 请稍候,正在将音频上传至服务器 +

    + + )} + {phase === "cloning" && ( + <> +
    +

    + AI 正在克隆你的声音… +

    +

    + 正在分析声音特征,生成专属音色模型 +

    + + )} +
    +
    + )} + + {/* ── 完成阶段 ──────────────────────────────────── */} + {phase === "done" && ( +
    + {/* 步骤指示器(全部完成) */} +
    + {PROGRESS_STEPS.map((step, idx) => ( + + {idx > 0 && ( +
    + )} +
    +
    + {step.label} +
    + + ))} +
    + +
    +
    🎉
    +

    克隆已提交

    +

    + 音色正在生成中,完成后将出现在「我的音色库」列表中 +

    +
    +
    + )} + + ); +}; + +export default VoiceCloneModal; diff --git a/apps/web/src/components/modals/clone-voice-modal.css b/apps/web/src/components/modals/clone-voice-modal.css new file mode 100644 index 000000000..33cc71dce --- /dev/null +++ b/apps/web/src/components/modals/clone-voice-modal.css @@ -0,0 +1,325 @@ +/** + * CloneVoiceModal — V21 Design System + * + * 音色克隆弹窗样式 + * 三步骤状态:input → uploading → success + */ + +/* ── 弹窗内容区 ─────────────────────────────────────────── */ + +.cvm-body { + display: flex; + flex-direction: column; + gap: 20px; +} + +/* ── 表单区 ─────────────────────────────────────────────── */ + +.cvm-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cvm-label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, #475467); +} + +.cvm-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--line, #e4e7ec); + border-radius: var(--radius-sm, 8px); + background: var(--bg-surface, #fff); + color: var(--text-primary, #101828); + font-size: 14px; + line-height: 1.5; + transition: + border-color 0.2s, + box-shadow 0.2s; + outline: none; +} + +.cvm-input:focus { + border-color: var(--primary, #6366f1); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--primary-color) 12%, transparent); +} + +.cvm-input::placeholder { + color: var(--muted, #98a2b3); +} + +/* ── 上传区域 ───────────────────────────────────────────── */ + +.cvm-upload-zone { + border: 2px dashed var(--line, #e4e7ec); + border-radius: var(--radius-md, 12px); + padding: 28px 20px; + text-align: center; + background: var(--bg-subtle, #f8fafc); + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; +} + +.cvm-upload-zone:hover { + border-color: var(--primary, #6366f1); + background: color-mix(in srgb, var(--primary-color) 4%, transparent); +} + +.cvm-upload-zone.cvm-upload-zone--active { + border-color: var(--primary, #6366f1); + background: color-mix(in srgb, var(--primary-color) 6%, transparent); +} + +.cvm-upload-icon { + font-size: 36px; + margin-bottom: 8px; + line-height: 1; +} + +.cvm-upload-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary, #101828); + margin: 0 0 4px; +} + +.cvm-upload-hint { + font-size: 13px; + color: var(--muted, #98a2b3); + margin: 0; +} + +/* ── 或分隔线 ───────────────────────────────────────────── */ + +.cvm-divider { + display: flex; + align-items: center; + gap: 16px; + margin: 4px 0; +} + +.cvm-divider-line { + flex: 1; + height: 1px; + background: var(--line, #e4e7ec); +} + +.cvm-divider-text { + font-size: 13px; + color: var(--muted, #98a2b3); + flex-shrink: 0; +} + +/* ── 录制区域 ───────────────────────────────────────────── */ + +.cvm-record-area { + border: 1px solid var(--line, #e4e7ec); + border-radius: var(--radius-md, 12px); + padding: 24px; + text-align: center; +} + +.cvm-record-hint { + font-size: 13px; + color: var(--muted, #98a2b3); + margin: 0 0 14px; +} + +.cvm-record-btn { + width: 80px; + height: 80px; + border-radius: 50%; + border: none; + cursor: pointer; + font-size: 32px; + line-height: 1; + padding: 0; + background: linear-gradient( + 135deg, + var(--error-color, #ef4444), + var(--error-dark, #dc2626) + ); + color: var(--text-inverse); + box-shadow: 0 4px 14px + color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent); + transition: + transform 0.15s, + box-shadow 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.cvm-record-btn:hover { + transform: scale(1.06); + box-shadow: 0 6px 20px + color-mix(in srgb, var(--error-color, #ef4444) 45%, transparent); +} + +.cvm-record-btn:active { + transform: scale(0.96); +} + +.cvm-record-btn--recording { + animation: cvm-pulse 1.2s ease-in-out infinite; +} + +@keyframes cvm-pulse { + 0%, + 100% { + box-shadow: 0 4px 14px + color-mix(in srgb, var(--error-color, #ef4444) 35%, transparent); + } + 50% { + box-shadow: 0 4px 28px + color-mix(in srgb, var(--error-color, #ef4444) 60%, transparent); + } +} + +/* ── 提示条 ─────────────────────────────────────────────── */ + +.cvm-tip { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 16px; + background: var(--warning-soft, #fef3c7); + border-radius: var(--radius-sm, 8px); + font-size: 13px; + color: var(--warning-color, #92400e); + line-height: 1.5; +} + +.cvm-tip-icon { + flex-shrink: 0; + font-size: 14px; + line-height: 1.5; +} + +/* ── 底部按钮 ───────────────────────────────────────────── */ + +.cvm-footer { + display: flex; + gap: 12px; + margin-top: 4px; +} + +.cvm-footer .xx-btn { + flex: 1; +} + +/* ── 上传中状态 ─────────────────────────────────────────── */ + +.cvm-uploading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 20px; + gap: 16px; +} + +.cvm-uploading-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--line, #e4e7ec); + border-top-color: var(--primary, #6366f1); + border-radius: 50%; + animation: cvm-spin 0.8s linear infinite; +} + +@keyframes cvm-spin { + to { + transform: rotate(360deg); + } +} + +.cvm-uploading-text { + font-size: 15px; + font-weight: 500; + color: var(--text-primary, #101828); + margin: 0; +} + +.cvm-uploading-sub { + font-size: 13px; + color: var(--muted, #98a2b3); + margin: 0; +} + +/* ── 成功状态 ───────────────────────────────────────────── */ + +.cvm-success { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 20px; + gap: 12px; +} + +.cvm-success-icon { + font-size: 56px; + line-height: 1; +} + +.cvm-success-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary, #101828); + margin: 0; +} + +.cvm-success-desc { + font-size: 14px; + color: var(--muted, #98a2b3); + margin: 0; +} + +/* ── 响应式 ─────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .cvm-overlay { + padding: var(--space-md); + } + + .cvm-modal { + width: 100%; + max-width: 100%; + padding: var(--space-lg); + } +} + +@media (max-width: 576px) { + .cvm-upload-zone { + padding: 20px 14px; + } + + .cvm-record-btn { + width: 64px; + height: 64px; + font-size: 26px; + } + + .cvm-footer { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .cvm-record-btn { + width: 60px; + height: 60px; + } + + .cvm-tip { + font-size: 12px; + padding: var(--space-sm); + } +} diff --git a/apps/web/src/components/modals/voice-clone-modal.css b/apps/web/src/components/modals/voice-clone-modal.css new file mode 100644 index 000000000..2761bced6 --- /dev/null +++ b/apps/web/src/components/modals/voice-clone-modal.css @@ -0,0 +1,471 @@ +/** + * VoiceCloneModal 样式 — V21 设计系统 + * CSS 变量统一,与配音库 / 我的音色页面风格一致 + * 命名规范:xx-vcmodal-* + */ +@import "../../styles/global.css"; + +/* ── 弹窗内容区 ───────────────────────────────────────────── */ + +.xx-vcmodal-body { + display: flex; + flex-direction: column; + gap: var(--space-md); + padding: var(--space-sm) 0; +} + +/* ── 字段容器 ─────────────────────────────────────────────── */ + +.xx-vcmodal-field { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.xx-vcmodal-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); + letter-spacing: var(--letter-spacing-wide); +} + +/* ── 输入框 ───────────────────────────────────────────────── */ + +.xx-vcmodal-input { + width: 100%; + height: 40px; + padding: 0 var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + font-size: var(--font-size-base); + color: var(--text-primary); + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.xx-vcmodal-input::placeholder { + color: var(--text-tertiary); +} + +.xx-vcmodal-input:focus { + border-color: var(--color-primary-400); + box-shadow: 0 0 0 3px var(--color-primary-100); +} + +/* ── 上传区域 ─────────────────────────────────────────────── */ + +.xx-vcmodal-upload-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-lg) var(--space-md); + border: 2px dashed var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.xx-vcmodal-upload-zone:hover { + border-color: var(--color-primary-300); + background: var(--color-primary-50); +} + +.xx-vcmodal-upload-zone--active { + border-color: var(--color-primary-500); + background: var(--color-primary-100); +} + +.xx-vcmodal-upload-zone--has-file { + border-style: solid; + border-color: var(--color-primary-400); + background: var(--color-primary-50); +} + +.xx-vcmodal-upload-icon { + font-size: 32px; + line-height: 1; +} + +.xx-vcmodal-upload-title { + margin: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + color: var(--text-primary); + text-align: center; + word-break: break-all; +} + +.xx-vcmodal-upload-hint { + margin: 0; + font-size: var(--font-size-sm); + color: var(--text-secondary); + text-align: center; +} + +/* ── 分隔线 ───────────────────────────────────────────────── */ + +.xx-vcmodal-divider { + display: flex; + align-items: center; + gap: var(--space-md); + margin: var(--space-xs) 0; +} + +.xx-vcmodal-divider-line { + flex: 1; + height: 1px; + background: var(--border-color); +} + +.xx-vcmodal-divider-text { + font-size: var(--font-size-sm); + color: var(--text-tertiary); + font-weight: var(--font-weight-medium); +} + +/* ── 录制区域 ─────────────────────────────────────────────── */ + +.xx-vcmodal-record-area { + display: flex; + align-items: center; + gap: var(--space-md); + padding: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-secondary); +} + +.xx-vcmodal-record-info { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.xx-vcmodal-record-hint { + margin: 0; + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +/* 录制波形动画 */ +.xx-vcmodal-record-wave { + display: flex; + align-items: flex-end; + gap: 3px; + height: 20px; +} + +.xx-vcmodal-record-wave-bar { + width: 3px; + background: var(--color-primary-500); + border-radius: var(--radius-full); + animation: vcmodal-wave 0.8s ease-in-out infinite alternate; +} + +.xx-vcmodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; } +.xx-vcmodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; } +.xx-vcmodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; } +.xx-vcmodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; } +.xx-vcmodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; } + +@keyframes vcmodal-wave { + from { transform: scaleY(0.4); } + to { transform: scaleY(1); } +} + +/* 录制按钮 */ +.xx-vcmodal-record-btn { + width: 48px; + height: 48px; + border: none; + border-radius: var(--radius-full); + background: var(--bg-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + font-size: 20px; + cursor: pointer; + transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.xx-vcmodal-record-btn:hover { + background: var(--color-primary-50); + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.xx-vcmodal-record-btn--recording { + background: var(--error-color); + color: var(--text-inverse); + animation: vcmodal-pulse 1.2s ease-in-out infinite; +} + +.xx-vcmodal-record-btn--recording:hover { + background: var(--error-hover); +} + +@keyframes vcmodal-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } + 50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); } +} + +/* ── 提示 ─────────────────────────────────────────────────── */ + +.xx-vcmodal-tip { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-xs); + background: var(--info-soft); + border: 1px solid var(--info-border); + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: var(--line-height-base); +} + +.xx-vcmodal-tip-icon { + flex-shrink: 0; + font-size: var(--font-size-base); +} + +/* ── 底部按钮 ─────────────────────────────────────────────── */ + +.xx-vcmodal-footer { + display: flex; + justify-content: flex-end; + gap: var(--space-sm); + padding-top: var(--space-sm); + border-top: 1px solid var(--border-light); +} + +/* ── 进度阶段通用 ─────────────────────────────────────────── */ + +.xx-vcmodal-progress-body { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xl); + padding: var(--space-lg) 0 var(--space-md); +} + +/* ── 步骤指示器 ───────────────────────────────────────────── */ + +.xx-vcmodal-steps { + display: flex; + align-items: center; + gap: 0; + width: 100%; + max-width: 360px; +} + +.xx-vcmodal-step { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + flex: 1; + position: relative; +} + +.xx-vcmodal-step-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: var(--bg-tertiary); + border: 2px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--text-tertiary); + transition: all 0.3s ease; +} + +.xx-vcmodal-step--active .xx-vcmodal-step-icon { + background: var(--color-primary-50); + border-color: var(--color-primary-500); + color: var(--color-primary-600); + box-shadow: 0 0 0 4px var(--color-primary-100); +} + +.xx-vcmodal-step--done .xx-vcmodal-step-icon { + background: var(--color-secondary-50); + border-color: var(--color-secondary-500); + color: var(--color-secondary-600); +} + +.xx-vcmodal-step-label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--text-tertiary); + transition: color 0.3s ease; +} + +.xx-vcmodal-step--active .xx-vcmodal-step-label { + color: var(--color-primary-600); + font-weight: var(--font-weight-semibold); +} + +.xx-vcmodal-step--done .xx-vcmodal-step-label { + color: var(--color-secondary-600); +} + +/* 步骤连接线 */ +.xx-vcmodal-step-connector { + flex: 0 0 40px; + height: 2px; + background: var(--border-color); + margin-bottom: 20px; + transition: background 0.3s ease; +} + +.xx-vcmodal-step-connector--done { + background: var(--color-secondary-400); +} + +/* ── 进度信息 ─────────────────────────────────────────────── */ + +.xx-vcmodal-progress-info { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + text-align: center; +} + +/* 加载动画 */ +.xx-vcmodal-progress-spinner { + width: 48px; + height: 48px; + border: 3px solid var(--color-primary-100); + border-top-color: var(--color-primary-500); + border-radius: var(--radius-full); + animation: vcmodal-spin 0.8s linear infinite; +} + +.xx-vcmodal-progress-spinner--cloning { + border-color: var(--color-secondary-100); + border-top-color: var(--color-secondary-500); +} + +@keyframes vcmodal-spin { + to { transform: rotate(360deg); } +} + +.xx-vcmodal-progress-text { + margin: 0; + font-size: var(--font-size-md); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.xx-vcmodal-progress-sub { + margin: 0; + font-size: var(--font-size-sm); + color: var(--text-secondary); +} + +/* ── 完成阶段 ─────────────────────────────────────────────── */ + +.xx-vcmodal-success { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + text-align: center; +} + +.xx-vcmodal-success-icon { + font-size: 48px; + line-height: 1; + animation: vcmodal-bounce 0.6s ease-out; +} + +@keyframes vcmodal-bounce { + 0% { transform: scale(0); opacity: 0; } + 60% { transform: scale(1.2); opacity: 1; } + 100% { transform: scale(1); } +} + +.xx-vcmodal-success-title { + margin: 0; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--text-primary); +} + +.xx-vcmodal-success-desc { + margin: 0; + font-size: var(--font-size-sm); + color: var(--text-secondary); + max-width: 280px; + line-height: var(--line-height-relaxed); +} + +/* ── 响应式 ───────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .xx-vcmodal-body { + gap: var(--space-sm); + } + + .xx-vcmodal-upload-zone { + padding: var(--space-md) var(--space-sm); + } + + .xx-vcmodal-record-area { + flex-direction: column; + align-items: stretch; + text-align: center; + } + + .xx-vcmodal-record-btn { + align-self: center; + } + + .xx-vcmodal-steps { + max-width: 280px; + } + + .xx-vcmodal-step-connector { + flex: 0 0 24px; + } + + .xx-vcmodal-footer { + flex-direction: column-reverse; + } + + .xx-vcmodal-footer > * { + width: 100%; + } +} + +@media (max-width: 480px) { + .xx-vcmodal-step-icon { + width: 32px; + height: 32px; + font-size: 13px; + } + + .xx-vcmodal-step-connector { + flex: 0 0 16px; + margin-bottom: 16px; + } + + .xx-vcmodal-progress-spinner { + width: 36px; + height: 36px; + } + + .xx-vcmodal-success-icon { + font-size: 36px; + } +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx index 16b33a1e4..a6fd9522b 100644 --- a/apps/web/src/components/ui/Button.tsx +++ b/apps/web/src/components/ui/Button.tsx @@ -9,12 +9,7 @@ import classNames from "classnames"; import "./ui.css"; export type ButtonType = - | "primary" - | "secondary" - | "ghost" - | "text" - | "danger" - | "link"; + "primary" | "secondary" | "ghost" | "text" | "danger" | "link"; export type ButtonSize = "sm" | "md" | "lg"; @@ -70,7 +65,9 @@ const Button: React.FC = ({ ...rest }) => { const antdType = type ?? toAntdType(buttonType); - const antdSize = size ?? (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle"); + const antdSize = + size ?? + (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle"); const v21Class = classNames( "xx-btn", diff --git a/apps/web/src/components/ui/Form.tsx b/apps/web/src/components/ui/Form.tsx index f8647b396..50e1f9ecf 100644 --- a/apps/web/src/components/ui/Form.tsx +++ b/apps/web/src/components/ui/Form.tsx @@ -20,7 +20,10 @@ const Form = ({ className, compact, children, ...rest }: FormProps) => { className, ); return ( - )}> + )} + > {children as React.ReactNode} ); diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx index 23ad6f212..405af59ca 100644 --- a/apps/web/src/components/ui/Input.tsx +++ b/apps/web/src/components/ui/Input.tsx @@ -9,7 +9,11 @@ import type { InputRef } from "antd/es/input"; import classNames from "classnames"; import "./ui.css"; -const { Search: AntSearch, TextArea: AntTextArea, Password: AntPassword } = AntInput; +const { + Search: AntSearch, + TextArea: AntTextArea, + Password: AntPassword, +} = AntInput; export interface InputProps extends AntInputProps { /** 是否处于错误状态 */ @@ -46,11 +50,7 @@ const Search: React.FC< const TextArea: React.FC< React.ComponentProps & { error?: boolean } > = ({ className, error, ...rest }) => { - const v21Class = classNames( - "xx-input", - error && "xx-input-error", - className, - ); + const v21Class = classNames("xx-input", error && "xx-input-error", className); return ; }; @@ -59,11 +59,7 @@ const Password = React.forwardRef< InputRef, React.ComponentProps & { error?: boolean } >(({ className, error, ...rest }, ref) => { - const v21Class = classNames( - "xx-input", - error && "xx-input-error", - className, - ); + const v21Class = classNames("xx-input", error && "xx-input-error", className); return ; }); Password.displayName = "Password"; diff --git a/apps/web/src/components/ui/Select.tsx b/apps/web/src/components/ui/Select.tsx index 86d6b50c8..ae2aed3f6 100644 --- a/apps/web/src/components/ui/Select.tsx +++ b/apps/web/src/components/ui/Select.tsx @@ -19,10 +19,7 @@ const Select: React.FC = ({ ...rest }) => { const v21Class = classNames("xx-select", className); - const v21DropdownClass = classNames( - "xx-select-dropdown", - dropdownClassName, - ); + const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName); return ( - extends AntTableProps { +export interface TableProps< + RecordType = unknown, +> extends AntTableProps { /** 使用 V21 样式 */ v21?: boolean; } diff --git a/apps/web/src/components/ui/Tag.tsx b/apps/web/src/components/ui/Tag.tsx index 357b82939..25859e684 100644 --- a/apps/web/src/components/ui/Tag.tsx +++ b/apps/web/src/components/ui/Tag.tsx @@ -4,7 +4,10 @@ */ import React from "react"; import { Tag as AntTag, Badge as AntBadge } from "antd"; -import type { TagProps as AntTagProps, BadgeProps as AntBadgeProps } from "antd"; +import type { + TagProps as AntTagProps, + BadgeProps as AntBadgeProps, +} from "antd"; import classNames from "classnames"; import "./ui.css"; diff --git a/apps/web/src/components/ui/Tooltip.tsx b/apps/web/src/components/ui/Tooltip.tsx index d75f310c0..77f4b7e00 100644 --- a/apps/web/src/components/ui/Tooltip.tsx +++ b/apps/web/src/components/ui/Tooltip.tsx @@ -3,10 +3,7 @@ * 封装 Ant Design Tooltip 和 Popover,应用 V21 设计系统样式 */ import React from "react"; -import { - Tooltip as AntTooltip, - Popover as AntPopover, -} from "antd"; +import { Tooltip as AntTooltip, Popover as AntPopover } from "antd"; import classNames from "classnames"; import "./ui.css"; diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx new file mode 100644 index 000000000..479c8a75b --- /dev/null +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -0,0 +1,366 @@ +/** + * CloneModal — 音色克隆弹窗 + * 任务 3.13:实现克隆 Modal,用户点击「克隆音色」按钮后弹出 + * + * 功能: + * - 步骤引导:上传音频 → 填写信息 → 提交克隆 + * - 表单字段:音频文件上传(wav/mp3/m4a,≤10MB)、音色名称(必填,2-20字符)、音色描述(可选,≤100字符) + * - 提交后调用 POST /api/v1/voice-clones 创建克隆 + * - 创建成功后关闭 Modal,刷新配音列表 + * - 错误处理:上传失败、格式错误、大小超限等提示 + * + * V21 Design System — 零 antd 直接导入 + */ +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { Modal, Button } from "@/components/ui"; +import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"; +import type { VoiceClone } from "@/api/voiceClone"; +import { uploadAsset } from "@/api/assets"; +import "./clone-modal.css"; + +/* ── 类型定义 ───────────────────────────────────────────── */ + +type ModalPhase = "input" | "uploading" | "success"; + +export interface CloneModalProps { + /** 弹窗是否可见 */ + open: boolean; + /** 关闭弹窗回调 */ + onClose: () => void; + /** 克隆成功回调(返回新创建的音色) */ + onSuccess?: (voice: VoiceClone) => void; +} + +/* ── 常量 ───────────────────────────────────────────────── */ + +const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]; +const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +/* ── 组件 ───────────────────────────────────────────────── */ + +const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { + const [phase, setPhase] = useState("input"); + const [voiceName, setVoiceName] = useState(""); + const [voiceDescription, setVoiceDescription] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const fileInputRef = useRef(null); + const timerRef = useRef | null>(null); + + /** 组件卸载时清理定时器(P2-2 修复) */ + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + /** 重置弹窗状态 */ + const resetState = useCallback(() => { + setPhase("input"); + setVoiceName(""); + setVoiceDescription(""); + setSelectedFile(null); + setDragActive(false); + setErrorMessage(""); + }, []); + + /** 关闭弹窗 */ + const handleClose = useCallback(() => { + resetState(); + onClose(); + }, [resetState, onClose]); + + /** 弹窗打开时重置状态 */ + useEffect(() => { + if (open) { + resetState(); + } + }, [open, resetState]); + + /* ── 文件验证 ──────────────────────────────────────── */ + + const validateFile = (file: File): string | null => { + const ext = file.name.split(".").pop()?.toLowerCase(); + if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) { + return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"; + } + if (file.size > MAX_FILE_SIZE) { + return "文件大小超过 10MB,请压缩后重试"; + } + return null; + }; + + /* ── 文件上传 ──────────────────────────────────────── */ + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const error = validateFile(file); + if (error) { + setErrorMessage(error); + setSelectedFile(null); + } else { + setErrorMessage(""); + setSelectedFile(file); + } + } + e.target.value = ""; + }; + + /* ── 拖拽 ──────────────────────────────────────────── */ + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file) { + const error = validateFile(file); + if (error) { + setErrorMessage(error); + setSelectedFile(null); + } else { + setErrorMessage(""); + setSelectedFile(file); + } + } + }; + + /* ── 表单验证 ──────────────────────────────────────── */ + + const validateForm = (): string | null => { + const name = voiceName.trim(); + if (!name) { + return "请输入音色名称"; + } + if (name.length < 2 || name.length > 20) { + return "音色名称需在 2-20 个字符之间"; + } + if (!selectedFile) { + return "请上传音频文件"; + } + return null; + }; + + /* ── 提交克隆 ──────────────────────────────────────── */ + + const handleSubmit = async () => { + const formError = validateForm(); + if (formError) { + setErrorMessage(formError); + return; + } + + setPhase("uploading"); + setErrorMessage(""); + + try { + // P1 修复:先上传音频文件获取真实 URL,再调用克隆 API + const formData = new FormData(); + formData.append("file", selectedFile!); + const uploadResult = await uploadAsset(formData); + + const result = await createVoiceClone({ + name: voiceName.trim(), + description: voiceDescription.trim() || undefined, + audio_url: uploadResult.url, + }); + + setPhase("success"); + + // 2秒后自动关闭(P2-2 修复:使用 timerRef 以便 cleanup) + timerRef.current = setTimeout(() => { + onSuccess?.(toVoiceClone(result)); + handleClose(); + }, 2000); + } catch (err) { + setPhase("input"); + setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试"); + } + }; + + /* ── 计算属性 ──────────────────────────────────────── */ + + const canSubmit = + voiceName.trim().length >= 2 && + voiceName.trim().length <= 20 && + selectedFile !== null; + + const isProcessing = phase === "uploading"; + + return ( + + {/* ── 输入阶段 ──────────────────────────────────── */} + {phase === "input" && ( +
    + {/* 步骤引导 */} +
    +
    +
    1
    + 上传音频 +
    +
    +
    +
    2
    + 填写信息 +
    +
    +
    +
    3
    + 提交克隆 +
    +
    + + {/* 上传区域 */} +
    + +
    +
    + {selectedFile ? "📄" : "🎵"} +
    +

    + {selectedFile + ? selectedFile.name + : "拖拽音频文件到此处,或点击上传"} +

    +

    + 支持 MP3、WAV、M4A 格式,最大 10MB +

    + +
    +
    + + {/* 音色名称 */} +
    + + setVoiceName(e.target.value)} + placeholder="输入音色名称(2-20字符)" + maxLength={20} + /> +
    + {voiceName.length}/20 +
    +
    + + {/* 音色描述 */} +
    + +