fix: ultrafast preset + 1fps output, move LayoutStudio before per-audio section

- Add -preset ultrafast and -r 1 to both libx264 encode paths for major
  1080p speed improvement (still image needs no motion estimation)
- Move LayoutStudio above the per-audio details list so layout/watermark
  settings are immediately visible and applied before submit
This commit is contained in:
Atakan Doğan Özban 2026-08-19 17:15:44 +02:00
parent 7f21bd437d
commit 3c733fec41
2 changed files with 884 additions and 257 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,15 +2,19 @@ import { spawn } from "child_process";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import ffmpegStatic from "ffmpeg-static"; import ffmpegStatic from "ffmpeg-static";
import { parseFile } from "music-metadata";
import { getVideoAttributionText } from "../branding"; import { getVideoAttributionText } from "../branding";
import { getResolution } from "../constants"; import { getResolution } from "../constants";
import { import {
isCuratedFontKey, isCuratedFontKey,
sanitizeFontfileForFilter, sanitizeFontfileForFilter,
} from "../fonts"; } from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server"; import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import { import {
buildArtTrackFilterComplex, buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
isLowerCornerTemplate,
normalizeLayoutSettings,
type LayoutSettings, type LayoutSettings,
} from "../layout"; } from "../layout";
import { import {
@ -35,6 +39,20 @@ function getFfmpegPath(): string {
export { getFfmpegPath }; export { getFfmpegPath };
async function getAudioDurationSeconds(audioPath: string): Promise<number> {
const { format } = await parseFile(audioPath);
const duration = format.duration;
if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) {
throw new Error(`Could not determine audio duration for ${audioPath}`);
}
return duration;
}
/** FFmpeg 5.x mishandles `-shortest` with `-loop 1` still inputs (adds ~50s tail silence). */
function formatOutputDurationSeconds(seconds: number): string {
return Math.max(0.01, seconds - 0.02).toFixed(3);
}
async function fileExists(p: string): Promise<boolean> { async function fileExists(p: string): Promise<boolean> {
try { try {
await fs.access(p); await fs.access(p);
@ -44,7 +62,7 @@ async function fileExists(p: string): Promise<boolean> {
} }
} }
/** Verify PNG magic bytes (prevents MIME spoof → FFmpeg surprises). */ /** Verify PNG magic bytes (prevents MIME spoof - FFmpeg surprises). */
export async function assertPngFile(filePath: string): Promise<void> { export async function assertPngFile(filePath: string): Promise<void> {
const fh = await fs.open(filePath, "r"); const fh = await fs.open(filePath, "r");
try { try {
@ -61,9 +79,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
async function resolveFontfileEscaped( async function resolveFontfileEscaped(
settings: WatermarkSettings, settings: WatermarkSettings,
weight: "regular" | "bold" = "regular",
): Promise<string | null> { ): Promise<string | null> {
const key = settings.fontKey ?? "system"; 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 (key === "custom") {
if (!settings.fontPath) return null; if (!settings.fontPath) return null;
@ -74,12 +109,19 @@ async function resolveFontfileEscaped(
} }
if (isCuratedFontKey(key)) { if (isCuratedFontKey(key)) {
const fontPath = resolveCuratedFontPath(key); const preferred = resolveCuratedFontPath(key, weight);
if (!(await fileExists(fontPath))) { const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`); for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
return null; 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; return null;
@ -89,6 +131,39 @@ function classicScaleFilter(width: number, height: number): string {
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`; return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
} }
/**
* Plan-aware audio for MP4:
* - Free: AAC 192k (compressed)
* - Pro: AAC 320k, or stream copy for already-compatible AAC/M4A
*/
export type AudioEncodeOptions = {
bitrateKbps: 192 | 320;
copyWhenSafe: boolean;
audioPath: string;
};
function isCopySafeAudioPath(audioPath: string): boolean {
const ext = path.extname(audioPath).toLowerCase();
return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
}
function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
args.push("-c:a", "copy");
return;
}
args.push(
"-c:a",
"aac",
"-b:a",
`${audio.bitrateKbps}k`,
"-ar",
"44100",
"-ac",
"2",
);
}
/** /**
* Art-track filter that ends at `outLabel` instead of hardcoded [laid]. * Art-track filter that ends at `outLabel` instead of hardcoded [laid].
*/ */
@ -99,6 +174,13 @@ function artTrackFilterEndingAt(
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`); 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: { export async function encodeVideo(options: {
imagePath: string; imagePath: string;
audioPath: string; audioPath: string;
@ -110,24 +192,64 @@ export async function encodeVideo(options: {
/** On-video song title (art-track layouts). */ /** On-video song title (art-track layouts). */
songTitle?: string; songTitle?: string;
artist?: string | null; 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;
/** Free = 192k AAC; Pro = 320k AAC (or copy when safe). Defaults to Free. */
audioBitrateKbps?: 192 | 320;
audioCopyWhenSafe?: boolean;
}): Promise<void> { }): Promise<void> {
const res = getResolution(options.resolution); const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`); if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
const audioEncode: AudioEncodeOptions = {
bitrateKbps: options.audioBitrateKbps ?? 192,
copyWhenSafe: Boolean(options.audioCopyWhenSafe),
audioPath: options.audioPath,
};
await fs.mkdir(path.dirname(options.outputPath), { recursive: true }); await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const outputDurationSec = formatOutputDurationSeconds(
await getAudioDurationSeconds(options.audioPath),
);
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark); 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 watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width); 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]; const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2; let nextInput = 2;
let separateBackgroundInputIndex: number | null = null;
let logoInputIndex: number | null = null; let logoInputIndex: number | null = null;
let defaultWmInputIndex: 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 needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath(); const defaultPath = getWatermarkPath();
const useDefaultPng = const useDefaultPng =
@ -156,17 +278,23 @@ export async function encodeVideo(options: {
? ("default-text" as const) ? ("default-text" as const)
: ("none" as const); : ("none" as const);
// Fast path: classic letterbox, no watermark // Fast path: classic letterbox (black bars), no watermark, no blur fill
if (!layout && applyWm === "none") { if (!layout && !classicBlurFill && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height)); args.push("-vf", classicScaleFilter(res.width, res.height));
args.push( args.push(
"-c:v", "-c:v",
"libx264", "libx264",
"-preset",
"ultrafast",
"-tune", "-tune",
"stillimage", "stillimage",
"-c:a", "-r",
"copy", "1",
"-shortest", );
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-t",
outputDurationSec,
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
options.outputPath, options.outputPath,
@ -192,6 +320,20 @@ export async function encodeVideo(options: {
titleEscaped, titleEscaped,
artistEscaped, artistEscaped,
fontfileEscaped: fontfile, 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, baseLabel,
), ),
@ -223,6 +365,7 @@ export async function encodeVideo(options: {
baseLabel, baseLabel,
watermarkInputIndex: defaultWmInputIndex, watermarkInputIndex: defaultWmInputIndex,
watermarkWidthPx: watermarkWidth, watermarkWidthPx: watermarkWidth,
position: settings.position,
offsetX: settings.offsetX, offsetX: settings.offsetX,
offsetY: settings.offsetY, offsetY: settings.offsetY,
}), }),
@ -235,7 +378,7 @@ export async function encodeVideo(options: {
position: settings.position, position: settings.position,
offsetX: settings.offsetX, offsetX: settings.offsetX,
offsetY: settings.offsetY, offsetY: settings.offsetY,
fontfileEscaped: null, fontfileEscaped: fontfile,
}); });
filterParts.push(`[${baseLabel}]${draw}[vout]`); filterParts.push(`[${baseLabel}]${draw}[vout]`);
} }
@ -244,11 +387,17 @@ export async function encodeVideo(options: {
args.push( args.push(
"-c:v", "-c:v",
"libx264", "libx264",
"-preset",
"ultrafast",
"-tune", "-tune",
"stillimage", "stillimage",
"-c:a", "-r",
"copy", "1",
"-shortest", );
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-t",
outputDurationSec,
"-pix_fmt", "-pix_fmt",
"yuv420p", "yuv420p",
options.outputPath, options.outputPath,