diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index b75760d04..3890bb1a7 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -1,9 +1,10 @@ /** - * 剪辑计划 API — Mock 数据 + 预留接口 - * 任务 2.14:对接后端 GET/POST/PUT /api/v1/edit-plans - * 当前使用 Mock 数据,后续替换为真实 API 调用 + * 剪辑计划 API + * 对接后端 /api/v1/edit-plans 端点,完整 CRUD */ import type { TemplateMode } from "./editingPlanner"; +import apiClient from "./client"; +import { getAssetLibraries, getAssets, type AssetItem } from "./assets"; /* ============================================================ * 类型定义 @@ -84,239 +85,123 @@ export interface MediaAsset { } /* ============================================================ - * Mock 数据 + * 辅助函数:AssetItem → MediaAsset 映射 * ============================================================ */ -const MOCK_ASSETS: MediaAsset[] = [ - { - id: "asset-001", - name: "产品展示-正面.mp4", - type: "video", - thumbnail_url: "https://picsum.photos/seed/asset001/320/180", - duration: 15, - size: 12_500_000, - tags: ["产品", "展示"], - created_at: "2026-06-20T10:00:00Z", - quality_score: 92, - classification_status: "completed", - }, - { - id: "asset-002", - name: "使用教程-片段A.mp4", - type: "video", - thumbnail_url: "https://picsum.photos/seed/asset002/320/180", - duration: 20, - size: 18_000_000, - tags: ["教程", "使用"], - created_at: "2026-06-21T14:00:00Z", - quality_score: 85, - classification_status: "completed", - }, - { - id: "asset-003", - name: "背景音乐-轻快.mp3", - type: "audio", - duration: 120, - size: 3_200_000, - tags: ["BGM", "轻快"], - created_at: "2026-06-18T09:00:00Z", - quality_score: 78, - classification_status: "completed", - }, - { - id: "asset-004", - name: "封面图-主图.jpg", - type: "image", - thumbnail_url: "https://picsum.photos/seed/asset004/320/180", - size: 850_000, - tags: ["封面", "主图"], - created_at: "2026-06-22T11:00:00Z", - quality_score: 88, - classification_status: "completed", - }, - { - id: "asset-005", - name: "细节特写-侧面.mp4", - type: "video", - thumbnail_url: "https://picsum.photos/seed/asset005/320/180", - duration: 10, - size: 8_500_000, - tags: ["产品", "细节"], - created_at: "2026-06-23T16:00:00Z", - quality_score: 72, - classification_status: "completed", - }, - { - id: "asset-006", - name: "开箱视频-片段.mp4", - type: "video", - thumbnail_url: "https://picsum.photos/seed/asset006/320/180", - duration: 25, - size: 22_000_000, - tags: ["开箱", "展示"], - created_at: "2026-06-19T08:00:00Z", - quality_score: 65, - classification_status: "processing", - }, - { - id: "asset-007", - name: "配音-产品介绍.wav", - type: "audio", - duration: 45, - size: 5_600_000, - tags: ["配音", "产品"], - created_at: "2026-06-24T10:00:00Z", - quality_score: 95, - classification_status: "completed", - }, - { - id: "asset-008", - name: "场景图-生活场景.jpg", - type: "image", - thumbnail_url: "https://picsum.photos/seed/asset008/320/180", - size: 1_200_000, - tags: ["场景", "生活"], - created_at: "2026-06-25T13:00:00Z", - quality_score: 58, - classification_status: "pending", - }, -]; +/** 从 mime_type 推断素材类型 */ +function inferAssetType(mimeType: string): MediaAsset["type"] { + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("audio/")) return "audio"; + return "video"; +} -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", - }, -]; +/** 将后端 AssetItem 映射为 UI 层 MediaAsset */ +function assetItemToMediaAsset( + item: AssetItem, + libraryKind?: string, +): MediaAsset { + const meta = item.metadata || {}; + return { + id: item.id, + name: item.name, + type: + libraryKind === "voice" + ? "audio" + : inferAssetType(item.mime_type || ""), + thumbnail_url: item.storage_key + ? `/api/v1/assets/${item.id}/thumbnail` + : undefined, + duration: + typeof meta.duration === "number" ? (meta.duration as number) : undefined, + size: item.file_size, + tags: Array.isArray(meta.tags) ? (meta.tags as string[]) : [], + created_at: item.created_at || new Date().toISOString(), + quality_score: item.quality_score ?? undefined, + classification_status: + (item.classification_status as MediaAsset["classification_status"]) || + undefined, + }; +} /* ============================================================ - * Mock 延迟 - * ============================================================ */ -const delay = (ms = 300) => new Promise((r) => setTimeout(r, ms)); - -/* ============================================================ - * API 函数(Mock 实现,后续替换为真实 API) + * API 函数 — 对接后端 /api/v1/edit-plans 和 /api/v1/assets * ============================================================ */ /** 获取剪辑计划列表 */ export async function getEditPlans(): Promise { - // TODO: 替换为 apiClient.get('/edit-plans') - await delay(); - return [...mockPlans]; + const response = await apiClient.get("/edit-plans"); + return response.data; } /** 获取单个剪辑计划 */ 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 }; + const response = await apiClient.get(`/edit-plans/${id}`); + return response.data; } /** 创建剪辑计划 */ export async function createEditPlan( - data: CreateEditPlanRequest + 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; + const response = await apiClient.post("/edit-plans", data); + return response.data; } /** 更新剪辑计划 */ export async function updateEditPlan( id: string, - data: UpdateEditPlanRequest + 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] }; + const response = await apiClient.put(`/edit-plans/${id}`, data); + return response.data; } /** 删除剪辑计划 */ export async function deleteEditPlan(id: string): Promise { - // TODO: 替换为 apiClient.delete(`/edit-plans/${id}`) - await delay(); - mockPlans = mockPlans.filter((p) => p.id !== id); + await apiClient.delete(`/edit-plans/${id}`); } -/** 获取素材库列表 */ +/** 获取素材库列表(聚合所有 library 的 assets) */ export async function getMediaAssets(): Promise { - // TODO: 替换为 apiClient.get('/media-assets') - await delay(); - return [...MOCK_ASSETS]; + const libraries = await getAssetLibraries(); + const allAssets = await Promise.all( + libraries.map((lib) => getAssets(lib.id)), + ); + return allAssets.flat().map((asset) => assetItemToMediaAsset(asset)); } /** 获取单个素材 */ 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 }; + const response = await apiClient.get(`/assets/${id}`); + return assetItemToMediaAsset(response.data); +} + +/** 获取所有素材(别名,GeneratePage 使用) */ +export async function getAllMaterials(): Promise { + return getMediaAssets(); +} + +/** 触发剪辑计划生成 */ +export async function generateEditPlan( + planId: string, +): Promise<{ task_id: string }> { + const response = await apiClient.post<{ task_id: string }>( + `/edit-plans/${planId}/generate`, + ); + return response.data; +} + +/** 获取剪辑计划生成任务状态 */ +export async function getEditPlanTask( + planId: string, +): Promise<{ id: string; status: string; progress: number }> { + const response = await apiClient.get<{ + id: string; + status: string; + progress: number; + }>(`/edit-plans/${planId}/task`); + return response.data; } /* ============================================================ diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index f8028024b..1c1438953 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -30,15 +30,17 @@ import { SoundOutlined, FormOutlined, } from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; import Button from "@/components/ui/Button"; import Input from "@/components/ui/Input"; import Card from "@/components/ui/Card"; import Tag from "@/components/ui/Tag"; import Form, { FormItem } from "@/components/ui/Form"; -import type { AssetItem } from "@/api/assets"; import type { VoiceItem } from "@/api/voices"; import { getVoiceClones, formatDuration } from "@/api/voiceClone"; import type { VoiceClone } from "@/api/voiceClone"; +import { getMediaAssets, createEditPlan, generateEditPlan } from "@/api/editPlans"; +import type { MediaAsset } from "@/api/editPlans"; import VoiceCloneModal from "@/components/modals/VoiceCloneModal"; import "./generate.css"; @@ -46,72 +48,9 @@ const { TextArea } = Input; const { Text } = Typography; /* ================================================================ - Mock 数据 + Mock 数据(配音/时间线暂保留,P1-2 仅替换素材) ================================================================ */ -const MOCK_MATERIALS: AssetItem[] = [ - { - id: "m1", - library_id: "lib1", - name: "产品展示视频.mp4", - storage_key: "", - mime_type: "video/mp4", - metadata: { duration: 30 }, - file_size: 15_000_000, - status: "active", - }, - { - id: "m2", - library_id: "lib1", - name: "品牌宣传片.mp4", - storage_key: "", - mime_type: "video/mp4", - metadata: { duration: 60 }, - file_size: 42_000_000, - status: "active", - }, - { - id: "m3", - library_id: "lib1", - name: "用户访谈片段.mp4", - storage_key: "", - mime_type: "video/mp4", - metadata: { duration: 45 }, - file_size: 28_000_000, - status: "active", - }, - { - id: "m4", - library_id: "lib2", - name: "Logo 动画.png", - storage_key: "", - mime_type: "image/png", - metadata: {}, - file_size: 500_000, - status: "active", - }, - { - id: "m5", - library_id: "lib2", - name: "背景音乐.mp3", - storage_key: "", - mime_type: "audio/mpeg", - metadata: { duration: 120 }, - file_size: 3_500_000, - status: "active", - }, - { - id: "m6", - library_id: "lib1", - name: "功能演示录屏.mp4", - storage_key: "", - mime_type: "video/mp4", - metadata: { duration: 90 }, - file_size: 55_000_000, - status: "active", - }, -]; - const MOCK_VOICES: VoiceItem[] = [ { id: "v1", @@ -291,6 +230,12 @@ const GeneratePage: React.FC = () => { const progressTimer = useRef>(undefined); + /* ── 获取素材列表(真实 API) ── */ + const { data: materials = [], isLoading: materialsLoading } = useQuery({ + queryKey: ["generate-page-materials"], + queryFn: getMediaAssets, + }); + /* ── 获取克隆音色列表 ── */ const fetchClonedVoices = useCallback(async () => { setLoadingClones(true); @@ -336,7 +281,7 @@ const GeneratePage: React.FC = () => { }); }, []); - const handleGenerate = useCallback(() => { + const handleGenerate = useCallback(async () => { if (!title.trim()) { message.warning("请输入视频标题"); return; @@ -350,19 +295,56 @@ const GeneratePage: React.FC = () => { setProgress(0); setGenerated(false); - progressTimer.current = setInterval(() => { - setProgress((prev) => { - if (prev >= 100) { + try { + // 1. 创建剪辑计划 + const plan = await createEditPlan({ + name: title.trim(), + mode: "pip", + clips: selectedMaterials.map((assetId, i) => { + const asset = materials.find((m) => m.id === assetId); + return { + template_segment_id: `seg-${i}`, + media_asset_id: assetId, + material_type: (asset?.type || "video") as "video" | "image" | "audio", + script_text: "", + duration: asset?.duration || 10, + order: i, + }; + }), + }); + + // 2. 触发生成 + const { task_id } = await generateEditPlan(plan.id); + + // 3. 轮询任务状态 + progressTimer.current = setInterval(async () => { + try { + const task = await fetch(`/api/v1/tasks/${task_id}`).then((r) => r.json()); + const pct = Math.min(99, Math.round(task.progress * 100) || 0); + setProgress(pct); + + if (task.status === "completed") { + clearInterval(progressTimer.current); + setProgress(100); + setGenerating(false); + setGenerated(true); + message.success("视频生成完成!"); + } else if (task.status === "failed") { + clearInterval(progressTimer.current); + setGenerating(false); + message.error("视频生成失败,请重试"); + } + } catch { clearInterval(progressTimer.current); setGenerating(false); - setGenerated(true); - message.success("视频生成完成!"); - return 100; + message.error("获取任务状态失败"); } - return prev + Math.random() * 12 + 3; - }); - }, 600); - }, [title, selectedMaterials]); + }, 2000); + } catch { + setGenerating(false); + message.error("创建生成任务失败"); + } + }, [title, selectedMaterials, materials]); /* ── 字数统计 ── */ const titleCount = title.length; @@ -473,9 +455,19 @@ const GeneratePage: React.FC = () => {
+ {materialsLoading ? ( +
+ 加载素材中... +
+ ) : materials.length === 0 ? ( +
+ 暂无素材,请先在素材库中上传 +
+ ) : (
- {MOCK_MATERIALS.map((m) => { + {materials.map((m) => { const selected = selectedMaterials.includes(m.id); + const mimeForIcon = m.type === "video" ? "video/mp4" : m.type === "image" ? "image/png" : "audio/mpeg"; return (
{ }} >
- {materialIcon(m.mime_type)} + {materialIcon(mimeForIcon)}
{m.name} - {m.mime_type.split("/")[1].toUpperCase()} - {m.file_size ? ` · ${formatSize(m.file_size)}` : ""} + {m.type.toUpperCase()} + {m.size ? ` · ${formatSize(m.size)}` : ""}
@@ -510,6 +502,7 @@ const GeneratePage: React.FC = () => { ); })}
+ )} {/* 上传入口 */} { )} {cv.status === "failed" && ( 失败