fix(cover): 文字背景跟随文字位置 + 自动生成按钮可用
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m26s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m38s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m38s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m11s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m38s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m51s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m36s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 7m37s
CI/CD Pipeline / CI Gate (pull_request) Failing after 0s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 6m19s

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 区分原因(未生成/视频未就绪/批量)
This commit is contained in:
xiaoxia
2026-09-26 13:08:24 +08:00
parent ac5353b335
commit 3d6ea425c6
5 changed files with 113 additions and 51 deletions
@@ -562,6 +562,8 @@ const GeneratePage: React.FC = () => {
generateError={generateError}
progress={progress}
generatedVideos={generatedVideos}
currentTaskId={currentTaskId}
onRetry={handleRetryGenerate}
onRetryBatchTask={handleRetryBatchTask}
onDismissError={handleDismissError}
@@ -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<void>
}
@@ -264,6 +266,7 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
selectedVariantIndexes={selectedVariantIds}
selectedTemplate={selectedCoverTemplate}
onTemplateChange={onSelectedCoverTemplateChange}
currentTaskId={props.currentTaskId}
/>
)
default:
@@ -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<Step6CoverSettingsProps> = (props) => {
@@ -47,6 +49,32 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (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<Step6CoverSettingsProps> = (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<string, unknown>
} = {
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<Step6CoverSettingsProps> = (props) => {
<div className="xx-form-section">
<h3>🖼️ 选择封面</h3>
{finalVideo && (
{(finalVideo || effectiveTaskId) && (
<div
style={{
padding: "10px 14px",
@@ -335,7 +376,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (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<Step6CoverSettingsProps> = (props) => {
<Button
buttonType="primary"
onClick={() => void shared.generateAutoCover()}
disabled={!finalVideo || shared.generating}
disabled={!canGenerateCover || shared.generating}
loading={shared.generating}
title={!canGenerateCover ? "请先完成视频生成" : ""}
>
✨ 自动生成封面
</Button>
@@ -16,6 +16,19 @@ import "@/components/cover/cover.css"
const mergeEditorConfig = (partial?: Partial<CoverEditorConfig> | 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<string, unknown> | undefined) => {
if (!bg) return {}
// 兼容老模板字段:posX/posY/rotation 在新版中已改为 offsetY(背景位置自动跟随文字)
const normalized = { ...bg }
delete (normalized as Record<string, unknown>).posX
delete (normalized as Record<string, unknown>).posY
delete (normalized as Record<string, unknown>).rotation
if (normalized.offsetY == null) normalized.offsetY = 0
return normalized
}
const mergeText = (
base: TextStyleConfig,
patch?: Partial<TextStyleConfig> | null,
@@ -23,7 +36,10 @@ const mergeEditorConfig = (partial?: Partial<CoverEditorConfig> | null): CoverEd
...base,
...(patch || {}),
position: { ...base.position, ...(patch?.position || {}) },
background: { ...base.background, ...(patch?.background || {}) },
background: {
...base.background,
...normalizeBg(patch?.background as Record<string, unknown> | undefined),
},
shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows],
})
return {
@@ -398,13 +414,13 @@ const TextStylePanel: React.FC<{
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">上下偏移: {config.background.posY - 50}%</label>
<label className="xx-ce-label">上下偏移: {config.background.offsetY ?? 0}%</label>
<Slider
min={-50}
max={50}
min={-30}
max={30}
step={1}
value={config.background.posY - 50}
onChange={(v) => upd("background", { ...config.background, posY: 50 + v })}
value={config.background.offsetY ?? 0}
onChange={(v) => upd("background", { ...config.background, offsetY: v })}
/>
</div>
</div>
@@ -595,7 +611,9 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ 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<CoverEditorModalProps> = ({ 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%)",
+4 -9
View File
@@ -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,
},
}