diff --git a/tauri-app/src/hooks/useCoverFabric.ts b/tauri-app/src/hooks/useCoverFabric.ts index f4f0982..dc9fd06 100644 --- a/tauri-app/src/hooks/useCoverFabric.ts +++ b/tauri-app/src/hooks/useCoverFabric.ts @@ -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); }); diff --git a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx index 3d217ce..9a18a76 100644 --- a/tauri-app/src/pages/VideoCreation/CoverDesign.tsx +++ b/tauri-app/src/pages/VideoCreation/CoverDesign.tsx @@ -102,6 +102,9 @@ export default function CoverDesign() { const [modalBgs, setModalBgs] = useState([]); const [lastModalBgIds, setLastModalBgIds] = useState>(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 () => { diff --git a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx index ad39898..45a63e7 100644 --- a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx +++ b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx @@ -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 }, diff --git a/tauri-app/src/utils/titlePngGenerator.ts b/tauri-app/src/utils/titlePngGenerator.ts index 3fd90a5..8fcece6 100644 --- a/tauri-app/src/utils/titlePngGenerator.ts +++ b/tauri-app/src/utils/titlePngGenerator.ts @@ -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 | undefined, subTitleStyle: Partial | undefined, + targetWidth: number = 1080, + targetHeight: number = 1920, ): Promise { // 确保字体已加载 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');