DEV Community

Cover image for Building a Multi-Model Media Queue in n8n
Nathan Brooks
Nathan Brooks

Posted on Originally published at cometapi.com

Building a Multi-Model Media Queue in n8n

I treat model access and workflow orchestration as separate concerns. n8n should own validation, routing, retries, persistence, and storage. A unified multi-model API such as CometAPI can centralize credentials and endpoints without making every provider change a workflow rewrite.

The important split is not OpenAI versus ByteDance. It is synchronous image generation versus asynchronous video generation. An image request returns data; a video request creates a task that needs a durable record before polling starts.

Define the Job Contract First

The pipeline is: Google Sheets Trigger → validate → route by media type → submit → persist and poll where needed → store media → upsert result.

Create a source sheet with these columns:

job_id | media_type | model | prompt | size | seconds | status
Enter fullscreen mode Exit fullscreen mode

Create an n8n Data Table named ai_jobs with job_id, media_type, model, status, task_id, result_url, error, and updated_at. Use job_id as the upsert key. I would require stable IDs for production jobs; an execution ID fallback does not identify the same sheet row across separate executions.

In n8n, create an HTTP Header Auth credential named Media API Bearer, with header Authorization and value Bearer your_api_key. Keep it out of the sheet. The base URL for the requests below is https://api.cometapi.com/v1; append each endpoint path to that URL. Self-hosted instances can use server-side environment variables instead, with an n8n restart after environment changes.

Model and Cost Boundaries

The source's August 11, 2026 model-directory snapshot lists gpt-image-2 for OpenAI text-to-image generation through POST /v1/images/generations, returning synchronous base64 data. It lists ByteDance seedance-2-5 for asynchronous generation through POST /v1/videos, supporting text-to-video, image-to-video, 4–30 second clips, and documented 480p and 720p sizes. This workflow uses text prompts, not image-to-video inputs.

At that date, the listed image rates were $4 per million input tokens and $24 per million output tokens. Video rates were $0.103 per second at 480p and $0.231 per second at 720p: four seconds costs about $0.412 or $0.924 respectively. Treat those as dated reference prices, not a permanent configuration. Check the live model directory at /api/models on the API host and review model-specific size constraints before deployment.

Validate Before Making Requests

Configure Google Sheets Trigger for Row added or updated, followed by an IF node that accepts only empty or queued status values. This filters completed sheet rows, but it is not sufficient deduplication: also check ai_jobs before submission and skip jobs already processing or completed.

Add a Code node named Normalize Job. The snippet below processes one row; configure it to run once for all items and feed it one item at a time, using a loop when an execution contains multiple rows.

const row = $json;
const allowedModels = { image: new Set(['gpt-image-2']), video: new Set(['seedance-2-5']) };
const mediaType = String(row.media_type || '').trim().toLowerCase();
if (!allowedModels[mediaType]) throw new Error(`media_type must be image or video; received: ${row.media_type}`);
const model = String(row.model || (mediaType === 'image' ? 'gpt-image-2' : 'seedance-2-5')).trim();
if (!allowedModels[mediaType].has(model)) throw new Error(`Model ${model} is not allowed for ${mediaType} jobs`);
const prompt = String(row.prompt || '').trim();
if (!prompt) throw new Error('prompt is required');
const seconds = mediaType === 'video' ? Number(row.seconds || 4) : null;
if (mediaType === 'video' && (!Number.isInteger(seconds) || seconds < 4 || seconds > 30)) throw new Error('Seedance 2.5 seconds must be an integer from 4 to 30');
return [{ json: { job_id: String(row.job_id || $execution.id), media_type: mediaType, model, prompt, size: String(row.size || (mediaType === 'image' ? '1024x1024' : '1280x720')), seconds, status: 'processing', updated_at: new Date().toISOString() } }];
Enter fullscreen mode Exit fullscreen mode

This validates modality, model, prompt, and duration, but only defaults size; add a reviewed size allowlist before exposing the sheet to unrestricted input. Persist the processing record, then use a Switch node on media_type to select the image or video branch.

Image Branch: Convert, Upload, Record

Configure an HTTP Request node named Create Image: POST to /images/generations, using Media API Bearer, with a JSON body. Map each body field using n8n expressions:

{"model":"={{ $('Normalize Job').item.json.model }}","prompt":"={{ $('Normalize Job').item.json.prompt }}","size":"={{ $('Normalize Job').item.json.size }}"}
Enter fullscreen mode Exit fullscreen mode

Add Prepare Image File as a Code node. It extracts the first base64 image into binary property media, rather than carrying the payload into the Data Table:

const job = $('Normalize Job').item.json;
const b64 = $json.data?.[0]?.b64_json;
if (!b64) throw new Error('API returned no image data');
return [{ json: { ...job, status: 'completed', task_id: '', result_url: '', error: '', updated_at: new Date().toISOString() }, binary: { media: { data: b64, mimeType: 'image/png', fileName: `${job.job_id}.png` } } }];
Enter fullscreen mode Exit fullscreen mode

Connect that output to S3, Google Drive, or your chosen storage node. Preserve the job metadata across the upload, map the stored asset's URL into result_url, and only then upsert the completed record. Large base64 strings do not belong in ai_jobs.

Video Branch: Persist Before Polling

Configure Create Video as an HTTP Request node: POST to /videos, the same credential, and Form-Data body content. Add model, prompt, seconds, and size, each mapped from Normalize Job. Follow it with Save Video Task:

const job = $('Normalize Job').item.json;
const taskId = $json.id || $json.task_id;
if (!taskId) throw new Error('Video task ID missing from create response');
return [{ json: { ...job, task_id: taskId, status: $json.status || 'queued', result_url: '', error: '', updated_at: new Date().toISOString() } }];
Enter fullscreen mode Exit fullscreen mode

Upsert this record before entering a Wait node set to 15 seconds. Persistence makes recovery possible after a restart or timeout; it does not itself implement recovery. A recovery execution needs to read unfinished records and resume polling their stored task IDs, not submit new generation requests.

Configure Get Video as an authenticated GET to /videos/{task_id}. In this execution, obtain the ID from $('Save Video Task').item.json.task_id; do not assume each polling response preserves a field named task_id. For recovery executions, use the ID loaded from the table.

Switch on the response status: send queued and in_progress back to Wait, completed to Finalize Video, and failed or error to a terminal failure branch. Bound the loop with a persisted attempt count and timeout. The completed branch uses:

const prior = $('Save Video Task').item.json;
const resultUrl = $json.video_url || $json.url || $json.data?.video_url;
if (!resultUrl) throw new Error('Completed video response has no video URL');
return [{ json: { ...prior, status: 'completed', result_url: resultUrl, error: '', updated_at: new Date().toISOString() } }];
Enter fullscreen mode Exit fullscreen mode

Generated video URLs can be signed and temporary. Download the completed asset, upload it to controlled storage, and replace result_url with the durable URL before the final upsert. Where the selected model supports callbacks and your application accepts inbound requests, a webhook can replace polling.

For the terminal failure branch, map these fields in an Edit Fields node and upsert by job_id:

{"job_id":"={{ $('Save Video Task').item.json.job_id }}","status":"failed","task_id":"={{ $('Save Video Task').item.json.task_id }}","result_url":"","error":"={{ $json.error?.message || $json.message || 'Video generation failed' }}","updated_at":"={{ $now.toISO() }}"}
Enter fullscreen mode Exit fullscreen mode

Exercise Both Paths

Use these two sheet rows to test submission, storage, and final table state:

img-001 | image | gpt-image-2 | A cinematic product photo of a glass robot on a dark desk | 1024x1024 | | queued
vid-001 | video | seedance-2-5 | A paper airplane flies through a sunlit studio, smooth tracking shot | 1280x720 | 4 | queued
Enter fullscreen mode Exit fullscreen mode

An image response should contain data[0].b64_json; the source's illustrative response has created: 1786400000. A video creation response can look like {"id":"video_task_abc123","object":"video","status":"queued","progress":0}. Completion should preserve the task identity and provide status: "completed" with a video URL. Optional fields vary by model, so persist normalized fields rather than the entire response.

Operational Rules I Would Enforce

Handle HTTP failures explicitly. For 401, verify the active key and Bearer prefix. For 404, check model availability and the task ID used in GET /v1/videos/{id}. For 400, check supported sizes and the 4–30 second duration constraint. Reduce concurrency on 429 and use exponential backoff with jitter; also back off on 500 and 503 without blindly creating duplicate tasks.

Keep retries separate from fallbacks. An IF or Switch can route model-specific failures to another model, but the replacement must support the same modality and required capabilities. A shared credential does not make request parameters or response contracts interchangeable.

Bound cost and retain an audit trail. Poll every 10–20 seconds with capped attempts and simultaneous executions. Record model, resolution, duration, usage, sanitized parameters, task ID, status transitions, retry count, response time, final asset location, and checksum. Never log credentials or full private prompts. Refresh availability and pricing on a schedule, but review allowlist changes before deploying them.

The reusable part is the orchestration: intake, validation, durable task state, bounded polling, and storage. Adding a model should change the allowlist and the relevant request adapter while leaving those responsibilities intact.


Originally published at cometapi.com

Top comments (0)