fix(cover): 文字背景跟随文字位置 + 自动生成封面按钮可用 #2052
@@ -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%)",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user