/** * 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", "LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT", ] 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; /** Lower-corner cover size as a fraction of encode frame width. */ export const LOWER_CORNER_COVER_WIDTH_FRACTION = 0.23; export function lowerCornerCoverSide( width: number, height: number, textPadding: number, ): number { const edge = clampTextPadding(textPadding); return Math.round( Math.min(width * LOWER_CORNER_COVER_WIDTH_FRACTION, height - edge * 2), ); } export const ARTIST_MAX = 80; export const SONG_TITLE_MAX = 120; export type LayoutSettings = { /** When null, classic letterbox (no art-track layout). */ template: LayoutTemplate | null; /** * Classic letterbox only: fill letterbox bars with a blurred cover. * Ignored when `template` is set (art-track always uses a blur fill). */ blurFill: boolean; /** 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; /** Bold weight for the on-video song title (artist stays regular). */ titleBold: boolean; }; export const DEFAULT_LAYOUT: LayoutSettings = { template: null, blurFill: false, 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, titleBold: true, }; 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; blurFill?: unknown; blur_fill?: 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; titleBold?: unknown; title_bold?: 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; const boldRaw = (input as RawLayoutInput).titleBold ?? (input as RawLayoutInput).title_bold ?? (input as LayoutSettings).titleBold; const blurFillRaw = (input as RawLayoutInput).blurFill ?? (input as RawLayoutInput).blur_fill ?? (input as LayoutSettings).blurFill; return { template, blurFill: template !== null ? false : Boolean(blurFillRaw), 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), titleBold: boldRaw === undefined || boldRaw === null ? true : Boolean(boldRaw), }; } 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; } case "LOWER_LEFT_COVER_TEXT": { // Equal left + bottom inset so the cover corner sits on a true diagonal from (0, H). const edge = pad; const side = lowerCornerCoverSide(width, height, pad); const coverTextGap = Math.max(10, Math.round(pad * 0.55)); const textX = edge + side + coverTextGap; const coverTop = height - edge - side; const textBlockH = titleFontSize + lineGap + artistFontSize; const textMidY = coverTop + Math.round(side / 2); const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2)); base = { coverMaxW: side, coverMaxH: side, coverX: String(edge), coverY: `H-h-${edge}`, titleFontSize, artistFontSize, titleX: String(textX), titleY: String(titleY), artistX: String(textX), artistY: String(titleY + titleFontSize + lineGap), }; break; } case "LOWER_RIGHT_COVER_TEXT": { // Equal right + bottom inset (mirror of lower-left diagonal). const edge = pad; const side = lowerCornerCoverSide(width, height, pad); const coverTextGap = Math.max(10, Math.round(pad * 0.55)); const coverTop = height - edge - side; const textBlockH = titleFontSize + lineGap + artistFontSize; const textMidY = coverTop + Math.round(side / 2); const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2)); const textInset = edge + side + coverTextGap; base = { coverMaxW: side, coverMaxH: side, coverX: `W-w-${edge}`, coverY: `H-h-${edge}`, titleFontSize, artistFontSize, titleX: `W-text_w-${textInset}`, titleY: String(titleY), artistX: `W-text_w-${textInset}`, artistY: String(titleY + titleFontSize + lineGap), }; break; } default: { throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE); } } return applyTextOffsets(base, textOffsetX, textOffsetY); } export function isLowerCornerTemplate( template: LayoutTemplate | null | undefined, ): boolean { return template === "LOWER_LEFT_COVER_TEXT" || template === "LOWER_RIGHT_COVER_TEXT"; } export function buildArtTrackFilterComplex(opts: { width: number; height: number; layout: LayoutSettings; titleEscaped: string; artistEscaped: string | null; /** Regular-weight font for artist (and title when not bold / no bold file). */ fontfileEscaped?: string | null; /** Bold font for title when `layout.titleBold` and a bold face is available. */ titleFontfileEscaped?: string | null; /** * When set, use this FFmpeg input index as the blur-fill source instead of * splitting the cover (`[0:v]`). Cover stays on input 0 as the sharp overlay. */ separateBackgroundInputIndex?: number | 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 artistFontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : ""; const titleUsesBoldFace = Boolean(layout.titleBold && opts.titleFontfileEscaped); const titleFontPart = titleUsesBoldFace ? `:fontfile='${opts.titleFontfileEscaped}'` : artistFontPart; // Faux-bold stroke when bold is requested but no bold TTF is available (custom/curated). const titleFauxBold = layout.titleBold && !titleUsesBoldFace ? `:borderw=1:bordercolor=white@0.95` : ""; const titleDraw = `drawtext=text='${opts.titleEscaped}'${titleFontPart}${titleFauxBold}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`; const artistDraw = opts.artistEscaped ? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}` : ""; const separateBgIdx = typeof opts.separateBackgroundInputIndex === "number" && Number.isFinite(opts.separateBackgroundInputIndex) && opts.separateBackgroundInputIndex >= 0 ? opts.separateBackgroundInputIndex : null; const parts: string[] = []; const bgSrc = separateBgIdx !== null ? `[${separateBgIdx}:v]` : "[bg]"; if (separateBgIdx !== null) { parts.push( `[0:v]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`, ); } else { parts.push(`[0:v]split=2[bg][fg]`); parts.push( `[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`, ); } // Fade blurred fill toward black when opacity < 100% if (opacity >= 0.999) { parts.push(`${bgSrc}${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( `${bgSrc}${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( `[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`, `[composed]${titleDraw}${artistDraw}[laid]`, ); return parts.join(";"); } /** * Classic letterbox with blurred cover fill (no on-video title/artist). * Ends at `[laid]` — same convention as art-track for encode relabeling. */ export function buildClassicBlurFillFilterComplex(opts: { width: number; height: number; blurAmount: number; blurOpacity: number; }): string { const { width: W, height: H } = opts; const blurSeg = boxblurFilterSegment(opts.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(opts.blurOpacity, BLUR_OPACITY_DEFAULT) / 100; const parts: string[] = [ `[0:v]split=2[bg][fg]`, `[fg]scale=${W}:${H}:force_original_aspect_ratio=decrease[cover]`, ]; 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(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[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", LOWER_LEFT_COVER_TEXT: "Lower left · cover + text", LOWER_RIGHT_COVER_TEXT: "Lower right · cover + text", }; /** Composition picker families — mirrored pairs share one tile + variant toggles. */ export type CompositionFamilyId = | "classic" | "side" | "top" | "centered" | "lower"; export type CompositionFamily = { id: CompositionFamilyId; /** Tile label in the composition grid. */ label: string; /** Which thumb art to draw (null = classic letterbox). */ thumb: LayoutTemplate | null; /** Templates in this family; length > 1 shows variant toggles when selected. */ variants: LayoutTemplate[]; /** Default when first selecting the family. */ defaultTemplate: LayoutTemplate | null; }; export const COMPOSITION_FAMILIES: readonly CompositionFamily[] = [ { id: "classic", label: "Classic letterbox", thumb: null, variants: [], defaultTemplate: null, }, { id: "side", label: "Cover beside text", thumb: "COVER_LEFT_TEXT_RIGHT", variants: ["COVER_LEFT_TEXT_RIGHT", "COVER_RIGHT_TEXT_LEFT"], defaultTemplate: "COVER_LEFT_TEXT_RIGHT", }, { id: "top", label: "Cover top · text bottom", thumb: "COVER_TOP_TEXT_BOTTOM", variants: ["COVER_TOP_TEXT_BOTTOM"], defaultTemplate: "COVER_TOP_TEXT_BOTTOM", }, { id: "centered", label: "Centered compact", thumb: "CENTERED_COMPACT", variants: ["CENTERED_COMPACT"], defaultTemplate: "CENTERED_COMPACT", }, { id: "lower", label: "Lower corner · cover + text", thumb: "LOWER_LEFT_COVER_TEXT", variants: ["LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT"], defaultTemplate: "LOWER_LEFT_COVER_TEXT", }, ] as const; export function compositionFamilyForTemplate( template: LayoutTemplate | null, ): CompositionFamily { if (template === null) return COMPOSITION_FAMILIES[0]!; const found = COMPOSITION_FAMILIES.find((f) => f.variants.includes(template), ); return found ?? COMPOSITION_FAMILIES[0]!; }