/** * 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 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 | 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 = { 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", };