Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
/**
|
||
* 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<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 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;
|
||
}
|
||
|