Files
songs2vid/lib/layout.ts
T
Atakan Doğan ÖzbanandCursor c8015937f9 Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
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.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 16:26:19 +02:00

419 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → 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",
};