Add lower-corner layouts, custom blur backgrounds, and classic blur fill.

Ship composition families, optional background images for lower-corner templates, and an optional blurred cover fill for classic letterbox.
This commit is contained in:
Atakan Doğan Özban
2026-08-07 00:51:37 +02:00
parent 03634d5100
commit 848607f9e0
25 changed files with 1081 additions and 122 deletions
+246 -11
View File
@@ -8,6 +8,8 @@ export const LAYOUT_TEMPLATES = [
"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];
@@ -37,12 +39,31 @@ 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 / blur fill). */
/** 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;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */
@@ -55,16 +76,20 @@ export type LayoutSettings = {
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 {
@@ -119,6 +144,8 @@ type RawLayoutInput = {
template?: unknown;
layoutTemplate?: unknown;
layout_template?: unknown;
blurFill?: unknown;
blur_fill?: unknown;
blurAmount?: unknown;
blur_amount?: unknown;
blurOpacity?: unknown;
@@ -131,6 +158,8 @@ type RawLayoutInput = {
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;
@@ -189,15 +218,25 @@ export function normalizeLayoutSettings(
(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),
};
}
@@ -342,6 +381,54 @@ export function computeLayoutGeometry(
};
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);
}
@@ -350,13 +437,27 @@ export function computeLayoutGeometry(
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) {
@@ -378,15 +479,84 @@ export function buildArtTrackFilterComplex(opts: {
: `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 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}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
: "";
const parts: string[] = [`[0:v]split=2[bg][fg]`];
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) {
@@ -401,12 +571,7 @@ export function buildArtTrackFilterComplex(opts: {
);
}
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]`,
);
parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
return parts.join(";");
}
@@ -415,4 +580,74 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
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]!;
}