Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
/**
|
||
* 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 const SONG_TITLE_MAX = 120;
|
||
|
||
export type LayoutSettings = {
|
||
/** When null, classic letterbox (no art-track layout / blur fill). */
|
||
template: LayoutTemplate | null;
|
||
/** 0–100 → FFmpeg boxblur intensity. */
|
||
blurAmount: number;
|
||
/** 0–100 → 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",
|
||
};
|