Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c0535da19 | |||
| c7288cd46b | |||
| 447aad166b | |||
| 1463cf0834 | |||
| 3a8d5e50d0 | |||
| 6778f4be32 | |||
| 778b17a723 |
@@ -813,7 +813,7 @@ jobs:
|
|||||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
|
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:pr-${GITHUB_SHA}"
|
||||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:develop"
|
||||||
|
|
||||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
EXTRA_BUILD_ARGS="APP_VERSION=${GITHUB_SHA}"
|
||||||
|
|
||||||
# Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存
|
# Worker 与 API/Web 统一走持久 builder(ci-builder-persist),共享宿主机层缓存
|
||||||
NO_CACHE_FLAG=""
|
NO_CACHE_FLAG=""
|
||||||
@@ -1014,7 +1014,7 @@ jobs:
|
|||||||
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
|
PUSHED_TAGS_SUMMARY="${BRANCH_TAG}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_SHA}\""
|
EXTRA_BUILD_ARGS="APP_VERSION=${GITHUB_SHA}"
|
||||||
|
|
||||||
NO_CACHE_FLAG=""
|
NO_CACHE_FLAG=""
|
||||||
for i in 1 2 3; do
|
for i in 1 2 3; do
|
||||||
@@ -1238,11 +1238,12 @@ jobs:
|
|||||||
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
|
ACR_PASSWORD: "${{ secrets.ACR_PASSWORD }}"
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
# CI runner (act_runner) 部署在 116 staging 本机(116.62.226.203 公网 22 未开放),
|
# Staging 业务机 = 47.98.113.167(公网 sshd 端口 22222;内网 VPC 10.0.0.2:22)。
|
||||||
# 默认走 127.0.0.1:22 本机 SSH,避免跨机网络依赖;可通过 secrets 覆盖。
|
# CI runner 已迁移到独立 CI 机器、job 在隔离容器网络内执行,127.0.0.1 会指向 job 容器自身而失败,
|
||||||
staging_host="${STAGING_SSH_HOST:-127.0.0.1}"
|
# 故默认目标必须是 staging 业务机;仍可通过 secrets 覆盖。
|
||||||
|
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||||
staging_user="${STAGING_SSH_USER:-root}"
|
staging_user="${STAGING_SSH_USER:-root}"
|
||||||
staging_port="${STAGING_SSH_PORT:-22}"
|
staging_port="${STAGING_SSH_PORT:-22222}"
|
||||||
echo "Host: $staging_host"
|
echo "Host: $staging_host"
|
||||||
echo "Port: $staging_port"
|
echo "Port: $staging_port"
|
||||||
|
|
||||||
@@ -1552,7 +1553,7 @@ jobs:
|
|||||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
|
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
|
||||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||||
|
|
||||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
EXTRA_BUILD_ARGS="APP_VERSION=${TAG_NAME}"
|
||||||
|
|
||||||
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
|
# Docker build 带重试:失败自动重试2次,第2次重试加--no-cache
|
||||||
NO_CACHE_FLAG=""
|
NO_CACHE_FLAG=""
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ export const FONT_OPTIONS: FontOption[] = [
|
|||||||
{
|
{
|
||||||
value: "优设标题黑",
|
value: "优设标题黑",
|
||||||
label: "优设标题黑",
|
label: "优设标题黑",
|
||||||
// 原版"优设标题黑"为商用字体,非开源;这里优先使用本地已安装字体,兜底用 ZCOOL QingKe HuangYou(站酷庆科黄油体,同为厚重黑体/海报风格,可从 Google Fonts 加载)
|
// 原版"优设标题黑"为商用字体非开源;优先本地已安装字体,兜底用 Noto Sans SC(Google Fonts 已加载 wght@900,保证 bold 字重可用),再用 ZCOOL 庆科黄油体作风格兜底
|
||||||
family:
|
family:
|
||||||
'"YouSheBiaoTiHei","YouShe Title Black","ZCOOL QingKe HuangYou","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
|
'"YouSheBiaoTiHei","YouShe Title Black","Noto Sans SC","ZCOOL QingKe HuangYou","PingFang SC","Microsoft YaHei",sans-serif',
|
||||||
tag: "hot",
|
tag: "hot",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "阿里普惠体Bold",
|
value: "阿里普惠体Bold",
|
||||||
label: "阿里普惠体Bold",
|
label: "阿里普惠体Bold",
|
||||||
// 阿里普惠体需要从阿里官网下载;兜底用 Noto Sans SC 900(同等字重)
|
// 阿里普惠体需从阿里官网下载;兜底用 Noto Sans SC 900(同等字重,已在 Google Fonts wght@400;500;700;900 加载)
|
||||||
family:
|
family:
|
||||||
'"Alibaba PuHuiTi","Alibaba PuHuiTi Bold","Alibaba Sans","Noto Sans SC",system-ui,"PingFang SC","Microsoft YaHei",sans-serif',
|
'"Alibaba PuHuiTi","Alibaba PuHuiTi Bold","Alibaba Sans","Noto Sans SC",system-ui,"PingFang SC","Microsoft YaHei",sans-serif',
|
||||||
tag: "hot",
|
tag: "hot",
|
||||||
@@ -36,9 +36,9 @@ export const FONT_OPTIONS: FontOption[] = [
|
|||||||
{
|
{
|
||||||
value: "抖音美好体",
|
value: "抖音美好体",
|
||||||
label: "抖音美好体",
|
label: "抖音美好体",
|
||||||
// 抖音美好体版权字体兜底 ZCOOL KuaiLe(站酷快乐体,圆润卡通风格近似)
|
// 抖音美好体为版权字体;兜底用 Noto Sans SC(确保 bold 字重可用),再用 ZCOOL KuaiLe(站酷快乐体,圆润卡通风格近似)
|
||||||
family:
|
family:
|
||||||
'"Douyin Sans","DouyinSans","ZCOOL KuaiLe","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif',
|
'"Douyin Sans","DouyinSans","Noto Sans SC","ZCOOL KuaiLe","PingFang SC","Microsoft YaHei",sans-serif',
|
||||||
tag: "hot",
|
tag: "hot",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -421,7 +421,10 @@ const GeneratePage: React.FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!finalVideo) {
|
// 单视频:finalVideo 可能因 /results 接口在 awaiting_cover 阶段暂未返回
|
||||||
|
// GeneratedVideo 记录而为 undefined;此时 currentTaskId 已在创建任务时保存,
|
||||||
|
// 下面 singleTaskId 兜底逻辑会用 currentTaskId 调 finalize,不应拦截
|
||||||
|
if (!finalVideo && !currentTaskId) {
|
||||||
message.warning("请等待视频生成完成")
|
message.warning("请等待视频生成完成")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -438,19 +441,24 @@ const GeneratePage: React.FC = () => {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batchTasks.map(async (task) => {
|
batchTasks.map(async (task) => {
|
||||||
const vi = task.variantIndex
|
const vi = task.variantIndex
|
||||||
const coverUrl = previewCovers[vi] || ""
|
const rawCoverUrl = previewCovers[vi] || ""
|
||||||
|
const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl
|
||||||
const title = previewTitles[vi] || titleSettings.title || ""
|
const title = previewTitles[vi] || titleSettings.title || ""
|
||||||
return finalizeGeneration(task.taskId, {
|
return finalizeGeneration(task.taskId, {
|
||||||
cover_url: coverUrl || undefined,
|
cover_url: coverUrl || undefined,
|
||||||
custom_title: title,
|
custom_title: title || undefined,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (singleTaskId) {
|
} else if (singleTaskId) {
|
||||||
const coverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
|
// 单视频:cover_url 仅在非 blob: 本地预览地址时才传;blob: URL 浏览器本地临时地址,
|
||||||
|
// 后端无法下载,此时不传让后端回退自动截帧封面(避免 400 保存失败)。
|
||||||
|
// 正常流程本地上传完成后 uploadLocalCover 会把 URL 替换为 OSS 真实 URL,这里仅兜底异常场景。
|
||||||
|
const rawCoverUrl = coverSettings.thumbnail_url || coverSettings.upload_url || ""
|
||||||
|
const coverUrl = rawCoverUrl.startsWith("blob:") ? "" : rawCoverUrl
|
||||||
await finalizeGeneration(singleTaskId, {
|
await finalizeGeneration(singleTaskId, {
|
||||||
cover_url: coverUrl || undefined,
|
cover_url: coverUrl || undefined,
|
||||||
custom_title: titleSettings.title || "",
|
custom_title: titleSettings.title || undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
|
console.warn("[handleFinish] 未找到任务 ID,跳过 finalize 直接跳转")
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
|
* - 批量:N 个独立标题输入框(AutoComplete 支持标题库选择)
|
||||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||||
*/
|
*/
|
||||||
import React, { useMemo } from "react"
|
import React, { useMemo, useState } from "react"
|
||||||
import type { TitleSettings } from "../types"
|
import type { TitleSettings } from "../types"
|
||||||
import { POSITION_OPTIONS } from "../constants"
|
|
||||||
import { FONT_OPTIONS } from "@/components/title/constants"
|
|
||||||
import { useStep4Title } from "../hooks/useStep4Title"
|
import { useStep4Title } from "../hooks/useStep4Title"
|
||||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||||
import TitleStylePanel from "./title/TitleStylePanel"
|
import TitleStyleModal from "./title/TitleStyleModal"
|
||||||
|
import Button from "@/components/ui/Button"
|
||||||
import type { TitleTemplate } from "@/components/title/template-types"
|
import type { TitleTemplate } from "@/components/title/template-types"
|
||||||
|
|
||||||
interface Step4TitleSettingsProps {
|
interface Step4TitleSettingsProps {
|
||||||
@@ -58,13 +57,6 @@ interface Step4TitleSettingsProps {
|
|||||||
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||||
const t = useStep4Title(props)
|
const t = useStep4Title(props)
|
||||||
const {
|
const {
|
||||||
onUpdatePosition,
|
|
||||||
onUpdateFont,
|
|
||||||
onUpdateSize,
|
|
||||||
onToggleBold,
|
|
||||||
onToggleItalic,
|
|
||||||
onToggleStroke,
|
|
||||||
onToggleShadow,
|
|
||||||
onApplyPreset,
|
onApplyPreset,
|
||||||
onUpdateStyle,
|
onUpdateStyle,
|
||||||
activePreset,
|
activePreset,
|
||||||
@@ -72,14 +64,12 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
|||||||
previewCount = 1,
|
previewCount = 1,
|
||||||
previewTitles,
|
previewTitles,
|
||||||
onPreviewTitlesChange,
|
onPreviewTitlesChange,
|
||||||
enableTemplates,
|
|
||||||
selectedTemplateId,
|
|
||||||
onApplyTemplate,
|
|
||||||
onConfirmGenerate,
|
onConfirmGenerate,
|
||||||
generating,
|
generating,
|
||||||
selectedCount = 1,
|
selectedCount = 1,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const [showTitleStyleModal, setShowTitleStyleModal] = useState(false)
|
||||||
const isBatch = previewCount > 1
|
const isBatch = previewCount > 1
|
||||||
|
|
||||||
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
|
/** 更新单个变体标题;变体0同步写回 titleSettings.title(全局样式面板/草稿/TTS 链路依赖) */
|
||||||
@@ -184,27 +174,30 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 标题样式面板(全局共用) */}
|
{/* 标题样式:点击打开弹窗(参考字幕模板/封面模板 Modal 模式) */}
|
||||||
<TitleStylePanel
|
<TitleStyleModal
|
||||||
|
open={showTitleStyleModal}
|
||||||
|
onClose={() => setShowTitleStyleModal(false)}
|
||||||
|
titleText={previewTitles?.[0] ?? t.titleSettings.title}
|
||||||
settings={t.titleSettings}
|
settings={t.titleSettings}
|
||||||
onUpdatePosition={onUpdatePosition}
|
|
||||||
onUpdateFont={onUpdateFont}
|
|
||||||
onUpdateSize={onUpdateSize}
|
|
||||||
onToggleBold={onToggleBold}
|
|
||||||
onToggleItalic={onToggleItalic}
|
|
||||||
onToggleStroke={onToggleStroke}
|
|
||||||
onToggleShadow={onToggleShadow}
|
|
||||||
onApplyPreset={onApplyPreset}
|
|
||||||
onUpdateStyle={onUpdateStyle}
|
|
||||||
showCoverToggle
|
|
||||||
activePreset={activePreset}
|
activePreset={activePreset}
|
||||||
titlePresets={titlePresets}
|
onSave={(nextSettings, nextPreset) => {
|
||||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
onUpdateStyle?.(nextSettings)
|
||||||
FONT_OPTIONS={FONT_OPTIONS}
|
if (nextPreset) onApplyPreset(nextPreset)
|
||||||
enableTemplates={enableTemplates}
|
}}
|
||||||
selectedTemplateId={selectedTemplateId}
|
|
||||||
onApplyTemplate={onApplyTemplate}
|
|
||||||
/>
|
/>
|
||||||
|
<div className="xx-form-field">
|
||||||
|
<label>标题样式</label>
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
onClick={() => setShowTitleStyleModal(true)}
|
||||||
|
style={{ justifyContent: "flex-start", textAlign: "left" }}
|
||||||
|
>
|
||||||
|
{activePreset
|
||||||
|
? titlePresets.find((p) => p.key === activePreset)?.label || "自定义样式"
|
||||||
|
: "点击选择标题样式(预设 / 字体 / 颜色 / 描边 / 阴影)"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*
|
*
|
||||||
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
|
* 模板 CRUD + 编辑器弹窗 + 自动生成 + 上传 复用 components/cover/useSharedCover
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useMemo } from "react"
|
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { Modal, Spin } from "antd"
|
import { Modal, Spin, message } from "antd"
|
||||||
import { LoadingOutlined } from "@ant-design/icons"
|
import { LoadingOutlined } from "@ant-design/icons"
|
||||||
import type { CoverConfig } from "../types/cover"
|
import type { CoverConfig } from "../types/cover"
|
||||||
import type { GeneratedVideo } from "@/api/template-editor"
|
import type { GeneratedVideo } from "@/api/template-editor"
|
||||||
@@ -17,6 +17,7 @@ import CoverSettingsModal from "./cover-settings/CoverSettingsModal"
|
|||||||
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
import CoverEditorModal from "./cover-settings/CoverEditorModal"
|
||||||
import { useSharedCover } from "@/components/cover/useSharedCover"
|
import { useSharedCover } from "@/components/cover/useSharedCover"
|
||||||
import { generateCover as apiGenerateCover } from "@/api/generation"
|
import { generateCover as apiGenerateCover } from "@/api/generation"
|
||||||
|
import { uploadAssetDirect, getAssetLibraries } from "@/api/assets"
|
||||||
|
|
||||||
interface Step6CoverSettingsProps {
|
interface Step6CoverSettingsProps {
|
||||||
coverSettings: CoverConfig
|
coverSettings: CoverConfig
|
||||||
@@ -159,9 +160,65 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange])
|
}, [isBatch, shared.selectedTemplateId, parentSelectedTemplate, onTemplateChange])
|
||||||
|
|
||||||
|
/** 单视频本地上传封面:选完文件后上传到素材库 OSS,拿到真实 URL 再 set */
|
||||||
|
const [uploadingLocalCover, setUploadingLocalCover] = useState(false)
|
||||||
|
const { coverSettings: curCoverSettings, onCoverSettingsChange } = props
|
||||||
|
const uploadLocalCover = useCallback(
|
||||||
|
async (file: File): Promise<string | null> => {
|
||||||
|
const hide = message.loading("正在上传封面...", 0)
|
||||||
|
setUploadingLocalCover(true)
|
||||||
|
try {
|
||||||
|
// 立即创建 blob URL 用于即时预览,同时异步上传 OSS
|
||||||
|
const previewUrl = URL.createObjectURL(file)
|
||||||
|
onCoverSettingsChange({
|
||||||
|
...curCoverSettings,
|
||||||
|
upload_url: previewUrl,
|
||||||
|
thumbnail_url: previewUrl,
|
||||||
|
mode: "upload",
|
||||||
|
})
|
||||||
|
// 查找图片素材库(复用批量封面的逻辑)
|
||||||
|
const libs = await getAssetLibraries()
|
||||||
|
const imageLib = libs.find((l) => l.kind === "image") || libs[0]
|
||||||
|
if (!imageLib) {
|
||||||
|
hide()
|
||||||
|
message.error("未找到素材库,请先创建图片素材库")
|
||||||
|
return previewUrl
|
||||||
|
}
|
||||||
|
const result = await uploadAssetDirect({ file, library_id: imageLib.id })
|
||||||
|
const realUrl = result?.url || ""
|
||||||
|
if (!realUrl) {
|
||||||
|
hide()
|
||||||
|
message.warning("上传完成但未获取到URL,将使用本地预览")
|
||||||
|
return previewUrl
|
||||||
|
}
|
||||||
|
hide()
|
||||||
|
// 替换 blob URL 为真实 OSS URL(blob 用于预览过渡,finalize 时必须用真实 URL)
|
||||||
|
onCoverSettingsChange({
|
||||||
|
...curCoverSettings,
|
||||||
|
upload_url: realUrl,
|
||||||
|
thumbnail_url: realUrl,
|
||||||
|
mode: "upload",
|
||||||
|
})
|
||||||
|
message.success("封面上传成功")
|
||||||
|
return realUrl
|
||||||
|
} catch (err) {
|
||||||
|
hide()
|
||||||
|
console.error("[Step6] 封面上传失败:", err)
|
||||||
|
message.error("封面上传失败,请重试")
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setUploadingLocalCover(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[curCoverSettings, onCoverSettingsChange],
|
||||||
|
)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shared.setOnUploadFile(() => null)
|
// 单视频:注册实际上传函数;批量场景已由 batchCovers.uploadOne 接管,
|
||||||
}, [shared])
|
// 这里不要覆盖(批量时 input ref 绑定到 batchUploadRef,不走 shared.handleFileInputChange)
|
||||||
|
if (!isBatch) {
|
||||||
|
shared.setOnUploadFile((file) => uploadLocalCover(file))
|
||||||
|
}
|
||||||
|
}, [shared, isBatch, uploadLocalCover])
|
||||||
|
|
||||||
const batchUploadRef = React.useRef<HTMLInputElement>(null)
|
const batchUploadRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
|
const [batchUploadCard, setBatchUploadCard] = React.useState<number | null>(null)
|
||||||
@@ -399,7 +456,12 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
|||||||
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
|
<Button buttonType="ghost" onClick={() => shared.setShowCoverSettings(true)}>
|
||||||
⚙️ 封面模板
|
⚙️ 封面模板
|
||||||
</Button>
|
</Button>
|
||||||
<Button buttonType="ghost" onClick={shared.handleUploadClick}>
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
onClick={shared.handleUploadClick}
|
||||||
|
disabled={uploadingLocalCover}
|
||||||
|
loading={uploadingLocalCover}
|
||||||
|
>
|
||||||
📷 本地上传
|
📷 本地上传
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -407,18 +469,7 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
|||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
onChange={(e) => {
|
onChange={shared.handleFileInputChange}
|
||||||
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",
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
ref={batchUploadRef}
|
ref={batchUploadRef}
|
||||||
|
|||||||
@@ -89,9 +89,8 @@ const TitleMiniPreview: React.FC<Props> = ({
|
|||||||
ctx.fillRect(0, 0, width, h)
|
ctx.fillRect(0, 0, width, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半)
|
// 分辨率缩放:以 360 宽为基准(对应 720p 的一半),与外层 previewScale/previewR 保持一致
|
||||||
const scale = width / 360
|
const r = previewR
|
||||||
const r = (v: number) => Math.round(v * scale)
|
|
||||||
|
|
||||||
// 字体
|
// 字体
|
||||||
const size = r(settings.size)
|
const size = r(settings.size)
|
||||||
@@ -158,6 +157,9 @@ const TitleMiniPreview: React.FC<Props> = ({
|
|||||||
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
|
ctx.fillStyle = settings.bgColor ?? "rgba(0,0,0,0.5)"
|
||||||
roundRect(ctx, bx, by, bw, bh, rad)
|
roundRect(ctx, bx, by, bw, bh, rad)
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
// 关键修复:画完背景块后必须把 fillStyle 重置为文字颜色,
|
||||||
|
// 否则后续 fillText 会用 bgColor 填充文字,导致「文字看不见只剩色块」
|
||||||
|
ctx.fillStyle = settings.color
|
||||||
// 恢复阴影
|
// 恢复阴影
|
||||||
if (shadowEnabled) {
|
if (shadowEnabled) {
|
||||||
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
|
ctx.shadowColor = settings.shadowColor ?? "rgba(0,0,0,0.8)"
|
||||||
@@ -194,15 +196,38 @@ const TitleMiniPreview: React.FC<Props> = ({
|
|||||||
ctx.shadowOffsetX = prevShadow.ox
|
ctx.shadowOffsetX = prevShadow.ox
|
||||||
ctx.shadowOffsetY = prevShadow.oy
|
ctx.shadowOffsetY = prevShadow.oy
|
||||||
}
|
}
|
||||||
// Web fonts (Google Fonts 等) 加载需要时间,等 fonts.ready 后再画,
|
// 计算当前字号(draw() 内部同样逻辑,抽出来供 fontString 复用)
|
||||||
// 避免第一次渲染用 sans-serif 画完再切字体造成「字体选择没反应」的错觉
|
const previewScale = width / 360
|
||||||
if (typeof document !== "undefined" && document.fonts && document.fonts.ready) {
|
const previewR = (v: number) => Math.round(v * previewScale)
|
||||||
document.fonts.ready.then(() => {
|
const buildFontString = () => {
|
||||||
if (cancelled) return
|
const size = previewR(settings.size)
|
||||||
draw()
|
const ff = getFontFamily(settings.font)
|
||||||
})
|
const parts: string[] = []
|
||||||
|
if (settings.italic) parts.push("italic")
|
||||||
|
if (settings.bold) parts.push("bold")
|
||||||
|
parts.push(`${size}px`, ff)
|
||||||
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
draw()
|
|
||||||
|
// Web Font 加载保障:
|
||||||
|
// 1) 等 document.fonts.ready(CSS @font-face 首次可用)
|
||||||
|
// 2) 显式 FontFaceSet.load(fontString, text) 触发浏览器真正下载并加载
|
||||||
|
// 当前字体到 Canvas 可用,避免首次绘制用 fallback 字体画出错字/色块
|
||||||
|
const doDrawWhenReady = async () => {
|
||||||
|
try {
|
||||||
|
if (typeof document !== "undefined" && document.fonts) {
|
||||||
|
await document.fonts.ready
|
||||||
|
try {
|
||||||
|
await document.fonts.load(buildFontString(), text)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doDrawWhenReady()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
.tsm-body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-preview {
|
||||||
|
width: 100%;
|
||||||
|
background: #111;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-presets-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 440px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-preset-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 6px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.tsm-preset-card:hover {
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgba(124, 58, 237, 0.12);
|
||||||
|
}
|
||||||
|
.tsm-preset-card.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-preset-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 9 / 16;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-preset-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.tsm-preset-emoji {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.tsm-preset-label {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-custom-panel {
|
||||||
|
max-height: 440px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsm-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 标题样式弹窗(Step3/智能剪辑用)
|
||||||
|
*
|
||||||
|
* 交互:
|
||||||
|
* - 点击"标题样式"按钮/卡片打开
|
||||||
|
* - 默认 Tab「预设」:10 个爆款预设卡片网格(Canvas 实时预览),点击选中
|
||||||
|
* - Tab「自定义」:详细参数(位置/字体/字号/粗斜/描边/阴影/颜色/背景)
|
||||||
|
* - 底部「取消」「保存」按钮:保存才会把临时编辑的 settings 应用到父组件
|
||||||
|
*
|
||||||
|
* 设计参考:CoverSettingsModal / 字幕模板选择弹窗
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from "react"
|
||||||
|
import { Tabs } from "antd"
|
||||||
|
import Modal from "@/components/ui/Modal"
|
||||||
|
import Button from "@/components/ui/Button"
|
||||||
|
import type { TitleSettings } from "../../types"
|
||||||
|
import { POSITION_OPTIONS } from "../../constants"
|
||||||
|
import { FONT_OPTIONS } from "@/components/title/constants"
|
||||||
|
import TitleStylePanel from "./TitleStylePanel"
|
||||||
|
import TitleMiniPreview from "./TitleMiniPreview"
|
||||||
|
import { titleStyleConfigToCamel } from "@/components/title/utils"
|
||||||
|
import { TITLE_PRESETS as TITLE_PRESETS_DATA } from "@/components/title/constants"
|
||||||
|
import "./TitleStyleModal.css"
|
||||||
|
|
||||||
|
interface TitleStyleModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** 当前标题文字(用于预览) */
|
||||||
|
titleText?: string
|
||||||
|
/** 当前生效的标题样式 */
|
||||||
|
settings: TitleSettings
|
||||||
|
/** 保存回调:把编辑后的 settings 回传父组件 */
|
||||||
|
onSave: (settings: TitleSettings, presetKey: string | null) => void
|
||||||
|
/** 当前选中的爆款预设 key(用于高亮) */
|
||||||
|
activePreset?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把预设应用到 base settings(参考 useTitleStyleUpdaters.applyPreset 逻辑) */
|
||||||
|
function applyPresetToSettings(base: TitleSettings, presetKey: string): TitleSettings {
|
||||||
|
const preset = TITLE_PRESETS_DATA.find((p) => p.key === presetKey)
|
||||||
|
if (!preset) return base
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(titleStyleConfigToCamel(preset.style) as Partial<TitleSettings>),
|
||||||
|
lineOverrides: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TitleStyleModal: React.FC<TitleStyleModalProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
titleText = "标题",
|
||||||
|
settings,
|
||||||
|
onSave,
|
||||||
|
activePreset: externalActivePreset = null,
|
||||||
|
}) => {
|
||||||
|
// 弹窗内维护一份草稿 settings,点保存才提交到父组件;取消/关闭丢弃
|
||||||
|
const [draft, setDraft] = useState<TitleSettings>(settings)
|
||||||
|
const [activePreset, setActivePreset] = useState<string | null>(externalActivePreset)
|
||||||
|
|
||||||
|
// 每次打开弹窗,用父组件最新 settings 重置草稿
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDraft(settings)
|
||||||
|
setActivePreset(externalActivePreset)
|
||||||
|
}
|
||||||
|
}, [open, settings, externalActivePreset])
|
||||||
|
|
||||||
|
const handleApplyPreset = (presetKey: string) => {
|
||||||
|
setActivePreset(presetKey)
|
||||||
|
const next = applyPresetToSettings(draft, presetKey)
|
||||||
|
setDraft(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateStyle = (patch: Partial<TitleSettings>) => {
|
||||||
|
setDraft((prev) => ({ ...prev, ...patch }))
|
||||||
|
// 手动调了参数,清除预设高亮(因为不再严格匹配某个预设)
|
||||||
|
setActivePreset(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdatePosition = (position: string) =>
|
||||||
|
handleUpdateStyle({ position: position as TitleSettings["position"] })
|
||||||
|
const handleUpdateFont = (font: string) => handleUpdateStyle({ font })
|
||||||
|
const handleUpdateSize = (size: number) => handleUpdateStyle({ size })
|
||||||
|
const handleToggleBold = () => handleUpdateStyle({ bold: !draft.bold })
|
||||||
|
const handleToggleItalic = () => handleUpdateStyle({ italic: !draft.italic })
|
||||||
|
const handleToggleStroke = () => handleUpdateStyle({ stroke: !draft.stroke })
|
||||||
|
const handleToggleShadow = () => handleUpdateStyle({ shadow: !draft.shadow })
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(draft, activePreset)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onCancel={onClose} title="标题样式" width={720} footer={null} destroyOnClose>
|
||||||
|
<div className="tsm-body">
|
||||||
|
{/* 顶部预览 */}
|
||||||
|
<div className="tsm-preview">
|
||||||
|
<TitleMiniPreview
|
||||||
|
settings={draft}
|
||||||
|
width={640}
|
||||||
|
sampleText={titleText || "标题"}
|
||||||
|
transparent={false}
|
||||||
|
portrait
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="presets"
|
||||||
|
size="small"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "presets",
|
||||||
|
label: "爆款预设",
|
||||||
|
children: (
|
||||||
|
<div className="tsm-presets-grid">
|
||||||
|
{TITLE_PRESETS_DATA.map((p) => {
|
||||||
|
const previewSettings = applyPresetToSettings(draft, p.key)
|
||||||
|
const isActive = activePreset === p.key
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
type="button"
|
||||||
|
className={`tsm-preset-card${isActive ? " active" : ""}`}
|
||||||
|
onClick={() => handleApplyPreset(p.key)}
|
||||||
|
title={p.label}
|
||||||
|
>
|
||||||
|
<div className="tsm-preset-preview">
|
||||||
|
<TitleMiniPreview
|
||||||
|
settings={previewSettings}
|
||||||
|
width={140}
|
||||||
|
sampleText="标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tsm-preset-meta">
|
||||||
|
<span className="tsm-preset-emoji">{p.emoji}</span>
|
||||||
|
<span className="tsm-preset-label">{p.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "custom",
|
||||||
|
label: "自定义",
|
||||||
|
children: (
|
||||||
|
<div className="tsm-custom-panel">
|
||||||
|
<TitleStylePanel
|
||||||
|
settings={draft}
|
||||||
|
onUpdatePosition={handleUpdatePosition}
|
||||||
|
onUpdateFont={handleUpdateFont}
|
||||||
|
onUpdateSize={handleUpdateSize}
|
||||||
|
onToggleBold={handleToggleBold}
|
||||||
|
onToggleItalic={handleToggleItalic}
|
||||||
|
onToggleStroke={handleToggleStroke}
|
||||||
|
onToggleShadow={handleToggleShadow}
|
||||||
|
onApplyPreset={handleApplyPreset}
|
||||||
|
onUpdateStyle={handleUpdateStyle}
|
||||||
|
activePreset={activePreset}
|
||||||
|
titlePresets={[]}
|
||||||
|
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||||
|
FONT_OPTIONS={FONT_OPTIONS}
|
||||||
|
paramsOnly
|
||||||
|
hideEditor
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮栏 */}
|
||||||
|
<div className="tsm-footer">
|
||||||
|
<Button buttonType="ghost" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="primary" onClick={handleSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TitleStyleModal
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* 标题工具函数单测 — 提升覆盖率到 50% 阈值以上
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
titleStyleConfigToCamel,
|
||||||
|
camelToTitleStyleConfig,
|
||||||
|
buildPresetPreviewSettings,
|
||||||
|
templateToPreviewSettings,
|
||||||
|
} from "@/components/title/utils"
|
||||||
|
import {
|
||||||
|
getFontFamily,
|
||||||
|
getTitlePreset,
|
||||||
|
TITLE_PRESETS,
|
||||||
|
FONT_OPTIONS,
|
||||||
|
} from "@/components/title/constants"
|
||||||
|
import { DEFAULT_TITLE_SETTINGS_FULL } from "@/pages/generate/types"
|
||||||
|
import type { TitleStyleConfig } from "@/components/title/types"
|
||||||
|
import type { TitleTemplate } from "@/components/title/template-types"
|
||||||
|
|
||||||
|
describe("getFontFamily", () => {
|
||||||
|
it("已知字体名返回对应 family 栈", () => {
|
||||||
|
const f = getFontFamily("思源黑体")
|
||||||
|
expect(f).toContain("Noto Sans SC")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("未知字体名回退到思源黑体", () => {
|
||||||
|
const f = getFontFamily("not-exist-font")
|
||||||
|
expect(f).toBe(FONT_OPTIONS[4].family)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("新增书法字体能查到", () => {
|
||||||
|
expect(getFontFamily("马善政毛笔")).toContain("Ma Shan Zheng")
|
||||||
|
expect(getFontFamily("站酷小薇体")).toContain("ZCOOL XiaoWei")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getTitlePreset", () => {
|
||||||
|
it("合法 key 返回对应 preset", () => {
|
||||||
|
const p = getTitlePreset(TITLE_PRESETS[0].key)
|
||||||
|
expect(p).toBeDefined()
|
||||||
|
expect(p?.key).toBe(TITLE_PRESETS[0].key)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("不存在的 key 返回 undefined", () => {
|
||||||
|
expect(getTitlePreset("__not_exist__")).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("titleStyleConfigToCamel", () => {
|
||||||
|
it("snake_case 字段映射到 camelCase", () => {
|
||||||
|
const cfg: Partial<TitleStyleConfig> = {
|
||||||
|
font: "思源黑体",
|
||||||
|
size: 48,
|
||||||
|
pos_x: 10,
|
||||||
|
pos_y: 20,
|
||||||
|
line_height: 1.4,
|
||||||
|
margin_top: 30,
|
||||||
|
max_chars_per_line: 8,
|
||||||
|
stroke_width: 4,
|
||||||
|
stroke_color: "#000",
|
||||||
|
shadow_offset_x: 2,
|
||||||
|
bg_enabled: true,
|
||||||
|
bg_padding: 12,
|
||||||
|
bg_radius: 6,
|
||||||
|
}
|
||||||
|
const out = titleStyleConfigToCamel(cfg)
|
||||||
|
expect(out.font).toBe("思源黑体")
|
||||||
|
expect(out.size).toBe(48)
|
||||||
|
expect(out.posX).toBe(10)
|
||||||
|
expect(out.posY).toBe(20)
|
||||||
|
expect(out.lineHeight).toBe(1.4)
|
||||||
|
expect(out.marginTop).toBe(30)
|
||||||
|
expect(out.maxCharsPerLine).toBe(8)
|
||||||
|
expect(out.strokeWidth).toBe(4)
|
||||||
|
expect(out.strokeColor).toBe("#000")
|
||||||
|
expect(out.shadowOffsetX).toBe(2)
|
||||||
|
expect(out.bgEnabled).toBe(true)
|
||||||
|
expect(out.bgPadding).toBe(12)
|
||||||
|
expect(out.bgRadius).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("空对象返回空对象", () => {
|
||||||
|
expect(titleStyleConfigToCamel({})).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("camelToTitleStyleConfig", () => {
|
||||||
|
it("camelCase 字段映射到 snake_case", () => {
|
||||||
|
const out = camelToTitleStyleConfig({
|
||||||
|
font: "思源宋体",
|
||||||
|
size: 36,
|
||||||
|
posX: 5,
|
||||||
|
posY: 15,
|
||||||
|
strokeWidth: 2,
|
||||||
|
shadowBlur: 8,
|
||||||
|
bgEnabled: false,
|
||||||
|
})
|
||||||
|
expect(out.font).toBe("思源宋体")
|
||||||
|
expect(out.pos_x).toBe(5)
|
||||||
|
expect(out.pos_y).toBe(15)
|
||||||
|
expect(out.stroke_width).toBe(2)
|
||||||
|
expect(out.shadow_blur).toBe(8)
|
||||||
|
expect(out.bg_enabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("两种转换在已知字段上往返一致", () => {
|
||||||
|
const camel = {
|
||||||
|
font: "优设标题黑",
|
||||||
|
size: 56,
|
||||||
|
color: "#ffffff",
|
||||||
|
stroke: true,
|
||||||
|
strokeWidth: 6,
|
||||||
|
bold: true,
|
||||||
|
shadow: true,
|
||||||
|
shadowOffsetX: 3,
|
||||||
|
shadowOffsetY: 3,
|
||||||
|
shadowBlur: 10,
|
||||||
|
bgEnabled: true,
|
||||||
|
bgColor: "#000000",
|
||||||
|
bgPadding: 16,
|
||||||
|
bgRadius: 8,
|
||||||
|
} as const
|
||||||
|
const snake = camelToTitleStyleConfig({ ...camel })
|
||||||
|
const back = titleStyleConfigToCamel(snake)
|
||||||
|
for (const k of Object.keys(camel) as (keyof typeof camel)[]) {
|
||||||
|
expect(back[k]).toBe(camel[k])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildPresetPreviewSettings", () => {
|
||||||
|
it("未知 preset key 返回 base", () => {
|
||||||
|
const base = { ...DEFAULT_TITLE_SETTINGS_FULL, size: 32 }
|
||||||
|
const out = buildPresetPreviewSettings(base, "__no_such_preset__")
|
||||||
|
expect(out).toBe(base)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("合法 preset 返回固定字号并缩放描边/阴影/padding", () => {
|
||||||
|
const key = TITLE_PRESETS[0].key
|
||||||
|
const out = buildPresetPreviewSettings(DEFAULT_TITLE_SETTINGS_FULL, key, 56)
|
||||||
|
expect(out.size).toBe(56)
|
||||||
|
expect(Array.isArray(out.lineOverrides)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("templateToPreviewSettings", () => {
|
||||||
|
it("按模板 style 合并默认值,固定字号并缩放装饰尺寸", () => {
|
||||||
|
const tpl: TitleTemplate = {
|
||||||
|
id: "tpl-test",
|
||||||
|
name: "测试模板",
|
||||||
|
category: "test",
|
||||||
|
thumbnail_url: "",
|
||||||
|
is_system: true,
|
||||||
|
style: {
|
||||||
|
font: "思源黑体",
|
||||||
|
size: 72,
|
||||||
|
color: "#ffd700",
|
||||||
|
stroke: true,
|
||||||
|
stroke_width: 8,
|
||||||
|
shadow: true,
|
||||||
|
shadow_offset_x: 4,
|
||||||
|
shadow_offset_y: 4,
|
||||||
|
shadow_blur: 12,
|
||||||
|
bg_enabled: false,
|
||||||
|
bg_padding: 24,
|
||||||
|
bg_radius: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const out = templateToPreviewSettings(tpl, 48)
|
||||||
|
expect(out.size).toBe(48)
|
||||||
|
expect(out.color).toBe("#ffd700")
|
||||||
|
expect(out.strokeWidth).toBe(Math.max(1, Math.round((48 / 72) * 8)))
|
||||||
|
expect(out.shadowBlur).toBe(Math.round((48 / 72) * 12))
|
||||||
|
expect(Array.isArray(out.lineOverrides)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("模板未指定 size 时使用默认 size(不触发缩放)", () => {
|
||||||
|
const tpl: TitleTemplate = {
|
||||||
|
id: "tpl-no-size",
|
||||||
|
name: "无字号模板",
|
||||||
|
category: "test",
|
||||||
|
thumbnail_url: "",
|
||||||
|
is_system: false,
|
||||||
|
style: { font: "思源黑体" },
|
||||||
|
}
|
||||||
|
const out = templateToPreviewSettings(tpl, 48)
|
||||||
|
expect(out.size).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -56,7 +56,6 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-staging}
|
APP_ENV: ${APP_ENV:-staging}
|
||||||
APP_VERSION: ${APP_VERSION:-unknown}
|
|
||||||
GENERATED_FILES_DIR: /app/generated
|
GENERATED_FILES_DIR: /app/generated
|
||||||
GENERATED_FILES_URL_PREFIX: /generated-files
|
GENERATED_FILES_URL_PREFIX: /generated-files
|
||||||
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
|
PUBLIC_API_BASE_URL: ${PUBLIC_API_BASE_URL:-https://api.xiaoxiajianji.com}
|
||||||
@@ -113,7 +112,6 @@ services:
|
|||||||
|
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-staging}
|
APP_ENV: ${APP_ENV:-staging}
|
||||||
APP_VERSION: ${APP_VERSION:-unknown}
|
|
||||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
||||||
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
|
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
|
||||||
# #1714 队列隔离:generation 队列独占 worker(默认并发 2),其余并发给转码
|
# #1714 队列隔离:generation 队列独占 worker(默认并发 2),其余并发给转码
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||||||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||||||
#
|
#
|
||||||
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 127.0.0.1,CI runner 在 staging 本机)
|
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167,CI runner 在独立 CI 机器)
|
||||||
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
# STAGING_SSH_USER - SSH 用户名 (默认 root)
|
||||||
# STAGING_SSH_PORT - SSH 端口 (默认 22)
|
# STAGING_SSH_PORT - SSH 端口 (默认 22222,公网;内网 VNC 用 10.0.0.2:22)
|
||||||
# STAGING_SSH_KEY - SSH 私钥内容
|
# STAGING_SSH_KEY - SSH 私钥内容
|
||||||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||||||
#
|
#
|
||||||
@@ -40,9 +40,9 @@ HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
|
|||||||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||||||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||||||
|
|
||||||
STAGING_SSH_HOST="${STAGING_SSH_HOST:-127.0.0.1}"
|
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
|
||||||
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
|
||||||
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22}"
|
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
|
||||||
|
|
||||||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||||||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||||||
|
|||||||
Reference in New Issue
Block a user