Initial OSS scaffold from Songs2VID (pre-strip)

This commit is contained in:
Songs2VID Support
2026-08-03 06:15:05 +02:00
commit 506d56fa92
195 changed files with 25023 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
/**
* Modular FFmpeg filter fragments for the Songs2VID brand watermark overlay.
* Always bottom-right with padding; scales to a fraction of frame width.
*/
export function buildBrandWatermarkOverlayFilters(options: {
baseLabel: string;
watermarkInputIndex: number;
watermarkWidthPx: number;
offsetX?: number;
offsetY?: number;
outLabel?: string;
}): string[] {
const ox = options.offsetX ?? 20;
const oy = options.offsetY ?? 20;
const out = options.outLabel ?? "vout";
return [
`[${options.watermarkInputIndex}:v]scale=${options.watermarkWidthPx}:-1[wm]`,
`[${options.baseLabel}][wm]overlay=W-w-${ox}:H-h-${oy}[${out}]`,
];
}
+286
View File
@@ -0,0 +1,286 @@
import { spawn } from "child_process";
import fs from "fs/promises";
import path from "path";
import ffmpegStatic from "ffmpeg-static";
import { getVideoAttributionText } from "../branding";
import { getResolution } from "../constants";
import {
isCuratedFontKey,
sanitizeFontfileForFilter,
} from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server";
import {
buildArtTrackFilterComplex,
type LayoutSettings,
} from "../layout";
import {
WATERMARK_WIDTH_FRACTION,
watermarkFontSizeForWidth,
} from "../preview-typography";
import { getWatermarkPath } from "../storage";
import {
buildDrawtextFilter,
normalizeWatermarkSettings,
overlayXy,
sanitizeDrawtext,
type WatermarkSettings,
} from "../watermark";
import { buildBrandWatermarkOverlayFilters } from "./brand-watermark";
function getFfmpegPath(): string {
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
if (ffmpegStatic) return ffmpegStatic;
return "ffmpeg";
}
export { getFfmpegPath };
async function fileExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
/** Verify PNG magic bytes (prevents MIME spoof → FFmpeg surprises). */
export async function assertPngFile(filePath: string): Promise<void> {
const fh = await fs.open(filePath, "r");
try {
const buf = Buffer.alloc(8);
await fh.read(buf, 0, 8, 0);
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (!buf.equals(sig)) {
throw new Error("Logo must be a valid PNG file");
}
} finally {
await fh.close();
}
}
async function resolveFontfileEscaped(
settings: WatermarkSettings,
): Promise<string | null> {
const key = settings.fontKey ?? "system";
if (key === "system") return null;
if (key === "custom") {
if (!settings.fontPath) return null;
if (!(await fileExists(settings.fontPath))) {
throw new Error("Custom watermark font file not found");
}
return sanitizeFontfileForFilter(path.resolve(settings.fontPath));
}
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;
}
return sanitizeFontfileForFilter(fontPath);
}
return null;
}
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`;
}
/**
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
*/
function artTrackFilterEndingAt(
opts: Parameters<typeof buildArtTrackFilterComplex>[0],
outLabel: string,
): string {
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
export async function encodeVideo(options: {
imagePath: string;
audioPath: string;
outputPath: string;
resolution: string;
includeWatermark: boolean;
watermark?: Partial<WatermarkSettings> | null;
layout?: LayoutSettings | null;
/** On-video song title (art-track layouts). */
songTitle?: string;
artist?: string | null;
}): Promise<void> {
const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
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 watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width);
const fontfile = await resolveFontfileEscaped(settings);
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2;
let logoInputIndex: number | null = null;
let defaultWmInputIndex: number | null = null;
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath();
const useDefaultPng =
settings.mode === "default" && (await fileExists(defaultPath));
if (needsLogo && settings.logoPath) {
if (!(await fileExists(settings.logoPath))) {
throw new Error("Watermark logo file not found");
}
await assertPngFile(settings.logoPath);
args.push("-i", settings.logoPath);
logoInputIndex = nextInput++;
} else if (useDefaultPng) {
args.push("-i", defaultPath);
defaultWmInputIndex = nextInput++;
}
const applyWm =
settings.mode === "logo" && logoInputIndex !== null
? ("logo" as const)
: settings.mode === "text" && settings.text?.trim()
? ("text" as const)
: settings.mode === "default" && defaultWmInputIndex !== null
? ("default-png" as const)
: settings.mode === "default"
? ("default-text" as const)
: ("none" as const);
// Fast path: classic letterbox, no watermark
if (!layout && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height));
args.push(
"-c:v",
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
"-shortest",
"-pix_fmt",
"yuv420p",
options.outputPath,
);
await runFfmpeg(args);
return;
}
const outDirect = applyWm === "none";
const baseLabel = outDirect ? "vout" : "base";
const filterParts: string[] = [];
if (layout) {
const titleEscaped = sanitizeDrawtext(options.songTitle?.trim() || "Untitled");
const artistRaw = options.artist?.trim();
const artistEscaped = artistRaw ? sanitizeDrawtext(artistRaw) : null;
filterParts.push(
artTrackFilterEndingAt(
{
width: res.width,
height: res.height,
layout,
titleEscaped,
artistEscaped,
fontfileEscaped: fontfile,
},
baseLabel,
),
);
} else {
filterParts.push(`[0:v]${classicScaleFilter(res.width, res.height)}[${baseLabel}]`);
}
if (applyWm === "logo" && logoInputIndex !== null) {
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
filterParts.push(
`[${logoInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
);
} else if (applyWm === "text") {
const draw = buildDrawtextFilter({
text: settings.text!.trim(),
fontSize,
fontColor: "white@0.9",
position: settings.position,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
fontfileEscaped: fontfile,
});
filterParts.push(`[${baseLabel}]${draw}[vout]`);
} else if (applyWm === "default-png" && defaultWmInputIndex !== null) {
filterParts.push(
...buildBrandWatermarkOverlayFilters({
baseLabel,
watermarkInputIndex: defaultWmInputIndex,
watermarkWidthPx: watermarkWidth,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
}),
);
} else if (applyWm === "default-text") {
const draw = buildDrawtextFilter({
text: getVideoAttributionText(),
fontSize,
fontColor: "white@0.85",
position: settings.position,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
fontfileEscaped: null,
});
filterParts.push(`[${baseLabel}]${draw}[vout]`);
}
args.push("-filter_complex", filterParts.join(";"), "-map", "[vout]", "-map", "1:a");
args.push(
"-c:v",
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
"-shortest",
"-pix_fmt",
"yuv420p",
options.outputPath,
);
await runFfmpeg(args);
}
function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn(getFfmpegPath(), args, { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
if (stderr.length > 64_000) stderr = stderr.slice(-32_000);
});
proc.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`FFmpeg failed (code ${code}): ${stderr.slice(-500)}`));
});
proc.on("error", (err) => {
reject(new Error(`FFmpeg not found or failed to start: ${err.message}`));
});
});
}
export async function cleanupFiles(paths: string[]) {
for (const p of paths) {
try {
await fs.unlink(p);
} catch {
// ignore missing files
}
}
}