diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 76e6aff07..e6fc3e70f 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -6,6 +6,7 @@ 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 @@ -122,3 +123,8 @@ api_router.include_router( prefix="/edit-templates", tags=["EditTemplate"], ) +api_router.include_router( + edit_plans_router, + prefix="/edit-plans", + tags=["EditPlan"], +) 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..069b58e4f --- /dev/null +++ b/apps/api/app/api/routes/edit_plans.py @@ -0,0 +1,304 @@ +"""剪辑计划管理 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} 删除 +""" + +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 fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl import SQLAlchemyEditPlanRepository +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 + + +# ── 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, + ) + + +def _apply_status_transition(plan: EditPlan, target_status_str: str) -> None: + """通过状态机方法流转状态,非法流转抛出 ValueError""" + try: + target = EditPlanStatus(target_status_str) + except ValueError: + raise ValueError( + f"无效的状态值: {target_status_str}," + f"可选值: draft, editing, rendering, completed, failed" + ) + + if target == plan.status: + return # 已是目标状态,无需流转 + + # 根据目标状态选择对应的状态机方法 + 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_map[target]() + + +# ── 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: + """获取剪辑计划列表(支持分页、按模板/状态筛选)""" + repo = SQLAlchemyEditPlanRepository(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 + + # 根据是否有 template_id 选择查询方法 + if template_id: + plans = repo.list_by_template( + template_id, + status=status_enum, + skip=skip, + limit=page_size, + ) + # count() 不支持 template_id 筛选,通过全量查询计算 total + all_matching = repo.list_by_template( + template_id, + status=status_enum, + skip=0, + limit=10000, + ) + total = len(all_matching) + else: + plans = repo.list_all(status=status_enum, skip=skip, limit=page_size) + total = repo.count(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: + """获取单个剪辑计划详情""" + repo = SQLAlchemyEditPlanRepository(db) + plan = repo.get(plan_id) + if plan is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + 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: + """创建剪辑计划""" + repo = SQLAlchemyEditPlanRepository(db) + try: + plan = EditPlan.create( + 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), + ) + created = repo.create(plan) + 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: + """更新剪辑计划(支持状态机流转)""" + repo = SQLAlchemyEditPlanRepository(db) + existing = repo.get(plan_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + + # 基础字段更新 + new_name = body.name.strip() if body.name is not None else existing.name + new_config = body.config if body.config is not None else existing.config + new_total_duration = body.total_duration if body.total_duration is not None else existing.total_duration + + # 状态机流转 + new_status = existing.status + if body.status is not None: + try: + _apply_status_transition(existing, body.status) + new_status = existing.status + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + try: + updated = EditPlan( + id=existing.id, + template_id=existing.template_id, + name=new_name, + status=new_status, + total_duration=new_total_duration, + config=new_config, + created_at=existing.created_at, + updated_at=existing.updated_at, + ) + except (ValueError, TypeError) as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + result = repo.update(updated) + 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: + """删除剪辑计划""" + repo = SQLAlchemyEditPlanRepository(db) + existing = repo.get(plan_id) + if existing is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"剪辑计划不存在: {plan_id}", + ) + + repo.delete(plan_id) + logger.info( + "删除剪辑计划: id=%s by user=%s", + plan_id, + current_user.user.id, + ) diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts new file mode 100644 index 000000000..c2af2f3a4 --- /dev/null +++ b/apps/web/src/api/editPlans.ts @@ -0,0 +1,336 @@ +/** + * 剪辑计划 API — Mock 数据 + 预留接口 + * 任务 2.14:对接后端 GET/POST/PUT /api/v1/edit-plans + * 当前使用 Mock 数据,后续替换为真实 API 调用 + */ +import type { TemplateMode } from "./editingPlanner"; + +/* ============================================================ + * 类型定义 + * ============================================================ */ + +/** 剪辑计划中的片段 */ +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; // 转场时长(秒) +} + +/** 剪辑计划 */ +export interface EditPlan { + id: string; + name: string; + template_id?: string; + mode: TemplateMode; + clips: EditPlanClip[]; + /** 总预估时长 */ + total_duration: number; + /** 状态 */ + status: "draft" | "ready" | "generating" | "completed" | "failed"; + created_at: string; + updated_at: string; +} + +/** 创建剪辑计划请求 */ +export interface CreateEditPlanRequest { + name: string; + template_id?: string; + mode: TemplateMode; + clips: Omit[]; +} + +/** 更新剪辑计划请求 */ +export interface UpdateEditPlanRequest { + name?: string; + clips?: EditPlanClip[]; + status?: EditPlan["status"]; +} + +/** 素材库资产 */ +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; +} + +/* ============================================================ + * Mock 数据 + * ============================================================ */ + +const MOCK_ASSETS: MediaAsset[] = [ + { + id: "asset-001", + name: "产品展示-正面.mp4", + type: "video", + duration: 15, + size: 12_500_000, + tags: ["产品", "展示"], + created_at: "2026-06-20T10:00:00Z", + }, + { + id: "asset-002", + name: "使用教程-片段A.mp4", + type: "video", + duration: 20, + size: 18_000_000, + tags: ["教程", "使用"], + created_at: "2026-06-21T14:00:00Z", + }, + { + id: "asset-003", + name: "背景音乐-轻快.mp3", + type: "audio", + duration: 120, + size: 3_200_000, + tags: ["BGM", "轻快"], + created_at: "2026-06-18T09:00:00Z", + }, + { + id: "asset-004", + name: "封面图-主图.jpg", + type: "image", + size: 850_000, + tags: ["封面", "主图"], + created_at: "2026-06-22T11:00:00Z", + }, + { + id: "asset-005", + name: "细节特写-侧面.mp4", + type: "video", + duration: 10, + size: 8_500_000, + tags: ["产品", "细节"], + created_at: "2026-06-23T16:00:00Z", + }, + { + id: "asset-006", + name: "开箱视频-片段.mp4", + type: "video", + duration: 25, + size: 22_000_000, + tags: ["开箱", "展示"], + created_at: "2026-06-19T08:00:00Z", + }, + { + id: "asset-007", + name: "配音-产品介绍.wav", + type: "audio", + duration: 45, + size: 5_600_000, + tags: ["配音", "产品"], + created_at: "2026-06-24T10:00:00Z", + }, + { + id: "asset-008", + name: "场景图-生活场景.jpg", + type: "image", + size: 1_200_000, + tags: ["场景", "生活"], + created_at: "2026-06-25T13:00:00Z", + }, +]; + +let mockPlans: EditPlan[] = [ + { + id: "plan-001", + name: "产品展示视频 v1", + template_id: "tpl-001", + mode: "pip", + clips: [ + { + id: "clip-001", + template_segment_id: "seg-001", + media_asset_id: "asset-001", + material_type: "video", + script_text: "大家好,今天给大家带来一款超值好物!", + duration: 10, + transition: { type: "fade", duration: 0.5 }, + order: 0, + }, + { + id: "clip-002", + template_segment_id: "seg-002", + media_asset_id: "asset-005", + material_type: "video", + script_text: "来看看这个细节做工,真的绝了", + duration: 8, + transition: { type: "dissolve", duration: 0.3 }, + order: 1, + }, + { + id: "clip-003", + template_segment_id: "seg-003", + material_type: "image", + script_text: "多种颜色可选,总有一款适合你", + duration: 6, + transition: { type: "none", duration: 0 }, + order: 2, + }, + ], + total_duration: 24, + status: "draft", + created_at: "2026-06-25T10:00:00Z", + updated_at: "2026-06-25T15:00:00Z", + }, +]; + +/* ============================================================ + * Mock 延迟 + * ============================================================ */ +const delay = (ms = 300) => new Promise((r) => setTimeout(r, ms)); + +/* ============================================================ + * API 函数(Mock 实现,后续替换为真实 API) + * ============================================================ */ + +/** 获取剪辑计划列表 */ +export async function getEditPlans(): Promise { + // TODO: 替换为 apiClient.get('/edit-plans') + await delay(); + return [...mockPlans]; +} + +/** 获取单个剪辑计划 */ +export async function getEditPlan(id: string): Promise { + // TODO: 替换为 apiClient.get(`/edit-plans/${id}`) + await delay(); + const plan = mockPlans.find((p) => p.id === id); + if (!plan) throw new Error(`剪辑计划 ${id} 不存在`); + return { ...plan }; +} + +/** 创建剪辑计划 */ +export async function createEditPlan( + data: CreateEditPlanRequest +): Promise { + // TODO: 替换为 apiClient.post('/edit-plans', data) + await delay(); + const now = new Date().toISOString(); + const plan: EditPlan = { + id: `plan-${Date.now()}`, + name: data.name, + template_id: data.template_id, + mode: data.mode, + clips: data.clips.map((c, i) => ({ + ...c, + id: `clip-${Date.now()}-${i}`, + })), + total_duration: data.clips.reduce((sum, c) => sum + c.duration, 0), + status: "draft", + created_at: now, + updated_at: now, + }; + mockPlans = [plan, ...mockPlans]; + return plan; +} + +/** 更新剪辑计划 */ +export async function updateEditPlan( + id: string, + data: UpdateEditPlanRequest +): Promise { + // TODO: 替换为 apiClient.put(`/edit-plans/${id}`, data) + await delay(); + const idx = mockPlans.findIndex((p) => p.id === id); + if (idx === -1) throw new Error(`剪辑计划 ${id} 不存在`); + mockPlans[idx] = { + ...mockPlans[idx], + ...data, + total_duration: + data.clips?.reduce((sum, c) => sum + c.duration, 0) ?? + mockPlans[idx].total_duration, + updated_at: new Date().toISOString(), + }; + return { ...mockPlans[idx] }; +} + +/** 删除剪辑计划 */ +export async function deleteEditPlan(id: string): Promise { + // TODO: 替换为 apiClient.delete(`/edit-plans/${id}`) + await delay(); + mockPlans = mockPlans.filter((p) => p.id !== id); +} + +/** 获取素材库列表 */ +export async function getMediaAssets(): Promise { + // TODO: 替换为 apiClient.get('/media-assets') + await delay(); + return [...MOCK_ASSETS]; +} + +/** 获取单个素材 */ +export async function getMediaAsset(id: string): Promise { + // TODO: 替换为 apiClient.get(`/media-assets/${id}`) + await delay(); + const asset = MOCK_ASSETS.find((a) => a.id === id); + if (!asset) throw new Error(`素材 ${id} 不存在`); + return { ...asset }; +} + +/* ============================================================ + * 常量 + * ============================================================ */ + +/** 转场效果选项 */ +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: "草稿", + ready: "就绪", + generating: "生成中", + completed: "已完成", + failed: "失败", +}; diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index f1487f681..a7b38a69a 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -1,6 +1,6 @@ /** * 剪辑计划编辑器 - V21 设计系统样式 - * 三栏布局 + 圆角胶囊模式切换 + 卡片式面板 + 时间线 + 素材面板 + * 三栏布局:素材面板 / 时间线 / 片段属性 * 统一使用 CSS 变量,支持深色/浅色主题 */ @import "../../styles/global.css"; @@ -34,11 +34,29 @@ gap: var(--space-md); } -.ep-toolbar-left h2 { - margin: 0; - font-size: var(--font-size-lg); - font-weight: var(--font-weight-bold); +.ep-toolbar-center { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.ep-toolbar-template-name { + font-size: var(--font-size-sm); + font-weight: 600; color: var(--text-primary); + padding: 4px 12px; + background: var(--bg-secondary); + border-radius: var(--radius-md); +} + +.ep-toolbar-plan-badge { + font-size: 12px; + font-weight: 600; + color: var(--success-color); + background: var(--success-soft); + border: 1px solid var(--success-border); + padding: 2px 8px; + border-radius: 999px; } .ep-toolbar-right { @@ -87,11 +105,6 @@ font-weight: 600; } -.ep-mode-icon { - font-size: 14px; - line-height: 1; -} - /* ============================================================ 三栏主体布局 ============================================================ */ @@ -102,9 +115,9 @@ min-height: 0; } -/* ── 左侧模板面板 ───────────────────────────────────────────── */ +/* ── 左侧素材面板 ───────────────────────────────────────────── */ .ep-left { - width: 260px; + width: 280px; flex-shrink: 0; display: flex; flex-direction: column; @@ -113,6 +126,48 @@ overflow: hidden; } +/* Tab 切换 */ +.ep-left-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.ep-left-tab { + flex: 1; + padding: 10px 12px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: var(--font-size-sm); + font-weight: 500; + cursor: pointer; + transition: var(--transition-all); + text-align: center; + position: relative; +} + +.ep-left-tab:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + +.ep-left-tab.active { + color: var(--primary-color); + font-weight: 600; +} + +.ep-left-tab.active::after { + content: ""; + position: absolute; + bottom: 0; + left: 16px; + right: 16px; + height: 2px; + background: var(--primary-color); + border-radius: 2px 2px 0 0; +} + .ep-left-header { padding: var(--space-md); border-bottom: 1px solid var(--border-color); @@ -122,13 +177,6 @@ flex-shrink: 0; } -.ep-left-header h3 { - margin: 0; - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--text-primary); -} - .ep-left-list { flex: 1; overflow-y: auto; @@ -196,13 +244,116 @@ margin-top: 6px; } +/* 素材卡片(可拖拽) */ +.ep-asset-card { + display: flex; + align-items: center; + gap: var(--space-sm); + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 10px 12px; + margin-bottom: var(--space-sm); + cursor: grab; + transition: var(--transition-all); + user-select: none; +} + +.ep-asset-card:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-sm); +} + +.ep-asset-card:active { + cursor: grabbing; + opacity: 0.7; +} + +.ep-asset-card-icon { + font-size: 24px; + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.ep-asset-card-info { + flex: 1; + min-width: 0; +} + +.ep-asset-card-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ep-asset-card-meta { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.ep-asset-card-tags { + display: inline-flex; + gap: 3px; +} + +.ep-asset-card-tag { + padding: 1px 4px; + background: var(--bg-secondary); + border-radius: 3px; + font-size: 10px; +} + +.ep-asset-card-drag-hint { + color: var(--text-secondary); + font-size: 16px; + opacity: 0.4; + flex-shrink: 0; + transition: var(--transition-all); +} + +.ep-asset-card:hover .ep-asset-card-drag-hint { + opacity: 0.8; +} + .ep-left-footer { padding: var(--space-sm) var(--space-md); border-top: 1px solid var(--border-color); flex-shrink: 0; } -/* ── 中间预览 + 时间线 ──────────────────────────────────────── */ +.ep-new-template-btn { + width: 100%; + padding: 8px; + border: 1px dashed var(--border-color); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + font-size: var(--font-size-sm); + cursor: pointer; + transition: var(--transition-all); +} + +.ep-new-template-btn:hover { + border-color: var(--primary-color); + color: var(--primary-color); + background: var(--primary-soft); +} + +/* ── 中间时间线 ──────────────────────────────────────────────── */ .ep-center { flex: 1; display: flex; @@ -212,68 +363,68 @@ background: var(--bg-primary); } -/* 预览区 */ -.ep-preview { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: var(--space-lg); - overflow: auto; +/* 可视化时长条 */ +.ep-timeline-bar { + padding: var(--space-md) var(--space-lg); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; } -.ep-preview-frame { - width: 160px; - height: 284px; - border: 2px dashed var(--border-color); - border-radius: var(--radius-lg); +.ep-timeline-bar-label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.ep-timeline-bar-duration { + font-weight: 400; + color: var(--text-secondary); + font-size: 12px; +} + +.ep-timeline-bar-track { + display: flex; + height: 28px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + overflow: hidden; + gap: 2px; +} + +.ep-timeline-bar-segment { + height: 100%; + border-radius: 3px; + transition: var(--transition-all); + min-width: 8px; + position: relative; +} + +.ep-timeline-bar-segment:hover { + opacity: 0.85; + transform: scaleY(1.1); +} + +.ep-timeline-bar-empty { + width: 100%; display: flex; - flex-direction: column; align-items: center; justify-content: center; color: var(--text-secondary); - background: var(--bg-secondary); - flex-shrink: 0; -} - -.ep-preview-frame-icon { - font-size: 40px; - margin-bottom: var(--space-sm); - opacity: 0.4; -} - -.ep-preview-frame p { - margin: 0; - font-size: var(--font-size-sm); -} - -.ep-preview-frame-info { font-size: 12px; - margin-top: 4px; - opacity: 0.7; -} - -/* 封面操作按钮 */ -.ep-cover-btns { - display: flex; - gap: var(--space-sm); - margin-top: var(--space-md); - flex-wrap: wrap; -} - -/* 时间线 */ -.ep-timeline { - flex-shrink: 0; - border-top: 1px solid var(--border-color); - background: var(--bg-primary); - padding: var(--space-md) var(--space-lg); + opacity: 0.6; } +/* 时间线头部 */ .ep-timeline-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: var(--space-md); + padding: var(--space-md) var(--space-lg); + flex-shrink: 0; } .ep-timeline-header h3 { @@ -283,120 +434,242 @@ color: var(--text-primary); } -.ep-timeline-duration { - font-size: var(--font-size-sm); - color: var(--text-secondary); -} - +/* 片段列表 */ .ep-timeline-list { + flex: 1; + overflow-y: auto; + padding: 0 var(--space-lg) var(--space-lg); display: flex; flex-direction: column; gap: var(--space-sm); } -.ep-timeline-empty { - text-align: center; - padding: var(--space-lg); - color: var(--text-secondary); - font-size: var(--font-size-sm); -} - -/* 素材类型选择(voice_pip 模式) */ -.ep-material-select { +/* 空状态拖放区 */ +.ep-timeline-empty-drop { display: flex; + flex-direction: column; align-items: center; - gap: var(--space-sm); - margin-bottom: var(--space-md); + justify-content: center; + padding: var(--space-2xl); + border: 2px dashed var(--border-color); + border-radius: var(--radius-lg); + text-align: center; + color: var(--text-secondary); + transition: var(--transition-all); + min-height: 120px; } -.ep-material-select-label { +.ep-timeline-empty-drop.active { + border-color: var(--primary-color); + background: var(--primary-soft); + color: var(--primary-color); +} + +.ep-timeline-empty-drop .ep-timeline-empty-icon { + font-size: 40px; + margin-bottom: var(--space-sm); + opacity: 0.5; +} + +.ep-timeline-empty-drop p { + margin: 0 0 4px; font-size: var(--font-size-sm); - color: var(--text-secondary); - white-space: nowrap; + font-weight: 500; +} + +.ep-timeline-empty-drop span { + font-size: 12px; + opacity: 0.7; +} + +/* 拖放指示器 */ +.ep-timeline-drop-indicator { + height: 3px; + background: var(--primary-color); + border-radius: 2px; + margin: -2px 0; + animation: ep-drop-pulse 0.6s ease infinite alternate; +} + +@keyframes ep-drop-pulse { + from { opacity: 0.6; } + to { opacity: 1; } } /* 片段卡片 */ -.ep-segment-card { +.ep-clip-card { display: flex; align-items: center; - gap: var(--space-md); + gap: var(--space-sm); background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 10px 12px; + cursor: pointer; transition: var(--transition-all); + user-select: none; } -.ep-segment-card:hover { +.ep-clip-card:hover { border-color: var(--primary-color); box-shadow: var(--shadow-sm); } -.ep-segment-drag { +.ep-clip-card.selected { + border-color: var(--primary-color); + background: var(--primary-soft); + box-shadow: 0 0 0 1px var(--primary-color); +} + +.ep-clip-card.drag-over { + border-color: var(--primary-color); + border-style: dashed; + background: var(--primary-soft); +} + +.ep-clip-card[draggable="true"]:active { + opacity: 0.6; +} + +.ep-clip-drag { cursor: grab; color: var(--text-secondary); font-size: 16px; user-select: none; flex-shrink: 0; + opacity: 0.5; + transition: var(--transition-all); } -.ep-segment-drag:active { +.ep-clip-card:hover .ep-clip-drag { + opacity: 1; +} + +.ep-clip-drag:active { cursor: grabbing; } -.ep-segment-index { - width: 24px; - height: 24px; +.ep-clip-index { + width: 26px; + height: 26px; border-radius: var(--radius-sm); - background: var(--primary-soft); - color: var(--primary-color); + color: #fff; font-size: 12px; - font-weight: 600; + font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; } -.ep-segment-info { +.ep-clip-info { flex: 1; min-width: 0; } -.ep-segment-info h4 { - margin: 0 0 4px; +.ep-clip-info-top { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.ep-clip-type-icon { + font-size: 14px; + flex-shrink: 0; +} + +.ep-clip-script { font-size: 13px; - font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; } -.ep-segment-controls { +.ep-clip-script-empty { + color: var(--text-secondary); + font-style: italic; + opacity: 0.6; +} + +.ep-clip-info-bottom { display: flex; align-items: center; - gap: var(--space-sm); + gap: 6px; } -.ep-segment-duration { - font-size: 12px; +.ep-clip-duration-bar { + flex: 1; + height: 4px; + background: var(--bg-secondary); + border-radius: 2px; + overflow: hidden; + max-width: 100px; +} + +.ep-clip-duration-fill { + height: 100%; + border-radius: 2px; + transition: width 0.2s ease; +} + +.ep-clip-duration-text { + font-size: 11px; color: var(--text-secondary); + font-weight: 600; white-space: nowrap; } -.ep-segment-actions { - display: flex; - align-items: center; - gap: 4px; +.ep-clip-asset-badge { + font-size: 12px; + opacity: 0.7; +} + +.ep-clip-transition-badge { + font-size: 10px; + color: var(--primary-color); + background: var(--primary-soft); + padding: 1px 6px; + border-radius: 999px; + white-space: nowrap; +} + +.ep-clip-actions { flex-shrink: 0; } +.ep-clip-action-btn { + width: 24px; + height: 24px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition-all); + opacity: 0; +} + +.ep-clip-card:hover .ep-clip-action-btn { + opacity: 1; +} + +.ep-clip-action-btn:hover { + background: var(--error-soft); + color: var(--error-color); +} + /* ============================================================ - 右侧设置面板 + 右侧片段属性面板 ============================================================ */ .ep-right { - width: 280px; + width: 300px; flex-shrink: 0; display: flex; flex-direction: column; @@ -421,63 +694,262 @@ color: var(--text-primary); } -.ep-setting-item { - margin-bottom: var(--space-md); -} - -.ep-setting-item:last-child { - margin-bottom: 0; -} - -.ep-setting-label { +/* 空状态 */ +.ep-clip-props-empty { display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - margin-bottom: 6px; - font-size: var(--font-size-sm); + justify-content: center; + padding: var(--space-2xl); + text-align: center; color: var(--text-secondary); + flex: 1; } -.ep-setting-label-text { +.ep-clip-props-empty-icon { + font-size: 48px; + margin-bottom: var(--space-md); + opacity: 0.4; +} + +.ep-clip-props-empty h3 { + margin: 0 0 4px; + font-size: var(--font-size-base); + color: var(--text-primary); +} + +.ep-clip-props-empty p { + margin: 0; + font-size: var(--font-size-sm); +} + +.ep-clip-props-summary { + margin-top: var(--space-lg); display: flex; + gap: var(--space-lg); +} + +.ep-clip-props-summary-item { + display: flex; + flex-direction: column; align-items: center; gap: 4px; } -.ep-setting-label-icon { - font-size: 14px; +.ep-clip-props-summary-label { + font-size: 12px; + color: var(--text-secondary); } -.ep-setting-row { +.ep-clip-props-summary-value { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +/* 片段信息头 */ +.ep-clip-props-header { display: flex; align-items: center; gap: var(--space-sm); } -/* 字体预设标签 */ -.ep-font-presets { - display: flex; - gap: 6px; - flex-wrap: wrap; +.ep-clip-props-header-icon { + font-size: 28px; } -.ep-font-preset-tag { - cursor: pointer; +.ep-clip-props-header h3 { + margin: 0 0 2px; + font-size: var(--font-size-base); } -/* 颜色输入 */ -.ep-color-input { +.ep-clip-props-header-type { + font-size: 12px; + color: var(--text-secondary); +} + +/* 字段 */ +.ep-clip-props-field { + margin-bottom: var(--space-sm); +} + +.ep-clip-props-field:last-child { + margin-bottom: 0; +} + +.ep-clip-props-field-hint { + font-size: 11px; + color: var(--text-secondary); + text-align: right; + margin-top: 4px; +} + +.ep-clip-props-label { + display: block; + font-size: var(--font-size-sm); + color: var(--text-secondary); + margin-bottom: 6px; +} + +/* 文案文本框 */ +.ep-clip-props-textarea { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--font-size-sm); + font-family: inherit; + resize: vertical; + transition: var(--transition-all); + line-height: 1.5; +} + +.ep-clip-props-textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-soft); +} + +.ep-clip-props-textarea::placeholder { + color: var(--text-secondary); + opacity: 0.6; +} + +/* 时长控制 */ +.ep-clip-props-duration-control { display: flex; align-items: center; + gap: var(--space-sm); +} + +.ep-clip-props-range { + flex: 1; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--bg-secondary); + border-radius: 2px; + outline: none; +} + +.ep-clip-props-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary-color); + cursor: pointer; + border: 2px solid var(--bg-primary); + box-shadow: var(--shadow-sm); +} + +.ep-clip-props-range::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--primary-color); + cursor: pointer; + border: 2px solid var(--bg-primary); + box-shadow: var(--shadow-sm); +} + +.ep-clip-props-duration-value { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + min-width: 36px; + text-align: right; +} + +/* 转场效果网格 */ +.ep-clip-props-transition-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); gap: 6px; } -.ep-color-swatch { - width: 24px; - height: 24px; - border-radius: var(--radius-sm); +.ep-clip-props-transition-btn { + padding: 6px 8px; border: 1px solid var(--border-color); - flex-shrink: 0; + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: var(--transition-all); + text-align: center; +} + +.ep-clip-props-transition-btn:hover { + border-color: var(--primary-color); + color: var(--primary-color); +} + +.ep-clip-props-transition-btn.active { + background: var(--primary-soft); + border-color: var(--primary-color); + color: var(--primary-color); + font-weight: 600; +} + +/* 关联素材 */ +.ep-clip-props-asset-linked { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 8px 12px; + background: var(--bg-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.ep-clip-props-asset-icon { + font-size: 20px; +} + +.ep-clip-props-asset-name { + flex: 1; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ep-clip-props-asset-unlink { + width: 20px; + height: 20px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + transition: var(--transition-all); +} + +.ep-clip-props-asset-unlink:hover { + background: var(--error-soft); + color: var(--error-color); +} + +.ep-clip-props-asset-empty { + padding: 12px; + text-align: center; + color: var(--text-secondary); + font-size: var(--font-size-sm); + background: var(--bg-secondary); + border-radius: var(--radius-md); + border: 1px dashed var(--border-color); +} + +.ep-clip-props-asset-empty p { + margin: 0; } /* ============================================================ @@ -562,62 +1034,16 @@ } } -/* ============================================================ - 开关组件(纯 CSS) - ============================================================ */ -.ep-switch { - position: relative; - display: inline-block; - width: 40px; - height: 22px; - flex-shrink: 0; -} - -.ep-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.ep-switch-slider { - position: absolute; - cursor: pointer; - inset: 0; - background: var(--bg-tertiary, #cbd5e1); - border-radius: 999px; - transition: var(--transition-all); -} - -.ep-switch-slider::before { - content: ""; - position: absolute; - height: 16px; - width: 16px; - left: 3px; - bottom: 3px; - background: var(--bg-primary); - border-radius: 50%; - transition: var(--transition-all); -} - -.ep-switch input:checked + .ep-switch-slider { - background: var(--primary-color); -} - -.ep-switch input:checked + .ep-switch-slider::before { - transform: translateX(18px); -} - /* ============================================================ 响应式 ============================================================ */ @media (max-width: 1200px) { .ep-right { - width: 240px; + width: 260px; } .ep-left { - width: 220px; + width: 240px; } } @@ -629,7 +1055,7 @@ .ep-left, .ep-right { width: 100%; - max-height: 200px; + max-height: 220px; border-right: none; border-left: none; border-bottom: 1px solid var(--border-color); @@ -658,14 +1084,21 @@ max-height: 180px; } - .ep-preview-frame { - width: 120px; - height: 213px; + .ep-timeline-bar { + padding: var(--space-sm) var(--space-md); } - .ep-timeline { + .ep-timeline-header { padding: var(--space-sm) var(--space-md); } + + .ep-timeline-list { + padding: 0 var(--space-md) var(--space-md); + } + + .ep-clip-props-transition-grid { + grid-template-columns: repeat(2, 1fr); + } } @media (max-width: 480px) { @@ -678,13 +1111,12 @@ font-size: var(--font-size-xs); } - .ep-preview-frame { - width: 100px; - height: 178px; + .ep-clip-card { + padding: 8px 10px; + gap: 6px; } - .ep-segment-card { + .ep-asset-card { padding: 8px 10px; - gap: var(--space-sm); } } diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index d3e364a5e..72b5f7576 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -1,13 +1,7 @@ /** - * 剪辑计划编辑器 — V21 设计系统 - * 三栏布局:左侧模板面板 / 中间预览+时间线 / 右侧设置面板 - * 支持 4 种模式切换(画中画 / 人物口播 / 一镜到底 / 口播+混剪) - * - * P0-2: 读取 URL 参数 ?template=xxx&generate=1 - * P1-3: 拆分为子组件 - * P1-4: voiceover_id → voiceover_duration - * P1-5: 分类 Input → Select(在 SaveModal 中实现) - * P1-6: SaveTemplatePayload 补充 estimated_duration + * 剪辑计划编辑器 — V21 设计系统(完整版) + * 任务 2.14:三栏布局 — 素材面板 / 时间线 / 片段属性 + * 支持拖拽排序、素材关联、转场编辑、剪辑计划 CRUD */ import React, { useState, useEffect, useCallback } from "react"; import "./EditingPlanner.css"; @@ -22,53 +16,28 @@ import { generateFromTemplate, MODE_LABELS, type EditingTemplate, - type TemplateSegment, type TemplateMode, - type TitleConfig, - type SubtitleConfig, - type BgmConfig, type SaveTemplatePayload, } from "@/api/editingPlanner"; +import { + getMediaAssets, + createEditPlan, + updateEditPlan, + type EditPlanClip, + type MediaAsset, +} from "@/api/editPlans"; /* ── 子组件 ── */ -import TemplatePanel from "./components/TemplatePanel"; +import MediaPanel from "./components/MediaPanel"; import TimelinePanel from "./components/TimelinePanel"; -import SettingsPanel from "./components/SettingsPanel"; +import ClipPropertiesPanel from "./components/ClipPropertiesPanel"; import SaveModal from "./components/SaveModal"; import GenerateModal from "./components/GenerateModal"; /* ──────────── 常量 ──────────── */ -const MODES: { key: TemplateMode; icon: string; desc: string }[] = [ - { key: "pip", icon: "🖼️", desc: "多画面叠加" }, - { key: "voice_over", icon: "🎙️", desc: "人物讲解为主" }, - { key: "one_take", icon: "🎬", desc: "连续不中断" }, - { key: "voice_pip", icon: "🎞️", desc: "口播搭配混剪素材" }, -]; - -const DEFAULT_TITLE: TitleConfig = { - ai_auto_select: true, - content: "", - font_preset: "思源黑体", - font_color: "#ffffff", - font_size: 32, - position: "top", -}; -const DEFAULT_SUBTITLE: SubtitleConfig = { - enabled: true, - position: "bottom", - font: "思源黑体", - color: "#ffffff", - size: 24, - animation: "fade", -}; -const DEFAULT_BGM: BgmConfig = { enabled: false, music_id: "" }; - -/** 计算预估时长 = Σ 片段时长范围中值 */ -const calcEstimatedDuration = (segs: TemplateSegment[]) => - Math.round( - segs.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0), - ); +let _clipId = 0; +const newClipId = () => `clip-new-${++_clipId}`; let _segId = 0; const newSegId = () => `seg-new-${++_segId}`; @@ -101,21 +70,16 @@ const EditingPlanner: React.FC = () => { const [searchParams] = useSearchParams(); const { show: showToast, ToastNode } = useToast(); - /* ── P0-2: URL 参数 ── */ + /* ── URL 参数 ── */ const urlTemplateId = searchParams.get("template"); const urlGenerate = searchParams.get("generate"); - /* ── 数据查询 ── */ - const [searchText, setSearchText] = useState(""); - const [filterCategory, setFilterCategory] = useState(""); - - const { data: templates = [], isLoading: tplLoading } = useQuery({ - queryKey: ["editing-templates", filterCategory, searchText], - queryFn: () => - getEditingTemplates({ - category: filterCategory || undefined, - tag: searchText || undefined, - }), + /* ── 数据查询:模板 ── */ + const { data: templates = [], isLoading: tplLoading } = useQuery< + EditingTemplate[] + >({ + queryKey: ["editing-templates"], + queryFn: () => getEditingTemplates(), }); const { data: categories = [] } = useQuery({ @@ -123,26 +87,28 @@ const EditingPlanner: React.FC = () => { queryFn: getTemplateCategories, }); + /* ── 数据查询:素材库 ── */ + const { data: assets = [], isLoading: assetsLoading } = useQuery({ + queryKey: ["media-assets"], + queryFn: getMediaAssets, + }); + /* ── 编辑器状态 ── */ const [currentMode, setCurrentMode] = useState("pip"); - const [segments, setSegments] = useState([ + const [clips, setClips] = useState([ { - id: newSegId(), - segment_order: 1, - duration_min: 5, - duration_max: 15, - material_type: null, + id: newClipId(), + template_segment_id: newSegId(), + material_type: "video", + script_text: "", + duration: 10, + transition: { type: "none", duration: 0 }, + order: 0, }, ]); + const [selectedClipId, setSelectedClipId] = useState(null); const [loadedTemplateId, setLoadedTemplateId] = useState(null); - - const [titleConfig, setTitleConfig] = useState({ - ...DEFAULT_TITLE, - }); - const [subtitleConfig, setSubtitleConfig] = useState({ - ...DEFAULT_SUBTITLE, - }); - const [bgmConfig, setBgmConfig] = useState({ ...DEFAULT_BGM }); + const [editPlanId, setEditPlanId] = useState(null); /* ── UI 状态 ── */ const [saveModalOpen, setSaveModalOpen] = useState(false); @@ -153,15 +119,13 @@ const EditingPlanner: React.FC = () => { const [voiceoverDuration, setVoiceoverDuration] = useState( null, ); - const [dragIdx, setDragIdx] = useState(null); - /* ── P0-2: 自动加载 URL 指定的模板 ── */ + /* ── 自动加载 URL 指定的模板 ── */ useEffect(() => { if (urlTemplateId && templates.length > 0 && !loadedTemplateId) { const tpl = templates.find((t) => t.id === urlTemplateId); if (tpl) { loadTemplate(tpl); - // 如果 URL 有 generate=1,自动打开发成弹窗 if (urlGenerate === "1") { setGenerateModalOpen(true); } @@ -169,7 +133,7 @@ const EditingPlanner: React.FC = () => { } }, [urlTemplateId, templates, loadedTemplateId, urlGenerate]); - /* ── Mutations ── */ + /* ── Mutations:模板 ── */ const createMutation = useMutation({ mutationFn: createEditingTemplate, onSuccess: () => { @@ -197,6 +161,31 @@ const EditingPlanner: React.FC = () => { }, }); + /* ── Mutations:剪辑计划 ── */ + const savePlanMutation = useMutation({ + mutationFn: createEditPlan, + onSuccess: (plan) => { + showToast("剪辑计划已保存", "success"); + setEditPlanId(plan.id); + setSaveModalOpen(false); + }, + onError: () => showToast("保存失败", "error"), + }); + + const updatePlanMutation = useMutation({ + mutationFn: ({ + id, + data, + }: { + id: string; + data: { name?: string; clips?: EditPlanClip[] }; + }) => updateEditPlan(id, data), + onSuccess: () => { + showToast("剪辑计划已更新", "success"); + }, + onError: () => showToast("更新失败", "error"), + }); + const generateMutation = useMutation({ mutationFn: ({ templateId, @@ -219,79 +208,99 @@ const EditingPlanner: React.FC = () => { }, }); - const saving = createMutation.isPending || updateMutation.isPending; + const saving = + createMutation.isPending || + updateMutation.isPending || + savePlanMutation.isPending || + updatePlanMutation.isPending; /* ──────────── 片段操作 ──────────── */ - const addSegment = () => { - if (currentMode === "one_take") return; - setSegments((prev) => [ - ...prev, - { - id: newSegId(), - segment_order: prev.length + 1, - duration_min: 5, - duration_max: 15, - material_type: currentMode === "voice_pip" ? "人物" : null, - }, - ]); - }; + const handleSelectClip = useCallback((clipId: string | null) => { + setSelectedClipId(clipId); + }, []); - const removeSegment = (id: string) => { - if (currentMode === "one_take") return; - setSegments((prev) => - prev - .filter((s) => s.id !== id) - .map((s, i) => ({ ...s, segment_order: i + 1 })), - ); - }; + const handleUpdateClip = useCallback( + (clipId: string, updates: Partial) => { + setClips((prev) => + prev.map((c) => (c.id === clipId ? { ...c, ...updates } : c)), + ); + }, + [], + ); - const updateSegment = (id: string, patch: Partial) => { - setSegments((prev) => - prev.map((s) => (s.id === id ? { ...s, ...patch } : s)), - ); - }; + const handleRemoveClip = useCallback( + (clipId: string) => { + setClips((prev) => { + const next = prev + .filter((c) => c.id !== clipId) + .map((c, i) => ({ ...c, order: i })); + return next; + }); + if (selectedClipId === clipId) { + setSelectedClipId(null); + } + }, + [selectedClipId], + ); - const handleDragStart = (idx: number) => setDragIdx(idx); + const handleReorderClips = useCallback( + (fromIdx: number, toIdx: number) => { + setClips((prev) => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + next.splice(toIdx, 0, moved); + return next.map((c, i) => ({ ...c, order: i })); + }); + }, + [], + ); - const handleDragOver = (e: React.DragEvent, idx: number) => { - e.preventDefault(); - if (dragIdx === null || dragIdx === idx) return; - setSegments((prev) => { - const next = [...prev]; - const [moved] = next.splice(dragIdx, 1); - next.splice(idx, 0, moved); - return next.map((s, i) => ({ ...s, segment_order: i + 1 })); - }); - setDragIdx(idx); - }; + const handleAssetDrop = useCallback( + (asset: MediaAsset, insertIdx: number) => { + const newClip: EditPlanClip = { + id: newClipId(), + template_segment_id: newSegId(), + media_asset_id: asset.id, + material_type: asset.type as EditPlanClip["material_type"], + script_text: "", + duration: asset.duration || 10, + transition: { type: "none", duration: 0 }, + order: insertIdx, + }; + setClips((prev) => { + const next = [...prev]; + next.splice(insertIdx, 0, newClip); + return next.map((c, i) => ({ ...c, order: i })); + }); + setSelectedClipId(newClip.id); + showToast(`已添加素材: ${asset.name}`, "success"); + }, + [showToast], + ); - const handleDragEnd = () => setDragIdx(null); + const handleAddClip = useCallback(() => { + const newClip: EditPlanClip = { + id: newClipId(), + template_segment_id: newSegId(), + material_type: "video", + script_text: "", + duration: 10, + transition: { type: "none", duration: 0 }, + order: clips.length, + }; + setClips((prev) => [...prev, newClip]); + setSelectedClipId(newClip.id); + }, [clips.length]); + + const handleAssetDragStart = useCallback((_asset: MediaAsset) => { + // 素材拖拽开始时的回调(可用于高亮时间线等) + }, []); /* ──────────── 模式切换 ──────────── */ const handleModeChange = (mode: TemplateMode) => { setCurrentMode(mode); - if (mode === "one_take") { - // 锁定为 1 个片段 - setSegments([ - { - id: newSegId(), - segment_order: 1, - duration_min: 10, - duration_max: 20, - material_type: null, - }, - ]); - } else if (mode === "voice_pip") { - // 确保每个片段有 material_type - setSegments((prev) => - prev.map((s) => ({ - ...s, - material_type: s.material_type || "人物", - })), - ); - } }; /* ──────────── 模板操作 ──────────── */ @@ -299,31 +308,42 @@ const EditingPlanner: React.FC = () => { const loadTemplate = (tpl: EditingTemplate) => { setLoadedTemplateId(tpl.id); setCurrentMode(tpl.mode); - setSegments(tpl.segments.map((s) => ({ ...s }))); - setTitleConfig({ ...tpl.title_config }); - setSubtitleConfig({ ...tpl.subtitle_config }); - setBgmConfig({ ...tpl.bgm_config }); + // 将模板片段转换为剪辑片段 + const newClips: EditPlanClip[] = tpl.segments.map((seg, i) => ({ + id: newClipId(), + template_segment_id: seg.id || `seg-${i}`, + material_type: (seg.material_type as EditPlanClip["material_type"]) || "video", + script_text: "", + duration: Math.round((seg.duration_min + seg.duration_max) / 2), + transition: { type: "none", duration: 0 }, + order: i, + })); + setClips(newClips); + setSelectedClipId(null); }; const resetEditor = () => { setLoadedTemplateId(null); + setEditPlanId(null); setCurrentMode("pip"); - setSegments([ + setClips([ { - id: newSegId(), - segment_order: 1, - duration_min: 5, - duration_max: 15, - material_type: null, + id: newClipId(), + template_segment_id: newSegId(), + material_type: "video", + script_text: "", + duration: 10, + transition: { type: "none", duration: 0 }, + order: 0, }, ]); - setTitleConfig({ ...DEFAULT_TITLE }); - setSubtitleConfig({ ...DEFAULT_SUBTITLE }); - setBgmConfig({ ...DEFAULT_BGM }); + setSelectedClipId(null); }; + /* ──────────── 保存/生成 ──────────── */ + const openSaveModal = () => { - if (segments.length === 0) { + if (clips.length === 0) { showToast("请至少添加一个片段", "warning"); return; } @@ -348,35 +368,69 @@ const EditingPlanner: React.FC = () => { const handleSave = () => { if (!draftName.trim()) { - showToast("请输入模板名称", "warning"); + showToast("请输入名称", "warning"); return; } - const estimatedDuration = calcEstimatedDuration(segments); - const payload = { - name: draftName.trim(), - mode: currentMode, - category: draftCategory, - tags: draftTags - .split(/[,,]/) - .map((t) => t.trim()) - .filter(Boolean), - title_config: titleConfig, - subtitle_config: subtitleConfig, - bgm_config: bgmConfig, - estimated_duration: estimatedDuration, - segments: segments.map(({ id: _id, ...rest }) => rest), - }; - if (loadedTemplateId) { - updateMutation.mutate({ id: loadedTemplateId, data: payload }); + // 保存剪辑计划 + if (editPlanId) { + updatePlanMutation.mutate({ + id: editPlanId, + data: { name: draftName.trim(), clips }, + }); } else { - createMutation.mutate(payload); + savePlanMutation.mutate({ + name: draftName.trim(), + template_id: loadedTemplateId || undefined, + mode: currentMode, + clips: clips.map(({ id: _id, ...rest }) => rest), + }); + } + + // 同时保存模板(如果有 loadedTemplateId) + if (loadedTemplateId) { + const estimatedDuration = clips.reduce((s, c) => s + c.duration, 0); + const payload: SaveTemplatePayload = { + name: draftName.trim(), + mode: currentMode, + category: draftCategory, + tags: draftTags + .split(/[,,]/) + .map((t) => t.trim()) + .filter(Boolean), + title_config: { + ai_auto_select: true, + content: "", + font_preset: "思源黑体", + font_color: "#ffffff", + font_size: 32, + position: "top", + }, + subtitle_config: { + enabled: true, + position: "bottom", + font: "思源黑体", + color: "#ffffff", + size: 24, + animation: "fade", + }, + bgm_config: { enabled: false, music_id: "" }, + estimated_duration: estimatedDuration, + segments: clips.map((c) => ({ + id: c.template_segment_id, + segment_order: c.order + 1, + duration_min: Math.max(1, c.duration - 3), + duration_max: c.duration + 3, + material_type: c.material_type === "voiceover" ? null : c.material_type, + })), + }; + updateMutation.mutate({ id: loadedTemplateId, data: payload }); } }; const handleGenerate = () => { if (!loadedTemplateId) { - showToast("请先保存模板", "warning"); + showToast("请先选择模板", "warning"); return; } setGenerateModalOpen(true); @@ -393,7 +447,8 @@ const EditingPlanner: React.FC = () => { }); }; - const estimatedDuration = calcEstimatedDuration(segments); + const totalDuration = clips.reduce((s, c) => s + c.duration, 0); + const selectedClip = clips.find((c) => c.id === selectedClipId) || null; /* ──────────── 渲染 ──────────── */ @@ -405,22 +460,35 @@ const EditingPlanner: React.FC = () => {
- {MODES.map((m) => ( - - ))} + {(["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[]).map( + (mode) => ( + + ), + )}
+
+ {loadedTemplateId && ( + + 📋{" "} + {templates.find((t) => t.id === loadedTemplateId)?.name || + "未命名模板"} + + )} + {editPlanId && ( + 已保存 + )} +
{/* ═══ 三栏主体 ═══ */}
- {/* 左侧:模板面板 */} - - {/* 中间:预览 + 时间线 */} + {/* 中间:时间线 */} - {/* 右侧:设置面板 */} -
@@ -477,12 +540,12 @@ const EditingPlanner: React.FC = () => { { open={generateModalOpen} loading={generateMutation.isPending} voiceoverDuration={voiceoverDuration} - estimatedDuration={estimatedDuration} + estimatedDuration={totalDuration} onDurationChange={setVoiceoverDuration} onGenerate={doGenerate} onCancel={() => setGenerateModalOpen(false)} diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx new file mode 100644 index 000000000..00be71323 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -0,0 +1,204 @@ +/** + * 右侧片段属性面板 — V21 设计系统 + * 选中片段后编辑:文案、时长、转场效果 + * 未选中时显示全局设置(标题/字幕/BGM) + */ +import React from "react"; +import type { EditPlanClip, TransitionEffect } from "@/api/editPlans"; +import { TRANSITION_OPTIONS, MATERIAL_TYPE_ICONS } from "@/api/editPlans"; + +interface ClipPropertiesPanelProps { + /** 当前选中的片段 */ + selectedClip: EditPlanClip | null; + /** 更新片段属性 */ + onUpdateClip: (clipId: string, updates: Partial) => void; + /** 所有片段列表(用于显示上下文) */ + clips: EditPlanClip[]; +} + +const ClipPropertiesPanel: React.FC = ({ + selectedClip, + onUpdateClip, + clips, +}) => { + if (!selectedClip) { + return ( +
+
+
👆
+

选择一个片段

+

点击时间线上的片段来编辑属性

+
+
+ 总片段数 + {clips.length} +
+
+ 总时长 + + {clips.reduce((s, c) => s + c.duration, 0)}s + +
+
+
+
+ ); + } + + const transition = selectedClip.transition ?? { + type: "none" as const, + duration: 0, + }; + + const handleTransitionTypeChange = (type: TransitionEffect["type"]) => { + const duration = type === "none" ? 0 : transition.duration || 0.5; + onUpdateClip(selectedClip.id, { + transition: { type, duration }, + }); + }; + + const handleTransitionDurationChange = (duration: number) => { + onUpdateClip(selectedClip.id, { + transition: { ...transition, duration }, + }); + }; + + return ( +
+ {/* 片段信息头 */} +
+
+ + {MATERIAL_TYPE_ICONS[selectedClip.material_type] || "📄"} + +
+

片段 {selectedClip.order + 1}

+ + {selectedClip.material_type} · {selectedClip.duration}s + +
+
+
+ + {/* 文案编辑 */} +
+

📝 文案

+
+