Compare commits

..
11 Commits
Author SHA1 Message Date
Atakan Doğan Özban b40127c2e8 Exclude integrations/n8n from Next/Docker builds so the app image typechecks cleanly. 2026-08-08 16:47:16 +02:00
Atakan Doğan Özban f9b2a997a2 Add n8n community node, /api/v1/render alias, and job webhooks for OSS automation.
Payment-free self-hosted builds keep full API access with optional webhookUrl callbacks and the published n8n-nodes-songs2vid package source under integrations/n8n.
2026-08-08 16:32:28 +02:00
Atakan Doğan Özban 848607f9e0 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.
2026-08-07 00:51:37 +02:00
atakan 03634d5100 Update README.md 2026-08-03 06:01:10 +00:00
atakan 6bc98606eb Update README.md 2026-08-03 05:57:28 +00:00
atakan b68e9fd8e5 Update README.md 2026-08-03 05:55:34 +00:00
atakan 888f5fb66a Update README.md 2026-08-03 05:55:08 +00:00
Atakan Doğan Özban 54bbf8b574 Close OSS self-host gaps: in-repo API docs, legal notes, and packaging.
Add MIT license and docs/api, strip SaaS status/admin remnants from robots and footer, align env/compose/README with payment-free product truth.
2026-08-03 07:36:08 +02:00
Atakan Doğan Özban 1fcae33ca3 Document payment-free OSS self-host quick start and Docker Hub overview. 2026-08-03 07:03:17 +02:00
Atakan Doğan Özban 6476bb42a4 Strip Songs2VID to a payment-free self-hosted OSS core.
Remove Stripe/billing/pricing/marketing, simplify schema and entitlements for unlimited self-host use, and keep auth, encode, and YouTube upload.
2026-08-03 06:52:00 +02:00
Songs2VID Support 506d56fa92 Initial OSS scaffold from Songs2VID (pre-strip) 2026-08-03 06:15:05 +02:00
46 changed files with 8416 additions and 137 deletions
+1
View File
@@ -10,3 +10,4 @@ uploads
deploy-*.tgz
scripts
bg-video
integrations
+3 -1
View File
@@ -43,13 +43,15 @@ OSS does not force a free-tier SaaS badge paywall.
- 💧 Custom watermarks (badge, text, or logo) and curated or uploaded fonts
- ▶️ YouTube playlists, privacy, tags, resolution, categories
- 🔌 REST API for automation (keys under **Dashboard → Settings → API key**)
- 🤖 n8n community node [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) + optional `webhookUrl` callbacks
- 🔓 Always-on self-hosted entitlements (no quota paywall)
## 📚 API documentation
- In-repo: [docs/api/overview.md](./docs/api/overview.md) and [docs/api/endpoints.md](./docs/api/endpoints.md)
- In-repo: [docs/api/overview.md](./docs/api/overview.md), [docs/api/endpoints.md](./docs/api/endpoints.md), [docs/n8n.md](./docs/n8n.md)
- Live site: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview)
- Machine-readable discovery: `GET /api/v1` (no auth; no billing routes)
- Node package source: [`integrations/n8n`](./integrations/n8n/)
## 🛠️ Local development
+14 -6
View File
@@ -1,25 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import { CURATED_FONTS, isCuratedFontKey } from "@/lib/fonts";
import { resolveCuratedFontPath } from "@/lib/fonts-server";
import { CURATED_FONTS, isCuratedFontKey, SYSTEM_FONT } from "@/lib/fonts";
import { resolveCuratedFontPath, resolveSystemFontPath } from "@/lib/fonts-server";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ key: string }> },
) {
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 });
}
const fontPath = resolveCuratedFontPath(key);
try {
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, {
headers: {
"Content-Type": "font/ttf",
"Content-Disposition": `inline; filename="${meta.file}"`,
"Content-Disposition": `inline; filename="${fileName}"`,
"Cache-Control": "public, max-age=86400, immutable",
},
});
+3
View File
@@ -35,12 +35,15 @@ export async function POST(req: NextRequest) {
const job = await createVideoJob(user, {
imagePath: body.imagePath,
items,
webhookUrl: body.webhookUrl ?? null,
});
return NextResponse.json({
jobId: job.id,
itemCount: job.items.length,
status: job.status,
statusUrl: `/api/v1/jobs/${job.id}`,
webhookUrl: body.webhookUrl ?? null,
playlist: playlist
? { id: playlist.id, title: playlist.title, privacy: playlist.privacy }
: null,
+5
View File
@@ -0,0 +1,5 @@
/**
* Alias for n8n / automation: POST|GET /api/v1/render → same as /api/v1/jobs
* OSS: no plan gate — Bearer API key + YouTube connected.
*/
export { POST, GET } from "../jobs/route";
+32 -4
View File
@@ -1,4 +1,9 @@
import { NextResponse } from "next/server";
import {
COMPOSITION_FAMILIES,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
} from "@/lib/layout";
import { API_DOCS_URL } from "@/lib/plans";
export async function GET() {
@@ -11,12 +16,28 @@ export async function GET() {
billing: false,
notes:
"OSS self-hosted: full entitlements, no Stripe/credits/paywall. In-repo docs: docs/api/",
webhooks:
"Optional webhookUrl on job create — HTTPS POST JSON on item/job terminal states for n8n",
guidance: {
recommended:
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/jobs with the returned paths",
"For most jobs (especially 5+ audio files): POST /api/v1/upload per file, then POST /api/v1/render (or /api/v1/jobs) with paths + optional webhookUrl",
batch:
"POST /api/v1/jobs/batch is for small packs only. Large multipart bodies may fail with 'failed to parse body as FormData'",
polling: "GET /api/v1/jobs/:id until status is COMPLETED, FAILED, or PARTIAL",
},
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: [
{
method: "POST",
@@ -25,12 +46,19 @@ export async function GET() {
"Upload image, audio, PNG logo, or .ttf/.otf font (font ≤10MB)",
body: "multipart/form-data: file, type (image|audio|logo|font)",
},
{
method: "POST",
path: "/api/v1/render",
description:
"Alias of /api/v1/jobs — create a render job (n8n-friendly). Optional webhookUrl for callbacks.",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
path: "/api/v1/jobs",
description:
"Create a video job from uploaded file paths with layouts, watermark, and per-item covers",
body: "application/json: { imagePath, items[{ audioPath, audioFilename, metadata }] }",
body: "application/json: { imagePath, webhookUrl?, items[{ audioPath, audioFilename, metadata }] }",
},
{
method: "POST",
@@ -53,13 +81,13 @@ export async function GET() {
{
method: "GET",
path: "/api/v1/jobs",
description: "List recent jobs",
description: "List recent jobs (also available as GET /api/v1/render)",
query: "limit (default 20, max 100)",
},
{
method: "GET",
path: "/api/v1/jobs/:id",
description: "Get job status and item details",
description: "Get job status and item details (poll for n8n)",
},
],
docs: API_DOCS_URL,
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

+298 -49
View File
@@ -10,6 +10,7 @@ import {
import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
@@ -19,15 +20,18 @@ import {
BLUR_OPACITY_DEFAULT,
BLUR_OPACITY_MAX,
BLUR_OPACITY_MIN,
COMPOSITION_FAMILIES,
DEFAULT_LAYOUT,
LAYOUT_TEMPLATE_LABELS,
LAYOUT_TEMPLATES,
TEXT_OFFSET_MAX,
TEXT_OFFSET_MIN,
TEXT_PADDING_MAX,
TEXT_PADDING_MIN,
TITLE_ARTIST_GAP_MAX,
TITLE_ARTIST_GAP_MIN,
compositionFamilyForTemplate,
isLowerCornerTemplate,
lowerCornerCoverSide,
type LayoutSettings,
type LayoutTemplate,
} from "@/lib/layout";
@@ -54,6 +58,8 @@ import {
type Props = {
locked: boolean;
previewImageUrl: string | null;
/** Optional custom blur-fill for lower-corner layouts. */
previewBackgroundUrl?: string | null;
/** On-video song title (art-track layouts). */
songTitle: string;
artist: string;
@@ -65,7 +71,10 @@ type Props = {
onWatermarkChange: (next: WatermarkSettings) => void;
onUploadLogo: (file: File) => Promise<string>;
onUploadFont: (file: File) => Promise<string>;
onUploadBackground?: (file: File) => Promise<string>;
onClearBackground?: () => void;
logoPreviewUrl?: string | null;
hasCustomBackground?: boolean;
};
const WM_POSITION_LABELS: Record<WatermarkPosition, string> = {
@@ -121,11 +130,13 @@ function watermarkOverlayStyle(
function MiniThumb({
template,
label,
active,
onClick,
disabled,
}: {
template: LayoutTemplate | null;
label: string;
active: boolean;
onClick: () => void;
disabled: boolean;
@@ -150,20 +161,14 @@ function MiniThumb({
) : (
<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]" />
{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 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" />
</>
)}
{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" && (
<>
<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" />
</>
)}
{(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>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">
{isClassic ? "Classic letterbox" : LAYOUT_TEMPLATE_LABELS[template]}
</p>
<p className="px-2 py-1.5 text-[10px] font-medium text-gray-300">{label}</p>
</button>
);
}
@@ -191,8 +202,18 @@ function MiniThumb({
function previewCoverStyle(
template: LayoutTemplate,
padPct: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
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) {
case "COVER_LEFT_TEXT_RIGHT":
return {
@@ -234,6 +255,24 @@ function previewCoverStyle(
maxHeight: "38%",
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,
textOffsetY: number,
titleArtistGap: number,
textPadding: number,
encodeWidth: number,
): CSSProperties {
const p = `${padPct}%`;
const shift = {
transform: undefined as string | undefined,
};
const cornerEdgePx = scaleFontToPreview(textPadding, encodeWidth);
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` };
@@ -294,13 +342,38 @@ function previewTextStyle(
textAlign: "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({
locked,
previewImageUrl,
previewBackgroundUrl = null,
songTitle,
artist,
encodeWidth = 1280,
@@ -310,24 +383,31 @@ export function LayoutStudio({
onWatermarkChange,
onUploadLogo,
onUploadFont,
onUploadBackground,
onClearBackground,
logoPreviewUrl,
hasCustomBackground = false,
}: Props) {
const [logoUploading, setLogoUploading] = useState(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [fontUploading, setFontUploading] = useState(false);
const [fontError, setFontError] = 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
const L: LayoutSettings = {
...DEFAULT_LAYOUT,
...layout,
blurFill: layout.blurFill ?? DEFAULT_LAYOUT.blurFill,
blurAmount: layout.blurAmount ?? DEFAULT_LAYOUT.blurAmount,
blurOpacity: layout.blurOpacity ?? BLUR_OPACITY_DEFAULT,
textPadding: layout.textPadding ?? DEFAULT_LAYOUT.textPadding,
titleArtistGap: layout.titleArtistGap ?? DEFAULT_LAYOUT.titleArtistGap,
textOffsetX: layout.textOffsetX ?? DEFAULT_LAYOUT.textOffsetX,
textOffsetY: layout.textOffsetY ?? DEFAULT_LAYOUT.textOffsetY,
titleBold: layout.titleBold ?? DEFAULT_LAYOUT.titleBold,
};
const W: WatermarkSettings = {
...DEFAULT_WATERMARK,
@@ -386,13 +466,34 @@ export function LayoutStudio({
el.id = styleId;
document.head.appendChild(el);
}
el.textContent = CURATED_FONTS.map(
(f) => `@font-face {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
el.textContent = [
`@font-face {
font-family: '${SYSTEM_FONT.previewFamily}';
src: url('${curatedFontApiUrl(SYSTEM_FONT.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
).join("\n");
`@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 {
font-family: 'S2VIDPreview-${f.key}';
src: url('${curatedFontApiUrl(f.key)}') format('truetype');
font-weight: 400;
font-display: swap;
}`,
),
].join("\n");
}, []);
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) {
if (key === "custom") {
patchWm({ fontKey: "custom", fontPath: watermark.fontPath ?? null });
@@ -472,6 +588,8 @@ export function LayoutStudio({
}
const artTrack = Boolean(L.template);
const lowerCorner = isLowerCornerTemplate(L.template);
const blurPreviewUrl = previewBackgroundUrl || previewImageUrl;
return (
<div
@@ -495,7 +613,7 @@ export function LayoutStudio({
<div className="absolute inset-0 bg-black" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
src={blurPreviewUrl!}
alt=""
className="absolute inset-0 h-full w-full object-cover"
style={{
@@ -508,7 +626,12 @@ export function LayoutStudio({
<img
src={previewImageUrl}
alt=""
style={previewCoverStyle(L.template!, padPct)}
style={previewCoverStyle(
L.template!,
padPct,
L.textPadding,
encodeWidth,
)}
className="pointer-events-none"
/>
<div
@@ -518,12 +641,17 @@ export function LayoutStudio({
L.textOffsetX,
L.textOffsetY,
L.titleArtistGap,
L.textPadding,
encodeWidth,
)}
>
<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={{
...textFontStyle,
fontWeight: L.titleBold ? 600 : 400,
fontSize: `${titlePreviewPx}px`,
lineHeight: 1.15,
}}
@@ -543,13 +671,28 @@ export function LayoutStudio({
</div>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
<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 */}
<img
src={previewImageUrl}
alt=""
className="max-h-full max-w-full object-contain"
/>
</div>
</div>
)}
@@ -595,22 +738,114 @@ export function LayoutStudio({
{/* Art-track templates */}
<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">
<MiniThumb
template={null}
active={L.template === null}
disabled={locked}
onClick={() => patchLayout({ template: null })}
/>
{LAYOUT_TEMPLATES.map((t) => (
<MiniThumb
key={t}
template={t}
active={L.template === t}
disabled={locked}
onClick={() => patchLayout({ template: t })}
/>
))}
{COMPOSITION_FAMILIES.map((family) => {
const active =
family.id === "classic"
? L.template === null
: family.variants.includes(L.template as LayoutTemplate);
return (
<MiniThumb
key={family.id}
template={family.thumb}
label={family.label}
active={active}
disabled={locked}
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 });
}}
/>
);
})}
</div>
{(() => {
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}
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>
);
})()}
{!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">
<label className="text-sm text-gray-400">
@@ -619,7 +854,7 @@ export function LayoutStudio({
type="range"
min={BLUR_AMOUNT_MIN}
max={BLUR_AMOUNT_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurAmount}
onChange={(e) => patchLayout({ blurAmount: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -631,7 +866,7 @@ export function LayoutStudio({
type="range"
min={BLUR_OPACITY_MIN}
max={BLUR_OPACITY_MAX}
disabled={locked || !artTrack}
disabled={locked || !(artTrack || L.blurFill)}
value={L.blurOpacity}
onChange={(e) => patchLayout({ blurOpacity: Number(e.target.value) })}
className="mt-1 w-full disabled:opacity-40"
@@ -687,6 +922,20 @@ export function LayoutStudio({
className="mt-1 w-full disabled:opacity-40"
/>
</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>
{/* Typography (matches FFmpeg art-track + watermark text fonts) */}
@@ -700,7 +949,7 @@ export function LayoutStudio({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">Arial (system default)</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
+30 -1
View File
@@ -8,7 +8,7 @@ import { audioTagsToMetadata } from "@/lib/audio-tags";
import { resolveYouTubeTitle } from "@/lib/titles";
import { SONG_TITLE_MAX } from "@/lib/layout";
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 { CategorySelect } from "./CategorySelect";
import { LayoutStudio } from "./LayoutStudio";
@@ -65,6 +65,8 @@ export function UploadForm() {
const [jobWatermark, setJobWatermark] = useState<WatermarkSettings>({ ...DEFAULT_WATERMARK });
const [jobLayout, setJobLayout] = useState<LayoutSettings>({ ...DEFAULT_LAYOUT });
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<{
maxBatchSize: number;
used: number;
@@ -89,6 +91,7 @@ export function UploadForm() {
return () => {
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
if (logoPreviewUrl) URL.revokeObjectURL(logoPreviewUrl);
if (backgroundPreviewUrl) URL.revokeObjectURL(backgroundPreviewUrl);
audioItems.forEach((a) => {
if (a.itemImagePreview) URL.revokeObjectURL(a.itemImagePreview);
});
@@ -258,6 +261,21 @@ export function UploadForm() {
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) {
const normalized = { ...DEFAULT_WATERMARK, ...next };
setJobWatermark(normalized);
@@ -276,6 +294,9 @@ export function UploadForm() {
function applyLayoutToAll(next: LayoutSettings) {
const normalized = { ...DEFAULT_LAYOUT, ...next };
setJobLayout(normalized);
if (!isLowerCornerTemplate(normalized.template)) {
clearBackgroundImage();
}
setAudioItems((prev) =>
prev.map((item) => ({
...item,
@@ -371,6 +392,10 @@ export function UploadForm() {
includeWatermark: effectiveWatermark.mode !== "none",
watermark: effectiveWatermark,
layout: jobLayout,
backgroundImagePath:
isLowerCornerTemplate(jobLayout.template) && backgroundImagePath
? backgroundImagePath
: null,
},
})),
}),
@@ -620,6 +645,7 @@ export function UploadForm() {
previewImageUrl={
audioItems.find((a) => a.itemImagePreview)?.itemImagePreview || imagePreviewUrl
}
previewBackgroundUrl={backgroundPreviewUrl}
songTitle={
audioItems[0]?.metadata.songTitle ||
audioItems[0]?.metadata.title ||
@@ -633,6 +659,9 @@ export function UploadForm() {
onWatermarkChange={applyWatermarkToAll}
onUploadLogo={handleLogoUpload}
onUploadFont={handleFontUpload}
onUploadBackground={handleBackgroundUpload}
onClearBackground={clearBackgroundImage}
hasCustomBackground={Boolean(backgroundImagePath)}
logoPreviewUrl={logoPreviewUrl}
/>
+33 -6
View File
@@ -11,9 +11,14 @@ import { getVideoAttributionText } from "@/lib/branding";
import {
CURATED_FONTS,
googleFontsStylesheetUrl,
SYSTEM_FONT,
type CuratedFontKey,
type WatermarkFontKey,
} from "@/lib/fonts";
import {
curatedFontApiUrl,
previewFontFamilyCss,
} from "@/lib/preview-typography";
import {
WATERMARK_OFFSET_MAX,
WATERMARK_OFFSET_MIN,
@@ -78,10 +83,7 @@ function previewStyle(
}
function previewFontFamily(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") return "ui-sans-serif, system-ui, sans-serif";
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";
return previewFontFamilyCss(fontKey);
}
export function WatermarkPreview({
@@ -110,7 +112,32 @@ export function WatermarkPreview({
[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(() => {
const id = "s2vid-watermark-google-fonts";
if (document.getElementById(id)) return;
@@ -287,7 +314,7 @@ export function WatermarkPreview({
onChange={(e) => setFontKey(e.target.value as WatermarkFontKey)}
className="input-field mt-1"
>
<option value="system">System default</option>
<option value="system">{SYSTEM_FONT.label}</option>
{CURATED_FONTS.map((f) => (
<option key={f.key} value={f.key} style={{ fontFamily: f.cssFamily }}>
{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 |
|-----|----------|
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows |
| [API endpoints](./api/endpoints.md) | curl examples for upload, jobs, playlists, errors |
| [API overview](./api/overview.md) | Auth, rate limits, recommended flows, discovery (`layoutTemplates`, `compositionFamilies`) |
| [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)
+80 -5
View File
@@ -84,16 +84,21 @@ curl -X POST "$BASE_URL/api/v1/upload" \
`audioTags` is present for MP3 when tags are readable; otherwise `null`.
## Create job from paths (recommended)
## Create job / render from paths (recommended)
Self-hosted unlocks per-track covers, custom watermarks/fonts, and art-track layouts. Max batch size: **100**. Watermarks are optional — nothing forces the default Songs2VID badge.
`POST /api/v1/render` is an **n8n-friendly alias** of `POST /api/v1/jobs` (identical body and response). `GET /api/v1/render` lists jobs like `GET /api/v1/jobs`.
Optional `webhookUrl` (absolute `http(s)` URL) makes Songs2VID POST JSON when items/jobs finish — preferred for n8n.
```bash
curl -X POST "$BASE_URL/api/v1/jobs" \
curl -X POST "$BASE_URL/api/v1/render" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imagePath": "/uploads/.../cover.jpg",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
@@ -112,11 +117,12 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
"creativeCommons": false,
"includeWatermark": true,
"layout": {
"template": "COVER_LEFT_TEXT_RIGHT",
"template": "LOWER_LEFT_COVER_TEXT",
"blurAmount": 60,
"blurOpacity": 85,
"textPadding": 48,
"titleArtistGap": 12,
"titleBold": true,
"textOffsetX": 0,
"textOffsetY": 0
},
@@ -133,6 +139,8 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
}'
```
Equivalent path: `POST $BASE_URL/api/v1/jobs` with the same JSON.
### Success response
```json
@@ -140,10 +148,51 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
"jobId": "clxxxxxxxx",
"itemCount": 1,
"status": "PENDING",
"statusUrl": "/api/v1/jobs/clxxxxxxxx",
"webhookUrl": "https://your-n8n.example/webhook/songs2vid-complete",
"playlist": null
}
```
### Webhook payload
| `event` | Meaning |
|---------|---------|
| `job.item.completed` | One track finished (`youtubeVideoId` set) |
| `job.item.failed` | One track failed (`error` set) |
| `job.completed` | All items succeeded |
| `job.failed` | All items failed |
| `job.partial` | Mix of success and failure |
Community node: [`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid) — see [docs/n8n.md](../n8n.md) and [docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n).
<details>
<summary>Legacy example without webhook (same metadata shape)</summary>
```bash
curl -X POST "$BASE_URL/api/v1/jobs" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"imagePath": "/uploads/.../cover.jpg",
"items": [{
"audioPath": "/uploads/.../track.mp3",
"audioFilename": "track.mp3",
"metadata": {
"title": "My Artist - My Track (Official Audio)",
"songTitle": "My Track",
"artist": "My Artist",
"privacy": "PUBLIC",
"categoryId": "10",
"resolution": "1920x1080",
"includeWatermark": false
}
}]
}'
```
</details>
### Metadata fields
| Field | Type | Notes |
@@ -162,6 +211,7 @@ curl -X POST "$BASE_URL/api/v1/jobs" \
| `creativeCommons` | boolean | CC vs standard YouTube license |
| `includeWatermark` | boolean | Apply watermark settings |
| `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 |
| `layout` | object | Art-track layout |
| `watermark` | object | Watermark settings |
@@ -216,7 +266,7 @@ Snake_case aliases are accepted for layout/watermark fields.
### 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 |
|------|-------------|
@@ -224,8 +274,33 @@ Snake_case aliases are accepted for layout/watermark fields.
| `COVER_TOP_TEXT_BOTTOM` | Cover top, title & artist below |
| `COVER_RIGHT_TEXT_LEFT` | Cover right, title & artist left |
| `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
+5 -3
View File
@@ -31,10 +31,12 @@ If a limit is hit, the response is **429** with `retryAfterSeconds` and a `Retry
### Recommended: two-step (especially 5+ audio files)
1. Upload each file with `POST /api/v1/upload`
2. Create the job with `POST /api/v1/jobs` (JSON paths)
2. Create the job with `POST /api/v1/render` (alias of `/api/v1/jobs`) — JSON paths + optional `webhookUrl`
This avoids huge multipart bodies. Max batch size is **100** tracks per job.
Prefer `webhookUrl` for n8n so long encodes do not block an HTTP Request node. Details: [n8n](../n8n.md).
### One-shot batch: small packs only
`POST /api/v1/jobs/batch` accepts one cover image and a few audio files in a single multipart request. Large bodies often fail with:
@@ -59,8 +61,8 @@ Poll with `GET /api/v1/jobs/:id`.
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
See [Endpoints](./endpoints.md) for curl examples. Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
See [Endpoints](./endpoints.md) for curl examples. n8n: [docs/n8n.md](../n8n.md). Hosted HTML docs: [docs.songs2vid.com/docs/api/overview](https://docs.songs2vid.com/docs/api/overview).
+36
View File
@@ -0,0 +1,36 @@
# n8n integration (OSS)
Automate self-hosted Songs2VID with the community package **[`n8n-nodes-songs2vid`](https://www.npmjs.com/package/n8n-nodes-songs2vid)**.
Full hosted guide: [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n)
## Install
In n8n → **Settings → Community nodes** → install:
```text
n8n-nodes-songs2vid
```
Or from this repo for local development:
```bash
cd integrations/n8n
npm install
npm run build
```
## Auth
1. Dashboard → **Settings → API key** → create `s2yt_live_…`
2. n8n credential **Songs2VID API**: paste key, Base URL = your instance (e.g. `http://localhost:3000`)
OSS has **no plan gate** — API keys work whenever YouTube is connected.
## Flow
1. **Upload** cover (`type=image`) and audio (`type=audio`)
2. **Create Render** (`POST /api/v1/render`) with paths + optional `webhookUrl`
3. Handle completion via n8n **Webhook** or poll `GET /api/v1/jobs/:id`
Package source: [`integrations/n8n`](../integrations/n8n/).
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
*.tgz
.env
.env.*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Songs2VID
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+89
View File
@@ -0,0 +1,89 @@
# n8n-nodes-songs2vid
Official community node for **[Songs2VID](https://songs2vid.com)** — turn audio + cover art into YouTube-ready videos from [n8n](https://n8n.io/).
**Documentation:** [https://docs.songs2vid.com/docs/n8n](https://docs.songs2vid.com/docs/n8n) · in-repo [docs/n8n.md](../../docs/n8n.md)
Self-hosted OSS: set **Base URL** to your instance. API keys work without a paid plan (YouTube must be connected).
## Install
### From n8n (recommended)
1. Open n8n → **Settings → Community nodes**
2. **Install a community node**
3. Enter:
```text
n8n-nodes-songs2vid
```
4. Confirm and restart n8n if prompted
5. Search the canvas for **Songs2VID**
Self-hosted n8n needs community packages enabled (`N8N_COMMUNITY_PACKAGES_ENABLED=true`).
### From npm (manual)
```bash
cd ~/.n8n
npm install n8n-nodes-songs2vid
```
Restart n8n.
## Credentials
1. Create an API key under [Dashboard → Settings](https://songs2vid.com/dashboard/settings) (`s2yt_live_…`)
2. Cloud requires **Developer & Automation** (€15) or **Enterprise**, plus YouTube connected
3. In n8n: create a **Songs2VID API** credential
- **API Key:** your token
- **Base URL:** `https://songs2vid.com` (or your self-hosted origin, no trailing slash)
Requests use `Authorization: Bearer …` only.
## Quick start
1. **File → Upload** — cover (`type=image`) → save `path`
2. **File → Upload** — audio (`type=audio`) → save `path` / `filename`
3. **Render → Create Render** — paths + privacy + resolution
Optional: **Additional Fields → Webhook URL** for async completion
4. Handle `job.completed` / `job.item.completed` on an n8n **Webhook** node, or poll **Get Render Status**
## Operations
| Resource | Operation | API |
|----------|-----------|-----|
| Discovery | Get API Catalog | `GET /api/v1` |
| File | Upload | `POST /api/v1/upload` |
| Render | Create Render | `POST /api/v1/render` |
| Render | Create Job (alias) | `POST /api/v1/jobs` |
| Render | Create Batch | `POST /api/v1/jobs/batch` |
| Render | Get Render Status | `GET /api/v1/jobs/:id` |
| Render | List Renders | `GET /api/v1/jobs` |
| Playlist | List / Create | `/api/v1/playlists` |
| API Key | List / Create / Delete | `/api/v1/user/api-keys` |
Layout, watermark, playlist, and multi-track options are under **Additional Fields** on Create Render.
There is no Delete Render endpoint — finished videos live on YouTube.
## Links
- Guide: https://songs2vid.com/docs/n8n
- REST endpoints: https://songs2vid.com/docs/api/endpoints
- Workflow template: https://songs2vid.com/docs/n8n/workflow-template.json
- npm: https://www.npmjs.com/package/n8n-nodes-songs2vid
## Development
```bash
npm install
npm run build
```
`prepublishOnly` runs the build before `npm publish`.
## License
MIT
@@ -0,0 +1,57 @@
import type {
IAuthenticateGeneric,
ICredentialTestRequest,
ICredentialType,
INodeProperties,
} from "n8n-workflow";
/**
* Songs2VID REST auth — cloud accepts Authorization: Bearer only.
* Paste the raw key (s2yt_live_…) or a full "Bearer …" value.
*/
export class Songs2VidApi implements ICredentialType {
name = "songs2VidApi";
displayName = "Songs2VID API";
documentationUrl = "https://songs2vid.com/docs/n8n";
properties: INodeProperties[] = [
{
displayName: "API Key",
name: "apiKey",
type: "string",
typeOptions: { password: true },
default: "",
required: true,
description:
"Developer+ key from Dashboard → Settings (starts with s2yt_live_). Sent as Authorization: Bearer.",
},
{
displayName: "Base URL",
name: "baseUrl",
type: "string",
default: "https://songs2vid.com",
required: true,
description: "Cloud origin or self-hosted base (no trailing slash).",
},
];
authenticate: IAuthenticateGeneric = {
type: "generic",
properties: {
headers: {
Authorization:
'={{ $credentials.apiKey.toString().startsWith("Bearer ") ? $credentials.apiKey : "Bearer " + $credentials.apiKey }}',
},
},
};
test: ICredentialTestRequest = {
request: {
baseURL: "={{ $credentials.baseUrl.replace(/\\/$/, \"\") }}",
url: "/api/v1/user/api-keys",
method: "GET",
},
};
}
+14
View File
@@ -0,0 +1,14 @@
const { src, dest } = require("gulp");
/**
* Copy node/credential icons (+ codex JSON) into dist so n8n can resolve
* `icon: "file:songs2vid.svg"` next to the compiled .js files.
*/
function copyIcons() {
return src(
["nodes/**/*.{png,svg,json}", "credentials/**/*.{png,svg,json}"],
{ base: "." },
).pipe(dest("dist"));
}
exports["build:icons"] = copyIcons;
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env node
"use strict";
module.exports = {};
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-songs2vid.songs2Vid",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Marketing", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://songs2vid.com/docs/n8n"
}
],
"primaryDocumentation": [
{
"url": "https://songs2vid.com/docs/api/endpoints"
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" fill="none">
<rect width="60" height="60" rx="12" fill="#0a0a0a"/>
<text x="8" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#fff">S2</text>
<text x="30" y="38" font-family="Arial,sans-serif" font-size="16" font-weight="700" fill="#f87171">VID</text>
</svg>

After

Width:  |  Height:  |  Size: 355 B

+5242
View File
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
{
"name": "n8n-nodes-songs2vid",
"version": "1.0.0",
"description": "n8n community node for Songs2VID — upload audio/covers, create YouTube renders, poll jobs, manage playlists & API keys",
"keywords": [
"n8n-community-node-package",
"n8n-node",
"songs2vid",
"audio-to-video",
"youtube"
],
"license": "MIT",
"homepage": "https://songs2vid.com/docs/n8n",
"author": {
"name": "Songs2VID",
"url": "https://songs2vid.com"
},
"repository": {
"type": "git",
"url": "https://git.atakanozban.com/Songs2VID/songs2vid.git",
"directory": "integrations/n8n"
},
"bugs": {
"url": "https://songs2vid.com/docs/n8n"
},
"main": "index.js",
"scripts": {
"build": "tsc && gulp build:icons",
"dev": "tsc --watch",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"n8n": {
"n8nNodesApiVersion": 1,
"credentials": [
"dist/credentials/Songs2VidApi.credentials.js"
],
"nodes": [
"dist/nodes/Songs2Vid/Songs2Vid.node.js"
]
},
"devDependencies": {
"@types/node": "^20.11.0",
"gulp": "^4.0.2",
"n8n-workflow": "^1.70.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"n8n-workflow": "*"
},
"engines": {
"node": ">=18.10"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["ES2019"],
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["credentials/**/*", "nodes/**/*"],
"exclude": ["dist", "node_modules"]
}
@@ -0,0 +1,395 @@
{
"name": "Songs2VID — Audio → Render → YouTube (full)",
"meta": {
"templateCredsSetupCompleted": false,
"instanceId": "songs2vid-docs-template-v2"
},
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
0,
0
]
},
{
"parameters": {
"path": "songs2vid-complete",
"httpMethod": "POST",
"responseMode": "onReceived",
"options": {}
},
"id": "webhook-complete",
"name": "Songs2VID Job Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
360
],
"webhookId": "songs2vid-complete"
},
{
"parameters": {
"content": "## Inputs\nProvide binary fields (`cover`, `audio`) or replace Manual Trigger with Drive/Dropbox.\n\nCredential: Header Auth → Name `Authorization`, Value `Bearer s2yt_live_…`\n\nSet env `SONGS2VID_WEBHOOK_URL` to this workflow's Production Webhook URL.",
"height": 240,
"width": 320
},
"id": "sticky-inputs",
"name": "Setup",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-320,
-40
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "cover"
},
{
"name": "type",
"value": "image"
}
]
},
"options": {}
},
"id": "upload-cover",
"name": "Upload Cover Image",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
280,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "audio"
},
{
"name": "type",
"value": "audio"
}
]
},
"options": {}
},
"id": "upload-audio",
"name": "Upload Audio",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
520,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}/api/v1/render",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"imagePath\": \"{{ $('Upload Cover Image').item.json.path }}\",\n \"webhookUrl\": \"{{ $env.SONGS2VID_WEBHOOK_URL || '' }}\",\n \"items\": [{\n \"audioPath\": \"{{ $('Upload Audio').item.json.path }}\",\n \"audioFilename\": \"{{ $('Upload Audio').item.json.filename }}\",\n \"metadata\": {\n \"title\": \"{{ $('Upload Audio').item.json.audioTags?.title || $('Upload Audio').item.json.filename }}\",\n \"songTitle\": \"{{ $('Upload Audio').item.json.audioTags?.title || '' }}\",\n \"artist\": \"{{ $('Upload Audio').item.json.audioTags?.artist || '' }}\",\n \"description\": \"Uploaded via n8n + Songs2VID\",\n \"tags\": \"music,songs2vid\",\n \"privacy\": \"UNLISTED\",\n \"categoryId\": \"10\",\n \"resolution\": \"1920x1080\",\n \"notifySubscribers\": false,\n \"madeForKids\": false,\n \"embeddable\": true,\n \"creativeCommons\": false,\n \"includeWatermark\": false\n }\n }]\n}",
"options": {}
},
"id": "create-render",
"name": "Create Render",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
760,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
}
},
{
"parameters": {
"method": "GET",
"url": "={{ $env.SONGS2VID_BASE_URL || 'https://songs2vid.com' }}{{ $json.statusUrl }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"options": {}
},
"id": "poll-status",
"name": "Get Render Status (optional poll)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1000,
0
],
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_ME",
"name": "Songs2VID API Key"
}
},
"disabled": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.completed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-done",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.completed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-completed",
"name": "Render Completed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
360
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "job-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.failed",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "item-fail",
"leftValue": "={{ $json.body?.event || $json.event }}",
"rightValue": "job.item.failed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
},
"options": {}
},
"id": "if-failed",
"name": "Render Failed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
280,
560
]
},
{
"parameters": {
"content": "## YouTube live\nVideo ID: `{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}`\n\nhttps://youtu.be/{{ $json.body?.youtubeVideoId || $json.youtubeVideoId }}",
"height": 180,
"width": 360
},
"id": "sticky-success",
"name": "Success",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
320
]
},
{
"parameters": {
"content": "## Failed\n`{{ $json.body?.error || $json.error }}`",
"height": 160,
"width": 360
},
"id": "sticky-fail",
"name": "Failure",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
560,
520
]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Upload Cover Image",
"type": "main",
"index": 0
}
]
]
},
"Upload Cover Image": {
"main": [
[
{
"node": "Upload Audio",
"type": "main",
"index": 0
}
]
]
},
"Upload Audio": {
"main": [
[
{
"node": "Create Render",
"type": "main",
"index": 0
}
]
]
},
"Create Render": {
"main": [
[
{
"node": "Get Render Status (optional poll)",
"type": "main",
"index": 0
}
]
]
},
"Songs2VID Job Webhook": {
"main": [
[
{
"node": "Render Completed?",
"type": "main",
"index": 0
}
]
]
},
"Render Completed?": {
"main": [
[
{
"node": "Success",
"type": "main",
"index": 0
}
],
[
{
"node": "Render Failed?",
"type": "main",
"index": 0
}
]
]
},
"Render Failed?": {
"main": [
[
{
"node": "Failure",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [
{
"name": "songs2vid"
},
{
"name": "n8n"
},
{
"name": "youtube"
}
],
"triggerCount": 0,
"updatedAt": "2026-08-08T00:00:00.000Z",
"versionId": "2"
}
+134 -16
View File
@@ -8,9 +8,12 @@ import {
isCuratedFontKey,
sanitizeFontfileForFilter,
} from "../fonts";
import { resolveCuratedFontPath } from "../fonts-server";
import { resolveCuratedFontPath, resolveSystemFontPath, assertFontFile } from "../fonts-server";
import {
buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
isLowerCornerTemplate,
normalizeLayoutSettings,
type LayoutSettings,
} from "../layout";
import {
@@ -61,9 +64,26 @@ export async function assertPngFile(filePath: string): Promise<void> {
async function resolveFontfileEscaped(
settings: WatermarkSettings,
weight: "regular" | "bold" = "regular",
): Promise<string | null> {
const key = settings.fontKey ?? "system";
if (key === "system") return null;
if (key === "system") {
const preferred = resolveSystemFontPath(weight);
const fallback = weight === "bold" ? resolveSystemFontPath("regular") : preferred;
for (const fontPath of weight === "bold" ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch (err) {
console.warn(
`[ffmpeg] system font invalid at ${fontPath}: ${err instanceof Error ? err.message : err}`,
);
}
}
console.warn(`[ffmpeg] system font missing; using FFmpeg default`);
return null;
}
if (key === "custom") {
if (!settings.fontPath) return null;
@@ -74,12 +94,19 @@ async function resolveFontfileEscaped(
}
if (isCuratedFontKey(key)) {
const fontPath = resolveCuratedFontPath(key);
if (!(await fileExists(fontPath))) {
console.warn(`[ffmpeg] curated font missing: ${key} at ${fontPath}; using system font`);
return null;
const preferred = resolveCuratedFontPath(key, weight);
const fallback = weight === "bold" ? resolveCuratedFontPath(key, "regular") : preferred;
for (const fontPath of weight === "bold" && preferred !== fallback ? [preferred, fallback] : [preferred]) {
if (!(await fileExists(fontPath))) continue;
try {
await assertFontFile(fontPath);
return sanitizeFontfileForFilter(fontPath);
} catch {
/* try next */
}
}
return sanitizeFontfileForFilter(fontPath);
console.warn(`[ffmpeg] curated font missing: ${key}; using system font`);
return null;
}
return null;
@@ -89,6 +116,38 @@ function classicScaleFilter(width: number, height: number): string {
return `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`;
}
/**
* Plan-aware audio for MP4.
* OSS / self-hosted defaults to Pro-quality 320k AAC.
*/
export type AudioEncodeOptions = {
bitrateKbps: 192 | 320;
copyWhenSafe: boolean;
audioPath: string;
};
function isCopySafeAudioPath(audioPath: string): boolean {
const ext = path.extname(audioPath).toLowerCase();
return ext === ".aac" || ext === ".m4a" || ext === ".mp4";
}
function pushAudioEncodeArgs(args: string[], audio: AudioEncodeOptions): void {
if (audio.copyWhenSafe && isCopySafeAudioPath(audio.audioPath)) {
args.push("-c:a", "copy");
return;
}
args.push(
"-c:a",
"aac",
"-b:a",
`${audio.bitrateKbps}k`,
"-ar",
"44100",
"-ac",
"2",
);
}
/**
* Art-track filter that ends at `outLabel` instead of hardcoded [laid].
*/
@@ -99,6 +158,13 @@ function artTrackFilterEndingAt(
return buildArtTrackFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
function classicBlurFillEndingAt(
opts: Parameters<typeof buildClassicBlurFillFilterComplex>[0],
outLabel: string,
): string {
return buildClassicBlurFillFilterComplex(opts).replace(/\[laid\]$/, `[${outLabel}]`);
}
export async function encodeVideo(options: {
imagePath: string;
audioPath: string;
@@ -110,24 +176,60 @@ export async function encodeVideo(options: {
/** On-video song title (art-track layouts). */
songTitle?: string;
artist?: string | null;
/**
* Optional full-frame background for lower-corner templates.
* When set with LOWER_LEFT / LOWER_RIGHT, blur fill uses this image; cover stays sharp.
*/
backgroundImagePath?: string | null;
/** Defaults to 320k (self-hosted / studio). */
audioBitrateKbps?: 192 | 320;
audioCopyWhenSafe?: boolean;
}): Promise<void> {
const res = getResolution(options.resolution);
if (!res) throw new Error(`Invalid resolution: ${options.resolution}`);
const audioEncode: AudioEncodeOptions = {
bitrateKbps: options.audioBitrateKbps ?? 320,
copyWhenSafe: options.audioCopyWhenSafe ?? true,
audioPath: options.audioPath,
};
await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
const settings = normalizeWatermarkSettings(options.watermark, options.includeWatermark);
const layout = options.layout?.template ? options.layout : null;
const layoutSettings = options.layout
? normalizeLayoutSettings(options.layout)
: null;
const artTrack = Boolean(layoutSettings?.template);
const classicBlurFill = Boolean(layoutSettings && !artTrack && layoutSettings.blurFill);
const layout = artTrack && layoutSettings ? layoutSettings : null;
const watermarkWidth = Math.max(1, Math.round(res.width * WATERMARK_WIDTH_FRACTION));
const fontSize = watermarkFontSizeForWidth(res.width);
const fontfile = await resolveFontfileEscaped(settings);
const fontfile = await resolveFontfileEscaped(settings, "regular");
const titleFontfile =
layout?.titleBold ? await resolveFontfileEscaped(settings, "bold") : null;
// Only pass a distinct bold face when it differs from regular (avoids faux-bold when bold TTF exists).
const titleFontfileDistinct =
titleFontfile && titleFontfile !== fontfile ? titleFontfile : null;
const args = ["-y", "-loop", "1", "-r", "1", "-i", options.imagePath, "-i", options.audioPath];
let nextInput = 2;
let separateBackgroundInputIndex: number | null = null;
let logoInputIndex: number | null = null;
let defaultWmInputIndex: number | null = null;
const useSeparateBackground =
Boolean(layout && isLowerCornerTemplate(layout.template) && options.backgroundImagePath);
if (useSeparateBackground && options.backgroundImagePath) {
if (!(await fileExists(options.backgroundImagePath))) {
throw new Error("Background image file not found");
}
args.push("-loop", "1", "-r", "1", "-i", options.backgroundImagePath);
separateBackgroundInputIndex = nextInput++;
}
const needsLogo = settings.mode === "logo" && Boolean(settings.logoPath);
const defaultPath = getWatermarkPath();
const useDefaultPng =
@@ -156,16 +258,17 @@ export async function encodeVideo(options: {
? ("default-text" as const)
: ("none" as const);
// Fast path: classic letterbox, no watermark
if (!layout && applyWm === "none") {
// Fast path: classic letterbox (black bars), no watermark, no blur fill
if (!layout && !classicBlurFill && applyWm === "none") {
args.push("-vf", classicScaleFilter(res.width, res.height));
args.push(
"-c:v",
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
@@ -192,6 +295,20 @@ export async function encodeVideo(options: {
titleEscaped,
artistEscaped,
fontfileEscaped: fontfile,
titleFontfileEscaped: titleFontfileDistinct,
separateBackgroundInputIndex,
},
baseLabel,
),
);
} else if (classicBlurFill && layoutSettings) {
filterParts.push(
classicBlurFillEndingAt(
{
width: res.width,
height: res.height,
blurAmount: layoutSettings.blurAmount,
blurOpacity: layoutSettings.blurOpacity,
},
baseLabel,
),
@@ -235,7 +352,7 @@ export async function encodeVideo(options: {
position: settings.position,
offsetX: settings.offsetX,
offsetY: settings.offsetY,
fontfileEscaped: null,
fontfileEscaped: fontfile,
});
filterParts.push(`[${baseLabel}]${draw}[vout]`);
}
@@ -246,8 +363,9 @@ export async function encodeVideo(options: {
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
+17 -4
View File
@@ -2,19 +2,32 @@
import fs from "fs/promises";
import path from "path";
import { CURATED_FONTS, type CuratedFontKey } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT, type CuratedFontKey } from "./fonts";
export function getFontsDir(): string {
return path.join(process.cwd(), "assets", "fonts");
}
/** Resolve the bundled system (Arimo) font for FFmpeg + preview. */
export function resolveSystemFontPath(weight: "regular" | "bold" = "regular"): string {
const file = weight === "bold" ? SYSTEM_FONT.boldFile : SYSTEM_FONT.file;
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
/** Resolve a curated font file on disk (must exist under assets/fonts). */
export function resolveCuratedFontPath(key: CuratedFontKey): string {
export function resolveCuratedFontPath(
key: CuratedFontKey,
weight: "regular" | "bold" = "regular",
): string {
const meta = CURATED_FONTS.find((f) => f.key === key);
if (!meta) throw new Error("Unknown font");
const file =
weight === "bold" && meta.boldFile ? meta.boldFile : meta.file;
// Whitelist filename only never accept user-controlled path segments
const safe = meta.file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== meta.file) throw new Error("Invalid font asset name");
const safe = file.replace(/[^a-zA-Z0-9._-]/g, "");
if (safe !== file) throw new Error("Invalid font asset name");
return path.join(getFontsDir(), safe);
}
+14
View File
@@ -3,6 +3,15 @@
/** Max custom font upload size (10 MB). */
export const FONT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
/** Bundled default font for preview + FFmpeg parity (Apache 2.0, Google Arimo). */
export const SYSTEM_FONT = {
key: "system",
label: "Arimo",
file: "Arimo-Regular.ttf",
boldFile: "Arimo-Bold.ttf",
previewFamily: "S2VIDPreview-system",
} as const;
export const CURATED_FONTS = [
{
key: "inter",
@@ -10,6 +19,7 @@ export const CURATED_FONTS = [
cssFamily: "Inter",
googleCss: "Inter:wght@400;600",
file: "Inter-Regular.ttf",
boldFile: null as string | null,
},
{
key: "montserrat",
@@ -17,6 +27,7 @@ export const CURATED_FONTS = [
cssFamily: "Montserrat",
googleCss: "Montserrat:wght@400;600",
file: "Montserrat-Regular.ttf",
boldFile: null as string | null,
},
{
key: "roboto",
@@ -24,6 +35,7 @@ export const CURATED_FONTS = [
cssFamily: "Roboto",
googleCss: "Roboto:wght@400;500",
file: "Roboto-Regular.ttf",
boldFile: null as string | null,
},
{
key: "oswald",
@@ -31,6 +43,7 @@ export const CURATED_FONTS = [
cssFamily: "Oswald",
googleCss: "Oswald:wght@400;500",
file: "Oswald-Regular.ttf",
boldFile: null as string | null,
},
{
key: "playfair",
@@ -38,6 +51,7 @@ export const CURATED_FONTS = [
cssFamily: "Playfair Display",
googleCss: "Playfair+Display:wght@400;600",
file: "PlayfairDisplay-Regular.ttf",
boldFile: null as string | null,
},
] as const;
+76 -13
View File
@@ -16,6 +16,7 @@ import { moveFile, writeUploadedFile } from "../fs-utils";
import {
ARTIST_MAX,
INVALID_LAYOUT_TEMPLATE_MESSAGE,
isLowerCornerTemplate,
normalizeLayoutSettings,
SONG_TITLE_MAX,
type LayoutSettings,
@@ -56,6 +57,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
metadata.layout?.blur_amount ??
metadata.blurAmount ??
metadata.blur_amount,
blurFill:
metadata.layout?.blurFill ??
(metadata.layout as { blur_fill?: boolean } | null | undefined)?.blur_fill ??
metadata.blurFill ??
metadata.blur_fill,
blurOpacity:
metadata.layout?.blurOpacity ??
(metadata.layout as { blur_opacity?: number } | null | undefined)?.blur_opacity ??
@@ -81,6 +87,11 @@ export function resolveLayoutFromMetadata(metadata: ItemMetadata): LayoutSetting
(metadata.layout as { text_offset_y?: number } | null | undefined)?.text_offset_y ??
metadata.textOffsetY ??
metadata.text_offset_y,
titleBold:
metadata.layout?.titleBold ??
(metadata.layout as { title_bold?: boolean } | null | undefined)?.title_bold ??
metadata.titleBold ??
metadata.title_bold,
});
}
@@ -180,6 +191,18 @@ export async function validateJobPayload(user: { id: string }, body: CreateJobPa
}
}
const layoutForBg = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
if (bgRaw && isLowerCornerTemplate(layoutForBg.template)) {
const bgPath = assertPathInUserUploads(user.id, bgRaw);
await fs.access(bgPath);
const st = await fs.stat(bgPath);
if (st.size > limits.maxFileSizeBytes) {
return `Background image for ${item.audioFilename} exceeds size limit`;
}
}
const wm = normalizeWatermarkSettings(
item.metadata.watermark,
item.metadata.includeWatermark,
@@ -242,28 +265,51 @@ export async function createVideoJob(
}
const imagePath = assertPathInUserUploads(user.id, body.imagePath);
const items = body.items.map((item) => ({
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
const items = body.items.map((item) => {
const layout = resolveLayoutFromMetadata(item.metadata);
const bgRaw =
item.metadata.backgroundImagePath ?? item.metadata.background_image_path ?? null;
const backgroundImagePath =
bgRaw && isLowerCornerTemplate(layout.template)
? assertPathInUserUploads(user.id, bgRaw)
: null;
return {
...item,
audioPath: assertPathInUserUploads(user.id, item.audioPath),
itemImagePath: item.metadata.imagePath
? assertPathInUserUploads(user.id, item.metadata.imagePath)
: null,
}));
backgroundImagePath,
watermarkLogoPath: item.metadata.watermark?.logoPath
? assertPathInUserUploads(user.id, item.metadata.watermark.logoPath)
: null,
watermarkFontPath:
item.metadata.watermark?.fontKey === "custom" && item.metadata.watermark?.fontPath
? assertPathInUserUploads(user.id, item.metadata.watermark.fontPath)
: null,
};
});
await reserveQuota(user.id, items.length);
const webhookRaw = body.webhookUrl?.trim() || null;
if (webhookRaw) {
try {
const u = new URL(webhookRaw);
if (u.protocol !== "https:" && u.protocol !== "http:") {
throw new Error("webhookUrl must be http(s)");
}
} catch {
throw new Error("Invalid webhookUrl. Provide an absolute http(s) URL for n8n callbacks.");
}
}
try {
const job = await prisma.job.create({
data: {
userId: user.id,
imagePath,
webhookUrl: webhookRaw,
items: {
create: items.map((item, index) => {
const wm = applyBrandWatermarkPolicy(
@@ -298,6 +344,7 @@ export async function createVideoJob(
creativeCommons: item.metadata.creativeCommons,
includeWatermark: wm.mode !== "none",
itemImagePath: item.itemImagePath,
backgroundImagePath: item.backgroundImagePath,
watermarkMode: wm.mode,
watermarkText: wm.mode === "text" ? wm.text?.trim() || null : null,
watermarkLogoPath: wm.mode === "logo" ? item.watermarkLogoPath : null,
@@ -309,10 +356,12 @@ export async function createVideoJob(
watermarkOffsetY: wm.offsetY,
artist: item.metadata.artist?.trim().slice(0, ARTIST_MAX) || null,
layoutTemplate: layout.template,
blurFill: layout.blurFill,
blurAmount: layout.blurAmount,
blurOpacity: layout.blurOpacity,
textPadding: layout.textPadding,
titleArtistGap: layout.titleArtistGap,
titleBold: layout.titleBold,
textOffsetX: layout.textOffsetX,
textOffsetY: layout.textOffsetY,
playlistId: item.metadata.playlistId?.trim() || null,
@@ -353,6 +402,19 @@ export async function createVideoJob(
}
}
let newBackgroundImage: string | null = null;
if (item.backgroundImagePath) {
const src = item.backgroundImagePath;
if (moved.has(src)) {
newBackgroundImage = moved.get(src)!;
} else {
const ext = path.extname(src);
newBackgroundImage = path.join(jobDir, `${item.id}-bg${ext}`);
await moveFile(src, newBackgroundImage);
moved.set(src, newBackgroundImage);
}
}
let newLogo: string | null = null;
if (item.watermarkLogoPath) {
const src = item.watermarkLogoPath;
@@ -383,6 +445,7 @@ export async function createVideoJob(
data: {
audioPath: newAudioPath,
itemImagePath: newItemImage,
backgroundImagePath: newBackgroundImage,
watermarkLogoPath: newLogo,
watermarkFontPath: newFont,
},
+246 -11
View File
@@ -8,6 +8,8 @@ export const LAYOUT_TEMPLATES = [
"COVER_TOP_TEXT_BOTTOM",
"COVER_RIGHT_TEXT_LEFT",
"CENTERED_COMPACT",
"LOWER_LEFT_COVER_TEXT",
"LOWER_RIGHT_COVER_TEXT",
] as const;
export type LayoutTemplate = (typeof LAYOUT_TEMPLATES)[number];
@@ -37,12 +39,31 @@ export const TEXT_OFFSET_MIN = -120;
export const TEXT_OFFSET_MAX = 120;
export const TEXT_OFFSET_DEFAULT = 0;
/** Lower-corner cover size as a fraction of encode frame width. */
export const LOWER_CORNER_COVER_WIDTH_FRACTION = 0.23;
export function lowerCornerCoverSide(
width: number,
height: number,
textPadding: number,
): number {
const edge = clampTextPadding(textPadding);
return Math.round(
Math.min(width * LOWER_CORNER_COVER_WIDTH_FRACTION, height - edge * 2),
);
}
export const ARTIST_MAX = 80;
export const SONG_TITLE_MAX = 120;
export type LayoutSettings = {
/** When null, classic letterbox (no art-track layout / blur fill). */
/** When null, classic letterbox (no art-track layout). */
template: LayoutTemplate | null;
/**
* Classic letterbox only: fill letterbox bars with a blurred cover.
* Ignored when `template` is set (art-track always uses a blur fill).
*/
blurFill: boolean;
/** 0100 → FFmpeg boxblur intensity. */
blurAmount: number;
/** 0100 → how visible the blurred fill is (vs black). */
@@ -55,16 +76,20 @@ export type LayoutSettings = {
textOffsetX: number;
/** Shift text block vertically within the template. */
textOffsetY: number;
/** Bold weight for the on-video song title (artist stays regular). */
titleBold: boolean;
};
export const DEFAULT_LAYOUT: LayoutSettings = {
template: null,
blurFill: false,
blurAmount: BLUR_AMOUNT_DEFAULT,
blurOpacity: BLUR_OPACITY_DEFAULT,
textPadding: TEXT_PADDING_DEFAULT,
titleArtistGap: TITLE_ARTIST_GAP_DEFAULT,
textOffsetX: TEXT_OFFSET_DEFAULT,
textOffsetY: TEXT_OFFSET_DEFAULT,
titleBold: true,
};
export function isLayoutTemplate(v: unknown): v is LayoutTemplate {
@@ -119,6 +144,8 @@ type RawLayoutInput = {
template?: unknown;
layoutTemplate?: unknown;
layout_template?: unknown;
blurFill?: unknown;
blur_fill?: unknown;
blurAmount?: unknown;
blur_amount?: unknown;
blurOpacity?: unknown;
@@ -131,6 +158,8 @@ type RawLayoutInput = {
text_offset_x?: unknown;
textOffsetY?: unknown;
text_offset_y?: unknown;
titleBold?: unknown;
title_bold?: unknown;
/** Rejected clients must not send free-form cover coordinates. */
x?: unknown;
y?: unknown;
@@ -189,15 +218,25 @@ export function normalizeLayoutSettings(
(input as RawLayoutInput).textOffsetY ??
(input as RawLayoutInput).text_offset_y ??
(input as LayoutSettings).textOffsetY;
const boldRaw =
(input as RawLayoutInput).titleBold ??
(input as RawLayoutInput).title_bold ??
(input as LayoutSettings).titleBold;
const blurFillRaw =
(input as RawLayoutInput).blurFill ??
(input as RawLayoutInput).blur_fill ??
(input as LayoutSettings).blurFill;
return {
template,
blurFill: template !== null ? false : Boolean(blurFillRaw),
blurAmount: clampBlurAmount(blurRaw, BLUR_AMOUNT_DEFAULT),
blurOpacity: clampBlurOpacity(opacityRaw, BLUR_OPACITY_DEFAULT),
textPadding: clampTextPadding(padRaw, TEXT_PADDING_DEFAULT),
titleArtistGap: clampTitleArtistGap(gapRaw, TITLE_ARTIST_GAP_DEFAULT),
textOffsetX: clampTextOffset(oxRaw, TEXT_OFFSET_DEFAULT),
textOffsetY: clampTextOffset(oyRaw, TEXT_OFFSET_DEFAULT),
titleBold: boldRaw === undefined || boldRaw === null ? true : Boolean(boldRaw),
};
}
@@ -342,6 +381,54 @@ export function computeLayoutGeometry(
};
break;
}
case "LOWER_LEFT_COVER_TEXT": {
// Equal left + bottom inset so the cover corner sits on a true diagonal from (0, H).
const edge = pad;
const side = lowerCornerCoverSide(width, height, pad);
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
const textX = edge + side + coverTextGap;
const coverTop = height - edge - side;
const textBlockH = titleFontSize + lineGap + artistFontSize;
const textMidY = coverTop + Math.round(side / 2);
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
base = {
coverMaxW: side,
coverMaxH: side,
coverX: String(edge),
coverY: `H-h-${edge}`,
titleFontSize,
artistFontSize,
titleX: String(textX),
titleY: String(titleY),
artistX: String(textX),
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
case "LOWER_RIGHT_COVER_TEXT": {
// Equal right + bottom inset (mirror of lower-left diagonal).
const edge = pad;
const side = lowerCornerCoverSide(width, height, pad);
const coverTextGap = Math.max(10, Math.round(pad * 0.55));
const coverTop = height - edge - side;
const textBlockH = titleFontSize + lineGap + artistFontSize;
const textMidY = coverTop + Math.round(side / 2);
const titleY = Math.max(edge, textMidY - Math.round(textBlockH / 2));
const textInset = edge + side + coverTextGap;
base = {
coverMaxW: side,
coverMaxH: side,
coverX: `W-w-${edge}`,
coverY: `H-h-${edge}`,
titleFontSize,
artistFontSize,
titleX: `W-text_w-${textInset}`,
titleY: String(titleY),
artistX: `W-text_w-${textInset}`,
artistY: String(titleY + titleFontSize + lineGap),
};
break;
}
default: {
throw new Error(INVALID_LAYOUT_TEMPLATE_MESSAGE);
}
@@ -350,13 +437,27 @@ export function computeLayoutGeometry(
return applyTextOffsets(base, textOffsetX, textOffsetY);
}
export function isLowerCornerTemplate(
template: LayoutTemplate | null | undefined,
): boolean {
return template === "LOWER_LEFT_COVER_TEXT" || template === "LOWER_RIGHT_COVER_TEXT";
}
export function buildArtTrackFilterComplex(opts: {
width: number;
height: number;
layout: LayoutSettings;
titleEscaped: string;
artistEscaped: string | null;
/** Regular-weight font for artist (and title when not bold / no bold file). */
fontfileEscaped?: string | null;
/** Bold font for title when `layout.titleBold` and a bold face is available. */
titleFontfileEscaped?: string | null;
/**
* When set, use this FFmpeg input index as the blur-fill source instead of
* splitting the cover (`[0:v]`). Cover stays on input 0 as the sharp overlay.
*/
separateBackgroundInputIndex?: number | null;
}): string {
const { width: W, height: H, layout } = opts;
if (!layout.template) {
@@ -378,15 +479,84 @@ export function buildArtTrackFilterComplex(opts: {
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
const opacity = clampBlurOpacity(layout.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const fontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${fontPart}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
const artistFontPart = opts.fontfileEscaped ? `:fontfile='${opts.fontfileEscaped}'` : "";
const titleUsesBoldFace = Boolean(layout.titleBold && opts.titleFontfileEscaped);
const titleFontPart = titleUsesBoldFace
? `:fontfile='${opts.titleFontfileEscaped}'`
: artistFontPart;
// Faux-bold stroke when bold is requested but no bold TTF is available (custom/curated).
const titleFauxBold =
layout.titleBold && !titleUsesBoldFace ? `:borderw=1:bordercolor=white@0.95` : "";
const titleDraw = `drawtext=text='${opts.titleEscaped}'${titleFontPart}${titleFauxBold}:fontsize=${geo.titleFontSize}:fontcolor=white@0.95:x=${geo.titleX}:y=${geo.titleY}`;
const artistDraw = opts.artistEscaped
? `,drawtext=text='${opts.artistEscaped}'${fontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
? `,drawtext=text='${opts.artistEscaped}'${artistFontPart}:fontsize=${geo.artistFontSize}:fontcolor=white@0.75:x=${geo.artistX}:y=${geo.artistY}`
: "";
const parts: string[] = [`[0:v]split=2[bg][fg]`];
const separateBgIdx =
typeof opts.separateBackgroundInputIndex === "number" &&
Number.isFinite(opts.separateBackgroundInputIndex) &&
opts.separateBackgroundInputIndex >= 0
? opts.separateBackgroundInputIndex
: null;
const parts: string[] = [];
const bgSrc = separateBgIdx !== null ? `[${separateBgIdx}:v]` : "[bg]";
if (separateBgIdx !== null) {
parts.push(
`[0:v]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
);
} else {
parts.push(`[0:v]split=2[bg][fg]`);
parts.push(
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
);
}
// Fade blurred fill toward black when opacity < 100%
if (opacity >= 0.999) {
parts.push(`${bgSrc}${bgChain}[blurred]`);
} else if (opacity <= 0.001) {
parts.push(`color=c=black:s=${W}x${H}:d=1[blurred]`);
} else {
const a = opacity.toFixed(3);
const b = (1 - opacity).toFixed(3);
parts.push(
`${bgSrc}${bgChain}[blur_raw]`,
`color=c=black:s=${W}x${H}:d=1[blk]`,
`[blur_raw][blk]blend=all_expr='A*${a}+B*${b}':shortest=1[blurred]`,
);
}
parts.push(
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
return parts.join(";");
}
/**
* Classic letterbox with blurred cover fill (no on-video title/artist).
* Ends at `[laid]` same convention as art-track for encode relabeling.
*/
export function buildClassicBlurFillFilterComplex(opts: {
width: number;
height: number;
blurAmount: number;
blurOpacity: number;
}): string {
const { width: W, height: H } = opts;
const blurSeg = boxblurFilterSegment(opts.blurAmount);
const bgChain = blurSeg
? `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},${blurSeg}`
: `scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H}`;
const opacity = clampBlurOpacity(opts.blurOpacity, BLUR_OPACITY_DEFAULT) / 100;
const parts: string[] = [
`[0:v]split=2[bg][fg]`,
`[fg]scale=${W}:${H}:force_original_aspect_ratio=decrease[cover]`,
];
if (opacity >= 0.999) {
parts.push(`[bg]${bgChain}[blurred]`);
} else if (opacity <= 0.001) {
@@ -401,12 +571,7 @@ export function buildArtTrackFilterComplex(opts: {
);
}
parts.push(
`[fg]scale=${geo.coverMaxW}:${geo.coverMaxH}:force_original_aspect_ratio=decrease[cover]`,
`[blurred][cover]overlay=${geo.coverX}:${geo.coverY}[composed]`,
`[composed]${titleDraw}${artistDraw}[laid]`,
);
parts.push(`[blurred][cover]overlay=(W-w)/2:(H-h)/2[laid]`);
return parts.join(";");
}
@@ -415,4 +580,74 @@ export const LAYOUT_TEMPLATE_LABELS: Record<LayoutTemplate, string> = {
COVER_TOP_TEXT_BOTTOM: "Cover top · text bottom",
COVER_RIGHT_TEXT_LEFT: "Cover right · text left",
CENTERED_COMPACT: "Centered compact",
LOWER_LEFT_COVER_TEXT: "Lower left · cover + text",
LOWER_RIGHT_COVER_TEXT: "Lower right · cover + text",
};
/** Composition picker families — mirrored pairs share one tile + variant toggles. */
export type CompositionFamilyId =
| "classic"
| "side"
| "top"
| "centered"
| "lower";
export type CompositionFamily = {
id: CompositionFamilyId;
/** Tile label in the composition grid. */
label: string;
/** Which thumb art to draw (null = classic letterbox). */
thumb: LayoutTemplate | null;
/** Templates in this family; length > 1 shows variant toggles when selected. */
variants: LayoutTemplate[];
/** Default when first selecting the family. */
defaultTemplate: LayoutTemplate | null;
};
export const COMPOSITION_FAMILIES: readonly CompositionFamily[] = [
{
id: "classic",
label: "Classic letterbox",
thumb: null,
variants: [],
defaultTemplate: null,
},
{
id: "side",
label: "Cover beside text",
thumb: "COVER_LEFT_TEXT_RIGHT",
variants: ["COVER_LEFT_TEXT_RIGHT", "COVER_RIGHT_TEXT_LEFT"],
defaultTemplate: "COVER_LEFT_TEXT_RIGHT",
},
{
id: "top",
label: "Cover top · text bottom",
thumb: "COVER_TOP_TEXT_BOTTOM",
variants: ["COVER_TOP_TEXT_BOTTOM"],
defaultTemplate: "COVER_TOP_TEXT_BOTTOM",
},
{
id: "centered",
label: "Centered compact",
thumb: "CENTERED_COMPACT",
variants: ["CENTERED_COMPACT"],
defaultTemplate: "CENTERED_COMPACT",
},
{
id: "lower",
label: "Lower corner · cover + text",
thumb: "LOWER_LEFT_COVER_TEXT",
variants: ["LOWER_LEFT_COVER_TEXT", "LOWER_RIGHT_COVER_TEXT"],
defaultTemplate: "LOWER_LEFT_COVER_TEXT",
},
] as const;
export function compositionFamilyForTemplate(
template: LayoutTemplate | null,
): CompositionFamily {
if (template === null) return COMPOSITION_FAMILIES[0]!;
const found = COMPOSITION_FAMILIES.find((f) =>
f.variants.includes(template),
);
return found ?? COMPOSITION_FAMILIES[0]!;
}
+4 -4
View File
@@ -4,7 +4,7 @@
*/
import type { WatermarkFontKey } from "./fonts";
import { CURATED_FONTS } from "./fonts";
import { CURATED_FONTS, SYSTEM_FONT } from "./fonts";
/** Matches LayoutStudio max preview width (tailwind max-w-xl ≈ 576px; use 576 for scaling). */
export const PREVIEW_REFERENCE_WIDTH = 576;
@@ -29,13 +29,13 @@ export function scaleFontToPreview(fontPx: number, encodeWidth: number): number
export function previewFontFamilyCss(fontKey: WatermarkFontKey | undefined): string {
if (!fontKey || fontKey === "system") {
return "Arial, Helvetica, sans-serif";
return `'${SYSTEM_FONT.previewFamily}', sans-serif`;
}
if (fontKey === "custom") {
return "'S2VIDCustomWm', Arial, sans-serif";
return "'S2VIDCustomWm', sans-serif";
}
const meta = CURATED_FONTS.find((f) => f.key === fontKey);
return meta ? `'S2VIDPreview-${meta.key}', Arial, sans-serif` : "Arial, sans-serif";
return meta ? `'S2VIDPreview-${meta.key}', sans-serif` : "sans-serif";
}
export function curatedFontApiUrl(key: string): string {
+17
View File
@@ -22,6 +22,12 @@ export type ItemMetadata = {
* When omitted, the job-level shared imagePath is used.
*/
imagePath?: string | null;
/**
* Optional full-frame background for LOWER_LEFT / LOWER_RIGHT templates.
* Blur/opacity apply to this image; cover stays the sharp corner square.
*/
backgroundImagePath?: string | null;
background_image_path?: string | null;
/** Custom branding watermark settings. */
watermark?: Partial<WatermarkSettings> | null;
/** Artist line for art-track layouts (on-video only). */
@@ -31,20 +37,27 @@ export type ItemMetadata = {
/**
* Art-track layout. Prefer camelCase; snake_case aliases accepted:
* layout_template, blur_amount, text_padding.
* `template`: COVER_LEFT_TEXT_RIGHT | COVER_TOP_TEXT_BOTTOM |
* COVER_RIGHT_TEXT_LEFT | CENTERED_COMPACT | LOWER_LEFT_COVER_TEXT |
* LOWER_RIGHT_COVER_TEXT (also listed on GET /api/v1 layoutTemplates).
*/
layout?: Partial<LayoutSettings> & {
layoutTemplate?: string | null;
layout_template?: string | null;
blur_fill?: boolean;
blur_amount?: number;
blur_opacity?: number;
text_padding?: number;
title_artist_gap?: number;
text_offset_x?: number;
text_offset_y?: number;
title_bold?: boolean;
} | null;
/** Flat aliases (also accepted). */
layoutTemplate?: string | null;
layout_template?: string | null;
blurFill?: boolean;
blur_fill?: boolean;
blurAmount?: number;
blur_amount?: number;
blurOpacity?: number;
@@ -57,6 +70,8 @@ export type ItemMetadata = {
text_offset_x?: number;
textOffsetY?: number;
text_offset_y?: number;
titleBold?: boolean;
title_bold?: boolean;
};
export type UploadedAudio = {
@@ -79,6 +94,8 @@ export type CreateJobPayload = {
audioFilename: string;
metadata: ItemMetadata;
}>;
/** Absolute http(s) URL — Songs2VID POSTs JSON on item/job terminal states (n8n). */
webhookUrl?: string | null;
/** Create a new playlist and attach its ID to every item (Pro). */
createPlaylist?: CreatePlaylistRequest | null;
};
+52
View File
@@ -0,0 +1,52 @@
/**
* Deliver optional job webhooks for n8n / automation consumers.
* Fire-and-forget; failures are logged and never fail the encode/upload path.
*/
type WebhookPayload = {
event: "job.item.completed" | "job.item.failed" | "job.completed" | "job.failed" | "job.partial";
jobId: string;
status: string;
itemId?: string;
youtubeVideoId?: string | null;
error?: string | null;
completedAt?: string | null;
};
export async function deliverJobWebhook(
webhookUrl: string | null | undefined,
payload: WebhookPayload,
): Promise<void> {
if (!webhookUrl?.trim()) return;
let url: URL;
try {
url = new URL(webhookUrl.trim());
} catch {
console.warn(`[webhook] invalid URL for job ${payload.jobId}`);
return;
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
console.warn(`[webhook] refused non-http(s) URL for job ${payload.jobId}`);
return;
}
try {
const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Songs2VID-Webhook/1.0",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) {
console.warn(`[webhook] ${payload.jobId}${res.status}`);
}
} catch (err) {
console.warn(
`[webhook] delivery failed for ${payload.jobId}:`,
err instanceof Error ? err.message : err,
);
}
}
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Job" ADD COLUMN IF NOT EXISTS "webhookUrl" TEXT;
+7
View File
@@ -59,6 +59,8 @@ model Job {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
status JobStatus @default(PENDING)
imagePath String
/// Optional absolute http(s) URL for n8n / automation callbacks
webhookUrl String?
items JobItem[]
createdAt DateTime @default(now())
completedAt DateTime?
@@ -82,6 +84,8 @@ model JobItem {
creativeCommons Boolean @default(false)
includeWatermark Boolean @default(false)
itemImagePath String?
/// Optional full-frame background for lower-corner layouts (blur fill). Cover stays foreground.
backgroundImagePath String?
watermarkMode String @default("none")
watermarkText String?
watermarkLogoPath String?
@@ -93,10 +97,13 @@ model JobItem {
artist String?
songTitle String?
layoutTemplate String?
/// Classic letterbox: fill black bars with blurred cover
blurFill Boolean @default(false)
blurAmount Int @default(55)
blurOpacity Int @default(100)
textPadding Int @default(48)
titleArtistGap Int @default(10)
titleBold Boolean @default(true)
textOffsetX Int @default(0)
textOffsetY Int @default(0)
playlistId String?
+27 -4
View File
@@ -9,8 +9,17 @@ import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
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 = [
{
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",
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 });
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) {
const dest = path.join(outDir, font.file);
try {
await fs.access(dest);
console.log("skip (exists)", font.file);
continue;
const existing = await fs.readFile(dest);
if (looksLikeFont(existing)) {
console.log("skip (exists)", font.file);
continue;
}
console.warn("corrupt font, re-fetching", font.file);
} catch {
/* download */
}
@@ -54,6 +73,10 @@ for (const font of FONTS) {
continue;
}
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);
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,
boxblurFilterSegment,
buildArtTrackFilterComplex,
buildClassicBlurFillFilterComplex,
clampBlurAmount,
clampTextPadding,
clampTitleArtistGap,
@@ -32,8 +33,22 @@ function testEnumsAndClamp() {
function testNormalize() {
const classic = normalizeLayoutSettings(null);
assert.equal(classic.template, null);
assert.equal(classic.blurFill, false);
assert.equal(classic.titleArtistGap, 10);
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(
normalizeLayoutSettings({
@@ -75,26 +90,117 @@ function testGeometryAndFilter() {
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));
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({
width: 1280,
height: 720,
layout: {
template: "CENTERED_COMPACT",
blurFill: false,
blurAmount: 40,
blurOpacity: 70,
textPadding: 40,
titleArtistGap: 18,
textOffsetX: 5,
textOffsetY: -8,
titleBold: true,
},
titleEscaped: sanitizeDrawtext("Hello:World"),
artistEscaped: sanitizeDrawtext("Artist"),
fontfileEscaped: null,
titleFontfileEscaped: "C\\:/fonts/Arimo-Bold.ttf",
});
assert.ok(fc.includes("split=2"));
assert.ok(fc.includes("blend=") || fc.includes("[blurred]"));
assert.ok(fc.includes("overlay="));
assert.ok(fc.includes("drawtext="));
assert.ok(fc.includes("fontfile='C\\:/fonts/Arimo-Bold.ttf'"));
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();
+1 -1
View File
@@ -19,5 +19,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "website"]
"exclude": ["node_modules", "website", "integrations"]
}
+47 -7
View File
@@ -9,8 +9,13 @@ import { getJobDir } from "../lib/storage";
import type { VideoJobData } from "../lib/types";
import { formatYouTubeErrorForUser } from "../lib/youtube/errors";
import { uploadToYouTube } from "../lib/youtube/upload";
import { deliverJobWebhook } from "../lib/webhooks";
async function updateJobStatus(jobId: string) {
const job = await prisma.job.findUniqueOrThrow({
where: { id: jobId },
select: { webhookUrl: true },
});
const items = await prisma.jobItem.findMany({ where: { jobId } });
const completed = items.filter((i) => i.status === JobItemStatus.COMPLETED).length;
const failed = items.filter((i) => i.status === JobItemStatus.FAILED).length;
@@ -23,13 +28,29 @@ async function updateJobStatus(jobId: string) {
else status = JobStatus.PARTIAL;
}
const completedAt = completed + failed === total ? new Date() : null;
await prisma.job.update({
where: { id: jobId },
data: {
status,
completedAt: completed + failed === total ? new Date() : null,
completedAt,
},
});
if (completedAt) {
const event =
status === JobStatus.COMPLETED
? "job.completed"
: status === JobStatus.FAILED
? "job.failed"
: "job.partial";
await deliverJobWebhook(job.webhookUrl, {
event,
jobId,
status,
completedAt: completedAt.toISOString(),
});
}
}
async function processJobItem(data: VideoJobData) {
@@ -70,21 +91,25 @@ async function processJobItem(data: VideoJobData) {
includeWatermark: item.includeWatermark,
songTitle: item.songTitle || item.title,
artist: item.artist,
layout: item.layoutTemplate
? {
template: item.layoutTemplate as
layout: {
template: (item.layoutTemplate as
| "COVER_LEFT_TEXT_RIGHT"
| "COVER_TOP_TEXT_BOTTOM"
| "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,
blurOpacity: item.blurOpacity ?? 100,
textPadding: item.textPadding ?? 48,
titleArtistGap: item.titleArtistGap ?? 10,
titleBold: item.titleBold ?? true,
textOffsetX: item.textOffsetX ?? 0,
textOffsetY: item.textOffsetY ?? 0,
}
: null,
},
backgroundImagePath: item.backgroundImagePath,
watermark: {
mode: (item.watermarkMode as "none" | "default" | "text" | "logo") || "default",
text: item.watermarkText,
@@ -132,6 +157,14 @@ async function processJobItem(data: VideoJobData) {
data: { createdVideoCount: { increment: 1 } },
});
await deliverJobWebhook(item.job.webhookUrl, {
event: "job.item.completed",
jobId: data.jobId,
status: "COMPLETED",
itemId: item.id,
youtubeVideoId,
});
await cleanupFiles([outputPath]);
} catch (err) {
const message = formatYouTubeErrorForUser(err);
@@ -140,6 +173,13 @@ async function processJobItem(data: VideoJobData) {
where: { id: item.id },
data: { status: JobItemStatus.FAILED, error: message },
});
await deliverJobWebhook(item.job.webhookUrl, {
event: "job.item.failed",
jobId: data.jobId,
status: "FAILED",
itemId: item.id,
error: message,
});
throw new Error(message);
} finally {
await updateJobStatus(data.jobId);