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
+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,
},