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