refactor(generate): remove frontend video preview & AI title generation, adjust step3 layout
Task 1 - Delete all frontend video preview code: - Remove FrontendPreviewPlayer, CanvasPreviewGrid, PreviewControls, PreviewProgressBar - Remove usePreviewAssets, usePreviewAudio, useVariantVoicePreview, useSegmentScheduler hooks - Clean up GeneratePage: remove preview state/effects/callbacks/render block - Remove onServerClipsChange prop chain (GenerateStepContent → Step2 still optional) - Layout: currentStep===3 now uses full-width (no more step4-layout dual column) Task 2 - Delete AI title generation: - Remove AiTitleGenerator, AiTitleCard components - Remove useAiTitleGenerator hook - Remove AI_TITLE_TEMPLATES from constants.ts - Remove AiTitleResult type from types.ts - Clean up Step4TitleSettings: remove AI toggle, AI generate buttons, batch AI row - Clean up useStep4Title: remove all aiTitle* fields and auto-generate effect Task 3 - Step3 title page layout adjustment: - generate.css: remove step4-layout grid, preview-col rules, title-sidebar max-height - Step4TitleSettings: title inputs max-width 640px, template grid uses full width - TitleStylePanel with enableTemplates gets 4-5 columns via auto-fill responsive grid Preserved: useBatchVariantPlans (used by useGenerateVideo), PreviewCountModal, aiAutoSelect field in TitleSettings (used by buildPayload/ai-avatar), TitleMiniPreview (shared with TitleStylePanel for AI数字人)
This commit is contained in:
@@ -8,15 +8,12 @@ import React, { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
import VoiceSelectModal from "./components/VoiceSelectModal"
|
||||
import ScriptSelectModal from "./components/ScriptSelectModal"
|
||||
import TtsVoiceModal from "./components/TtsVoiceModal"
|
||||
import GenerateHeader from "./components/GenerateHeader"
|
||||
import FrontendPreviewPlayer from "./components/FrontendPreviewPlayer"
|
||||
import CanvasPreviewGrid from "./components/CanvasPreviewGrid"
|
||||
import PreviewCountModal from "./components/PreviewCountModal"
|
||||
import GenerateStepsBar from "./components/GenerateStepsBar"
|
||||
import GenerateStepContent from "./components/GenerateStepContent"
|
||||
@@ -25,12 +22,8 @@ import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
import { useVariantVoicePreview } from "./hooks/useVariantVoicePreview"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
import { usePointsStore } from "@/store/pointsStore"
|
||||
import { hasEnoughPoints } from "./hooks/pointsCost"
|
||||
import { ENABLE_CREDIT_SYSTEM } from "@/config/features"
|
||||
@@ -101,8 +94,6 @@ const GeneratePage: React.FC = () => {
|
||||
setPreviewTaskId,
|
||||
storedSourceEditPlanId,
|
||||
setStoredSourceEditPlanId,
|
||||
serverClips,
|
||||
setServerClips,
|
||||
previewCount,
|
||||
setPreviewCount,
|
||||
previewTitles,
|
||||
@@ -159,62 +150,6 @@ const GeneratePage: React.FC = () => {
|
||||
onTitleSettingsChange: setTitleSettings,
|
||||
})
|
||||
|
||||
/* ── 配音素材库(TTS 试听)── */
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const [previewVoiceAudioUrl, setPreviewVoiceAudioUrl] = useState<string | null>(null)
|
||||
const ttsAbortRef = useRef<AbortController | null>(null)
|
||||
const variant0Title = isBatch ? previewTitles?.[0] || "" : ""
|
||||
|
||||
useEffect(() => {
|
||||
if (isBatch) return
|
||||
const voiceAsset = voiceMaterials.find((m) => m.id === selectedVoice)
|
||||
if (voiceAsset?.file_url) {
|
||||
setPreviewVoiceAudioUrl(voiceAsset.file_url)
|
||||
return
|
||||
}
|
||||
|
||||
const ttsTitle = titleSettings.title
|
||||
const voiceId = selectedClonedVoice || selectedVoice
|
||||
if (!voiceId || !ttsTitle) {
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
ttsAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
ttsAbortRef.current = controller
|
||||
let cancelled = false
|
||||
|
||||
previewTts({ text: ttsTitle, voice_id: voiceId })
|
||||
.then((res) => {
|
||||
if (!cancelled && res.audio_url) {
|
||||
setPreviewVoiceAudioUrl(res.audio_url)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.warn("[预览配音生成失败]", err)
|
||||
setPreviewVoiceAudioUrl(null)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
titleSettings.title,
|
||||
variant0Title,
|
||||
isBatch,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
/* ── 克隆声音 ── */
|
||||
const { addClone } = useCloneProgress()
|
||||
|
||||
@@ -239,30 +174,11 @@ const GeneratePage: React.FC = () => {
|
||||
[bgm],
|
||||
)
|
||||
|
||||
/* ── 加载素材详情 ── */
|
||||
const previewAssetsEnabled = previewAssetIds.length > 0
|
||||
const {
|
||||
assets: previewAssets,
|
||||
ready: previewAssetsReady,
|
||||
ensureAssets,
|
||||
} = usePreviewAssets(previewAssetIds, previewAssetsEnabled)
|
||||
|
||||
/* ── 预览就绪 ── */
|
||||
// #1899: 不再依赖 currentTemplate,素材加载完即可预览
|
||||
const previewReady = previewAssetsReady
|
||||
|
||||
/* ── 批量变体真实片段(#1744) ── */
|
||||
const batchVoiceLibraryId =
|
||||
voiceMode === "clone" ? selectedClonedVoice || selectedVoice || "" : selectedVoice || ""
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
voiceDurationsByVariant: variantVoiceDurations,
|
||||
loading: variantClipsLoading,
|
||||
error: variantClipsError,
|
||||
retry: retryVariantClips,
|
||||
} = useBatchVariantPlans({
|
||||
enabled: isBatch && currentStep === 3 && previewAssetsReady,
|
||||
const { planIdsByVariant: variantPlanIds } = useBatchVariantPlans({
|
||||
enabled: isBatch && currentStep === 3,
|
||||
count: previewCount,
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
@@ -272,39 +188,6 @@ const GeneratePage: React.FC = () => {
|
||||
voiceModePerVideo,
|
||||
})
|
||||
|
||||
/* ── 批量变体配音预览 URL ── */
|
||||
const variantVoiceAudioUrls = useVariantVoicePreview({
|
||||
enabled: isBatch,
|
||||
count: previewCount,
|
||||
perVideo: voiceModePerVideo,
|
||||
sharedVoiceId: selectedVoice || "",
|
||||
clonedVoiceId: selectedClonedVoice || "",
|
||||
variantVoiceIds: voiceLibraryIds || [],
|
||||
titles: previewTitles || [],
|
||||
})
|
||||
|
||||
/* ── 变体 clips 引用素材补拉 ── */
|
||||
const clipAssetIds = useMemo(() => {
|
||||
if (!isBatch || !variantClips?.length) return []
|
||||
const ids = new Set<string>()
|
||||
variantClips.forEach((list) => list.forEach((c) => c.asset_id && ids.add(c.asset_id)))
|
||||
return Array.from(ids)
|
||||
}, [isBatch, variantClips])
|
||||
useEffect(() => {
|
||||
if (clipAssetIds.length > 0) void ensureAssets(clipAssetIds)
|
||||
}, [clipAssetIds, ensureAssets])
|
||||
|
||||
/* ── 勾选变体 ── */
|
||||
const toggleVariantSelect = useCallback(
|
||||
(index: number) => {
|
||||
setSelectedVariantIds((prev) => {
|
||||
const list = prev || []
|
||||
return list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
|
||||
})
|
||||
},
|
||||
[setSelectedVariantIds],
|
||||
)
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
const {
|
||||
generating,
|
||||
@@ -484,10 +367,6 @@ const GeneratePage: React.FC = () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!previewReady) {
|
||||
message.warning("预览素材正在加载,请稍候")
|
||||
return
|
||||
}
|
||||
const ok = await handleGenerate()
|
||||
if (ok) {
|
||||
setCurrentStep(4)
|
||||
@@ -498,7 +377,6 @@ const GeneratePage: React.FC = () => {
|
||||
previewTitles,
|
||||
titleSettings.aiAutoSelect,
|
||||
titleSettings.title,
|
||||
previewReady,
|
||||
handleGenerate,
|
||||
setCurrentStep,
|
||||
balance,
|
||||
@@ -530,10 +408,7 @@ const GeneratePage: React.FC = () => {
|
||||
const finalVideo = generatedVideos[0]
|
||||
|
||||
/* ── 布局 class ── */
|
||||
const layoutClassName = useMemo(() => {
|
||||
if (currentStep === 3) return "xx-generate-layout step4-layout"
|
||||
return "xx-generate-layout full-width"
|
||||
}, [currentStep])
|
||||
const layoutClassName = "xx-generate-layout full-width"
|
||||
|
||||
/* ── 积分消耗估算(步骤3确认生成展示用) ── */
|
||||
const unitsForCost = isBatch ? Math.max(selectedVariantIds.length, 1) : 1
|
||||
@@ -561,78 +436,7 @@ const GeneratePage: React.FC = () => {
|
||||
<GenerateStepsBar currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
<div className={layoutClassName}>
|
||||
{/* ════ 步骤3:左侧预览大区域 ════ */}
|
||||
{currentStep === 3 && (
|
||||
<div className="xx-generate-preview-col">
|
||||
{!isBatch ? (
|
||||
<FrontendPreviewPlayer
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
ready={previewAssets.length > 0}
|
||||
serverClips={serverClips}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
titleSettings={{
|
||||
title: titleSettings.title,
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
strokeWidth: titleSettings.strokeWidth,
|
||||
strokeColor: titleSettings.strokeColor,
|
||||
shadow: titleSettings.shadow,
|
||||
shadowOffsetX: titleSettings.shadowOffsetX,
|
||||
shadowOffsetY: titleSettings.shadowOffsetY,
|
||||
shadowBlur: titleSettings.shadowBlur,
|
||||
shadowColor: titleSettings.shadowColor,
|
||||
bgEnabled: titleSettings.bgEnabled,
|
||||
bgColor: titleSettings.bgColor,
|
||||
bgPadding: titleSettings.bgPadding,
|
||||
bgRadius: titleSettings.bgRadius,
|
||||
lineHeight: titleSettings.lineHeight,
|
||||
maxCharsPerLine: titleSettings.maxCharsPerLine,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
onTitlePositionChange={styleUpdaters.updateTitlePosition}
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-form-section">
|
||||
<div className="xx-preview-header">
|
||||
<h3>🎬 {previewCount} 个视频预览</h3>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
实时预览,勾选要生成的视频
|
||||
</span>
|
||||
</div>
|
||||
<CanvasPreviewGrid
|
||||
count={previewCount}
|
||||
assets={previewAssets}
|
||||
videoRatio={videoRatio as "9:16" | "16:9"}
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrls={variantVoiceAudioUrls}
|
||||
voiceDurations={variantVoiceDurations}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
clipsError={variantClipsError}
|
||||
onRetryClips={retryVariantClips}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ════ 右侧:步骤1~2 表单 / 步骤3 标题边栏 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
|
||||
{/* ════ 步骤1~2 表单 / 步骤3 标题设置 / 步骤4 确认生成进度 / 步骤5 封面 ════ */}
|
||||
<div className="xx-generate-form">
|
||||
<GenerateStepContent
|
||||
currentStep={currentStep}
|
||||
@@ -680,7 +484,6 @@ const GeneratePage: React.FC = () => {
|
||||
ttsVoiceId={ttsVoiceId}
|
||||
ttsVoiceSource={ttsVoiceSource}
|
||||
onSelectedVoiceChange={setSelectedVoice}
|
||||
onServerClipsChange={setServerClips}
|
||||
generating={generating}
|
||||
generated={generated}
|
||||
generateError={generateError}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 起,#1750 对齐基线:只播放后端真实计划)
|
||||
*
|
||||
* N 个 FrontendPreviewPlayer 网格排列:
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口;
|
||||
* - #1744/#1750:片段只来自后端变体计划接口(POST /generation/variant-plans)的真实
|
||||
* clips,与正式批量生成同源自 reselect_plan_for_variant,预览即成片;
|
||||
* 接口失败/数据不完整 → 整网格显示错误态+重试(严禁本地假数据冒充预览);
|
||||
* 加载中 → 9:16 占位防塌陷;
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用;
|
||||
* - 勾选框决定提交时生成哪些变体;
|
||||
* - 每个变体挂载各自配音 URL(独立模式 #1750)或共用同一条;播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)。
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { LoadingOutlined, ReloadOutlined } from "@ant-design/icons"
|
||||
import { Button } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
interface CanvasPreviewGridProps {
|
||||
count: number
|
||||
assets: AssetItem[]
|
||||
videoRatio: string
|
||||
titles: string[]
|
||||
titleSettings: TitleSettings
|
||||
/**
|
||||
* 各变体配音预览音频 URL(#1750:独立配音模式每变体一条;共用模式全为同一条;
|
||||
* 元素为 null 表示该变体暂无音频(AI 音色 TTS 合成中))
|
||||
*/
|
||||
voiceAudioUrls?: (string | null)[]
|
||||
/**
|
||||
* 各变体配音时长(秒):后端返回 voice_duration 优先;未返回则为 undefined,
|
||||
* 由 FrontendPreviewPlayer 在 audio loadedmetadata 时自测兜底。
|
||||
* 长度=count,undefined 项表示该变体未提供后端时长。
|
||||
*/
|
||||
voiceDurations?: (number | undefined)[]
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744/#1750):长度=count。
|
||||
* 仅 clipsLoading=false 且 clipsError=false 时才会传给播放器。
|
||||
*/
|
||||
variantClips?: EditPlanClip[][]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
clipsLoading?: boolean
|
||||
/** 申请变体计划失败(端点未上线/网络错误/数据不完整):显示错误态,严禁假数据 */
|
||||
clipsError?: boolean
|
||||
/** 用户点击错误态「重试」 */
|
||||
onRetryClips?: () => void
|
||||
/** 勾选的变体序号 */
|
||||
selectedIds: number[]
|
||||
onToggleSelect: (index: number) => void
|
||||
/** 生成中禁止勾选 */
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
count,
|
||||
assets,
|
||||
videoRatio,
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrls,
|
||||
voiceDurations,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
clipsError = false,
|
||||
onRetryClips,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权(token = 变体序号,0 起,#1750) ──
|
||||
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
|
||||
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
|
||||
return (
|
||||
<div className="xx-canvas-grid">
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const checked = selectedIds.includes(i)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`xx-canvas-grid-card${checked ? " selected" : ""}`}
|
||||
data-variant={i}
|
||||
>
|
||||
<div className="xx-canvas-grid-card-bar">
|
||||
<label className="xx-canvas-grid-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!selectable}
|
||||
onChange={() => onToggleSelect(i)}
|
||||
/>
|
||||
<span>视频 {i + 1}</span>
|
||||
</label>
|
||||
</div>
|
||||
{clipsError ? (
|
||||
// ── 错误态(#1750):9:16 占位防塌陷,不渲染任何播放器(严禁假数据) ──
|
||||
<div className="xx-variant-clips-status" role="alert">
|
||||
<span className="xx-variant-clips-error-text">预览加载失败,请重试</span>
|
||||
{i === 0 && onRetryClips ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={onRetryClips}
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : clipsLoading || !variantClips?.[i]?.length ? (
|
||||
// ── 加载态:9:16 占位防塌陷 ──
|
||||
<div className="xx-variant-clips-status" aria-label={`变体${i + 1}片段加载中`}>
|
||||
<LoadingOutlined />
|
||||
<span style={{ marginTop: 8 }}>独立选片中…</span>
|
||||
</div>
|
||||
) : (
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 0}
|
||||
playToken={i}
|
||||
serverClips={variantClips[i]}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={voiceAudioUrls?.[i] || undefined}
|
||||
voiceDurationHint={voiceDurations?.[i]}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
size: titleSettings.size,
|
||||
font: titleSettings.font,
|
||||
color: titleSettings.color,
|
||||
position: titleSettings.position as "top" | "center" | "bottom" | "custom",
|
||||
bold: titleSettings.bold,
|
||||
italic: titleSettings.italic,
|
||||
stroke: titleSettings.stroke,
|
||||
strokeWidth: titleSettings.strokeWidth,
|
||||
strokeColor: titleSettings.strokeColor,
|
||||
shadow: titleSettings.shadow,
|
||||
shadowOffsetX: titleSettings.shadowOffsetX,
|
||||
shadowOffsetY: titleSettings.shadowOffsetY,
|
||||
shadowBlur: titleSettings.shadowBlur,
|
||||
shadowColor: titleSettings.shadowColor,
|
||||
bgEnabled: titleSettings.bgEnabled,
|
||||
bgColor: titleSettings.bgColor,
|
||||
bgPadding: titleSettings.bgPadding,
|
||||
bgRadius: titleSettings.bgRadius,
|
||||
lineHeight: titleSettings.lineHeight,
|
||||
maxCharsPerLine: titleSettings.maxCharsPerLine,
|
||||
posX: titleSettings.posX,
|
||||
posY: titleSettings.posY,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CanvasPreviewGrid
|
||||
@@ -1,633 +0,0 @@
|
||||
/**
|
||||
* 前端预览播放器 — 原生 Video 元素方案(浏览器硬件解码,独立线程,不阻塞 UI)
|
||||
*
|
||||
* 架构:
|
||||
* - 默认走原生 video 元素多片段切换播放(useSegmentScheduler 调度),
|
||||
* 叠加标题 CSS 浮层、配音音轨(usePreviewAudio)、尾段冻结看门狗、批量播放互斥 token。
|
||||
* UI 拆分为 PreviewControls(控制条/按钮) + PreviewProgressBar(进度条)两个子组件。
|
||||
* - WebCodecs 路径已废弃(原 useWebCodecs 常量恒为 false,相关死代码已移除),
|
||||
* 保留 useCanvasPlayer hook 文件供未来兜底(不影响当前打包体积)。
|
||||
*
|
||||
* 对外 API 完全不变:assets / videoRatio / ready / voiceAudioUrl / serverClips 等。
|
||||
*/
|
||||
import React, { useMemo, useCallback, useState, useRef, useEffect } from "react"
|
||||
import { PlayCircleOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { useSegmentScheduler, type PlaybackSegment } from "../hooks/useSegmentScheduler"
|
||||
import { usePreviewAudio } from "../hooks/usePreviewAudio"
|
||||
import { PreviewControls } from "./PreviewControls"
|
||||
import { getFontFamily } from "@/components/title/constants"
|
||||
|
||||
interface FrontendPreviewPlayerProps {
|
||||
assets: AssetItem[]
|
||||
videoRatio: string
|
||||
ready: boolean
|
||||
/** 服务端变体计划真实片段(#1750:必填,无 fallback;批量网格传入各变体自己的 clips) */
|
||||
serverClips?: EditPlanClip[]
|
||||
voiceAudioUrl?: string
|
||||
titleSettings?: {
|
||||
title: string
|
||||
size: number
|
||||
font: string
|
||||
color: string
|
||||
position: "top" | "center" | "bottom" | "custom"
|
||||
bold?: boolean
|
||||
italic?: boolean
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
strokeWidth?: number
|
||||
strokeColor?: string
|
||||
shadowOffsetX?: number
|
||||
shadowOffsetY?: number
|
||||
shadowBlur?: number
|
||||
shadowColor?: string
|
||||
bgEnabled?: boolean
|
||||
bgColor?: string
|
||||
bgPadding?: number
|
||||
bgRadius?: number
|
||||
lineHeight?: number
|
||||
maxCharsPerLine?: number
|
||||
posX?: number | null
|
||||
posY?: number | null
|
||||
}
|
||||
onTitlePositionChange?: (posX: number, posY: number) => void
|
||||
/**
|
||||
* 播放互斥 token(#1750):批量网格中为变体序号(0 起),单视频不传。
|
||||
* 持有播放权的实例播放,其余自动暂停。
|
||||
*/
|
||||
playToken?: number
|
||||
/** 变体标题文字(批量时每个预览独立标题,叠加在画面上);不传用 titleSettings.title */
|
||||
variantTitle?: string
|
||||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||||
compact?: boolean
|
||||
/**
|
||||
* 批量网格播放互斥(#1741/#1750):当前持有播放权的实例 token(变体序号)。
|
||||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||||
*/
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 playToken,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
/**
|
||||
* 后端返回的配音时长(秒)P0 对齐:优先以该值作为音画时长锚点;
|
||||
* 未提供则在 audio loadedmetadata 后自测兜底。
|
||||
*/
|
||||
voiceDurationHint?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端变体计划 clips 映射为播放片段(#1750:唯一数据来源,无本地模拟 fallback)
|
||||
*
|
||||
* 预览不渲染:浏览器按后端真实计划即时播放素材原片,预览即成片。
|
||||
* 模板片段数固定、成片时长=配音时长(后端等比分配),前端不再有任何时长假设。
|
||||
*/
|
||||
function buildPlaybackSegments(
|
||||
assets: AssetItem[],
|
||||
serverClips?: EditPlanClip[],
|
||||
/** #1754 前端兜底:配音时长≠clips 总时长时,按比例缩放每段播放时长并调速 */
|
||||
speedFactor = 1,
|
||||
): PlaybackSegment[] {
|
||||
if (!assets.length || !serverClips || serverClips.length === 0) return []
|
||||
|
||||
const assetMap = new Map(assets.map((a) => [a.id, a]))
|
||||
const segments: PlaybackSegment[] = []
|
||||
// speedFactor > 1 表示 clips 偏短需加速;< 1 表示 clips 偏长需减速
|
||||
const invSpeed = speedFactor > 0 && Math.abs(speedFactor - 1) > 0.01 ? 1 / speedFactor : 1
|
||||
for (const clip of serverClips) {
|
||||
const asset = assetMap.get(clip.asset_id)
|
||||
if (!asset) continue
|
||||
const assetDuration = asset.duration || asset.metadata?.duration || 0
|
||||
const startTime = clip.start_time || 0
|
||||
// 片段时长以后端计划为准(配音时长等比分配);素材时长仅用于兜底钳制边界
|
||||
const rawClipDuration = clip.duration || 0
|
||||
// #1754:按 speedFactor 缩放片段时长,使总时长匹配配音
|
||||
const clipDuration = invSpeed !== 1 ? rawClipDuration * invSpeed : rawClipDuration
|
||||
const endTime =
|
||||
assetDuration > 0
|
||||
? Math.min(startTime + clipDuration, assetDuration)
|
||||
: startTime + clipDuration
|
||||
const videoUrl = asset.file_url || asset.storage_key
|
||||
segments.push({
|
||||
assetId: asset.id,
|
||||
videoUrl,
|
||||
startTime,
|
||||
endTime,
|
||||
order: clip.order,
|
||||
playbackRate: invSpeed !== 1 ? speedFactor : undefined,
|
||||
})
|
||||
}
|
||||
return segments.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
|
||||
const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
assets,
|
||||
videoRatio,
|
||||
ready,
|
||||
serverClips,
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
titleSettings,
|
||||
onTitlePositionChange,
|
||||
playToken,
|
||||
variantTitle,
|
||||
compact = false,
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
// #1754→P0:配音时长作为音画时长锚点。
|
||||
// 优先使用后端返回的 voiceDurationHint;音频 loadedmetadata 后再以自测值覆盖(更精确)。
|
||||
const [voiceDuration, setVoiceDuration] = useState<number>(() =>
|
||||
voiceDurationHint && voiceDurationHint > 0 ? voiceDurationHint : 0,
|
||||
)
|
||||
|
||||
// #1756:clips 原始总时长 + 转场时长(后端等比分配配音时包含转场占位)
|
||||
const rawClipsDuration = useMemo(() => {
|
||||
if (!serverClips?.length) return 0
|
||||
return serverClips.reduce((sum, c) => sum + (c.duration || 0) + (c.transition_duration || 0), 0)
|
||||
}, [serverClips])
|
||||
|
||||
// #1754→#1756:配音时长可用且与 clips+转场 总时长偏差 > 5% 时,按比例调速
|
||||
const speedFactor = useMemo(() => {
|
||||
if (!voiceDuration || voiceDuration <= 0 || rawClipsDuration <= 0) return 1
|
||||
const ratio = rawClipsDuration / voiceDuration
|
||||
return Math.abs(ratio - 1) > 0.05 ? ratio : 1
|
||||
}, [voiceDuration, rawClipsDuration])
|
||||
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, serverClips, speedFactor),
|
||||
[assets, serverClips, speedFactor],
|
||||
)
|
||||
// 批量变体:标题文字取 variantTitle,样式仍由全局 titleSettings 控制
|
||||
const effectiveTitle = variantTitle ?? titleSettings?.title
|
||||
|
||||
// ── ASS 坐标系参数(与后端 ass_subtitle_builder.py 一致) ──
|
||||
const TITLE_MARGIN_TOP = 180
|
||||
const TITLE_MARGIN_BOTTOM = 100
|
||||
const TITLE_MARGIN_SIDE = 40
|
||||
const playRes = (() => {
|
||||
switch (videoRatio) {
|
||||
case "16:9":
|
||||
return { width: 1920, height: 1080 }
|
||||
case "1:1":
|
||||
return { width: 1080, height: 1080 }
|
||||
case "9:16":
|
||||
default:
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
})()
|
||||
const customTitleXPct =
|
||||
titleSettings?.posX != null && playRes.width > 0
|
||||
? (titleSettings.posX / playRes.width) * 100
|
||||
: null
|
||||
const customTitleYPct =
|
||||
titleSettings?.posY != null && playRes.height > 0
|
||||
? (titleSettings.posY / playRes.height) * 100
|
||||
: null
|
||||
|
||||
// ── 标题拖拽(用 ref 避免每帧触发 React 重渲染)──
|
||||
const draggingTitleRef = useRef(false)
|
||||
const titleDragRef = useRef<HTMLDivElement>(null)
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null)
|
||||
const handleTitlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!onTitlePositionChange || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
;(e.target as Element).setPointerCapture(e.pointerId)
|
||||
draggingTitleRef.current = true
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grabbing"
|
||||
},
|
||||
[onTitlePositionChange],
|
||||
)
|
||||
const handleTitlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current || !playerContainerRef.current) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (titleDragRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const xpct = (relX / rect.width) * 100
|
||||
const ypct = (relY / rect.height) * 100
|
||||
titleDragRef.current.style.left = `${xpct}%`
|
||||
titleDragRef.current.style.top = `${ypct}%`
|
||||
}
|
||||
}, [])
|
||||
const handleTitlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!draggingTitleRef.current) return
|
||||
draggingTitleRef.current = false
|
||||
if (onTitlePositionChange && playerContainerRef.current) {
|
||||
const rect = playerContainerRef.current.getBoundingClientRect()
|
||||
const relX = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||
const relY = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
|
||||
const posX = Math.round((relX / rect.width) * playRes.width)
|
||||
const posY = Math.round((relY / rect.height) * playRes.height)
|
||||
onTitlePositionChange(posX, posY)
|
||||
}
|
||||
;(e.currentTarget as HTMLDivElement).style.cursor = "grab"
|
||||
try {
|
||||
if ((e.currentTarget as Element).hasPointerCapture(e.pointerId)) {
|
||||
;(e.currentTarget as Element).releasePointerCapture(e.pointerId)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[onTitlePositionChange, playRes.width, playRes.height],
|
||||
)
|
||||
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = playerContainerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height
|
||||
if (h > 0) setContainerHeight(h)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.height > 0) setContainerHeight(rect.height)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// 标题字号按容器高度与 PlayResY 的比例缩放
|
||||
const titleFontSizePx =
|
||||
containerHeight > 0
|
||||
? ((titleSettings?.size ?? 36) / playRes.height) * containerHeight
|
||||
: (titleSettings?.size ?? 36)
|
||||
const titleSidePct = (TITLE_MARGIN_SIDE / playRes.width) * 100
|
||||
const titleTopPct = (TITLE_MARGIN_TOP / playRes.height) * 100
|
||||
const titleBottomPct = (TITLE_MARGIN_BOTTOM / playRes.height) * 100
|
||||
// 标题缩放到容器:字号 px = (settings.size / playRes.height) * containerHeight
|
||||
// 描边/阴影:读 titleSettings 真实值,按容器比例缩放
|
||||
const titleStrokeWidth = Math.max(
|
||||
1,
|
||||
((titleSettings?.strokeWidth ?? 4) / playRes.height) * containerHeight,
|
||||
)
|
||||
const titleStrokeColor = titleSettings?.strokeColor ?? "#000000"
|
||||
const titleShadowBlur = ((titleSettings?.shadowBlur ?? 4) / playRes.height) * containerHeight
|
||||
const titleShadowOffsetX =
|
||||
((titleSettings?.shadowOffsetX ?? 2) / playRes.height) * containerHeight
|
||||
const titleShadowOffsetY =
|
||||
((titleSettings?.shadowOffsetY ?? 2) / playRes.height) * containerHeight
|
||||
const titleShadowColor = titleSettings?.shadowColor ?? "rgba(0,0,0,0.8)"
|
||||
const titleShadowStr = `${titleShadowOffsetX}px ${titleShadowOffsetY}px ${titleShadowBlur}px ${titleShadowColor}`
|
||||
// 标题背景色块
|
||||
const titleBgEnabled = !!titleSettings?.bgEnabled
|
||||
const titleBgColor = titleSettings?.bgColor ?? "rgba(0,0,0,0.5)"
|
||||
const titleBgPadding = ((titleSettings?.bgPadding ?? 12) / playRes.height) * containerHeight
|
||||
const titleBgRadius = ((titleSettings?.bgRadius ?? 8) / playRes.height) * containerHeight
|
||||
|
||||
// ── Video 播放器(默认路径,浏览器原生硬件解码) ──
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
currentSegmentIndex,
|
||||
canPlay,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
pause,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
// P0 fix:以配音时长为音画同步锚点。
|
||||
// 有配音时总时长 = 配音时长(短则末帧冻结,长则硬停);无配音时沿用视频总时长(素材原声兜底)。
|
||||
const effectiveTotalDuration =
|
||||
!!voiceAudioUrl && voiceDuration > 0 ? voiceDuration : totalDuration
|
||||
|
||||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||||
const [muted, setMuted] = useState(false)
|
||||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||||
const hasVoice = !!voiceAudioUrl
|
||||
|
||||
// 音频 ended:兜底触发暂停与释放播放权
|
||||
const handleAudioEnded = useCallback(() => {
|
||||
if (!isPlaying) return
|
||||
pause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [isPlaying, pause, playToken, onPlayTokenChange])
|
||||
|
||||
const {
|
||||
seekTo: audioSeekTo,
|
||||
ensurePlayingAt: audioEnsurePlayingAt,
|
||||
pause: audioPause,
|
||||
} = usePreviewAudio({
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
muted,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onVoiceDurationChange: setVoiceDuration,
|
||||
onEnded: handleAudioEnded,
|
||||
})
|
||||
|
||||
// 片段切换时同步音频时间(video fallback)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
audioSeekTo(currentTime)
|
||||
// 注意:不要把 currentTime 放进依赖数组,否则每200ms会重置音频位置导致卡顿
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSegmentIndex, isPlaying])
|
||||
|
||||
// P0 fix:视频比配音短时的「末帧冻结+音频续播」模式。
|
||||
// 视频调度器播完最后一段自动 pause,此时若配音仍在播,用 rAF 虚拟时钟推进 currentTime 直到配音结束。
|
||||
const [tailCurrentTime, setTailCurrentTime] = useState<number | null>(null)
|
||||
const tailStartRef = useRef<number>(0)
|
||||
const tailBaseRef = useRef<number>(0)
|
||||
const tailAudioRef = useRef({ ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause })
|
||||
tailAudioRef.current = { ensurePlayingAt: audioEnsurePlayingAt, pause: audioPause }
|
||||
|
||||
useEffect(() => {
|
||||
const needTail =
|
||||
!!voiceAudioUrl &&
|
||||
voiceDuration > 0 &&
|
||||
!isPlaying &&
|
||||
typeof currentTime === "number" &&
|
||||
currentTime >= totalDuration - 0.1 &&
|
||||
currentTime < voiceDuration - 0.1
|
||||
if (needTail && tailCurrentTime === null) {
|
||||
tailBaseRef.current = currentTime
|
||||
tailStartRef.current = performance.now()
|
||||
setTailCurrentTime(currentTime)
|
||||
tailAudioRef.current.ensurePlayingAt(currentTime)
|
||||
return
|
||||
}
|
||||
if (!needTail && tailCurrentTime !== null) {
|
||||
setTailCurrentTime(null)
|
||||
}
|
||||
}, [isPlaying, currentTime, totalDuration, voiceDuration, voiceAudioUrl, tailCurrentTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (tailCurrentTime === null) return
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const elapsed = (performance.now() - tailStartRef.current) / 1000
|
||||
const t = Math.min(tailBaseRef.current + elapsed, voiceDuration || tailBaseRef.current)
|
||||
setTailCurrentTime(t)
|
||||
tailAudioRef.current.ensurePlayingAt(t)
|
||||
if (t >= (voiceDuration || 0) - 0.05) {
|
||||
tailAudioRef.current.pause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
setTailCurrentTime(null)
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [tailCurrentTime, voiceDuration, playToken, onPlayTokenChange])
|
||||
|
||||
// 呈现给 UI/进度条的「当前时间」:尾段用虚拟时间,否则用视频时间
|
||||
const displayCurrentTime = tailCurrentTime !== null ? tailCurrentTime : currentTime
|
||||
|
||||
const handleSeekTo = useCallback(
|
||||
(time: number) => {
|
||||
setTailCurrentTime(null)
|
||||
seekTo(time)
|
||||
audioSeekTo(time)
|
||||
},
|
||||
[seekTo, audioSeekTo],
|
||||
)
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停 ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || playToken == null || activePlayToken === playToken) return
|
||||
if (isPlaying) {
|
||||
pause()
|
||||
}
|
||||
// isPlaying 不放依赖:只在 token 变化时执行一次暂停
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, playToken])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (playToken != null) onPlayTokenChange?.(isPlaying ? null : playToken)
|
||||
togglePlayPause()
|
||||
}, [togglePlayPause, isPlaying, playToken, onPlayTokenChange])
|
||||
|
||||
// P0 fix:音画同步看门狗——有配音时播放时间达到配音时长立即暂停视频+音频(末帧冻结)
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return
|
||||
if (!voiceAudioUrl || voiceDuration <= 0) return
|
||||
if (displayCurrentTime < voiceDuration - 0.08) return
|
||||
pause()
|
||||
audioPause()
|
||||
if (playToken != null) onPlayTokenChange?.(null)
|
||||
}, [
|
||||
isPlaying,
|
||||
displayCurrentTime,
|
||||
voiceAudioUrl,
|
||||
voiceDuration,
|
||||
pause,
|
||||
audioPause,
|
||||
playToken,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// ── 未就绪 ──
|
||||
if (!ready || !assets.length) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }} />
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
准备预览素材...
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
加载素材后即可预览播放
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 无播放片段 ──
|
||||
if (!canPlay) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: 280,
|
||||
aspectRatio: "9 / 16",
|
||||
background: "#0a0a0a",
|
||||
borderRadius: 24,
|
||||
overflow: "hidden",
|
||||
boxShadow:
|
||||
"0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined
|
||||
style={{ fontSize: 40, color: "rgba(255,255,255,0.3)", marginBottom: 12 }}
|
||||
/>
|
||||
<p style={{ color: "rgba(255,255,255,0.6)", fontSize: 14, margin: "0 0 4px" }}>
|
||||
暂无可播放素材
|
||||
</p>
|
||||
<p style={{ color: "rgba(255,255,255,0.35)", fontSize: 12, margin: 0 }}>
|
||||
请先在左侧选择素材
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={playerContainerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
maxWidth: compact ? "100%" : 280,
|
||||
margin: compact ? 0 : "0 auto",
|
||||
aspectRatio: "9 / 16",
|
||||
background: compact ? "transparent" : "#0a0a0a",
|
||||
borderRadius: compact ? 10 : 24,
|
||||
overflow: "hidden",
|
||||
boxShadow: compact
|
||||
? "inset 0 0 0 1px rgba(255,255,255,0.06)"
|
||||
: "0 4px 6px -1px rgba(0,0,0,0.3), 0 20px 50px -12px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* ── Video 渲染层(默认路径,浏览器原生硬件解码) ── */}
|
||||
{segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
preload="auto"
|
||||
src={seg.videoUrl}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
background: "#000",
|
||||
zIndex: 1,
|
||||
opacity: i === currentSegmentIndex ? 1 : 0,
|
||||
pointerEvents: i === currentSegmentIndex ? "auto" : "none",
|
||||
}}
|
||||
muted={hasVoice || muted}
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 标题CSS叠加层 — 与后端 ASS 烧录坐标系 1:1 对齐 */}
|
||||
{titleSettings?.title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 5,
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: `${100 - 2 * titleSidePct}%`,
|
||||
maxWidth: `${100 - 2 * titleSidePct}%`,
|
||||
...(customTitleXPct != null && customTitleYPct != null
|
||||
? {
|
||||
left: `${customTitleXPct}%`,
|
||||
top: `${customTitleYPct}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
textAlign: "center" as const,
|
||||
}
|
||||
: {
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
textAlign: "center" as const,
|
||||
...(titleSettings.position === "top"
|
||||
? { top: `${titleTopPct}%` }
|
||||
: titleSettings.position === "center"
|
||||
? { top: "50%", transform: "translate(-50%, -50%)" }
|
||||
: { bottom: `${titleBottomPct}%` }),
|
||||
}),
|
||||
pointerEvents: onTitlePositionChange && playToken == null ? "auto" : "none",
|
||||
cursor: onTitlePositionChange && playToken == null ? "grab" : "default",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
padding: "8px 12px",
|
||||
boxShadow: "inset 0 0 0 16px transparent",
|
||||
}}
|
||||
ref={titleDragRef}
|
||||
onPointerDown={handleTitlePointerDown}
|
||||
onPointerMove={handleTitlePointerMove}
|
||||
onPointerUp={handleTitlePointerUp}
|
||||
onPointerCancel={handleTitlePointerUp}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${titleFontSizePx}px`,
|
||||
fontFamily: getFontFamily(titleSettings.font || "思源黑体"),
|
||||
color: titleSettings.color || "#ffffff",
|
||||
fontWeight: titleSettings.bold ? 700 : 400,
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
lineHeight: titleSettings.lineHeight ?? 1.05,
|
||||
wordBreak: "break-word",
|
||||
WebkitTextStroke: titleSettings.stroke
|
||||
? `${titleStrokeWidth}px ${titleStrokeColor}`
|
||||
: undefined,
|
||||
textShadow: titleSettings.shadow ? titleShadowStr : undefined,
|
||||
background: titleBgEnabled ? titleBgColor : undefined,
|
||||
padding: titleBgEnabled
|
||||
? `${titleBgPadding}px ${titleBgPadding * 1.5}px`
|
||||
: undefined,
|
||||
borderRadius: titleBgEnabled ? `${titleBgRadius}px` : undefined,
|
||||
display: titleBgEnabled ? "inline-block" : undefined,
|
||||
}}
|
||||
>
|
||||
{(effectiveTitle || "").split(/[//]/).map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <br />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PreviewControls
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={handleTogglePlay}
|
||||
muted={muted}
|
||||
onToggleMute={() => setMuted((m) => !m)}
|
||||
hasSegments={segments.length > 0}
|
||||
segmentIndex={currentSegmentIndex}
|
||||
segmentCount={segments.length}
|
||||
currentTime={displayCurrentTime}
|
||||
totalDuration={effectiveTotalDuration}
|
||||
onSeek={handleSeekTo}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FrontendPreviewPlayer
|
||||
@@ -4,7 +4,6 @@
|
||||
* 原步骤"选择配音"已从主流程移除,改为 Step1 下一步分支弹窗(VoiceSelectModal / ScriptSelectModal → TtsVoiceModal)。
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../types/cover"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { ScriptItem } from "@/api/scripts"
|
||||
@@ -69,7 +68,6 @@ export interface GenerateStepContentProps {
|
||||
/* ── 配音 ── */
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
onServerClipsChange: (clips: EditPlanClip[]) => void
|
||||
/* ── 生成 ── */
|
||||
generating: boolean
|
||||
generated: boolean
|
||||
@@ -137,7 +135,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
titlePresets,
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
onServerClipsChange,
|
||||
generating,
|
||||
generateError,
|
||||
progress,
|
||||
@@ -155,13 +152,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
ttsVoiceSource,
|
||||
} = props
|
||||
|
||||
const handleClipsChange = React.useCallback(
|
||||
(clips: EditPlanClip[], _templateId?: string) => {
|
||||
onServerClipsChange(clips)
|
||||
},
|
||||
[onServerClipsChange],
|
||||
)
|
||||
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
@@ -189,7 +179,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
clipCount={clipCount}
|
||||
onClipCountChange={onClipCountChange}
|
||||
selectedTemplate={selectedTemplate}
|
||||
onServerClipsChange={handleClipsChange}
|
||||
/>
|
||||
)
|
||||
case 3:
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { PreviewProgressBar } from "./PreviewProgressBar"
|
||||
|
||||
interface PreviewControlsProps {
|
||||
isPlaying: boolean
|
||||
onTogglePlay: () => void
|
||||
muted: boolean
|
||||
onToggleMute: () => void
|
||||
hasSegments: boolean
|
||||
segmentIndex: number
|
||||
segmentCount: number
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
onSeek: (time: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制 UI 组件(静音按钮 / 片段指示器 / 中央播放按钮 / 底部毛玻璃控制条)
|
||||
*/
|
||||
export const PreviewControls: React.FC<PreviewControlsProps> = ({
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
muted,
|
||||
onToggleMute,
|
||||
hasSegments,
|
||||
segmentIndex,
|
||||
segmentCount,
|
||||
currentTime,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
compact = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 静音/有声切换(#1741):左上角 */}
|
||||
{hasSegments && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={onToggleMute}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontWeight: 500,
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{`${segmentIndex + 1} / ${segmentCount}`}
|
||||
</div>
|
||||
|
||||
{/* 中央播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "50%",
|
||||
width: 52,
|
||||
height: 52,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#fff",
|
||||
fontSize: 26,
|
||||
zIndex: 10,
|
||||
transition: "transform 0.2s ease, background 0.2s ease",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1.08)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.6)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "translate(-50%, -50%) scale(1)"
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 控制条 — 手机风格毛玻璃 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: compact ? 14 : 16,
|
||||
cursor: "pointer",
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.25)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</button>
|
||||
|
||||
<PreviewProgressBar
|
||||
currentTime={currentTime}
|
||||
totalDuration={totalDuration}
|
||||
onSeek={onSeek}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
|
||||
interface PreviewProgressBarProps {
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
onSeek: (time: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度条组件:点击/拖拽 seek
|
||||
*/
|
||||
export const PreviewProgressBar: React.FC<PreviewProgressBarProps> = ({
|
||||
currentTime,
|
||||
totalDuration,
|
||||
onSeek,
|
||||
compact = false,
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const seekByClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!progressRef.current || totalDuration <= 0) return
|
||||
const rect = progressRef.current.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
onSeek(ratio * totalDuration)
|
||||
},
|
||||
[totalDuration, onSeek],
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setIsDragging(true)
|
||||
seekByClientX(e.clientX)
|
||||
},
|
||||
[seekByClientX],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return
|
||||
const handleMouseMove = (e: MouseEvent) => seekByClientX(e.clientX)
|
||||
const handleMouseUp = () => setIsDragging(false)
|
||||
window.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}, [isDragging, seekByClientX])
|
||||
|
||||
const progressPercent = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
style={{
|
||||
fontSize: compact ? 10 : 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: compact ? 58 : 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
>
|
||||
{formatDuration(currentTime)} / {formatDuration(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={progressRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
borderRadius: 2,
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progressPercent}%`,
|
||||
background: "#fff",
|
||||
borderRadius: 2,
|
||||
transition: isDragging ? "none" : "width 0.1s linear",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${progressPercent}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: "#fff",
|
||||
boxShadow: "0 0 6px rgba(255,255,255,0.5)",
|
||||
opacity: isDragging ? 1 : 0,
|
||||
transition: "opacity 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* Step 4 选择标题(Issue #1677 批量生成)
|
||||
*
|
||||
* 布局(由 GeneratePage 编排):左侧大区域实时预览(单=大播放器,批量=Canvas 网格),
|
||||
* 右侧边栏标题设置。本组件渲染在右侧边栏:
|
||||
* - 单视频:AI 标题生成器 + AutoComplete 标题库(与旧版完全一致,零回归)
|
||||
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)+ 批量 AI 生成
|
||||
* (一次生成 N 个标题,分别填入各变体,可单独换一个)
|
||||
* 布局:全宽区域,标题输入 + 标题模板卡片网格。
|
||||
* - 单视频:TitleLibraryAutoComplete 标题库输入
|
||||
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Input, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import React, { useMemo } from "react"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS } from "../constants"
|
||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import { AI_TITLE_TEMPLATES } from "../constants"
|
||||
import type { TitleTemplate } from "@/components/title/template-types"
|
||||
|
||||
interface Step4TitleSettingsProps {
|
||||
@@ -55,36 +49,6 @@ interface Step4TitleSettingsProps {
|
||||
onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void
|
||||
}
|
||||
|
||||
/** 从本地 AI 标题模板池按主题词生成 N 个不同标题(与单视频 AI 生成同源) */
|
||||
function buildBatchAiTitles(topic: string, count: number): string[] {
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const pool: string[] = []
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style] || []
|
||||
templates.forEach((tpl) => pool.push(tpl.replace(/\{topic\}/g, topic)))
|
||||
})
|
||||
// 洗牌后取前 count 个;不足则轮转补齐
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5)
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.push(shuffled[i % shuffled.length] || "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function extractTopic(text: string): string {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
const t = useStep4Title(props)
|
||||
const {
|
||||
@@ -108,8 +72,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
} = props
|
||||
|
||||
const isBatch = previewCount > 1
|
||||
const [batchAiLoading, setBatchAiLoading] = useState(false)
|
||||
const [batchAiTopic, setBatchAiTopic] = useState("")
|
||||
|
||||
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
|
||||
const updateVariantTitle = (index: number, val: string) => {
|
||||
@@ -122,32 +84,6 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量 AI 生成:按主题词生成标题,分别填入 N 个变体 */
|
||||
const handleBatchAiGenerate = async (onlyEmpty = false) => {
|
||||
if (!onPreviewTitlesChange || !previewTitles) return
|
||||
const topic = (batchAiTopic || t.aiTitleInput || "").trim()
|
||||
if (!topic) {
|
||||
message.warning("请先输入主题词,例如:萌宠日常、旅行vlog")
|
||||
return
|
||||
}
|
||||
setBatchAiLoading(true)
|
||||
try {
|
||||
// 与单视频一致:本地模板模拟 AI 生成(1200ms 体验延迟)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const picked = buildBatchAiTitles(extractTopic(topic), previewCount)
|
||||
const next = [...previewTitles]
|
||||
for (let i = 0; i < previewCount; i++) {
|
||||
if (onlyEmpty && next[i]?.trim()) continue
|
||||
if (picked[i]) next[i] = picked[i]
|
||||
}
|
||||
onPreviewTitlesChange(next)
|
||||
if (next[0]) t.updateTitle(next[0])
|
||||
message.success(`已为 ${previewCount} 个视频生成标题,可单独修改`)
|
||||
} finally {
|
||||
setBatchAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const titleOptions = useMemo(
|
||||
() => t.userTitles.map((ut) => ({ label: ut.content, value: ut.content })),
|
||||
[t.userTitles],
|
||||
@@ -158,81 +94,21 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
{!isBatch ? (
|
||||
/* ── 单视频:原有 AI 标题 + 输入框(保持不变,零回归) ── */
|
||||
<>
|
||||
{t.titleSettings.aiAutoSelect ? (
|
||||
<>
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>当前 AI 选定标题</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--text-primary, #333)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{(previewTitles?.[0] ?? t.titleSettings.title) || "AI 将自动为你选择标题"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
|
||||
onClick={t.autoGenerateTitle}
|
||||
>
|
||||
🔄 换一个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
/* ── 单视频:标题输入框 ── */
|
||||
<div className="xx-form-field" style={{ maxWidth: 640 }}>
|
||||
<label>标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* ── 批量:AI 批量生成 + N 个独立标题输入框(AutoComplete 支持标题库) ── */
|
||||
/* ── 批量:N 个独立标题输入框 ── */
|
||||
<div className="xx-batch-titles">
|
||||
<div
|
||||
style={{
|
||||
@@ -242,42 +118,11 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
为每个视频输入独立标题,修改会实时叠加到左侧对应视频上。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
|
||||
{/* 批量 AI 标题 */}
|
||||
<div className="xx-batch-ai-row">
|
||||
<Input
|
||||
placeholder="主题词,如:萌宠日常、旅行vlog"
|
||||
value={batchAiTopic || t.aiTitleInput}
|
||||
onChange={(e) => {
|
||||
setBatchAiTopic(e.target.value)
|
||||
t.setAiTitleInput(e.target.value)
|
||||
}}
|
||||
maxLength={30}
|
||||
size="small"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
disabled={batchAiLoading}
|
||||
onClick={() => handleBatchAiGenerate(false)}
|
||||
>
|
||||
{batchAiLoading ? <LoadingOutlined /> : "✨"} 一键生成 {previewCount} 个标题
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-ghost xx-btn-sm"
|
||||
disabled={batchAiLoading}
|
||||
onClick={() => handleBatchAiGenerate(true)}
|
||||
>
|
||||
补填空标题
|
||||
</button>
|
||||
为每个视频输入独立标题。标题样式(字体/颜色/位置)全局统一。
|
||||
</div>
|
||||
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<div className="xx-form-field" key={i} style={{ maxWidth: 640 }}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 单个 AI 标题卡片
|
||||
*/
|
||||
import React from "react"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
|
||||
interface AiTitleCardProps {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AiTitleCard: React.FC<AiTitleCardProps> = ({
|
||||
title,
|
||||
highlight,
|
||||
style,
|
||||
selected,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`xx-ai-title-card ${selected ? "selected" : ""} ${style}`} onClick={onClick}>
|
||||
<div className="xx-ai-title-card-text">{title}</div>
|
||||
<div className="xx-ai-title-card-tag">{highlight}</div>
|
||||
{selected && (
|
||||
<div className="xx-ai-title-card-check">
|
||||
<CheckCircleFilled style={{ color: "#fff", fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleCard
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* AI 智能生成标题
|
||||
* 输入框 + 生成按钮 + 结果列表 + 加载状态
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import AiTitleCard from "./AiTitleCard"
|
||||
|
||||
interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface AiTitleGeneratorProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
generating: boolean
|
||||
onGenerate: () => void
|
||||
results: AiTitleItem[]
|
||||
hasGenerated: boolean
|
||||
onSelect: (title: string) => void
|
||||
selectedTitle: string
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
const AiTitleGenerator: React.FC<AiTitleGeneratorProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
generating,
|
||||
onGenerate,
|
||||
results,
|
||||
hasGenerated,
|
||||
onSelect,
|
||||
selectedTitle,
|
||||
onRefresh,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-ai-title-section">
|
||||
<div className="xx-ai-title-header">
|
||||
<span className="xx-ai-title-label">✨ AI 智能生成标题</span>
|
||||
</div>
|
||||
<div className="xx-ai-title-input-row">
|
||||
<input
|
||||
className="xx-ai-title-input"
|
||||
placeholder="输入视频内容描述或关键词,如:职场成长、副业赚钱…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onGenerate()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
onClick={onGenerate}
|
||||
disabled={generating || !inputValue.trim()}
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
生成中
|
||||
</>
|
||||
) : (
|
||||
"生成标题"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 生成结果 */}
|
||||
{hasGenerated && !generating && results.length > 0 && (
|
||||
<div className="xx-ai-title-results">
|
||||
<div className="xx-ai-title-results-header">
|
||||
<span className="xx-ai-title-results-count">为你生成 {results.length} 个标题</span>
|
||||
<button type="button" className="xx-link-btn" onClick={onRefresh} disabled={generating}>
|
||||
🔄 换一批
|
||||
</button>
|
||||
</div>
|
||||
<div className="xx-ai-title-list">
|
||||
{results.map((item, idx) => (
|
||||
<AiTitleCard
|
||||
key={idx}
|
||||
title={item.title}
|
||||
highlight={item.highlight}
|
||||
style={item.style}
|
||||
selected={selectedTitle === item.title}
|
||||
onClick={() => onSelect(item.title)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{generating && (
|
||||
<div className="xx-ai-title-loading">
|
||||
<LoadingOutlined style={{ color: "var(--primary-color)", marginRight: 8 }} />
|
||||
AI 正在为你创作标题…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiTitleGenerator
|
||||
@@ -535,31 +535,6 @@ export const SMART_MATCH_REASONS = [
|
||||
"人物表情生动",
|
||||
]
|
||||
|
||||
/* ── AI 标题模板 ── */
|
||||
export const AI_TITLE_TEMPLATES: Record<string, string[]> = {
|
||||
catchy: [
|
||||
"震惊!{topic}居然还能这样操作",
|
||||
"99%的人都不知道的{topic}秘诀",
|
||||
"{topic}的终极指南,看完直接封神",
|
||||
"别再走弯路了!{topic}看这一篇就够",
|
||||
"一个视频讲透{topic},建议收藏",
|
||||
],
|
||||
emotional: [
|
||||
"致每一个在{topic}路上坚持的人",
|
||||
"关于{topic},我想说句真心话",
|
||||
"{topic}背后的故事,看完沉默了",
|
||||
"为什么我劝你一定要了解{topic}",
|
||||
"这才是{topic}最动人的样子",
|
||||
],
|
||||
informative: [
|
||||
"{topic}完整科普:从入门到精通",
|
||||
"深度解析{topic}的核心原理",
|
||||
"{topic}行业趋势报告|2026最新版",
|
||||
"三分钟带你全面了解{topic}",
|
||||
"{topic}常见问题与解决方案汇总",
|
||||
],
|
||||
}
|
||||
|
||||
/* ── 默认封面设置 ── */
|
||||
export const DEFAULT_COVER_SETTINGS: CoverConfig = {
|
||||
enabled: true,
|
||||
|
||||
@@ -2880,25 +2880,9 @@
|
||||
Issue #1677 多视频批量生成
|
||||
================================================================ */
|
||||
|
||||
/* ── Step4 布局对调:左侧预览大区域,右侧标题边栏 ── */
|
||||
.xx-generate-layout.step4-layout {
|
||||
grid-template-columns: 1fr 380px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col {
|
||||
min-width: 0;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col .xx-form-section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 标题设置区域(全宽区块,不再受侧边栏约束) ── */
|
||||
.xx-title-sidebar {
|
||||
max-height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
/* no max-height / overflow constraints */
|
||||
}
|
||||
|
||||
/* ── 数量选择弹窗 ── */
|
||||
@@ -3240,135 +3224,13 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏 Step4 回退单列 ── */
|
||||
/* ── 响应式:窄屏标题区域 ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-generate-layout.step4-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.xx-generate-preview-col {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.xx-title-sidebar {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
|
||||
============================================================ */
|
||||
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
|
||||
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
|
||||
.xx-canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 180px));
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid {
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card.selected {
|
||||
border-color: var(--primary-color, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.15);
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card-bar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-surface, #fff);
|
||||
border-bottom: 1px solid var(--border-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.xx-canvas-grid-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xx-canvas-grid-check input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color, #1677ff);
|
||||
}
|
||||
|
||||
/* #1750:批量变体片段加载/错误占位(9:16 竖屏比例,与播放器卡片同尺寸防塌陷) */
|
||||
.xx-variant-clips-status {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: #0a0a0a;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.3),
|
||||
0 20px 50px -12px rgba(0, 0, 0, 0.5),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.xx-variant-clips-status .anticon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.xx-variant-clips-error-text {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
批量标题:AI 一键生成行(Issue #1677)
|
||||
============================================================ */
|
||||
.xx-batch-ai-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--bg-secondary, #f7f8fa);
|
||||
border: 1px dashed var(--border-primary, #d9d9d9);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.xx-batch-ai-row .xx-form-field {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.xx-batch-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* 预览素材加载 Hook
|
||||
* 根据选中的素材 ID 列表,逐个获取素材详情(含 file_url、duration 等)
|
||||
* 供前端预览播放器使用
|
||||
*
|
||||
* 注意:后端没有批量接口(/assets/batch 返回 405),
|
||||
* 因此直接使用 Promise.allSettled 并发请求单个 GET /assets/{id}
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { AxiosResponse } from "axios"
|
||||
|
||||
/**
|
||||
* 通过 ID 列表逐个获取素材(并发)
|
||||
* 使用 Promise.allSettled 确保单个失败不影响整体
|
||||
*/
|
||||
async function fetchAssetsByIds(ids: string[]): Promise<AssetItem[]> {
|
||||
// 防御:过滤空值/undefined/非字符串 id,避免发出 /assets/undefined 请求
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return []
|
||||
|
||||
try {
|
||||
const { default: apiClient } = await import("@/api/client")
|
||||
const results = await Promise.allSettled(
|
||||
validIds.map((id) => apiClient.get<AssetItem>(`/assets/${id}`)),
|
||||
)
|
||||
return results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<AxiosResponse<AssetItem>> =>
|
||||
r.status === "fulfilled" && !!r.value?.data,
|
||||
)
|
||||
.map((r) => r.value.data)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface UsePreviewAssetsReturn {
|
||||
/** 加载后的素材列表 */
|
||||
assets: AssetItem[]
|
||||
/** 是否正在加载 */
|
||||
loading: boolean
|
||||
/** 是否已就绪(加载完成) */
|
||||
ready: boolean
|
||||
/** 手动触发重新加载 */
|
||||
reload: () => void
|
||||
/**
|
||||
* 差集补拉(#1750):后端变体计划 clips 可能引用不在用户已选列表中的素材
|
||||
* (跨素材库选片/素材池扩展),发现 assets 中缺失的 asset_id 时补拉详情并合并,
|
||||
* 保证预览播放器拿得到素材文件 URL,而不是静默丢片段。
|
||||
*/
|
||||
ensureAssets: (ids: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* usePreviewAssets — 加载选中素材的视频文件信息
|
||||
*/
|
||||
export function usePreviewAssets(assetIds: string[], enabled: boolean): UsePreviewAssetsReturn {
|
||||
const [assets, setAssets] = useState<AssetItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
// 稳定化 assetIds:只有内容真正变化时才更新引用
|
||||
const stableAssetIds = useStableArray(assetIds)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const validIds = stableAssetIds.filter(
|
||||
(id): id is string => typeof id === "string" && id.length > 0,
|
||||
)
|
||||
if (!validIds.length || !enabled) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
const thisRequestId = ++requestIdRef.current
|
||||
setLoading(true)
|
||||
setReady(false)
|
||||
|
||||
try {
|
||||
const result = await fetchAssetsByIds(validIds)
|
||||
// 防止竞态:只保留最新请求的结果
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets(result)
|
||||
setReady(result.length > 0)
|
||||
}
|
||||
} catch {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setAssets([])
|
||||
setReady(false)
|
||||
}
|
||||
} finally {
|
||||
if (requestIdRef.current === thisRequestId) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [stableAssetIds, enabled])
|
||||
|
||||
// 补拉用独立序号:不干扰主 load 的竞态守卫
|
||||
const ensureSeqRef = useRef(0)
|
||||
const assetsRef = useRef<AssetItem[]>([])
|
||||
useEffect(() => {
|
||||
assetsRef.current = assets
|
||||
}, [assets])
|
||||
|
||||
/**
|
||||
* 差集补拉(#1750):变体计划 clips 引用的 asset_id 不在当前素材列表时,
|
||||
* 补拉素材详情并去重合并(不静默丢片段、不用假数据冒充)。
|
||||
*/
|
||||
const ensureAssets = useCallback(async (ids: string[]) => {
|
||||
const validIds = ids.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
if (!validIds.length) return
|
||||
const seq = ++ensureSeqRef.current
|
||||
const missing = Array.from(new Set(validIds)).filter(
|
||||
(id) => !assetsRef.current.some((a) => a.id === id),
|
||||
)
|
||||
if (!missing.length) return
|
||||
const fetched = await fetchAssetsByIds(missing)
|
||||
if (seq !== ensureSeqRef.current || !fetched.length) return
|
||||
const existing = new Set(assetsRef.current.map((a) => a.id))
|
||||
const additions = fetched.filter((a) => !existing.has(a.id))
|
||||
if (!additions.length) return
|
||||
const merged = [...assetsRef.current, ...additions]
|
||||
assetsRef.current = merged
|
||||
setAssets(merged)
|
||||
setReady(merged.length > 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { assets, loading, ready, reload: load, ensureAssets }
|
||||
}
|
||||
|
||||
/**
|
||||
* useStableArray — 数组内容稳定化 Hook
|
||||
* 只有数组内容真正变化时才返回新的引用,避免父组件 re-render 导致的无效更新
|
||||
*/
|
||||
function useStableArray<T>(array: T[]): T[] {
|
||||
const ref = useRef<T[]>(array)
|
||||
|
||||
// 比较数组内容是否真正变化
|
||||
const hasChanged =
|
||||
array.length !== ref.current.length || array.some((item, index) => item !== ref.current[index])
|
||||
|
||||
if (hasChanged) {
|
||||
ref.current = array
|
||||
}
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePreviewAssets
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
interface UsePreviewAudioOptions {
|
||||
voiceAudioUrl: string | undefined
|
||||
voiceDurationHint: number | undefined
|
||||
muted: boolean
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
onVoiceDurationChange: (d: number) => void
|
||||
onEnded: () => void
|
||||
}
|
||||
|
||||
interface UsePreviewAudioReturn {
|
||||
seekTo: (time: number) => void
|
||||
ensurePlayingAt: (time: number) => void
|
||||
pause: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 配音音频管理 hook:加载配音、loadedmetadata 自测时长、play/pause 同步、
|
||||
* ended 事件回调、seek 同步、末帧冻结期间续播。
|
||||
*/
|
||||
export function usePreviewAudio({
|
||||
voiceAudioUrl,
|
||||
voiceDurationHint,
|
||||
muted,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
onVoiceDurationChange,
|
||||
onEnded,
|
||||
}: UsePreviewAudioOptions): UsePreviewAudioReturn {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
|
||||
// 外部 hint 初始化(自测值前的兜底)
|
||||
useEffect(() => {
|
||||
if (voiceDurationHint && voiceDurationHint > 0) {
|
||||
onVoiceDurationChange(voiceDurationHint)
|
||||
}
|
||||
}, [voiceDurationHint, onVoiceDurationChange])
|
||||
|
||||
// 创建/替换 audio 元素,加载 metadata 时自测时长并监听 ended
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.src = ""
|
||||
audioRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio()
|
||||
audioRef.current.preload = "auto"
|
||||
}
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
audioRef.current.muted = muted
|
||||
|
||||
const audio = audioRef.current
|
||||
const onLoaded = () => {
|
||||
if (audio.duration && isFinite(audio.duration) && audio.duration > 0) {
|
||||
onVoiceDurationChange(audio.duration)
|
||||
}
|
||||
}
|
||||
const onEndedHandler = () => onEnded()
|
||||
audio.addEventListener("loadedmetadata", onLoaded)
|
||||
audio.addEventListener("ended", onEndedHandler)
|
||||
return () => {
|
||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
||||
audio.removeEventListener("ended", onEndedHandler)
|
||||
}
|
||||
}, [voiceAudioUrl, muted, onVoiceDurationChange, onEnded])
|
||||
|
||||
// mute 变化即时同步
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.muted = muted
|
||||
}, [muted])
|
||||
|
||||
// 播放/暂停同步(跟随视频 isPlaying)
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
if (isPlaying && !prevIsPlayingRef.current) {
|
||||
if (Math.abs(audio.currentTime - currentTime) > 0.3) {
|
||||
try {
|
||||
audio.currentTime = currentTime
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
audio.play().catch(() => {})
|
||||
} else if (!isPlaying && prevIsPlayingRef.current) {
|
||||
audio.pause()
|
||||
}
|
||||
prevIsPlayingRef.current = isPlaying
|
||||
}, [isPlaying, currentTime])
|
||||
|
||||
const seekTo = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (audio && audio.src) {
|
||||
try {
|
||||
audio.currentTime = time
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensurePlayingAt = useCallback((time: number) => {
|
||||
const audio = audioRef.current
|
||||
if (!audio || !audio.src) return
|
||||
try {
|
||||
if (Math.abs(audio.currentTime - time) > 0.5) audio.currentTime = time
|
||||
if (audio.paused) audio.play().catch(() => {})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
try {
|
||||
audioRef.current?.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { seekTo, ensurePlayingAt, pause }
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* 素材片段调度器 Hook(多 video 元素方案 v3)
|
||||
*
|
||||
* v3 修复:
|
||||
* - 所有动态状态存入 ref,tick 为稳定函数,彻底消除 RAF 闭包陷阱
|
||||
* - 片段切换时先启动下一个 video 再切可见性,消除冻屏间隔
|
||||
* - 进度更新 200ms 节流
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
|
||||
export interface PlaybackSegment {
|
||||
assetId: string
|
||||
videoUrl: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
order: number
|
||||
/** #1754 兜底:配音时长≠clips 总时长时按比例调速,1.0 = 原速 */
|
||||
playbackRate?: number
|
||||
}
|
||||
|
||||
export interface SegmentSchedulerState {
|
||||
isPlaying: boolean
|
||||
currentTime: number
|
||||
totalDuration: number
|
||||
currentSegmentIndex: number
|
||||
segmentLocalTime: number
|
||||
isEnded: boolean
|
||||
canPlay: boolean
|
||||
play: () => void
|
||||
pause: () => void
|
||||
togglePlayPause: () => void
|
||||
seekTo: (time: number) => void
|
||||
videoRefs: React.MutableRefObject<(HTMLVideoElement | null)[]>
|
||||
}
|
||||
|
||||
function findSegmentAtTime(
|
||||
segments: PlaybackSegment[],
|
||||
globalTime: number,
|
||||
): { index: number; localTime: number } {
|
||||
let accumulated = 0
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i]
|
||||
const segDuration = seg.endTime - seg.startTime
|
||||
if (globalTime < accumulated + segDuration || i === segments.length - 1) {
|
||||
return { index: i, localTime: seg.startTime + (globalTime - accumulated) }
|
||||
}
|
||||
accumulated += segDuration
|
||||
}
|
||||
return { index: segments.length - 1, localTime: segments[segments.length - 1].endTime }
|
||||
}
|
||||
|
||||
function buildTimeline(segments: PlaybackSegment[]): number[] {
|
||||
const starts: number[] = []
|
||||
let acc = 0
|
||||
for (const seg of segments) {
|
||||
starts.push(acc)
|
||||
acc += seg.endTime - seg.startTime
|
||||
}
|
||||
return starts
|
||||
}
|
||||
|
||||
export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedulerState {
|
||||
const videoRefs = useRef<(HTMLVideoElement | null)[]>([])
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0)
|
||||
const [isEnded, setIsEnded] = useState(false)
|
||||
const rafRef = useRef(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const lastTimeUpdateRef = useRef(0)
|
||||
|
||||
// 所有动态值存入 ref,tick 始终读取最新值,不依赖闭包
|
||||
const segIdxRef = useRef(0)
|
||||
const segmentsRef = useRef(segments)
|
||||
const timelineStartsData = useMemo(() => buildTimeline(segments), [segments])
|
||||
const totalDurationData = useMemo(
|
||||
() => segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0),
|
||||
[segments],
|
||||
)
|
||||
const timelineStartsRef = useRef(timelineStartsData)
|
||||
const totalDurationRef = useRef(totalDurationData)
|
||||
const isPlayingRef = useRef(false)
|
||||
|
||||
segmentsRef.current = segments
|
||||
timelineStartsRef.current = timelineStartsData
|
||||
totalDurationRef.current = totalDurationData
|
||||
|
||||
const canPlay = segments.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
segIdxRef.current = currentSegmentIndex
|
||||
}, [currentSegmentIndex])
|
||||
|
||||
useEffect(() => {
|
||||
isPlayingRef.current = isPlaying
|
||||
}, [isPlaying])
|
||||
|
||||
const waitForReady = useCallback((video: HTMLVideoElement, timeout = 3000): Promise<void> => {
|
||||
if (video.readyState >= 3) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const onCanPlay = () => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
video.removeEventListener("canplay", onCanPlay)
|
||||
resolve()
|
||||
}, timeout)
|
||||
video.addEventListener("canplay", onCanPlay)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const switchToSegment = useCallback(
|
||||
async (index: number, seekToLocalTime?: number) => {
|
||||
const segs = segmentsRef.current
|
||||
const video = videoRefs.current[index]
|
||||
if (!video || index >= segs.length) return
|
||||
|
||||
const seg = segs[index]
|
||||
const localTime = seekToLocalTime ?? seg.startTime
|
||||
const oldIdx = segIdxRef.current
|
||||
const oldVideo = videoRefs.current[oldIdx]
|
||||
|
||||
if (oldVideo && oldVideo !== video) oldVideo.pause()
|
||||
|
||||
if (!video.src && seg.videoUrl) {
|
||||
video.src = seg.videoUrl
|
||||
video.load()
|
||||
}
|
||||
|
||||
if (Math.abs(video.currentTime - localTime) > 0.05) {
|
||||
video.currentTime = localTime
|
||||
}
|
||||
// #1754:按比例调速(配音时长≠clips 总时长时的前端兜底)
|
||||
const rate = seg.playbackRate || 1
|
||||
if (Math.abs(video.playbackRate - rate) > 0.01) {
|
||||
video.playbackRate = rate
|
||||
}
|
||||
|
||||
segIdxRef.current = index
|
||||
setCurrentSegmentIndex(index)
|
||||
|
||||
await waitForReady(video)
|
||||
},
|
||||
[waitForReady],
|
||||
)
|
||||
|
||||
// 稳定的 tick 函数,空依赖,所有值从 ref 读取
|
||||
const tick = useCallback(() => {
|
||||
const segs = segmentsRef.current
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
|
||||
if (!video || isSeekingRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const seg = segs[idx]
|
||||
if (!seg) return
|
||||
|
||||
// 预加载下一个片段
|
||||
const nextIndex = idx + 1
|
||||
if (nextIndex < segs.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
if (nextVideo) {
|
||||
const timeToEnd = seg.endTime - video.currentTime
|
||||
if (timeToEnd <= 2 && nextVideo.readyState < 3) {
|
||||
const nextSeg = segs[nextIndex]
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.5) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测片段边界
|
||||
if (video.currentTime >= seg.endTime - 0.1) {
|
||||
if (nextIndex < segs.length) {
|
||||
const nextVideo = videoRefs.current[nextIndex]
|
||||
const nextSeg = segs[nextIndex]
|
||||
const accumulatedTime =
|
||||
(timelineStartsRef.current[idx] || 0) + (seg.endTime - seg.startTime)
|
||||
|
||||
if (nextVideo) {
|
||||
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 0.1) {
|
||||
nextVideo.currentTime = nextSeg.startTime
|
||||
}
|
||||
// 先启动下一个视频(muted,可安全同时播放)
|
||||
nextVideo
|
||||
.play()
|
||||
.catch((e) => console.warn("[useSegmentScheduler] next segment play failed:", e))
|
||||
}
|
||||
|
||||
// 立即切换可见性
|
||||
segIdxRef.current = nextIndex
|
||||
setCurrentSegmentIndex(nextIndex)
|
||||
setCurrentTime(accumulatedTime)
|
||||
lastTimeUpdateRef.current = 0
|
||||
setIsPlaying(true)
|
||||
|
||||
// 下一帧暂停旧视频(让新视频先渲染,避免冻屏)
|
||||
const oldVideo = video
|
||||
requestAnimationFrame(() => {
|
||||
oldVideo.pause()
|
||||
})
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
} else {
|
||||
video.pause()
|
||||
setIsPlaying(false)
|
||||
setIsEnded(true)
|
||||
setCurrentTime(totalDurationRef.current)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const globalTime = (timelineStartsRef.current[idx] || 0) + (video.currentTime - seg.startTime)
|
||||
const now = performance.now()
|
||||
if (now - lastTimeUpdateRef.current >= 200) {
|
||||
lastTimeUpdateRef.current = now
|
||||
setCurrentTime(Math.max(0, Math.min(globalTime, totalDurationRef.current)))
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}, [])
|
||||
|
||||
const play = useCallback(async () => {
|
||||
if (!canPlay) return
|
||||
setIsEnded(false)
|
||||
const idx = segIdxRef.current
|
||||
const video = videoRefs.current[idx]
|
||||
if (!video) return
|
||||
|
||||
if (idx === 0 && video.readyState < 2) {
|
||||
if (!video.src && segmentsRef.current[0]?.videoUrl) {
|
||||
video.src = segmentsRef.current[0].videoUrl
|
||||
video.load()
|
||||
}
|
||||
await waitForReady(video)
|
||||
}
|
||||
|
||||
// 播放前 seek 到片段起始时间,确保 progress 计算正确
|
||||
const seg = segmentsRef.current[idx]
|
||||
if (seg && Math.abs(video.currentTime - seg.startTime) > 0.1) {
|
||||
video.currentTime = seg.startTime
|
||||
}
|
||||
|
||||
// #1754:调速
|
||||
const rate = seg?.playbackRate || 1
|
||||
if (Math.abs(video.playbackRate - rate) > 0.01) {
|
||||
video.playbackRate = rate
|
||||
}
|
||||
|
||||
try {
|
||||
await video.play()
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
} catch (err) {
|
||||
console.warn("[useSegmentScheduler] 播放失败:", err)
|
||||
}
|
||||
}, [canPlay, waitForReady, tick])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRefs.current[segIdxRef.current]
|
||||
if (video) video.pause()
|
||||
setIsPlaying(false)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}, [])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (isPlayingRef.current) {
|
||||
pause()
|
||||
} else {
|
||||
if (isEnded) {
|
||||
setIsEnded(false)
|
||||
lastTimeUpdateRef.current = 0
|
||||
const firstVideo = videoRefs.current[0]
|
||||
if (firstVideo) {
|
||||
videoRefs.current.forEach((v, i) => {
|
||||
if (v && i !== 0) v.pause()
|
||||
})
|
||||
firstVideo.currentTime = segmentsRef.current[0]?.startTime || 0
|
||||
segIdxRef.current = 0
|
||||
setCurrentSegmentIndex(0)
|
||||
setCurrentTime(0)
|
||||
firstVideo
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true)
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
})
|
||||
.catch((e) => console.warn("[useSegmentScheduler] restart failed:", e))
|
||||
}
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
}, [isEnded, pause, play, tick])
|
||||
|
||||
const seekTo = useCallback(
|
||||
async (time: number) => {
|
||||
if (!canPlay) return
|
||||
const clampedTime = Math.max(0, Math.min(time, totalDurationRef.current))
|
||||
const { index, localTime } = findSegmentAtTime(segmentsRef.current, clampedTime)
|
||||
|
||||
isSeekingRef.current = true
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
|
||||
if (index !== segIdxRef.current) {
|
||||
await switchToSegment(index, localTime)
|
||||
} else {
|
||||
const video = videoRefs.current[index]
|
||||
if (video) video.currentTime = localTime
|
||||
}
|
||||
|
||||
setCurrentTime(clampedTime)
|
||||
setIsEnded(false)
|
||||
lastTimeUpdateRef.current = 0
|
||||
|
||||
if (isPlayingRef.current) {
|
||||
const video = videoRefs.current[index]
|
||||
if (video) {
|
||||
video.play().catch(() => {})
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isSeekingRef.current = false
|
||||
}, 200)
|
||||
},
|
||||
[canPlay, switchToSegment, tick],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
videoRefs.current = videoRefs.current.slice(0, segments.length)
|
||||
while (videoRefs.current.length < segments.length) {
|
||||
videoRefs.current.push(null)
|
||||
}
|
||||
}, [segments])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
segIdxRef.current = 0
|
||||
setIsPlaying(false)
|
||||
setCurrentTime(0)
|
||||
setCurrentSegmentIndex(0)
|
||||
setIsEnded(false)
|
||||
}, [segments])
|
||||
|
||||
const currentSegment = segments[currentSegmentIndex] || null
|
||||
const segmentLocalTime = currentSegment
|
||||
? currentTime - (timelineStartsRef.current[currentSegmentIndex] || 0) + currentSegment.startTime
|
||||
: 0
|
||||
|
||||
return {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
totalDuration: totalDurationData,
|
||||
currentSegmentIndex,
|
||||
segmentLocalTime,
|
||||
isEnded,
|
||||
canPlay,
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
seekTo,
|
||||
videoRefs,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSegmentScheduler
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
// #1894: 标题候选从文案库 scripts[].title 获取,不再调用废弃的 /api/titles
|
||||
import { getScripts } from "@/api/scripts"
|
||||
import type { TitleSettings } from "../../types"
|
||||
import { useAiTitleGenerator } from "./useAiTitleGenerator"
|
||||
import { useTitleStyleUpdaters } from "./useTitleStyleUpdaters"
|
||||
import { useDraftAutoSave } from "../useDraftAutoSave"
|
||||
|
||||
@@ -16,7 +15,7 @@ interface UseStep4TitleProps {
|
||||
|
||||
/**
|
||||
* Step 4 标题设置 Hook
|
||||
* 封装 AI 标题生成、标题样式设置等逻辑
|
||||
* 封装标题样式设置等逻辑
|
||||
*/
|
||||
export function useStep4Title({
|
||||
titleSettings,
|
||||
@@ -34,26 +33,9 @@ export function useStep4Title({
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// AI 标题生成
|
||||
const {
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
} = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 样式更新
|
||||
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 追踪 AI 自动选择开关的上一次值 & 是否首次挂载
|
||||
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
/* ── Step4 标题内容/样式变化后自动保存草稿(防抖 800ms,失败静默) ── */
|
||||
const { scheduleSave: scheduleTitleSave } = useDraftAutoSave(selectedTemplate)
|
||||
useEffect(() => {
|
||||
@@ -86,40 +68,12 @@ export function useStep4Title({
|
||||
scheduleTitleSave,
|
||||
])
|
||||
|
||||
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
|
||||
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
|
||||
useEffect(() => {
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false
|
||||
if (titleSettings.aiAutoSelect && !titleSettings.title) {
|
||||
autoGenerateTitle()
|
||||
}
|
||||
prevAiAutoSelect.current = titleSettings.aiAutoSelect
|
||||
return
|
||||
}
|
||||
if (titleSettings.aiAutoSelect && !prevAiAutoSelect.current && !titleSettings.title) {
|
||||
autoGenerateTitle()
|
||||
}
|
||||
prevAiAutoSelect.current = titleSettings.aiAutoSelect
|
||||
}, [titleSettings.aiAutoSelect, titleSettings.title, autoGenerateTitle])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
// AI 标题状态
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset: styleUpdaters.activePreset,
|
||||
titlePresets: styleUpdaters.titlePresets,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
// 标题设置操作
|
||||
updateTitle: styleUpdaters.updateTitle,
|
||||
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { message } from "antd"
|
||||
import { AI_TITLE_TEMPLATES } from "../../constants"
|
||||
import type { TitleSettings } from "../../types"
|
||||
|
||||
export interface AiTitleItem {
|
||||
title: string
|
||||
highlight: string
|
||||
style: "catchy" | "emotional" | "informative"
|
||||
}
|
||||
|
||||
interface UseAiTitleGeneratorOptions {
|
||||
titleSettings: TitleSettings
|
||||
onTitleSettingsChange: (settings: TitleSettings) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 标题生成 Hook
|
||||
* 封装 AI 标题生成、刷新、选择等逻辑
|
||||
*/
|
||||
export function useAiTitleGenerator({
|
||||
titleSettings,
|
||||
onTitleSettingsChange,
|
||||
}: UseAiTitleGeneratorOptions) {
|
||||
const [aiTitleInput, setAiTitleInput] = useState("")
|
||||
const [aiTitleGenerating, setAiTitleGenerating] = useState(false)
|
||||
const [aiTitleResults, setAiTitleResults] = useState<AiTitleItem[]>([])
|
||||
const [hasGeneratedTitles, setHasGeneratedTitles] = useState(false)
|
||||
|
||||
const extractTopic = (text: string): string => {
|
||||
const keywords = text
|
||||
.replace(/[,。!?、,.!?]/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
if (keywords.length === 0) return "这个话题"
|
||||
return keywords.slice(0, 3).join("")
|
||||
}
|
||||
|
||||
const generateTitlesFromTopic = (topic: string): AiTitleItem[] => {
|
||||
const results: AiTitleItem[] = []
|
||||
const styles: Array<"catchy" | "emotional" | "informative"> = [
|
||||
"catchy",
|
||||
"emotional",
|
||||
"informative",
|
||||
]
|
||||
const highlights = { catchy: "吸睛标题", emotional: "情感共鸣", informative: "知识干货" }
|
||||
styles.forEach((style) => {
|
||||
const templates = AI_TITLE_TEMPLATES[style]
|
||||
const shuffled = [...templates].sort(() => Math.random() - 0.5).slice(0, 2)
|
||||
shuffled.forEach((tpl) => {
|
||||
results.push({
|
||||
title: tpl.replace(/\{topic\}/g, topic),
|
||||
highlight: highlights[style],
|
||||
style,
|
||||
})
|
||||
})
|
||||
})
|
||||
results.sort(() => Math.random() - 0.5)
|
||||
return results
|
||||
}
|
||||
|
||||
const handleGenerateAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) {
|
||||
message.warning("请先输入视频描述或关键词")
|
||||
return
|
||||
}
|
||||
setAiTitleGenerating(true)
|
||||
setHasGeneratedTitles(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1200))
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/**
|
||||
* 自动生成标题(供 AI 自动选择开关使用)
|
||||
* 如果已有生成结果,直接从中选一个;否则用默认关键词生成
|
||||
*/
|
||||
const autoGenerateTitle = useCallback((): string => {
|
||||
if (aiTitleResults.length > 0) {
|
||||
const picked = aiTitleResults[Math.floor(Math.random() * aiTitleResults.length)]
|
||||
if (!picked) return ""
|
||||
onTitleSettingsChange({ ...titleSettings, title: picked.title })
|
||||
return picked.title
|
||||
}
|
||||
// 没有已有结果,用默认关键词生成
|
||||
const results = generateTitlesFromTopic("短视频")
|
||||
setAiTitleResults(results)
|
||||
setHasGeneratedTitles(true)
|
||||
const picked = results[Math.floor(Math.random() * results.length)]
|
||||
if (!picked) return ""
|
||||
onTitleSettingsChange({ ...titleSettings, title: picked.title })
|
||||
return picked.title
|
||||
}, [aiTitleResults, titleSettings, onTitleSettingsChange])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
)
|
||||
|
||||
const handleRefreshAiTitles = useCallback(async () => {
|
||||
if (!aiTitleInput.trim()) return
|
||||
setAiTitleGenerating(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
const topic = extractTopic(aiTitleInput)
|
||||
setAiTitleResults(generateTitlesFromTopic(topic))
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
return {
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* 批量变体配音预览音频解析(#1750)
|
||||
*
|
||||
* 独立配音模式下每个变体挂载各自的配音 URL:
|
||||
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
|
||||
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
|
||||
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
|
||||
*
|
||||
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
|
||||
interface UseVariantVoicePreviewOptions {
|
||||
/** 是否批量模式(count>1) */
|
||||
enabled: boolean
|
||||
/** 变体数量 */
|
||||
count: number
|
||||
/** 是否每个视频独立配音 */
|
||||
perVideo: boolean
|
||||
/** 共用配音 ID(独立模式下为变体 0 的配音) */
|
||||
sharedVoiceId: string
|
||||
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice) */
|
||||
clonedVoiceId?: string
|
||||
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
|
||||
variantVoiceIds: string[]
|
||||
/** 各变体标题(TTS 合成文案源) */
|
||||
titles: string[]
|
||||
}
|
||||
|
||||
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
|
||||
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
|
||||
|
||||
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
|
||||
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
|
||||
if (!id) return null
|
||||
const m = materials.find((x) => x.id === id)
|
||||
return m?.file_url || null
|
||||
}
|
||||
|
||||
export function useVariantVoicePreview({
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId = "",
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
}: UseVariantVoicePreviewOptions): (string | null)[] {
|
||||
const [urls, setUrls] = useState<(string | null)[]>([])
|
||||
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
|
||||
const { data: voiceMaterialsData } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
|
||||
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
|
||||
const ttsCacheRef = useRef<Map<string, string>>(new Map())
|
||||
// 在途请求 AbortController
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || count <= 1) {
|
||||
setUrls((prev) => (prev.length === 0 ? prev : []))
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++seqRef.current
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
// 每个变体实际使用的配音 ID
|
||||
const voiceIds = Array.from({ length: count }, (_, i) =>
|
||||
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
|
||||
)
|
||||
|
||||
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
|
||||
const result: (string | null)[] = voiceIds.map((id, i) => {
|
||||
const materialUrl = findMaterialUrl(id, voiceMaterials)
|
||||
if (materialUrl) return materialUrl
|
||||
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
|
||||
if (!perVideo && i === 0 && clonedVoiceId) {
|
||||
return findMaterialUrl(clonedVoiceId, voiceMaterials)
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
|
||||
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
|
||||
voiceIds.forEach((id, i) => {
|
||||
if (result[i]) return
|
||||
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
|
||||
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
|
||||
const title = titles[i] || ""
|
||||
if (!ttsVoiceId || !title) return
|
||||
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
|
||||
})
|
||||
|
||||
setUrls((prev) =>
|
||||
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
|
||||
)
|
||||
|
||||
if (ttsJobs.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
|
||||
for (const job of ttsJobs) {
|
||||
const cacheKey = `${job.voiceId}|${job.title}`
|
||||
const cached = ttsCacheRef.current.get(cacheKey)
|
||||
if (cached) {
|
||||
if (seq === seqRef.current) {
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === cached) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = cached
|
||||
return next
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
const audioUrl = res.audio_url || ""
|
||||
if (audioUrl) {
|
||||
ttsCacheRef.current.set(cacheKey, audioUrl)
|
||||
setUrls((prev) => {
|
||||
if (prev[job.index] === audioUrl) return prev
|
||||
const next = [...prev]
|
||||
next[job.index] = audioUrl
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||||
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
count,
|
||||
perVideo,
|
||||
sharedVoiceId,
|
||||
clonedVoiceId,
|
||||
variantVoiceIds,
|
||||
titles,
|
||||
voiceMaterials,
|
||||
])
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
export default useVariantVoicePreview
|
||||
@@ -81,14 +81,6 @@ export interface SmartMatchResult {
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
/* ── AI 标题结果 ── */
|
||||
export interface AiTitleResult {
|
||||
title: string
|
||||
style: string
|
||||
styleLabel: string
|
||||
highlights: string[]
|
||||
}
|
||||
|
||||
/* ── 配音推荐结果 ── */
|
||||
export interface VoiceRecommendation {
|
||||
voiceId: string
|
||||
|
||||
@@ -19,7 +19,6 @@ import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/Step3VoiceWithMode"
|
||||
import "@/pages/generate/components/CanvasPreviewGrid"
|
||||
import "@/pages/generate/components/BatchGenerationGrid"
|
||||
import "@/pages/generate/components/PreviewCountModal"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
@@ -33,8 +32,6 @@ import "@/pages/generate/components/material/MaterialModeTabs"
|
||||
import "@/pages/generate/components/material/ManualMaterialList"
|
||||
import "@/pages/generate/components/material/SmartMatchInput"
|
||||
import "@/pages/generate/components/material/SmartMatchResults"
|
||||
import "@/pages/generate/components/title/AiTitleGenerator"
|
||||
import "@/pages/generate/components/title/AiTitleCard"
|
||||
import "@/pages/generate/components/title/TitleStylePanel"
|
||||
import "@/pages/generate/components/title/TitlePresetsGrid"
|
||||
import "@/pages/generate/utils/formatDuration"
|
||||
@@ -48,9 +45,6 @@ describe("GeneratePage module smoke test", () => {
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useBatchCovers"
|
||||
import "@/pages/generate/hooks/useBatchVariantPlans"
|
||||
import "@/pages/generate/hooks/useVariantVoicePreview"
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useVoiceState"
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
/**
|
||||
* Step4+5 merged preview smoke test
|
||||
* 确保 vitest related 模式能匹配到第5步预览生成相关文件的改动
|
||||
* Step4+5 preview smoke test (legacy — preview components removed)
|
||||
* 保留最小测试确保 vitest related 模式仍能匹配 generate 目录改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/hooks/usePreviewAssets"
|
||||
import "@/pages/generate/hooks/useSegmentScheduler"
|
||||
import "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Merged Step4+5 preview module smoke test", () => {
|
||||
it("should load all merged step4+5 preview modules", () => {
|
||||
describe("Generate page smoke test", () => {
|
||||
it("should load generate modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user