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:
+47
-4
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user