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:
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Download curated watermark fonts into assets/fonts for FFmpeg drawtext.
|
||||
* Run: node scripts/fetch-watermark-fonts.mjs
|
||||
*/
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
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). */
|
||||
const FONTS = [
|
||||
{
|
||||
file: "Inter-Regular.ttf",
|
||||
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf",
|
||||
},
|
||||
{
|
||||
file: "Montserrat-Regular.ttf",
|
||||
url: "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf",
|
||||
},
|
||||
{
|
||||
file: "Roboto-Regular.ttf",
|
||||
url: "https://github.com/google/fonts/raw/main/ofl/roboto/Roboto%5Bwdth%2Cwght%5D.ttf",
|
||||
},
|
||||
{
|
||||
file: "Oswald-Regular.ttf",
|
||||
url: "https://github.com/google/fonts/raw/main/ofl/oswald/Oswald%5Bwght%5D.ttf",
|
||||
},
|
||||
{
|
||||
file: "PlayfairDisplay-Regular.ttf",
|
||||
url: "https://github.com/google/fonts/raw/main/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf",
|
||||
},
|
||||
];
|
||||
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const font of FONTS) {
|
||||
const dest = path.join(outDir, font.file);
|
||||
try {
|
||||
await fs.access(dest);
|
||||
console.log("skip (exists)", font.file);
|
||||
continue;
|
||||
} catch {
|
||||
/* download */
|
||||
}
|
||||
console.log("fetch", font.file);
|
||||
const res = await fetch(font.url, {
|
||||
headers: { "User-Agent": "songs2vid-font-fetch/1.0" },
|
||||
redirect: "follow",
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("FAILED", font.file, res.status);
|
||||
continue;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.writeFile(dest, buf);
|
||||
console.log("wrote", font.file, buf.length, "bytes");
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(outDir, "README.md"),
|
||||
`# Watermark fonts
|
||||
|
||||
Curated TTF assets for FFmpeg \`drawtext\` (OFL via Google Fonts).
|
||||
Refresh with \`node scripts/fetch-watermark-fonts.mjs\`.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
console.log("done →", outDir);
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
BLUR_AMOUNT_MAX,
|
||||
INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
blurToBoxblur,
|
||||
boxblurFilterSegment,
|
||||
buildArtTrackFilterComplex,
|
||||
clampBlurAmount,
|
||||
clampTextPadding,
|
||||
clampTitleArtistGap,
|
||||
computeLayoutGeometry,
|
||||
normalizeLayoutSettings,
|
||||
requiresArtTrackLayoutEntitlement,
|
||||
} from "../lib/layout";
|
||||
import { sanitizeDrawtext } from "../lib/watermark";
|
||||
|
||||
function testEnumsAndClamp() {
|
||||
assert.equal(clampBlurAmount(150), BLUR_AMOUNT_MAX);
|
||||
assert.equal(clampBlurAmount(-1), 0);
|
||||
assert.equal(clampTextPadding(999), 120);
|
||||
assert.equal(clampTextPadding(1), 16);
|
||||
assert.equal(clampTitleArtistGap(100), 64);
|
||||
assert.equal(clampTitleArtistGap(-5), 0);
|
||||
|
||||
assert.equal(blurToBoxblur(0), null);
|
||||
const mid = blurToBoxblur(50);
|
||||
assert.ok(mid && mid.radius >= 1 && mid.power >= 1);
|
||||
assert.ok(boxblurFilterSegment(60).includes("boxblur="));
|
||||
assert.equal(boxblurFilterSegment(0), "");
|
||||
}
|
||||
|
||||
function testNormalize() {
|
||||
const classic = normalizeLayoutSettings(null);
|
||||
assert.equal(classic.template, null);
|
||||
assert.equal(classic.titleArtistGap, 10);
|
||||
assert.equal(classic.textOffsetX, 0);
|
||||
|
||||
assert.equal(
|
||||
normalizeLayoutSettings({
|
||||
layout_template: "CENTERED_COMPACT",
|
||||
blur_amount: 80,
|
||||
title_artist_gap: 24,
|
||||
text_offset_y: -20,
|
||||
}).titleArtistGap,
|
||||
24,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => normalizeLayoutSettings({ template: "NOT_A_TEMPLATE" }),
|
||||
(err: Error) => err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeLayoutSettings({ template: "COVER_LEFT_TEXT_RIGHT", x: 10 }),
|
||||
(err: Error) => err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
requiresArtTrackLayoutEntitlement(normalizeLayoutSettings({ template: null })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
requiresArtTrackLayoutEntitlement(
|
||||
normalizeLayoutSettings({ template: "COVER_TOP_TEXT_BOTTOM" }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function testGeometryAndFilter() {
|
||||
const geo = computeLayoutGeometry("COVER_TOP_TEXT_BOTTOM", 1920, 1080, 48, 16, 10, -5);
|
||||
assert.ok(geo.coverMaxW > 1600, "top layout cover should be near full width");
|
||||
assert.ok(geo.artistY !== geo.titleY);
|
||||
|
||||
const withGap = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 40, 0, 0);
|
||||
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 fc = buildArtTrackFilterComplex({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
layout: {
|
||||
template: "CENTERED_COMPACT",
|
||||
blurAmount: 40,
|
||||
blurOpacity: 70,
|
||||
textPadding: 40,
|
||||
titleArtistGap: 18,
|
||||
textOffsetX: 5,
|
||||
textOffsetY: -8,
|
||||
},
|
||||
titleEscaped: sanitizeDrawtext("Hello:World"),
|
||||
artistEscaped: sanitizeDrawtext("Artist"),
|
||||
});
|
||||
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.endsWith("[laid]"));
|
||||
}
|
||||
|
||||
testEnumsAndClamp();
|
||||
testNormalize();
|
||||
testGeometryAndFilter();
|
||||
console.log("layout.test.ts: ok");
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
artistFontSizeForWidth,
|
||||
scaleFontToPreview,
|
||||
titleFontSizeForWidth,
|
||||
watermarkFontSizeForWidth,
|
||||
} from "../lib/preview-typography";
|
||||
|
||||
function testFontSizesMatchLayout() {
|
||||
const width = 1280;
|
||||
assert.equal(titleFontSizeForWidth(width), Math.max(22, Math.round(width * 0.032)));
|
||||
assert.equal(artistFontSizeForWidth(width), Math.max(16, Math.round(width * 0.02)));
|
||||
assert.equal(watermarkFontSizeForWidth(width), Math.max(16, Math.round(width * 0.018)));
|
||||
}
|
||||
|
||||
function testPreviewScaling() {
|
||||
const encodeWidth = 1920;
|
||||
const titlePx = titleFontSizeForWidth(encodeWidth);
|
||||
const previewPx = scaleFontToPreview(titlePx, encodeWidth);
|
||||
assert.ok(previewPx > 0 && previewPx < titlePx);
|
||||
}
|
||||
|
||||
testFontSizesMatchLayout();
|
||||
testPreviewScaling();
|
||||
console.log("preview-typography.test.ts: ok");
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build multi-arch production image and optionally push to Docker Hub.
|
||||
# Usage:
|
||||
# ./scripts/release-cloud.sh # build locally (current arch)
|
||||
# ./scripts/release-cloud.sh --push # build+push linux/amd64,linux/arm64
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
IMAGE="${DOCKER_IMAGE:-atakanozban/songs2vid}"
|
||||
TAG="${DOCKER_TAG:-latest}"
|
||||
FULL="${IMAGE}:${TAG}"
|
||||
|
||||
echo "==> Prisma generate"
|
||||
npx prisma generate
|
||||
|
||||
echo "==> Typecheck / lint (optional soft)"
|
||||
npm run lint || true
|
||||
|
||||
echo "==> Watermark unit checks"
|
||||
npx tsx scripts/watermark.test.ts
|
||||
|
||||
if [[ "${1:-}" == "--push" ]]; then
|
||||
echo "==> Multi-arch build+push ${FULL} (amd64,arm64)"
|
||||
docker buildx create --name s2yt-builder --use 2>/dev/null || docker buildx use s2yt-builder
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t "$FULL" \
|
||||
--push \
|
||||
.
|
||||
echo "Pushed ${FULL}. Deploy on songs2vid.com host with deploy/songs2vid/docker-compose.yml"
|
||||
else
|
||||
echo "==> Local docker build ${FULL}"
|
||||
docker build -t "$FULL" .
|
||||
echo "Built ${FULL}. Re-run with --push for Hub multi-arch."
|
||||
fi
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Plan } from "@prisma/client";
|
||||
import {
|
||||
resolveBurnedSongTitle,
|
||||
resolveYouTubeTitle,
|
||||
validateYouTubeTitle,
|
||||
} from "../lib/titles";
|
||||
|
||||
function testYouTubeTitleResolution() {
|
||||
assert.equal(
|
||||
resolveYouTubeTitle({ title: "My Video", songTitle: "Song", artist: "Artist" }, Plan.FREE),
|
||||
"My Video",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveYouTubeTitle({ title: "", songTitle: "Song", artist: "Artist" }, Plan.FREE),
|
||||
"",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveYouTubeTitle({ title: "", songTitle: "Song", artist: "Artist" }, Plan.PREMIUM),
|
||||
"Artist - Song",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveYouTubeTitle({ title: "Custom", songTitle: "Song", artist: "Artist" }, Plan.PREMIUM),
|
||||
"Custom",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveYouTubeTitle({ title: "", songTitle: "Song", artist: "" }, Plan.PREMIUM),
|
||||
"Song",
|
||||
);
|
||||
}
|
||||
|
||||
function testBurnedSongTitle() {
|
||||
assert.equal(resolveBurnedSongTitle({ songTitle: "Track" }), "Track");
|
||||
assert.equal(resolveBurnedSongTitle({ title: "YouTube only" }, "Fallback"), "Fallback");
|
||||
}
|
||||
|
||||
function testValidation() {
|
||||
assert.equal(validateYouTubeTitle({ title: "Ok" }, Plan.FREE), null);
|
||||
assert.ok(validateYouTubeTitle({ title: "" }, Plan.FREE));
|
||||
assert.equal(
|
||||
validateYouTubeTitle({ title: "", songTitle: "S", artist: "A" }, Plan.PREMIUM),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
testYouTubeTitleResolution();
|
||||
testBurnedSongTitle();
|
||||
testValidation();
|
||||
console.log("titles.test.ts: ok");
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { sanitizeFontfileForFilter } from "../lib/fonts";
|
||||
import {
|
||||
buildDrawtextFilter,
|
||||
clampOffset,
|
||||
drawtextXy,
|
||||
overlayXy,
|
||||
requiresCustomWatermarkEntitlement,
|
||||
sanitizeDrawtext,
|
||||
normalizeWatermarkSettings,
|
||||
} from "../lib/watermark";
|
||||
|
||||
function testSanitize() {
|
||||
const s = sanitizeDrawtext("Hi:there'100%[x]");
|
||||
assert.ok(s.includes("\\:"), "colon escaped as \\\\:");
|
||||
assert.ok(s.includes("\\'"), "quote escaped");
|
||||
assert.equal(sanitizeDrawtext("a".repeat(200)).length, 80);
|
||||
assert.equal(sanitizeDrawtext("ok\nline").includes("\n"), false);
|
||||
}
|
||||
|
||||
function testPositions() {
|
||||
assert.deepEqual(overlayXy("bottom-right", 20, 20), { x: "W-w-20", y: "H-h-20" });
|
||||
assert.deepEqual(overlayXy("top-left", 10, 5), { x: "10", y: "5" });
|
||||
assert.deepEqual(drawtextXy("center", 0, 0), {
|
||||
x: "(w-text_w)/2+0",
|
||||
y: "(h-th)/2+0",
|
||||
});
|
||||
}
|
||||
|
||||
function testEntitlements() {
|
||||
assert.equal(
|
||||
requiresCustomWatermarkEntitlement(
|
||||
normalizeWatermarkSettings({ mode: "default" }, true),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
requiresCustomWatermarkEntitlement(
|
||||
normalizeWatermarkSettings({ mode: "text", text: "Brand" }, true),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
requiresCustomWatermarkEntitlement(
|
||||
normalizeWatermarkSettings({ mode: "default", position: "top-left" }, true),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
requiresCustomWatermarkEntitlement(
|
||||
normalizeWatermarkSettings({ mode: "default", fontKey: "inter" }, true),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(clampOffset(999), 200);
|
||||
assert.equal(clampOffset(-5), 0);
|
||||
}
|
||||
|
||||
function testDrawtextFontfile() {
|
||||
const withFont = buildDrawtextFilter({
|
||||
text: "Brand",
|
||||
fontSize: 24,
|
||||
position: "bottom-right",
|
||||
offsetX: 20,
|
||||
offsetY: 20,
|
||||
fontfileEscaped: sanitizeFontfileForFilter("C:\\fonts\\My Font.ttf"),
|
||||
});
|
||||
assert.ok(withFont.includes("fontfile="), "fontfile present");
|
||||
assert.ok(withFont.includes("C\\:/fonts/My Font.ttf") || withFont.includes("C\\:/fonts/My\\ Font.ttf") || withFont.includes("fontfile='"), "escaped path");
|
||||
assert.ok(!withFont.includes("C:\\fonts"), "backslashes normalized");
|
||||
|
||||
const injection = sanitizeFontfileForFilter("/tmp/evil':drawtext=text='x");
|
||||
assert.ok(injection.includes("\\'"), "quote escaped in font path");
|
||||
|
||||
const noFont = buildDrawtextFilter({
|
||||
text: "Hi",
|
||||
fontSize: 18,
|
||||
position: "top-left",
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
assert.equal(noFont.includes("fontfile"), false);
|
||||
}
|
||||
|
||||
testSanitize();
|
||||
testPositions();
|
||||
testEntitlements();
|
||||
testDrawtextFontfile();
|
||||
console.log("watermark.test.ts: ok");
|
||||
Reference in New Issue
Block a user