Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.
This commit is contained in:
+210
-33
@@ -1,8 +1,98 @@
|
||||
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 { getWatermarkPath } from "../storage";
|
||||
import {
|
||||
buildDrawtextFilter,
|
||||
normalizeWatermarkSettings,
|
||||
overlayXy,
|
||||
sanitizeDrawtext,
|
||||
type WatermarkSettings,
|
||||
} from "../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;
|
||||
@@ -10,62 +100,148 @@ export async function encodeVideo(options: {
|
||||
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 scaleFilter = `scale=${res.width}:${res.height}:force_original_aspect_ratio=decrease,pad=${res.width}:${res.height}:(ow-iw)/2:(oh-ih)/2:black`;
|
||||
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
|
||||
const layout = options.layout?.template ? options.layout : null;
|
||||
const watermarkWidth = Math.max(1, Math.round(res.width * 0.32));
|
||||
const fontSize = Math.max(16, Math.round(res.width * 0.018));
|
||||
const fontfile = await resolveFontfileEscaped(settings);
|
||||
|
||||
const watermarkPath = getWatermarkPath();
|
||||
let watermarkExists = false;
|
||||
try {
|
||||
await fs.access(watermarkPath);
|
||||
watermarkExists = true;
|
||||
} catch {
|
||||
watermarkExists = false;
|
||||
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 useWatermark = options.includeWatermark && watermarkExists;
|
||||
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);
|
||||
|
||||
const args = ["-y", "-loop", "1", "-i", options.imagePath, "-i", options.audioPath];
|
||||
|
||||
if (useWatermark) {
|
||||
args.push("-i", watermarkPath);
|
||||
// Fast path: classic letterbox, no watermark
|
||||
if (!layout && applyWm === "none") {
|
||||
args.push("-vf", classicScaleFilter(res.width, res.height));
|
||||
args.push(
|
||||
"-filter_complex",
|
||||
`[0:v]${scaleFilter}[scaled];[2:v]scale=iw*0.15:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-tune",
|
||||
"stillimage",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
options.outputPath,
|
||||
);
|
||||
} else if (options.includeWatermark && !watermarkExists) {
|
||||
args.push(
|
||||
"-filter_complex",
|
||||
`[0:v]${scaleFilter},drawtext=text='s2yt':fontsize=24:fontcolor=white@0.7:x=w-tw-20:y=h-th-20[vout]`,
|
||||
"-map",
|
||||
"[vout]",
|
||||
"-map",
|
||||
"1:a",
|
||||
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 {
|
||||
args.push("-vf", scaleFilter);
|
||||
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) {
|
||||
const { x, y } = overlayXy(settings.position, settings.offsetX, settings.offsetY);
|
||||
filterParts.push(
|
||||
`[${defaultWmInputIndex}:v]scale=${watermarkWidth}:-1[wm]`,
|
||||
`[${baseLabel}][wm]overlay=${x}:${y}[vout]`,
|
||||
);
|
||||
} 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",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"192k",
|
||||
"copy",
|
||||
"-shortest",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-shortest",
|
||||
options.outputPath,
|
||||
);
|
||||
|
||||
@@ -74,10 +250,11 @@ export async function encodeVideo(options: {
|
||||
|
||||
function runFfmpeg(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user