diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 303facc1a..90777846d 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -68,6 +68,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 +222,30 @@ 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; +} + /** * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 将后端 AssetResponse 映射为前端 MediaAsset 类型 diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 39e71dd14..bd33b5967 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -26,7 +26,13 @@ import { MODE_LABELS, } from "@/api/editingPlanner"; import type { EditPlanClip, MediaAsset } from "@/api/editPlans"; -import { getMediaAssets, MATERIAL_TYPE_LABELS } from "@/api/editPlans"; +import { + getMediaAssets, + MATERIAL_TYPE_LABELS, + aiRecommendClips, + generateCover, +} from "@/api/editPlans"; +import { useUndoRedo } from "./hooks/useUndoRedo"; import type { TaskItem } from "@/api/tasks"; import { createGenerationTask, getTask, retryTask } from "@/api/tasks"; @@ -80,10 +86,22 @@ const EditingPlanner: React.FC = () => { ); const [currentMode, setCurrentMode] = useState("pip"); - /* ── 片段 ── */ - const [clips, setClips] = useState([]); + /* ── 片段(撤销/重做) ── */ + const { + state: clips, + set: setClips, + undo, + redo, + canUndo, + canRedo, + reset: resetClips, + } = useUndoRedo([]); const [selectedClipId, setSelectedClipId] = useState(null); + /* ── AI 操作状态 ── */ + const [aiRecommendLoading, setAiRecommendLoading] = useState(false); + const [aiCoverLoading, setAiCoverLoading] = useState(false); + /* ── 封面方案 ── */ const [currentCoverScheme, setCurrentCoverScheme] = useState("ai_frame"); @@ -182,7 +200,7 @@ const EditingPlanner: React.FC = () => { assetName: undefined, transition: { type: "none", duration: 0 }, })); - setClips(mapped); + resetClips(mapped); setTitleSettings((prev) => ({ ...prev, @@ -290,6 +308,74 @@ const EditingPlanner: React.FC = () => { setClips((prev) => [...prev, newClip]); }; + /* AI 推荐片段 */ + const handleAiRecommend = async () => { + if (!loadedTemplateId) { + msg.warning("请先加载模板"); + return; + } + const assetIds = clips + .map((c) => c.media_asset_id) + .filter((v): v is string => !!v); + if (assetIds.length === 0) { + msg.warning("请先添加素材到片段"); + return; + } + setAiRecommendLoading(true); + try { + const res = await aiRecommendClips(loadedTemplateId, { + asset_ids: assetIds, + editing_mode: currentMode, + target_duration: totalDuration || 30, + }); + const mapped: ClipData[] = res.clips.map((c, i) => ({ + id: `clip-ai-${i}-${Date.now()}`, + template_segment_id: "", + name: `片段${i + 1} · ${c.clip_type}`, + material_type: (c.clip_type as ClipData["material_type"]) || "video", + script_text: c.text_content || "", + duration: c.duration || 5, + order: c.order ?? i, + media_asset_id: c.asset_id || undefined, + assetName: undefined, + transition: { type: "none", duration: 0 }, + })); + setClips(mapped); + msg.success(`AI 推荐了 ${mapped.length} 个片段`); + } catch { + msg.error("AI 推荐失败"); + } finally { + setAiRecommendLoading(false); + } + }; + + /* AI 封面生成 */ + const handleAiGenerateCover = async ( + coverType: "ai_frame" | "ai_regenerate", + ) => { + if (!loadedTemplateId) return; + const assetIds = clips + .map((c) => c.media_asset_id) + .filter((v): v is string => !!v); + if (assetIds.length === 0) { + msg.warning("请先添加素材"); + return; + } + setAiCoverLoading(true); + try { + await generateCover(loadedTemplateId, { + asset_ids: assetIds, + cover_type: coverType, + }); + setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect"); + msg.success("AI 封面生成成功"); + } catch { + msg.error("AI 封面生成失败"); + } finally { + setAiCoverLoading(false); + } + }; + /* 保存 */ const handleOpenSaveModal = () => { if (!loadedTemplateId) { @@ -447,6 +533,22 @@ const EditingPlanner: React.FC = () => {
+ +
diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index 71f41595f..cc23169e2 100644 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -50,10 +50,12 @@ interface ClipPropertiesPanelProps { clipsCount: number; totalDuration: number; currentMode: TemplateMode; + aiRecommendLoading: boolean; onTitleSettingsChange: (partial: Partial) => void; onSubtitleSettingsChange: (partial: Partial) => void; onBgmSettingsChange: (partial: Partial) => void; onClipUpdate: (clipId: string, data: Partial) => void; + onAiRecommend: () => void; } const POSITION_OPTIONS = [ @@ -87,10 +89,12 @@ const ClipPropertiesPanel: React.FC = ({ clipsCount, totalDuration, currentMode, + aiRecommendLoading, onTitleSettingsChange, onSubtitleSettingsChange, onBgmSettingsChange, onClipUpdate, + onAiRecommend, }) => { return (
@@ -383,8 +387,12 @@ const ClipPropertiesPanel: React.FC = ({
- diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index 824dfba7f..d2f8067d6 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -25,9 +25,11 @@ interface PreviewPlayerProps { isPlaying: boolean; currentCoverScheme: string; coverSchemes: CoverScheme[]; + aiCoverLoading: boolean; onClipSelect: (clipId: string) => void; onCoverSchemeChange: (scheme: string) => void; onPlayPause: () => void; + onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void; } const MATERIAL_ICONS: Record = { @@ -43,8 +45,10 @@ const PreviewPlayer: React.FC = ({ isPlaying, currentCoverScheme, coverSchemes, + aiCoverLoading, onCoverSchemeChange, onPlayPause, + onAiGenerateCover, }) => { const selectedClip = clips.find((c) => c.id === selectedClipId); const displayClip = selectedClip || clips[0]; @@ -94,6 +98,25 @@ const PreviewPlayer: React.FC = ({ {coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"} + {/* AI 封面操作按钮 */} +
+ + +
{/* 封面方案按钮(竖排4个) */} diff --git a/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts b/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts new file mode 100644 index 000000000..4e96e25ab --- /dev/null +++ b/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts @@ -0,0 +1,65 @@ +/** + * 撤销/重做 Hook — 通用历史栈管理 + * 支持任意状态的快照式 undo/redo + */ +import { useState, useCallback, useRef } from "react"; + +const MAX_HISTORY = 50; + +export function useUndoRedo(initialState: T) { + const [present, setPresent] = useState(initialState); + const pastRef = useRef([]); + const futureRef = useRef([]); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + + const set = useCallback( + (next: T | ((prev: T) => T)) => { + setPresent((curr) => { + const resolved = typeof next === "function" ? (next as (p: T) => T)(curr) : next; + pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), curr]; + futureRef.current = []; + setCanUndo(true); + setCanRedo(false); + return resolved; + }); + }, + [], + ); + + const undo = useCallback(() => { + setPresent((curr) => { + const past = pastRef.current; + if (past.length === 0) return curr; + const prev = past[past.length - 1]; + pastRef.current = past.slice(0, -1); + futureRef.current = [curr, ...futureRef.current]; + setCanUndo(pastRef.current.length > 0); + setCanRedo(true); + return prev; + }); + }, []); + + const redo = useCallback(() => { + setPresent((curr) => { + const future = futureRef.current; + if (future.length === 0) return curr; + const next = future[0]; + futureRef.current = future.slice(1); + pastRef.current = [...pastRef.current, curr]; + setCanUndo(true); + setCanRedo(futureRef.current.length > 0); + return next; + }); + }, []); + + const reset = useCallback((state: T) => { + pastRef.current = []; + futureRef.current = []; + setPresent(state); + setCanUndo(false); + setCanRedo(false); + }, []); + + return { state: present, set, undo, redo, canUndo, canRedo, reset }; +}