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:
@@ -9,8 +9,17 @@ import { fileURLToPath } from "url";
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = path.join(__dirname, "..", "assets", "fonts");
|
||||
|
||||
/** Direct TTF URLs from Google Fonts GitHub (OFL). */
|
||||
/** Direct TTF URLs from Google Fonts GitHub (OFL / Apache 2.0). */
|
||||
const FONTS = [
|
||||
{
|
||||
file: "Arimo-Regular.ttf",
|
||||
// Static TTF from googlefonts/arimo (not the broken google/fonts apache/static path)
|
||||
url: "https://cdn.jsdelivr.net/gh/googlefonts/arimo@main/fonts/ttf/Arimo-Regular.ttf",
|
||||
},
|
||||
{
|
||||
file: "Arimo-Bold.ttf",
|
||||
url: "https://cdn.jsdelivr.net/gh/googlefonts/arimo@main/fonts/ttf/Arimo-Bold.ttf",
|
||||
},
|
||||
{
|
||||
file: "Inter-Regular.ttf",
|
||||
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf",
|
||||
@@ -35,12 +44,22 @@ const FONTS = [
|
||||
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
function looksLikeFont(buf) {
|
||||
if (buf.length < 4) return false;
|
||||
const asStr = buf.subarray(0, 4).toString("ascii");
|
||||
if (asStr === "OTTO" || asStr === "true" || asStr === "typ1") return true;
|
||||
return buf[0] === 0x00 && buf[1] === 0x01 && buf[2] === 0x00 && buf[3] === 0x00;
|
||||
}
|
||||
|
||||
for (const font of FONTS) {
|
||||
const dest = path.join(outDir, font.file);
|
||||
try {
|
||||
await fs.access(dest);
|
||||
console.log("skip (exists)", font.file);
|
||||
continue;
|
||||
const existing = await fs.readFile(dest);
|
||||
if (looksLikeFont(existing)) {
|
||||
console.log("skip (exists)", font.file);
|
||||
continue;
|
||||
}
|
||||
console.warn("corrupt font, re-fetching", font.file);
|
||||
} catch {
|
||||
/* download */
|
||||
}
|
||||
@@ -54,6 +73,10 @@ for (const font of FONTS) {
|
||||
continue;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (!looksLikeFont(buf)) {
|
||||
console.error("FAILED invalid font bytes", font.file, buf.subarray(0, 8).toString("hex"));
|
||||
continue;
|
||||
}
|
||||
await fs.writeFile(dest, buf);
|
||||
console.log("wrote", font.file, buf.length, "bytes");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Build assets/watermark.png for the default Songs2VID badge overlay.
|
||||
* Uses bundled Arimo + FFmpeg (same toolchain as video encode).
|
||||
*
|
||||
* Run: node scripts/generate-watermark-png.mjs
|
||||
*/
|
||||
import { spawnSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import ffmpegStatic from "ffmpeg-static";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.join(__dirname, "..");
|
||||
const fontPath = path.join(root, "assets", "fonts", "Arimo-Regular.ttf");
|
||||
const outPath = path.join(root, "assets", "watermark.png");
|
||||
|
||||
function ffmpegPath() {
|
||||
return process.env.FFMPEG_PATH || ffmpegStatic || "ffmpeg";
|
||||
}
|
||||
|
||||
function escFont(p) {
|
||||
return p.replace(/\\/g, "/").replace(/:/g, "\\:");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(fontPath)) {
|
||||
console.error("Missing font:", fontPath, "— run node scripts/fetch-watermark-fonts.mjs first");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const font = escFont(fontPath);
|
||||
const w = 960;
|
||||
const h = 88;
|
||||
const filter = [
|
||||
`[0:v]format=rgba,drawbox=x=0:y=0:w=iw:h=ih:color=0x000000@0.45:t=fill`,
|
||||
`drawtext=fontfile='${font}':text='Uploaded through Songs2VID.com':fontsize=32:fontcolor=white@0.92:x=(w-text_w)/2:y=(h-th)/2`,
|
||||
].join(",");
|
||||
|
||||
const args = [
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
`color=c=black@0.0:s=${w}x${h}`,
|
||||
"-vf",
|
||||
filter,
|
||||
"-update",
|
||||
"1",
|
||||
outPath,
|
||||
];
|
||||
|
||||
const res = spawnSync(ffmpegPath(), args, { stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
console.log("wrote", outPath, fs.statSync(outPath).size, "bytes");
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
blurToBoxblur,
|
||||
boxblurFilterSegment,
|
||||
buildArtTrackFilterComplex,
|
||||
buildClassicBlurFillFilterComplex,
|
||||
clampBlurAmount,
|
||||
clampTextPadding,
|
||||
clampTitleArtistGap,
|
||||
@@ -32,8 +33,22 @@ function testEnumsAndClamp() {
|
||||
function testNormalize() {
|
||||
const classic = normalizeLayoutSettings(null);
|
||||
assert.equal(classic.template, null);
|
||||
assert.equal(classic.blurFill, false);
|
||||
assert.equal(classic.titleArtistGap, 10);
|
||||
assert.equal(classic.textOffsetX, 0);
|
||||
assert.equal(classic.titleBold, true);
|
||||
|
||||
assert.equal(
|
||||
normalizeLayoutSettings({ blur_fill: true }).blurFill,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeLayoutSettings({
|
||||
template: "CENTERED_COMPACT",
|
||||
blurFill: true,
|
||||
}).blurFill,
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
normalizeLayoutSettings({
|
||||
@@ -75,26 +90,117 @@ function testGeometryAndFilter() {
|
||||
const tight = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 0, 0, 0);
|
||||
assert.ok(Number(withGap.artistY) - Number(withGap.titleY) > Number(tight.artistY) - Number(tight.titleY));
|
||||
|
||||
const lowerLeft = computeLayoutGeometry("LOWER_LEFT_COVER_TEXT", 1280, 720, 48, 10, 0, 0);
|
||||
assert.equal(lowerLeft.coverX, "48");
|
||||
assert.equal(lowerLeft.coverY, "H-h-48");
|
||||
assert.ok(Number(lowerLeft.titleX) > Number(lowerLeft.coverX));
|
||||
assert.ok(Number(lowerLeft.titleY) > 300, "title should sit in the lower half with the cover");
|
||||
|
||||
const lowerRight = computeLayoutGeometry("LOWER_RIGHT_COVER_TEXT", 1280, 720, 48, 10, 0, 0);
|
||||
assert.equal(lowerRight.coverX, "W-w-48");
|
||||
assert.equal(lowerRight.coverY, "H-h-48");
|
||||
assert.ok(String(lowerRight.titleX).startsWith("W-text_w-"));
|
||||
|
||||
const fc = buildArtTrackFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
layout: {
|
||||
template: "CENTERED_COMPACT",
|
||||
blurFill: false,
|
||||
blurAmount: 40,
|
||||
blurOpacity: 70,
|
||||
textPadding: 40,
|
||||
titleArtistGap: 18,
|
||||
textOffsetX: 5,
|
||||
textOffsetY: -8,
|
||||
titleBold: true,
|
||||
},
|
||||
titleEscaped: sanitizeDrawtext("Hello:World"),
|
||||
artistEscaped: sanitizeDrawtext("Artist"),
|
||||
fontfileEscaped: null,
|
||||
titleFontfileEscaped: "C\\:/fonts/Arimo-Bold.ttf",
|
||||
});
|
||||
assert.ok(fc.includes("split=2"));
|
||||
assert.ok(fc.includes("blend=") || fc.includes("[blurred]"));
|
||||
assert.ok(fc.includes("overlay="));
|
||||
assert.ok(fc.includes("drawtext="));
|
||||
assert.ok(fc.includes("fontfile='C\\:/fonts/Arimo-Bold.ttf'"));
|
||||
assert.ok(fc.endsWith("[laid]"));
|
||||
|
||||
const regular = buildArtTrackFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
layout: {
|
||||
template: "CENTERED_COMPACT",
|
||||
blurFill: false,
|
||||
blurAmount: 40,
|
||||
blurOpacity: 70,
|
||||
textPadding: 40,
|
||||
titleArtistGap: 18,
|
||||
textOffsetX: 0,
|
||||
textOffsetY: 0,
|
||||
titleBold: false,
|
||||
},
|
||||
titleEscaped: "Hi",
|
||||
artistEscaped: null,
|
||||
fontfileEscaped: "C\\:/fonts/Arimo-Regular.ttf",
|
||||
});
|
||||
assert.ok(regular.includes("fontfile='C\\:/fonts/Arimo-Regular.ttf'"));
|
||||
assert.equal(regular.includes("borderw="), false);
|
||||
|
||||
const withSeparateBg = buildArtTrackFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
layout: {
|
||||
template: "LOWER_LEFT_COVER_TEXT",
|
||||
blurFill: false,
|
||||
blurAmount: 40,
|
||||
blurOpacity: 100,
|
||||
textPadding: 48,
|
||||
titleArtistGap: 10,
|
||||
textOffsetX: 0,
|
||||
textOffsetY: 0,
|
||||
titleBold: true,
|
||||
},
|
||||
titleEscaped: "Song",
|
||||
artistEscaped: "Artist",
|
||||
separateBackgroundInputIndex: 2,
|
||||
});
|
||||
assert.equal(withSeparateBg.includes("[0:v]split=2"), false);
|
||||
assert.ok(withSeparateBg.includes("[2:v]"));
|
||||
assert.ok(withSeparateBg.includes("[0:v]scale="));
|
||||
assert.ok(withSeparateBg.includes("overlay="));
|
||||
|
||||
const withoutSeparateBg = buildArtTrackFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
layout: {
|
||||
template: "LOWER_LEFT_COVER_TEXT",
|
||||
blurFill: false,
|
||||
blurAmount: 40,
|
||||
blurOpacity: 100,
|
||||
textPadding: 48,
|
||||
titleArtistGap: 10,
|
||||
textOffsetX: 0,
|
||||
textOffsetY: 0,
|
||||
titleBold: true,
|
||||
},
|
||||
titleEscaped: "Song",
|
||||
artistEscaped: null,
|
||||
});
|
||||
assert.ok(withoutSeparateBg.includes("[0:v]split=2"));
|
||||
|
||||
const classicBlur = buildClassicBlurFillFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
blurAmount: 55,
|
||||
blurOpacity: 100,
|
||||
});
|
||||
assert.ok(classicBlur.includes("[0:v]split=2"));
|
||||
assert.ok(classicBlur.includes("overlay=(W-w)/2:(H-h)/2"));
|
||||
assert.equal(classicBlur.includes("pad="), false);
|
||||
assert.equal(classicBlur.includes("drawtext="), false);
|
||||
assert.ok(classicBlur.endsWith("[laid]"));
|
||||
}
|
||||
|
||||
testEnumsAndClamp();
|
||||
|
||||
Reference in New Issue
Block a user