mirror of
https://git.atakanozban.com/Songs2VID/songs2vid.git
synced 2026-10-04 10:38:32 +02:00
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:
parent
7f21bd437d
commit
3c733fec41
File diff suppressed because it is too large
Load Diff
@ -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);
|
return sanitizeFontfileForFilter(fontPath);
|
||||||
|
} catch {
|
||||||
|
/* try next */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user