diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index fd9787faa..a2f377d64 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -74,6 +74,8 @@ const GeneratePage: React.FC = () => { setPreviewTaskId, storedSourceEditPlanId, setStoredSourceEditPlanId, + serverClips, + setServerClips, } = formState /* ── 标题样式回调 ── */ @@ -263,6 +265,7 @@ const GeneratePage: React.FC = () => { selectedVoice={selectedVoice} onSelectedVoiceChange={setSelectedVoice} totalVideoDuration={totalVideoDuration} + onServerClipsChange={setServerClips} voiceMode={voiceMode} onVoiceModeChange={setVoiceMode} selectedClonedVoice={selectedClonedVoice} @@ -301,6 +304,7 @@ const GeneratePage: React.FC = () => { template={currentTemplate} videoRatio={videoRatio} ready={previewAssets.length > 0} + serverClips={serverClips} voiceAudioUrl={previewVoiceAudioUrl || undefined} titleSettings={{ title: titleSettings.title, diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index db5c66e8d..1d3199df0 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -16,6 +16,7 @@ import { } from "@ant-design/icons" import type { AssetItem } from "@/api/assets" import type { EditingTemplate } from "@/api/editing-planner" +import type { EditPlanClip } from "@/api/template-editor" import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler" import { useCanvasPlayer } from "../hooks/useCanvasPlayer" @@ -24,6 +25,7 @@ interface FrontendPreviewPlayerProps { template: EditingTemplate | null videoRatio: string ready: boolean + serverClips?: EditPlanClip[] voiceAudioUrl?: string titleSettings?: { title: string @@ -50,9 +52,31 @@ function formatTime(seconds: number): string { function buildPlaybackSegments( assets: AssetItem[], template: EditingTemplate | null, + serverClips?: EditPlanClip[], ): PlaybackSegment[] { if (!assets.length) return [] + // Build asset lookup map + const assetMap = new Map(assets.map((a) => [a.id, a])) + + // 优先使用服务端 clips(含随机 start_time 和正确数量),与最终生成结果一致 + if (serverClips && serverClips.length > 0) { + const segments: PlaybackSegment[] = [] + for (const clip of serverClips) { + const asset = assetMap.get(clip.asset_id) + if (!asset) continue + const assetDuration = asset.duration || asset.metadata?.duration || 30 + const startTime = clip.start_time || 0 + const endTime = Math.min(startTime + (clip.duration || assetDuration), assetDuration) + const videoUrl = asset.file_url || asset.storage_key + segments.push({ assetId: asset.id, videoUrl, startTime, endTime, order: clip.order }) + } + if (segments.length > 0) { + return segments.sort((a, b) => a.order - b.order) + } + } + + // Fallback: 本地构建片段(与旧行为一致) const templateSegments = template?.segments || [] const segments: PlaybackSegment[] = [] @@ -78,10 +102,14 @@ const FrontendPreviewPlayer: React.FC = ({ template, videoRatio, ready, + serverClips, voiceAudioUrl, titleSettings, }) => { - const segments = useMemo(() => buildPlaybackSegments(assets, template), [assets, template]) + const segments = useMemo( + () => buildPlaybackSegments(assets, template, serverClips), + [assets, template, serverClips], + ) // ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ── const TITLE_MARGIN_TOP = 120 diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 3e640b3fe..40d2351ab 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -7,6 +7,7 @@ */ import React from "react" import type { EditingTemplate } from "@/api/editing-planner" +import type { EditPlanClip } from "@/api/template-editor" import type { PresetVoiceItem } from "@/api/voices" import type { VoiceClone } from "@/api/voice-clone" import type { CoverConfig } from "../types/cover" @@ -55,6 +56,7 @@ export interface GenerateStepContentProps { selectedVoice: string onSelectedVoiceChange: (id: string) => void totalVideoDuration?: number + onServerClipsChange: (clips: EditPlanClip[]) => void voiceMode: "preset" | "custom" | "clone" onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void selectedClonedVoice: string @@ -114,6 +116,7 @@ export const GenerateStepContent: React.FC = (props) = selectedVoice, onSelectedVoiceChange, totalVideoDuration, + onServerClipsChange, voiceMode, selectedClonedVoice, clonedVoices, @@ -155,6 +158,7 @@ export const GenerateStepContent: React.FC = (props) = onSmartSelectedIdsChange={onSmartSelectedIdsChange} selectedTemplate={selectedTemplate} templateSegments={templateSegments} + onServerClipsChange={onServerClipsChange} /> ) case 3: diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index b074dc1af..5b34ab333 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -3,6 +3,7 @@ */ import React from "react" import type { TemplateSegment } from "@/api/templates/types" +import type { EditPlanClip } from "@/api/template-editor" import { useStep2Materials } from "../hooks/useStep2Materials" import MaterialModeTabs from "./material/MaterialModeTabs" import ManualMaterialList from "./material/ManualMaterialList" @@ -20,6 +21,8 @@ interface Step2MaterialSelectProps { selectedTemplate?: string /** 当前模板的 segments(用于构建 clips duration) */ templateSegments?: TemplateSegment[] + /** 服务端 clips 创建成功后的回调 */ + onServerClipsChange?: (clips: EditPlanClip[]) => void } const Step2MaterialSelect: React.FC = (props) => { diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index 7b969c13d..1c469fc2d 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -5,6 +5,7 @@ import { useState } from "react" import { useSearchParams } from "react-router-dom" import type { EditingTemplate } from "@/api/editing-planner" +import type { EditPlanClip } from "@/api/template-editor" import type { CoverConfig } from "../../types/cover" import type { PresetVoiceItem } from "@/api/voices" import { DEFAULT_COVER_SETTINGS } from "../../constants" @@ -46,6 +47,10 @@ export interface GenerateFormState { smartSelectedIds: string[] setSmartSelectedIds: (ids: string[]) => void + /* 服务端片段(/clips/from-assets 创建后获取) */ + serverClips: EditPlanClip[] + setServerClips: (clips: EditPlanClip[]) => void + /* 标题 */ titleSettings: TitleSettings setTitleSettings: (settings: TitleSettings | ((prev: TitleSettings) => TitleSettings)) => void @@ -116,6 +121,9 @@ export const useGenerateFormState = (): GenerateFormState => { const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") const [smartSelectedIds, setSmartSelectedIds] = useState([]) + /* ── 服务端片段(供预览播放器使用)── */ + const [serverClips, setServerClips] = useState([]) + /* ── 标题设置 ── */ const [titleSettings, setTitleSettings] = useState(DEFAULT_TITLE_SETTINGS) @@ -196,6 +204,8 @@ export const useGenerateFormState = (): GenerateFormState => { setMaterialMode, smartSelectedIds, setSmartSelectedIds, + serverClips, + setServerClips, titleSettings, setTitleSettings, coverSettings, diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 21c7c9f38..3aa5dbac1 100755 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -4,7 +4,8 @@ */ import { useCallback, useEffect, useRef } from "react" import type { TemplateSegment } from "@/api/templates/types" -import { updateEditPlanClips, createClipsFromAssets } from "@/api/template-editor" +import type { EditPlanClip } from "@/api/template-editor" +import { updateEditPlanClips, createClipsFromAssets, getEditPlanClips } from "@/api/template-editor" import { formatDuration } from "../utils/formatDuration" import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" import { useSmartMatch } from "./step2-materials/useSmartMatch" @@ -21,6 +22,8 @@ interface UseStep2MaterialsProps { selectedTemplate?: string /** 当前模板的 segments(用于构建 clips duration) */ templateSegments?: TemplateSegment[] + /** 服务端 clips 创建成功后的回调,用于通知预览播放器 */ + onServerClipsChange?: (clips: EditPlanClip[]) => void } export function useStep2Materials({ @@ -32,6 +35,7 @@ export function useStep2Materials({ onSmartSelectedIdsChange, selectedTemplate, templateSegments, + onServerClipsChange, }: UseStep2MaterialsProps) { const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } = useMaterialLibrary() @@ -83,12 +87,17 @@ export function useStep2Materials({ templateSegmentsRef.current = templateSegments const selectedTemplateRef = useRef(selectedTemplate) selectedTemplateRef.current = selectedTemplate + const onServerClipsChangeRef = useRef(onServerClipsChange) + onServerClipsChangeRef.current = onServerClipsChange useEffect(() => { const tid = selectedTemplateRef.current if (!tid) return const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials - if (!ids.length) return + if (!ids.length) { + onServerClipsChangeRef.current?.([]) + return + } if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current) clipsTimerRef.current = setTimeout(async () => { @@ -105,6 +114,12 @@ export function useStep2Materials({ await updateEditPlanClips(tid, [], controller.signal) // 2. 调用后端 from-assets 接口创建片段 await createClipsFromAssets(tid, ids, "main", requiredClipsCount) + // 3. 获取服务端生成的 clips(含 start_time/duration),供预览播放器使用 + const clipList = await getEditPlanClips(tid, { limit: 500 }) + const readyClips = clipList.items + .filter((c) => c.status === "ready") + .sort((a, b) => a.order - b.order) + onServerClipsChangeRef.current?.(readyClips) } catch (err) { const name = (err as { name?: string })?.name if (name !== "CanceledError" && name !== "AbortError") {