Compare commits

...

3 Commits

Author SHA1 Message Date
xiaoxia 9709d5471b fix(ass): Fontsize乘1.35补偿系数对齐CSS预览字号 (#1516)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m56s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
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 / Validate - Migration (alembic) (push) Successful in 6m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 6m30s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 6m55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m56s
AI Code Review / AI Code Review (pull_request) Failing after 4m14s
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 / Frontend Unit Tests (push) Successful in 8m39s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 8m58s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 9m46s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m56s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Failing after 11m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 12m56s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m2s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 13m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m46s
CI/CD Pipeline / Unit Tests (push) Successful in 16m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 8m9s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 8m30s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 14m55s
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
CI/CD Pipeline / CI Gate (pull_request) Successful in 29s
2026-08-27 01:44:19 +08:00
xiaoxia e1482a4b11 fix: Step2素材选择后调用/clips/from-assets,修复片段数量不足+随机起始时间 (#1515)
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 4m9s
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m10s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5m22s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 5m52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 9m28s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (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
AI Code Review / AI Code Review (pull_request) Failing after 7m32s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m33s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m48s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 12m26s
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
CI/CD Pipeline / CI Gate (pull_request) Successful in 24s
问题:前端在本地构建clips(每素材1个片段,start_time=0),
未调用后端/clips/from-assets,导致模板要求4个片段但只选3个素材时
只生成3个片段,后端随机start_time和多片段逻辑从未被触发。

修复:
- createClipsFromAssets增加requiredClipsCount可选参数
- useStep2Materials改为先清空旧片段再调用from-assets,
  required_clips_count=模板segments数
- 删除废弃的buildClipsFromAssets工具函数
2026-08-27 00:42:32 +08:00
xiaoxia d4c8064cdc fix(fonts): FONT_NAME_MAP改为Noto Sans SC适配容器实际字体 (#1514)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m39s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 5m50s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m36s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m35s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 4m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m24s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 5m9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 8m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m21s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m22s
CI/CD Pipeline / Integration Tests (push) Successful in 3m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m17s
CI/CD Pipeline / Unit Tests (push) Successful in 13m18s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
2026-08-27 00:35:48 +08:00
10 changed files with 129 additions and 136 deletions
+9 -1
View File
@@ -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,
}
})
}
+44 -20
View File
@@ -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())
+1
View File
@@ -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):
+42 -12
View File
@@ -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
+3 -3
View File
@@ -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)
+3 -3
View File
@@ -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):
"""自定义对齐方式."""
+8 -8
View File
@@ -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)