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 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 },
|
||||
|
||||
@@ -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:1,Canvas 尺寸就是视频分辨率
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user