Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9709d5471b | |||
| e1482a4b11 | |||
| d4c8064cdc |
@@ -90,10 +90,18 @@ export async function createClipsFromAssets(
|
||||
templateId: string,
|
||||
assetIds: string[],
|
||||
clipType = "main",
|
||||
requiredClipsCount?: number,
|
||||
): Promise<ClipsFromAssetsResponse> {
|
||||
const body: Record<string, unknown> = {
|
||||
asset_ids: assetIds,
|
||||
clip_type: clipType,
|
||||
}
|
||||
if (requiredClipsCount !== undefined) {
|
||||
body.required_clips_count = requiredClipsCount
|
||||
}
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/templates/${templateId}/editor/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
body,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import { updateEditPlanClips } from "@/api/template-editor"
|
||||
import { updateEditPlanClips, createClipsFromAssets } from "@/api/template-editor"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { buildClipsFromAssets } from "../utils/buildClipsFromAssets"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
import { useDraftAutoSave } from "./useDraftAutoSave"
|
||||
@@ -72,17 +70,19 @@ export function useStep2Materials({
|
||||
scheduleSave({ asset_ids: ids }, 500)
|
||||
}, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave])
|
||||
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */
|
||||
/* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ──
|
||||
* 调用后端 POST /clips/from-assets,由后端处理:
|
||||
* - 素材不够时同一素材切多个片段
|
||||
* - 随机 start_time,不重复
|
||||
* - required_clips_count 保证片段数与模板 segments 一致
|
||||
* 先 PUT /clips(空数组)清空旧片段,再调用 from-assets 创建新片段
|
||||
*/
|
||||
const clipsTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const clipsAbortRef = useRef<AbortController | null>(null)
|
||||
const templateSegmentsRef = useRef(templateSegments)
|
||||
templateSegmentsRef.current = templateSegments
|
||||
const selectedTemplateRef = useRef(selectedTemplate)
|
||||
selectedTemplateRef.current = selectedTemplate
|
||||
const materialsRef = useRef(materials)
|
||||
materialsRef.current = materials
|
||||
const smartMatchedRef = useRef<AssetItem[]>(smartMatch.smartMatchedResults)
|
||||
smartMatchedRef.current = smartMatch.smartMatchedResults
|
||||
|
||||
useEffect(() => {
|
||||
const tid = selectedTemplateRef.current
|
||||
@@ -97,15 +97,14 @@ export function useStep2Materials({
|
||||
const controller = new AbortController()
|
||||
clipsAbortRef.current = controller
|
||||
|
||||
const clips = buildClipsFromAssets({
|
||||
selectedIds: ids,
|
||||
materials: materialsRef.current.items,
|
||||
smartMatchedAssets: smartMatchedRef.current,
|
||||
templateSegments: templateSegmentsRef.current || [],
|
||||
})
|
||||
const segs = templateSegmentsRef.current || []
|
||||
const requiredClipsCount = segs.length > 0 ? segs.length : undefined
|
||||
|
||||
try {
|
||||
await updateEditPlanClips(tid, clips, controller.signal)
|
||||
// 1. 清空旧片段
|
||||
await updateEditPlanClips(tid, [], controller.signal)
|
||||
// 2. 调用后端 from-assets 接口创建片段
|
||||
await createClipsFromAssets(tid, ids, "main", requiredClipsCount)
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name
|
||||
if (name !== "CanceledError" && name !== "AbortError") {
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* 将选中素材 + 模板 segments 构建为 edit_plan_clips 写入数据。
|
||||
*
|
||||
* 逻辑必须与 FrontendPreviewPlayer.tsx 中 buildPlaybackSegments 完全一致:
|
||||
* assetDuration = asset.duration || asset.metadata?.duration || 30
|
||||
* tplSeg = templateSegments[i] || lastSegment
|
||||
* segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max)
|
||||
* start_time = 0
|
||||
* // 关键:预览播放器中 endTime = min(startTime + segDuration, assetDuration)
|
||||
* // 因此 clips.duration 也必须用 min(segDuration, assetDuration) 截断,
|
||||
* // 避免素材实际时长比 clamp 后的 segDuration 短时,Worker 尝试读取不存在的片段
|
||||
* duration = min(segDuration, assetDuration)
|
||||
*
|
||||
* 预览播放器(Canvas 实时预览)直接在内存中构建 segments 播放,不读 edit_plan_clips;
|
||||
* 本函数产出的 clips 写入 DB 后由 Worker 渲染。两边用完全相同的时长计算,
|
||||
* 保证用户在编辑过程中看到的预览与最终生成视频一致。
|
||||
*/
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
import type { EditPlanClipInput } from "@/api/template-editor"
|
||||
|
||||
interface BuildClipsOptions {
|
||||
/** 选中的素材 ID 列表(按选择顺序) */
|
||||
selectedIds: string[]
|
||||
/** 已加载的素材列表(用于查 duration) */
|
||||
materials: AssetItem[]
|
||||
/** 智能匹配返回的素材(auto 模式下可能不在 materials 列表中) */
|
||||
smartMatchedAssets?: AssetItem[]
|
||||
/** 模板 segments */
|
||||
templateSegments?: TemplateSegment[]
|
||||
}
|
||||
|
||||
export function buildClipsFromAssets({
|
||||
selectedIds,
|
||||
materials,
|
||||
smartMatchedAssets = [],
|
||||
templateSegments = [],
|
||||
}: BuildClipsOptions): EditPlanClipInput[] {
|
||||
if (!selectedIds.length) return []
|
||||
|
||||
// 合并两个素材来源,建立 id → asset 索引
|
||||
const assetMap = new Map<string, AssetItem>()
|
||||
for (const a of materials) assetMap.set(a.id, a)
|
||||
for (const a of smartMatchedAssets) assetMap.set(a.id, a)
|
||||
|
||||
const lastSeg = templateSegments[templateSegments.length - 1]
|
||||
|
||||
return selectedIds.map((assetId, i) => {
|
||||
const asset = assetMap.get(assetId)
|
||||
const assetDuration = asset?.duration || asset?.metadata?.duration || 30
|
||||
|
||||
const tplSeg = templateSegments[i] || lastSeg
|
||||
const segDuration = tplSeg
|
||||
? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration))
|
||||
: Math.min(assetDuration, 10)
|
||||
|
||||
// 与 FrontendPreviewPlayer.buildPlaybackSegments 中
|
||||
// endTime = Math.min(startTime + segDuration, assetDuration)
|
||||
// 保持一致:duration 不能超过素材实际时长
|
||||
const duration = Math.min(segDuration, assetDuration)
|
||||
|
||||
return {
|
||||
asset_id: assetId,
|
||||
start_time: 0,
|
||||
duration,
|
||||
order: i,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -26,15 +26,26 @@ TITLE_MARGIN_SIDE = 40
|
||||
|
||||
# 字体名称映射:前端中文字体名 → 服务器实际注册名(ffmpeg/ASS 通过注册名匹配字体)
|
||||
FONT_NAME_MAP: dict[str, str] = {
|
||||
"思源黑体": "Noto Sans CJK SC",
|
||||
"思源黑体": "Noto Sans SC",
|
||||
"思源宋体": "Noto Serif CJK SC",
|
||||
"苹方": "Noto Sans CJK SC",
|
||||
"PingFang": "Noto Sans CJK SC",
|
||||
"微软雅黑": "Noto Sans CJK SC",
|
||||
"苹方": "Noto Sans SC",
|
||||
"PingFang": "Noto Sans SC",
|
||||
"微软雅黑": "Noto Sans SC",
|
||||
"楷体": "Noto Serif CJK SC",
|
||||
"华康俪金黑": "Noto Sans CJK SC",
|
||||
"华康俪金黑": "Noto Sans SC",
|
||||
}
|
||||
|
||||
# ASS Fontsize 是字体 em-square 高度(含 Latin 升降部留白),
|
||||
# 中文字符实际只占声明字号的约 65%~75%;浏览器 CSS font-size 让中文字符占满声明高度。
|
||||
# 为让成片中文字高与前端 CSS 预览一致,写入 ASS 时对字号乘以补偿系数。
|
||||
# font_size=89 → ASS Fontsize=round(89*1.35)=120,实际中文字高约 78~85px。
|
||||
ASS_FONTSIZE_COMPENSATION = 1.35
|
||||
|
||||
|
||||
def _compensate_ass_fontsize(font_size: int) -> int:
|
||||
"""将 CSS 语义字号换算为 ASS Fontsize,补偿中文字符在 em-square 中的留白。"""
|
||||
return max(1, round(font_size * ASS_FONTSIZE_COMPENSATION))
|
||||
|
||||
|
||||
# ── 颜色转换 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,8 +148,11 @@ def build_ass_style(
|
||||
# Shadow 深度:shadow_offset[1] 作为纵向偏移
|
||||
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
|
||||
|
||||
# 写入 ASS Style 时对字号做补偿,使成片中文字高与前端 CSS 预览一致
|
||||
ass_font_size = _compensate_ass_fontsize(font_size)
|
||||
|
||||
return (
|
||||
f"Style: {style_name},{actual_font},{font_size},{primary_color},"
|
||||
f"Style: {style_name},{actual_font},{ass_font_size},{primary_color},"
|
||||
f"&H000000FF,{outline_color},{back_color},"
|
||||
f"{bold_val},{italic_val},0,0,100,100,0,0,"
|
||||
f"1,{outline_width},{shadow_depth},{alignment},"
|
||||
@@ -191,7 +205,6 @@ def format_ass_time(seconds: float) -> str:
|
||||
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _wrap_title_text(
|
||||
text: str,
|
||||
video_width: int,
|
||||
@@ -211,6 +224,9 @@ def _wrap_title_text(
|
||||
if available_width <= 0:
|
||||
return text
|
||||
|
||||
# 换行宽度必须与实际渲染(补偿后的 ASS Fontsize)一致,否则换行位置会错位
|
||||
ass_font_size = _compensate_ass_fontsize(font_size)
|
||||
|
||||
# 先按已有 \N 分段,每段独立自动换行,最后用 \N 拼回
|
||||
segments = text.split("\\N")
|
||||
wrapped_segments: list[str] = []
|
||||
@@ -222,7 +238,7 @@ def _wrap_title_text(
|
||||
|
||||
for ch in seg:
|
||||
# CJK 字符按全角估算,其他按半角
|
||||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||||
char_width = float(ass_font_size) if ord(ch) > 0x2E80 else ass_font_size * 0.55
|
||||
|
||||
if current_width + char_width > available_width and current_line:
|
||||
lines.append(current_line)
|
||||
@@ -282,20 +298,28 @@ def build_ass_content(
|
||||
if title_config:
|
||||
_stroke_val = title_config.get("stroke")
|
||||
if isinstance(_stroke_val, bool):
|
||||
title_config["stroke"] = {
|
||||
"enabled": _stroke_val,
|
||||
"color": "#000000",
|
||||
"width": 2,
|
||||
} if _stroke_val else {"enabled": False}
|
||||
title_config["stroke"] = (
|
||||
{
|
||||
"enabled": _stroke_val,
|
||||
"color": "#000000",
|
||||
"width": 2,
|
||||
}
|
||||
if _stroke_val
|
||||
else {"enabled": False}
|
||||
)
|
||||
_shadow_val = title_config.get("shadow")
|
||||
if isinstance(_shadow_val, bool):
|
||||
title_config["shadow"] = {
|
||||
"enabled": _shadow_val,
|
||||
"color": "#000000",
|
||||
"blur": 4,
|
||||
"offset_x": 2,
|
||||
"offset_y": 2,
|
||||
} if _shadow_val else {"enabled": False}
|
||||
title_config["shadow"] = (
|
||||
{
|
||||
"enabled": _shadow_val,
|
||||
"color": "#000000",
|
||||
"blur": 4,
|
||||
"offset_x": 2,
|
||||
"offset_y": 2,
|
||||
}
|
||||
if _shadow_val
|
||||
else {"enabled": False}
|
||||
)
|
||||
|
||||
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
|
||||
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
|
||||
|
||||
@@ -15,6 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 按优先级查找 CJK 字体(Debian/Ubuntu fonts-noto-cjk 安装路径)
|
||||
_FONT_CANDIDATES = (
|
||||
"/usr/share/fonts/opentype/noto/NotoSansSC-VF.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||||
|
||||
@@ -105,9 +105,9 @@ class TestBuildAssStyle:
|
||||
|
||||
def test_contains_font_size(self):
|
||||
result = build_ass_style("S1", font_size=36)
|
||||
# Style行格式:Name, Fontname, Fontsize, ...
|
||||
# Style行格式:Name, Fontname, Fontsize, ...(36*1.35=48.6→49)
|
||||
parts = result.split(",")
|
||||
assert parts[2] == "36"
|
||||
assert parts[2] == "49"
|
||||
|
||||
def test_bold_true(self):
|
||||
result = build_ass_style("S1", bold=True)
|
||||
@@ -420,7 +420,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "72"
|
||||
assert parts[2] == "97" # 72*1.35=97.2→97
|
||||
break
|
||||
|
||||
def test_title_font_size_frontend_field_alias(self):
|
||||
@@ -435,7 +435,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "48"
|
||||
assert parts[2] == "65" # 48*1.35=64.8→65
|
||||
break
|
||||
|
||||
def test_title_font_color_frontend_field_alias(self):
|
||||
@@ -462,7 +462,7 @@ class TestBuildAssContent:
|
||||
for line in result.split("\n"):
|
||||
if line.startswith("Style: TitleStyle"):
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "56"
|
||||
assert parts[2] == "76" # 56*1.35=75.6→76
|
||||
break
|
||||
|
||||
def test_title_bold(self):
|
||||
|
||||
@@ -79,12 +79,12 @@ class TestBuildAssStyle:
|
||||
def test_minimal_style(self):
|
||||
result = build_ass_style("TestStyle")
|
||||
assert result.startswith("Style: TestStyle,")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert ",48," in result
|
||||
assert "Noto Sans SC" in result
|
||||
assert ",65," in result # 48*1.35=64.8→65
|
||||
|
||||
def test_custom_font_size(self):
|
||||
result = build_ass_style("Title", font_size=64)
|
||||
assert ",64," in result
|
||||
assert ",86," in result # 64*1.35=86.4→86
|
||||
|
||||
def test_bold_enabled(self):
|
||||
result = build_ass_style("BoldStyle", bold=True)
|
||||
@@ -152,21 +152,20 @@ class TestBuildAssStyle:
|
||||
# Style: 行有 23 个字段(去掉 "Style: " 前缀后)
|
||||
assert len(parts) == 23
|
||||
|
||||
|
||||
def test_font_name_mapping_siyuan(self):
|
||||
"""思源黑体 → Noto Sans CJK SC"""
|
||||
"""思源黑体 → Noto Sans SC"""
|
||||
result = build_ass_style("Test", font_name="思源黑体")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert "Noto Sans SC" in result
|
||||
|
||||
def test_font_name_mapping_apple(self):
|
||||
"""苹方 → Noto Sans CJK SC"""
|
||||
"""苹方 → Noto Sans SC"""
|
||||
result = build_ass_style("Test", font_name="苹方")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert "Noto Sans SC" in result
|
||||
|
||||
def test_font_name_mapping_msyh(self):
|
||||
"""微软雅黑 → Noto Sans CJK SC"""
|
||||
"""微软雅黑 → Noto Sans SC"""
|
||||
result = build_ass_style("Test", font_name="微软雅黑")
|
||||
assert "Noto Sans CJK SC" in result
|
||||
assert "Noto Sans SC" in result
|
||||
|
||||
def test_font_name_mapping_unknown_passthrough(self):
|
||||
"""未映射字体原样使用"""
|
||||
@@ -174,7 +173,6 @@ class TestBuildAssStyle:
|
||||
assert "CustomFont" in result
|
||||
|
||||
|
||||
|
||||
# ── 文本转义 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -483,6 +481,7 @@ class TestConstants:
|
||||
def test_title_margin_side(self):
|
||||
assert TITLE_MARGIN_SIDE == 40
|
||||
|
||||
|
||||
# ── 标题自动换行 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -554,7 +553,6 @@ class TestWrapTitleText:
|
||||
assert result == text
|
||||
assert result.count("\\N") == 2
|
||||
|
||||
|
||||
def test_build_ass_content_integration(self):
|
||||
"""集成测试:build_ass_content 中的标题应该自动换行。"""
|
||||
long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作"
|
||||
@@ -572,3 +570,35 @@ class TestWrapTitleText:
|
||||
break
|
||||
else:
|
||||
pytest.fail("未找到 TitleStyle Dialogue 行")
|
||||
|
||||
|
||||
class TestFontsizeCompensation:
|
||||
"""ASS Fontsize 补偿系数(CSS 字号 → ASS em-square 字号)。"""
|
||||
|
||||
def test_default_48_compensated_to_65(self):
|
||||
result = build_ass_style("S")
|
||||
parts = result.split(",")
|
||||
assert parts[2] == "65" # round(48*1.35)=65
|
||||
|
||||
def test_89_compensated_to_120(self):
|
||||
"""实测对齐点:font_size=89 → ASS Fontsize=120。"""
|
||||
result = build_ass_style("S", font_size=89)
|
||||
parts = result.split(",")
|
||||
assert parts[2] == "120"
|
||||
|
||||
def test_subtitle_also_compensated(self):
|
||||
content = build_ass_content(
|
||||
video_width=1080,
|
||||
video_height=1920,
|
||||
video_duration=5.0,
|
||||
subtitle_text="字幕",
|
||||
subtitle_config={"size": 24},
|
||||
)
|
||||
sub_line = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0]
|
||||
fields = [f.strip() for f in sub_line.split(",")]
|
||||
assert fields[2] == "32" # round(24*1.35)=32
|
||||
|
||||
def test_minimum_fontsize_at_least_one(self):
|
||||
result = build_ass_style("S", font_size=0)
|
||||
parts = result.split(",")
|
||||
assert int(parts[2]) >= 1
|
||||
|
||||
@@ -80,13 +80,13 @@ class TestBuildAssStyle:
|
||||
def test_basic_style(self):
|
||||
style = _build_ass_style("Default")
|
||||
assert style.startswith("Style: Default,")
|
||||
assert "Noto Sans CJK SC" in style
|
||||
assert "48" in style # font_size
|
||||
assert "Noto Sans SC" in style
|
||||
assert "65" in style # font_size 48*1.35=65
|
||||
|
||||
def test_custom_font(self):
|
||||
style = _build_ass_style("Custom", font_name="Arial", font_size=32)
|
||||
assert "Arial" in style
|
||||
assert ",32," in style
|
||||
assert ",43," in style # font_size 32*1.35=43
|
||||
|
||||
def test_bold(self):
|
||||
style = _build_ass_style("Bold", bold=True)
|
||||
|
||||
@@ -84,8 +84,8 @@ class TestBuildAssStyle:
|
||||
"""基本样式行包含关键字段."""
|
||||
line = _build_ass_style("Default")
|
||||
assert line.startswith("Style: Default,")
|
||||
assert "Noto Sans CJK SC" in line
|
||||
assert "48" in line # font_size
|
||||
assert "Noto Sans SC" in line
|
||||
assert "65" in line # font_size 48*1.35=65
|
||||
|
||||
def test_bold_enabled(self):
|
||||
"""加粗时Bold=-1."""
|
||||
@@ -112,7 +112,7 @@ class TestBuildAssStyle:
|
||||
"""自定义字号."""
|
||||
line = _build_ass_style("Big", font_size=72)
|
||||
parts = line.split(",")
|
||||
assert parts[2] == "72" # Fontsize
|
||||
assert parts[2] == "97" # Fontsize 72*1.35=97
|
||||
|
||||
def test_custom_alignment(self):
|
||||
"""自定义对齐方式."""
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",36," in content, f"默认字号应为36,实际内容: {content}"
|
||||
assert ",49," in content, f"默认字号36应补偿为49(36*1.35),实际内容: {content}"
|
||||
|
||||
def test_size_32_preserved(self):
|
||||
"""size=32 应原样使用。"""
|
||||
@@ -43,7 +43,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",32," in content
|
||||
assert ",43," in content # 32*1.35=43
|
||||
|
||||
def test_size_60_preserved(self):
|
||||
"""size=60 应原样保留(字号上限已移除)。"""
|
||||
@@ -58,7 +58,7 @@ class TestFontSize:
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
font_size = int(fields[2])
|
||||
assert font_size == 60, f"字号60应原样保留, 实际={font_size}"
|
||||
assert font_size == 81, f"字号60应补偿为81(60*1.35), 实际={font_size}"
|
||||
|
||||
def test_font_size_alias_normalized(self):
|
||||
"""前端传 font_size 应归一化为 size。"""
|
||||
@@ -72,7 +72,7 @@ class TestFontSize:
|
||||
)
|
||||
style_line = [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0]
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
assert fields[2] == "52", f"font_size=52 应归一化, 实际={fields[2]}"
|
||||
assert fields[2] == "70", f"font_size=52 应补偿为70(52*1.35), 实际={fields[2]}"
|
||||
|
||||
def test_font_color_alias_normalized(self):
|
||||
"""前端传 font_color 应归一化为 color。"""
|
||||
@@ -97,7 +97,7 @@ class TestFontSize:
|
||||
title_text="测试标题",
|
||||
title_config=config,
|
||||
)
|
||||
assert ",24," in content
|
||||
assert ",32," in content # 24*1.35=32.4→32
|
||||
|
||||
|
||||
class TestBooleanStrokeNormalization:
|
||||
@@ -230,9 +230,9 @@ class TestFullStyleConsistency:
|
||||
fields = [f.strip() for f in style_line.split(",")]
|
||||
|
||||
# Fontname
|
||||
assert fields[1] == "Noto Sans CJK SC"
|
||||
# Fontsize = 28
|
||||
assert fields[2] == "28"
|
||||
assert fields[1] == "Noto Sans SC"
|
||||
# Fontsize = 28*1.35=37.8→38
|
||||
assert fields[2] == "38"
|
||||
# Bold = -1 (True)
|
||||
assert fields[7] == "-1"
|
||||
# Outline width = 2 (前端默认 stroke width)
|
||||
|
||||
Reference in New Issue
Block a user