From cc18485ca9b6b44d4eca289b75cff96ddc48bc27 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 26 Sep 2026 09:19:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(cover):=20=E4=BF=AE=E5=A4=8D9=E4=B8=AA?= =?UTF-8?q?=E5=B0=81=E9=9D=A2/=E6=B5=81=E7=A8=8BUX=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 去掉Step2重复的生成数量弹窗,用Step1的previewCount直接进Step3 2. Step3右上角加醒目的「🎬 确认生成」主按钮 3. 修复单视频「自动生成封面」按钮点击无反应(canGenerate加status校验+id兼容) 4. CoverSettingsModal加宽到860px+响应式网格 5. 系统模板按钮文案改回「编辑」,点击仍走自动副本逻辑 6. CoverEditorModal加宽到1150px,右侧9:16预览画布放大(minHeight 600) 7. 封面主/副标题支持鼠标拖拽移动位置(原生mousedown/mousemove/mouseup) 8. 主/副标题文字背景设置(剪映样式:开关/形状/颜色/透明度/圆角/宽高/偏移) 9. 封面完成后通过currentTaskId兜底正确finalize视频,直接入库展示成品 --- apps/web/src/pages/generate/GeneratePage.tsx | 56 +++- .../components/GenerateStepContent.tsx | 8 + .../components/Step4TitleSettings.tsx | 51 +++- .../components/Step6CoverSettings.tsx | 14 +- .../cover-settings/CoverEditorModal.tsx | 240 ++++++++++++++++-- .../cover-settings/CoverSettingsModal.tsx | 2 +- .../pages/generate/hooks/useGenerateVideo.ts | 7 + .../pages/generate/hooks/useStepNavigation.ts | 13 +- 8 files changed, 353 insertions(+), 38 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index b130a0684..5fd306f9a 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -200,6 +200,7 @@ const GeneratePage: React.FC = () => { generated, generateError, generatedVideos, + currentTaskId, batchTasks, generate: handleGenerate, retry: handleRetryGenerate, @@ -278,6 +279,38 @@ const GeneratePage: React.FC = () => { ], ) + /* ── 对齐批量数组长度到 previewCount(用于进入 Step3 时) ── */ + const ensureArraysAligned = useCallback(() => { + setPreviewTitles((prev) => { + const list = prev || [] + if (list.length === previewCount) return list + const base = list[0] || titleSettings.title || "" + return Array.from({ length: previewCount }, (_, i) => list[i] ?? (i === 0 ? base : "")) + }) + setVoiceLibraryIds((prev) => { + const list = prev || [] + if (list.length === previewCount) return list + return Array.from({ length: previewCount }, (_, i) => list[i] ?? selectedVoice ?? "") + }) + setPreviewCovers((prev) => { + const list = prev || [] + if (list.length === previewCount) return list + return Array.from({ length: previewCount }, (_, i) => list[i] ?? "") + }) + setSelectedVariantIds((prev) => { + if (prev && prev.length === previewCount) return prev + return Array.from({ length: previewCount }, (_, i) => i) + }) + }, [ + previewCount, + setPreviewTitles, + setVoiceLibraryIds, + setPreviewCovers, + setSelectedVariantIds, + titleSettings.title, + selectedVoice, + ]) + /* ── #1970:Step1 弹窗回调 ── */ const handleVoiceModalConfirm = useCallback( (voiceAssetId: string) => { @@ -399,7 +432,7 @@ const GeneratePage: React.FC = () => { smartSelectedIds, titleSettings, generated, - onOpenCountModal: () => setCountModalOpen(true), + onBeforeEnterStep3: ensureArraysAligned, onOpenStep1Modal: () => { if (editMode === "random") { setVoiceModalOpen(true) @@ -430,30 +463,35 @@ const GeneratePage: React.FC = () => { setFinishing(true) const hide = message.loading("正在保存到视频库...", 0) try { + // 收集需要 finalize 的任务 ID:批量用 batchTasks;单视频优先用 finalVideo.generation_task_id,兜底 currentTaskId + const singleTaskId = finalVideo?.generation_task_id || currentTaskId || "" const taskIds = batchTasks && batchTasks.length > 0 ? batchTasks.map((t) => t.taskId).filter(Boolean) - : finalVideo?.generation_task_id - ? [finalVideo.generation_task_id] + : singleTaskId + ? [singleTaskId] : [] - // 单视频/批量:为每个 awaiting_cover 任务调用 finalize(入库 + 绑定封面 + 自定义标题) - if (isBatch && previewCovers.length > 0) { + // 单视频/批量:为每个任务调用 finalize(入库 + 绑定封面 + 自定义标题) + if (isBatch && taskIds.length > 0) { await Promise.all( taskIds.map(async (taskId, idx) => { const coverUrl = previewCovers[idx] || "" + const title = previewTitles[idx] || titleSettings.title || "" return finalizeGeneration(taskId, { cover_url: coverUrl || undefined, - custom_title: previewTitles[idx] || titleSettings.title || "", + custom_title: title, }) }), ) - } else if (finalVideo?.generation_task_id) { + } else if (singleTaskId) { const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || "" - await finalizeGeneration(finalVideo.generation_task_id, { + await finalizeGeneration(singleTaskId, { cover_url: coverUrl || undefined, custom_title: titleSettings.title || "", }) + } else { + console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转") } hide() @@ -482,6 +520,7 @@ const GeneratePage: React.FC = () => { previewTitles, titleSettings.title, coverSettings, + currentTaskId, navigate, ]) @@ -580,6 +619,7 @@ const GeneratePage: React.FC = () => { selectedVariantIds={selectedVariantIds} selectedCoverTemplate={selectedTemplate} onSelectedCoverTemplateChange={setSelectedTemplate} + onConfirmGenerate={handleConfirmGenerate} /> {/* ════ 步骤4(单视频):成片播放器 ════ */} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index 8822160d8..a1bab19e1 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -91,6 +91,8 @@ export interface GenerateStepContentProps { selectedVariantIds?: number[] selectedCoverTemplate?: string onSelectedCoverTemplateChange?: (templateId: string) => void + /** Step3 右上角确认生成按钮 */ + onConfirmGenerate?: () => void | Promise } export const GenerateStepContent: React.FC = (props) => { @@ -146,6 +148,7 @@ export const GenerateStepContent: React.FC = (props) = selectedVariantIds, selectedCoverTemplate, onSelectedCoverTemplateChange, + onConfirmGenerate, } = props switch (currentStep) { @@ -199,6 +202,11 @@ export const GenerateStepContent: React.FC = (props) = previewCount={previewCount} previewTitles={previewTitles} onPreviewTitlesChange={onPreviewTitlesChange} + onConfirmGenerate={onConfirmGenerate} + generating={props.generating} + selectedCount={ + props.previewCount && props.previewCount > 1 ? props.selectedVariantIds?.length || 1 : 1 + } /> ) case 4: diff --git a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx index 89caf44f1..0beb3a5bb 100644 --- a/apps/web/src/pages/generate/components/Step4TitleSettings.tsx +++ b/apps/web/src/pages/generate/components/Step4TitleSettings.tsx @@ -47,6 +47,12 @@ interface Step4TitleSettingsProps { enableTemplates?: boolean selectedTemplateId?: string | null onApplyTemplate?: (settings: TitleSettings, template: TitleTemplate) => void + /** Step3 右上角「🎬 确认生成」主按钮 */ + onConfirmGenerate?: () => void | Promise + /** 是否生成中 */ + generating?: boolean + /** 批量模式下勾选数量 */ + selectedCount?: number } const Step4TitleSettings: React.FC = (props) => { @@ -69,6 +75,9 @@ const Step4TitleSettings: React.FC = (props) => { enableTemplates, selectedTemplateId, onApplyTemplate, + onConfirmGenerate, + generating, + selectedCount = 1, } = props const isBatch = previewCount > 1 @@ -90,7 +99,47 @@ const Step4TitleSettings: React.FC = (props) => { ) return ( -
+
+ {/* ── 右上角「🎬 确认生成」主按钮 ── */} + {onConfirmGenerate && ( + + )}

📝 选择标题

{!isBatch ? ( diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index a99b38db8..213e674d4 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -60,13 +60,21 @@ const Step6CoverSettings: React.FC = (props) => { * 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发 */ const shared = useSharedCover({ - canGenerate: !!finalVideo && !isBatch, + canGenerate: + !!finalVideo && + !isBatch && + (finalVideo.status === "completed" || + finalVideo.status === "awaiting_cover" || + !finalVideo.status), disabledHint: isBatch ? "批量场景请在上方操作卡片" : "请先生成视频再选择封面", - initialTemplateId: props.selectedTemplate || "default", + initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default generateFn: async (tplId) => { if (!finalVideo || isBatch) return null const response = await apiGenerateCover(tplId, { - generated_video_id: finalVideo.id, + generated_video_id: + (finalVideo as { id?: string; video_id?: string }).id || + (finalVideo as { video_id?: string }).video_id || + "", video_url: finalVideo.file_url || finalVideo.download_url || "", cover_type: "ai_frame", ...(props.titleSettings?.title diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx index 6accdeaf8..e6880e941 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -294,6 +294,122 @@ const TextStylePanel: React.FC<{ onChange={(v) => upd("rotation", v)} />
+ + {/* 文字背景(剪映样式) */} +
+
+ 文字背景 + upd("background", { ...config.background, enabled: v })} + /> +
+ {config.background?.enabled && ( +
+
+ +
+ + +
+
+
+ + upd("background", { ...config.background, color: v })} + /> +
+
+ + upd("background", { ...config.background, opacity: v })} + /> +
+
+ + { + // 圆角近似:通过 height 控制 + void v + }} + /> +
+
+ + upd("background", { ...config.background, width: v })} + /> +
+
+ + upd("background", { ...config.background, height: v })} + /> +
+
+ + upd("background", { ...config.background, posY: 50 + v })} + /> +
+
+ )} +
) } @@ -316,6 +432,59 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp const portraitFileRef = useRef(null) const [bgImageUrl, setBgImageUrl] = useState(initCfg.backgroundImage || "") const [portraitImageUrl, setPortraitImageUrl] = useState(initCfg.portraitImage || "") + /* ── 文字拖拽状态 ── */ + const canvasRef = useRef(null) + const dragRef = useRef(null) + + const handleTextMouseDown = ( + e: React.MouseEvent, + target: "title" | "subtitle", + ) => { + e.preventDefault() + e.stopPropagation() + const tc = target === "title" ? cfg.title : cfg.subtitle + if (!tc) return + dragRef.current = { + target, + startX: e.clientX, + startY: e.clientY, + startPosX: tc.position.x, + startPosY: tc.position.y, + } + const onMove = (ev: MouseEvent) => { + const d = dragRef.current + if (!d || !canvasRef.current) return + const rect = canvasRef.current.getBoundingClientRect() + const dx = ((ev.clientX - d.startX) / rect.width) * 100 + const dy = ((ev.clientY - d.startY) / rect.height) * 100 + const newX = Math.max(0, Math.min(100, d.startPosX + dx)) + const newY = Math.max(0, Math.min(100, d.startPosY + dy)) + if (d.target === "title") { + setCfg((prev) => ({ + ...prev, + title: { ...prev.title, position: { x: newX, y: newY } }, + })) + } else { + setCfg((prev) => ({ + ...prev, + subtitle: { ...prev.subtitle, position: { x: newX, y: newY } }, + })) + } + } + const onUp = () => { + dragRef.current = null + window.removeEventListener("mousemove", onMove) + window.removeEventListener("mouseup", onUp) + } + window.addEventListener("mousemove", onMove) + window.addEventListener("mouseup", onUp) + } // reset state when modal opens with a new template useEffect(() => { @@ -394,19 +563,6 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp const renderTextStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => { if (!tc) return {} - // wrap text by charsPerLine - const rawText = tc.text || "" - const lines: string[] = [] - if (tc.direction === "vertical") { - lines.push(rawText) - } else { - for (let i = 0; i < rawText.length; i += Math.max(1, tc.charsPerLine)) { - lines.push(rawText.slice(i, i + Math.max(1, tc.charsPerLine))) - } - } - // store rendered as lines via data attribute; for JSX we'll render outside style - void lines - const style: React.CSSProperties = { position: "absolute", left: `${tc.position.x}%`, @@ -422,9 +578,11 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp tc.strokeWidth > 0 ? `${Math.max(0.5, s(tc.strokeWidth))}px ${tc.strokeColor}` : undefined, whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "pre", writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined, - zIndex: 3, + zIndex: 4, textAlign: "center", userSelect: "none", + cursor: "grab", + padding: 0, } if (tc.shadows.length > 0) { style.textShadow = tc.shadows @@ -434,6 +592,35 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp return style } + const renderTextBgStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => { + if (!tc?.background?.enabled) return { display: "none" } + const bg = tc.background + const radius = bg.shape === "polygon" ? `${Math.max(4, Math.round(bg.height / 2))}px` : "0" + const alpha = Math.max(0, Math.min(1, bg.opacity / 100)) + const hex = (bg.color || "#000000").replace("#", "") + let r = 0, + g = 0, + b = 0 + if (hex.length === 6) { + r = parseInt(hex.substring(0, 2), 16) + g = parseInt(hex.substring(2, 4), 16) + b = parseInt(hex.substring(4, 6), 16) + } + const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})` + return { + position: "absolute", + left: `${bg.posX}%`, + top: `${bg.posY}%`, + width: `${bg.width}%`, + height: `${bg.height}%`, + transform: "translate(-50%, -50%)", + background: rgba, + borderRadius: radius, + zIndex: 3, + pointerEvents: "none", + } + } + const renderTextLines = (tc: TextStyleConfig | undefined | null): string => { if (!tc) return "" const raw = tc.text || "" @@ -857,7 +1044,7 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp -
+
{/* base background layer */}
= ({ open, onClose, temp /> )} - {/* Title */} + {/* Title bg */} + {cfg.title?.background?.enabled &&
} + {/* Subtitle bg */} + {cfg.subtitle?.background?.enabled &&
} + + {/* Title (draggable) */} {cfg.title && ( -
{renderTextLines(cfg.title)}
+
handleTextMouseDown(e, "title")} + > + {renderTextLines(cfg.title)} +
)} - {/* Subtitle */} + {/* Subtitle (draggable) */} {cfg.subtitle && ( -
{renderTextLines(cfg.subtitle)}
+
handleTextMouseDown(e, "subtitle")} + > + {renderTextLines(cfg.subtitle)} +
)} {/* Mask overlay */} diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx index c03ae826c..37923b6be 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -116,7 +116,7 @@ const CoverSettingsModal: React.FC = ({ onClick={() => onEditTemplate(tpl)} title={tpl.is_system ? "基于此模板新建自定义模板" : "编辑模板"} > - {tpl.is_system ? "复制" : "编辑"} + 编辑 {!tpl.is_system && (