Files
songs2vid/lib/watermark.ts
T
Songs2YTandCursor d951ecb489 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>
2026-07-25 03:50:06 +02:00

193 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;