From 3d6ea425c6b929b3176af93cb0638b8cf2bf099f Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 26 Sep 2026 13:08:24 +0800 Subject: [PATCH] =?UTF-8?q?fix(cover):=20=E6=96=87=E5=AD=97=E8=83=8C?= =?UTF-8?q?=E6=99=AF=E8=B7=9F=E9=9A=8F=E6=96=87=E5=AD=97=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=20+=20=E8=87=AA=E5=8A=A8=E7=94=9F=E6=88=90=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 文字背景跟随文字(问题1): - TextBackground 接口移除 posX/posY/rotation,改为 offsetY(相对文字上下偏移%) - renderTextBgStyle 背景 left/top 绑定文字 tc.position.x/y,拖拽时背景自动跟随 - offsetY 滑块范围改为 -30~30,仅做上下微调 - 副标题默认黑底 offsetY=8%(贴近文字底部) - mergeEditorConfig 增加老模板字段兼容(自动丢弃 posX/posY/rotation) 2. 自动生成封面按钮点不动(问题2): - 新增 currentTaskId prop 从 GeneratePage 透传到 Step6CoverSettings - awaiting_cover 阶段 finalVideo 可能是 preview-{taskId} 合成对象: * 视频 id 以 preview- 开头时不再作为 generated_video_id 传给后端 (让后端走 plan.config.generation_task_id/rendered_storage_key 兜底) - 传 video_url 作为抽帧兜底(finalVideo.file_url/download_url) - canGenerate 条件放宽:有 finalVideo 对象或 currentTaskId 即可点 - 信息条 banner 也在有 taskId 时显示 - 请求/响应加 console.log 便于后续排查 - disabledHint 区分原因(未生成/视频未就绪/批量) --- apps/web/src/pages/generate/GeneratePage.tsx | 2 + .../components/GenerateStepContent.tsx | 3 + .../components/Step6CoverSettings.tsx | 108 ++++++++++++------ .../cover-settings/CoverEditorModal.tsx | 38 ++++-- apps/web/src/pages/generate/types/cover.ts | 13 +-- 5 files changed, 113 insertions(+), 51 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index cd5ac4adb..cc5b09c86 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -562,6 +562,8 @@ const GeneratePage: React.FC = () => { generateError={generateError} progress={progress} generatedVideos={generatedVideos} + + currentTaskId={currentTaskId} onRetry={handleRetryGenerate} onRetryBatchTask={handleRetryBatchTask} onDismissError={handleDismissError} diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index a1bab19e1..2d29b3904 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 + /** 单视频任务 ID(兜底,awaiting_cover 状态下 results 接口未入库时用) */ + currentTaskId?: string /** Step3 右上角确认生成按钮 */ onConfirmGenerate?: () => void | Promise } @@ -264,6 +266,7 @@ export const GenerateStepContent: React.FC = (props) = selectedVariantIndexes={selectedVariantIds} selectedTemplate={selectedCoverTemplate} onTemplateChange={onSelectedCoverTemplateChange} + currentTaskId={props.currentTaskId} /> ) default: diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index 213e674d4..a94c0275e 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -30,6 +30,8 @@ interface Step6CoverSettingsProps { onPreviewCoversChange?: (urls: string[]) => void selectedVariantIndexes?: number[] onTemplateChange?: (templateId: string) => void + /** 单视频任务 ID(awaiting_cover 阶段 results 接口可能返回 preview-xxx 合成对象,兜底用) */ + currentTaskId?: string } const Step6CoverSettings: React.FC = (props) => { @@ -47,6 +49,32 @@ const Step6CoverSettings: React.FC = (props) => { props.generatedVideos.find((v) => v.status === "completed" || v.status === "awaiting_cover") || props.generatedVideos[0] + /** + * 兜底任务/视频 ID:awaiting_cover 阶段后端 /results 可能还没有入库 GeneratedVideo, + * 只返回合成的 preview-{taskId} 轻量对象;此时用 currentTaskId 兜底让后端能找到任务。 + * 同时统一抽取 taskId(generation_task_id 优先)用于日志/错误提示。 + */ + const effectiveTaskId = + (finalVideo as { generation_task_id?: string } | undefined)?.generation_task_id || + props.currentTaskId || + "" + const _rawVideoId = + (finalVideo as { id?: string; video_id?: string } | undefined)?.id || + (finalVideo as { video_id?: string } | undefined)?.video_id || + "" + // preview-{taskId} 是后端合成的临时 id,gv_repo.get 查不到 → 不传 generated_video_id, + // 让后端走 plan.config.generation_task_id / rendered_storage_key 兜底路径。 + const effectiveVideoId = _rawVideoId && !_rawVideoId.startsWith("preview-") ? _rawVideoId : "" + const effectiveVideoUrl = finalVideo?.file_url || finalVideo?.download_url || "" + + /** 按钮可用:非批量 且 (有 finalVideo 对象或兜底 taskId) 且 视频状态已完成/等待封面/未设置 */ + const isVideoReady = + !finalVideo || + finalVideo.status === "completed" || + finalVideo.status === "awaiting_cover" || + !finalVideo.status + const canGenerateCover = !isBatch && (!!finalVideo || !!effectiveTaskId) && isVideoReady + const completedVideos = useMemo( () => props.generatedVideos.filter( @@ -60,45 +88,58 @@ const Step6CoverSettings: React.FC = (props) => { * 批量场景 canGenerate=false,避免 shared.generateAutoCover 被误触发 */ const shared = useSharedCover({ - canGenerate: - !!finalVideo && - !isBatch && - (finalVideo.status === "completed" || - finalVideo.status === "awaiting_cover" || - !finalVideo.status), - disabledHint: isBatch ? "批量场景请在上方操作卡片" : "请先生成视频再选择封面", + canGenerate: canGenerateCover, + disabledHint: isBatch + ? "批量场景请在上方操作卡片" + : !finalVideo && !effectiveTaskId + ? "请先生成视频再选择封面" + : "视频尚未就绪,请稍候", initialTemplateId: "default", // 封面模板独立于编辑模板,默认用 default generateFn: async (tplId) => { - if (!finalVideo || isBatch) return null - const response = await apiGenerateCover(tplId, { - 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 || "", + if (isBatch) return null + if (!finalVideo && !effectiveTaskId) { + console.warn("[Cover] generateAutoCover: no finalVideo and no taskId") + return null + } + // 请求体:generated_video_id 仅在后端已入库(非 preview-xxx 合成id)时传; + // video_url 兜底让后端能直接下载视频抽帧;generation_task_id 后端已从 plan.config 自动读取。 + const requestBody: { + generated_video_id?: string + video_url?: string + cover_type: "ai_frame" + title_config?: Record + } = { 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 (effectiveVideoId) { + requestBody.generated_video_id = effectiveVideoId + } + if (effectiveVideoUrl) { + requestBody.video_url = effectiveVideoUrl + } + if (props.titleSettings?.title) { + requestBody.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, + } + } + console.log("[Cover] auto-generate request:", { tplId, ...requestBody }) + const response = await apiGenerateCover(tplId, requestBody) + const url = response.cover?.image_url || response.cover?.thumbnail_url || "" if (url) { props.onCoverSettingsChange({ ...props.coverSettings, thumbnail_url: url, ai_suggested_time: response.cover?.frame_time ?? null, }) + } else { + console.warn("[Cover] generate returned empty url:", response) } return url }, @@ -323,7 +364,7 @@ const Step6CoverSettings: React.FC = (props) => {

🖼️ 选择封面

- {finalVideo && ( + {(finalVideo || effectiveTaskId) && (
= (props) => { color: "var(--text-secondary, #666)", }} > - 🎬 封面将从最终成片「{finalVideo.name}」中智能选帧 + 🎬 封面将从最终成片{finalVideo?.name ? `「${finalVideo.name}」` : ""}中智能选帧 {shared.selectedTemplateId && shared.selectedTemplateId !== "default" && ( <> {" "} @@ -349,8 +390,9 @@ const Step6CoverSettings: React.FC = (props) => { 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 e6880e941..7c4d85738 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -16,6 +16,19 @@ import "@/components/cover/cover.css" const mergeEditorConfig = (partial?: Partial | null): CoverEditorConfig => { const def = DEFAULT_EDITOR_CONFIG const src = partial || {} + /** 兼容老模板:老版本 background 有 posX/posY/rotation,新版改为 offsetY(相对文字位置偏移)。 + * 老模板黑底默认 posY 通常是 50(与文字对齐)或 80(副标题偏下),统一归一为 offsetY=0, + * 因为新版背景位置已自动跟随文字位置,offsetY 仅做相对微调。 */ + const normalizeBg = (bg: Record | undefined) => { + if (!bg) return {} + // 兼容老模板字段:posX/posY/rotation 在新版中已改为 offsetY(背景位置自动跟随文字) + const normalized = { ...bg } + delete (normalized as Record).posX + delete (normalized as Record).posY + delete (normalized as Record).rotation + if (normalized.offsetY == null) normalized.offsetY = 0 + return normalized + } const mergeText = ( base: TextStyleConfig, patch?: Partial | null, @@ -23,7 +36,10 @@ const mergeEditorConfig = (partial?: Partial | null): CoverEd ...base, ...(patch || {}), position: { ...base.position, ...(patch?.position || {}) }, - background: { ...base.background, ...(patch?.background || {}) }, + background: { + ...base.background, + ...normalizeBg(patch?.background as Record | undefined), + }, shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows], }) return { @@ -398,13 +414,13 @@ const TextStylePanel: React.FC<{ />
- + upd("background", { ...config.background, posY: 50 + v })} + value={config.background.offsetY ?? 0} + onChange={(v) => upd("background", { ...config.background, offsetY: v })} />
@@ -595,7 +611,9 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp 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" + // 背景位置跟随文字:left/top 对齐文字中心,用 offsetY(-50~50% 相对文字位置)做上下微调 + // 这样文字拖拽时背景会自动跟随,不需要独立的位置控制 + const radius = bg.shape === "polygon" ? `${Math.max(4, Math.round(bg.height / 4))}px` : "0" const alpha = Math.max(0, Math.min(1, bg.opacity / 100)) const hex = (bg.color || "#000000").replace("#", "") let r = 0, @@ -607,10 +625,12 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp b = parseInt(hex.substring(4, 6), 16) } const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})` + // bg.offsetY 是相对文字位置的上下偏移(-50~50,单位%画布高度),默认 0 表示与文字中心对齐 + const offsetY = typeof bg.offsetY === "number" ? bg.offsetY : 0 return { position: "absolute", - left: `${bg.posX}%`, - top: `${bg.posY}%`, + left: `${tc.position.x}%`, + top: `calc(${tc.position.y}% + ${offsetY}%)`, width: `${bg.width}%`, height: `${bg.height}%`, transform: "translate(-50%, -50%)", diff --git a/apps/web/src/pages/generate/types/cover.ts b/apps/web/src/pages/generate/types/cover.ts index 61df46fbd..0a474001a 100644 --- a/apps/web/src/pages/generate/types/cover.ts +++ b/apps/web/src/pages/generate/types/cover.ts @@ -63,9 +63,8 @@ export interface TextBackground { shape: TextBgShape width: number height: number - posX: number - posY: number - rotation: number + /** 相对文字的上下偏移(百分比),背景自动跟随文字位置 */ + offsetY: number } /** 文字样式配置(主标题/副标题共用) */ @@ -154,9 +153,7 @@ export const DEFAULT_TITLE_CONFIG: TextStyleConfig = { shape: "polygon", width: 30, height: 10, - posX: 50, - posY: 50, - rotation: 0, + offsetY: 0, }, } @@ -184,9 +181,7 @@ export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = { shape: "rectangle", width: 100, height: 20, - posX: 50, - posY: 80, - rotation: 0, + offsetY: 8, }, }