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:
+246
-11
@@ -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;
|
||||
/** 0–100 → FFmpeg boxblur intensity. */
|
||||
blurAmount: number;
|
||||
/** 0–100 → 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]!;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user