Initial OSS scaffold from Songs2VID (pre-strip)

This commit is contained in:
Songs2VID Support
2026-08-03 06:15:05 +02:00
commit 506d56fa92
195 changed files with 25023 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
/**
* Art-track layout templates + blur background (Pro).
* Cover/text anchors come from enum templates; only clamped fine-tuning offsets are allowed.
*/
export const LAYOUT_TEMPLATES = [
"COVER_LEFT_TEXT_RIGHT",
"COVER_TOP_TEXT_BOTTOM",
"COVER_RIGHT_TEXT_LEFT",
"CENTERED_COMPACT",
] as const;
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
export const INVALID_LAYOUT_TEMPLATE_MESSAGE =
"Invalid layout template. Refer to API documentation for valid enum values.";
export const BLUR_AMOUNT_MIN = 0;
export const BLUR_AMOUNT_MAX = 100;
export const BLUR_AMOUNT_DEFAULT = 55;
/** Visibility of the blurred cover fill over black (0 = solid black, 100 = full blur). */
export const BLUR_OPACITY_MIN = 0;
export const BLUR_OPACITY_MAX = 100;
export const BLUR_OPACITY_DEFAULT = 100;
export const TEXT_PADDING_MIN = 16;
export const TEXT_PADDING_MAX = 120;
export const TEXT_PADDING_DEFAULT = 48;
export const TITLE_ARTIST_GAP_MIN = 0;
export const TITLE_ARTIST_GAP_MAX = 64;
export const TITLE_ARTIST_GAP_DEFAULT = 10;
/** Fine-tune title/artist block within the template (not free-form canvas coords). */
export const TEXT_OFFSET_MIN = -120;
export const TEXT_OFFSET_MAX = 120;
export const TEXT_OFFSET_DEFAULT = 0;
export const ARTIST_MAX = 80;
export const SONG_TITLE_MAX = 120;
export type LayoutSettings = {
/** When null, classic letterbox (no art-track layout / blur fill). */
template: LayoutTemplate | null;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */
blurOpacity: number;
/** Pixel padding / gap around cover + text. */
textPadding: number;
/** Extra pixels between title and artist lines. */
titleArtistGap: number;
/** Shift text block horizontally within the template. */
textOffsetX: number;
/** Shift text block vertically within the template. */
textOffsetY: number;
};
export const DEFAULT_LAYOUT: LayoutSettings = {
template: null,
blurAmount: BLUR_AMOUNT_DEFAULT,
blurOpacity: BLUR_OPACITY_DEFAULT,
textPadding: TEXT_PADDING_DEFAULT,
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: TEXT_OFFSET_DEFAULT,
textOffsetY: TEXT_OFFSET_DEFAULT,
};
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
return typeof v === "string" && (LAYOUT_TEMPLATES as readonly string[]).includes(v);
}
export function clampBlurAmount(n: unknown, fallback = BLUR_AMOUNT_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(BLUR_AMOUNT_MIN, Math.min(BLUR_AMOUNT_MAX, Math.round(v)));
}
export function clampBlurOpacity(n: unknown, fallback = BLUR_OPACITY_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(BLUR_OPACITY_MIN, Math.min(BLUR_OPACITY_MAX, Math.round(v)));
}
export function clampTextPadding(n: unknown, fallback = TEXT_PADDING_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(TEXT_PADDING_MIN, Math.min(TEXT_PADDING_MAX, Math.round(v)));
}
export function clampTitleArtistGap(n: unknown, fallback = TITLE_ARTIST_GAP_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(TITLE_ARTIST_GAP_MIN, Math.min(TITLE_ARTIST_GAP_MAX, Math.round(v)));
}
export function clampTextOffset(n: unknown, fallback = TEXT_OFFSET_DEFAULT): number {
const v = typeof n === "number" ? n : Number(n);
if (!Number.isFinite(v)) return fallback;
return Math.max(TEXT_OFFSET_MIN, Math.min(TEXT_OFFSET_MAX, Math.round(v)));
}
export function blurToBoxblur(blurAmount: number): { radius: number; power: number } | null {
const amount = clampBlurAmount(blurAmount, 0);
if (amount <= 0) return null;
const radius = Math.max(1, Math.round((amount / 100) * 50));
const power = Math.max(1, Math.min(4, Math.ceil(amount / 25)));
return { radius, power };
}
export function boxblurFilterSegment(blurAmount: number): string {
const bb = blurToBoxblur(blurAmount);
if (!bb) return "";
return `boxblur=luma_radius=${bb.radius}:luma_power=${bb.power}:chroma_radius=${bb.radius}:chroma_power=${bb.power}`;
}
type RawLayoutInput = {
template?: unknown;
layoutTemplate?: unknown;
layout_template?: unknown;
blurAmount?: unknown;
blur_amount?: unknown;
blurOpacity?: unknown;
blur_opacity?: unknown;
textPadding?: unknown;
text_padding?: unknown;
titleArtistGap?: unknown;
title_artist_gap?: unknown;
textOffsetX?: unknown;
text_offset_x?: unknown;
textOffsetY?: unknown;
text_offset_y?: unknown;
/** Rejected clients must not send free-form cover coordinates. */
x?: unknown;
y?: unknown;
coverX?: unknown;
coverY?: unknown;
offsetX?: unknown;
offsetY?: unknown;
};
export function normalizeLayoutSettings(
input: RawLayoutInput | Partial<LayoutSettings> | null | undefined,
): LayoutSettings {
if (!input) return { ...DEFAULT_LAYOUT };
const forbidden = ["x", "y", "coverX", "coverY", "offsetX", "offsetY"] as const;
for (const k of forbidden) {
if ((input as RawLayoutInput)[k] !== undefined) {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
}
const raw =
(input as RawLayoutInput).template ??
(input as RawLayoutInput).layoutTemplate ??
(input as RawLayoutInput).layout_template;
let template: LayoutTemplate | null = null;
if (raw !== undefined && raw !== null && raw !== "" && raw !== "CLASSIC" && raw !== "classic") {
if (!isLayoutTemplate(raw)) {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
template = raw;
}
const blurRaw =
(input as RawLayoutInput).blurAmount ??
(input as RawLayoutInput).blur_amount ??
(input as LayoutSettings).blurAmount;
const opacityRaw =
(input as RawLayoutInput).blurOpacity ??
(input as RawLayoutInput).blur_opacity ??
(input as LayoutSettings).blurOpacity;
const padRaw =
(input as RawLayoutInput).textPadding ??
(input as RawLayoutInput).text_padding ??
(input as LayoutSettings).textPadding;
const gapRaw =
(input as RawLayoutInput).titleArtistGap ??
(input as RawLayoutInput).title_artist_gap ??
(input as LayoutSettings).titleArtistGap;
const oxRaw =
(input as RawLayoutInput).textOffsetX ??
(input as RawLayoutInput).text_offset_x ??
(input as LayoutSettings).textOffsetX;
const oyRaw =
(input as RawLayoutInput).textOffsetY ??
(input as RawLayoutInput).text_offset_y ??
(input as LayoutSettings).textOffsetY;
return {
template,
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
};
}
export function requiresArtTrackLayoutEntitlement(settings: LayoutSettings): boolean {
return settings.template !== null;
}
export type LayoutGeometry = {
coverMaxW: number;
coverMaxH: number;
coverX: string;
coverY: string;
titleFontSize: number;
artistFontSize: number;
titleX: string;
titleY: string;
artistX: string;
artistY: string;
};
function applyTextOffsets(
geo: LayoutGeometry,
textOffsetX: number,
textOffsetY: number,
): LayoutGeometry {
const ox = clampTextOffset(textOffsetX);
const oy = clampTextOffset(textOffsetY);
if (ox === 0 && oy === 0) return geo;
const shiftX = (expr: string) => {
if (expr === "(w-text_w)/2") return `(w-text_w)/2+${ox}`;
if (/^-?\d+$/.test(expr)) return String(Number(expr) + ox);
return `${expr}+${ox}`;
};
const shiftY = (expr: string) => {
if (/^-?\d+$/.test(expr)) return String(Number(expr) + oy);
return `${expr}+${oy}`;
};
return {
...geo,
titleX: shiftX(geo.titleX),
titleY: shiftY(geo.titleY),
artistX: shiftX(geo.artistX),
artistY: shiftY(geo.artistY),
};
}
/** Pixel geometry template + padding + title/artist gap + text offsets. */
export function computeLayoutGeometry(
template: LayoutTemplate,
width: number,
height: number,
textPadding: number,
titleArtistGap: number = TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: number = 0,
textOffsetY: number = 0,
): LayoutGeometry {
const pad = clampTextPadding(textPadding);
const gap = clampTitleArtistGap(titleArtistGap);
const titleFontSize = Math.max(22, Math.round(width * 0.032));
const artistFontSize = Math.max(16, Math.round(width * 0.02));
const lineGap = gap;
let base: LayoutGeometry;
switch (template) {
case "COVER_LEFT_TEXT_RIGHT": {
const coverMaxW = Math.round(width * 0.42);
const coverMaxH = height - pad * 2;
const textX = pad + coverMaxW + pad;
const midY = Math.round(height / 2);
base = {
coverMaxW,
coverMaxH,
coverX: String(pad),
coverY: "(H-h)/2",
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(midY - titleFontSize - Math.round(lineGap / 2)),
artistX: String(textX),
artistY: String(midY + Math.round(lineGap / 2)),
};
break;
}
case "COVER_RIGHT_TEXT_LEFT": {
const coverMaxW = Math.round(width * 0.42);
const coverMaxH = height - pad * 2;
const textX = pad;
const midY = Math.round(height / 2);
base = {
coverMaxW,
coverMaxH,
coverX: `W-w-${pad}`,
coverY: "(H-h)/2",
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(midY - titleFontSize - Math.round(lineGap / 2)),
artistX: String(textX),
artistY: String(midY + Math.round(lineGap / 2)),
};
break;
}
case "COVER_TOP_TEXT_BOTTOM": {
// Near full width avoid empty side gutters next to the cover
const coverMaxW = width - pad * 2;
const coverMaxH = Math.round(height * 0.56);
const textBlockTop = pad + coverMaxH + Math.round(pad * 0.55);
base = {
coverMaxW,
coverMaxH,
coverX: "(W-w)/2",
coverY: String(pad),
titleFontSize,
artistFontSize,
titleX: "(w-text_w)/2",
titleY: String(textBlockTop),
artistX: "(w-text_w)/2",
artistY: String(textBlockTop + titleFontSize + lineGap),
};
break;
}
case "CENTERED_COMPACT": {
const side = Math.round(Math.min(width, height) * 0.4);
const stackH = side + pad + titleFontSize + lineGap + artistFontSize;
const stackTop = Math.round((height - stackH) / 2);
const coverY = Math.max(pad, stackTop);
const titleY = coverY + side + Math.round(pad * 0.55);
base = {
coverMaxW: side,
coverMaxH: side,
coverX: "(W-w)/2",
coverY: String(coverY),
titleFontSize,
artistFontSize,
titleX: "(w-text_w)/2",
titleY: String(titleY),
artistX: "(w-text_w)/2",
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
default: {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
}
return applyTextOffsets(base, textOffsetX, textOffsetY);
}
export function buildArtTrackFilterComplex(opts: {
width: number;
height: number;
layout: LayoutSettings;
titleEscaped: string;
artistEscaped: string | null;
fontfileEscaped?: string | null;
}): string {
const { width: W, height: H, layout } = opts;
if (!layout.template) {
throw new Error("Art-track filter requires a layout template");
}
const geo = computeLayoutGeometry(
layout.template,
W,
H,
layout.textPadding,
layout.titleArtistGap,
layout.textOffsetX,
layout.textOffsetY,
);
const blurSeg = boxblurFilterSegment(layout.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(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
const artistDraw = opts.artistEscaped
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
: "";
const parts: string[] = [`[0:v]split=2[bg][fg]`];
// Fade blurred fill toward black when opacity < 100%
if (opacity >= 0.999) {
parts.push(`[bg]${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(
`[bg]${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(
`[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(";");
}
export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
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",
};