Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6af6892cd | |||
| 60263703a6 | |||
| 6c047cdc20 | |||
| 185c240557 | |||
| 5e8dcb9843 |
@@ -190,11 +190,12 @@ test.describe("Core generation flow", () => {
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中
|
||||
const materialCard = page.getByText(sourceFileName).locator("..")
|
||||
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开
|
||||
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName })
|
||||
await expect(materialCard).toBeVisible({ timeout: 10_000 })
|
||||
await materialCard.click()
|
||||
// 验证选中:卡片应出现勾选标记 ✓
|
||||
await expect(materialCard.getByText("✓")).toBeVisible({ timeout: 5_000 })
|
||||
await materialCard.click({ position: { x: 15, y: 15 } })
|
||||
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
|
||||
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
|
||||
@@ -91,6 +91,7 @@ export async function createClipsFromAssets(
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
@@ -99,9 +100,11 @@ export async function createClipsFromAssets(
|
||||
if (requiredClipsCount !== undefined) {
|
||||
body.required_clips_count = requiredClipsCount
|
||||
}
|
||||
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
body,
|
||||
{ timeout: 60000, signal: opts?.signal },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import React, { useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import { MODE_LIST } from "./constants"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
|
||||
import MediaPanel from "./components/MediaPanel"
|
||||
import PreviewPlayer from "./components/PreviewPlayer"
|
||||
import TimelinePanel from "./components/TimelinePanel"
|
||||
@@ -79,14 +77,6 @@ const EditingPlanner: React.FC = () => {
|
||||
/* ── 右侧栏 Tab ── */
|
||||
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([])
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
|
||||
|
||||
const handleAssetSelect = (ids: string[]) => {
|
||||
setSelectedAssetIds(ids)
|
||||
}
|
||||
|
||||
/* ── 配音素材 ── */
|
||||
const {
|
||||
voiceMaterials,
|
||||
@@ -113,7 +103,6 @@ const EditingPlanner: React.FC = () => {
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId: clipOps.setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
@@ -164,9 +153,6 @@ const EditingPlanner: React.FC = () => {
|
||||
onLoadTemplate={tpl.handleLoadTemplate}
|
||||
onSearchChange={tpl.setSearchQuery}
|
||||
onFilterChange={tpl.setCurrentFilter}
|
||||
mediaAssets={mediaAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
* 左侧面板 — 模板列表
|
||||
* 模板编辑器只负责定义模板规则(片段数量、时长范围),不承载素材管理。
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { MODE_LABELS } from "@/api/editing-planner"
|
||||
import type { MediaAsset } from "@/api/template-editor"
|
||||
import AssetSelector from "@/components/asset-selector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[]
|
||||
@@ -18,10 +16,6 @@ interface MediaPanelProps {
|
||||
onLoadTemplate: (id: string) => void
|
||||
onSearchChange: (q: string) => void
|
||||
onFilterChange: (f: string) => void
|
||||
// 素材相关
|
||||
mediaAssets?: MediaAsset[]
|
||||
onAssetSelect?: (ids: string[]) => void
|
||||
selectedAssetIds?: string[]
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
@@ -34,113 +28,73 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
mediaAssets = [],
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates")
|
||||
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📋 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
📁 素材
|
||||
</button>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模板 Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 素材 Tab */}
|
||||
{activeTab === "assets" && (
|
||||
<div className="ep-assets-tab">
|
||||
<AssetSelector
|
||||
assets={mediaAssets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={onAssetSelect}
|
||||
showQualityFilter={false}
|
||||
showBatchSelect={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,13 @@ import {
|
||||
type EditingTemplate,
|
||||
type TemplateCategory,
|
||||
} from "@/api/editing-planner"
|
||||
import { getMediaAssets, type MediaAsset } from "@/api/template-editor"
|
||||
import { FILTER_CATEGORIES } from "../../constants"
|
||||
|
||||
/**
|
||||
* 模板列表 + 分类 + 筛选搜索
|
||||
* 模板编辑器只负责模板规则定义,不再加载/管理业务素材。
|
||||
*/
|
||||
export function useTemplateList(
|
||||
setMediaAssets: (assets: MediaAsset[]) => void,
|
||||
initialTemplateId: string | null,
|
||||
) {
|
||||
export function useTemplateList(initialTemplateId: string | null) {
|
||||
const [templates, setTemplates] = useState<EditingTemplate[]>([])
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [loadingTemplates, setLoadingTemplates] = useState(false)
|
||||
@@ -24,26 +21,20 @@ export function useTemplateList(
|
||||
const [loadedTemplateId, setLoadedTemplateId] = useState<string | null>(initialTemplateId)
|
||||
|
||||
/**
|
||||
* 并行加载模板列表、分类、素材库
|
||||
* 三个接口无依赖关系,用 Promise.all 并发
|
||||
* 并行加载模板列表和分类(两者无依赖关系)
|
||||
*/
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoadingTemplates(true)
|
||||
try {
|
||||
const [tpls, cats, assets] = await Promise.all([
|
||||
getEditingTemplates(),
|
||||
getTemplateCategories(),
|
||||
getMediaAssets(),
|
||||
])
|
||||
const [tpls, cats] = await Promise.all([getEditingTemplates(), getTemplateCategories()])
|
||||
setTemplates(tpls)
|
||||
setCategories(cats)
|
||||
setMediaAssets(assets)
|
||||
} catch {
|
||||
message.error("加载模板失败")
|
||||
} finally {
|
||||
setLoadingTemplates(false)
|
||||
}
|
||||
}, [setMediaAssets])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, type Dispatch, type SetStateAction } from "react"
|
||||
import type { TemplateMode } from "@/api/editing-planner"
|
||||
import type { MediaAsset, TitleConfig } from "@/api/template-editor"
|
||||
import type { TitleConfig } from "@/api/template-editor"
|
||||
import type {
|
||||
ClipData,
|
||||
WatermarkConfig,
|
||||
@@ -24,7 +24,6 @@ interface UseTemplateManagementParams {
|
||||
resetClips: (clips: ClipData[]) => void
|
||||
setClips: (updater: (prev: ClipData[]) => ClipData[]) => void
|
||||
setSelectedClipId: (id: string | null) => void
|
||||
setMediaAssets: (assets: MediaAsset[]) => void
|
||||
setTitleConfig: Dispatch<SetStateAction<TitleConfig>>
|
||||
setSubtitleSettings: Dispatch<SetStateAction<SubtitleStyleConfig>>
|
||||
setBgmSettings: Dispatch<SetStateAction<BgmMixConfig>>
|
||||
@@ -52,7 +51,6 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
resetClips,
|
||||
setClips,
|
||||
setSelectedClipId,
|
||||
setMediaAssets,
|
||||
setTitleConfig,
|
||||
setSubtitleSettings,
|
||||
setBgmSettings,
|
||||
@@ -86,7 +84,7 @@ export const useTemplateManagement = (params: UseTemplateManagementParams) => {
|
||||
filteredTemplates,
|
||||
currentTemplate,
|
||||
loadTemplates,
|
||||
} = useTemplateList(setMediaAssets, urlTemplateId || null)
|
||||
} = useTemplateList(urlTemplateId || null)
|
||||
|
||||
/* ── 保存 ── */
|
||||
const {
|
||||
|
||||
@@ -70,6 +70,7 @@ const MaterialCard: React.FC<{
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="material-card"
|
||||
onClick={handleCardClick}
|
||||
style={{
|
||||
position: "relative",
|
||||
@@ -216,6 +217,8 @@ const MaterialCard: React.FC<{
|
||||
{/* 选中勾选标记(左上角) */}
|
||||
{checked && (
|
||||
<div
|
||||
data-testid="material-card-check"
|
||||
aria-label="已选中"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { type GeneratedVideo, createClipsFromAssets } from "@/api/template-editor"
|
||||
import { type GeneratedVideo, getEditPlanClips } from "@/api/template-editor"
|
||||
import { createGenerationTask } from "@/api/tasks/tasks"
|
||||
import type { UseGenerateVideoProps } from "./generate-video/types"
|
||||
import { getGenerationPhase } from "./generate-video/phase"
|
||||
@@ -13,6 +13,14 @@ import { validateGenerateInputs } from "./generate-video/buildPayload"
|
||||
import { calculateResolution } from "../utils/calculateResolution"
|
||||
import { extractBackendError, translateError } from "./generate-video/errorUtils"
|
||||
|
||||
/**
|
||||
* 片段创建轮询:最多等 30 秒。
|
||||
* useStep2Materials 在用户选素材时(debounce 800ms)已调用 from-assets,
|
||||
* 这里只做轻量校验,确认片段已落库就放行,不死等 ready。
|
||||
*/
|
||||
const CLIPS_CREATED_MAX_WAIT_MS = 30_000
|
||||
const CLIPS_CREATED_POLL_INTERVAL_MS = 1_500
|
||||
|
||||
export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const { selectedTemplate, onGenerationSuccess } = props
|
||||
|
||||
@@ -29,7 +37,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerating(false)
|
||||
setGenerated(true)
|
||||
setGeneratedVideos(videos as GeneratedVideo[])
|
||||
// 生成成功后清除持久化的预览状态,避免下次进入复用旧任务
|
||||
onGenerationSuccess?.()
|
||||
},
|
||||
[onGenerationSuccess],
|
||||
@@ -45,6 +52,28 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
onFailed: handleFailed,
|
||||
})
|
||||
|
||||
/**
|
||||
* 轮询确认片段已被创建。
|
||||
* useStep2Materials 在用户选素材时已调用 from-assets 创建片段,
|
||||
* 这里只要该 plan 下存在任意 clips(无论 ready/pending),就立即放行 generate。
|
||||
* 片段是否 ready 由后端生成流程自行等待/兜底,前端不死等 ready,避免:
|
||||
* 1. MediaKit 失败时前端卡死无法生成
|
||||
* 2. E2E/弱网环境下 generate 请求迟迟不发出
|
||||
* 30s 仍查不到片段也放行,由后端返回明确错误。
|
||||
*/
|
||||
const waitForClipsCreated = useCallback(async (templateId: string): Promise<void> => {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < CLIPS_CREATED_MAX_WAIT_MS) {
|
||||
try {
|
||||
const clipList = await getEditPlanClips(templateId, { limit: 500 })
|
||||
if (clipList.items.length > 0) return
|
||||
} catch {
|
||||
// 单次查询失败不终止,继续轮询
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, CLIPS_CREATED_POLL_INTERVAL_MS))
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 生成视频 ── */
|
||||
const generate = useCallback(async () => {
|
||||
const errorMsg = validateGenerateInputs(props)
|
||||
@@ -60,7 +89,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
clearTimer()
|
||||
|
||||
try {
|
||||
// 解析分辨率(共享工具函数)
|
||||
const { width: outputWidth, height: outputHeight } = calculateResolution(
|
||||
props.videoRatio || "9:16",
|
||||
)
|
||||
@@ -68,27 +96,25 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
const assetIds =
|
||||
props.materialMode === "auto" ? props.smartSelectedIds : props.selectedMaterials
|
||||
|
||||
// 封面 URL:优先 AI 生成缩略图,兜底用户上传
|
||||
// from-assets 已由 useStep2Materials 在用户选素材时(debounce 800ms)调用。
|
||||
// 这里轻量确认片段已落库,再发 generate;最多等 30s,超时也放行。
|
||||
// 片段 ready 状态由后端生成流程兜底,前端不死等。
|
||||
if (assetIds.length > 0 && selectedTemplate) {
|
||||
const hide = message.loading("正在准备素材片段...", 0)
|
||||
try {
|
||||
await waitForClipsCreated(selectedTemplate)
|
||||
} finally {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const coverUrl = props.coverSettings?.thumbnail_url || props.coverSettings?.upload_url || ""
|
||||
|
||||
// 解析配音参数:voice_library_id 是配音素材库 asset ID
|
||||
// 优先用当前 voiceMode 对应的选择,兜底用 selectedVoice(防止 voiceMode 切换后丢失)
|
||||
const voiceLibraryId =
|
||||
props.voiceMode === "clone"
|
||||
? props.selectedClonedVoice || props.selectedVoice || ""
|
||||
: props.selectedVoice || ""
|
||||
|
||||
// 确保片段已创建(显式调用 from-assets,不依赖 useEffect 时机)
|
||||
// useStep2Materials 中也用 selectedTemplate 调用 from-assets,后端通过 template_id 自动关联 plan
|
||||
if (assetIds.length > 0 && selectedTemplate) {
|
||||
try {
|
||||
await createClipsFromAssets(selectedTemplate, assetIds, "main", assetIds.length)
|
||||
} catch (clipErr) {
|
||||
console.warn("[handleGenerate] from-assets 调用失败,继续尝试生成:", clipErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建生成任务(服务器渲染)
|
||||
const taskResp = await createGenerationTask({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
@@ -98,11 +124,8 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
custom_title: props.titleSettings?.title || "",
|
||||
duration: props.duration || undefined,
|
||||
video_ratio: props.videoRatio,
|
||||
// 配音:始终传递 voice_library_id,确保后端能正确接收
|
||||
voice_library_id: voiceLibraryId,
|
||||
// 兜底:如果 voice_library_id 为空但 selectedVoice 有值,也传 voice_ids
|
||||
...(props.selectedVoice && !voiceLibraryId ? { voice_ids: [props.selectedVoice] } : {}),
|
||||
// BGM 配置:受 bgm 开关控制,enabled=false 时也显式传覆盖模板 BGM
|
||||
bgm_config: {
|
||||
enabled: props.bgm !== false,
|
||||
...(props.bgmConfig?.music_id ? { preset_id: props.bgmConfig.music_id } : {}),
|
||||
@@ -138,20 +161,17 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
setGenerateError(finalMsg)
|
||||
message.error(finalMsg)
|
||||
}
|
||||
}, [props, clearTimer, startPolling, selectedTemplate])
|
||||
}, [props, clearTimer, startPolling, selectedTemplate, waitForClipsCreated])
|
||||
|
||||
/* 重新生成(失败后重试) */
|
||||
const retry = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
generate()
|
||||
}, [generate])
|
||||
|
||||
/* 清除错误 */
|
||||
const dismissError = useCallback(() => {
|
||||
setGenerateError(null)
|
||||
}, [])
|
||||
|
||||
/* ── 下载视频 ── */
|
||||
const download = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
@@ -172,7 +192,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}
|
||||
}, [generatedVideos])
|
||||
|
||||
/* ── 分享视频 ── */
|
||||
const share = useCallback(async () => {
|
||||
if (!generatedVideos.length) return
|
||||
const video = generatedVideos[0]
|
||||
@@ -186,19 +205,16 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
}, [generatedVideos])
|
||||
|
||||
return {
|
||||
// 状态
|
||||
generating,
|
||||
progress,
|
||||
generated,
|
||||
generateError,
|
||||
generatedVideos,
|
||||
// 操作
|
||||
generate,
|
||||
retry,
|
||||
dismissError,
|
||||
download,
|
||||
share,
|
||||
// 工具
|
||||
getGenerationPhase,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { message } from "antd"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor"
|
||||
@@ -112,8 +113,10 @@ export function useStep2Materials({
|
||||
try {
|
||||
// 1. 清空旧片段
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 2. 调用后端 from-assets 接口创建片段
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount)
|
||||
// 2. 调用后端 from-assets 接口创建片段(60s 超时,与后端 MediaKit 一致)
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
// 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用
|
||||
const clipList = await getEditPlanClips(tid, { limit: 500 })
|
||||
const readyClips = clipList.items
|
||||
@@ -122,9 +125,16 @@ export function useStep2Materials({
|
||||
onServerClipsChangeRef.current?.(readyClips)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
// 用户切换素材导致的主动取消,静默
|
||||
if (name === "CanceledError" || name === "AbortError") return
|
||||
// from-assets 60s 超时(MediaKit 智能选片未完成)
|
||||
const code = (err as { code?: string })?.code
|
||||
if (code === "ECONNABORTED" || /timeout/i.test((err as Error)?.message || "")) {
|
||||
console.warn("[useStep2Materials] 智能选片超时:", err)
|
||||
message.error("智能选片超时,请重试")
|
||||
return
|
||||
}
|
||||
console.warn("[useStep2Materials] 写入 clips 失败:", err)
|
||||
}
|
||||
}, 800)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user