fix: 标题 PNG 和封面 PNG 按视频分辨率等比例缩放

- titlePngGenerator.ts: 接收 targetWidth/targetHeight,Canvas 尺寸和字体大小按分辨率缩放
- SubtitleBurning.tsx: 探测视频分辨率后传入 titlePngGenerator
- useCoverFabric.ts: renderCover 接收 targetWidth/targetHeight,调整 canvas 尺寸,
  字体大小、描边宽度、阴影参数、文字位置全部按 resolutionScale 缩放
- CoverDesign.tsx: 探测成品视频/人物形象素材分辨率,传入 renderCover
This commit is contained in:
小鱼开发
2026-06-05 11:17:40 +08:00
parent 81de5ab642
commit c46c51170d
4 changed files with 99 additions and 37 deletions
+45 -19
View File
@@ -239,9 +239,11 @@ export function useCoverFabric() {
});
const fabricImg = new FabricImage(img);
const cw = canvas.width ?? CANVAS_WIDTH;
const ch = canvas.height ?? CANVAS_HEIGHT;
// 计算缩放:宽度/高度最大占画布的 68%
const maxWidth = CANVAS_WIDTH * 0.68;
const maxHeight = CANVAS_HEIGHT * 0.68;
const maxWidth = cw * 0.68;
const maxHeight = ch * 0.68;
const scale = Math.min(
maxWidth / (fabricImg.width || 1),
maxHeight / (fabricImg.height || 1)
@@ -251,10 +253,10 @@ export function useCoverFabric() {
const scaledHeight = (fabricImg.height || 1) * scale;
// 左下区域定位:左侧留边,底部与背景对齐
const leftMargin = 40;
const leftMargin = 40 * (cw / CANVAS_WIDTH);
fabricImg.set({
left: leftMargin,
top: CANVAS_HEIGHT - scaledHeight,
top: ch - scaledHeight,
originX: 'left',
originY: 'top',
selectable: false,
@@ -270,7 +272,11 @@ export function useCoverFabric() {
// 渲染封面
const renderCover = useCallback(
async (config: CoverDesignConfig) => {
async (
config: CoverDesignConfig,
targetWidth: number = CANVAS_WIDTH,
targetHeight: number = CANVAS_HEIGHT,
) => {
const canvas = fabricCanvasRef.current;
if (!canvas) {return;}
@@ -278,6 +284,16 @@ export function useCoverFabric() {
await loadCustomFont().catch(() => {
});
// 调整 canvas 内部渲染尺寸以匹配目标分辨率
if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
canvas.setDimensions(
{ width: targetWidth, height: targetHeight },
{ cssOnly: false }
);
}
const resolutionScale = targetWidth / CANVAS_WIDTH;
canvas.clear();
canvas.backgroundColor = '#1a1a2e';
@@ -300,29 +316,39 @@ export function useCoverFabric() {
}
const template = TEMPLATES[config.template];
const scaledMainFontSize = template.mainTitle.fontSize * resolutionScale;
const scaledSubFontSize = template.subtitle.fontSize * resolutionScale;
// 预计算主副标题行数
const mainTitleLines = config.mainTitle.trim()
? wrapTextByWidth(config.mainTitle.trim(), CANVAS_WIDTH - 120, template.mainTitle.fontSize).slice(0, 2)
? wrapTextByWidth(config.mainTitle.trim(), targetWidth - 120 * resolutionScale, scaledMainFontSize).slice(0, 2)
: [];
const subtitleLines = config.subtitle.trim()
? wrapTextByWidth(config.subtitle.trim(), CANVAS_WIDTH - 120, template.subtitle.fontSize).slice(0, 2)
? wrapTextByWidth(config.subtitle.trim(), targetWidth - 120 * resolutionScale, scaledSubFontSize).slice(0, 2)
: [];
const mainTitleLineHeight = template.mainTitle.fontSize * 1.2;
const subtitleLineHeight = template.subtitle.fontSize * 1.5;
const mainTitleLineHeight = scaledMainFontSize * 1.2;
const subtitleLineHeight = scaledSubFontSize * 1.5;
// 文字位置固定,不再根据封面形象高度和文字行数动态计算
const mainTitleTop = template.mainTitle.top;
const subtitleTop = template.subtitle.top;
// 文字位置按分辨率等比例缩放
const mainTitleTop = template.mainTitle.top * resolutionScale;
const subtitleTop = template.subtitle.top * resolutionScale;
// 缩放阴影参数
const scaleShadow = (shadow: Shadow) => new Shadow({
color: shadow.color,
blur: shadow.blur * resolutionScale,
offsetX: shadow.offsetX * resolutionScale,
offsetY: shadow.offsetY * resolutionScale,
});
// 3. 主标题(放在人物上方最外侧)
if (mainTitleLines.length > 0) {
mainTitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
left: targetWidth / 2,
top: mainTitleTop + i * mainTitleLineHeight,
fontSize: template.mainTitle.fontSize,
fontSize: scaledMainFontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
@@ -331,7 +357,7 @@ export function useCoverFabric() {
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
shadow: scaleShadow(template.mainTitle.shadow),
});
canvas.add(text);
});
@@ -341,13 +367,13 @@ export function useCoverFabric() {
if (subtitleLines.length > 0) {
subtitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
left: targetWidth / 2,
top: subtitleTop + i * subtitleLineHeight,
fontSize: template.subtitle.fontSize,
fontSize: scaledSubFontSize,
fill: template.subtitle.fill,
fontWeight: 'bold',
stroke: '#000000',
strokeWidth: 5,
strokeWidth: 5 * resolutionScale,
paintFirst: 'stroke',
fontFamily: FONT_FAMILY,
textAlign: 'center',
@@ -355,7 +381,7 @@ export function useCoverFabric() {
originY: 'top',
selectable: false,
evented: false,
shadow: template.subtitle.shadow,
shadow: scaleShadow(template.subtitle.shadow),
});
canvas.add(text);
});
@@ -102,6 +102,9 @@ export default function CoverDesign() {
const [modalBgs, setModalBgs] = useState<BgImage[]>([]);
const [lastModalBgIds, setLastModalBgIds] = useState<Set<string>>(new Set());
// 视频原始分辨率(用于封面按目标分辨率渲染)
const [videoResolution, setVideoResolution] = useState<{ width: number; height: number } | null>(null);
const { canvasRef, initCanvas, renderCover, exportPng } = useCoverFabric();
const { checkBalance } = usePointsCheck();
@@ -259,13 +262,43 @@ export default function CoverDesign() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.backgroundImage, config.avatarImage, projectId]);
// 探测视频分辨率(优先从成品视频,其次从人物形象素材)
useEffect(() => {
const detect = async () => {
const state = useProjectStore.getState();
const pathsToTry = [
state.composedVideoPath,
state.avatarMaterialPath,
].filter(Boolean) as string[];
for (const path of pathsToTry) {
try {
const res = await invoke<{ code: number; data?: { width: number; height: number; duration: number; fps: number }; message: string }>('get_video_metadata_cmd', {
request: { path },
});
if (res.code === 200 && res.data) {
setVideoResolution({ width: res.data.width, height: res.data.height });
return;
}
} catch {
// 继续尝试下一个
}
}
// 全部探测失败,使用默认 1080p
setVideoResolution({ width: 1080, height: 1920 });
};
detect();
}, []);
// 配置变化时重新渲染 Canvas
useEffect(() => {
const timer = setTimeout(() => {
renderCover(config);
const tw = videoResolution?.width ?? 1080;
const th = videoResolution?.height ?? 1920;
renderCover(config, tw, th);
}, 50);
return () => clearTimeout(timer);
}, [config, renderCover]);
}, [config, renderCover, videoResolution]);
// 生成封面图
const handleGenerate = async () => {
@@ -385,6 +385,10 @@ export default function SubtitleBurning() {
const mtStyle = mtPreset ? buildMainTitleStyle(mtPreset) : undefined;
const stStyle = stPreset ? buildSubTitleStyle(stPreset) : undefined;
// 2. 确定目标分辨率(优先复用组件 state 中已探测的结果)
let targetWidth = videoResolution?.width ?? 1080;
let targetHeight = videoResolution?.height ?? 1920;
if ((mainTitle && mtStyle) || (subTitle && stStyle)) {
const { generateTitlePngDataUrl } = await import('../../utils/titlePngGenerator');
const pngDataUrl = await generateTitlePngDataUrl(
@@ -392,6 +396,8 @@ export default function SubtitleBurning() {
subTitle || undefined,
mtStyle,
stStyle,
targetWidth,
targetHeight,
);
// 去掉 data:image/png;base64, 前缀
const base64 = pngDataUrl.replace(/^data:image\/png;base64,/, '');
@@ -406,9 +412,6 @@ export default function SubtitleBurning() {
overlayImagePath = pngSaveRes.data;
}
// 2. 确定 ASS 字幕目标分辨率(优先复用组件 state 中已探测的结果)
let targetWidth = videoResolution?.width ?? 1080;
let targetHeight = videoResolution?.height ?? 1920;
if (!videoResolution) {
const metaRes = await invoke<{ code: number; data?: { width: number; height: number; duration: number; fps: number }; message: string }>('get_video_metadata_cmd', {
request: { path: actualVideoPath },
+13 -13
View File
@@ -9,13 +9,10 @@
import type { AssStyle } from './assGenerator';
import { setCanvasFont, drawTextBlock, loadCustomFont } from './canvasSubtitleDrawer';
const VIDEO_WIDTH = 1080;
const VIDEO_HEIGHT = 1920;
/**
* 生成标题 PNG DataURL
*
* Canvas 尺寸与视频一致1080x1920,只在标题区域绘制内容,
* Canvas 尺寸与视频分辨率一致,只在标题区域绘制内容,
* 其余部分透明。FFmpeg overlay 时直接 x=0:y=0 叠加即可。
*/
export async function generateTitlePngDataUrl(
@@ -23,13 +20,15 @@ export async function generateTitlePngDataUrl(
subTitle: string | undefined,
mainTitleStyle: Partial<AssStyle> | undefined,
subTitleStyle: Partial<AssStyle> | undefined,
targetWidth: number = 1080,
targetHeight: number = 1920,
): Promise<string> {
// 确保字体已加载
await loadCustomFont();
const canvas = document.createElement('canvas');
canvas.width = VIDEO_WIDTH;
canvas.height = VIDEO_HEIGHT;
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
@@ -39,30 +38,31 @@ export async function generateTitlePngDataUrl(
// 清空为透明
ctx.clearRect(0, 0, canvas.width, canvas.height);
const scale = 1; // 1:1Canvas 尺寸就是视频分辨率
const baseWidth = 1080;
const scale = targetWidth / baseWidth;
// ---- 绘制大标题(单行,不换行) ----
// ASS alignment=8: marginV 是文字块顶部到画面顶部的距离
// Canvas fillText y 是基线,基线 = 顶部 + ascent
if (mainTitle && mainTitleStyle) {
setCanvasFont(ctx, mainTitleStyle, scale);
const marginV = mainTitleStyle.marginV ?? 360;
const fontSize = mainTitleStyle.fontSize ?? 104;
const marginV = (mainTitleStyle.marginV ?? 360) * scale;
const fontSize = (mainTitleStyle.fontSize ?? 104) * scale;
const metrics = ctx.measureText(mainTitle);
const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.72;
const startY = marginV + ascent;
drawTextBlock(ctx, mainTitle, VIDEO_WIDTH / 2, startY, mainTitleStyle, scale, VIDEO_WIDTH, 1);
drawTextBlock(ctx, mainTitle, targetWidth / 2, startY, mainTitleStyle, scale, targetWidth, 1);
}
// ---- 绘制小标题(单行,不换行) ----
if (subTitle && subTitleStyle) {
setCanvasFont(ctx, subTitleStyle, scale);
const marginV = subTitleStyle.marginV ?? 480;
const fontSize = subTitleStyle.fontSize ?? 88;
const marginV = (subTitleStyle.marginV ?? 480) * scale;
const fontSize = (subTitleStyle.fontSize ?? 88) * scale;
const metrics = ctx.measureText(subTitle);
const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.72;
const startY = marginV + ascent;
drawTextBlock(ctx, subTitle, VIDEO_WIDTH / 2, startY, subTitleStyle, scale, VIDEO_WIDTH, 1);
drawTextBlock(ctx, subTitle, targetWidth / 2, startY, subTitleStyle, scale, targetWidth, 1);
}
return canvas.toDataURL('image/png');