diff --git a/alembic/versions/022_add_source_edit_plan_id.py b/alembic/versions/022_add_source_edit_plan_id.py new file mode 100644 index 000000000..404a5b49e --- /dev/null +++ b/alembic/versions/022_add_source_edit_plan_id.py @@ -0,0 +1,56 @@ +"""Task: Add source_edit_plan_id to edit_plans and generation_tasks + +Revision ID: 022 +Revises: 021 +Create Date: 2026-07-04 + +新增 source_edit_plan_id 字段到 edit_plans 和 generation_tasks 表, +用于关联生成记录到其来源的剪辑计划。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "022" +down_revision = "021" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "edit_plans", + sa.Column("source_edit_plan_id", sa.String(32), nullable=True), + ) + op.create_index( + op.f("ix_edit_plans_source_edit_plan_id"), + "edit_plans", + ["source_edit_plan_id"], + unique=False, + ) + + op.add_column( + "generation_tasks", + sa.Column("source_edit_plan_id", sa.String(32), nullable=True), + ) + op.create_index( + op.f("ix_generation_tasks_source_edit_plan_id"), + "generation_tasks", + ["source_edit_plan_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_generation_tasks_source_edit_plan_id"), + table_name="generation_tasks", + ) + op.drop_column("generation_tasks", "source_edit_plan_id") + + op.drop_index( + op.f("ix_edit_plans_source_edit_plan_id"), + table_name="edit_plans", + ) + op.drop_column("edit_plans", "source_edit_plan_id") diff --git a/alembic/versions/023_add_edit_plan_project_and_user.py b/alembic/versions/023_add_edit_plan_project_and_user.py new file mode 100644 index 000000000..c39583683 --- /dev/null +++ b/alembic/versions/023_add_edit_plan_project_and_user.py @@ -0,0 +1,56 @@ +"""Task: Add project_id and created_by_user_id to edit_plans + +Revision ID: 023 +Revises: 022 +Create Date: 2026-07-05 + +新增 project_id 和 created_by_user_id 字段到 edit_plans 表, +用于项目归属鉴权和用户归属追踪,修复审计发现的 P1 越权漏洞。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "023" +down_revision = "022" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "edit_plans", + sa.Column("project_id", sa.String(32), nullable=False, server_default=""), + ) + op.create_index( + op.f("ix_edit_plans_project_id"), + "edit_plans", + ["project_id"], + unique=False, + ) + + op.add_column( + "edit_plans", + sa.Column("created_by_user_id", sa.String(32), nullable=False, server_default=""), + ) + op.create_index( + op.f("ix_edit_plans_created_by_user_id"), + "edit_plans", + ["created_by_user_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_edit_plans_created_by_user_id"), + table_name="edit_plans", + ) + op.drop_column("edit_plans", "created_by_user_id") + + op.drop_index( + op.f("ix_edit_plans_project_id"), + table_name="edit_plans", + ) + op.drop_column("edit_plans", "project_id") diff --git a/alembic/versions/024_add_user_is_admin.py b/alembic/versions/024_add_user_is_admin.py new file mode 100644 index 000000000..1fd571735 --- /dev/null +++ b/alembic/versions/024_add_user_is_admin.py @@ -0,0 +1,28 @@ +"""Task: Add is_admin to users + +Revision ID: 024 +Revises: 023 +Create Date: 2026-07-05 + +新增 is_admin 字段到 users 表,用于模板管理等管理员权限校验。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "024" +down_revision = "023" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + + +def downgrade() -> None: + op.drop_column("users", "is_admin") diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py index 74b09af61..f005a1b63 100644 --- a/apps/api/app/api/routes/asset_diagnosis.py +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -1,4 +1,5 @@ -from typing import Any +import logging +from typing import Any, Optional from app.auth import AuthenticatedUser, get_current_user from app.dependencies import ( @@ -7,10 +8,12 @@ from app.dependencies import ( get_project_repository, ) from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from packages.domain import Asset, AssetLibraryKind, AssetStatus +logger = logging.getLogger(__name__) + router = APIRouter() @@ -190,21 +193,155 @@ def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagno ) +def _build_single_asset_diagnosis(project_id: str, asset: Asset) -> ProjectAssetDiagnosisResponse: + """为单个素材构建诊断结果""" + kind = _asset_kind(asset) + is_ready = asset.status == AssetStatus.READY + is_problem = asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING} + is_risky = ( + is_ready + and ( + (asset.quality_score is not None and asset.quality_score < 60) + or asset.metadata.get("review_status") == "rejected" + or asset.status == AssetStatus.ERROR + ) + ) + is_unclassified = is_ready and asset.classification_status.value in {"pending", "failed"} + + # 单素材评分 + score = 0 + if is_ready: + score = 60 + if kind == "video": + score += 20 + if asset.duration and asset.duration >= 5: + score += 10 + if asset.quality_score and asset.quality_score >= 60: + score += 10 + if is_problem: + score = max(score - 30, 0) + if is_risky: + score = max(score - 20, 0) + score = max(0, min(100, score)) + + gaps: list[AssetGapItem] = [] + if not is_ready: + gaps.append( + AssetGapItem( + key="asset_not_ready", + severity="critical", + message=f"素材状态为 {asset.status.value},尚未就绪", + recommendation="等待素材导入完成后再使用。", + ) + ) + if is_risky: + gaps.append( + AssetGapItem( + key="asset_low_quality", + severity="warning", + message="素材质量分偏低或已被拒绝", + recommendation="建议使用更清晰、稳定的素材替代。", + ) + ) + if is_unclassified: + gaps.append( + AssetGapItem( + key="asset_unclassified", + severity="info", + message="素材尚未完成分类", + recommendation="等待分类完成或手动检查素材类型。", + ) + ) + if kind == "video" and (asset.duration is None or asset.duration < 5): + gaps.append( + AssetGapItem( + key="short_video", + severity="warning", + message="视频时长偏短", + recommendation="建议使用时长 5 秒以上的视频素材。", + ) + ) + + used_count = int(asset.metadata.get("generation_use_count") or 0) + smart_views = [ + AssetSmartViewItem( + key="asset_info", + label="素材信息", + count=1, + description=f"类型: {kind},状态: {asset.status.value}", + ), + AssetSmartViewItem( + key="asset_quality", + label="质量评分", + count=int(asset.quality_score or 0), + description=f"质量分: {asset.quality_score or '未评分'}", + ), + AssetSmartViewItem( + key="asset_usage", + label="使用次数", + count=used_count, + description=f"参与生成 {used_count} 次", + ), + ] + + video_count = 1 if kind == "video" and is_ready else 0 + image_count = 1 if kind == "image" and is_ready else 0 + voice_count = 1 if kind == "voice" and is_ready else 0 + total_duration = round(float(asset.duration or 0), 2) if kind == "video" else 0.0 + + return ProjectAssetDiagnosisResponse( + project_id=project_id, + readiness_score=score, + readiness_label=_readiness_label(score), + total_assets=1, + ready_assets=1 if is_ready else 0, + video_assets=video_count, + image_assets=image_count, + voice_assets=voice_count, + total_duration_seconds=total_duration, + estimated_video_count=1 if video_count and total_duration >= 5 else 0, + used_assets=1 if used_count > 0 else 0, + unused_assets=1 if used_count == 0 and is_ready else 0, + pending_review_assets=1 if asset.metadata.get("review_status") == "pending_review" else 0, + smart_views=smart_views, + gaps=gaps, + ) + + @router.get("/projects/{project_id}/asset-diagnosis", response_model=ProjectAssetDiagnosisResponse) def get_project_asset_diagnosis( project_id: str, + asset_id: Optional[str] = Query(None), authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), asset_library_repository: Any = Depends(get_asset_library_repository), asset_repository: Any = Depends(get_asset_repository), ) -> ProjectAssetDiagnosisResponse: - project = project_repository.find_by_id(project_id) - if project is None: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + try: + 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(authenticated_user.user.id): + raise HTTPException(status_code=403, detail="Access denied to project") - libraries = asset_library_repository.find_by_project(project_id) - assets: list[Asset] = [] - for library in libraries: - assets.extend(asset_repository.list_by_library(library.id)) + # 单素材诊断模式 + if asset_id: + asset = asset_repository.get(asset_id) + if asset is None: + raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found") + if asset.project_id != project_id: + raise HTTPException(status_code=403, detail="Asset does not belong to this project") + return _build_single_asset_diagnosis(project_id, asset) + + libraries = asset_library_repository.find_by_project(project_id) + assets: list[Asset] = [] + for library in libraries: + assets.extend(asset_repository.list_by_library(library.id)) + except HTTPException: + raise + except Exception: + logger.exception("素材诊断查询失败: project_id=%s", project_id) + # 返回空诊断结果,避免 500 + return _build_diagnosis(project_id, []) return _build_diagnosis(project_id, assets) diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 2a8181916..187c59711 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -1,13 +1,15 @@ -from typing import Any +import logging +from typing import Any, Optional from app.auth import AuthenticatedUser, get_current_user +from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, ) from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from packages.application import ( CreateAssetCommand, @@ -16,10 +18,27 @@ from packages.application import ( ) from packages.domain import AssetStatus, ClassificationStatus +logger = logging.getLogger(__name__) + router = APIRouter() -def _to_asset_response(item) -> AssetResponse: +def _to_asset_response(item, storage_service=None) -> AssetResponse: + # 生成签名文件 URL(用于视频播放 / 文件下载) + file_url = None + if item.storage_key: + try: + svc = storage_service or get_storage_service() + file_url = svc.get_download_url(item.storage_key) + except Exception: + logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True) + file_url = None + + # 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL + thumbnail_url = item.thumbnail_url + if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url: + thumbnail_url = file_url + return AssetResponse( id=item.id, project_id=item.project_id, @@ -29,7 +48,8 @@ def _to_asset_response(item) -> AssetResponse: mime_type=item.mime_type, metadata=item.metadata, file_size=item.file_size, - thumbnail_url=item.thumbnail_url, + file_url=file_url, + thumbnail_url=thumbnail_url, duration=item.duration, width=item.width, height=item.height, @@ -53,19 +73,41 @@ def _check_project_access(project_id: str, user_id: str, project_repository) -> @router.get("", response_model=ListAssetsResponse) def list_assets( - library_id: str, + library_id: Optional[str] = Query(None), + project_id: Optional[str] = Query(None), authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_repository: Any = Depends(get_asset_repository), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), ) -> ListAssetsResponse: - library = asset_library_repository.get(library_id) - if library is None: - raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") - _check_project_access(library.project_id, authenticated_user.user.id, project_repository) - use_case = ListAssetsUseCase(asset_repository) - items = use_case.execute(library_id) - return ListAssetsResponse(items=[_to_asset_response(item) for item in items]) + user_id = authenticated_user.user.id + + # 模式1:指定 library_id → 返回该库的素材 + if library_id: + library = asset_library_repository.get(library_id) + if library is None: + raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") + _check_project_access(library.project_id, user_id, project_repository) + items = asset_repository.find_by_library(library_id) + return ListAssetsResponse(items=[_to_asset_response(item) for item in items]) + + # 模式2:指定 project_id → 返回该项目所有素材 + if project_id: + _check_project_access(project_id, user_id, project_repository) + items = asset_repository.find_by_project(project_id) + return ListAssetsResponse(items=[_to_asset_response(item) for item in items]) + + # 模式3:都不传 → 返回用户可访问的所有项目的所有素材 + try: + projects = project_repository.find_accessible_projects(user_id) + except Exception: + logger.exception("查询用户可访问项目失败: user_id=%s", user_id) + return ListAssetsResponse(items=[]) + + all_items = [] + for proj in projects: + all_items.extend(asset_repository.find_by_project(proj.id)) + return ListAssetsResponse(items=[_to_asset_response(item) for item in all_items]) def _apply_asset_review_status(item, review_status: str): diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index bef193c39..ee4b3350c 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -8,6 +8,8 @@ RESTful CRUD for EditPlan: - 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) +- POST /api/v1/edit-plans/{id}/ai-recommend AI 推荐片段方案(任务 3.09) +- POST /api/v1/edit-plans/{id}/generate-cover AI 生成封面(任务 3.09) 业务逻辑委托给 EditPlanService 服务层。 """ @@ -20,13 +22,13 @@ 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.dependencies import get_db_session, get_project_repository from app.services import EditPlanService from fastapi import APIRouter, Depends, HTTPException, Query, status -from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from app.schemas.generation_task import GenerationTaskResponse from packages.adapters.sqlalchemy_impl.generation_task_repository import ( SQLAlchemyGenerationTaskRepository, ) @@ -34,8 +36,12 @@ from packages.application.generation_tasks import ( CreateGenerationTaskCommand, CreateGenerationTaskUseCase, ) +from packages.domain.config_schemas import normalize_plan_config from packages.domain.edit_plan import EditPlan, EditPlanStatus +# AI 推荐 & 封面生成(轻量模块,无 Celery 依赖,可安全静态导入) +from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend, run_generate_cover + logger = logging.getLogger(__name__) router = APIRouter() @@ -51,6 +57,7 @@ class EditPlanCreateRequest(BaseModel): 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="总时长 (秒)") + project_id: str = Field(default="", description="所属项目 ID") class EditPlanUpdateRequest(BaseModel): @@ -73,6 +80,8 @@ class EditPlanResponse(BaseModel): name: str status: str total_duration: float + project_id: str = "" + created_by_user_id: str = "" config: dict[str, Any] created_at: datetime updated_at: datetime @@ -119,9 +128,86 @@ class EditPlanGenerateResponse(BaseModel): clip_count: int +class EditPlanGenerationsResponse(BaseModel): + """剪辑计划关联的生成记录列表响应体""" + + items: List[GenerationTaskResponse] + total: int + + +# ── AI 推荐片段方案 Schemas(任务 3.09) ────────────────────────────────────── + + +class AIRecommendRequest(BaseModel): + """AI 推荐片段方案请求体""" + + asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") + editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip") + target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)") + + +class AIRecommendClipItem(BaseModel): + """AI 推荐的单个片段""" + + clip_type: str = Field(..., description="片段类型: intro / showcase / title / subtitle / cta / outro") + order: int = Field(..., ge=0, description="片段顺序") + text_content: str = Field(default="", description="文字内容") + duration: float = Field(..., ge=0.0, description="片段时长(秒)") + transition_effect: str = Field(default="cut", description="转场效果") + asset_id: str = Field(default="", description="关联素材 ID") + start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)") + config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置") + + +class AIRecommendResponse(BaseModel): + """AI 推荐片段方案响应体""" + + plan_id: str = Field(..., description="剪辑计划 ID") + clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表") + config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)") + total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)") + confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)") + + +# ── AI 封面生成 Schemas(任务 3.09) ───────────────────────────────────────── + + +class GenerateCoverRequest(BaseModel): + """AI 封面生成请求体""" + + asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)") + cover_type: str = Field( + default="ai_frame", + description="封面类型: ai_frame / manual / upload / ai_regenerate", + ) + frame_time: Optional[float] = Field( + default=None, + ge=0.0, + description="手动选帧时间点(秒),仅 cover_type=manual 时有效", + ) + + +class GenerateCoverResponse(BaseModel): + """AI 封面生成响应体""" + + plan_id: str = Field(..., description="剪辑计划 ID") + cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)") + + # ── Helpers ─────────────────────────────────────────────────────────────────── +def _check_project_access(project_id: str, user_id: str, project_repository: Any) -> None: + """校验用户对项目的访问权限(参照 assets.py 的 can_access 模式)""" + if not project_id or not project_id.strip(): + return + 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") + + def _to_response(p: EditPlan) -> EditPlanResponse: return EditPlanResponse( id=p.id, @@ -129,6 +215,8 @@ def _to_response(p: EditPlan) -> EditPlanResponse: name=p.name, status=p.status.value if hasattr(p.status, "value") else p.status, total_duration=p.total_duration, + project_id=p.project_id or "", + created_by_user_id=p.created_by_user_id or "", config=p.config, created_at=p.created_at, updated_at=p.updated_at, @@ -143,6 +231,7 @@ 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 筛选"), + project_id: Optional[str] = Query(default=None, description="按项目 ID 筛选"), status_filter: Optional[str] = Query( default=None, alias="status", @@ -150,10 +239,15 @@ def list_plans( ), db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> EditPlanListResponse: - """获取剪辑计划列表(支持分页、按模板/状态筛选)""" + """获取剪辑计划列表(支持分页、按模板/状态/项目筛选)""" svc = EditPlanService(db) + # 空串 project_id 视为未传(避免 DB 中匹配到空串记录) + if project_id is not None and not project_id.strip(): + project_id = None + # 解析状态筛选 status_enum: Optional[EditPlanStatus] = None if status_filter: @@ -165,15 +259,21 @@ def list_plans( detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"), ) + # 项目鉴权:如果指定了 project_id,校验用户是否有权访问 + if project_id: + _check_project_access(project_id, current_user.user.id, project_repository) + skip = (page - 1) * page_size plans = svc.list_plans( template_id=template_id, + project_id=project_id, status=status_enum, skip=skip, limit=page_size, ) total = svc.count_plans( template_id=template_id, + project_id=project_id, status=status_enum, ) @@ -190,6 +290,7 @@ def get_plan( plan_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> EditPlanResponse: """获取单个剪辑计划详情""" svc = EditPlanService(db) @@ -200,6 +301,9 @@ def get_plan( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc), ) + # 项目鉴权 + if plan.project_id: + _check_project_access(plan.project_id, current_user.user.id, project_repository) return _to_response(plan) @@ -208,15 +312,25 @@ def create_plan( body: EditPlanCreateRequest, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> EditPlanResponse: """创建剪辑计划""" + # 空串 project_id 统一为 "" + project_id = (body.project_id or "").strip() + # 项目鉴权 + if project_id: + _check_project_access(project_id, current_user.user.id, project_repository) svc = EditPlanService(db) + # 标准化 config,填充 cover/title/subtitle/bgm 默认值 + normalized_config = normalize_plan_config(body.config) try: created = svc.create_plan( template_id=body.template_id, name=body.name, - config=body.config, + config=normalized_config, total_duration=body.total_duration, + project_id=project_id, + created_by_user_id=current_user.user.id, ) except ValueError as exc: raise HTTPException( @@ -238,17 +352,26 @@ def update_plan( body: EditPlanUpdateRequest, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> EditPlanResponse: """更新剪辑计划(支持状态机流转)""" svc = EditPlanService(db) + # 项目鉴权 + existing = svc.get_plan(plan_id) + if existing is None: + raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") + if existing.project_id: + _check_project_access(existing.project_id, current_user.user.id, project_repository) # 基础字段更新 try: if body.name is not None or body.config is not None or body.total_duration is not None: + # 标准化 config(如果提供了) + config_to_update = normalize_plan_config(body.config) if body.config is not None else None svc.update_plan( plan_id, name=body.name, - config=body.config, + config=config_to_update, total_duration=body.total_duration, ) @@ -285,9 +408,14 @@ def delete_plan( plan_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), -) -> Response: + project_repository: Any = Depends(get_project_repository), +) -> None: """删除剪辑计划""" svc = EditPlanService(db) + # 项目鉴权 + existing = svc.get_plan(plan_id) + if existing and existing.project_id: + _check_project_access(existing.project_id, current_user.user.id, project_repository) deleted = svc.delete_plan(plan_id) if not deleted: raise HTTPException( @@ -299,7 +427,6 @@ def delete_plan( plan_id, current_user.user.id, ) - return Response(status_code=204) # ── 生成相关端点(任务 2.05) ───────────────────────────────────────────────── @@ -310,6 +437,7 @@ def generate_plan( plan_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> EditPlanGenerateResponse: """触发剪辑计划渲染生成 @@ -322,6 +450,12 @@ def generate_plan( 5. 将计划状态流转为 rendering """ svc = EditPlanService(db) + # 项目鉴权 + plan_check = svc.get_plan(plan_id) + if plan_check is None: + raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}") + if plan_check.project_id: + _check_project_access(plan_check.project_id, current_user.user.id, project_repository) # 检查是否可生成 try: @@ -349,6 +483,7 @@ def generate_plan( project_id="", template_id=plan.template_id, created_by_user_id=current_user.user.id, + source_edit_plan_id=plan_id, ) ) @@ -388,6 +523,7 @@ def get_generation_status( plan_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> EditPlanGenerationStatusResponse: """查询剪辑计划生成进度 @@ -403,6 +539,9 @@ def get_generation_status( ) plan = gen_status["plan"] + # 项目鉴权 + if plan.project_id: + _check_project_access(plan.project_id, current_user.user.id, project_repository) clips = gen_status["clips"] clip_items = [ @@ -426,6 +565,250 @@ def get_generation_status( ) +@router.get( + "/{plan_id}/generations", + response_model=EditPlanGenerationsResponse, +) +def list_plan_generations( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> EditPlanGenerationsResponse: + """查询剪辑计划关联的所有生成记录 + + 返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。 + """ + svc = EditPlanService(db) + # 验证计划存在 + 项目鉴权 + plan = svc.get_plan_or_raise(plan_id) + if plan.project_id: + _check_project_access(plan.project_id, current_user.user.id, project_repository) + + gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + tasks = gen_task_repo.list_by_source_edit_plan(plan_id) + items = [ + GenerationTaskResponse( + id=t.id, + project_id=t.project_id, + asset_library_id=t.asset_library_id, + strategy_id=t.strategy_id, + voice_library_id=t.voice_library_id, + template_id=t.template_id, + asset_ids=t.asset_ids, + title_ids=t.title_ids, + voice_ids=t.voice_ids, + source_edit_plan_id=t.source_edit_plan_id or "", + status=t.status.value if hasattr(t.status, "value") else t.status, + progress=t.progress, + result_count=t.result_count, + error_message=t.error_message, + ) + for t in tasks + ] + return EditPlanGenerationsResponse(items=items, total=len(items)) + + +# ── AI 推荐 & 封面生成端点(任务 3.09) ──────────────────────────────────────── + + +@router.post( + "/{plan_id}/ai-recommend", + response_model=AIRecommendResponse, +) +def ai_recommend_clips( + plan_id: str, + body: AIRecommendRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> AIRecommendResponse: + """AI 推荐片段方案 + + 调用 AI 服务分析素材,自动生成片段编排方案并写入剪辑计划。 + + 流程: + 1. 验证计划存在且状态为 draft/editing + 2. 调用 AI 推荐服务(当前为 stub,后续接入真实 AI) + 3. 清除计划现有片段,按推荐方案重新创建 + 4. 更新计划 config(cover/title/subtitle/bgm)和 total_duration + 5. 返回推荐方案详情 + + 前端对接: + - 请求体只需传 asset_ids(必填),editing_mode 和 target_duration 可选 + - 返回的 clips 可直接渲染到时间线 + - 返回的 config 包含推荐的封面/标题/字幕/BGM 配置 + """ + 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), + ) + + # 项目鉴权 + if plan.project_id: + _check_project_access(plan.project_id, current_user.user.id, project_repository) + + # 验证状态:只允许 draft 或 editing + plan_status = plan.status.value if hasattr(plan.status, "value") else plan.status + if plan_status not in ("draft", "editing"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"AI 推荐仅支持 draft/editing 状态的计划,当前状态: {plan_status}", + ) + + # 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步) + result = run_ai_recommend( + plan_id=plan_id, + template_id=plan.template_id, + asset_ids=body.asset_ids, + editing_mode=body.editing_mode, + target_duration=body.target_duration, + ) + + # ── 事务保护:清除 → 重建 → 更新 必须在同一逻辑事务中 ── + # TODO: 当前各 repo 方法内部 commit(),无法真正回滚。 + # 后续重构 repo 为 flush() 模式后,此处改为统一 commit。 + try: + # 清除现有片段 + svc.delete_all_clips(plan_id) + + # 按推荐方案创建新片段 + for clip_data in result["clips"]: + svc.create_clip( + plan_id=plan_id, + clip_type=clip_data["clip_type"], + order=clip_data["order"], + text_content=clip_data.get("text_content", ""), + duration=clip_data["duration"], + transition_effect=clip_data.get("transition_effect", "cut"), + asset_id=clip_data.get("asset_id", ""), + start_time=clip_data.get("start_time", 0.0), + config=clip_data.get("config", {}), + ) + + # 更新计划 config 和 total_duration + normalized_config = normalize_plan_config(result.get("config", {})) + svc.update_plan( + plan_id, + config=normalized_config, + total_duration=result["total_duration"], + ) + except Exception as exc: + logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id) + # 尝试回滚未提交的变更 + try: + db.rollback() + except Exception: + pass + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"AI 推荐结果写入失败: {exc}", + ) + + logger.info( + "AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s", + plan_id, + len(result["clips"]), + result["total_duration"], + current_user.user.id, + ) + + return AIRecommendResponse( + plan_id=plan_id, + clips=[ + AIRecommendClipItem( + clip_type=c["clip_type"], + order=c["order"], + text_content=c.get("text_content", ""), + duration=c["duration"], + transition_effect=c.get("transition_effect", "cut"), + asset_id=c.get("asset_id", ""), + start_time=c.get("start_time", 0.0), + config=c.get("config", {}), + ) + for c in result["clips"] + ], + config=normalized_config, + total_duration=result["total_duration"], + confidence=result["confidence"], + ) + + +@router.post( + "/{plan_id}/generate-cover", + response_model=GenerateCoverResponse, +) +def generate_cover( + plan_id: str, + body: GenerateCoverRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> GenerateCoverResponse: + """AI 生成封面 + + 调用 AI 服务从视频中选帧或生成封面图,并更新计划 config.cover。 + + 流程: + 1. 验证计划存在 + 2. 调用 AI 封面生成服务(当前为 stub,后续接入真实 AI) + 3. 更新 plan.config["cover"] 为生成的封面数据 + 4. 返回封面数据 + + 前端对接: + - cover_type=ai_frame: AI 智能选帧(默认) + - cover_type=manual: 手动选帧,需传 frame_time + - cover_type=upload: 用户上传,接口返回空 image_url,前端自行上传后更新 + - cover_type=ai_regenerate: AI 重新生成 + """ + 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), + ) + + # 项目鉴权 + if plan.project_id: + _check_project_access(plan.project_id, current_user.user.id, project_repository) + + # 调用 AI 封面生成服务 + cover_data = run_generate_cover( + plan_id=plan_id, + asset_ids=body.asset_ids, + cover_type=body.cover_type, + frame_time=body.frame_time, + ) + + # 更新 plan.config["cover"] + current_config = dict(plan.config) + current_config["cover"] = cover_data + normalized = normalize_plan_config(current_config) + svc.update_plan_config(plan_id, {"cover": normalized["cover"]}) + + logger.info( + "AI 封面生成: plan_id=%s type=%s by user=%s", + plan_id, + body.cover_type, + current_user.user.id, + ) + + return GenerateCoverResponse( + plan_id=plan_id, + cover=cover_data, + ) + + # ── Timeline / Scene 端点(P2-6) ───────────────────────────────────────────── @@ -505,6 +888,7 @@ def get_plan_timeline( plan_id: str, db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), ) -> TimelineResponse: """获取剪辑计划的时间线场景数据 @@ -512,6 +896,9 @@ def get_plan_timeline( """ svc = EditPlanService(db) plan = svc.get_plan_or_raise(plan_id) + # 项目鉴权 + if plan.project_id: + _check_project_access(plan.project_id, current_user.user.id, project_repository) clips = svc.list_clips(plan_id=plan_id, skip=0, limit=200) # 按 order 排序 diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py index c412e0322..0f9eb94c4 100644 --- a/apps/api/app/api/routes/edit_templates.py +++ b/apps/api/app/api/routes/edit_templates.py @@ -24,6 +24,7 @@ from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from packages.domain.config_schemas import normalize_template_config from packages.domain.edit_template import EditTemplate, EditTemplateStatus logger = logging.getLogger(__name__) @@ -86,6 +87,15 @@ class EditTemplateListResponse(BaseModel): # ── Helpers ─────────────────────────────────────────────────────────────────── +def _require_admin(current_user: AuthenticatedUser) -> None: + """校验当前用户是否为管理员,非管理员返回 403""" + if not getattr(current_user.user, "is_admin", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅管理员可执行此操作", + ) + + def _to_response(t: EditTemplate) -> EditTemplateResponse: return EditTemplateResponse( id=t.id, @@ -176,13 +186,16 @@ def create_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """创建模板(管理员)""" + _require_admin(current_user) svc = EditTemplateService(db) + # 标准化 config,填充 cover/title/subtitle/bgm 默认值 + normalized_config = normalize_template_config(body.config) try: created = svc.create_template( name=body.name, description=body.description, template_type=body.template_type, - config=body.config, + config=normalized_config, preview_url=body.preview_url, sort_weight=body.sort_weight, ) @@ -203,6 +216,7 @@ def update_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> EditTemplateResponse: """更新模板""" + _require_admin(current_user) svc = EditTemplateService(db) # 解析状态 @@ -216,13 +230,16 @@ def update_template( detail=f"无效的状态值: {body.status},可选值: active, inactive", ) + # 标准化 config(如果提供了) + config_to_update = normalize_template_config(body.config) if body.config is not None else None + try: result = svc.update_template( template_id, name=body.name, description=body.description, template_type=body.template_type, - config=body.config, + config=config_to_update, preview_url=body.preview_url, sort_weight=body.sort_weight, status=status_enum, @@ -249,6 +266,7 @@ def delete_template( current_user: AuthenticatedUser = Depends(get_current_user), ) -> Response: """删除模板(软删除 → 设为 inactive)""" + _require_admin(current_user) svc = EditTemplateService(db) try: svc.deactivate_template(template_id) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index b01a869eb..cfd47edbd 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -50,6 +50,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: asset_ids=task.asset_ids, title_ids=task.title_ids, voice_ids=task.voice_ids, + source_edit_plan_id=task.source_edit_plan_id or "", status=task.status, progress=task.progress, result_count=task.result_count, @@ -155,6 +156,7 @@ def create_generation_task( title_ids=request.title_ids, voice_ids=request.voice_ids, created_by_user_id=authenticated_user.user.id, + source_edit_plan_id=request.source_edit_plan_id, ) ) celery_app.send_task("worker.generate_video", args=[task.id]) @@ -234,6 +236,7 @@ def retry_generation_task( title_ids=task.title_ids, voice_ids=task.voice_ids, created_by_user_id=authenticated_user.user.id, + source_edit_plan_id=task.source_edit_plan_id or "", ) ) celery_app.send_task("worker.generate_video", args=[retried.id]) diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 58a85c023..6d5c5185f 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.schemas.template import ( @@ -13,6 +15,7 @@ from app.schemas.template import ( ListTemplatesResponse, SegmentResponse, TemplateResponse, + ToggleFavoriteResponse, UpdateTemplateRequest, ValidateTemplateRequest, ValidateTemplateResponse, @@ -20,6 +23,8 @@ from app.schemas.template import ( from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session +logger = logging.getLogger(__name__) + from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository from packages.application.template.commands import ( CreateCategoryCommand, @@ -92,9 +97,13 @@ def list_templates( template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListTemplatesResponse: user_id = authenticated_user.user.id - use_case = ListTemplatesUseCase(template_repository) - templates = use_case.execute(user_id, skip=skip, limit=limit) - total = template_repository.count_by_user(user_id) + try: + use_case = ListTemplatesUseCase(template_repository) + templates = use_case.execute(user_id, skip=skip, limit=limit) + total = template_repository.count_by_user(user_id) + except Exception: + logger.exception("list_templates 查询失败: user_id=%s", user_id) + return ListTemplatesResponse(items=[], total=0) return ListTemplatesResponse( items=[_to_response(t) for t in templates], total=total, @@ -108,8 +117,12 @@ def get_template( template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> TemplateResponse: user_id = authenticated_user.user.id - use_case = GetTemplateUseCase(template_repository) - template = use_case.execute(template_id, user_id) + try: + use_case = GetTemplateUseCase(template_repository) + template = use_case.execute(template_id, user_id) + except Exception: + logger.exception("get_template 查询失败: template_id=%s", template_id) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") if template is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") return _to_response(template) @@ -207,6 +220,25 @@ def delete_template( return Response(status_code=204) +@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse) +def toggle_favorite( + template_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), +) -> ToggleFavoriteResponse: + """切换模板收藏状态(当前为兼容端点,始终返回 false)""" + user_id = authenticated_user.user.id + use_case = GetTemplateUseCase(template_repository) + try: + template = use_case.execute(template_id, user_id) + except Exception: + logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + if template is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + return ToggleFavoriteResponse(id=template_id, is_favorite=False) + + # ── Validate template ── @@ -246,8 +278,12 @@ def list_categories( template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListCategoriesResponse: user_id = authenticated_user.user.id - use_case = ListCategoriesUseCase(template_repository) - categories = use_case.execute(user_id) + try: + use_case = ListCategoriesUseCase(template_repository) + categories = use_case.execute(user_id) + except Exception: + logger.exception("list_categories 查询失败: user_id=%s", user_id) + return ListCategoriesResponse(items=[]) return ListCategoriesResponse( items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories], ) diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 5749430d5..ac2e8fb4b 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -34,6 +34,7 @@ class AssetResponse(BaseModel): mime_type: str metadata: dict[str, object] file_size: int + file_url: str | None = None thumbnail_url: str | None = None duration: float | None = None width: int | None = None diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 64219f214..8e1fc7a74 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -19,6 +19,8 @@ class CreateGenerationTaskRequest(BaseModel): asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list) + # ── 来源剪辑计划 ── + source_edit_plan_id: str = "" @model_validator(mode="after") def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": @@ -43,6 +45,7 @@ class GenerationTaskResponse(BaseModel): asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list) + source_edit_plan_id: str = "" status: str progress: float result_count: int diff --git a/apps/api/app/schemas/template.py b/apps/api/app/schemas/template.py index 9d95ecbb4..8519a623b 100644 --- a/apps/api/app/schemas/template.py +++ b/apps/api/app/schemas/template.py @@ -44,10 +44,16 @@ class TemplateResponse(BaseModel): estimated_duration: float = 0.0 segments: List[SegmentResponse] = Field(default_factory=list) is_active: bool = True + is_favorite: bool = False created_at: datetime updated_at: datetime +class ToggleFavoriteResponse(BaseModel): + id: str + is_favorite: bool + + class ListTemplatesResponse(BaseModel): items: List[TemplateResponse] total: int = 0 diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index f60f91d61..4b88bc265 100644 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -44,6 +44,7 @@ class EditPlanService: self, *, template_id: Optional[str] = None, + project_id: Optional[str] = None, status: Optional[EditPlanStatus] = None, skip: int = 0, limit: int = 50, @@ -52,10 +53,18 @@ class EditPlanService: Args: template_id: 按模板 ID 筛选 + project_id: 按项目 ID 筛选 status: 按状态筛选 skip: 分页偏移 limit: 每页数量 """ + if project_id: + return self._plan_repo.list_by_project( + project_id, + status=status, + skip=skip, + limit=limit, + ) if template_id: return self._plan_repo.list_by_template( template_id, @@ -69,13 +78,22 @@ class EditPlanService: self, *, template_id: Optional[str] = None, + project_id: Optional[str] = None, status: Optional[EditPlanStatus] = None, ) -> int: """统计计划数量 Note: - 当指定 template_id 时,通过全量查询计算 total(repo 限制)。 + 当指定 template_id/project_id 时,通过全量查询计算 total(repo 限制)。 """ + if project_id: + all_matching = self._plan_repo.list_by_project( + project_id, + status=status, + skip=0, + limit=10000, + ) + return len(all_matching) if template_id: all_matching = self._plan_repo.list_by_template( template_id, @@ -104,6 +122,8 @@ class EditPlanService: *, config: Optional[dict[str, Any]] = None, total_duration: float = 0.0, + project_id: str = "", + created_by_user_id: str = "", ) -> EditPlan: """创建剪辑计划 @@ -115,6 +135,8 @@ class EditPlanService: name=name, config=config, total_duration=total_duration, + project_id=project_id, + created_by_user_id=created_by_user_id, ) created = self._plan_repo.create(plan) logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) @@ -141,6 +163,9 @@ class EditPlanService: 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, + source_edit_plan_id=existing.source_edit_plan_id, + project_id=existing.project_id, + created_by_user_id=existing.created_by_user_id, config=config if config is not None else existing.config, created_at=existing.created_at, updated_at=existing.updated_at, @@ -468,6 +493,9 @@ class EditPlanService: name=plan.name, status=plan.status, total_duration=plan.total_duration, + source_edit_plan_id=plan.source_edit_plan_id, + project_id=plan.project_id, + created_by_user_id=plan.created_by_user_id, config=new_config, created_at=plan.created_at, updated_at=plan.updated_at, diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index e7672ff06..bc7437207 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -14,6 +14,8 @@ export interface AssetItem { mime_type: string; metadata: Record; file_size?: number; + file_url?: string; + thumbnail_url?: string; status?: string; classification_status?: string | null; quality_score?: number | null; @@ -80,9 +82,11 @@ export interface AssetDiagnosis { // ─── 素材诊断 ────────────────────────────────────────────── -/** 获取当前用户的素材诊断信息 */ -export const getAssetDiagnosis = async (): Promise => { - const response = await apiClient.get("/asset-diagnosis"); +/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ +export const getAssetDiagnosis = async (assetId?: string): Promise => { + const params: Record = {}; + if (assetId) params.asset_id = assetId; + const response = await apiClient.get("/asset-diagnosis", { params }); return response.data; }; diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 303facc1a..2280d7282 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -31,6 +31,8 @@ export interface CreateEditPlanRequest { name: string; config?: Record; total_duration?: number; + /** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */ + source_edit_plan_id?: string; } /** 更新剪辑计划请求 */ @@ -49,6 +51,16 @@ export interface GenerateResponse { clip_count: number; } +/** 剪辑计划关联的生成记录 */ +export interface EditPlanGeneration { + id: string; + edit_plan_id: string; + generation_task_id: string; + status: EditPlanStatus; + created_at: string; + updated_at: string; +} + /** 片段生成状态 */ export interface ClipStatusItem { clip_id: string; @@ -68,6 +80,51 @@ export interface GenerationStatusResponse { clips: ClipStatusItem[]; } +/* ============================================================ + * AI 推荐 & 封面生成(任务 3.09) + * ============================================================ */ + +/** AI 推荐请求 */ +export interface AIRecommendRequest { + asset_ids: string[]; + editing_mode?: string; + target_duration?: number; +} + +/** AI 推荐单个片段 */ +export interface AIRecommendClipItem { + clip_type: string; + order: number; + text_content: string; + duration: number; + transition_effect: string; + asset_id: string; + start_time: number; + config: Record; +} + +/** AI 推荐响应 */ +export interface AIRecommendResponse { + plan_id: string; + clips: AIRecommendClipItem[]; + config: Record; + total_duration: number; + confidence: number; +} + +/** AI 封面生成请求 */ +export interface GenerateCoverRequest { + asset_ids: string[]; + cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"; + frame_time?: number; +} + +/** AI 封面生成响应 */ +export interface GenerateCoverResponse { + plan_id: string; + cover: Record; +} + /* ============================================================ * 前端 UI 类型(EditingPlanner 组件依赖,保留兼容) * ============================================================ */ @@ -177,6 +234,38 @@ export async function getGenerationStatus( return response.data; } +/** AI 推荐片段方案 */ +export async function aiRecommendClips( + planId: string, + data: AIRecommendRequest, +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/ai-recommend`, + data, + ); + return response.data; +} + +/** AI 生成封面 */ +export async function generateCover( + planId: string, + data: GenerateCoverRequest, +): Promise { + const response = await apiClient.post( + `/edit-plans/${planId}/generate-cover`, + data, + ); + return response.data; +} + +/** 获取剪辑计划关联的生成记录 */ +export async function getEditPlanGenerations( + planId: string, +): Promise { + const response = await apiClient.get(`/edit-plans/${planId}/generations`); + return response.data.items || []; +} + /** * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 将后端 AssetResponse 映射为前端 MediaAsset 类型 diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index bfe3b3b5c..4b6c7a588 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -16,6 +16,8 @@ import { CheckOutlined, DeleteOutlined, ExperimentOutlined, + LoadingOutlined, + ExclamationCircleOutlined, } from "@ant-design/icons"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -50,6 +52,7 @@ interface AssetItem { name: string; kind: AssetKind; thumbUrl?: string; + fileUrl?: string; status: StatusType; statusLabel: string; duration?: string; @@ -111,7 +114,13 @@ const mapAsset = (item: ApiAssetItem): AssetItem => { id: item.id, name: item.name, kind: inferKind(item.mime_type || ""), - thumbUrl: metadata.thumbnail_url as string | undefined, + thumbUrl: + (item.thumbnail_url as string | undefined) || + (item.file_url as string | undefined) || + (metadata.thumbnail_url as string | undefined), + fileUrl: + (item.file_url as string | undefined) || + (metadata.file_url as string | undefined), status, statusLabel: label, duration: @@ -178,15 +187,31 @@ const StatusPill: React.FC<{ status: StatusType; label: string }> = ({ {label} ); +/* ============================================================ + * SkeletonCard — 骨架屏卡片(素材列表加载时占位) + * ============================================================ */ +const SkeletonCard: React.FC = () => ( +
+
+
+
+
+
+
+
+); + /* ============================================================ * AssetCard 组件 * ============================================================ */ const AssetCard: React.FC<{ asset: AssetItem; selected: boolean; + diagnosing?: boolean; onToggle: () => void; onDiagnose: () => void; -}> = ({ asset, selected, onToggle, onDiagnose }) => ( + onPlay: () => void; +}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay }) => (
+ { + e.stopPropagation(); + onPlay(); + }} + > )} @@ -229,14 +260,15 @@ const AssetCard: React.FC<{ {asset.duration && {asset.duration}}
@@ -321,6 +353,12 @@ const AssetLibrary: React.FC = () => { const [newLibName, setNewLibName] = useState(""); const [newLibKind, setNewLibKind] = useState("video"); + /* 视频播放 */ + const [playingAsset, setPlayingAsset] = useState(null); + + /* 诊断中状态 — 记录正在诊断的素材 ID */ + const [diagnosingId, setDiagnosingId] = useState(null); + /* 派生数据 */ const filteredAssets = useMemo(() => { let list = assets; @@ -432,15 +470,18 @@ const AssetLibrary: React.FC = () => { } }; - /* 诊断 — 调用真实 API */ + /* 诊断 — 调用真实 API,带 loading 状态 */ const handleDiagnose = async (asset: AssetItem) => { + setDiagnosingId(asset.id); try { - const result = await getAssetDiagnosis(); + const result = await getAssetDiagnosis(asset.id); const score = result.readiness_score ?? "-"; message.success(`"${asset.name}" 诊断完成,就绪分:${score}`); queryClient.invalidateQueries({ queryKey: ["assets"] }); } catch { message.error(`"${asset.name}" 诊断失败`); + } finally { + setDiagnosingId(null); } }; @@ -466,9 +507,10 @@ const AssetLibrary: React.FC = () => { if (libLoading) { return (
-
-
⏳
-

加载中...

+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))}
); @@ -486,14 +528,8 @@ const AssetLibrary: React.FC = () => { className={`xx-asset-library-item${lib.id === effectiveLibId ? " active" : ""}`} onClick={() => setActiveLibId(lib.id)} > -
-

+
+

{kindIcon(lib.kind)} {lib.name}

{ - + 共 {filteredAssets.length} 个素材
@@ -630,14 +661,15 @@ const AssetLibrary: React.FC = () => { {/* 素材网格 */} {assetsLoading ? ( -
-
⏳
-

加载素材中...

+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))}
) : assetsError ? (
-
❌
-

{assetsErrorObj?.message || "加载失败"}

+
+

{assetsErrorObj?.message || "加载失败"}

@@ -663,7 +697,7 @@ const AssetLibrary: React.FC = () => {
-

暂无素材,请上传或切换素材库

+

暂无素材,请上传或切换素材库

)}

@@ -680,24 +714,9 @@ const AssetLibrary: React.FC = () => { destroyOnClose confirmLoading={createLibMutation.isPending} > -
+
-
- 名称 -
+
名称
{ />
-
- 类型 -
+
类型