Add lower-corner layouts, custom blur backgrounds, and classic blur fill.
Ship composition families, optional background images for lower-corner templates, and an optional blurred cover fill for classic letterbox.
This commit is contained in:
+87
-12
@@ -8,9 +8,12 @@ import {
|
||||
isCuratedFontKey,
|
||||
sanitizeFontfileForFilter,
|
||||
} from "../fonts";
|
||||
import { resolveCuratedFontPath } from "../fonts-server";
|
||||
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
|
||||
import {
|
||||
buildArtTrackFilterComplex,
|
||||
buildClassicBlurFillFilterComplex,
|
||||
isLowerCornerTemplate,
|
||||
normalizeLayoutSettings,
|
||||
type LayoutSettings,
|
||||
} from "../layout";
|
||||
import {
|
||||
@@ -61,9 +64,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
|
||||
|
||||
async function resolveFontfileEscaped(
|
||||
settings: WatermarkSettings,
|
||||
weight: "regular" | "bold" = "regular",
|
||||
): Promise<string | null> {
|
||||
const key = settings.fontKey ?? "system";
|
||||
if (key === "system") return null;
|
||||
if (key === "system") {
|
||||
const preferred = resolveSystemFontPath(weight);
|
||||
const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
|
||||
for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
|
||||
if (!(await fileExists(fontPath))) continue;
|
||||
try {
|
||||
await assertFontFile(fontPath);
|
||||
return sanitizeFontfileForFilter(fontPath);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key === "custom") {
|
||||
if (!settings.fontPath) return null;
|
||||
@@ -74,12 +94,19 @@ async function resolveFontfileEscaped(
|
||||
}
|
||||
|
||||
if (isCuratedFontKey(key)) {
|
||||
const fontPath = resolveCuratedFontPath(key);
|
||||
if (!(await fileExists(fontPath))) {
|
||||
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
|
||||
return null;
|
||||
const preferred = resolveCuratedFontPath(key, weight);
|
||||
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
|
||||
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
|
||||
if (!(await fileExists(fontPath))) continue;
|
||||
try {
|
||||
await assertFontFile(fontPath);
|
||||
return sanitizeFontfileForFilter(fontPath);
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return sanitizeFontfileForFilter(fontPath);
|
||||
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -99,6 +126,13 @@ function artTrackFilterEndingAt(
|
||||
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
||||
}
|
||||
|
||||
function classicBlurFillEndingAt(
|
||||
opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
|
||||
outLabel: string,
|
||||
): string {
|
||||
return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
|
||||
}
|
||||
|
||||
export async function encodeVideo(options: {
|
||||
imagePath: string;
|
||||
audioPath: string;
|
||||
@@ -110,6 +144,11 @@ export async function encodeVideo(options: {
|
||||
/** On-video song title (art-track layouts). */
|
||||
songTitle?: string;
|
||||
artist?: string | null;
|
||||
/**
|
||||
* Optional full-frame background for lower-corner templates.
|
||||
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
|
||||
*/
|
||||
backgroundImagePath?: string | null;
|
||||
}): Promise<void> {
|
||||
const res = getResolution(options.resolution);
|
||||
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
|
||||
@@ -117,17 +156,39 @@ export async function encodeVideo(options: {
|
||||
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
|
||||
|
||||
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||
const layout = options.layout?.template ? options.layout : null;
|
||||
const layoutSettings = options.layout
|
||||
? normalizeLayoutSettings(options.layout)
|
||||
: null;
|
||||
const artTrack = Boolean(layoutSettings?.template);
|
||||
const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill);
|
||||
const layout = artTrack && layoutSettings ? layoutSettings : null;
|
||||
const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
|
||||
const fontSize = watermarkFontSizeForWidth(res.width);
|
||||
const fontfile = await resolveFontfileEscaped(settings);
|
||||
const fontfile = await resolveFontfileEscaped(settings, "regular");
|
||||
const titleFontfile =
|
||||
layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
|
||||
// Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
|
||||
const titleFontfileDistinct =
|
||||
titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;
|
||||
|
||||
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||
|
||||
let nextInput = 2;
|
||||
let separateBackgroundInputIndex: number | null = null;
|
||||
let logoInputIndex: number | null = null;
|
||||
let defaultWmInputIndex: number | null = null;
|
||||
|
||||
const useSeparateBackground =
|
||||
Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);
|
||||
|
||||
if (useSeparateBackground && options.backgroundImagePath) {
|
||||
if (!(await fileExists(options.backgroundImagePath))) {
|
||||
throw new Error("Background image file not found");
|
||||
}
|
||||
args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
|
||||
separateBackgroundInputIndex = nextInput++;
|
||||
}
|
||||
|
||||
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
|
||||
const defaultPath = getWatermarkPath();
|
||||
const useDefaultPng =
|
||||
@@ -156,8 +217,8 @@ export async function encodeVideo(options: {
|
||||
? ("default-text" as const)
|
||||
: ("none" as const);
|
||||
|
||||
// Fast path: classic letterbox, no watermark
|
||||
if (!layout && applyWm === "none") {
|
||||
// Fast path: classic letterbox (black bars), no watermark, no blur fill
|
||||
if (!layout && !classicBlurFill && applyWm === "none") {
|
||||
args.push("-vf", classicScaleFilter(res.width, res.height));
|
||||
args.push(
|
||||
"-c:v",
|
||||
@@ -192,6 +253,20 @@ export async function encodeVideo(options: {
|
||||
titleEscaped,
|
||||
artistEscaped,
|
||||
fontfileEscaped: fontfile,
|
||||
titleFontfileEscaped: titleFontfileDistinct,
|
||||
separateBackgroundInputIndex,
|
||||
},
|
||||
baseLabel,
|
||||
),
|
||||
);
|
||||
} else if (classicBlurFill && layoutSettings) {
|
||||
filterParts.push(
|
||||
classicBlurFillEndingAt(
|
||||
{
|
||||
width: res.width,
|
||||
height: res.height,
|
||||
blurAmount: layoutSettings.blurAmount,
|
||||
blurOpacity: layoutSettings.blurOpacity,
|
||||
},
|
||||
baseLabel,
|
||||
),
|
||||
@@ -235,7 +310,7 @@ export async function encodeVideo(options: {
|
||||
position: settings.position,
|
||||
offsetX: settings.offsetX,
|
||||
offsetY: settings.offsetY,
|
||||
fontfileEscaped: null,
|
||||
fontfileEscaped: fontfile,
|
||||
});
|
||||
filterParts.push(`[${baseLabel}]${draw}[vout]`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user