From 07a0efefade4cd47e0cd7120d0f740c76527e355 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 25 Sep 2026 10:40:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor(cover):=20AI=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E4=BA=BA=E5=B0=81=E9=9D=A2=E5=A4=8D=E7=94=A8=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=89=AA=E8=BE=91=E5=85=B1=E4=BA=AB=E7=BB=84=E4=BB=B6=20(#2034?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 抽取共享 hook useSharedCover 统一封装模板CRUD/自动生成/编辑器/上传逻辑 - AI数字人 ModalCoverSelect 改为复用 CoverSettingsModal+CoverEditorModal,支持模板选择、7面板自定义编辑器、自动生成封面、本地上传 - generateRenderSmartCover 新增 templateId 参数(默认default),支持模板样式渲染;模板模式超时延长到 180s - Step6CoverSettings 改为基于 useSharedCover,删除重复的模板/编辑器状态逻辑 - 从 generate.css 抽取封面编辑器样式到 components/cover/cover.css,两个业务模块按需引入 - PanelCoverAndGenerate 精简为 setup 变体(分辨率/配置摘要/生成按钮),封面相关UI统一迁入 ModalCoverSelect - tsc/eslint/prettier 全过 --- apps/web/src/components/cover/cover.css | 563 ++++++++++++++++++ apps/web/src/components/cover/index.ts | 2 + .../src/components/cover/useSharedCover.ts | 219 +++++++ apps/web/src/pages/ai-avatar/AiAvatarPage.tsx | 8 +- apps/web/src/pages/ai-avatar/api/aiAvatar.ts | 11 +- .../ai-avatar/components/ModalCoverSelect.tsx | 341 ++++++++++- .../components/PanelCoverAndGenerate.tsx | 171 +----- .../components/Step6CoverSettings.tsx | 232 +++++--- .../cover-settings/CoverEditorModal.tsx | 1 + .../cover-settings/CoverSettingsModal.tsx | 1 + 10 files changed, 1257 insertions(+), 292 deletions(-) create mode 100644 apps/web/src/components/cover/cover.css create mode 100644 apps/web/src/components/cover/index.ts create mode 100644 apps/web/src/components/cover/useSharedCover.ts diff --git a/apps/web/src/components/cover/cover.css b/apps/web/src/components/cover/cover.css new file mode 100644 index 000000000..9bd4d08c0 --- /dev/null +++ b/apps/web/src/components/cover/cover.css @@ -0,0 +1,563 @@ +/** + * 共享封面编辑器样式(智能剪辑 generate + AI数字人 ai-avatar 共用) + * #2033:从 generate.css 抽取 xx-ce-* / xx-cover-template-* / xx-cover-modal-* 规则 + */ + +.xx-cover-modal-toolbar { + display: flex; + gap: 8px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.xx-cover-template-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.xx-cover-template-card { + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + cursor: pointer; + transition: border-color 0.2s; +} + +.xx-cover-template-card:hover { + border-color: var(--primary-color); +} + +.xx-cover-template-card.selected { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2); +} + +.xx-cover-template-thumb { + aspect-ratio: 9/16; + background: linear-gradient(135deg, #f0f0f0, #e0e0e0); + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + color: #ccc; + position: relative; +} + +.xx-cover-template-info { + padding: 8px; +} + +.xx-cover-template-name { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 500; + margin-bottom: 2px; +} + +.xx-cover-template-badge { + font-size: 11px; + color: #7c3aed; + background: rgba(124, 58, 237, 0.1); + padding: 1px 6px; + border-radius: 4px; + white-space: nowrap; +} + +.xx-cover-template-date { + font-size: 11px; + color: var(--text-tertiary); + margin-bottom: 6px; +} + +.xx-cover-template-actions { + display: flex; + gap: 4px; +} + +.xx-ce-header { + margin-bottom: 16px; +} + +.xx-ce-name-input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: var(--radius-sm, 6px); + font-size: 14px; + margin-bottom: 12px; + outline: none; +} + +.xx-ce-name-input:focus { + border-color: #7c3aed; +} + +.xx-ce-header-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.xx-ce-layout { + display: flex; + gap: 20px; + min-height: 500px; +} + +.xx-ce-left { + width: 300px; + flex-shrink: 0; + max-height: 70vh; + overflow-y: auto; +} + +.xx-ce-right { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: 8px; + min-height: 480px; +} + +.xx-ce-section { + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + margin-bottom: 8px; +} + +.xx-ce-section-header { + padding: 10px 12px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + background: #f0f4ff; + user-select: none; +} + +.xx-ce-section-header:hover { + background: #e8edf8; +} + +.xx-ce-section-body { + padding: 12px; + font-size: 12px; + color: var(--text-secondary, #666); +} + +.xx-ce-header-right { + display: flex; + align-items: center; + gap: 6px; +} + +.xx-ce-status-text { + font-size: 11px; + font-weight: 400; + color: #3b82f6; +} + +.xx-ce-row { + margin: 12px 0; +} + +.xx-ce-label { + display: block; + font-size: 12px; + color: #374151; + margin-bottom: 4px; +} + +.xx-ce-hint { + font-size: 11px; + color: #9ca3af; + margin-top: 4px; +} + +.xx-ce-sub-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; +} + +.xx-ce-switch-row { + display: flex; + align-items: center; + justify-content: space-between; +} + +.xx-ce-switch-item { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #f3f4f6; +} + +.xx-ce-switch-item:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.xx-ce-color-picker { + display: flex; + align-items: center; + gap: 6px; +} + +.xx-ce-color-picker input[type="color"] { + width: 32px; + height: 24px; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + background: none; +} + +.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.xx-ce-color-picker input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + +.xx-ce-color-hex { + width: 70px; + padding: 2px 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + font-family: monospace; +} + +.xx-ce-position { + display: flex; + gap: 8px; +} + +.xx-ce-position .ant-input-number { + flex: 1; +} + +.xx-ce-radio-group { + display: flex; + gap: 0; +} + +.xx-ce-radio-btn { + padding: 4px 14px; + font-size: 12px; + border: 1px solid #d1d5db; + background: #fff; + color: #374151; + cursor: pointer; + transition: all 0.15s; +} + +.xx-ce-radio-btn:first-child { + border-radius: 4px 0 0 4px; +} + +.xx-ce-radio-btn:last-child { + border-radius: 0 4px 4px 0; +} + +.xx-ce-radio-btn + .xx-ce-radio-btn { + border-left: none; +} + +.xx-ce-radio-btn.active { + background: #7c3aed; + color: #fff; + border-color: #7c3aed; +} + +.xx-ce-radio-btn.active + .xx-ce-radio-btn { + border-left: 1px solid #d1d5db; +} + +.xx-ce-font-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; +} + +.xx-ce-font-dot--preset { + background: #10b981; +} + +.xx-ce-font-dot--system { + background: #3b82f6; +} + +.xx-ce-shadow-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + +.xx-ce-add-shadow-btn { + padding: 4px 10px; + font-size: 12px; + background: #7c3aed; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.xx-ce-add-shadow-btn:hover { + background: #6d28d9; +} + +.xx-ce-preset-shadow-btn { + padding: 4px 10px; + font-size: 12px; + background: #fff; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; +} + +.xx-ce-text-bg-section { + margin-top: 8px; + padding: 8px; + background: #fafafa; + border-radius: 4px; + border: 1px solid #f0f0f0; +} + +.xx-ce-readonly-text { + padding: 6px 10px; + background: #eff6ff; + border-radius: 4px; + font-size: 13px; + color: #1e40af; + margin-bottom: 4px; +} + +.xx-ce-file-row { + display: flex; + gap: 6px; + align-items: center; +} + +.xx-ce-file-name { + flex: 1; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 4px; + font-size: 12px; + background: #f9fafb; + color: #6b7280; +} + +.xx-ce-file-btn { + padding: 4px 10px; + font-size: 12px; + background: #fff; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.xx-ce-file-btn:hover { + border-color: #7c3aed; + color: #7c3aed; +} + +.xx-ce-canvas-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.xx-ce-canvas { + width: 225px; + height: 400px; + background: #ddd; + position: relative; + border-radius: 8px; + overflow: hidden; +} + +.xx-ce-anchor-dot { + position: absolute; + width: 8px; + height: 8px; + background: #ef4444; + border-radius: 50%; + z-index: 5; +} + +.xx-ce-el-portrait { + position: absolute; + background: #a8d4f0; + border: 2px solid #333; + z-index: 2; +} + +.xx-ce-handle { + position: absolute; + width: 8px; + height: 8px; + background: #3b82f6; + border: 1px solid #fff; + z-index: 10; +} + +.xx-ce-handle--0 { + top: -4px; + left: -4px; +} + +.xx-ce-handle--1 { + top: -4px; + left: 50%; + margin-left: -4px; +} + +.xx-ce-handle--2 { + top: -4px; + right: -4px; +} + +.xx-ce-handle--3 { + top: 50%; + right: -4px; + margin-top: -4px; +} + +.xx-ce-handle--4 { + bottom: -4px; + right: -4px; +} + +.xx-ce-handle--5 { + bottom: -4px; + left: 50%; + margin-left: -4px; +} + +.xx-ce-handle--6 { + bottom: -4px; + left: -4px; +} + +.xx-ce-handle--7 { + top: 50%; + left: -4px; + margin-top: -4px; +} + +.xx-ce-el-bg { + position: absolute; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + z-index: 1; +} + +.xx-ce-el-mask { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +.xx-ce-text-bg { + position: absolute; + z-index: -1; +} + +.xx-cover-template-check { + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; + background: #7c3aed; + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + z-index: 2; + box-shadow: 0 2px 6px rgba(124, 58, 237, 0.4); +} + +.xx-cover-template-thumb { + position: relative; +} + +.xx-ce-preview-tip { + text-align: center; + margin-top: 12px; + font-size: 12px; + color: #6b7280; +} + +.xx-ce-canvas { + background: #1a1a2e; +} + +.xx-ce-section-body .ant-slider { + margin: 4px 0 8px; +} + +.xx-ce-section-body .ant-slider-rail { + background: #e5e7eb; +} + +.xx-ce-section-body .ant-slider-track { + background: #3b82f6; +} + +.xx-ce-section-body .ant-slider-handle::after { + box-shadow: 0 0 0 2px #3b82f6; +} + +.xx-ce-section-body .ant-slider-mark-text { + font-size: 10px; +} + +.xx-ce-font-select-dropdown .ant-select-item-option-content { + display: flex; + align-items: center; +} + +.xx-ce-canvas > div { + pointer-events: none; +} + +/* Text panel wrapper */ +.xx-ce-text-panel { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Canvas base gradient layer (behind all elements) */ +.xx-ce-canvas-base { + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient(135deg, #1e3a8a 0%, #312e81 100%); +} diff --git a/apps/web/src/components/cover/index.ts b/apps/web/src/components/cover/index.ts new file mode 100644 index 000000000..ec1c48ffa --- /dev/null +++ b/apps/web/src/components/cover/index.ts @@ -0,0 +1,2 @@ +export { useSharedCover } from "./useSharedCover" +export type { UseSharedCoverOptions, UseSharedCoverReturn } from "./useSharedCover" diff --git a/apps/web/src/components/cover/useSharedCover.ts b/apps/web/src/components/cover/useSharedCover.ts new file mode 100644 index 000000000..5f88e8e2b --- /dev/null +++ b/apps/web/src/components/cover/useSharedCover.ts @@ -0,0 +1,219 @@ +/** + * 共享封面选择 Hook(供智能剪辑 generate 与 AI 数字人 ai-avatar 共同使用) + * + * 能力: + * - 封面模板列表加载 / 选择 / 创建 / 编辑 / 删除(调用 /cover-templates 接口) + * - 自动生成封面按钮点击 → 调用调用方传入的 generateFn + * - 封面编辑器弹窗状态 + * - 本地封面上传文件选择 + */ +import type React from "react" +import { useCallback, useEffect, useRef, useState } from "react" +import { message } from "antd" +import type { CoverTemplate } from "@/pages/generate/types/cover" +import { + fetchCoverTemplates, + createCoverTemplate, + updateCoverTemplate, + deleteCoverTemplate, +} from "@/api/cover-templates" + +export interface UseSharedCoverOptions { + canGenerate: boolean + disabledHint?: string + generateFn: (templateId: string) => Promise + initialTemplateId?: string +} + +export interface UseSharedCoverReturn { + templates: CoverTemplate[] + templatesLoading: boolean + templatesError: string | null + selectedTemplateId: string + selectedTemplateName: string + handleSelectTemplate: (id: string) => void + reloadTemplates: () => void + showCoverSettings: boolean + setShowCoverSettings: (v: boolean) => void + showCoverEditor: boolean + setShowCoverEditor: (v: boolean) => void + editingTemplate: CoverTemplate | null + handleEditTemplate: (tpl: CoverTemplate) => void + handleCreateTemplate: () => void + handleSaveTemplate: (tpl: CoverTemplate) => Promise + handleDeleteTemplate: (id: string) => Promise + generating: boolean + generateAutoCover: () => Promise + uploadInputRef: React.RefObject + handleUploadClick: () => void + handleFileInputChange: (e: React.ChangeEvent) => void + setOnUploadFile: (fn: (file: File) => Promise | string | null) => void +} + +export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverReturn { + const { canGenerate, disabledHint, generateFn, initialTemplateId = "default" } = opts + const [generating, setGenerating] = useState(false) + const [showCoverSettings, setShowCoverSettings] = useState(false) + const [showCoverEditor, setShowCoverEditor] = useState(false) + const [selectedTemplateId, setSelectedTemplateId] = useState(initialTemplateId) + const [editingTemplate, setEditingTemplate] = useState(null) + const [templates, setTemplates] = useState([]) + const [templatesLoading, setTemplatesLoading] = useState(false) + const [templatesError, setTemplatesError] = useState(null) + const uploadInputRef = useRef(null) + const onUploadFileRef = useRef< + ((file: File) => Promise | string | null) | undefined + >(undefined) + + const setOnUploadFile = useCallback( + (fn: (file: File) => Promise | string | null) => { + onUploadFileRef.current = fn + }, + [], + ) + + const reloadTemplates = useCallback(async () => { + setTemplatesLoading(true) + setTemplatesError(null) + try { + const res = await fetchCoverTemplates() + setTemplates(res.items || []) + } catch (err) { + console.error("[SharedCover] 加载封面模板失败:", err) + setTemplatesError("加载模板失败,请稍后重试") + } finally { + setTemplatesLoading(false) + } + }, []) + + useEffect(() => { + if (showCoverSettings) { + void reloadTemplates() + } + }, [showCoverSettings, reloadTemplates]) + + const handleSelectTemplate = useCallback((id: string) => { + setSelectedTemplateId(id) + }, []) + + const handleEditTemplate = useCallback((tpl: CoverTemplate) => { + setEditingTemplate(tpl) + setShowCoverEditor(true) + }, []) + + const handleCreateTemplate = useCallback(() => { + setEditingTemplate(null) + setShowCoverEditor(true) + }, []) + + const handleSaveTemplate = useCallback( + async (tpl: CoverTemplate) => { + try { + if (tpl.id && templates.some((t) => t.id === tpl.id)) { + const updated = await updateCoverTemplate(tpl.id, { name: tpl.name, config: tpl.config }) + setTemplates((prev) => prev.map((t) => (t.id === tpl.id ? { ...t, ...updated } : t))) + } else { + const created = await createCoverTemplate({ name: tpl.name, config: tpl.config }) + setTemplates((prev) => [...prev, created]) + setSelectedTemplateId(created.id || tpl.id) + } + setShowCoverEditor(false) + } catch (err) { + console.error("[SharedCover] 保存模板失败:", err) + message.error("保存模板失败,请稍后重试") + } + }, + [templates], + ) + + const handleDeleteTemplate = useCallback( + async (id: string) => { + try { + await deleteCoverTemplate(id) + setTemplates((prev) => prev.filter((t) => t.id !== id)) + if (selectedTemplateId === id) { + setSelectedTemplateId("default") + } + } catch (err) { + console.error("[SharedCover] 删除模板失败:", err) + message.error("删除模板失败,请稍后重试") + } + }, + [selectedTemplateId], + ) + + const generateAutoCover = useCallback(async () => { + if (generating) { + message.warning("封面正在生成中,请稍候…") + return + } + if (!canGenerate) { + if (disabledHint) message.warning(disabledHint) + return + } + setGenerating(true) + try { + const url = await generateFn(selectedTemplateId || "default") + if (!url) { + message.warning("封面生成未返回图片,请重试") + } + } catch (err) { + console.error("[SharedCover] 自动生成封面失败:", err) + const anyErr = err as { __msgShown?: boolean; message?: string } + if (!anyErr?.__msgShown) { + message.error(anyErr?.message || "封面生成失败") + } + } finally { + setGenerating(false) + } + }, [generating, canGenerate, disabledHint, generateFn, selectedTemplateId]) + + const handleUploadClick = useCallback(() => { + uploadInputRef.current?.click() + }, []) + + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = "" + if (!file) return + if (onUploadFileRef.current) { + const ret = onUploadFileRef.current(file) + if (ret instanceof Promise) { + ret.catch((err) => { + console.error("[SharedCover] 上传封面失败:", err) + }) + } + } + }, []) + + const selectedTemplateName = + templates.find((t) => t.id === selectedTemplateId)?.name || + (selectedTemplateId === "default" ? "默认模板" : "自定义") + + return { + templates, + templatesLoading, + templatesError, + selectedTemplateId, + selectedTemplateName, + handleSelectTemplate, + reloadTemplates, + showCoverSettings, + setShowCoverSettings, + showCoverEditor, + setShowCoverEditor, + editingTemplate, + handleEditTemplate, + handleCreateTemplate, + handleSaveTemplate, + handleDeleteTemplate, + generating, + generateAutoCover, + uploadInputRef, + handleUploadClick, + handleFileInputChange, + setOnUploadFile, + } +} + +export default useSharedCover diff --git a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx index b3c0537f4..84c7017cd 100644 --- a/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx +++ b/apps/web/src/pages/ai-avatar/AiAvatarPage.tsx @@ -726,17 +726,11 @@ const AiAvatarPage: React.FC = () => {
{currentRenderJob?.status !== "completed" ? ( - state.setCoverConfig((prev) => ({ ...prev, ...partial })) - } - renderJob={currentRenderJob} - onGenerateRenderSmartCover={handleGenerateRenderSmartCover} resolution={state.resolution} onResolutionChange={state.setResolution} isGenerating={state.isGenerating} onGenerate={handleGenerate} + renderJob={currentRenderJob} summary={summary} /> ) : ( diff --git a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts index 83412bace..d8dbc2037 100644 --- a/apps/web/src/pages/ai-avatar/api/aiAvatar.ts +++ b/apps/web/src/pages/ai-avatar/api/aiAvatar.ts @@ -99,15 +99,18 @@ export const cancelRenderJob = async (jobId: string): Promise => { await apiClient.post(`/ai-avatar/render/${jobId}/cancel`) } -/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/renders/{job_id}/smart-cover) ── */ +/* ── 从最终渲染成片智能抽封面(POST /ai-avatar/render/{job_id}/smart-cover) ── + * #2033 共享封面组件:支持传 template_id(模板ID,传 default 走默认智能抽帧) + */ export const generateRenderSmartCover = async ( jobId: string, + templateId: string = "default", ): Promise<{ cover_url: string; status: string; message: string }> => { const response = await apiClient.post<{ cover_url: string; status: string; message: string }>( `/ai-avatar/render/${jobId}/smart-cover`, - {}, - // 抽帧+评分+转存 OSS 链路较长,120s 超时 - { timeout: 120000 }, + templateId && templateId !== "default" ? { template_id: templateId } : {}, + // 抽帧+评分+转存 OSS 链路较长,120s 超时;使用模板时叠加文字渲染再加 60s + { timeout: templateId && templateId !== "default" ? 180000 : 120000 }, ) return response.data } diff --git a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx index 55654476a..126af59dc 100644 --- a/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx +++ b/apps/web/src/pages/ai-avatar/components/ModalCoverSelect.tsx @@ -1,11 +1,25 @@ /** - * AI数字人 — 封面选择弹窗 - * 渲染完成后由主页面唤起,内部用 PanelCoverAndGenerate(select-cover 变体)提供 - * 智能抽帧 + 自定义上传 + 预览 + 确定按钮。 + * AI数字人 — 封面选择弹窗(#2033 共享封面组件重构) + * + * 复用智能剪辑的 CoverSettingsModal(模板选择)+ CoverEditorModal(7 面板自定义编辑器) + * + 智能生成 / 本地上传 / 封面预览,与智能剪辑侧 UI 一致。 + * + * 父组件仍维持 AiAvatarCoverConfig { mode, smart_cover_url, upload_url, thumbnail_url } 结构: + * - 智能生成封面:mode="auto_frame",thumbnail_url/smart_cover_url 指向后端返回的 cover_url + * - 本地上传封面:mode="upload",upload_url/thumbnail_url 指向 blob 预览 URL + * + * 模板 CRUD 通过 @/api/cover-templates 统一接口(智能剪辑与 AI数字人共享同一套模板库)。 */ -import React from "react" +import React, { useCallback, useEffect, useMemo } from "react" +import { Modal as AntModal, Spin, message } from "antd" +import { LoadingOutlined } from "@ant-design/icons" +import Modal from "@/components/ui/Modal" +import Button from "@/components/ui/Button" +import CoverSettingsModal from "@/pages/generate/components/cover-settings/CoverSettingsModal" +import CoverEditorModal from "@/pages/generate/components/cover-settings/CoverEditorModal" +import { useSharedCover } from "@/components/cover/useSharedCover" +import { generateRenderSmartCover as apiGenerateSmartCover } from "../api/aiAvatar" import type { AiAvatarCoverConfig, RenderJob } from "../types" -import PanelCoverAndGenerate from "./PanelCoverAndGenerate" interface ModalCoverSelectProps { open: boolean @@ -13,7 +27,13 @@ interface ModalCoverSelectProps { renderJob: RenderJob | null coverConfig: AiAvatarCoverConfig onCoverConfigChange: (partial: Partial) => void - onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> + /** + * 【保留兼容】老接口:单参 renderId;新接口支持 templateId 由本组件内部直接调用,不再需要父层传入 + * 如果父层传了该回调,本组件的"自动生成封面"按钮会调用它;否则走本组件内部 apiGenerateSmartCover。 + */ + onGenerateRenderSmartCover?: ( + renderId: string, + ) => Promise<{ cover_url: string; message?: string }> onUploadCover?: (file: File) => void onCoverSelected: (coverUrl: string) => void } @@ -28,31 +48,302 @@ const ModalCoverSelect: React.FC = ({ onUploadCover, onCoverSelected, }) => { + const isRenderCompleted = renderJob?.status === "completed" && !!renderJob?.id + + const generateFn = useCallback( + async (templateId: string): Promise => { + if (!renderJob || !isRenderCompleted) return null + try { + let coverUrl = "" + if (onGenerateRenderSmartCover) { + const res = await onGenerateRenderSmartCover(renderJob.id) + coverUrl = res.cover_url + } else { + const res = await apiGenerateSmartCover(renderJob.id, templateId) + coverUrl = res.cover_url + if (!coverUrl && res.message) { + const err = new Error(res.message) as Error & { __msgShown?: boolean } + err.__msgShown = true + message.error(res.message) + throw err + } + } + if (coverUrl) { + onCoverConfigChange({ + mode: "auto_frame", + thumbnail_url: coverUrl, + smart_cover_url: coverUrl, + }) + onCoverSelected(coverUrl) + message.success("智能封面已生成") + } + return coverUrl || null + } catch (err) { + const anyErr = err as { __msgShown?: boolean; message?: string } + if (!anyErr?.__msgShown) { + message.error(anyErr?.message || "智能封面生成失败") + } + throw err + } + }, + [ + renderJob, + isRenderCompleted, + onGenerateRenderSmartCover, + onCoverConfigChange, + onCoverSelected, + ], + ) + + const shared = useSharedCover({ + canGenerate: isRenderCompleted, + disabledHint: "请先完成视频生成再选择封面", + initialTemplateId: "default", + generateFn, + }) + + // 父层 onUploadCover 走 onUploadFile 回调(兼容老父组件) + useEffect(() => { + shared.setOnUploadFile((file: File) => { + if (onUploadCover) { + onUploadCover(file) + } else { + const url = URL.createObjectURL(file) + onCoverConfigChange({ + mode: "upload", + upload_url: url, + thumbnail_url: url, + }) + onCoverSelected(url) + } + return null + }) + }, [shared, onUploadCover, onCoverConfigChange, onCoverSelected]) + + // 打开时同步刷新模板列表 + useEffect(() => { + if (open) void shared.reloadTemplates() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + /** 当前预览 URL:智能封面 > 自定义上传 */ + const previewUrl = useMemo( + () => coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url || "", + [coverConfig.smart_cover_url, coverConfig.thumbnail_url, coverConfig.upload_url], + ) + if (!open) return null return ( -
-
e.stopPropagation()} style={{ maxWidth: 480 }}> -
- 选择封面 - + + +
-
- + } + > +
+ {renderJob && ( +
+ 🎬 从渲染成片中智能选帧 + {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( + <> + {" "} + · 当前模板:{shared.selectedTemplateName} + + )} +
+ )} + +
+ {/* 左:封面预览 */} +
+
+ {previewUrl ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {isRenderCompleted ? "点击下方按钮生成/上传" : "视频生成后可选择封面"} + +
+ )} + {shared.generating && ( +
+ } /> + AI 选帧中… +
+ )} +
+
+ 9:16 竖版封面 +
+
+ + {/* 右:操作按钮 */} +
+ + + + +
+ 💡 选择模板后点击"自动生成封面"会按模板样式渲染;"本地上传"使用本地图片作为封面。 +
+
-
+ + {/* 模板选择弹窗 */} + shared.setShowCoverSettings(false)} + templates={shared.templates} + loading={shared.templatesLoading} + error={shared.templatesError} + selectedTemplateId={shared.selectedTemplateId} + onSelectTemplate={shared.handleSelectTemplate} + onEditTemplate={shared.handleEditTemplate} + onDeleteTemplate={shared.handleDeleteTemplate} + onCreateNew={shared.handleCreateTemplate} + /> + + {/* 自定义编辑器弹窗 */} + shared.setShowCoverEditor(false)} + template={shared.editingTemplate} + onSave={shared.handleSaveTemplate} + /> + + {/* 自动生成 loading 兜底弹窗(shared.generating 时按钮已自带 loading,这里保险) */} + +
+ +

+ AI 正在从最终成片选帧,请稍候... +

+
+
+ ) } diff --git a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx index 72a6c62f3..feda83d02 100644 --- a/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx +++ b/apps/web/src/pages/ai-avatar/components/PanelCoverAndGenerate.tsx @@ -1,36 +1,19 @@ /** - * AI数字人 — 面板5 / 封面选择弹窗内容: - * - variant="setup"(默认):分辨率 / 配置摘要 / 「开始生成视频」按钮,用于主页面步骤2配置阶段; - * 渲染完成后仍内嵌封面预览与按钮,方便不打开弹窗直接操作。 - * - variant="select-cover":只渲染封面选择区(智能获取封面 + 自定义上传 + 预览), - * 用于 ModalCoverSelect 弹窗中;传 onClose 时底部显示「确定」按钮。 - * - * 封面一律从最终成片(已叠加标题/B-roll)抽帧,本面板不再叠加标题。 + * AI数字人 — 面板5 / 生成配置面板(渲染前) + * #2033 重构后:只保留 setup 变体(分辨率/配置摘要/生成按钮) + * 封面相关功能已迁移到 ModalCoverSelect(复用智能剪辑共享封面组件) */ -import React, { useRef, useState } from "react" -import type { AiAvatarCoverConfig, RenderJob } from "../types" - -type PanelVariant = "setup" | "select-cover" +import React from "react" +import type { RenderJob } from "../types" interface PanelCoverAndGenerateProps { - variant?: PanelVariant - coverConfig: AiAvatarCoverConfig - onCoverConfigChange: (partial: Partial) => void resolution?: string onResolutionChange?: (r: string) => void isGenerating?: boolean onGenerate?: () => void - /** 当前渲染任务(渲染完成后才有 output_video_url,才能抽封面) */ + /** 当前渲染任务 */ renderJob: RenderJob | null - /** 从最终成片智能抽帧(参数 renderId),返回 { cover_url } */ - onGenerateRenderSmartCover: (renderId: string) => Promise<{ cover_url: string; message?: string }> - /** 自定义上传封面(选择本地文件后由父组件处理实际上传) */ - onUploadCover?: (file: File) => void - /** 弹窗关闭回调(传入则表示在弹窗中使用,底部显示「确定」按钮) */ - onClose?: () => void - /** 封面选好(智能抽帧/自定义上传成功)后通知父组件,参数为封面 URL */ - onCoverSelected?: (coverUrl: string) => void - /** 配置汇总信息(仅 variant="setup" 使用) */ + /** 配置汇总信息 */ summary?: { videoName: string | null voiceName: string | null @@ -38,7 +21,6 @@ interface PanelCoverAndGenerateProps { lipsyncStatus: string | null brollCount: number hasTitle: boolean - /** 封面状态:'not_ready'(视频未生成) / 'pending'(视频生成了但未选) / 'selected'(已选) */ coverStatus: "not_ready" | "pending" | "selected" } } @@ -58,89 +40,15 @@ const LIPSYNC_STATUS_LABEL: Record = { } const PanelCoverAndGenerate: React.FC = ({ - variant = "setup", - coverConfig, - onCoverConfigChange, resolution = "720p", onResolutionChange, isGenerating = false, onGenerate, - renderJob, - onGenerateRenderSmartCover, - onUploadCover, - onClose, - onCoverSelected, + renderJob: _renderJob, summary, }) => { - const uploadInputRef = useRef(null) - // 内部维护智能封面加载态(修复点 2 次 bug:不依赖外层异步 setState 顺序) - const [smartCoverLoading, setSmartCoverLoading] = useState(false) - - /** 自定义上传封面 */ - const handleUploadClick = () => { - uploadInputRef.current?.click() - } - - const _applyCoverUrl = (url: string, mode: "upload" | "auto_frame") => { - const partial: Partial = { - mode, - thumbnail_url: url, - } - if (mode === "auto_frame") { - partial.smart_cover_url = url - } else { - partial.upload_url = url - } - onCoverConfigChange(partial) - onCoverSelected?.(url) - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - if (onUploadCover) { - onUploadCover(file) - e.target.value = "" - return - } - // 本地预览兜底(实际上传由父级处理;blob URL 仅作本地展示) - const url = URL.createObjectURL(file) - _applyCoverUrl(url, "upload") - e.target.value = "" - } - - /** 智能获取封面(从最终成片抽帧;必须等 render 完成) */ - const handleSmartCover = async () => { - if (!renderJob || renderJob.status !== "completed" || !renderJob.id) return - setSmartCoverLoading(true) - try { - const res = await onGenerateRenderSmartCover(renderJob.id) - if (res.cover_url) { - _applyCoverUrl(res.cover_url, "auto_frame") - } else { - // 失败由父组件 message 提示,这里不重复弹窗 - console.warn("[智能封面] 返回空 cover_url:", res.message) - } - } catch (err) { - console.error("[智能封面] 调用失败:", err) - } finally { - setSmartCoverLoading(false) - } - } - const lipsync = summary?.lipsyncStatus ? LIPSYNC_STATUS_LABEL[summary.lipsyncStatus] : null const canGenerate = summary?.lipsyncStatus === "completed" && !isGenerating - // 渲染已完成 → 封面区可用 - const isRenderCompleted = renderJob?.status === "completed" - const canSmartCover = isRenderCompleted && !smartCoverLoading - - /** 封面图实际展示的 url:智能封面 > 自定义上传 > 空 */ - const coverUrl = - coverConfig.smart_cover_url || coverConfig.thumbnail_url || coverConfig.upload_url - const hasCoverImage = Boolean(coverUrl) - - /** 封面区占位文字 */ - const coverPlaceholder = isRenderCompleted ? "暂无封面" : "视频生成后可选择封面" /** 配置摘要中的封面状态标签 */ const coverSummaryNode = (() => { @@ -154,69 +62,6 @@ const PanelCoverAndGenerate: React.FC = ({ return 生成视频后可选 })() - // ── 封面选择区(两种 variant 共用) ───────────────────────────────── - const coverSection = ( -
-
- {variant === "select-cover" ? "选择封面" : "封面"} -
- {/* 封面预览(竖屏 9:16)——成片帧已经通过 Canvas PNG overlay 带有标题,直接展示原图即可 */} -
- {hasCoverImage ? ( - 封面预览 - ) : ( - {coverPlaceholder} - )} - {smartCoverLoading &&
⏳ 智能选帧中…
} -
- -
- - - -
-
- ) - - // ── select-cover 变体:只渲染封面区 + 弹窗确定按钮 ── - if (variant === "select-cover") { - return ( -
- {coverSection} - {onClose && ( -
- -
- )} -
- ) - } - - // ── setup 变体:分辨率 / 配置摘要 / 生成按钮(渲染完成后内嵌封面区) ── return (
{/* 分辨率选择 */} diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index c28f03cec..1630cf697 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,83 +1,115 @@ /** - * Step 5 选择封面(Issue #1677 批量生成改造) - * - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览) + * Step 5/6 选择封面(Issue #1677 批量生成改造 + #2033 封面bug修复) + * - 单视频:保留原封面流程(自动生成/封面设置模板/封面预览/自定义上传) * - N 个视频:N 张封面卡片,每张带对应视频标题,可逐个自动生成或上传 + * + * 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover */ -import React, { useRef } from "react" +import React, { useEffect } from "react" import { Modal, Spin } from "antd" import { LoadingOutlined } from "@ant-design/icons" import type { CoverConfig } from "../types/cover" import type { GeneratedVideo } from "@/api/template-editor" import type { TitleSettings } from "../types" -import { useStep6Cover } from "../hooks/useStep6Cover" import { useBatchCovers } from "../hooks/useBatchCovers" import Button from "@/components/ui/Button" import CoverSettingsModal from "./cover-settings/CoverSettingsModal" import CoverEditorModal from "./cover-settings/CoverEditorModal" +import { useSharedCover } from "@/components/cover/useSharedCover" +import { generateCover as apiGenerateCover } from "@/api/generation" interface Step6CoverSettingsProps { coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void - /** 当前选中的模板 ID */ selectedTemplate?: string - /** Step4 标题设置,用于封面叠加标题 */ titleSettings?: TitleSettings - /** 确认生成步骤产出的最终视频列表 */ generatedVideos: GeneratedVideo[] - /* ── 批量生成(#1677)── */ previewCount?: number - /** 每个变体的标题文字 */ previewTitles?: string[] - /** 每个变体的封面URL(按变体索引) */ previewCovers?: string[] onPreviewCoversChange?: (urls: string[]) => void - /** 勾选的变体索引(批量封面按此顺序展示,与最终成片顺序一致) */ selectedVariantIndexes?: number[] } const Step6CoverSettings: React.FC = (props) => { - const { - coverSettings, - generating, - generateAutoCover, - finalVideo, - showCoverSettings, - setShowCoverSettings, - showCoverEditor, - setShowCoverEditor, - selectedTemplateId, - editingTemplate, - coverTemplates, - templatesLoading, - templatesError, - handleSelectTemplate, - handleEditTemplate, - handleSaveTemplate, - handleDeleteTemplate, - } = useStep6Cover({ - coverSettings: props.coverSettings, - onCoverSettingsChange: props.onCoverSettingsChange, - selectedTemplate: props.selectedTemplate, - titleSettings: props.titleSettings, - generatedVideos: props.generatedVideos, - }) - const previewCount = props.previewCount || 1 const isBatch = previewCount > 1 const previewTitles = props.previewTitles || [] const previewCovers = props.previewCovers || [] - /** 卡片展示的变体索引顺序:批量=勾选顺序(与成片顺序一致),单视频=[0] */ const cardIndexes = isBatch && props.selectedVariantIndexes?.length ? props.selectedVariantIndexes : Array.from({ length: previewCount }, (_, i) => i) - const uploadInputRef = useRef(null) - const uploadTargetRef = useRef(0) - const completedVideos = props.generatedVideos.filter( - (v) => - v.status === "completed" || v.status === "awaiting_cover" || v.status === "awaiting_cover", - ) + /** 最终成片:取第一个已完成视频(单视频场景) */ + const finalVideo = + props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") || + props.generatedVideos[0] + + const shared = useSharedCover({ + canGenerate: !!finalVideo, + disabledHint: "请先生成视频再选择封面", + initialTemplateId: "default", + generateFn: async (tplId) => { + if (!finalVideo) return null + const response = await apiGenerateCover(tplId, { + generated_video_id: finalVideo.id, + video_url: finalVideo.file_url || finalVideo.download_url || "", + cover_type: "ai_frame", + ...(props.titleSettings?.title + ? { + title_config: { + text: props.titleSettings.title, + font: props.titleSettings.font, + font_size: props.titleSettings.size, + font_color: props.titleSettings.color, + position: props.titleSettings.position, + bold: props.titleSettings.bold, + stroke: props.titleSettings.stroke, + shadow: props.titleSettings.shadow, + }, + } + : {}), + }) + const url = response.cover?.image_url || "" + if (url) { + props.onCoverSettingsChange({ + ...props.coverSettings, + thumbnail_url: url, + ai_suggested_time: response.cover?.frame_time ?? null, + }) + } + return url + }, + }) + + useEffect(() => { + shared.setOnUploadFile(() => null) + }, [shared]) + + const batchUploadRef = React.useRef(null) + const [batchUploadCard, setBatchUploadCard] = React.useState(null) + const handleBatchUploadClick = (cardPos: number) => { + setBatchUploadCard(cardPos) + batchUploadRef.current?.click() + } + const handleBatchUploadChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + e.target.value = "" + const cardPos = batchUploadCard + setBatchUploadCard(null) + if (!file || cardPos == null) return + const url = URL.createObjectURL(file) + const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "") + const nextCardView = [...prevCardView] + nextCardView[cardPos] = url + const next = [...(props.previewCovers || [])] + cardIndexes.forEach((vi, i) => { + next[vi] = nextCardView[i] || "" + }) + props.onPreviewCoversChange?.(next) + } + const batchTitles = cardIndexes.map((vi) => previewTitles[vi] || "") const batchCoversList = cardIndexes.map((vi) => previewCovers[vi] || "") const batchCovers = useBatchCovers({ @@ -95,7 +127,6 @@ const Step6CoverSettings: React.FC = (props) => { }, covers: batchCoversList, onCoversChange: (updater) => { - // 按卡片顺序写回对应变体索引;支持函数式 updater(#1750:串行回写避免闭包覆盖) const prevCardView = cardIndexes.map((vi) => (props.previewCovers || [])[vi] || "") const nextCardView = typeof updater === "function" ? updater(prevCardView) : updater const next = [...(props.previewCovers || [])] @@ -106,22 +137,11 @@ const Step6CoverSettings: React.FC = (props) => { }, }) - const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url + const previewUrl = props.coverSettings.thumbnail_url || props.coverSettings.upload_url - const handleUploadClick = (variantIndex: number) => { - uploadTargetRef.current = variantIndex - uploadInputRef.current?.click() - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = "" - if (file) { - const variantIndex = uploadTargetRef.current - const cardPos = cardIndexes.indexOf(variantIndex) - if (cardPos >= 0) void batchCovers.uploadOne(cardPos, file) - } - } + const completedVideos = props.generatedVideos.filter( + (v) => v.status === "completed" || v.status === "awaiting_cover", + ) /* ── 批量封面 ── */ if (isBatch) { @@ -212,7 +232,7 @@ const Step6CoverSettings: React.FC = (props) => { type="button" className="xx-btn xx-btn-ghost xx-btn-sm" style={{ flex: 1, fontSize: 12, padding: "4px 8px" }} - onClick={() => handleUploadClick(variantIndex)} + onClick={() => handleBatchUploadClick(cardPos)} disabled={isLoading || isUploading} > 📤 上传 @@ -222,24 +242,15 @@ const Step6CoverSettings: React.FC = (props) => { ) })}
- -
) } - /* ── 单视频:原有流程保持不变 ── */ + /* ── 单视频 ── */ return (

🖼️ 选择封面

- {/* 最终成片信息 */} {finalVideo && (
= (props) => { }} > 🎬 封面将从最终成片「{finalVideo.name}」中智能选帧 + {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( + <> + {" "} + · 当前模板:{shared.selectedTemplateName} + + )}
)}
- - + + { + const file = e.target.files?.[0] + e.target.value = "" + if (!file) return + const url = URL.createObjectURL(file) + props.onCoverSettingsChange({ + ...props.coverSettings, + upload_url: url, + thumbnail_url: url, + mode: "upload", + }) + }} + /> +
封面预览
@@ -279,30 +329,26 @@ const Step6CoverSettings: React.FC = (props) => {
setShowCoverSettings(false)} - templates={coverTemplates} - loading={templatesLoading} - error={templatesError} - selectedTemplateId={selectedTemplateId} - onSelectTemplate={handleSelectTemplate} - onEditTemplate={handleEditTemplate} - onDeleteTemplate={handleDeleteTemplate} - onCreateNew={() => { - setShowCoverSettings(false) - setShowCoverEditor(true) - }} + open={shared.showCoverSettings} + onClose={() => shared.setShowCoverSettings(false)} + templates={shared.templates} + loading={shared.templatesLoading} + error={shared.templatesError} + selectedTemplateId={shared.selectedTemplateId} + onSelectTemplate={shared.handleSelectTemplate} + onEditTemplate={shared.handleEditTemplate} + onDeleteTemplate={shared.handleDeleteTemplate} + onCreateNew={shared.handleCreateTemplate} /> setShowCoverEditor(false)} - template={editingTemplate} - onSave={handleSaveTemplate} + open={shared.showCoverEditor} + onClose={() => shared.setShowCoverEditor(false)} + template={shared.editingTemplate} + onSave={shared.handleSaveTemplate} /> - {/* AI 生成封面进度弹窗 */} - +

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 83d561bd8..24062538b 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -10,6 +10,7 @@ import type { import { DEFAULT_EDITOR_CONFIG, PRESET_FONTS, SYSTEM_FONTS, ALL_FONTS } from "../../types/cover" import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" +import "@/components/cover/cover.css" /* ── Props ── */ interface CoverEditorModalProps { 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 e9a124a52..c4e4423bf 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverSettingsModal.tsx @@ -2,6 +2,7 @@ import React from "react" import type { CoverTemplate } from "../../types/cover" import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" +import "@/components/cover/cover.css" interface CoverSettingsModalProps { open: boolean