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:
+87
-12
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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]!;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user