feat(editing-planner): P1 交互 + AI 接口对接
Deploy / Staging E2E Tests (push) Failing after 112h33m14s
Deploy / Deploy Staging (push) Failing after 112h34m54s
CI/CD Pipeline / Frontend Lint (push) Failing after 112h35m6s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 112h35m6s
Deploy / Production Browser E2E (push) Failing after 1771h10m33s
Deploy / Build Production Runtime Images (push) Failing after 1771h10m37s
Deploy / Deploy Production (push) Failing after 1771h42m1s

- 对接 POST /api/v1/edit-plans/{plan_id}/ai-recommend(AI 推荐片段方案)
- 对接 POST /api/v1/edit-plans/{plan_id}/generate-cover(AI 封面生成)
- 新增 useUndoRedo hook(快照式历史栈,MAX_HISTORY=50)
- 顶栏新增撤销/重做按钮
- ClipPropertiesPanel: AI 推荐素材按钮绑定 onClick + loading 状态
- PreviewPlayer: 封面预览区新增 AI 选帧/AI 重选按钮
- editPlans.ts 新增 aiRecommendClips/generateCover API 函数及类型
This commit is contained in:
灵应
2026-07-04 20:19:19 +08:00
parent 48d7e01498
commit eef1f18e05
5 changed files with 277 additions and 6 deletions
+69
View File
@@ -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<string, unknown>;
}
/** AI 推荐响应 */
export interface AIRecommendResponse {
plan_id: string;
clips: AIRecommendClipItem[];
config: Record<string, unknown>;
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<string, unknown>;
}
/* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */
@@ -177,6 +222,30 @@ export async function getGenerationStatus(
return response.data;
}
/** AI 推荐片段方案 */
export async function aiRecommendClips(
planId: string,
data: AIRecommendRequest,
): Promise<AIRecommendResponse> {
const response = await apiClient.post(
`/edit-plans/${planId}/ai-recommend`,
data,
);
return response.data;
}
/** AI 生成封面 */
export async function generateCover(
planId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(
`/edit-plans/${planId}/generate-cover`,
data,
);
return response.data;
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
@@ -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<TemplateMode>("pip");
/* ── 片段 ── */
const [clips, setClips] = useState<ClipData[]>([]);
/* ── 片段(撤销/重做) ── */
const {
state: clips,
set: setClips,
undo,
redo,
canUndo,
canRedo,
reset: resetClips,
} = useUndoRedo<ClipData[]>([]);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
/* ── AI 操作状态 ── */
const [aiRecommendLoading, setAiRecommendLoading] = useState(false);
const [aiCoverLoading, setAiCoverLoading] = useState(false);
/* ── 封面方案 ── */
const [currentCoverScheme, setCurrentCoverScheme] =
useState<string>("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 = () => {
</span>
</div>
<div className="ep-top-bar-right">
<button
className="ep-btn ep-btn-secondary"
onClick={undo}
disabled={!canUndo}
title="撤销 (Ctrl+Z)"
>
⬅️ 撤销
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={redo}
disabled={!canRedo}
title="重做 (Ctrl+Shift+Z)"
>
➡️ 重做
</button>
<button
className="ep-btn ep-btn-secondary"
onClick={handleOpenSaveModal}
@@ -500,9 +602,11 @@ const EditingPlanner: React.FC = () => {
isPlaying={isPlaying}
currentCoverScheme={currentCoverScheme}
coverSchemes={COVER_SCHEMES}
aiCoverLoading={aiCoverLoading}
onClipSelect={handleClipSelect}
onCoverSchemeChange={setCurrentCoverScheme}
onPlayPause={() => setIsPlaying(!isPlaying)}
onAiGenerateCover={handleAiGenerateCover}
/>
{/* 下半部:水平时间线 */}
@@ -528,6 +632,7 @@ const EditingPlanner: React.FC = () => {
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
aiRecommendLoading={aiRecommendLoading}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
@@ -538,6 +643,7 @@ const EditingPlanner: React.FC = () => {
setBgmSettings((prev) => ({ ...prev, ...partial }))
}
onClipUpdate={handleClipUpdate}
onAiRecommend={handleAiRecommend}
/>
</div>
@@ -50,10 +50,12 @@ interface ClipPropertiesPanelProps {
clipsCount: number;
totalDuration: number;
currentMode: TemplateMode;
aiRecommendLoading: boolean;
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
onAiRecommend: () => void;
}
const POSITION_OPTIONS = [
@@ -87,10 +89,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
clipsCount,
totalDuration,
currentMode,
aiRecommendLoading,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange,
onClipUpdate,
onAiRecommend,
}) => {
return (
<div className="ep-right-panel">
@@ -383,8 +387,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
</div>
</div>
<button className="ep-ai-recommend-btn">
✨ AI 推荐素材
<button
className="ep-ai-recommend-btn"
onClick={onAiRecommend}
disabled={aiRecommendLoading}
>
{aiRecommendLoading ? "⏳ AI 分析中..." : "✨ AI 推荐素材"}
</button>
</div>
</div>
@@ -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<string, string> = {
@@ -43,8 +45,10 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
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<PreviewPlayerProps> = ({
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label ||
"封面预览"}
</div>
{/* AI 封面操作按钮 */}
<div className="ep-cover-ai-btns">
<button
className="ep-cover-ai-btn"
onClick={() => onAiGenerateCover("ai_frame")}
disabled={aiCoverLoading}
title="AI 智能选帧"
>
{aiCoverLoading ? "⏳" : "🤖"} AI 选帧
</button>
<button
className="ep-cover-ai-btn"
onClick={() => onAiGenerateCover("ai_regenerate")}
disabled={aiCoverLoading}
title="AI 重新生成封面"
>
{aiCoverLoading ? "⏳" : "🔄"} AI 重选
</button>
</div>
</div>
{/* 封面方案按钮(竖排4个) */}
@@ -0,0 +1,65 @@
/**
* 撤销/重做 Hook — 通用历史栈管理
* 支持任意状态的快照式 undo/redo
*/
import { useState, useCallback, useRef } from "react";
const MAX_HISTORY = 50;
export function useUndoRedo<T>(initialState: T) {
const [present, setPresent] = useState<T>(initialState);
const pastRef = useRef<T[]>([]);
const futureRef = useRef<T[]>([]);
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 };
}