Sync layouts, typography, and watermark studio for self-hosted OSS.

Unlock features without credits/Stripe, point docs to docs.songs2vid.com, and drop leftover billing admin surfaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Songs2YT
2026-07-25 03:50:06 +02:00
co-authored by Cursor
parent bcca0a6e6f
commit d951ecb489
53 changed files with 3258 additions and 1977 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ export async function requirePaidApiUser(req: NextRequest) {
if (!hasProFeatures(user.plan)) {
return {
error: NextResponse.json(
{ error: "API access requires an active Pro subscription" },
{ error: "API access is unavailable for this account" },
{ status: 403 },
),
user: null,
@@ -87,7 +87,7 @@ export async function requirePaidUserFromSessionOrApi(req: NextRequest) {
if (!hasProFeatures(sessionUser.plan)) {
return {
error: NextResponse.json(
{ error: "API access requires an active Pro subscription" },
{ error: "API access is unavailable for this account" },
{ status: 403 },
),
user: null,
+1 -1
View File
@@ -1,4 +1,4 @@
export const BRAND_NAME = "Songs2YT";
export const BRAND_NAME = "Songs2VID";
export const BRAND_DOMAIN = `${BRAND_NAME}.com`;
export const VIDEO_ATTRIBUTION_PREFIX = "Uploaded through";
+11 -3
View File
@@ -1,9 +1,17 @@
import { Plan } from "@prisma/client";
/**
* Self-hosted / OSS edition. Prefer S2VID_EDITION; S2YT_EDITION kept for older compose files.
*/
export function isSelfHostedEdition(): boolean {
return process.env.S2YT_EDITION === "selfhosted";
const edition =
process.env.S2VID_EDITION?.trim() || process.env.S2YT_EDITION?.trim() || "";
// Default to self-hosted for this OSS package when unset
if (!edition) return true;
return edition === "selfhosted";
}
export function hasProFeatures(plan: Plan): boolean {
return isSelfHostedEdition() || plan === "PREMIUM";
/** All Pro features are unlocked in the OSS / self-hosted edition. */
export function hasProFeatures(_plan?: Plan): boolean {
return true;
}
+41
View File
@@ -0,0 +1,41 @@
import { Plan } from "@prisma/client";
import type { LayoutSettings } from "./layout";
import {
PREMIUM_REQUIRED_CODE,
type WatermarkSettings,
} from "./watermark";
export class PremiumRequiredError extends Error {
readonly code = PREMIUM_REQUIRED_CODE;
readonly status = 403;
constructor(message: string) {
super(message);
this.name = "PremiumRequiredError";
}
}
/** OSS: all watermark / typography features unlocked. */
export function assertCustomWatermarkAllowed(_plan: Plan, _settings: WatermarkSettings) {
return;
}
/** OSS: all art-track layout features unlocked. */
export function assertArtTrackLayoutAllowed(_plan: Plan, _settings: LayoutSettings) {
return;
}
/** OSS: per-track cover images always allowed. */
export function assertPerItemImagesAllowed(
_plan: Plan,
_sharedImagePath: string,
_itemImagePaths: Array<string | null | undefined>,
) {
return;
}
export function premiumRequiredResponse(message: string) {
return {
error: message,
code: PREMIUM_REQUIRED_CODE,
};
}
+200 -35
View File
@@ -4,7 +4,23 @@ 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;
@@ -14,59 +30,207 @@ function getFfmpegPath(): string {
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;
title?: 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 watermarkPath = getWatermarkPath();
let watermarkExists = false;
try {
await fs.access(watermarkPath);
watermarkExists = true;
} catch {
watermarkExists = false;
}
const useWatermark = options.includeWatermark && watermarkExists;
const attributionText = getVideoAttributionText().replace(/:/g, "\\:").replace(/'/g, "\\'");
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);
// Base template: ffmpeg -loop 1 -r 1 -i image -i audio -c:v libx264 -tune stillimage -c:a copy -shortest -pix_fmt yuv420p output.mp4
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
if (useWatermark) {
args.push("-i", watermarkPath);
args.push(
"-filter_complex",
`[0:v]${scaleFilter}[scaled];[2:v]scale=${watermarkWidth}:-1[wm];[scaled][wm]overlay=W-w-20:H-h-20[vout]`,
"-map",
"[vout]",
"-map",
"1:a",
);
} else if (options.includeWatermark && !watermarkExists) {
args.push(
"-filter_complex",
`[0:v]${scaleFilter},drawtext=text='${attributionText}':fontsize=22:fontcolor=white@0.85:x=w-text_w-20:y=h-th-20[vout]`,
"-map",
"[vout]",
"-map",
"1:a",
);
} else {
args.push("-vf", scaleFilter);
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.title?.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) {
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",
@@ -89,6 +253,7 @@ function runFfmpeg(args: string[]): Promise<void> {
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();
+44
View File
@@ -0,0 +1,44 @@
/** Server-only font filesystem helpers. Do not import from client components. */
import fs from "fs/promises";
import path from "path";
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
export function getFontsDir(): string {
return path.join(process.cwd(), "assets", "fonts");
}
/** Resolve a curated font file on disk (must exist under assets/fonts). */
export function resolveCuratedFontPath(key: CuratedFontKey): string {
const meta = CURATED_FONTS.find((f) => f.key === key);
if (!meta) throw new Error("Unknown font");
// Whitelist filename only never accept user-controlled path segments
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== meta.file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
/**
* Validate TTF / OTF magic bytes.
* TTF: 00 01 00 00 | true | typ1
* OTF: OTTO
*/
export async function assertFontFile(filePath: string): Promise<"ttf" | "otf"> {
const fh = await fs.open(filePath, "r");
try {
const buf = Buffer.alloc(4);
await fh.read(buf, 0, 4, 0);
const asStr = buf.toString("ascii");
if (asStr === "OTTO") return "otf";
if (asStr === "true" || asStr === "typ1") return "ttf";
if (buf[0] === 0x00 && buf[1] === 0x01 && buf[2] === 0x00 && buf[3] === 0x00) {
return "ttf";
}
if (asStr === "wOFF" || asStr === "wOF2") {
throw new Error("WOFF fonts are not supported. Upload a .ttf or .otf file.");
}
throw new Error("File is not a valid TTF or OTF font");
} finally {
await fh.close();
}
}
+75
View File
@@ -0,0 +1,75 @@
/** Shared font catalog safe for client and server bundles (no Node APIs). */
/** Max custom font upload size (10 MB). */
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
export const CURATED_FONTS = [
{
key: "inter",
label: "Inter",
cssFamily: "Inter",
googleCss: "Inter:wght@400;600",
file: "Inter-Regular.ttf",
},
{
key: "montserrat",
label: "Montserrat",
cssFamily: "Montserrat",
googleCss: "Montserrat:wght@400;600",
file: "Montserrat-Regular.ttf",
},
{
key: "roboto",
label: "Roboto",
cssFamily: "Roboto",
googleCss: "Roboto:wght@400;500",
file: "Roboto-Regular.ttf",
},
{
key: "oswald",
label: "Oswald",
cssFamily: "Oswald",
googleCss: "Oswald:wght@400;500",
file: "Oswald-Regular.ttf",
},
{
key: "playfair",
label: "Playfair Display",
cssFamily: "Playfair Display",
googleCss: "Playfair+Display:wght@400;600",
file: "PlayfairDisplay-Regular.ttf",
},
] as const;
export type CuratedFontKey = (typeof CURATED_FONTS)[number]["key"];
export type WatermarkFontKey = CuratedFontKey | "custom" | "system";
const CURATED_KEYS = new Set<string>(CURATED_FONTS.map((f) => f.key));
export function isCuratedFontKey(v: unknown): v is CuratedFontKey {
return typeof v === "string" && CURATED_KEYS.has(v);
}
export function isWatermarkFontKey(v: unknown): v is WatermarkFontKey {
return v === "custom" || v === "system" || isCuratedFontKey(v);
}
/**
* Escape an absolute font path for use inside an FFmpeg filtergraph `fontfile=` value.
* Paths are never passed through a shell; this only escapes filter special chars.
*/
export function sanitizeFontfileForFilter(absPath: string): string {
return absPath
.replace(/\\/g, "/")
.replace(/:/g, "\\:")
.replace(/'/g, "\\'")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]");
}
export function googleFontsStylesheetUrl(keys: CuratedFontKey[]): string {
const families = CURATED_FONTS.filter((f) => keys.includes(f.key))
.map((f) => `family=${f.googleCss}`)
.join("&");
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
}
+330 -34
View File
@@ -5,7 +5,22 @@ import { readAudioTags } from "../audio-tags";
import { isAllowedResolution } from "../constants";
import { prisma } from "../db";
import { hasProFeatures } from "../edition";
import {
assertArtTrackLayoutAllowed,
assertCustomWatermarkAllowed,
assertPerItemImagesAllowed,
PremiumRequiredError,
} from "../entitlements";
import { FONT_UPLOAD_MAX_BYTES } from "../fonts";
import { assertFontFile } from "../fonts-server";
import { assertPngFile } from "../ffmpeg/encode";
import { moveFile, writeUploadedFile } from "../fs-utils";
import {
ARTIST_MAX,
INVALID_LAYOUT_TEMPLATE_MESSAGE,
normalizeLayoutSettings,
type LayoutSettings,
} from "../layout";
import { enqueueVideoJob } from "../queue/client";
import {
getPlanLimits,
@@ -19,7 +34,54 @@ import {
getUserStagingDir,
sanitizeUploadSessionKey,
} from "../upload-paths";
import type { CreateJobPayload } from "../types";
import type { CreateJobPayload, ItemMetadata } from "../types";
import {
normalizeWatermarkSettings,
WATERMARK_TEXT_MAX,
} from "../watermark";
/** Merge nested + flat layout fields, then normalize (throws on bad template / coords). */
export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSettings {
return normalizeLayoutSettings({
...(metadata.layout ?? {}),
template:
metadata.layout?.template ??
metadata.layout?.layoutTemplate ??
metadata.layout?.layout_template ??
metadata.layoutTemplate ??
metadata.layout_template,
blurAmount:
metadata.layout?.blurAmount ??
metadata.layout?.blur_amount ??
metadata.blurAmount ??
metadata.blur_amount,
blurOpacity:
metadata.layout?.blurOpacity ??
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
metadata.blurOpacity ??
metadata.blur_opacity,
textPadding:
metadata.layout?.textPadding ??
metadata.layout?.text_padding ??
metadata.textPadding ??
metadata.text_padding,
titleArtistGap:
metadata.layout?.titleArtistGap ??
(metadata.layout as { title_artist_gap?: number } | null | undefined)?.title_artist_gap ??
metadata.titleArtistGap ??
metadata.title_artist_gap,
textOffsetX:
metadata.layout?.textOffsetX ??
(metadata.layout as { text_offset_x?: number } | null | undefined)?.text_offset_x ??
metadata.textOffsetX ??
metadata.text_offset_x,
textOffsetY:
metadata.layout?.textOffsetY ??
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
metadata.textOffsetY ??
metadata.text_offset_y,
});
}
export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["metadata"]) {
if (!metadata.title?.trim()) return "Each video must have a title";
@@ -27,6 +89,20 @@ export function validateItemMetadata(metadata: CreateJobPayload["items"][0]["met
if (!["PUBLIC", "PRIVATE", "UNLISTED"].includes(metadata.privacy)) {
return "Invalid privacy setting";
}
if (metadata.watermark?.text && metadata.watermark.text.length > WATERMARK_TEXT_MAX) {
return `Watermark text must be at most ${WATERMARK_TEXT_MAX} characters`;
}
if (metadata.artist && metadata.artist.length > ARTIST_MAX) {
return `Artist must be at most ${ARTIST_MAX} characters`;
}
try {
resolveLayoutFromMetadata(metadata);
} catch (err) {
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
}
throw err;
}
return null;
}
@@ -35,6 +111,8 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
return "Image and at least one audio file required";
}
const itemImagePaths: Array<string | null | undefined> = [];
for (const item of body.items) {
const metaError = validateItemMetadata(item.metadata);
if (metaError) return metaError;
@@ -42,8 +120,32 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
return `Resolution ${item.metadata.resolution} is not available on your plan`;
}
if (item.metadata.playlistId && !hasProFeatures(user.plan)) {
return "Adding videos to a YouTube playlist requires the Pro plan";
return "Adding videos to a YouTube playlist is unavailable for this account";
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
);
try {
assertCustomWatermarkAllowed(user.plan, wm);
assertArtTrackLayoutAllowed(user.plan, resolveLayoutFromMetadata(item.metadata));
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
return INVALID_LAYOUT_TEMPLATE_MESSAGE;
}
throw err;
}
itemImagePaths.push(item.metadata.imagePath);
}
try {
assertPerItemImagesAllowed(user.plan, body.imagePath, itemImagePaths);
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
throw err;
}
const limits = getPlanLimits(user.plan);
@@ -66,11 +168,60 @@ export async function validateJobPayload(user: { id: string; plan: Plan }, body:
if (stat.size > limits.maxFileSizeBytes) {
return `Audio file ${item.audioFilename} exceeds size limit`;
}
if (item.metadata.imagePath) {
const itemImg = assertPathInUserUploads(user.id, item.metadata.imagePath);
await fs.access(itemImg);
const st = await fs.stat(itemImg);
if (st.size > limits.maxFileSizeBytes) {
return `Per-item image for ${item.audioFilename} exceeds size limit`;
}
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
);
if (wm.mode === "logo" && wm.logoPath) {
const logoPath = assertPathInUserUploads(user.id, wm.logoPath);
await fs.access(logoPath);
await assertPngFile(logoPath);
const st = await fs.stat(logoPath);
if (st.size > limits.maxFileSizeBytes) {
return "Watermark logo exceeds size limit";
}
}
if (wm.mode === "text" && !wm.text?.trim()) {
return "Watermark text mode requires non-empty text";
}
if (wm.mode === "text" && wm.fontKey === "custom") {
if (!wm.fontPath) {
return "Custom font selected but no font file uploaded";
}
const fontPath = assertPathInUserUploads(user.id, wm.fontPath);
await fs.access(fontPath);
await assertFontFile(fontPath);
const st = await fs.stat(fontPath);
if (st.size > FONT_UPLOAD_MAX_BYTES) {
return "Custom font exceeds 10 MB limit";
}
}
}
} catch (err) {
if (err instanceof PremiumRequiredError) return err.message;
if (err instanceof Error && err.message === "Invalid upload path") {
return "Invalid upload path";
}
if (
err instanceof Error &&
(err.message.includes("PNG") ||
err.message.includes("font") ||
err.message.includes("TTF") ||
err.message.includes("OTF") ||
err.message.includes("WOFF"))
) {
return err.message;
}
return "One or more uploaded files not found";
}
@@ -83,13 +234,32 @@ export async function createVideoJob(
) {
const validationError = await validateJobPayload(user, body);
if (validationError) {
throw new Error(validationError);
if (validationError === INVALID_LAYOUT_TEMPLATE_MESSAGE) {
throw new Error(validationError);
}
const isPremium =
validationError.includes("requires Pro") ||
validationError.includes("Pro plan");
const err = isPremium
? new PremiumRequiredError(validationError)
: new Error(validationError);
throw err;
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
: null,
}));
const reservation = await reserveQuota(user.id, items.length);
@@ -100,23 +270,54 @@ export async function createVideoJob(
userId: user.id,
imagePath,
items: {
create: items.map((item, index) => ({
audioPath: item.audioPath,
audioFilename: item.audioFilename,
title: item.metadata.title.trim(),
description: item.metadata.description || "",
tags: item.metadata.tags || "",
privacy: item.metadata.privacy as Privacy,
categoryId: item.metadata.categoryId || "10",
resolution: item.metadata.resolution,
notifySubscribers: item.metadata.notifySubscribers,
madeForKids: item.metadata.madeForKids,
embeddable: item.metadata.embeddable,
creativeCommons: item.metadata.creativeCommons,
includeWatermark: item.metadata.includeWatermark,
playlistId: item.metadata.playlistId?.trim() || null,
billingSource: index < reservation.fromQuota ? "QUOTA" : "CREDIT",
})),
create: items.map((item) => {
const wm = normalizeWatermarkSettings(
item.metadata.watermark
? {
...item.metadata.watermark,
logoPath: item.watermarkLogoPath,
fontPath: item.watermarkFontPath,
}
: null,
item.metadata.includeWatermark,
);
const layout = resolveLayoutFromMetadata(item.metadata);
return {
audioPath: item.audioPath,
audioFilename: item.audioFilename,
title: item.metadata.title.trim(),
description: item.metadata.description || "",
tags: item.metadata.tags || "",
privacy: item.metadata.privacy as Privacy,
categoryId: item.metadata.categoryId || "10",
resolution: item.metadata.resolution,
notifySubscribers: item.metadata.notifySubscribers,
madeForKids: item.metadata.madeForKids,
embeddable: item.metadata.embeddable,
creativeCommons: item.metadata.creativeCommons,
includeWatermark: wm.mode !== "none",
itemImagePath: item.itemImagePath,
watermarkMode: wm.mode,
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
watermarkFontKey: wm.mode === "text" ? wm.fontKey ?? "system" : null,
watermarkFontPath:
wm.mode === "text" && wm.fontKey === "custom" ? item.watermarkFontPath : null,
watermarkPosition: wm.position,
watermarkOffsetX: wm.offsetX,
watermarkOffsetY: wm.offsetY,
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
layoutTemplate: layout.template,
blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity,
textPadding: layout.textPadding,
titleArtistGap: layout.titleArtistGap,
textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null,
billingSource: "QUOTA",
};
}),
},
},
include: { items: true },
@@ -130,14 +331,61 @@ export async function createVideoJob(
await moveFile(imagePath, newImagePath);
await prisma.job.update({ where: { id: job.id }, data: { imagePath: newImagePath } });
const moved = new Map<string, string>();
moved.set(imagePath, newImagePath);
await Promise.all(
job.items.map(async (item) => {
const audioExt = path.extname(item.audioPath);
const newAudioPath = path.join(jobDir, `${item.id}${audioExt}`);
await moveFile(item.audioPath, newAudioPath);
let newItemImage: string | null = null;
if (item.itemImagePath) {
const src = item.itemImagePath;
if (moved.has(src)) {
newItemImage = moved.get(src)!;
} else {
const ext = path.extname(src);
newItemImage = path.join(jobDir, `${item.id}-cover${ext}`);
await moveFile(src, newItemImage);
moved.set(src, newItemImage);
}
}
let newLogo: string | null = null;
if (item.watermarkLogoPath) {
const src = item.watermarkLogoPath;
if (moved.has(src)) {
newLogo = moved.get(src)!;
} else {
newLogo = path.join(jobDir, `${item.id}-logo.png`);
await moveFile(src, newLogo);
moved.set(src, newLogo);
}
}
let newFont: string | null = null;
if (item.watermarkFontPath) {
const src = item.watermarkFontPath;
if (moved.has(src)) {
newFont = moved.get(src)!;
} else {
const ext = path.extname(src).toLowerCase() === ".otf" ? ".otf" : ".ttf";
newFont = path.join(jobDir, `${item.id}-font${ext}`);
await moveFile(src, newFont);
moved.set(src, newFont);
}
}
await prisma.jobItem.update({
where: { id: item.id },
data: { audioPath: newAudioPath },
data: {
audioPath: newAudioPath,
itemImagePath: newItemImage,
watermarkLogoPath: newLogo,
watermarkFontPath: newFont,
},
});
await enqueueVideoJob({
@@ -155,19 +403,42 @@ export async function createVideoJob(
}
}
export type UploadFileType = "image" | "audio" | "logo" | "font";
export async function saveUploadedFile(
userId: string,
file: File,
type: "image" | "audio",
type: UploadFileType,
plan: Plan,
options?: { sessionKey?: string },
) {
const limits = getPlanLimits(plan);
if (file.size > limits.maxFileSizeBytes) {
if (type === "font") {
if (!limits.customWatermark && !hasProFeatures(plan)) {
throw new PremiumRequiredError("Custom watermark fonts require Pro.");
}
if (file.size > FONT_UPLOAD_MAX_BYTES) {
throw new Error("Font file must be 10 MB or smaller");
}
if (!/\.(ttf|otf)$/i.test(file.name)) {
throw new Error("Font must be a .ttf or .otf file");
}
} else if (file.size > limits.maxFileSizeBytes) {
throw new Error(`File exceeds ${limits.maxFileSizeBytes / (1024 * 1024)} MB limit`);
}
if (type === "logo") {
if (!limits.customWatermark && !hasProFeatures(plan)) {
throw new PremiumRequiredError("Custom logo watermarks require Pro.");
}
const nameOk = /\.png$/i.test(file.name);
const typeOk = file.type === "image/png" || file.type === "";
if (!nameOk && !typeOk) {
throw new Error("Watermark logo must be a PNG file");
}
}
const allowedImageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const allowedAudioTypes = [
"audio/mpeg",
@@ -180,18 +451,25 @@ export async function saveUploadedFile(
"audio/mp4",
"audio/x-m4a",
];
const allowedTypes = type === "image" ? allowedImageTypes : allowedAudioTypes;
if (
!allowedTypes.includes(file.type) &&
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a|jpg|jpeg|png|webp|gif)$/i)
) {
throw new Error("Invalid file type");
}
if (type === "audio" && !isAudioExtensionAllowed(file.name, plan)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`);
if (type === "image") {
if (
!allowedImageTypes.includes(file.type) &&
!file.name.match(/\.(jpg|jpeg|png|webp|gif)$/i)
) {
throw new Error("Invalid image file type");
}
} else if (type === "audio") {
if (
!allowedAudioTypes.includes(file.type) &&
!file.name.match(/\.(mp3|wav|ogg|flac|aac|m4a)$/i)
) {
throw new Error("Invalid audio file type");
}
if (!isAudioExtensionAllowed(file.name, plan)) {
const allowed = limits.allowedAudioExtensions.join(", ");
throw new Error(`Your plan supports ${allowed} audio files only`);
}
}
const sessionKey = sanitizeUploadSessionKey(options?.sessionKey);
@@ -204,6 +482,24 @@ export async function saveUploadedFile(
assertPathInUserUploads(userId, filePath);
await writeUploadedFile(file, filePath);
if (type === "logo") {
try {
await assertPngFile(filePath);
} catch (err) {
await fs.unlink(filePath).catch(() => {});
throw err;
}
}
if (type === "font") {
try {
await assertFontFile(filePath);
} catch (err) {
await fs.unlink(filePath).catch(() => {});
throw err;
}
}
let audioTags = null;
if (type === "audio" && limits.id3TagSupport && file.name.toLowerCase().endsWith(".mp3")) {
audioTags = await readAudioTags(filePath);
+417
View File
@@ -0,0 +1,417 @@
/**
* Art-track layout templates + blur background (Pro).
* Cover/text anchors come from enum templates; only clamped fine-tuning offsets are allowed.
*/
export const LAYOUT_TEMPLATES = [
"COVER_LEFT_TEXT_RIGHT",
"COVER_TOP_TEXT_BOTTOM",
"COVER_RIGHT_TEXT_LEFT",
"CENTERED_COMPACT",
] as const;
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
export const INVALID_LAYOUT_TEMPLATE_MESSAGE =
"Invalid layout template. Refer to API documentation for valid enum values.";
export const BLUR_AMOUNT_MIN = 0;
export const BLUR_AMOUNT_MAX = 100;
export const BLUR_AMOUNT_DEFAULT = 55;
/** Visibility of the blurred cover fill over black (0 = solid black, 100 = full blur). */
export const BLUR_OPACITY_MIN = 0;
export const BLUR_OPACITY_MAX = 100;
export const BLUR_OPACITY_DEFAULT = 100;
export const TEXT_PADDING_MIN = 16;
export const TEXT_PADDING_MAX = 120;
export const TEXT_PADDING_DEFAULT = 48;
export const TITLE_ARTIST_GAP_MIN = 0;
export const TITLE_ARTIST_GAP_MAX = 64;
export const TITLE_ARTIST_GAP_DEFAULT = 10;
/** Fine-tune title/artist block within the template (not free-form canvas coords). */
export const TEXT_OFFSET_MIN = -120;
export const TEXT_OFFSET_MAX = 120;
export const TEXT_OFFSET_DEFAULT = 0;
export const ARTIST_MAX = 80;
export type LayoutSettings = {
/** When null, classic letterbox (no art-track layout / blur fill). */
template: LayoutTemplate | null;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */
blurOpacity: number;
/** Pixel padding / gap around cover + text. */
textPadding: number;
/** Extra pixels between title and artist lines. */
titleArtistGap: number;
/** Shift text block horizontally within the template. */
textOffsetX: number;
/** Shift text block vertically within the template. */
textOffsetY: number;
};
export const DEFAULT_LAYOUT: LayoutSettings = {
template: null,
blurAmount: BLUR_AMOUNT_DEFAULT,
blurOpacity: BLUR_OPACITY_DEFAULT,
textPadding: TEXT_PADDING_DEFAULT,
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: TEXT_OFFSET_DEFAULT,
textOffsetY: TEXT_OFFSET_DEFAULT,
};
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
return typeof v === "string" && (LAYOUT_TEMPLATES as readonly string[]).includes(v);
}
export function clampBlurAmount(n: unknown, fallback = BLUR_AMOUNT_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(BLUR_AMOUNT_MIN, Math.min(BLUR_AMOUNT_MAX, Math.round(v)));
}
export function clampBlurOpacity(n: unknown, fallback = BLUR_OPACITY_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(BLUR_OPACITY_MIN, Math.min(BLUR_OPACITY_MAX, Math.round(v)));
}
export function clampTextPadding(n: unknown, fallback = TEXT_PADDING_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(TEXT_PADDING_MIN, Math.min(TEXT_PADDING_MAX, Math.round(v)));
}
export function clampTitleArtistGap(n: unknown, fallback = TITLE_ARTIST_GAP_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(TITLE_ARTIST_GAP_MIN, Math.min(TITLE_ARTIST_GAP_MAX, Math.round(v)));
}
export function clampTextOffset(n: unknown, fallback = TEXT_OFFSET_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(TEXT_OFFSET_MIN, Math.min(TEXT_OFFSET_MAX, Math.round(v)));
}
export function blurToBoxblur(blurAmount: number): { radius: number; power: number } | null {
const amount = clampBlurAmount(blurAmount, 0);
if (amount <= 0) return null;
const radius = Math.max(1, Math.round((amount / 100) * 50));
const power = Math.max(1, Math.min(4, Math.ceil(amount / 25)));
return { radius, power };
}
export function boxblurFilterSegment(blurAmount: number): string {
const bb = blurToBoxblur(blurAmount);
if (!bb) return "";
return `boxblur=luma_radius=${bb.radius}:luma_power=${bb.power}:chroma_radius=${bb.radius}:chroma_power=${bb.power}`;
}
type RawLayoutInput = {
template?: unknown;
layoutTemplate?: unknown;
layout_template?: unknown;
blurAmount?: unknown;
blur_amount?: unknown;
blurOpacity?: unknown;
blur_opacity?: unknown;
textPadding?: unknown;
text_padding?: unknown;
titleArtistGap?: unknown;
title_artist_gap?: unknown;
textOffsetX?: unknown;
text_offset_x?: unknown;
textOffsetY?: unknown;
text_offset_y?: unknown;
/** Rejected clients must not send free-form cover coordinates. */
x?: unknown;
y?: unknown;
coverX?: unknown;
coverY?: unknown;
offsetX?: unknown;
offsetY?: unknown;
};
export function normalizeLayoutSettings(
input: RawLayoutInput | Partial<LayoutSettings> | null | undefined,
): LayoutSettings {
if (!input) return { ...DEFAULT_LAYOUT };
const forbidden = ["x", "y", "coverX", "coverY", "offsetX", "offsetY"] as const;
for (const k of forbidden) {
if ((input as RawLayoutInput)[k] !== undefined) {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
}
const raw =
(input as RawLayoutInput).template ??
(input as RawLayoutInput).layoutTemplate ??
(input as RawLayoutInput).layout_template;
let template: LayoutTemplate | null = null;
if (raw !== undefined && raw !== null && raw !== "" && raw !== "CLASSIC" && raw !== "classic") {
if (!isLayoutTemplate(raw)) {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
template = raw;
}
const blurRaw =
(input as RawLayoutInput).blurAmount ??
(input as RawLayoutInput).blur_amount ??
(input as LayoutSettings).blurAmount;
const opacityRaw =
(input as RawLayoutInput).blurOpacity ??
(input as RawLayoutInput).blur_opacity ??
(input as LayoutSettings).blurOpacity;
const padRaw =
(input as RawLayoutInput).textPadding ??
(input as RawLayoutInput).text_padding ??
(input as LayoutSettings).textPadding;
const gapRaw =
(input as RawLayoutInput).titleArtistGap ??
(input as RawLayoutInput).title_artist_gap ??
(input as LayoutSettings).titleArtistGap;
const oxRaw =
(input as RawLayoutInput).textOffsetX ??
(input as RawLayoutInput).text_offset_x ??
(input as LayoutSettings).textOffsetX;
const oyRaw =
(input as RawLayoutInput).textOffsetY ??
(input as RawLayoutInput).text_offset_y ??
(input as LayoutSettings).textOffsetY;
return {
template,
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
};
}
export function requiresArtTrackLayoutEntitlement(settings: LayoutSettings): boolean {
return settings.template !== null;
}
export type LayoutGeometry = {
coverMaxW: number;
coverMaxH: number;
coverX: string;
coverY: string;
titleFontSize: number;
artistFontSize: number;
titleX: string;
titleY: string;
artistX: string;
artistY: string;
};
function applyTextOffsets(
geo: LayoutGeometry,
textOffsetX: number,
textOffsetY: number,
): LayoutGeometry {
const ox = clampTextOffset(textOffsetX);
const oy = clampTextOffset(textOffsetY);
if (ox === 0 && oy === 0) return geo;
const shiftX = (expr: string) => {
if (expr === "(w-text_w)/2") return `(w-text_w)/2+${ox}`;
if (/^-?\d+$/.test(expr)) return String(Number(expr) + ox);
return `${expr}+${ox}`;
};
const shiftY = (expr: string) => {
if (/^-?\d+$/.test(expr)) return String(Number(expr) + oy);
return `${expr}+${oy}`;
};
return {
...geo,
titleX: shiftX(geo.titleX),
titleY: shiftY(geo.titleY),
artistX: shiftX(geo.artistX),
artistY: shiftY(geo.artistY),
};
}
/** Pixel geometry template + padding + title/artist gap + text offsets. */
export function computeLayoutGeometry(
template: LayoutTemplate,
width: number,
height: number,
textPadding: number,
titleArtistGap: number = TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: number = 0,
textOffsetY: number = 0,
): LayoutGeometry {
const pad = clampTextPadding(textPadding);
const gap = clampTitleArtistGap(titleArtistGap);
const titleFontSize = Math.max(22, Math.round(width * 0.032));
const artistFontSize = Math.max(16, Math.round(width * 0.02));
const lineGap = gap;
let base: LayoutGeometry;
switch (template) {
case "COVER_LEFT_TEXT_RIGHT": {
const coverMaxW = Math.round(width * 0.42);
const coverMaxH = height - pad * 2;
const textX = pad + coverMaxW + pad;
const midY = Math.round(height / 2);
base = {
coverMaxW,
coverMaxH,
coverX: String(pad),
coverY: "(H-h)/2",
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(midY - titleFontSize - Math.round(lineGap / 2)),
artistX: String(textX),
artistY: String(midY + Math.round(lineGap / 2)),
};
break;
}
case "COVER_RIGHT_TEXT_LEFT": {
const coverMaxW = Math.round(width * 0.42);
const coverMaxH = height - pad * 2;
const textX = pad;
const midY = Math.round(height / 2);
base = {
coverMaxW,
coverMaxH,
coverX: `W-w-${pad}`,
coverY: "(H-h)/2",
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(midY - titleFontSize - Math.round(lineGap / 2)),
artistX: String(textX),
artistY: String(midY + Math.round(lineGap / 2)),
};
break;
}
case "COVER_TOP_TEXT_BOTTOM": {
// Near full width avoid empty side gutters next to the cover
const coverMaxW = width - pad * 2;
const coverMaxH = Math.round(height * 0.56);
const textBlockTop = pad + coverMaxH + Math.round(pad * 0.55);
base = {
coverMaxW,
coverMaxH,
coverX: "(W-w)/2",
coverY: String(pad),
titleFontSize,
artistFontSize,
titleX: "(w-text_w)/2",
titleY: String(textBlockTop),
artistX: "(w-text_w)/2",
artistY: String(textBlockTop + titleFontSize + lineGap),
};
break;
}
case "CENTERED_COMPACT": {
const side = Math.round(Math.min(width, height) * 0.4);
const stackH = side + pad + titleFontSize + lineGap + artistFontSize;
const stackTop = Math.round((height - stackH) / 2);
const coverY = Math.max(pad, stackTop);
const titleY = coverY + side + Math.round(pad * 0.55);
base = {
coverMaxW: side,
coverMaxH: side,
coverX: "(W-w)/2",
coverY: String(coverY),
titleFontSize,
artistFontSize,
titleX: "(w-text_w)/2",
titleY: String(titleY),
artistX: "(w-text_w)/2",
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
default: {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
}
return applyTextOffsets(base, textOffsetX, textOffsetY);
}
export function buildArtTrackFilterComplex(opts: {
width: number;
height: number;
layout: LayoutSettings;
titleEscaped: string;
artistEscaped: string | null;
fontfileEscaped?: string | null;
}): string {
const { width: W, height: H, layout } = opts;
if (!layout.template) {
throw new Error("Art-track filter requires a layout template");
}
const geo = computeLayoutGeometry(
layout.template,
W,
H,
layout.textPadding,
layout.titleArtistGap,
layout.textOffsetX,
layout.textOffsetY,
);
const blurSeg = boxblurFilterSegment(layout.blurAmount);
const bgChain = blurSeg
? `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},${blurSeg}`
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
const artistDraw = opts.artistEscaped
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
: "";
const parts: string[] = [`[0:v]split=2[bg][fg]`];
// Fade blurred fill toward black when opacity < 100%
if (opacity >= 0.999) {
parts.push(`[bg]${bgChain}[blurred]`);
} else if (opacity <= 0.001) {
parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`);
} else {
const a = opacity.toFixed(3);
const b = (1 - opacity).toFixed(3);
parts.push(
`[bg]${bgChain}[blur_raw]`,
`color=c=black:s=${W}x${H}:d=1[blk]`,
`[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`,
);
}
parts.push(
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
return parts.join(";");
}
export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
COVER_LEFT_TEXT_RIGHT: "Cover left · text right",
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
CENTERED_COMPACT: "Centered compact",
};
+35 -34
View File
@@ -8,54 +8,55 @@ export type PlanLimits = {
maxResolutionHeight: number;
maxFileSizeBytes: number;
watermarkOptional: boolean;
customWatermark: boolean;
perItemImages: boolean;
artTrackLayouts: boolean;
allowedAudioExtensions: readonly string[];
id3TagSupport: boolean;
};
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
FREE: {
monthlyQuota: 14,
maxBatchSize: 3,
maxResolutionHeight: 720,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkOptional: true,
allowedAudioExtensions: [".mp3"],
id3TagSupport: true,
},
PREMIUM: {
monthlyQuota: 50,
maxBatchSize: 5,
maxResolutionHeight: 1080,
maxFileSizeBytes: 30 * 1024 * 1024,
watermarkOptional: true,
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
id3TagSupport: true,
},
};
/** Unlimited self-hosted limits — used for every plan in this OSS build. */
export const SELFHOSTED_LIMITS: PlanLimits = {
monthlyQuota: 1_000_000,
maxBatchSize: 100,
maxBatchSize: 1_000,
maxResolutionHeight: Math.max(...RESOLUTIONS.map((r) => r.height)),
maxFileSizeBytes: 500 * 1024 * 1024,
watermarkOptional: true,
customWatermark: true,
perItemImages: true,
artTrackLayouts: true,
allowedAudioExtensions: [".mp3", ".wav", ".flac"],
id3TagSupport: true,
};
export const SALES_EMAIL = "songs2yt@atakanozban.com";
export const SUPPORT_EMAIL = "songs2yt@atakanozban.com";
export const UPGRADE_URL = "/#pricing";
export const GITEA_ISSUES_URL =
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ?? "https://git.atakanozban.com/Songs2YT/issues";
export const GITEA_URL =
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2YT";
export const DOCKER_HUB_URL =
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2yt";
/** Kept for type compatibility; OSS always returns SELFHOSTED_LIMITS. */
export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
FREE: { ...SELFHOSTED_LIMITS },
PREMIUM: { ...SELFHOSTED_LIMITS },
};
export function getPlanLimits(plan: Plan): PlanLimits {
if (isSelfHostedEdition()) return SELFHOSTED_LIMITS;
return PLAN_LIMITS[plan];
export const SALES_EMAIL = "songs2vid@atakanozban.com";
export const SUPPORT_EMAIL = "songs2vid@atakanozban.com";
export const QUOTA_REQUEST_EMAIL = "songs2vid@atakanozban.com";
export const UPGRADE_URL = "https://songs2vid.com";
export const WEBSITE_URL = "https://songs2vid.com";
export const GITEA_ISSUES_URL =
process.env.NEXT_PUBLIC_GITEA_ISSUES_URL ??
"https://git.atakanozban.com/Songs2VID/songs2vid/issues";
export const GITEA_URL =
process.env.NEXT_PUBLIC_GITEA_URL ?? "https://git.atakanozban.com/Songs2VID/songs2vid";
export const DOCKER_HUB_URL =
process.env.NEXT_PUBLIC_DOCKER_HUB_URL ?? "https://hub.docker.com/r/atakanozban/songs2vid";
/** Public docs (Docusaurus). Override with NEXT_PUBLIC_DOCS_URL if needed. */
export const DOCS_URL = (
process.env.NEXT_PUBLIC_DOCS_URL?.trim() || "https://docs.songs2vid.com"
).replace(/\/$/, "");
export const API_DOCS_URL = `${DOCS_URL}/docs/api/overview`;
export function getPlanLimits(_plan?: Plan): PlanLimits {
void isSelfHostedEdition();
return SELFHOSTED_LIMITS;
}
export function getResolutionsForPlan(plan: Plan) {
-186
View File
@@ -1,186 +0,0 @@
import { QuotaExtensionRequestStatus } from "@prisma/client";
import { MAX_ADMIN_API_RATE_BONUS } from "./api-rate-limit";
import { prisma } from "./db";
import { getPlanLimits } from "./plans";
export const QUOTA_EXTENSION_ANNUAL_LIMIT = 5;
/** Max bonus videos an admin may grant per approval. */
export const MAX_ADMIN_BONUS_QUOTA = 50;
export const EXTENSION_KIND = {
VIDEO_QUOTA: "VIDEO_QUOTA",
API_RATE_LIMIT: "API_RATE_LIMIT",
} as const;
export type ExtensionKind = (typeof EXTENSION_KIND)[keyof typeof EXTENSION_KIND];
function getCalendarYearBounds(year = new Date().getFullYear()) {
return {
start: new Date(year, 0, 1),
end: new Date(year + 1, 0, 1),
};
}
export async function getQuotaExtensionUsage(
userId: string,
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
) {
const { start, end } = getCalendarYearBounds();
const requests = await prisma.quotaExtensionRequest.findMany({
where: {
userId,
kind,
requestedAt: { gte: start, lt: end },
},
orderBy: { requestedAt: "desc" },
});
const used = requests.filter((r) => r.status !== QuotaExtensionRequestStatus.REJECTED).length;
return {
used,
limit: QUOTA_EXTENSION_ANNUAL_LIMIT,
remaining: Math.max(0, QUOTA_EXTENSION_ANNUAL_LIMIT - used),
kind,
requests: requests.map((r) => ({
id: r.id,
kind: r.kind,
status: r.status,
message: r.message,
requestedAt: r.requestedAt.toISOString(),
processedAt: r.processedAt?.toISOString() ?? null,
adminNote: r.adminNote,
})),
};
}
export async function createQuotaExtensionRequest(
userId: string,
message = "",
kind: ExtensionKind = EXTENSION_KIND.VIDEO_QUOTA,
) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (user.plan !== "PREMIUM") {
throw new Error("Only Pro subscribers can request quota resets or extensions.");
}
const usage = await getQuotaExtensionUsage(userId, kind);
if (usage.remaining <= 0) {
throw new Error(
`You have used all ${QUOTA_EXTENSION_ANNUAL_LIMIT} ${
kind === EXTENSION_KIND.API_RATE_LIMIT ? "API rate limit" : "quota"
} extension requests for this year.`,
);
}
const pending = await prisma.quotaExtensionRequest.findFirst({
where: { userId, kind, status: QuotaExtensionRequestStatus.PENDING },
});
if (pending) {
throw new Error("You already have a pending request. Please wait for it to be processed.");
}
const request = await prisma.quotaExtensionRequest.create({
data: {
userId,
kind,
message: message.trim().slice(0, 1000),
status: QuotaExtensionRequestStatus.PENDING,
},
});
const updatedUsage = await getQuotaExtensionUsage(userId, kind);
return {
requestId: request.id,
...updatedUsage,
};
}
/** Call when approving a request in the database (admin / support). */
export async function approveQuotaExtensionRequest(
requestId: string,
options?: { bonusQuota?: number; bonusRateLimit?: number; adminNote?: string },
) {
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
where: { id: requestId },
include: { user: true },
});
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
throw new Error("Request is not pending.");
}
if (request.kind === EXTENSION_KIND.API_RATE_LIMIT) {
const bonus = Math.min(
MAX_ADMIN_API_RATE_BONUS,
Math.max(0, Math.floor(options?.bonusRateLimit ?? 30)),
);
await prisma.$transaction([
prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.APPROVED,
processedAt: new Date(),
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
},
}),
prisma.user.update({
where: { id: request.userId },
data: {
apiRateLimitBonus: request.user.apiRateLimitBonus + bonus,
},
}),
]);
return;
}
const bonus = Math.min(
MAX_ADMIN_BONUS_QUOTA,
Math.max(0, Math.floor(options?.bonusQuota ?? 0)),
);
await prisma.$transaction([
prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.APPROVED,
processedAt: new Date(),
adminNote: options?.adminNote?.trim().slice(0, 500) ?? null,
},
}),
prisma.user.update({
where: { id: request.userId },
data: {
videosUsed: 0,
bonusQuota: request.user.bonusQuota + bonus,
},
}),
]);
}
export async function rejectQuotaExtensionRequest(requestId: string, adminNote?: string) {
const request = await prisma.quotaExtensionRequest.findUniqueOrThrow({
where: { id: requestId },
});
if (request.status !== QuotaExtensionRequestStatus.PENDING) {
throw new Error("Request is not pending.");
}
await prisma.quotaExtensionRequest.update({
where: { id: requestId },
data: {
status: QuotaExtensionRequestStatus.REJECTED,
processedAt: new Date(),
adminNote: adminNote?.trim().slice(0, 500) ?? null,
},
});
}
export function getEffectiveQuotaLimit(plan: Parameters<typeof getPlanLimits>[0], bonusQuota: number) {
return getPlanLimits(plan).monthlyQuota + bonusQuota;
}
+56 -243
View File
@@ -1,129 +1,48 @@
import { Plan } from "@prisma/client";
import { prisma } from "./db";
import { isSelfHostedEdition } from "./edition";
import { getEffectiveQuotaLimit } from "./quota-extensions";
import { getNextMonthlyQuotaReset, getPlanLimits } from "./plans";
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
const CREDIT_BALANCE_MAX = 1000;
/**
* OSS / self-hosted quota: never blocks on credits or plan limits.
* Batch size still enforced via SELFHOSTED_LIMITS.maxBatchSize.
* videosUsed is incremented for display only.
*/
function getNextFreeQuotaReset(from: Date = new Date()): Date {
return new Date(from.getTime() + TWELVE_HOURS_MS);
export function formatQuotaResetCountdown(_resetsAt: Date | string): string {
return "never";
}
function getNextQuotaResetForPlan(plan: Plan, from: Date = new Date()): Date {
return plan === "FREE" ? getNextFreeQuotaReset(from) : getNextMonthlyQuotaReset(from);
}
function isMonthlyResetDate(date: Date): boolean {
return (
date.getDate() === 1 &&
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
);
}
export function formatQuotaResetCountdown(resetsAt: Date | string): string {
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
const ms = Math.max(0, target.getTime() - Date.now());
const totalSec = Math.ceil(ms / 1000);
const hours = Math.floor(totalSec / 3600);
const minutes = Math.floor((totalSec % 3600) / 60);
const seconds = totalSec % 60;
return `${hours}h ${minutes}m ${seconds}s`;
}
export function formatQuotaResetDisplay(plan: Plan, resetsAt: Date | string): string {
const target = typeof resetsAt === "string" ? new Date(resetsAt) : resetsAt;
if (plan === "PREMIUM") {
return target.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
});
}
return formatQuotaResetCountdown(target);
}
function getQuotaExceededMessage(plan: Plan, resetsAt: Date, videoCredits: number): string {
if (plan === "PREMIUM") {
const resetLabel = formatQuotaResetDisplay(plan, resetsAt);
return `Quota exceeded. Your monthly Pro quota resets on ${resetLabel}. Request an extension in Settings if needed.`;
}
const countdown = formatQuotaResetCountdown(resetsAt);
const creditHint = videoCredits > 0 ? "" : " Credits are not available in this build.";
return `Not enough free quota or credits. Your free quota resets in ${countdown}.${creditHint}`;
export function formatQuotaResetDisplay(_plan: Plan, _resetsAt: Date | string): string {
return "never (self-hosted)";
}
export async function ensureQuotaReset(userId: string) {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const now = new Date();
if (user.plan === "PREMIUM") {
if (now >= user.quotaResetAt) {
return prisma.user.update({
where: { id: userId },
data: {
videosUsed: 0,
bonusQuota: 0,
quotaResetAt: getNextMonthlyQuotaReset(now),
},
});
}
if (!isMonthlyResetDate(user.quotaResetAt)) {
return prisma.user.update({
where: { id: userId },
data: {
quotaResetAt: getNextMonthlyQuotaReset(now),
},
});
}
return user;
}
if (now >= user.quotaResetAt) {
return prisma.user.update({
where: { id: userId },
data: {
videosUsed: 0,
quotaResetAt: getNextQuotaResetForPlan(user.plan, now),
},
});
}
return user;
return prisma.user.findUniqueOrThrow({ where: { id: userId } });
}
export async function getQuotaInfo(userId: string) {
const user = await ensureQuotaReset(userId);
const limits = getPlanLimits(user.plan);
const limit = isSelfHostedEdition()
? limits.monthlyQuota
: getEffectiveQuotaLimit(user.plan, user.bonusQuota);
const remaining = Math.max(0, limit - user.videosUsed);
const videoCredits =
isSelfHostedEdition() || user.plan !== "FREE" ? 0 : user.videoCredits;
return {
used: user.videosUsed,
limit,
limit: limits.monthlyQuota,
baseLimit: limits.monthlyQuota,
bonusQuota: isSelfHostedEdition() ? 0 : user.bonusQuota,
remaining,
videoCredits,
creditBalanceMax: CREDIT_BALANCE_MAX,
totalAvailable: remaining + videoCredits,
bonusQuota: 0,
remaining: limits.monthlyQuota,
videoCredits: 0,
extraCredits: 0,
creditBalanceMax: 0,
totalAvailable: limits.monthlyQuota,
resetsAt: user.quotaResetAt.toISOString(),
resetsIn: formatQuotaResetDisplay(user.plan, user.quotaResetAt),
plan: user.plan,
maxBatchSize: limits.maxBatchSize,
watermarkOptional: limits.watermarkOptional,
customWatermark: limits.customWatermark,
perItemImages: limits.perItemImages,
artTrackLayouts: limits.artTrackLayouts,
maxResolutionHeight: limits.maxResolutionHeight,
selfHosted: isSelfHostedEdition(),
selfHosted: true,
};
}
@@ -132,150 +51,55 @@ export type ReservationSplit = {
fromCredits: number;
};
/**
* Free plan: use included quota first, then pay-as-you-go credits.
* Pro plan: subscription quota only (PAYG is a separate product).
*/
/** Always succeed (except empty). Credits are never used in OSS. */
export function planReservation(
plan: Plan,
remainingQuota: number,
videoCredits: number,
_plan: Plan,
_remainingQuota: number,
_videoCredits: number,
count: number,
): ReservationSplit | null {
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
if (plan === "PREMIUM") {
if (count > remainingQuota) return null;
return { fromQuota: count, fromCredits: 0 };
}
const fromQuota = Math.min(count, Math.max(0, remainingQuota));
const fromCredits = count - fromQuota;
if (fromCredits > videoCredits) return null;
return { fromQuota, fromCredits };
return { fromQuota: count, fromCredits: 0 };
}
export async function checkQuota(userId: string, requestedCount: number) {
if (isSelfHostedEdition()) {
const info = await getQuotaInfo(userId);
if (requestedCount > info.maxBatchSize) {
return {
ok: false as const,
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
used: info.used,
limit: info.limit,
remaining: info.remaining,
videoCredits: 0,
resetsAt: info.resetsAt,
};
}
return {
ok: true as const,
...info,
reservation: { fromQuota: requestedCount, fromCredits: 0 },
};
}
const user = await ensureQuotaReset(userId);
const limits = getPlanLimits(user.plan);
const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota);
const remaining = Math.max(0, limit - user.videosUsed);
if (requestedCount > limits.maxBatchSize) {
return {
ok: false as const,
error: `Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
used: user.videosUsed,
limit,
remaining,
videoCredits: user.videoCredits,
resetsAt: user.quotaResetAt.toISOString(),
};
}
const split = planReservation(user.plan, remaining, user.videoCredits, requestedCount);
if (!split) {
return {
ok: false as const,
error: getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits),
used: user.videosUsed,
limit,
remaining,
videoCredits: user.plan === "FREE" ? user.videoCredits : 0,
resetsAt: user.quotaResetAt.toISOString(),
};
}
const info = await getQuotaInfo(userId);
return { ok: true as const, ...info, reservation: split };
if (requestedCount > info.maxBatchSize) {
return {
ok: false as const,
error: `Batch limit exceeded. Max ${info.maxBatchSize} files per batch.`,
used: info.used,
limit: info.limit,
remaining: info.remaining,
videoCredits: 0,
resetsAt: info.resetsAt,
};
}
return {
ok: true as const,
...info,
reservation: { fromQuota: requestedCount, fromCredits: 0 },
};
}
/**
* Atomically reserve plan quota first, then prepaid credits.
* Returns how many of each were taken (for per-item billingSource).
*/
/** Track usage for display; never deduct credits or block. */
export async function reserveQuota(userId: string, count: number): Promise<ReservationSplit> {
if (count <= 0) return { fromQuota: 0, fromCredits: 0 };
if (isSelfHostedEdition()) {
const limits = getPlanLimits("FREE");
if (count > limits.maxBatchSize) {
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
}
await ensureQuotaReset(userId);
await prisma.user.update({
where: { id: userId },
data: { videosUsed: { increment: count } },
});
return { fromQuota: count, fromCredits: 0 };
const limits = getPlanLimits();
if (count > limits.maxBatchSize) {
throw new Error(`Batch limit exceeded. Max ${limits.maxBatchSize} files per batch.`);
}
await ensureQuotaReset(userId);
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<
Array<{
id: string;
plan: Plan;
videosUsed: number;
bonusQuota: number;
videoCredits: number;
quotaResetAt: Date;
}>
>`SELECT id, plan, "videosUsed", "bonusQuota", "videoCredits", "quotaResetAt" FROM "User" WHERE id = ${userId} FOR UPDATE`;
const user = rows[0];
if (!user) throw new Error("User not found");
const limits = getPlanLimits(user.plan);
if (count > limits.maxBatchSize) {
throw new Error(
`Batch limit exceeded. Your plan allows up to ${limits.maxBatchSize} files per batch.`,
);
}
const limit = getEffectiveQuotaLimit(user.plan, user.bonusQuota);
const remaining = Math.max(0, limit - user.videosUsed);
const split = planReservation(user.plan, remaining, user.videoCredits, count);
if (!split) {
throw new Error(getQuotaExceededMessage(user.plan, user.quotaResetAt, user.videoCredits));
}
await tx.user.update({
where: { id: userId },
data: {
...(split.fromQuota > 0 ? { videosUsed: { increment: split.fromQuota } } : {}),
...(split.fromCredits > 0 ? { videoCredits: { decrement: split.fromCredits } } : {}),
},
});
return split;
await prisma.user.update({
where: { id: userId },
data: { videosUsed: { increment: count } },
});
return { fromQuota: count, fromCredits: 0 };
}
export async function releaseQuota(userId: string, count: number) {
if (count <= 0) return;
await ensureQuotaReset(userId);
await prisma.$executeRaw`
UPDATE "User"
SET "videosUsed" = GREATEST(0, "videosUsed" - ${count})
@@ -283,38 +107,27 @@ export async function releaseQuota(userId: string, count: number) {
`;
}
export async function releaseCredits(userId: string, count: number) {
if (count <= 0) return;
await prisma.$executeRaw`
UPDATE "User"
SET "videoCredits" = LEAST(${CREDIT_BALANCE_MAX}, "videoCredits" + ${count})
WHERE id = ${userId}
`;
/** No-op in OSS (no prepaid credits). */
export async function releaseCredits(_userId: string, _count: number) {
return;
}
/** Release one reserved unit based on how the job item was billed. */
export async function releaseReservation(
userId: string,
billingSource: "QUOTA" | "CREDIT",
_billingSource: "QUOTA" | "CREDIT",
count = 1,
) {
if (billingSource === "CREDIT") {
await releaseCredits(userId, count);
} else {
await releaseQuota(userId, count);
}
await releaseQuota(userId, count);
}
export async function releaseReservationSplit(userId: string, split: ReservationSplit) {
if (split.fromQuota > 0) await releaseQuota(userId, split.fromQuota);
if (split.fromCredits > 0) await releaseCredits(userId, split.fromCredits);
}
/** @deprecated Prefer reserveQuota at create; kept for callers that still increment on success. */
export async function incrementQuota(userId: string, count: number) {
await reserveQuota(userId, count);
}
export function getInitialQuotaResetAt(): Date {
return getNextFreeQuotaReset();
return getNextMonthlyQuotaReset();
}
+42
View File
@@ -1,4 +1,6 @@
import { Privacy } from "@prisma/client";
import type { LayoutSettings } from "./layout";
import type { WatermarkSettings } from "./watermark";
export type ItemMetadata = {
title: string;
@@ -11,9 +13,48 @@ export type ItemMetadata = {
madeForKids: boolean;
embeddable: boolean;
creativeCommons: boolean;
/** Legacy Free toggle default Songs2VID branding when watermark.mode is omitted. */
includeWatermark: boolean;
/** YouTube playlist ID (Pro only). Video is added after upload. */
playlistId?: string | null;
/**
* Optional per-item cover image path (Pro / perItemImages).
* When omitted, the job-level shared imagePath is used.
*/
imagePath?: string | null;
/** Pro custom branding. Free may only use mode none|default at bottom-right. */
watermark?: Partial<WatermarkSettings> | null;
/** Artist line for art-track layouts (optional). */
artist?: string | null;
/**
* Pro art-track layout. Prefer camelCase; snake_case aliases accepted:
* layout_template, blur_amount, text_padding.
*/
layout?: Partial<LayoutSettings> & {
layoutTemplate?: string | null;
layout_template?: string | null;
blur_amount?: number;
blur_opacity?: number;
text_padding?: number;
title_artist_gap?: number;
text_offset_x?: number;
text_offset_y?: number;
} | null;
/** Flat aliases (also accepted). */
layoutTemplate?: string | null;
layout_template?: string | null;
blurAmount?: number;
blur_amount?: number;
blurOpacity?: number;
blur_opacity?: number;
textPadding?: number;
text_padding?: number;
titleArtistGap?: number;
title_artist_gap?: number;
textOffsetX?: number;
text_offset_x?: number;
textOffsetY?: number;
text_offset_y?: number;
};
export type UploadedAudio = {
@@ -29,6 +70,7 @@ export type CreatePlaylistRequest = {
};
export type CreateJobPayload = {
/** Shared cover for Free / default when items omit imagePath. */
imagePath: string;
items: Array<{
audioPath: string;
+192
View File
@@ -0,0 +1,192 @@
/**
* Watermark / branding helpers position math + FFmpeg-safe sanitization.
* Never interpolate unsanitized user text into filter graphs.
*/
import {
isWatermarkFontKey,
type WatermarkFontKey,
} from "./fonts";
export const WATERMARK_POSITIONS = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
"center",
] as const;
export type WatermarkPosition = (typeof WATERMARK_POSITIONS)[number];
export const WATERMARK_MODES = ["none", "default", "text", "logo"] as const;
export type WatermarkMode = (typeof WATERMARK_MODES)[number];
export type WatermarkSettings = {
mode: WatermarkMode;
text?: string | null;
logoPath?: string | null;
position: WatermarkPosition;
/** Pixel offset from the chosen anchor (0200). */
offsetX: number;
offsetY: number;
/**
* Typography (Pro / text mode).
* `system` = FFmpeg default; curated keys map to assets/fonts; `custom` uses fontPath.
*/
fontKey?: WatermarkFontKey;
/** Absolute/staging path to uploaded .ttf/.otf when fontKey === "custom". */
fontPath?: string | null;
};
export const DEFAULT_WATERMARK: WatermarkSettings = {
mode: "default",
text: null,
logoPath: null,
position: "bottom-right",
offsetX: 20,
offsetY: 20,
fontKey: "system",
fontPath: null,
};
export const WATERMARK_TEXT_MAX = 80;
export const WATERMARK_OFFSET_MIN = 0;
export const WATERMARK_OFFSET_MAX = 200;
export function isWatermarkPosition(v: unknown): v is WatermarkPosition {
return typeof v === "string" && (WATERMARK_POSITIONS as readonly string[]).includes(v);
}
export function isWatermarkMode(v: unknown): v is WatermarkMode {
return typeof v === "string" && (WATERMARK_MODES as readonly string[]).includes(v);
}
export function clampOffset(n: unknown, fallback = 20): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(WATERMARK_OFFSET_MIN, Math.min(WATERMARK_OFFSET_MAX, Math.round(v)));
}
/**
* Escape user text for FFmpeg drawtext.
* Strips control chars; escapes \, :, ', %, and [.
*/
export function sanitizeDrawtext(raw: string): string {
return raw
.slice(0, WATERMARK_TEXT_MAX)
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/\\/g, "\\\\")
.replace(/:/g, "\\:")
.replace(/'/g, "\\'")
.replace(/%/g, "%%")
.replace(/\[/g, "\\[");
}
/** FFmpeg overlay=x:y expressions for a scaled watermark layer `[wm]`. */
export function overlayXy(
position: WatermarkPosition,
offsetX: number,
offsetY: number,
): { x: string; y: string } {
const ox = clampOffset(offsetX);
const oy = clampOffset(offsetY);
switch (position) {
case "top-left":
return { x: String(ox), y: String(oy) };
case "top-right":
return { x: `W-w-${ox}`, y: String(oy) };
case "bottom-left":
return { x: String(ox), y: `H-h-${oy}` };
case "center":
return { x: `(W-w)/2+${ox}`, y: `(H-h)/2+${oy}` };
case "bottom-right":
default:
return { x: `W-w-${ox}`, y: `H-h-${oy}` };
}
}
/** drawtext x/y for text watermarks. */
export function drawtextXy(
position: WatermarkPosition,
offsetX: number,
offsetY: number,
): { x: string; y: string } {
const ox = clampOffset(offsetX);
const oy = clampOffset(offsetY);
switch (position) {
case "top-left":
return { x: String(ox), y: String(oy) };
case "top-right":
return { x: `w-text_w-${ox}`, y: String(oy) };
case "bottom-left":
return { x: String(ox), y: `h-th-${oy}` };
case "center":
return { x: `(w-text_w)/2+${ox}`, y: `(h-th)/2+${oy}` };
case "bottom-right":
default:
return { x: `w-text_w-${ox}`, y: `h-th-${oy}` };
}
}
/**
* Build a drawtext filter segment (without leading comma).
* `fontfileEscaped` must already be sanitized via sanitizeFontfileForFilter.
*/
export function buildDrawtextFilter(opts: {
text: string;
fontSize: number;
fontColor?: string;
position: WatermarkPosition;
offsetX: number;
offsetY: number;
fontfileEscaped?: string | null;
}): string {
const text = sanitizeDrawtext(opts.text);
const { x, y } = drawtextXy(opts.position, opts.offsetX, opts.offsetY);
const color = opts.fontColor ?? "white@0.9";
const fontPart = opts.fontfileEscaped
? `:fontfile='${opts.fontfileEscaped}'`
: "";
return `drawtext=text='${text}'${fontPart}:fontsize=${opts.fontSize}:fontcolor=${color}:x=${x}:y=${y}`;
}
export function normalizeWatermarkSettings(
input: Partial<WatermarkSettings> | null | undefined,
includeWatermarkFallback: boolean,
): WatermarkSettings {
if (!input) {
return {
...DEFAULT_WATERMARK,
mode: includeWatermarkFallback ? "default" : "none",
};
}
const mode = isWatermarkMode(input.mode)
? input.mode
: includeWatermarkFallback
? "default"
: "none";
const fontKey = isWatermarkFontKey(input.fontKey) ? input.fontKey : "system";
return {
mode,
text: typeof input.text === "string" ? input.text.slice(0, WATERMARK_TEXT_MAX) : null,
logoPath: typeof input.logoPath === "string" ? input.logoPath : null,
position: isWatermarkPosition(input.position) ? input.position : "bottom-right",
offsetX: clampOffset(input.offsetX, 20),
offsetY: clampOffset(input.offsetY, 20),
fontKey,
fontPath: typeof input.fontPath === "string" ? input.fontPath : null,
};
}
/** True when settings go beyond Free-tier default branding toggle. */
export function requiresCustomWatermarkEntitlement(settings: WatermarkSettings): boolean {
if (settings.mode === "text" || settings.mode === "logo") return true;
if (settings.mode === "none") return false;
if (settings.position !== "bottom-right") return true;
if (settings.offsetX !== 20 || settings.offsetY !== 20) return true;
if (settings.fontKey && settings.fontKey !== "system") return true;
if (settings.fontPath) return true;
return false;
}
export const PREMIUM_REQUIRED_CODE = "PREMIUM_REQUIRED" as const;