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
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user