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
+87 -12
View File
@@ -8,9 +8,12 @@ import {
isCuratedFontKey,
sanitizeFontfileForFilter,
} from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server";
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import {
buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
isLowerCornerTemplate,
normalizeLayoutSettings,
type LayoutSettings,
} from "../layout";
import {
@@ -61,9 +64,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
async function resolveFontfileEscaped(
settings: WatermarkSettings,
weight: "regular" | "bold" = "regular",
): Promise<string | null> {
const key = settings.fontKey ?? "system";
if (key === "system") return null;
if (key === "system") {
const preferred = resolveSystemFontPath(weight);
const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch (err) {
console.warn(
`[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
);
}
}
console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
return null;
}
if (key === "custom") {
if (!settings.fontPath) return null;
@@ -74,12 +94,19 @@ async function resolveFontfileEscaped(
}
if (isCuratedFontKey(key)) {
const fontPath = resolveCuratedFontPath(key);
if (!(await fileExists(fontPath))) {
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
return null;
const preferred = resolveCuratedFontPath(key, weight);
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch {
/* try next */
}
}
return sanitizeFontfileForFilter(fontPath);
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
return null;
}
return null;
@@ -99,6 +126,13 @@ function artTrackFilterEndingAt(
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
function classicBlurFillEndingAt(
opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
outLabel: string,
): string {
return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
export async function encodeVideo(options: {
imagePath: string;
audioPath: string;
@@ -110,6 +144,11 @@ export async function encodeVideo(options: {
/** On-video song title (art-track layouts). */
songTitle?: string;
artist?: string | null;
/**
* Optional full-frame background for lower-corner templates.
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
*/
backgroundImagePath?: string | null;
}): Promise<void> {
const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
@@ -117,17 +156,39 @@ export async function encodeVideo(options: {
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
const layout = options.layout?.template ? options.layout : null;
const layoutSettings = options.layout
? normalizeLayoutSettings(options.layout)
: null;
const artTrack = Boolean(layoutSettings?.template);
const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill);
const layout = artTrack && layoutSettings ? layoutSettings : null;
const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width);
const fontfile = await resolveFontfileEscaped(settings);
const fontfile = await resolveFontfileEscaped(settings, "regular");
const titleFontfile =
layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
// Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
const titleFontfileDistinct =
titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2;
let separateBackgroundInputIndex: number | null = null;
let logoInputIndex: number | null = null;
let defaultWmInputIndex: number | null = null;
const useSeparateBackground =
Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);
if (useSeparateBackground && options.backgroundImagePath) {
if (!(await fileExists(options.backgroundImagePath))) {
throw new Error("Background image file not found");
}
args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
separateBackgroundInputIndex = nextInput++;
}
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath();
const useDefaultPng =
@@ -156,8 +217,8 @@ export async function encodeVideo(options: {
? ("default-text" as const)
: ("none" as const);
// Fast path: classic letterbox, no watermark
if (!layout && applyWm === "none") {
// Fast path: classic letterbox (black bars), no watermark, no blur fill
if (!layout && !classicBlurFill && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height));
args.push(
"-c:v",
@@ -192,6 +253,20 @@ export async function encodeVideo(options: {
titleEscaped,
artistEscaped,
fontfileEscaped: fontfile,
titleFontfileEscaped: titleFontfileDistinct,
separateBackgroundInputIndex,
},
baseLabel,
),
);
} else if (classicBlurFill && layoutSettings) {
filterParts.push(
classicBlurFillEndingAt(
{
width: res.width,
height: res.height,
blurAmount: layoutSettings.blurAmount,
blurOpacity: layoutSettings.blurOpacity,
},
baseLabel,
),
@@ -235,7 +310,7 @@ export async function encodeVideo(options: {
position: settings.position,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
fontfileEscaped: null,
fontfileEscaped: fontfile,
});
filterParts.push(`[${baseLabel}]${draw}[vout]`);
}
+17 -4
View File
@@ -2,19 +2,32 @@
import fs from "fs/promises";
import path from "path";
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
export function getFontsDir(): string {
return path.join(process.cwd(), "assets", "fonts");
}
/** Resolve the bundled system (Arimo) font for FFmpeg + preview. */
export function resolveSystemFontPath(weight: "regular" | "bold" = "regular"): string {
const file = weight === "bold" ? SYSTEM_FONT.boldFile : SYSTEM_FONT.file;
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
/** Resolve a curated font file on disk (must exist under assets/fonts). */
export function resolveCuratedFontPath(key: CuratedFontKey): string {
export function resolveCuratedFontPath(
key: CuratedFontKey,
weight: "regular" | "bold" = "regular",
): string {
const meta = CURATED_FONTS.find((f) => f.key === key);
if (!meta) throw new Error("Unknown font");
const file =
weight === "bold" && meta.boldFile ? meta.boldFile : meta.file;
// Whitelist filename only never accept user-controlled path segments
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== meta.file) throw new Error("Invalid font asset name");
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
+14
View File
@@ -3,6 +3,15 @@
/** Max custom font upload size (10 MB). */
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
/** Bundled default font for preview + FFmpeg parity (Apache 2.0, Google Arimo). */
export const SYSTEM_FONT = {
key: "system",
label: "Arimo",
file: "Arimo-Regular.ttf",
boldFile: "Arimo-Bold.ttf",
previewFamily: "S2VIDPreview-system",
} as const;
export const CURATED_FONTS = [
{
key: "inter",
@@ -10,6 +19,7 @@ export const CURATED_FONTS = [
cssFamily: "Inter",
googleCss: "Inter:wght@400;600",
file: "Inter-Regular.ttf",
boldFile: null as string | null,
},
{
key: "montserrat",
@@ -17,6 +27,7 @@ export const CURATED_FONTS = [
cssFamily: "Montserrat",
googleCss: "Montserrat:wght@400;600",
file: "Montserrat-Regular.ttf",
boldFile: null as string | null,
},
{
key: "roboto",
@@ -24,6 +35,7 @@ export const CURATED_FONTS = [
cssFamily: "Roboto",
googleCss: "Roboto:wght@400;500",
file: "Roboto-Regular.ttf",
boldFile: null as string | null,
},
{
key: "oswald",
@@ -31,6 +43,7 @@ export const CURATED_FONTS = [
cssFamily: "Oswald",
googleCss: "Oswald:wght@400;500",
file: "Oswald-Regular.ttf",
boldFile: null as string | null,
},
{
key: "playfair",
@@ -38,6 +51,7 @@ export const CURATED_FONTS = [
cssFamily: "Playfair Display",
googleCss: "Playfair+Display:wght@400;600",
file: "PlayfairDisplay-Regular.ttf",
boldFile: null as string | null,
},
] as const;
+63 -13
View File
@@ -16,6 +16,7 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
import {
ARTIST_MAX,
INVALID_LAYOUT_TEMPLATE_MESSAGE,
isLowerCornerTemplate,
normalizeLayoutSettings,
SONG_TITLE_MAX,
type LayoutSettings,
@@ -56,6 +57,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
metadata.layout?.blur_amount ??
metadata.blurAmount ??
metadata.blur_amount,
blurFill:
metadata.layout?.blurFill ??
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
metadata.blurFill ??
metadata.blur_fill,
blurOpacity:
metadata.layout?.blurOpacity ??
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
@@ -81,6 +87,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
metadata.textOffsetY ??
metadata.text_offset_y,
titleBold:
metadata.layout?.titleBold ??
(metadata.layout as { title_bold?: boolean } | null | undefined)?.title_bold ??
metadata.titleBold ??
metadata.title_bold,
});
}
@@ -180,6 +191,18 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
}
}
const layoutForBg = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
if (bgRaw && isLowerCornerTemplate(layoutForBg.template)) {
const bgPath = assertPathInUserUploads(user.id, bgRaw);
await fs.access(bgPath);
const st = await fs.stat(bgPath);
if (st.size > limits.maxFileSizeBytes) {
return `Background image for ${item.audioFilename} exceeds size limit`;
}
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
@@ -242,20 +265,30 @@ export async function createVideoJob(
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
const items = body.items.map((item) => {
const layout = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
const backgroundImagePath =
bgRaw && isLowerCornerTemplate(layout.template)
? assertPathInUserUploads(user.id, bgRaw)
: null;
return {
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
}));
backgroundImagePath,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
: null,
};
});
await reserveQuota(user.id, items.length);
@@ -298,6 +331,7 @@ export async function createVideoJob(
creativeCommons: item.metadata.creativeCommons,
includeWatermark: wm.mode !== "none",
itemImagePath: item.itemImagePath,
backgroundImagePath: item.backgroundImagePath,
watermarkMode: wm.mode,
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
@@ -309,10 +343,12 @@ export async function createVideoJob(
watermarkOffsetY: wm.offsetY,
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
layoutTemplate: layout.template,
blurFill: layout.blurFill,
blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity,
textPadding: layout.textPadding,
titleArtistGap: layout.titleArtistGap,
titleBold: layout.titleBold,
textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null,
@@ -353,6 +389,19 @@ export async function createVideoJob(
}
}
let newBackgroundImage: string | null = null;
if (item.backgroundImagePath) {
const src = item.backgroundImagePath;
if (moved.has(src)) {
newBackgroundImage = moved.get(src)!;
} else {
const ext = path.extname(src);
newBackgroundImage = path.join(jobDir, `${item.id}-bg${ext}`);
await moveFile(src, newBackgroundImage);
moved.set(src, newBackgroundImage);
}
}
let newLogo: string | null = null;
if (item.watermarkLogoPath) {
const src = item.watermarkLogoPath;
@@ -383,6 +432,7 @@ export async function createVideoJob(
data: {
audioPath: newAudioPath,
itemImagePath: newItemImage,
backgroundImagePath: newBackgroundImage,
watermarkLogoPath: newLogo,
watermarkFontPath: newFont,
},
+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]!;
}
+4 -4
View File
@@ -4,7 +4,7 @@
*/
import type { WatermarkFontKey } from "./fonts";
import { CURATED_FONTS } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT } from "./fonts";
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
export const PREVIEW_REFERENCE_WIDTH = 576;
@@ -29,13 +29,13 @@ export function scaleFontToPreview(fontPx: number, encodeWidth: number): number
export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") {
return "Arial, Helvetica, sans-serif";
return `'${SYSTEM_FONT.previewFamily}', sans-serif`;
}
if (fontKey === "custom") {
return "'S2VIDCustomWm', Arial, sans-serif";
return "'S2VIDCustomWm', sans-serif";
}
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'S2VIDPreview-${meta.key}', Arial, sans-serif` : "Arial, sans-serif";
return meta ? `'S2VIDPreview-${meta.key}', sans-serif` : "sans-serif";
}
export function curatedFontApiUrl(key: string): string {
+15
View File
@@ -22,6 +22,12 @@ export type ItemMetadata = {
* When omitted, the job-level shared imagePath is used.
*/
imagePath?: string | null;
/**
* Optional full-frame background for LOWER_LEFT / LOWER_RIGHT templates.
* Blur/opacity apply to this image; cover stays the sharp corner square.
*/
backgroundImagePath?: string | null;
background_image_path?: string | null;
/** Custom branding watermark settings. */
watermark?: Partial<WatermarkSettings> | null;
/** Artist line for art-track layouts (on-video only). */
@@ -31,20 +37,27 @@ export type ItemMetadata = {
/**
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
* layout_template, blur_amount, text_padding.
* `template`: COVER_LEFT_TEXT_RIGHT | COVER_TOP_TEXT_BOTTOM |
* COVER_RIGHT_TEXT_LEFT | CENTERED_COMPACT | LOWER_LEFT_COVER_TEXT |
* LOWER_RIGHT_COVER_TEXT (also listed on GET /api/v1 → layoutTemplates).
*/
layout?: Partial<LayoutSettings> & {
layoutTemplate?: string | null;
layout_template?: string | null;
blur_fill?: boolean;
blur_amount?: number;
blur_opacity?: number;
text_padding?: number;
title_artist_gap?: number;
text_offset_x?: number;
text_offset_y?: number;
title_bold?: boolean;
} | null;
/** Flat aliases (also accepted). */
layoutTemplate?: string | null;
layout_template?: string | null;
blurFill?: boolean;
blur_fill?: boolean;
blurAmount?: number;
blur_amount?: number;
blurOpacity?: number;
@@ -57,6 +70,8 @@ export type ItemMetadata = {
text_offset_x?: number;
textOffsetY?: number;
text_offset_y?: number;
titleBold?: boolean;
title_bold?: boolean;
};
export type UploadedAudio = {