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.
This commit is contained in:
Atakan Doğan Özban
2026-08-08 16:32:28 +02:00
parent 848607f9e0
commit f9b2a997a2
27 changed files with 7333 additions and 14 deletions
+47 -4
View File
@@ -116,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].
*/
@@ -149,10 +181,19 @@ export async function encodeVideo(options: {
* 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);
@@ -225,8 +266,9 @@ export async function encodeVideo(options: {
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
@@ -321,8 +363,9 @@ export async function encodeVideo(options: {
"libx264",
"-tune",
"stillimage",
"-c:a",
"copy",
);
pushAudioEncodeArgs(args, audioEncode);
args.push(
"-shortest",
"-pix_fmt",
"yuv420p",
+13
View File
@@ -292,11 +292,24 @@ export async function createVideoJob(
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(
+2
View File
@@ -94,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,
);
}
}