/** * 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 (0–200). */ offsetX: number; offsetY: number; /** * Typography (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 | 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 the 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; }