Initial OSS scaffold from Songs2VID (pre-strip)
This commit is contained in:
@@ -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 (0–200). */
|
||||
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;
|
||||
Reference in New Issue
Block a user