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
+14 -6
View File
@@ -1,25 +1,33 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises"; import fs from "fs/promises";
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts"; import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts";
import { resolveCuratedFontPath } from "@/lib/fonts-server"; import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server";
export async function GET( export async function GET(
_req: NextRequest, _req: NextRequest,
{ params }: { params: Promise<{ key: string }> }, { params }: { params: Promise<{ key: string }> },
) { ) {
const { key } = await params; const { key } = await params;
if (!isCuratedFontKey(key)) { const fontPath =
key === SYSTEM_FONT.key
? resolveSystemFontPath()
: isCuratedFontKey(key)
? resolveCuratedFontPath(key)
: null;
if (!fontPath) {
return NextResponse.json({ error: "Unknown font" }, { status: 404 }); return NextResponse.json({ error: "Unknown font" }, { status: 404 });
} }
const fontPath = resolveCuratedFontPath(key);
try { try {
const buf = await fs.readFile(fontPath); const buf = await fs.readFile(fontPath);
const meta = CURATED_FONTS.find((f) => f.key === key)!; const fileName =
key === SYSTEM_FONT.key
? SYSTEM_FONT.file
: CURATED_FONTS.find((f) => f.key === key)!.file;
return new NextResponse(buf, { return new NextResponse(buf, {
headers: { headers: {
"Content-Type": "font/ttf", "Content-Type": "font/ttf",
"Content-Disposition": `inline; filename="${meta.file}"`, "Content-Disposition": `inline; filename="${fileName}"`,
"Cache-Control": "public, max-age=86400, immutable", "Cache-Control": "public, max-age=86400, immutable",
}, },
}); });
+18
View File
@@ -1,4 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
COMPOSITION_FAMILIES,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
} from "@/lib/layout";
import { API_DOCS_URL } from "@/lib/plans"; import { API_DOCS_URL } from "@/lib/plans";
export async function GET() { export async function GET() {
@@ -17,6 +22,19 @@ export async function GET() {
batch: batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'", "POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
}, },
layoutTemplates: LAYOUT_TEMPLATES.map((id) => ({
id,
label: LAYOUT_TEMPLATE_LABELS[id],
})),
compositionFamilies: COMPOSITION_FAMILIES.map((family) => ({
id: family.id,
label: family.label,
defaultTemplate: family.defaultTemplate,
variants: family.variants.map((id) => ({
id,
label: LAYOUT_TEMPLATE_LABELS[id],
})),
})),
endpoints: [ endpoints: [
{ {
method: "POST", method: "POST",
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+284 -35
View File
@@ -10,6 +10,7 @@ import {
import { getVideoAttributionText } from "@/lib/branding"; import { getVideoAttributionText } from "@/lib/branding";
import { import {
CURATED_FONTS, CURATED_FONTS,
SYSTEM_FONT,
type CuratedFontKey, type CuratedFontKey,
type WatermarkFontKey, type WatermarkFontKey,
} from "@/lib/fonts"; } from "@/lib/fonts";
@@ -19,15 +20,18 @@ import {
BLUR_OPACITY_DEFAULT, BLUR_OPACITY_DEFAULT,
BLUR_OPACITY_MAX, BLUR_OPACITY_MAX,
BLUR_OPACITY_MIN, BLUR_OPACITY_MIN,
COMPOSITION_FAMILIES,
DEFAULT_LAYOUT, DEFAULT_LAYOUT,
LAYOUT_TEMPLATE_LABELS, LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
TEXT_OFFSET_MAX, TEXT_OFFSET_MAX,
TEXT_OFFSET_MIN, TEXT_OFFSET_MIN,
TEXT_PADDING_MAX, TEXT_PADDING_MAX,
TEXT_PADDING_MIN, TEXT_PADDING_MIN,
TITLE_ARTIST_GAP_MAX, TITLE_ARTIST_GAP_MAX,
TITLE_ARTIST_GAP_MIN, TITLE_ARTIST_GAP_MIN,
compositionFamilyForTemplate,
isLowerCornerTemplate,
lowerCornerCoverSide,
type LayoutSettings, type LayoutSettings,
type LayoutTemplate, type LayoutTemplate,
} from "@/lib/layout"; } from "@/lib/layout";
@@ -54,6 +58,8 @@ import {
type Props = { type Props = {
locked: boolean; locked: boolean;
previewImageUrl: string | null; previewImageUrl: string | null;
/** Optional custom blur-fill for lower-corner layouts. */
previewBackgroundUrl?: string | null;
/** On-video song title (art-track layouts). */ /** On-video song title (art-track layouts). */
songTitle: string; songTitle: string;
artist: string; artist: string;
@@ -65,7 +71,10 @@ type Props = {
onWatermarkChange: (next: WatermarkSettings) => void; onWatermarkChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>; onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>; onUploadFont: (file: File) => Promise<string>;
onUploadBackground?: (file: File) => Promise<string>;
onClearBackground?: () => void;
logoPreviewUrl?: string | null; logoPreviewUrl?: string | null;
hasCustomBackground?: boolean;
}; };
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = { const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
@@ -121,11 +130,13 @@ function watermarkOverlayStyle(
function MiniThumb({ function MiniThumb({
template, template,
label,
active, active,
onClick, onClick,
disabled, disabled,
}: { }: {
template: LayoutTemplate | null; template: LayoutTemplate | null;
label: string;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
disabled: boolean; disabled: boolean;
@@ -150,20 +161,14 @@ function MiniThumb({
) : ( ) : (
<div className="relative h-full w-full overflow-hidden bg-gray-700/50"> <div className="relative h-full w-full overflow-hidden bg-gray-700/50">
<div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" /> <div className="absolute inset-0 scale-110 bg-gray-600/40 blur-[3px]" />
{template === "COVER_LEFT_TEXT_RIGHT" && ( {(template === "COVER_LEFT_TEXT_RIGHT" ||
template === "COVER_RIGHT_TEXT_LEFT") && (
<> <>
<div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" /> <div className="absolute left-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" /> <div className="absolute right-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" /> <div className="absolute right-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</> </>
)} )}
{template === "COVER_RIGHT_TEXT_LEFT" && (
<>
<div className="absolute right-[6%] top-[18%] h-[64%] w-[38%] bg-gray-300" />
<div className="absolute left-[8%] top-[38%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute left-[8%] top-[52%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
{template === "COVER_TOP_TEXT_BOTTOM" && ( {template === "COVER_TOP_TEXT_BOTTOM" && (
<> <>
<div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" /> <div className="absolute left-[8%] top-[6%] h-[52%] w-[84%] bg-gray-300" />
@@ -178,12 +183,18 @@ function MiniThumb({
<div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" /> <div className="absolute left-[34%] bottom-[12%] h-0.5 w-[32%] rounded bg-white/50" />
</> </>
)} )}
{(template === "LOWER_LEFT_COVER_TEXT" ||
template === "LOWER_RIGHT_COVER_TEXT") && (
<>
<div className="absolute bottom-[8%] left-[5%] h-[36%] w-[24%] bg-gray-300" />
<div className="absolute bottom-[26%] left-[34%] h-1 w-[40%] rounded bg-white/80" />
<div className="absolute bottom-[14%] left-[34%] h-0.5 w-[28%] rounded bg-white/50" />
</>
)}
</div> </div>
)} )}
</div> </div>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300"> <p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
</p>
</button> </button>
); );
} }
@@ -191,8 +202,18 @@ function MiniThumb({
function previewCoverStyle( function previewCoverStyle(
template: LayoutTemplate, template: LayoutTemplate,
padPct: number, padPct: number,
textPadding: number,
encodeWidth: number,
): CSSProperties { ): CSSProperties {
const p = `${padPct}%`; const p = `${padPct}%`;
// Equal pixel inset on both axes (not % — % of width ≠ % of height on 16:9).
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
// Assume 16:9 when only width is known; side is usually width-driven for lower corner.
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
switch (template) { switch (template) {
case "COVER_LEFT_TEXT_RIGHT": case "COVER_LEFT_TEXT_RIGHT":
return { return {
@@ -234,6 +255,24 @@ function previewCoverStyle(
maxHeight: "38%", maxHeight: "38%",
objectFit: "contain", objectFit: "contain",
}; };
case "LOWER_LEFT_COVER_TEXT":
return {
position: "absolute",
left: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
case "LOWER_RIGHT_COVER_TEXT":
return {
position: "absolute",
right: cornerEdgePx,
bottom: cornerEdgePx,
width: cornerSidePx,
height: cornerSidePx,
objectFit: "contain",
};
} }
} }
@@ -243,11 +282,20 @@ function previewTextStyle(
textOffsetX: number, textOffsetX: number,
textOffsetY: number, textOffsetY: number,
titleArtistGap: number, titleArtistGap: number,
textPadding: number,
encodeWidth: number,
): CSSProperties { ): CSSProperties {
const p = `${padPct}%`; const p = `${padPct}%`;
const shift = { const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
transform: undefined as string | undefined, const coverTextGapPx = scaleFontToPreview(
}; Math.max(10, Math.round(textPadding * 0.55)),
encodeWidth,
);
const encodeHeight = Math.round((encodeWidth * 9) / 16);
const cornerSidePx = scaleFontToPreview(
lowerCornerCoverSide(encodeWidth, encodeHeight, textPadding),
encodeWidth,
);
const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` }; const baseGap = { display: "flex", flexDirection: "column" as const, gap: `${titleArtistGap}px` };
@@ -294,13 +342,38 @@ function previewTextStyle(
textAlign: "center", textAlign: "center",
alignItems: "center", alignItems: "center",
}; };
case "LOWER_LEFT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: cornerEdgePx + cornerSidePx + coverTextGapPx + textOffsetX,
right: p,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "left",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
case "LOWER_RIGHT_COVER_TEXT":
return {
...baseGap,
position: "absolute",
left: p,
right: cornerEdgePx + cornerSidePx + coverTextGapPx - textOffsetX,
bottom: cornerEdgePx,
height: cornerSidePx,
justifyContent: "center",
textAlign: "right",
alignItems: "flex-end",
transform: textOffsetY ? `translateY(${textOffsetY}px)` : undefined,
};
} }
void shift;
} }
export function LayoutStudio({ export function LayoutStudio({
locked, locked,
previewImageUrl, previewImageUrl,
previewBackgroundUrl = null,
songTitle, songTitle,
artist, artist,
encodeWidth = 1280, encodeWidth = 1280,
@@ -310,24 +383,31 @@ export function LayoutStudio({
onWatermarkChange, onWatermarkChange,
onUploadLogo, onUploadLogo,
onUploadFont, onUploadFont,
onUploadBackground,
onClearBackground,
logoPreviewUrl, logoPreviewUrl,
hasCustomBackground = false,
}: Props) { }: Props) {
const [logoUploading, setLogoUploading] = useState(false); const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState<string | null>(null); const [logoError, setLogoError] = useState<string | null>(null);
const [fontUploading, setFontUploading] = useState(false); const [fontUploading, setFontUploading] = useState(false);
const [fontError, setFontError] = useState<string | null>(null); const [fontError, setFontError] = useState<string | null>(null);
const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null); const [customFontObjectUrl, setCustomFontObjectUrl] = useState<string | null>(null);
const [bgUploading, setBgUploading] = useState(false);
const [bgError, setBgError] = useState<string | null>(null);
// Always coalesce HMR / older session state may omit newly added fields // Always coalesce HMR / older session state may omit newly added fields
const L: LayoutSettings = { const L: LayoutSettings = {
...DEFAULT_LAYOUT, ...DEFAULT_LAYOUT,
...layout, ...layout,
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount, blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT, blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding, textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap, titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX, textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY, textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
}; };
const W: WatermarkSettings = { const W: WatermarkSettings = {
...DEFAULT_WATERMARK, ...DEFAULT_WATERMARK,
@@ -386,13 +466,34 @@ export function LayoutStudio({
el.id = styleId; el.id = styleId;
document.head.appendChild(el); document.head.appendChild(el);
} }
el.textContent = CURATED_FONTS.map( el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 600;
font-display: swap;
}`,
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}?weight=bold') format('truetype');
font-weight: 700;
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face { (f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}'; font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype'); src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-weight: 400;
font-display: swap; font-display: swap;
}`, }`,
).join("\n"); ),
].join("\n");
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -463,6 +564,21 @@ export function LayoutStudio({
} }
} }
async function handleBackground(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || locked || !onUploadBackground) return;
setBgError(null);
setBgUploading(true);
try {
await onUploadBackground(file);
} catch (err) {
setBgError(err instanceof Error ? err.message : "Background upload failed");
} finally {
setBgUploading(false);
}
}
function setFontKey(key: WatermarkFontKey) { function setFontKey(key: WatermarkFontKey) {
if (key === "custom") { if (key === "custom") {
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null }); patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
@@ -472,6 +588,8 @@ export function LayoutStudio({
} }
const artTrack = Boolean(L.template); const artTrack = Boolean(L.template);
const lowerCorner = isLowerCornerTemplate(L.template);
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
return ( return (
<div <div
@@ -495,7 +613,7 @@ export function LayoutStudio({
<div className="absolute inset-0 bg-black" /> <div className="absolute inset-0 bg-black" />
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={previewImageUrl} src={blurPreviewUrl!}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
style={{ style={{
@@ -508,7 +626,12 @@ export function LayoutStudio({
<img <img
src={previewImageUrl} src={previewImageUrl}
alt="" alt=""
style={previewCoverStyle(L.template!, padPct)} style={previewCoverStyle(
L.template!,
padPct,
L.textPadding,
encodeWidth,
)}
className="pointer-events-none" className="pointer-events-none"
/> />
<div <div
@@ -518,12 +641,17 @@ export function LayoutStudio({
L.textOffsetX, L.textOffsetX,
L.textOffsetY, L.textOffsetY,
L.titleArtistGap, L.titleArtistGap,
L.textPadding,
encodeWidth,
)} )}
> >
<p <p
className="max-w-full truncate font-semibold text-white drop-shadow" className={`max-w-full truncate text-white drop-shadow ${
L.titleBold ? "font-semibold" : "font-normal"
}`}
style={{ style={{
...textFontStyle, ...textFontStyle,
fontWeight: L.titleBold ? 600 : 400,
fontSize: `${titlePreviewPx}px`, fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15, lineHeight: 1.15,
}} }}
@@ -543,7 +671,21 @@ export function LayoutStudio({
</div> </div>
</> </>
) : ( ) : (
<div className="absolute inset-0 flex items-center justify-center bg-black"> <div className="absolute inset-0 bg-black">
{L.blurFill && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewImageUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
filter: L.blurAmount > 0 ? `blur(${blurPx}px)` : undefined,
transform: "scale(1.15)",
opacity: L.blurOpacity / 100,
}}
/>
)}
<div className="absolute inset-0 flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={previewImageUrl} src={previewImageUrl}
@@ -551,6 +693,7 @@ export function LayoutStudio({
className="max-h-full max-w-full object-contain" className="max-h-full max-w-full object-contain"
/> />
</div> </div>
</div>
)} )}
{W.mode !== "none" && ( {W.mode !== "none" && (
@@ -595,22 +738,114 @@ export function LayoutStudio({
{/* Art-track templates */} {/* Art-track templates */}
<p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p> <p className="mb-2 mt-5 text-sm font-medium text-gray-300">Composition</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{COMPOSITION_FAMILIES.map((family) => {
const active =
family.id === "classic"
? L.template === null
: family.variants.includes(L.template as LayoutTemplate);
return (
<MiniThumb <MiniThumb
template={null} key={family.id}
active={L.template === null} template={family.thumb}
label={family.label}
active={active}
disabled={locked} disabled={locked}
onClick={() => patchLayout({ template: null })} onClick={() => {
if (family.id === "classic") {
patchLayout({ template: null });
return;
}
if (
L.template &&
family.variants.includes(L.template as LayoutTemplate)
) {
return;
}
patchLayout({ template: family.defaultTemplate, blurFill: false });
}}
/> />
{LAYOUT_TEMPLATES.map((t) => ( );
<MiniThumb })}
key={t} </div>
template={t} {(() => {
active={L.template === t} const family = compositionFamilyForTemplate(L.template);
if (family.variants.length < 2) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{family.variants.map((variant) => (
<button
key={variant}
type="button"
disabled={locked} disabled={locked}
onClick={() => patchLayout({ template: t })} onClick={() => patchLayout({ template: variant })}
/> className={`rounded border px-2.5 py-1.5 text-[11px] font-medium transition-colors ${
L.template === variant
? "border-accent bg-accent/15 text-white"
: "border-gray-700 bg-black/40 text-gray-300 hover:border-gray-500"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{LAYOUT_TEMPLATE_LABELS[variant]}
</button>
))} ))}
</div> </div>
);
})()}
{!artTrack && (
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-gray-300">
<input
type="checkbox"
className="mt-0.5"
disabled={locked}
checked={L.blurFill}
onChange={(e) => patchLayout({ blurFill: e.target.checked })}
/>
<span>
<span className="font-medium text-gray-200">Blurred cover background</span>
<span className="mt-0.5 block text-xs text-gray-500">
Fill letterbox bars with a blurred cover instead of black. Adjust blur and opacity
below when enabled.
</span>
</span>
</label>
)}
{lowerCorner && (
<div className="mt-3 rounded border border-gray-700 bg-black/30 p-3">
<p className="mb-2 text-sm font-medium text-gray-300">Background image</p>
<p className="mb-2 text-xs text-gray-500">
Optional. Used as the blurred full-frame fill; the cover stays the sharp corner square.
Leave empty to blur the cover itself.
</p>
<div className="flex flex-wrap items-center gap-2">
<label
className={`cursor-pointer rounded border border-gray-600 bg-surface-dark px-3 py-1.5 text-xs text-gray-200 hover:border-gray-400 ${
locked || bgUploading ? "pointer-events-none opacity-40" : ""
}`}
>
{bgUploading ? "Uploading…" : hasCustomBackground ? "Replace image" : "Upload image"}
<input
type="file"
accept="image/*"
className="hidden"
disabled={locked || bgUploading || !onUploadBackground}
onChange={handleBackground}
/>
</label>
{hasCustomBackground && (
<button
type="button"
disabled={locked}
onClick={() => onClearBackground?.()}
className="rounded border border-gray-700 px-3 py-1.5 text-xs text-gray-400 hover:border-gray-500 hover:text-gray-200 disabled:opacity-40"
>
Use cover as background
</button>
)}
</div>
{bgError && <p className="mt-2 text-xs text-red-400">{bgError}</p>}
</div>
)}
<div className="mt-4 grid gap-3 sm:grid-cols-2"> <div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="text-sm text-gray-400"> <label className="text-sm text-gray-400">
@@ -619,7 +854,7 @@ export function LayoutStudio({
type="range" type="range"
min={BLUR_AMOUNT_MIN} min={BLUR_AMOUNT_MIN}
max={BLUR_AMOUNT_MAX} max={BLUR_AMOUNT_MAX}
disabled={locked || !artTrack} disabled={locked || !(artTrack || L.blurFill)}
value={L.blurAmount} value={L.blurAmount}
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })} onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40" className="mt-1 w-full disabled:opacity-40"
@@ -631,7 +866,7 @@ export function LayoutStudio({
type="range" type="range"
min={BLUR_OPACITY_MIN} min={BLUR_OPACITY_MIN}
max={BLUR_OPACITY_MAX} max={BLUR_OPACITY_MAX}
disabled={locked || !artTrack} disabled={locked || !(artTrack || L.blurFill)}
value={L.blurOpacity} value={L.blurOpacity}
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })} onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40" className="mt-1 w-full disabled:opacity-40"
@@ -687,6 +922,20 @@ export function LayoutStudio({
className="mt-1 w-full disabled:opacity-40" className="mt-1 w-full disabled:opacity-40"
/> />
</label> </label>
<label
className={`flex items-center gap-2 text-sm text-gray-300 sm:col-span-2 ${
locked || !artTrack ? "opacity-40" : ""
}`}
>
<input
type="checkbox"
className="rounded border-gray-600 bg-surface-dark text-accent focus:ring-accent"
disabled={locked || !artTrack}
checked={L.titleBold}
onChange={(e) => patchLayout({ titleBold: e.target.checked })}
/>
Bold song title
</label>
</div> </div>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */} {/* Typography (matches FFmpeg art-track + watermark text fonts) */}
@@ -700,7 +949,7 @@ export function LayoutStudio({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)} onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1" className="input-field mt-1"
> >
<option value="system">Arial (system default)</option> <option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => ( {CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key}> <option key={f.key} value={f.key}>
{f.label} {f.label}
+30 -1
View File
@@ -8,7 +8,7 @@ import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles"; import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout"; import { SONG_TITLE_MAX } from "@/lib/layout";
import type { ItemMetadata } from "@/lib/types"; import type { ItemMetadata } from "@/lib/types";
import { DEFAULT_LAYOUT, type LayoutSettings } from "@/lib/layout"; import { DEFAULT_LAYOUT, isLowerCornerTemplate, type LayoutSettings } from "@/lib/layout";
import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark"; import { DEFAULT_WATERMARK, type WatermarkSettings } from "@/lib/watermark";
import { CategorySelect } from "./CategorySelect"; import { CategorySelect } from "./CategorySelect";
import { LayoutStudio } from "./LayoutStudio"; import { LayoutStudio } from "./LayoutStudio";
@@ -65,6 +65,8 @@ export function UploadForm() {
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK }); const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT }); const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [backgroundImagePath, setBackgroundImagePath] = useState<string | null>(null);
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = useState<string | null>(null);
const [quota, setQuota] = useState<{ const [quota, setQuota] = useState<{
maxBatchSize: number; maxBatchSize: number;
used: number; used: number;
@@ -89,6 +91,7 @@ export function UploadForm() {
return () => { return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl); if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
audioItems.forEach((a) => { audioItems.forEach((a) => {
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview); if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
}); });
@@ -258,6 +261,21 @@ export function UploadForm() {
return uploaded.path; return uploaded.path;
} }
async function handleBackgroundUpload(file: File) {
const preview = URL.createObjectURL(file);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(preview);
const uploaded = await uploadFile(file, "image");
setBackgroundImagePath(uploaded.path);
return uploaded.path;
}
function clearBackgroundImage() {
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
setBackgroundPreviewUrl(null);
setBackgroundImagePath(null);
}
function applyWatermarkToAll(next: WatermarkSettings) { function applyWatermarkToAll(next: WatermarkSettings) {
const normalized = { ...DEFAULT_WATERMARK, ...next }; const normalized = { ...DEFAULT_WATERMARK, ...next };
setJobWatermark(normalized); setJobWatermark(normalized);
@@ -276,6 +294,9 @@ export function UploadForm() {
function applyLayoutToAll(next: LayoutSettings) { function applyLayoutToAll(next: LayoutSettings) {
const normalized = { ...DEFAULT_LAYOUT, ...next }; const normalized = { ...DEFAULT_LAYOUT, ...next };
setJobLayout(normalized); setJobLayout(normalized);
if (!isLowerCornerTemplate(normalized.template)) {
clearBackgroundImage();
}
setAudioItems((prev) => setAudioItems((prev) =>
prev.map((item) => ({ prev.map((item) => ({
...item, ...item,
@@ -371,6 +392,10 @@ export function UploadForm() {
includeWatermark: effectiveWatermark.mode !== "none", includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark, watermark: effectiveWatermark,
layout: jobLayout, layout: jobLayout,
backgroundImagePath:
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
? backgroundImagePath
: null,
}, },
})), })),
}), }),
@@ -620,6 +645,7 @@ export function UploadForm() {
previewImageUrl={ previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
} }
previewBackgroundUrl={backgroundPreviewUrl}
songTitle={ songTitle={
audioItems[0]?.metadata.songTitle || audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title || audioItems[0]?.metadata.title ||
@@ -633,6 +659,9 @@ export function UploadForm() {
onWatermarkChange={applyWatermarkToAll} onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload} onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload} onUploadFont={handleFontUpload}
onUploadBackground={handleBackgroundUpload}
onClearBackground={clearBackgroundImage}
hasCustomBackground={Boolean(backgroundImagePath)}
logoPreviewUrl={logoPreviewUrl} logoPreviewUrl={logoPreviewUrl}
/> />
+33 -6
View File
@@ -11,9 +11,14 @@ import { getVideoAttributionText } from "@/lib/branding";
import { import {
CURATED_FONTS, CURATED_FONTS,
googleFontsStylesheetUrl, googleFontsStylesheetUrl,
SYSTEM_FONT,
type CuratedFontKey, type CuratedFontKey,
type WatermarkFontKey, type WatermarkFontKey,
} from "@/lib/fonts"; } from "@/lib/fonts";
import {
curatedFontApiUrl,
previewFontFamilyCss,
} from "@/lib/preview-typography";
import { import {
WATERMARK_OFFSET_MAX, WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN, WATERMARK_OFFSET_MIN,
@@ -78,10 +83,7 @@ function previewStyle(
} }
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string { function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif"; return previewFontFamilyCss(fontKey);
if (fontKey === "custom") return `'${CUSTOM_PREVIEW_FAMILY}', sans-serif`;
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'${meta.cssFamily}', sans-serif` : "sans-serif";
} }
export function WatermarkPreview({ export function WatermarkPreview({
@@ -110,7 +112,32 @@ export function WatermarkPreview({
[value.fontKey], [value.fontKey],
); );
// Load curated Google Fonts for live canvas preview // Load bundled + curated fonts for live canvas preview (same files as FFmpeg)
useEffect(() => {
const styleId = "s2vid-watermark-preview-fonts";
let el = document.getElementById(styleId) as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-display: swap;
}`,
...CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-display: swap;
}`,
),
].join("\n");
}, []);
// Legacy Google Fonts link (kept for any older preview paths)
useEffect(() => { useEffect(() => {
const id = "s2vid-watermark-google-fonts"; const id = "s2vid-watermark-google-fonts";
if (document.getElementById(id)) return; if (document.getElementById(id)) return;
@@ -287,7 +314,7 @@ export function WatermarkPreview({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)} onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1" className="input-field mt-1"
> >
<option value="system">System default</option> <option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => ( {CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}> <option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{f.label} {f.label}
+2 -2
View File
@@ -4,8 +4,8 @@ This folder ships with the OSS repo so API reference stays available without the
| Doc | Contents | | Doc | Contents |
|-----|----------| |-----|----------|
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows | | [API overview](./api/overview.md) | Auth, rate limits, recommended flows, discovery (`layoutTemplates`, `compositionFamilies`) |
| [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, playlists, errors | | [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, layouts (incl. lower-corner templates), playlists, errors |
Live docs (same product truth for self-host): [docs.songs2vid.com](https://docs.songs2vid.com) Live docs (same product truth for self-host): [docs.songs2vid.com](https://docs.songs2vid.com)
+30 -3
View File
@@ -112,11 +112,12 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
"creativeCommons": false, "creativeCommons": false,
"includeWatermark": true, "includeWatermark": true,
"layout": { "layout": {
"template": "COVER_LEFT_TEXT_RIGHT", "template": "LOWER_LEFT_COVER_TEXT",
"blurAmount": 60, "blurAmount": 60,
"blurOpacity": 85, "blurOpacity": 85,
"textPadding": 48, "textPadding": 48,
"titleArtistGap": 12, "titleArtistGap": 12,
"titleBold": true,
"textOffsetX": 0, "textOffsetX": 0,
"textOffsetY": 0 "textOffsetY": 0
}, },
@@ -162,6 +163,7 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
| `creativeCommons` | boolean | CC vs standard YouTube license | | `creativeCommons` | boolean | CC vs standard YouTube license |
| `includeWatermark` | boolean | Apply watermark settings | | `includeWatermark` | boolean | Apply watermark settings |
| `imagePath` | string \| null | Per-track cover | | `imagePath` | string \| null | Per-track cover |
| `backgroundImagePath` | string \| null | Lower-corner templates only: separate blur-fill image (else cover is blurred) |
| `playlistId` | string \| null | Existing playlist ID | | `playlistId` | string \| null | Existing playlist ID |
| `layout` | object | Art-track layout | | `layout` | object | Art-track layout |
| `watermark` | object | Watermark settings | | `watermark` | object | Watermark settings |
@@ -216,7 +218,7 @@ Snake_case aliases are accepted for layout/watermark fields.
### Art-track layouts ### Art-track layouts
`metadata.layout.template`: `metadata.layout.template` (or flat `layout_template` / `layoutTemplate`). Valid enums are also listed on `GET /api/v1` as `layoutTemplates`. Mirrored pairs used by the Layout Studio composition grid are listed under `compositionFamilies` (`side`: cover beside text; `lower`: lower corner).
| Enum | Description | | Enum | Description |
|------|-------------| |------|-------------|
@@ -224,8 +226,33 @@ Snake_case aliases are accepted for layout/watermark fields.
| `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below | | `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below |
| `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left | | `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left |
| `CENTERED_COMPACT` | Centered cover + text stack | | `CENTERED_COMPACT` | Centered cover + text stack |
| `LOWER_LEFT_COVER_TEXT` | Lower-left cover; `textPadding` is equal left + bottom inset (diagonal from frame corner) with title/artist to the right |
| `LOWER_RIGHT_COVER_TEXT` | Lower-right cover; `textPadding` is equal right + bottom inset (diagonal from frame corner) with title/artist to the left |
Fine-tuning (defaults in parentheses): `blurAmount` (55), `blurOpacity` (100), `textPadding` (48), `titleArtistGap` (10), `textOffsetX` (0), `textOffsetY` (0). For lower-corner templates only, optional `metadata.backgroundImagePath` (or `background_image_path`) sets a separate full-frame blur fill. Upload with `type=image` first, then pass the returned path. The cover (`imagePath` / per-item `metadata.imagePath`) stays the sharp corner square. Omit the field to blur the cover itself (default). `blurAmount` / `blurOpacity` still apply to whichever image is used as the fill.
Optional fine-tuning (clamped; camelCase or snake_case):
| Field | Range | Default | Purpose |
|-------|-------|---------|---------|
| `blurAmount` / `blur_amount` | 0100 | 55 | Background `boxblur` intensity |
| `blurOpacity` / `blur_opacity` | 0100 | 100 | Blurred fill vs black |
| `blurFill` / `blur_fill` | boolean | `false` | Classic letterbox only: fill bars with blurred cover (ignored for art-track templates) |
| `textPadding` / `text_padding` | 16120 | 48 | Edge inset for cover/text. On lower-corner templates this value is applied equally on both axes (left=bottom or right=bottom) so the cover corner sits on a true diagonal from the frame corner |
| `titleArtistGap` / `title_artist_gap` | 064 | 10 | Space between title and artist |
| `titleBold` / `title_bold` | boolean | `true` | Bold song title (preview + FFmpeg) |
| `textOffsetX` / `text_offset_x` | 120120 | 0 | Shift text block horizontally |
| `textOffsetY` / `text_offset_y` | 120120 | 0 | Shift text block vertically |
Also set `metadata.songTitle` (max 120) and `metadata.artist` (max 80) for the on-video text lines. `metadata.title` remains the YouTube title.
Omit `layout.template` for classic letterbox (black-padded cover by default). Set `layout.blurFill` / `blur_fill` to `true` to fill letterbox bars with a blurred cover; then `blurAmount` / `blurOpacity` apply. Free-form cover coordinates (`x`, `y`, `coverX`, …) and layout-level `offsetX`/`offsetY` are **rejected**.
Invalid template strings return **400**:
```json
{ "error": "Invalid layout template. Refer to API documentation for valid enum values." }
```
## YouTube playlists ## YouTube playlists
+1 -1
View File
@@ -59,7 +59,7 @@ Poll with `GET /api/v1/jobs/:id`.
GET /api/v1 GET /api/v1
``` ```
Returns the endpoint list and requirements (no auth). Useful as a machine-readable catalog of what this instance exposes. Returns the endpoint list, requirements, `layoutTemplates` (every art-track enum id + label, including `LOWER_LEFT_COVER_TEXT` / `LOWER_RIGHT_COVER_TEXT`), and `compositionFamilies` (UI grouping for mirrored left/right variants). No auth required.
## Next ## Next
+87 -12
View File
@@ -8,9 +8,12 @@ import {
isCuratedFontKey, isCuratedFontKey,
sanitizeFontfileForFilter, sanitizeFontfileForFilter,
} from "../fonts"; } from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server"; import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import { import {
buildArtTrackFilterComplex, buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
isLowerCornerTemplate,
normalizeLayoutSettings,
type LayoutSettings, type LayoutSettings,
} from "../layout"; } from "../layout";
import { import {
@@ -61,9 +64,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
async function resolveFontfileEscaped( async function resolveFontfileEscaped(
settings: WatermarkSettings, settings: WatermarkSettings,
weight: "regular" | "bold" = "regular",
): Promise<string | null> { ): Promise<string | null> {
const key = settings.fontKey ?? "system"; 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 (key === "custom") {
if (!settings.fontPath) return null; if (!settings.fontPath) return null;
@@ -74,12 +94,19 @@ async function resolveFontfileEscaped(
} }
if (isCuratedFontKey(key)) { if (isCuratedFontKey(key)) {
const fontPath = resolveCuratedFontPath(key); const preferred = resolveCuratedFontPath(key, weight);
if (!(await fileExists(fontPath))) { const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`); for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
return null; if (!(await fileExists(fontPath))) continue;
} try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath); return sanitizeFontfileForFilter(fontPath);
} catch {
/* try next */
}
}
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
return null;
} }
return null; return null;
@@ -99,6 +126,13 @@ function artTrackFilterEndingAt(
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`); 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: { export async function encodeVideo(options: {
imagePath: string; imagePath: string;
audioPath: string; audioPath: string;
@@ -110,6 +144,11 @@ export async function encodeVideo(options: {
/** On-video song title (art-track layouts). */ /** On-video song title (art-track layouts). */
songTitle?: string; songTitle?: string;
artist?: string | null; 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> { }): Promise<void> {
const res = getResolution(options.resolution); const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${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 }); await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark); 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 watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width); 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]; const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2; let nextInput = 2;
let separateBackgroundInputIndex: number | null = null;
let logoInputIndex: number | null = null; let logoInputIndex: number | null = null;
let defaultWmInputIndex: 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 needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath(); const defaultPath = getWatermarkPath();
const useDefaultPng = const useDefaultPng =
@@ -156,8 +217,8 @@ export async function encodeVideo(options: {
? ("default-text" as const) ? ("default-text" as const)
: ("none" as const); : ("none" as const);
// Fast path: classic letterbox, no watermark // Fast path: classic letterbox (black bars), no watermark, no blur fill
if (!layout && applyWm === "none") { if (!layout && !classicBlurFill && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height)); args.push("-vf", classicScaleFilter(res.width, res.height));
args.push( args.push(
"-c:v", "-c:v",
@@ -192,6 +253,20 @@ export async function encodeVideo(options: {
titleEscaped, titleEscaped,
artistEscaped, artistEscaped,
fontfileEscaped: fontfile, 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, baseLabel,
), ),
@@ -235,7 +310,7 @@ export async function encodeVideo(options: {
position: settings.position, position: settings.position,
offsetX: settings.offsetX, offsetX: settings.offsetX,
offsetY: settings.offsetY, offsetY: settings.offsetY,
fontfileEscaped: null, fontfileEscaped: fontfile,
}); });
filterParts.push(`[${baseLabel}]${draw}[vout]`); filterParts.push(`[${baseLabel}]${draw}[vout]`);
} }
+17 -4
View File
@@ -2,19 +2,32 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { CURATED_FONTS, type CuratedFontKey } from "./fonts"; import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
export function getFontsDir(): string { export function getFontsDir(): string {
return path.join(process.cwd(), "assets", "fonts"); 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). */ /** 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); const meta = CURATED_FONTS.find((f) => f.key === key);
if (!meta) throw new Error("Unknown font"); 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 // Whitelist filename only never accept user-controlled path segments
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, ""); const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== meta.file) throw new Error("Invalid font asset name"); if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe); return path.join(getFontsDir(), safe);
} }
+14
View File
@@ -3,6 +3,15 @@
/** Max custom font upload size (10 MB). */ /** Max custom font upload size (10 MB). */
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; 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 = [ export const CURATED_FONTS = [
{ {
key: "inter", key: "inter",
@@ -10,6 +19,7 @@ export const CURATED_FONTS = [
cssFamily: "Inter", cssFamily: "Inter",
googleCss: "Inter:wght@400;600", googleCss: "Inter:wght@400;600",
file: "Inter-Regular.ttf", file: "Inter-Regular.ttf",
boldFile: null as string | null,
}, },
{ {
key: "montserrat", key: "montserrat",
@@ -17,6 +27,7 @@ export const CURATED_FONTS = [
cssFamily: "Montserrat", cssFamily: "Montserrat",
googleCss: "Montserrat:wght@400;600", googleCss: "Montserrat:wght@400;600",
file: "Montserrat-Regular.ttf", file: "Montserrat-Regular.ttf",
boldFile: null as string | null,
}, },
{ {
key: "roboto", key: "roboto",
@@ -24,6 +35,7 @@ export const CURATED_FONTS = [
cssFamily: "Roboto", cssFamily: "Roboto",
googleCss: "Roboto:wght@400;500", googleCss: "Roboto:wght@400;500",
file: "Roboto-Regular.ttf", file: "Roboto-Regular.ttf",
boldFile: null as string | null,
}, },
{ {
key: "oswald", key: "oswald",
@@ -31,6 +43,7 @@ export const CURATED_FONTS = [
cssFamily: "Oswald", cssFamily: "Oswald",
googleCss: "Oswald:wght@400;500", googleCss: "Oswald:wght@400;500",
file: "Oswald-Regular.ttf", file: "Oswald-Regular.ttf",
boldFile: null as string | null,
}, },
{ {
key: "playfair", key: "playfair",
@@ -38,6 +51,7 @@ export const CURATED_FONTS = [
cssFamily: "Playfair Display", cssFamily: "Playfair Display",
googleCss: "Playfair+Display:wght@400;600", googleCss: "Playfair+Display:wght@400;600",
file: "PlayfairDisplay-Regular.ttf", file: "PlayfairDisplay-Regular.ttf",
boldFile: null as string | null,
}, },
] as const; ] as const;
+52 -2
View File
@@ -16,6 +16,7 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
import { import {
ARTIST_MAX, ARTIST_MAX,
INVALID_LAYOUT_TEMPLATE_MESSAGE, INVALID_LAYOUT_TEMPLATE_MESSAGE,
isLowerCornerTemplate,
normalizeLayoutSettings, normalizeLayoutSettings,
SONG_TITLE_MAX, SONG_TITLE_MAX,
type LayoutSettings, type LayoutSettings,
@@ -56,6 +57,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
metadata.layout?.blur_amount ?? metadata.layout?.blur_amount ??
metadata.blurAmount ?? metadata.blurAmount ??
metadata.blur_amount, metadata.blur_amount,
blurFill:
metadata.layout?.blurFill ??
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
metadata.blurFill ??
metadata.blur_fill,
blurOpacity: blurOpacity:
metadata.layout?.blurOpacity ?? metadata.layout?.blurOpacity ??
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ?? (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.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
metadata.textOffsetY ?? metadata.textOffsetY ??
metadata.text_offset_y, 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( const wm = normalizeWatermarkSettings(
item.metadata.watermark, item.metadata.watermark,
item.metadata.includeWatermark, item.metadata.includeWatermark,
@@ -242,12 +265,21 @@ export async function createVideoJob(
} }
const imagePath = assertPathInUserUploads(user.id, body.imagePath); const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({ 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, ...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath), audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath) ? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null, : null,
backgroundImagePath,
watermarkLogoPath: item.metadata.watermark?.logoPath watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath) ? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null, : null,
@@ -255,7 +287,8 @@ export async function createVideoJob(
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath) ? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
: null, : null,
})); };
});
await reserveQuota(user.id, items.length); await reserveQuota(user.id, items.length);
@@ -298,6 +331,7 @@ export async function createVideoJob(
creativeCommons: item.metadata.creativeCommons, creativeCommons: item.metadata.creativeCommons,
includeWatermark: wm.mode !== "none", includeWatermark: wm.mode !== "none",
itemImagePath: item.itemImagePath, itemImagePath: item.itemImagePath,
backgroundImagePath: item.backgroundImagePath,
watermarkMode: wm.mode, watermarkMode: wm.mode,
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null, watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null, watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
@@ -309,10 +343,12 @@ export async function createVideoJob(
watermarkOffsetY: wm.offsetY, watermarkOffsetY: wm.offsetY,
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null, artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
layoutTemplate: layout.template, layoutTemplate: layout.template,
blurFill: layout.blurFill,
blurAmount: layout.blurAmount, blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity, blurOpacity: layout.blurOpacity,
textPadding: layout.textPadding, textPadding: layout.textPadding,
titleArtistGap: layout.titleArtistGap, titleArtistGap: layout.titleArtistGap,
titleBold: layout.titleBold,
textOffsetX: layout.textOffsetX, textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY, textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null, 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; let newLogo: string | null = null;
if (item.watermarkLogoPath) { if (item.watermarkLogoPath) {
const src = item.watermarkLogoPath; const src = item.watermarkLogoPath;
@@ -383,6 +432,7 @@ export async function createVideoJob(
data: { data: {
audioPath: newAudioPath, audioPath: newAudioPath,
itemImagePath: newItemImage, itemImagePath: newItemImage,
backgroundImagePath: newBackgroundImage,
watermarkLogoPath: newLogo, watermarkLogoPath: newLogo,
watermarkFontPath: newFont, watermarkFontPath: newFont,
}, },
+246 -11
View File
@@ -8,6 +8,8 @@ export const LAYOUT_TEMPLATES = [
"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_RIGHT_COVER_TEXT",
] as const; ] as const;
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number]; 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_MAX = 120;
export const TEXT_OFFSET_DEFAULT = 0; 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 ARTIST_MAX = 80;
export const SONG_TITLE_MAX = 120; export const SONG_TITLE_MAX = 120;
export type LayoutSettings = { export type LayoutSettings = {
/** When null, classic letterbox (no art-track layout / blur fill). */ /** When null, classic letterbox (no art-track layout). */
template: LayoutTemplate | null; 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. */ /** 0100 → FFmpeg boxblur intensity. */
blurAmount: number; blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */ /** 0100 → how visible the blurred fill is (vs black). */
@@ -55,16 +76,20 @@ export type LayoutSettings = {
textOffsetX: number; textOffsetX: number;
/** Shift text block vertically within the template. */ /** Shift text block vertically within the template. */
textOffsetY: number; textOffsetY: number;
/** Bold weight for the on-video song title (artist stays regular). */
titleBold: boolean;
}; };
export const DEFAULT_LAYOUT: LayoutSettings = { export const DEFAULT_LAYOUT: LayoutSettings = {
template: null, template: null,
blurFill: false,
blurAmount: BLUR_AMOUNT_DEFAULT, blurAmount: BLUR_AMOUNT_DEFAULT,
blurOpacity: BLUR_OPACITY_DEFAULT, blurOpacity: BLUR_OPACITY_DEFAULT,
textPadding: TEXT_PADDING_DEFAULT, textPadding: TEXT_PADDING_DEFAULT,
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT, titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: TEXT_OFFSET_DEFAULT, textOffsetX: TEXT_OFFSET_DEFAULT,
textOffsetY: TEXT_OFFSET_DEFAULT, textOffsetY: TEXT_OFFSET_DEFAULT,
titleBold: true,
}; };
export function isLayoutTemplate(v: unknown): v is LayoutTemplate { export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
@@ -119,6 +144,8 @@ type RawLayoutInput = {
template?: unknown; template?: unknown;
layoutTemplate?: unknown; layoutTemplate?: unknown;
layout_template?: unknown; layout_template?: unknown;
blurFill?: unknown;
blur_fill?: unknown;
blurAmount?: unknown; blurAmount?: unknown;
blur_amount?: unknown; blur_amount?: unknown;
blurOpacity?: unknown; blurOpacity?: unknown;
@@ -131,6 +158,8 @@ type RawLayoutInput = {
text_offset_x?: unknown; text_offset_x?: unknown;
textOffsetY?: unknown; textOffsetY?: unknown;
text_offset_y?: unknown; text_offset_y?: unknown;
titleBold?: unknown;
title_bold?: unknown;
/** Rejected clients must not send free-form cover coordinates. */ /** Rejected clients must not send free-form cover coordinates. */
x?: unknown; x?: unknown;
y?: unknown; y?: unknown;
@@ -189,15 +218,25 @@ export function normalizeLayoutSettings(
(input as RawLayoutInput).textOffsetY ?? (input as RawLayoutInput).textOffsetY ??
(input as RawLayoutInput).text_offset_y ?? (input as RawLayoutInput).text_offset_y ??
(input as LayoutSettings).textOffsetY; (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 { return {
template, template,
blurFill: template !== null ? false : Boolean(blurFillRaw),
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT), blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT), blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT), textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT), titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT), textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
textOffsetY: clampTextOffset(oyRaw, 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; 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: { default: {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE); throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
} }
@@ -350,13 +437,27 @@ export function computeLayoutGeometry(
return applyTextOffsets(base, textOffsetX, textOffsetY); 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: { export function buildArtTrackFilterComplex(opts: {
width: number; width: number;
height: number; height: number;
layout: LayoutSettings; layout: LayoutSettings;
titleEscaped: string; titleEscaped: string;
artistEscaped: string | null; artistEscaped: string | null;
/** Regular-weight font for artist (and title when not bold / no bold file). */
fontfileEscaped?: string | null; 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 { }): string {
const { width: W, height: H, layout } = opts; const { width: W, height: H, layout } = opts;
if (!layout.template) { if (!layout.template) {
@@ -378,15 +479,84 @@ export function buildArtTrackFilterComplex(opts: {
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`; : `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100; const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : ""; const artistFontPart = 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 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 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% // 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) { if (opacity >= 0.999) {
parts.push(`[bg]${bgChain}[blurred]`); parts.push(`[bg]${bgChain}[blurred]`);
} else if (opacity <= 0.001) { } else if (opacity <= 0.001) {
@@ -401,12 +571,7 @@ export function buildArtTrackFilterComplex(opts: {
); );
} }
parts.push( parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
return parts.join(";"); return parts.join(";");
} }
@@ -415,4 +580,74 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom", COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
COVER_RIGHT_TEXT_LEFT: "Cover right · text left", COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
CENTERED_COMPACT: "Centered compact", 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 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). */ /** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
export const PREVIEW_REFERENCE_WIDTH = 576; 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 { export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") { if (!fontKey || fontKey === "system") {
return "Arial, Helvetica, sans-serif"; return `'${SYSTEM_FONT.previewFamily}', sans-serif`;
} }
if (fontKey === "custom") { if (fontKey === "custom") {
return "'S2VIDCustomWm', Arial, sans-serif"; return "'S2VIDCustomWm', sans-serif";
} }
const meta = CURATED_FONTS.find((f) => f.key === fontKey); 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 { 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. * When omitted, the job-level shared imagePath is used.
*/ */
imagePath?: string | null; 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. */ /** Custom branding watermark settings. */
watermark?: Partial<WatermarkSettings> | null; watermark?: Partial<WatermarkSettings> | null;
/** Artist line for art-track layouts (on-video only). */ /** 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: * Art-track layout. Prefer camelCase; snake_case aliases accepted:
* layout_template, blur_amount, text_padding. * 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> & { layout?: Partial<LayoutSettings> & {
layoutTemplate?: string | null; layoutTemplate?: string | null;
layout_template?: string | null; layout_template?: string | null;
blur_fill?: boolean;
blur_amount?: number; blur_amount?: number;
blur_opacity?: number; blur_opacity?: number;
text_padding?: number; text_padding?: number;
title_artist_gap?: number; title_artist_gap?: number;
text_offset_x?: number; text_offset_x?: number;
text_offset_y?: number; text_offset_y?: number;
title_bold?: boolean;
} | null; } | null;
/** Flat aliases (also accepted). */ /** Flat aliases (also accepted). */
layoutTemplate?: string | null; layoutTemplate?: string | null;
layout_template?: string | null; layout_template?: string | null;
blurFill?: boolean;
blur_fill?: boolean;
blurAmount?: number; blurAmount?: number;
blur_amount?: number; blur_amount?: number;
blurOpacity?: number; blurOpacity?: number;
@@ -57,6 +70,8 @@ export type ItemMetadata = {
text_offset_x?: number; text_offset_x?: number;
textOffsetY?: number; textOffsetY?: number;
text_offset_y?: number; text_offset_y?: number;
titleBold?: boolean;
title_bold?: boolean;
}; };
export type UploadedAudio = { export type UploadedAudio = {
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "backgroundImagePath" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "JobItem" ADD COLUMN "blurFill" BOOLEAN NOT NULL DEFAULT false;
+5
View File
@@ -82,6 +82,8 @@ model JobItem {
creativeCommons Boolean @default(false) creativeCommons Boolean @default(false)
includeWatermark Boolean @default(false) includeWatermark Boolean @default(false)
itemImagePath String? itemImagePath String?
/// Optional full-frame background for lower-corner layouts (blur fill). Cover stays foreground.
backgroundImagePath String?
watermarkMode String @default("none") watermarkMode String @default("none")
watermarkText String? watermarkText String?
watermarkLogoPath String? watermarkLogoPath String?
@@ -93,10 +95,13 @@ model JobItem {
artist String? artist String?
songTitle String? songTitle String?
layoutTemplate String? layoutTemplate String?
/// Classic letterbox: fill black bars with blurred cover
blurFill Boolean @default(false)
blurAmount Int @default(55) blurAmount Int @default(55)
blurOpacity Int @default(100) blurOpacity Int @default(100)
textPadding Int @default(48) textPadding Int @default(48)
titleArtistGap Int @default(10) titleArtistGap Int @default(10)
titleBold Boolean @default(true)
textOffsetX Int @default(0) textOffsetX Int @default(0)
textOffsetY Int @default(0) textOffsetY Int @default(0)
playlistId String? playlistId String?
+25 -2
View File
@@ -9,8 +9,17 @@ import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, "..", "assets", "fonts"); const outDir = path.join(__dirname, "..", "assets", "fonts");
/** Direct TTF URLs from Google Fonts GitHub (OFL). */ /** Direct TTF URLs from Google Fonts GitHub (OFL / Apache 2.0). */
const FONTS = [ const FONTS = [
{
file: "Arimo-Regular.ttf",
// Static TTF from googlefonts/arimo (not the broken google/fonts apache/static path)
url: "https://cdn.jsdelivr.net/gh/googlefonts/arimo@main/fonts/ttf/Arimo-Regular.ttf",
},
{
file: "Arimo-Bold.ttf",
url: "https://cdn.jsdelivr.net/gh/googlefonts/arimo@main/fonts/ttf/Arimo-Bold.ttf",
},
{ {
file: "Inter-Regular.ttf", file: "Inter-Regular.ttf",
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf", url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz%2Cwght%5D.ttf",
@@ -35,12 +44,22 @@ const FONTS = [
await fs.mkdir(outDir, { recursive: true }); await fs.mkdir(outDir, { recursive: true });
function looksLikeFont(buf) {
if (buf.length < 4) return false;
const asStr = buf.subarray(0, 4).toString("ascii");
if (asStr === "OTTO" || asStr === "true" || asStr === "typ1") return true;
return buf[0] === 0x00 && buf[1] === 0x01 && buf[2] === 0x00 && buf[3] === 0x00;
}
for (const font of FONTS) { for (const font of FONTS) {
const dest = path.join(outDir, font.file); const dest = path.join(outDir, font.file);
try { try {
await fs.access(dest); const existing = await fs.readFile(dest);
if (looksLikeFont(existing)) {
console.log("skip (exists)", font.file); console.log("skip (exists)", font.file);
continue; continue;
}
console.warn("corrupt font, re-fetching", font.file);
} catch { } catch {
/* download */ /* download */
} }
@@ -54,6 +73,10 @@ for (const font of FONTS) {
continue; continue;
} }
const buf = Buffer.from(await res.arrayBuffer()); const buf = Buffer.from(await res.arrayBuffer());
if (!looksLikeFont(buf)) {
console.error("FAILED invalid font bytes", font.file, buf.subarray(0, 8).toString("hex"));
continue;
}
await fs.writeFile(dest, buf); await fs.writeFile(dest, buf);
console.log("wrote", font.file, buf.length, "bytes"); console.log("wrote", font.file, buf.length, "bytes");
} }
+57
View File
@@ -0,0 +1,57 @@
/**
* Build assets/watermark.png for the default Songs2VID badge overlay.
* Uses bundled Arimo + FFmpeg (same toolchain as video encode).
*
* Run: node scripts/generate-watermark-png.mjs
*/
import { spawnSync } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import ffmpegStatic from "ffmpeg-static";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, "..");
const fontPath = path.join(root, "assets", "fonts", "Arimo-Regular.ttf");
const outPath = path.join(root, "assets", "watermark.png");
function ffmpegPath() {
return process.env.FFMPEG_PATH || ffmpegStatic || "ffmpeg";
}
function escFont(p) {
return p.replace(/\\/g, "/").replace(/:/g, "\\:");
}
if (!fs.existsSync(fontPath)) {
console.error("Missing font:", fontPath, "— run node scripts/fetch-watermark-fonts.mjs first");
process.exit(1);
}
const font = escFont(fontPath);
const w = 960;
const h = 88;
const filter = [
`[0:v]format=rgba,drawbox=x=0:y=0:w=iw:h=ih:color=0x000000@0.45:t=fill`,
`drawtext=fontfile='${font}':text='Uploaded through Songs2VID.com':fontsize=32:fontcolor=white@0.92:x=(w-text_w)/2:y=(h-th)/2`,
].join(",");
const args = [
"-y",
"-f",
"lavfi",
"-i",
`color=c=black@0.0:s=${w}x${h}`,
"-vf",
filter,
"-update",
"1",
outPath,
];
const res = spawnSync(ffmpegPath(), args, { stdio: "inherit" });
if (res.status !== 0) {
process.exit(res.status ?? 1);
}
console.log("wrote", outPath, fs.statSync(outPath).size, "bytes");
+106
View File
@@ -5,6 +5,7 @@ import {
blurToBoxblur, blurToBoxblur,
boxblurFilterSegment, boxblurFilterSegment,
buildArtTrackFilterComplex, buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
clampBlurAmount, clampBlurAmount,
clampTextPadding, clampTextPadding,
clampTitleArtistGap, clampTitleArtistGap,
@@ -32,8 +33,22 @@ function testEnumsAndClamp() {
function testNormalize() { function testNormalize() {
const classic = normalizeLayoutSettings(null); const classic = normalizeLayoutSettings(null);
assert.equal(classic.template, null); assert.equal(classic.template, null);
assert.equal(classic.blurFill, false);
assert.equal(classic.titleArtistGap, 10); assert.equal(classic.titleArtistGap, 10);
assert.equal(classic.textOffsetX, 0); assert.equal(classic.textOffsetX, 0);
assert.equal(classic.titleBold, true);
assert.equal(
normalizeLayoutSettings({ blur_fill: true }).blurFill,
true,
);
assert.equal(
normalizeLayoutSettings({
template: "CENTERED_COMPACT",
blurFill: true,
}).blurFill,
false,
);
assert.equal( assert.equal(
normalizeLayoutSettings({ normalizeLayoutSettings({
@@ -75,26 +90,117 @@ function testGeometryAndFilter() {
const tight = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 0, 0, 0); const tight = computeLayoutGeometry("COVER_LEFT_TEXT_RIGHT", 1280, 720, 40, 0, 0, 0);
assert.ok(Number(withGap.artistY) - Number(withGap.titleY) > Number(tight.artistY) - Number(tight.titleY)); assert.ok(Number(withGap.artistY) - Number(withGap.titleY) > Number(tight.artistY) - Number(tight.titleY));
const lowerLeft = computeLayoutGeometry("LOWER_LEFT_COVER_TEXT", 1280, 720, 48, 10, 0, 0);
assert.equal(lowerLeft.coverX, "48");
assert.equal(lowerLeft.coverY, "H-h-48");
assert.ok(Number(lowerLeft.titleX) > Number(lowerLeft.coverX));
assert.ok(Number(lowerLeft.titleY) > 300, "title should sit in the lower half with the cover");
const lowerRight = computeLayoutGeometry("LOWER_RIGHT_COVER_TEXT", 1280, 720, 48, 10, 0, 0);
assert.equal(lowerRight.coverX, "W-w-48");
assert.equal(lowerRight.coverY, "H-h-48");
assert.ok(String(lowerRight.titleX).startsWith("W-text_w-"));
const fc = buildArtTrackFilterComplex({ const fc = buildArtTrackFilterComplex({
width: 1280, width: 1280,
height: 720, height: 720,
layout: { layout: {
template: "CENTERED_COMPACT", template: "CENTERED_COMPACT",
blurFill: false,
blurAmount: 40, blurAmount: 40,
blurOpacity: 70, blurOpacity: 70,
textPadding: 40, textPadding: 40,
titleArtistGap: 18, titleArtistGap: 18,
textOffsetX: 5, textOffsetX: 5,
textOffsetY: -8, textOffsetY: -8,
titleBold: true,
}, },
titleEscaped: sanitizeDrawtext("Hello:World"), titleEscaped: sanitizeDrawtext("Hello:World"),
artistEscaped: sanitizeDrawtext("Artist"), artistEscaped: sanitizeDrawtext("Artist"),
fontfileEscaped: null,
titleFontfileEscaped: "C\\:/fonts/Arimo-Bold.ttf",
}); });
assert.ok(fc.includes("split=2")); assert.ok(fc.includes("split=2"));
assert.ok(fc.includes("blend=") || fc.includes("[blurred]")); assert.ok(fc.includes("blend=") || fc.includes("[blurred]"));
assert.ok(fc.includes("overlay=")); assert.ok(fc.includes("overlay="));
assert.ok(fc.includes("drawtext=")); assert.ok(fc.includes("drawtext="));
assert.ok(fc.includes("fontfile='C\\:/fonts/Arimo-Bold.ttf'"));
assert.ok(fc.endsWith("[laid]")); assert.ok(fc.endsWith("[laid]"));
const regular = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "CENTERED_COMPACT",
blurFill: false,
blurAmount: 40,
blurOpacity: 70,
textPadding: 40,
titleArtistGap: 18,
textOffsetX: 0,
textOffsetY: 0,
titleBold: false,
},
titleEscaped: "Hi",
artistEscaped: null,
fontfileEscaped: "C\\:/fonts/Arimo-Regular.ttf",
});
assert.ok(regular.includes("fontfile='C\\:/fonts/Arimo-Regular.ttf'"));
assert.equal(regular.includes("borderw="), false);
const withSeparateBg = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "LOWER_LEFT_COVER_TEXT",
blurFill: false,
blurAmount: 40,
blurOpacity: 100,
textPadding: 48,
titleArtistGap: 10,
textOffsetX: 0,
textOffsetY: 0,
titleBold: true,
},
titleEscaped: "Song",
artistEscaped: "Artist",
separateBackgroundInputIndex: 2,
});
assert.equal(withSeparateBg.includes("[0:v]split=2"), false);
assert.ok(withSeparateBg.includes("[2:v]"));
assert.ok(withSeparateBg.includes("[0:v]scale="));
assert.ok(withSeparateBg.includes("overlay="));
const withoutSeparateBg = buildArtTrackFilterComplex({
width: 1280,
height: 720,
layout: {
template: "LOWER_LEFT_COVER_TEXT",
blurFill: false,
blurAmount: 40,
blurOpacity: 100,
textPadding: 48,
titleArtistGap: 10,
textOffsetX: 0,
textOffsetY: 0,
titleBold: true,
},
titleEscaped: "Song",
artistEscaped: null,
});
assert.ok(withoutSeparateBg.includes("[0:v]split=2"));
const classicBlur = buildClassicBlurFillFilterComplex({
width: 1280,
height: 720,
blurAmount: 55,
blurOpacity: 100,
});
assert.ok(classicBlur.includes("[0:v]split=2"));
assert.ok(classicBlur.includes("overlay=(W-w)/2:(H-h)/2"));
assert.equal(classicBlur.includes("pad="), false);
assert.equal(classicBlur.includes("drawtext="), false);
assert.ok(classicBlur.endsWith("[laid]"));
} }
testEnumsAndClamp(); testEnumsAndClamp();
+10 -6
View File
@@ -70,21 +70,25 @@ async function processJobItem(data: VideoJobData) {
includeWatermark: item.includeWatermark, includeWatermark: item.includeWatermark,
songTitle: item.songTitle || item.title, songTitle: item.songTitle || item.title,
artist: item.artist, artist: item.artist,
layout: item.layoutTemplate layout: {
? { template: (item.layoutTemplate as
template: item.layoutTemplate as
| "COVER_LEFT_TEXT_RIGHT" | "COVER_LEFT_TEXT_RIGHT"
| "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_RIGHT_COVER_TEXT"
| null) ?? null,
blurFill: item.blurFill ?? false,
blurAmount: item.blurAmount ?? 55, blurAmount: item.blurAmount ?? 55,
blurOpacity: item.blurOpacity ?? 100, blurOpacity: item.blurOpacity ?? 100,
textPadding: item.textPadding ?? 48, textPadding: item.textPadding ?? 48,
titleArtistGap: item.titleArtistGap ?? 10, titleArtistGap: item.titleArtistGap ?? 10,
titleBold: item.titleBold ?? true,
textOffsetX: item.textOffsetX ?? 0, textOffsetX: item.textOffsetX ?? 0,
textOffsetY: item.textOffsetY ?? 0, textOffsetY: item.textOffsetY ?? 0,
} },
: null, backgroundImagePath: item.backgroundImagePath,
watermark: { watermark: {
mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default", mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default",
text: item.watermarkText, text: item.watermarkText,