From 30d810b35709862b432f3b32c653918cc4183e5d Mon Sep 17 00:00:00 2001 From: Jesus Date: Tue, 6 Oct 2026 07:35:07 +0200 Subject: [PATCH 1/5] Move the API spec to the version that carries the generation endpoints --- package-lock.json | 44 ++++---------------------------------------- package.json | 4 ++-- 2 files changed, 6 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc7d1de..d28cea7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,7 @@ "license": "MIT", "devDependencies": { "@n8n/node-cli": "^0.44.4", - "@shotstack/cli": "^0.8.1", - "@shotstack/schemas": "1.17.0", + "@shotstack/schemas": "1.22.0", "eslint": "9.32.0", "prettier": "3.6.2", "release-it": "^19.0.4", @@ -3046,45 +3045,10 @@ "node": ">=18" } }, - "node_modules/@shotstack/cli": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@shotstack/cli/-/cli-0.8.1.tgz", - "integrity": "sha512-xH25C1WHVJ5Kq1mXB/5E4u9MH6170zD921ibXoWzbkzDGuRcRy5q4xwF5dNDrr4+fUFIGrlnKdd5YZ/cg7l1Hg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@shotstack/schemas": "^1.17.0", - "commander": "^12.1.0", - "zod": "^4.4.3" - }, - "bin": { - "shotstack": "dist/shotstack.js" - } - }, - "node_modules/@shotstack/cli/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@shotstack/cli/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@shotstack/schemas": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@shotstack/schemas/-/schemas-1.17.0.tgz", - "integrity": "sha512-ilFAJcO8vZ9X/vC0uri8ZX+cM1BMnvx0cnUiU4sdVbPmJVdN0uta2XBL1rYWC1G/0GZUSZc9/ql0EwDSk0n4mw==", + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@shotstack/schemas/-/schemas-1.22.0.tgz", + "integrity": "sha512-GF+F4xSfh1395xP1BYAwEzJr/3cA8Y1I3F9Xkvndaj8aDE6IBaiDxf+5s/HwgfjEZ1FayUQVZqCV7HMxvyRJqA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 365f501..558d3c9 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "lint:fix": "npm run gen && n8n-node lint --fix", "release": "n8n-node release", "prepublishOnly": "n8n-node prerelease", - "test": "npm run build && node test/package-loads.mjs && node test/credential-host-scope.mjs && node test/docs-match-ui.mjs && node test/environment-mapping.mjs && node test/wait-and-backoff.mjs && node test/submit-guards.mjs", + "test": "npm run build && node test/package-loads.mjs && node test/credential-host-scope.mjs && node test/docs-match-ui.mjs && node test/environment-mapping.mjs && node test/wait-and-backoff.mjs && node test/submit-guards.mjs && node test/generation-guards.mjs", "gen": "node scripts/build-user-agent.mjs" }, "files": [ @@ -55,7 +55,7 @@ }, "devDependencies": { "@n8n/node-cli": "^0.44.4", - "@shotstack/schemas": "1.17.0", + "@shotstack/schemas": "1.22.0", "eslint": "9.32.0", "prettier": "3.6.2", "release-it": "^19.0.4", From cb76355df2e97157fee0c9a547b762e51cf1d80b Mon Sep 17 00:00:00 2001 From: Jesus Date: Tue, 6 Oct 2026 07:35:08 +0200 Subject: [PATCH 2/5] Add the Generation resource: generate, quote, status and models --- MAINTAINING.md | 5 +- README.md | 40 +++ nodes/Shotstack/Shotstack.node.ts | 10 + nodes/Shotstack/loadOptions/getModels.ts | 44 +++ .../resources/generate/getGenerate.ts | 29 ++ nodes/Shotstack/resources/generate/index.ts | 91 ++++++ .../resources/generate/postGenerate.ts | 279 ++++++++++++++++++ nodes/Shotstack/resources/renderId.ts | 15 + test/generation-guards.mjs | 135 +++++++++ 9 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 nodes/Shotstack/loadOptions/getModels.ts create mode 100644 nodes/Shotstack/resources/generate/getGenerate.ts create mode 100644 nodes/Shotstack/resources/generate/index.ts create mode 100644 nodes/Shotstack/resources/generate/postGenerate.ts create mode 100644 test/generation-guards.mjs diff --git a/MAINTAINING.md b/MAINTAINING.md index e5759cb..747b8ac 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -122,14 +122,17 @@ only eight. | --- | --- | | `package.json` | the package name `@shotstack/n8n-nodes-shotstack`, and the built paths `dist/nodes/Shotstack/Shotstack.node.js` and `dist/credentials/ShotstackApi.credentials.js` | | `credentials/ShotstackApi.credentials.ts` | the credential type `shotstackApi`; the fields `environment` and `apiKey`; the values `sandbox` and `production` | -| `nodes/Shotstack/Shotstack.node.ts` | the node type `shotstack`; the behaviour of version 1, which n8n stamps on every saved node as typeVersion; the field `resource`; the values `render` and `asset` | +| `nodes/Shotstack/Shotstack.node.ts` | the node type `shotstack`; the behaviour of version 1, which n8n stamps on every saved node as typeVersion; the field `resource`; the values `render`, `asset` and `generate` | | `nodes/Shotstack/resources/render/index.ts` | the field `operation`; the values `postRender`, `postTemplateRender` and `getRender` | | `nodes/Shotstack/resources/asset/index.ts` | the field `operation`; the value `getAssetByRenderId` | +| `nodes/Shotstack/resources/generate/index.ts` | the field `operation`; the values `postGenerate`, `getGenerate`, `getModels` and `postGenerateQuote` | | `nodes/Shotstack/Shotstack.node.json` | the codex key `@shotstack/n8n-nodes-shotstack.shotstack`. n8n matches the codex to the node on that exact string, which joins the package name to the node type, so it is a third place either can break | | `nodes/Shotstack/resources/render/postRender.ts` | the fields `edit` and `callback` | | `nodes/Shotstack/resources/render/postTemplateRender.ts` | the fields `templateId`, `mergeSource`, `mergeJson` and `merge`; the picker modes `list` and `id`; the collection key `mergeFields` and its fields `find` and `replace`; the values `fields` and `json` | | `nodes/Shotstack/resources/render/getRender.ts` | the fields `renderId`, `waitForCompletion`, `giveUpAfter`, `includeData` and `simple`; the Simplify keys `id`, `status`, `url`, `poster`, `thumbnail`, `duration`, `renderTime`, `error` and `data` | | `nodes/Shotstack/resources/asset/getAssetByRenderId.ts` | the fields `renderId`, `mainFileOnly` and `simple`; the Simplify keys `assetId`, `renderId`, `url`, `filename` and `status`. `assetId` departs from the spec on purpose: the spec calls it `id`, but only inside an asset object, and Simplify flattens that object away. Do not correct it back | +| `nodes/Shotstack/resources/generate/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length`, `idempotencyKey` and `waitForAsset`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | +| `nodes/Shotstack/resources/generate/getGenerate.ts` | the field `generationId` | | `nodes/Shotstack/telemetry.ts` | the origin value `n8n`. Shotstack keeps a fixed vocabulary of origins, and this node joins it beside the ones for the API, the CLI, the MCP server, Studio and the playground. Only this node can send it, so it is what separates this node from n8n traffic that is not this node | | `scripts/build-user-agent.mjs` | the product token `shotstack-n8n-node`. It writes the user agent file, which git ignores, so edit the generator and never the output. The version after the slash is meant to move | diff --git a/README.md b/README.md index 72ddd78..2a690e2 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,10 @@ and the API reference read the same way. | **Render → Render Template** | `POST /templates/render` · `postTemplateRender` | | **Render → Get Render Status** | `GET /render/{id}` · `getRender` | | **Asset → Get Asset by Render ID** | `GET /assets/render/{id}` · `getAssetByRenderId` (Serve API) | +| **Generation → Generate Asset** | `POST /generate` · `postGenerate` | +| **Generation → Get Generation Status** | `GET /generate/{id}` · `getGenerate` | +| **Generation → List Generation Models** | `GET /models` · `getModels` | +| **Generation → Quote Generation** | `POST /generate/quote` · `postGenerateQuote` | Every operation has an entry in the spec. The node adds none of its own. @@ -143,6 +147,42 @@ completes. The publish time varies, so a fixed Wait node does not fit. This operation waits for you, up to two minutes, and names the cause if the file does not appear. Do not add a Wait node after the render finishes. +### Generation → Generate Asset + +Generates one image, video or audio file from a prompt, without rendering a +whole edit. Use the URL it returns as an asset in a later render. + +| Field | Notes | +| --- | --- | +| **Asset Type** | Image, video or audio. It decides which models the picker offers. | +| **Prompt** | What to generate. For a text-to-speech model this is the text that gets spoken. Empty by default, so an agent that omits it spends nothing. | +| **Model Name or ID** | Pick one from the list, or leave it on the default for the asset type. The list comes from your account, so a newly launched model needs no release of this node. | +| **Model Options** | Optional JSON of settings for the chosen model. **List Generation Models** returns what each one accepts. | +| **Clip Length (Seconds)** | Only read by models that generate to a duration. 0 uses the model default. | +| **Idempotency Key** | Optional. Set a different key to get a fresh take on a prompt generated before. | +| **Wait for the Asset** | On by default, so the step returns the finished URL rather than a job ID. Turn it off for a long video. | + +Shotstack bills generation in credits per asset, in Sandbox and in Production. +Generating the same asset twice returns the first result and is not billed +again, so a retried workflow costs nothing extra. + +### Generation → Quote Generation + +Estimates the credits a generation would cost. Takes the same fields as +**Generate Asset**, starts no job and spends nothing. + +### Generation → Get Generation Status + +| Field | Notes | +| --- | --- | +| **Generation ID** | The id returned by **Generate Asset**. The default reads it from the previous step. | + +### Generation → List Generation Models + +Returns one item per model this account can generate with, each carrying the +JSON Schema of the options it accepts. Point an AI agent here before it +generates anything, so it picks a model that exists rather than one it recalls. + ### Output shape This node unwraps Shotstack's response envelope, so read `{{$json.id}}`, not diff --git a/nodes/Shotstack/Shotstack.node.ts b/nodes/Shotstack/Shotstack.node.ts index 1cc251d..e6619db 100644 --- a/nodes/Shotstack/Shotstack.node.ts +++ b/nodes/Shotstack/Shotstack.node.ts @@ -1,9 +1,11 @@ import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow'; import { renderDescription } from './resources/render'; import { assetDescription } from './resources/asset'; +import { generateDescription } from './resources/generate'; import { TELEMETRY_HEADERS } from './telemetry'; import { EDIT_BASE_URL } from './environment'; import { getTemplates } from './listSearch/getTemplates'; +import { getModels } from './loadOptions/getModels'; export class Shotstack implements INodeType { description: INodeTypeDescription = { @@ -53,6 +55,10 @@ export class Shotstack implements INodeType { name: 'Asset', value: 'asset', }, + { + name: 'Generation', + value: 'generate', + }, { name: 'Render', value: 'render', @@ -62,6 +68,7 @@ export class Shotstack implements INodeType { }, ...renderDescription, ...assetDescription, + ...generateDescription, ], }; @@ -69,5 +76,8 @@ export class Shotstack implements INodeType { listSearch: { getTemplates, }, + loadOptions: { + getModels, + }, }; } diff --git a/nodes/Shotstack/loadOptions/getModels.ts b/nodes/Shotstack/loadOptions/getModels.ts new file mode 100644 index 0000000..482eaf3 --- /dev/null +++ b/nodes/Shotstack/loadOptions/getModels.ts @@ -0,0 +1,44 @@ +import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow'; +import { TELEMETRY_HEADERS } from '../telemetry'; +import { apiPathFor } from '../environment'; + +type GenerationModel = { + model: string; + type: string; + name?: string; + available?: boolean; +}; + +/** + * Lists the generation models the account can use, for the Model dropdown. + * + * Fetched rather than hard coded: a model launched after this release shows up + * without one. Filtered by the chosen asset type, because a video model in an + * image picker only produces a 400. + */ +export async function getModels(this: ILoadOptionsFunctions): Promise { + const credentials = await this.getCredentials('shotstackApi'); + const assetType = String(this.getCurrentNodeParameter('assetType') ?? ''); + + const response = (await this.helpers.httpRequestWithAuthentication.call(this, 'shotstackApi', { + method: 'GET', + url: `https://api.shotstack.io/edit/${apiPathFor(credentials?.environment)}/models`, + json: true, + timeout: 30000, + headers: { ...TELEMETRY_HEADERS }, + })) as IDataObject; + + const models = ((response?.models ?? []) as GenerationModel[]) + // `available` is omitted when the account's access cannot be read. Only a + // stated false means the plan does not include the model. + .filter((m) => m.available !== false) + .filter((m) => !assetType || m.type === assetType) + .sort((a, b) => (a.name ?? a.model).localeCompare(b.name ?? b.model)); + + // Empty leaves `model` out of the request, so the API picks its own default + // for the asset type. Listed first so it is the obvious choice. + return [ + { name: 'Default for This Asset Type', value: '' }, + ...models.map((m) => ({ name: m.name || m.model, value: m.model })), + ]; +} diff --git a/nodes/Shotstack/resources/generate/getGenerate.ts b/nodes/Shotstack/resources/generate/getGenerate.ts new file mode 100644 index 0000000..bbe8805 --- /dev/null +++ b/nodes/Shotstack/resources/generate/getGenerate.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { requireGenerationId } from '../renderId'; + +const showOnly = { + resource: ['generate'], + operation: ['getGenerate'], +}; + +export const getGenerateDescription: INodeProperties[] = [ + { + displayName: 'Generation ID', + name: 'generationId', + type: 'string', + required: true, + default: '={{ $json.id }}', + placeholder: '8a1f2c3d-4e5b-5a6c-9d7e-1f2a3b4c5d6e', + displayOptions: { show: showOnly }, + description: + 'The ID returned when the generation was submitted. The default reads it from the previous step.', + routing: { + request: { + url: '=/generate/{{$value}}', + }, + send: { + preSend: [requireGenerationId], + }, + }, + }, +]; diff --git a/nodes/Shotstack/resources/generate/index.ts b/nodes/Shotstack/resources/generate/index.ts new file mode 100644 index 0000000..602b061 --- /dev/null +++ b/nodes/Shotstack/resources/generate/index.ts @@ -0,0 +1,91 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { postGenerateDescription } from './postGenerate'; +import { getGenerateDescription } from './getGenerate'; + +const showOnlyForGenerate = { + resource: ['generate'], +}; + +// Operation names and values come from Shotstack's OpenAPI spec: the display +// name is the operation summary, the value is the operationId. +// +// None of these answers is wrapped in { success, message, response }, so unlike +// the render operations none of them unwraps anything. List Generation Models +// is the one exception: its answer is an object holding the array, and n8n +// should hand back one item per model. +export const generateDescription: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: showOnlyForGenerate }, + options: [ + { + name: 'Generate Asset', + value: 'postGenerate', + description: + 'Generate a single image, video or audio file from a text prompt, without rendering a whole edit. Use the URL it returns as an asset in a later render.', + action: 'Generate an AI asset from a prompt', + routing: { + request: { + method: 'POST', + url: '/generate', + }, + }, + }, + { + name: 'Get Generation Status', + value: 'getGenerate', + description: 'Check a generation that was submitted earlier and get its URL', + action: 'Get the status of a generation', + routing: { + request: { + method: 'GET', + }, + }, + }, + { + name: 'List Generation Models', + value: 'getModels', + description: + 'List the models this account can generate with, and the options each one accepts. Point an AI agent here before it generates anything.', + action: 'List the available generation models', + routing: { + request: { + method: 'GET', + url: '/models', + // Always ask for the option schemas. A model list without them + // only answers half the question, and an agent that has to call + // twice usually calls once and guesses. + qs: { expand: 'options' }, + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { property: 'models' }, + }, + ], + }, + }, + }, + { + name: 'Quote Generation', + value: 'postGenerateQuote', + description: + 'Estimate the credits a generation would cost. Spends nothing and starts no job.', + action: 'Quote the credits for a generation', + routing: { + request: { + method: 'POST', + url: '/generate/quote', + }, + }, + }, + ], + default: 'postGenerate', + }, + ...postGenerateDescription, + ...getGenerateDescription, +]; diff --git a/nodes/Shotstack/resources/generate/postGenerate.ts b/nodes/Shotstack/resources/generate/postGenerate.ts new file mode 100644 index 0000000..d2d98b3 --- /dev/null +++ b/nodes/Shotstack/resources/generate/postGenerate.ts @@ -0,0 +1,279 @@ +import { NodeOperationError, sleep } from 'n8n-workflow'; +import type { + DeclarativeRestApiSettings, + IDataObject, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHttpRequestOptions, + INodeExecutionData, + INodeProperties, + PreSendAction, +} from 'n8n-workflow'; +import { TELEMETRY_HEADERS } from '../../telemetry'; +import { isRateLimited, pollGapMs, RATE_LIMIT_HELP } from '../../polling'; + +const showOnly = { + resource: ['generate'], + operation: ['postGenerate'], +}; + +// Quote takes the same body as Generate Asset, so one set of fields builds both +// and only the route differs. A quote spends nothing, which is the point of it. +const showForBoth = { + resource: ['generate'], + operation: ['postGenerate', 'postGenerateQuote'], +}; + +const POLL_GAP_MS = 5000; +const MAX_GAP_MS = 20000; +const REQUEST_TIMEOUT_MS = 30000; +// The same ceiling as a render wait. n8n runs items one at a time, so a longer +// one risks the 1 hour EXECUTIONS_TIMEOUT_MAX ending the whole run. +const MAX_MINUTES = 10; + +/** + * Builds the generation body from the separate fields. + * + * Kept out of a routing expression for the reason postRender gives: an + * expression that throws fails the node rather than the item, so Continue On + * Fail never sees it. Here it also leaves an unset Model and unset Options out + * of the body, rather than sending empty values the API rejects. + */ +const buildGenerationBody: PreSendAction = async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +) { + const prompt = String(this.getNodeParameter('prompt', '') ?? '').trim(); + if (!prompt) { + throw new NodeOperationError(this.getNode(), 'The Prompt field is empty', { + description: 'Describe the asset to generate. For a speech model this is the text spoken.', + itemIndex: this.getItemIndex(), + }); + } + + const asset: IDataObject = { + type: this.getNodeParameter('assetType', 'image'), + prompt, + }; + + const model = String(this.getNodeParameter('model', '') ?? '').trim(); + if (model) asset.model = model; + + const raw = this.getNodeParameter('modelOptions', '') as string | IDataObject; + const text = typeof raw === 'string' ? raw.trim() : ''; + if (typeof raw === 'string' ? text !== '' && text !== '{}' : Boolean(raw)) { + let options: IDataObject; + if (typeof raw === 'string') { + try { + options = JSON.parse(text) as IDataObject; + } catch (error) { + throw new NodeOperationError(this.getNode(), 'Model Options is not valid JSON', { + description: (error as Error).message, + itemIndex: this.getItemIndex(), + }); + } + } else { + options = raw; + } + // A bare string or array parses cleanly and then spreads into numbered + // keys, which the API rejects with nothing the user can act on. + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw new NodeOperationError(this.getNode(), 'Model Options is not a JSON object', { + description: + 'It holds the settings for the chosen model, such as {"aspectRatio": "16:9"}. List Generation Models returns the options each model accepts.', + itemIndex: this.getItemIndex(), + }); + } + asset.options = options; + } + + const body: IDataObject = { asset }; + + // Only a model that generates to a duration reads this; the rest ignore it. + // Zero means not set, because the API rejects a length that is not positive + // and every model has its own default. + const length = Number(this.getNodeParameter('length', 0)); + if (Number.isFinite(length) && length > 0) body.length = length; + + // Without a key, identical assets share one cached result, so a retried + // workflow is not billed twice. With one, a loop can ask for a second take + // of the same prompt. + const idempotencyKey = String(this.getNodeParameter('idempotencyKey', '') ?? '').trim(); + if (idempotencyKey) { + requestOptions.headers = { ...requestOptions.headers, 'Idempotency-Key': idempotencyKey }; + } + + requestOptions.body = body; + return requestOptions; +}; + +/** + * Submits the generation, then polls until the asset exists. + * + * The submit answers either 200 with a finished job, when the same asset was + * generated before, or 202 with a queued one. Without this the workflow gets an + * ID and no asset, and needs a Wait node and a Switch that loops back. + */ +const waitForGeneration = async function ( + this: IExecutePaginationFunctions, + requestData: DeclarativeRestApiSettings.ResultOptions, +): Promise { + const items = await this.makeRoutingRequest(requestData); + if (!this.getNodeParameter('waitForAsset', false)) return items; + + const job = (items[0]?.json ?? {}) as IDataObject; + const id = String(job.id ?? ''); + let last = String(job.status ?? 'unknown'); + + const failed = (error: unknown): never => { + throw new NodeOperationError(this.getNode(), 'The generation failed', { + description: + typeof error === 'string' && error + ? `Shotstack reported: ${error}` + : 'The response carries no error detail.', + itemIndex: this.getItemIndex(), + }); + }; + + if (last === 'failed') failed(job.error); + // A cache hit comes back done, with its URL already set. + if (last === 'done' || !id) return items; + + const url = `${String(requestData.options.baseURL ?? '')}/generate/${id}`; + const deadline = Date.now() + MAX_MINUTES * 60000; + let response: { statusCode: number; body: IDataObject; headers: IDataObject } | undefined; + let throttled = false; + + for (let attempt = 0; ; attempt++) { + // Wait first. The submit already answered with a status, so polling + // straight away spends a request to be told the same thing. + const gap = pollGapMs(attempt, POLL_GAP_MS, MAX_GAP_MS, response); + if (Date.now() + gap >= deadline) break; + await sleep(gap); + + try { + response = (await this.helpers.httpRequestWithAuthentication.call(this, 'shotstackApi', { + method: 'GET', + url, + json: true, + returnFullResponse: true, + ignoreHttpStatusErrors: true, + timeout: REQUEST_TIMEOUT_MS, + headers: { ...TELEMETRY_HEADERS }, + })) as { statusCode: number; body: IDataObject; headers: IDataObject }; + } catch { + // A dropped connection is not an answer. Keep waiting. + } + + if (isRateLimited(response)) throttled = true; + + if (response?.statusCode === 200) { + // The generation endpoints answer with the job itself, not the + // { success, message, response } envelope the rest of the Edit API + // wraps its answers in. + const body = (response.body ?? {}) as IDataObject; + last = String(body.status ?? last); + if (last === 'failed') failed(body.error); + if (last === 'done') return [{ json: body, pairedItem: { item: this.getItemIndex() } }]; + } + } + + throw new NodeOperationError( + this.getNode(), + `The asset is still ${last} after ${MAX_MINUTES} minutes`, + { + description: throttled + ? RATE_LIMIT_HELP + : 'Generation keeps going after this runs out. Turn off Wait for the Asset, keep the ID it returns, and read the result later with Get Generation Status.', + itemIndex: this.getItemIndex(), + }, + ); +}; + +export const postGenerateDescription: INodeProperties[] = [ + { + displayName: 'Asset Type', + name: 'assetType', + type: 'options', + noDataExpression: true, + options: [ + { name: 'Audio', value: 'audio' }, + { name: 'Image', value: 'image' }, + { name: 'Video', value: 'video' }, + ], + default: 'image', + displayOptions: { show: showForBoth }, + description: 'The kind of asset to generate. It decides which models are offered.', + }, + { + displayName: 'Prompt', + name: 'prompt', + type: 'string', + required: true, + // Empty on purpose. Generation spends credits, and this node is usable as + // an AI tool, so a sample here would bill on a call that omits the field. + default: '', + placeholder: 'A lighthouse on a rocky coast at sunset, cinematic lighting', + typeOptions: { rows: 3 }, + displayOptions: { show: showForBoth }, + description: 'What to generate. For a text-to-speech model this is the text that gets spoken.', + routing: { + send: { preSend: [buildGenerationBody] }, + }, + }, + { + displayName: 'Model Name or ID', + name: 'model', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getModels', + loadOptionsDependsOn: ['assetType'], + }, + default: '', + displayOptions: { show: showForBoth }, + description: + 'The generation model to use. Choose from the list, or specify an ID using an expression.', + }, + { + displayName: 'Model Options', + name: 'modelOptions', + type: 'json', + default: '', + placeholder: '{"aspectRatio": "16:9"}', + typeOptions: { rows: 3 }, + displayOptions: { show: showForBoth }, + description: + 'Settings for the chosen model, such as an aspect ratio, a voice or a starting image. Run List Generation Models to see what each one accepts. Leave empty to use its defaults.', + }, + { + displayName: 'Clip Length (Seconds)', + name: 'length', + type: 'number', + default: 0, + typeOptions: { minValue: 0 }, + displayOptions: { show: showForBoth }, + description: + 'The length of the clip this asset has to fill. A model that generates to a duration uses it instead of its own duration option, and the rest ignore it. Leave at 0 for the model default.', + }, + { + displayName: 'Idempotency Key', + name: 'idempotencyKey', + type: 'string', + default: '', + displayOptions: { show: showOnly }, + description: + 'Optional. Without one, generating the same asset twice returns the first result and is not billed again. Set a different key each time to get a fresh take on the same prompt.', + }, + { + displayName: 'Wait for the Asset', + name: 'waitForAsset', + type: 'boolean', + default: true, + displayOptions: { show: showOnly }, + description: 'Whether to keep checking until the asset is ready and return its URL, instead of returning a job ID straight away. Turn it off for a long video and read the result later with Get Generation Status.', + routing: { + send: { paginate: true }, + operations: { pagination: waitForGeneration }, + }, + }, +]; diff --git a/nodes/Shotstack/resources/renderId.ts b/nodes/Shotstack/resources/renderId.ts index ebe5c60..4ddcbeb 100644 --- a/nodes/Shotstack/resources/renderId.ts +++ b/nodes/Shotstack/resources/renderId.ts @@ -24,3 +24,18 @@ export const requireRenderId: PreSendAction = async function ( } return requestOptions; }; + +/** The same guard for a generation job ID, which also goes straight into a path. */ +export const requireGenerationId: PreSendAction = async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +) { + const value = String(this.getNodeParameter('generationId', '') ?? '').trim(); + if (!isRenderId(value)) { + throw new NodeOperationError(this.getNode(), 'That is not a Shotstack generation ID', { + description: `A generation ID looks like 8a1f2c3d-4e5b-5a6c-9d7e-1f2a3b4c5d6e. Got "${value}". Generate Asset returns it as "id".`, + itemIndex: this.getItemIndex(), + }); + } + return requestOptions; +}; diff --git a/test/generation-guards.mjs b/test/generation-guards.mjs new file mode 100644 index 0000000..d07e3f6 --- /dev/null +++ b/test/generation-guards.mjs @@ -0,0 +1,135 @@ +// What Generate Asset sends, and when it stops waiting. +// +// npm test +// +// Generation spends credits, so the body is built in code rather than in a +// routing expression: an unset Model or Options has to be absent, not empty, +// and an empty prompt has to fail here rather than after a billed round trip. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const pkg = require('../package.json'); +const node = new (Object.values(require(`../${pkg.n8n.nodes[0]}`))[0])(); +const properties = node.description.properties; + +const shownFor = (name, operation) => + properties.find( + (p) => p.name === name && p.displayOptions?.show?.operation?.includes(operation), + ); + +const preSend = shownFor('prompt', 'postGenerate').routing.send.preSend[0]; +const paginate = shownFor('waitForAsset', 'postGenerate').routing.operations.pagination; + +const node_ = () => ({ name: 'Shotstack' }); + +const send = async (params) => + await preSend.call( + { + getNodeParameter: (name, fallback) => (name in params ? params[name] : fallback), + getNode: node_, + getItemIndex: () => 0, + }, + { headers: { 'x-shotstack-origin': 'n8n' }, body: {} }, + ); + +const rejected = async (params) => { + try { + await send(params); + return null; + } catch (error) { + return error; + } +}; + +// Polling a real endpoint is not what these cases are about. Any request here +// means the loop failed to stop on an answer it already had. +const neverPolls = { + httpRequestWithAuthentication: async () => { + throw new Error('polled when it should not have'); + }, +}; + +const wait = async (job, waitForAsset = true) => + await paginate.call( + { + makeRoutingRequest: async () => [{ json: job }], + getNodeParameter: (name, fallback) => (name === 'waitForAsset' ? waitForAsset : fallback), + getNode: node_, + getItemIndex: () => 0, + helpers: neverPolls, + }, + { options: { baseURL: 'https://api.shotstack.io/edit/stage', url: '/generate' } }, + ); + +let passed = 0; +const check = async (label, run) => { + await run(); + passed += 1; + console.log(` ok ${label}`); +}; + +await check('an unset model, options and length are left out of the body', async () => { + const { body } = await send({ prompt: 'a lighthouse at sunset' }); + assert.deepEqual(body, { asset: { type: 'image', prompt: 'a lighthouse at sunset' } }); +}); + +await check('a set model, options and length are sent', async () => { + const { body } = await send({ + assetType: 'video', + prompt: 'a lighthouse at sunset', + model: 'seedance-2.0-text-to-video', + modelOptions: '{"aspectRatio":"16:9"}', + length: 5, + }); + assert.deepEqual(body, { + asset: { + type: 'video', + prompt: 'a lighthouse at sunset', + model: 'seedance-2.0-text-to-video', + options: { aspectRatio: '16:9' }, + }, + length: 5, + }); +}); + +await check('an empty prompt never reaches the API', async () => { + const error = await rejected({ prompt: ' ' }); + assert.ok(error, 'the generation was sent anyway'); + assert.match(error.message, /empty/); +}); + +await check('options that are not JSON are named as such', async () => { + const error = await rejected({ prompt: 'a cat', modelOptions: '{aspectRatio: 16:9}' }); + assert.match(error.message, /not valid JSON/); +}); + +await check('options that parse to an array are refused', async () => { + const error = await rejected({ prompt: 'a cat', modelOptions: '["16:9"]' }); + assert.match(error.message, /not a JSON object/); +}); + +await check('an idempotency key rides along without losing the telemetry headers', async () => { + const { headers } = await send({ prompt: 'a cat', idempotencyKey: 'take-2' }); + assert.equal(headers['Idempotency-Key'], 'take-2'); + assert.equal(headers['x-shotstack-origin'], 'n8n'); +}); + +await check('a cached generation comes back done, with no poll at all', async () => { + const items = await wait({ id: 'abc', status: 'done', url: 'https://cdn/x.png' }); + assert.equal(items[0].json.url, 'https://cdn/x.png'); +}); + +await check('a generation that failed on submit stops the step', async () => { + await assert.rejects( + async () => await wait({ id: 'abc', status: 'failed', error: 'the model refused it' }), + /generation failed/, + ); +}); + +await check('waiting turned off hands back the job without polling', async () => { + const items = await wait({ id: 'abc', status: 'queued' }, false); + assert.equal(items[0].json.status, 'queued'); +}); + +console.log(`\n${passed} passing`); From 4afafd36f6e4e6605b9e561bf832b4bb65f9ce54 Mon Sep 17 00:00:00 2001 From: Jesus Date: Tue, 6 Oct 2026 11:06:12 +0200 Subject: [PATCH 3/5] Rename the generate resource to generation, to match render and asset --- MAINTAINING.md | 8 ++++---- nodes/Shotstack/Shotstack.node.ts | 6 +++--- .../resources/{generate => generation}/getGenerate.ts | 2 +- .../Shotstack/resources/{generate => generation}/index.ts | 8 ++++---- .../resources/{generate => generation}/postGenerate.ts | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) rename nodes/Shotstack/resources/{generate => generation}/getGenerate.ts (96%) rename nodes/Shotstack/resources/{generate => generation}/index.ts (93%) rename nodes/Shotstack/resources/{generate => generation}/postGenerate.ts (99%) diff --git a/MAINTAINING.md b/MAINTAINING.md index 747b8ac..d9d3bca 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -122,17 +122,17 @@ only eight. | --- | --- | | `package.json` | the package name `@shotstack/n8n-nodes-shotstack`, and the built paths `dist/nodes/Shotstack/Shotstack.node.js` and `dist/credentials/ShotstackApi.credentials.js` | | `credentials/ShotstackApi.credentials.ts` | the credential type `shotstackApi`; the fields `environment` and `apiKey`; the values `sandbox` and `production` | -| `nodes/Shotstack/Shotstack.node.ts` | the node type `shotstack`; the behaviour of version 1, which n8n stamps on every saved node as typeVersion; the field `resource`; the values `render`, `asset` and `generate` | +| `nodes/Shotstack/Shotstack.node.ts` | the node type `shotstack`; the behaviour of version 1, which n8n stamps on every saved node as typeVersion; the field `resource`; the values `render`, `asset` and `generation` | | `nodes/Shotstack/resources/render/index.ts` | the field `operation`; the values `postRender`, `postTemplateRender` and `getRender` | | `nodes/Shotstack/resources/asset/index.ts` | the field `operation`; the value `getAssetByRenderId` | -| `nodes/Shotstack/resources/generate/index.ts` | the field `operation`; the values `postGenerate`, `getGenerate`, `getModels` and `postGenerateQuote` | +| `nodes/Shotstack/resources/generation/index.ts` | the field `operation`; the values `postGenerate`, `getGenerate`, `getModels` and `postGenerateQuote` | | `nodes/Shotstack/Shotstack.node.json` | the codex key `@shotstack/n8n-nodes-shotstack.shotstack`. n8n matches the codex to the node on that exact string, which joins the package name to the node type, so it is a third place either can break | | `nodes/Shotstack/resources/render/postRender.ts` | the fields `edit` and `callback` | | `nodes/Shotstack/resources/render/postTemplateRender.ts` | the fields `templateId`, `mergeSource`, `mergeJson` and `merge`; the picker modes `list` and `id`; the collection key `mergeFields` and its fields `find` and `replace`; the values `fields` and `json` | | `nodes/Shotstack/resources/render/getRender.ts` | the fields `renderId`, `waitForCompletion`, `giveUpAfter`, `includeData` and `simple`; the Simplify keys `id`, `status`, `url`, `poster`, `thumbnail`, `duration`, `renderTime`, `error` and `data` | | `nodes/Shotstack/resources/asset/getAssetByRenderId.ts` | the fields `renderId`, `mainFileOnly` and `simple`; the Simplify keys `assetId`, `renderId`, `url`, `filename` and `status`. `assetId` departs from the spec on purpose: the spec calls it `id`, but only inside an asset object, and Simplify flattens that object away. Do not correct it back | -| `nodes/Shotstack/resources/generate/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length`, `idempotencyKey` and `waitForAsset`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | -| `nodes/Shotstack/resources/generate/getGenerate.ts` | the field `generationId` | +| `nodes/Shotstack/resources/generation/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length`, `idempotencyKey` and `waitForAsset`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | +| `nodes/Shotstack/resources/generation/getGenerate.ts` | the field `generationId` | | `nodes/Shotstack/telemetry.ts` | the origin value `n8n`. Shotstack keeps a fixed vocabulary of origins, and this node joins it beside the ones for the API, the CLI, the MCP server, Studio and the playground. Only this node can send it, so it is what separates this node from n8n traffic that is not this node | | `scripts/build-user-agent.mjs` | the product token `shotstack-n8n-node`. It writes the user agent file, which git ignores, so edit the generator and never the output. The version after the slash is meant to move | diff --git a/nodes/Shotstack/Shotstack.node.ts b/nodes/Shotstack/Shotstack.node.ts index e6619db..f5699ee 100644 --- a/nodes/Shotstack/Shotstack.node.ts +++ b/nodes/Shotstack/Shotstack.node.ts @@ -1,7 +1,7 @@ import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow'; import { renderDescription } from './resources/render'; import { assetDescription } from './resources/asset'; -import { generateDescription } from './resources/generate'; +import { generationDescription } from './resources/generation'; import { TELEMETRY_HEADERS } from './telemetry'; import { EDIT_BASE_URL } from './environment'; import { getTemplates } from './listSearch/getTemplates'; @@ -57,7 +57,7 @@ export class Shotstack implements INodeType { }, { name: 'Generation', - value: 'generate', + value: 'generation', }, { name: 'Render', @@ -68,7 +68,7 @@ export class Shotstack implements INodeType { }, ...renderDescription, ...assetDescription, - ...generateDescription, + ...generationDescription, ], }; diff --git a/nodes/Shotstack/resources/generate/getGenerate.ts b/nodes/Shotstack/resources/generation/getGenerate.ts similarity index 96% rename from nodes/Shotstack/resources/generate/getGenerate.ts rename to nodes/Shotstack/resources/generation/getGenerate.ts index bbe8805..eb44665 100644 --- a/nodes/Shotstack/resources/generate/getGenerate.ts +++ b/nodes/Shotstack/resources/generation/getGenerate.ts @@ -2,7 +2,7 @@ import type { INodeProperties } from 'n8n-workflow'; import { requireGenerationId } from '../renderId'; const showOnly = { - resource: ['generate'], + resource: ['generation'], operation: ['getGenerate'], }; diff --git a/nodes/Shotstack/resources/generate/index.ts b/nodes/Shotstack/resources/generation/index.ts similarity index 93% rename from nodes/Shotstack/resources/generate/index.ts rename to nodes/Shotstack/resources/generation/index.ts index 602b061..962e961 100644 --- a/nodes/Shotstack/resources/generate/index.ts +++ b/nodes/Shotstack/resources/generation/index.ts @@ -2,8 +2,8 @@ import type { INodeProperties } from 'n8n-workflow'; import { postGenerateDescription } from './postGenerate'; import { getGenerateDescription } from './getGenerate'; -const showOnlyForGenerate = { - resource: ['generate'], +const showOnlyForGeneration = { + resource: ['generation'], }; // Operation names and values come from Shotstack's OpenAPI spec: the display @@ -13,13 +13,13 @@ const showOnlyForGenerate = { // the render operations none of them unwraps anything. List Generation Models // is the one exception: its answer is an object holding the array, and n8n // should hand back one item per model. -export const generateDescription: INodeProperties[] = [ +export const generationDescription: INodeProperties[] = [ { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, - displayOptions: { show: showOnlyForGenerate }, + displayOptions: { show: showOnlyForGeneration }, options: [ { name: 'Generate Asset', diff --git a/nodes/Shotstack/resources/generate/postGenerate.ts b/nodes/Shotstack/resources/generation/postGenerate.ts similarity index 99% rename from nodes/Shotstack/resources/generate/postGenerate.ts rename to nodes/Shotstack/resources/generation/postGenerate.ts index d2d98b3..8a667ad 100644 --- a/nodes/Shotstack/resources/generate/postGenerate.ts +++ b/nodes/Shotstack/resources/generation/postGenerate.ts @@ -13,14 +13,14 @@ import { TELEMETRY_HEADERS } from '../../telemetry'; import { isRateLimited, pollGapMs, RATE_LIMIT_HELP } from '../../polling'; const showOnly = { - resource: ['generate'], + resource: ['generation'], operation: ['postGenerate'], }; // Quote takes the same body as Generate Asset, so one set of fields builds both // and only the route differs. A quote spends nothing, which is the point of it. const showForBoth = { - resource: ['generate'], + resource: ['generation'], operation: ['postGenerate', 'postGenerateQuote'], }; From 8a3b1cb6184b1a5515e09b28e0ed392e64363762 Mon Sep 17 00:00:00 2001 From: Jesus Date: Tue, 6 Oct 2026 11:07:09 +0200 Subject: [PATCH 4/5] Drop Idempotency Key and share the JSON and ID guards --- MAINTAINING.md | 2 +- README.md | 1 - .../resources/generation/postGenerate.ts | 52 +++--------------- nodes/Shotstack/resources/jsonObject.ts | 40 ++++++++++++++ .../Shotstack/resources/render/postRender.ts | 39 ++++---------- nodes/Shotstack/resources/renderId.ts | 53 +++++++++---------- test/generation-guards.mjs | 6 --- 7 files changed, 84 insertions(+), 109 deletions(-) create mode 100644 nodes/Shotstack/resources/jsonObject.ts diff --git a/MAINTAINING.md b/MAINTAINING.md index d9d3bca..3dfa736 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -131,7 +131,7 @@ only eight. | `nodes/Shotstack/resources/render/postTemplateRender.ts` | the fields `templateId`, `mergeSource`, `mergeJson` and `merge`; the picker modes `list` and `id`; the collection key `mergeFields` and its fields `find` and `replace`; the values `fields` and `json` | | `nodes/Shotstack/resources/render/getRender.ts` | the fields `renderId`, `waitForCompletion`, `giveUpAfter`, `includeData` and `simple`; the Simplify keys `id`, `status`, `url`, `poster`, `thumbnail`, `duration`, `renderTime`, `error` and `data` | | `nodes/Shotstack/resources/asset/getAssetByRenderId.ts` | the fields `renderId`, `mainFileOnly` and `simple`; the Simplify keys `assetId`, `renderId`, `url`, `filename` and `status`. `assetId` departs from the spec on purpose: the spec calls it `id`, but only inside an asset object, and Simplify flattens that object away. Do not correct it back | -| `nodes/Shotstack/resources/generation/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length`, `idempotencyKey` and `waitForAsset`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | +| `nodes/Shotstack/resources/generation/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length` and `waitForAsset`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | | `nodes/Shotstack/resources/generation/getGenerate.ts` | the field `generationId` | | `nodes/Shotstack/telemetry.ts` | the origin value `n8n`. Shotstack keeps a fixed vocabulary of origins, and this node joins it beside the ones for the API, the CLI, the MCP server, Studio and the playground. Only this node can send it, so it is what separates this node from n8n traffic that is not this node | | `scripts/build-user-agent.mjs` | the product token `shotstack-n8n-node`. It writes the user agent file, which git ignores, so edit the generator and never the output. The version after the slash is meant to move | diff --git a/README.md b/README.md index 2a690e2..ab1c9d2 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,6 @@ whole edit. Use the URL it returns as an asset in a later render. | **Model Name or ID** | Pick one from the list, or leave it on the default for the asset type. The list comes from your account, so a newly launched model needs no release of this node. | | **Model Options** | Optional JSON of settings for the chosen model. **List Generation Models** returns what each one accepts. | | **Clip Length (Seconds)** | Only read by models that generate to a duration. 0 uses the model default. | -| **Idempotency Key** | Optional. Set a different key to get a fresh take on a prompt generated before. | | **Wait for the Asset** | On by default, so the step returns the finished URL rather than a job ID. Turn it off for a long video. | Shotstack bills generation in credits per asset, in Sandbox and in Production. diff --git a/nodes/Shotstack/resources/generation/postGenerate.ts b/nodes/Shotstack/resources/generation/postGenerate.ts index 8a667ad..efd8d2e 100644 --- a/nodes/Shotstack/resources/generation/postGenerate.ts +++ b/nodes/Shotstack/resources/generation/postGenerate.ts @@ -11,6 +11,7 @@ import type { } from 'n8n-workflow'; import { TELEMETRY_HEADERS } from '../../telemetry'; import { isRateLimited, pollGapMs, RATE_LIMIT_HELP } from '../../polling'; +import { jsonObjectParam } from '../jsonObject'; const showOnly = { resource: ['generation'], @@ -59,33 +60,13 @@ const buildGenerationBody: PreSendAction = async function ( const model = String(this.getNodeParameter('model', '') ?? '').trim(); if (model) asset.model = model; - const raw = this.getNodeParameter('modelOptions', '') as string | IDataObject; - const text = typeof raw === 'string' ? raw.trim() : ''; - if (typeof raw === 'string' ? text !== '' && text !== '{}' : Boolean(raw)) { - let options: IDataObject; - if (typeof raw === 'string') { - try { - options = JSON.parse(text) as IDataObject; - } catch (error) { - throw new NodeOperationError(this.getNode(), 'Model Options is not valid JSON', { - description: (error as Error).message, - itemIndex: this.getItemIndex(), - }); - } - } else { - options = raw; - } - // A bare string or array parses cleanly and then spreads into numbered - // keys, which the API rejects with nothing the user can act on. - if (options === null || typeof options !== 'object' || Array.isArray(options)) { - throw new NodeOperationError(this.getNode(), 'Model Options is not a JSON object', { - description: - 'It holds the settings for the chosen model, such as {"aspectRatio": "16:9"}. List Generation Models returns the options each model accepts.', - itemIndex: this.getItemIndex(), - }); - } - asset.options = options; - } + const options = jsonObjectParam.call( + this, + 'modelOptions', + 'Model Options', + 'It holds the settings for the chosen model, such as {"aspectRatio": "16:9"}. List Generation Models returns the options each model accepts.', + ); + if (options) asset.options = options; const body: IDataObject = { asset }; @@ -95,14 +76,6 @@ const buildGenerationBody: PreSendAction = async function ( const length = Number(this.getNodeParameter('length', 0)); if (Number.isFinite(length) && length > 0) body.length = length; - // Without a key, identical assets share one cached result, so a retried - // workflow is not billed twice. With one, a loop can ask for a second take - // of the same prompt. - const idempotencyKey = String(this.getNodeParameter('idempotencyKey', '') ?? '').trim(); - if (idempotencyKey) { - requestOptions.headers = { ...requestOptions.headers, 'Idempotency-Key': idempotencyKey }; - } - requestOptions.body = body; return requestOptions; }; @@ -255,15 +228,6 @@ export const postGenerateDescription: INodeProperties[] = [ description: 'The length of the clip this asset has to fill. A model that generates to a duration uses it instead of its own duration option, and the rest ignore it. Leave at 0 for the model default.', }, - { - displayName: 'Idempotency Key', - name: 'idempotencyKey', - type: 'string', - default: '', - displayOptions: { show: showOnly }, - description: - 'Optional. Without one, generating the same asset twice returns the first result and is not billed again. Set a different key each time to get a fresh take on the same prompt.', - }, { displayName: 'Wait for the Asset', name: 'waitForAsset', diff --git a/nodes/Shotstack/resources/jsonObject.ts b/nodes/Shotstack/resources/jsonObject.ts new file mode 100644 index 0000000..af00a51 --- /dev/null +++ b/nodes/Shotstack/resources/jsonObject.ts @@ -0,0 +1,40 @@ +import { NodeOperationError } from 'n8n-workflow'; +import type { IDataObject, IExecuteSingleFunctions } from 'n8n-workflow'; + +/** + * Reads a JSON field as an object, or undefined when it is left empty. + * + * The field holds text in fixed mode and a parsed value in expression mode, so + * both become text and go through one parse. + */ +export function jsonObjectParam( + this: IExecuteSingleFunctions, + name: string, + label: string, + help: string, +): IDataObject | undefined { + const raw = this.getNodeParameter(name, ''); + const text = typeof raw === 'string' ? raw.trim() : JSON.stringify(raw ?? {}); + if (!text || text === '{}') return undefined; + + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new NodeOperationError(this.getNode(), `${label} is not valid JSON`, { + description: (error as Error).message, + itemIndex: this.getItemIndex(), + }); + } + + // A bare string, number or array parses cleanly and then spreads into numbered + // keys, which Shotstack rejects with nothing the user can act on. + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + const got = Array.isArray(value) ? 'an array' : value === null ? 'null' : typeof value; + throw new NodeOperationError(this.getNode(), `${label} is not a JSON object`, { + description: `${help} Got ${got}.`, + itemIndex: this.getItemIndex(), + }); + } + return value as IDataObject; +} diff --git a/nodes/Shotstack/resources/render/postRender.ts b/nodes/Shotstack/resources/render/postRender.ts index 9d99fc8..1a17002 100644 --- a/nodes/Shotstack/resources/render/postRender.ts +++ b/nodes/Shotstack/resources/render/postRender.ts @@ -1,11 +1,11 @@ import { NodeOperationError } from 'n8n-workflow'; import type { - IDataObject, IExecuteSingleFunctions, IHttpRequestOptions, INodeProperties, PreSendAction, } from 'n8n-workflow'; +import { jsonObjectParam } from '../jsonObject'; const showOnly = { resource: ['render'], @@ -27,34 +27,15 @@ const buildRenderBody: PreSendAction = async function ( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, ) { - const raw = this.getNodeParameter('edit', '') as string | IDataObject; - - let edit: IDataObject; - if (typeof raw === 'string') { - const text = raw.trim(); - if (!text) { - throw new NodeOperationError(this.getNode(), 'The Edit field is empty', { - description: 'Paste a Shotstack edit, or use Render Template if you have one saved.', - itemIndex: this.getItemIndex(), - }); - } - try { - edit = JSON.parse(text) as IDataObject; - } catch (error) { - throw new NodeOperationError(this.getNode(), 'The Edit field is not valid JSON', { - description: (error as Error).message, - itemIndex: this.getItemIndex(), - }); - } - } else { - edit = (raw ?? {}) as IDataObject; - } - - // A bare string, number or array parses cleanly and then spreads into a body - // of numbered keys, which Shotstack rejects with nothing the user can act on. - if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) { - throw new NodeOperationError(this.getNode(), 'The Edit field is not a Shotstack edit', { - description: `An edit is a JSON object with a timeline and an output. Got ${Array.isArray(edit) ? 'an array' : typeof edit}.`, + const edit = jsonObjectParam.call( + this, + 'edit', + 'The Edit field', + 'An edit is a JSON object with a timeline and an output.', + ); + if (!edit) { + throw new NodeOperationError(this.getNode(), 'The Edit field is empty', { + description: 'Paste a Shotstack edit, or use Render Template if you have one saved.', itemIndex: this.getItemIndex(), }); } diff --git a/nodes/Shotstack/resources/renderId.ts b/nodes/Shotstack/resources/renderId.ts index 4ddcbeb..731b66e 100644 --- a/nodes/Shotstack/resources/renderId.ts +++ b/nodes/Shotstack/resources/renderId.ts @@ -6,36 +6,33 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export const isRenderId = (value: string) => UUID.test(value.trim()); /** - * Rejects anything that is not a render ID before it reaches the URL. + * Rejects anything that is not a job ID before it reaches the URL. * * The ID is put straight into the path, so an unchecked value can point the * request at another endpoint. */ -export const requireRenderId: PreSendAction = async function ( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -) { - const value = String(this.getNodeParameter('renderId', '') ?? '').trim(); - if (!isRenderId(value)) { - throw new NodeOperationError(this.getNode(), 'That is not a Shotstack render ID', { - description: `A render ID looks like 4a37ef85-b4d1-4b4a-90be-6515290c5091. Got "${value}". A render action returns it as "id", and Get Asset by Render ID returns it as "renderId".`, - itemIndex: this.getItemIndex(), - }); - } - return requestOptions; -}; +const requireId = (param: string, job: string, example: string, source: string): PreSendAction => + async function (this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions) { + const value = String(this.getNodeParameter(param, '') ?? '').trim(); + if (!isRenderId(value)) { + throw new NodeOperationError(this.getNode(), `That is not a Shotstack ${job} ID`, { + description: `A ${job} ID looks like ${example}. Got "${value}". ${source}`, + itemIndex: this.getItemIndex(), + }); + } + return requestOptions; + }; -/** The same guard for a generation job ID, which also goes straight into a path. */ -export const requireGenerationId: PreSendAction = async function ( - this: IExecuteSingleFunctions, - requestOptions: IHttpRequestOptions, -) { - const value = String(this.getNodeParameter('generationId', '') ?? '').trim(); - if (!isRenderId(value)) { - throw new NodeOperationError(this.getNode(), 'That is not a Shotstack generation ID', { - description: `A generation ID looks like 8a1f2c3d-4e5b-5a6c-9d7e-1f2a3b4c5d6e. Got "${value}". Generate Asset returns it as "id".`, - itemIndex: this.getItemIndex(), - }); - } - return requestOptions; -}; +export const requireRenderId = requireId( + 'renderId', + 'render', + '4a37ef85-b4d1-4b4a-90be-6515290c5091', + 'A render action returns it as "id", and Get Asset by Render ID returns it as "renderId".', +); + +export const requireGenerationId = requireId( + 'generationId', + 'generation', + '8a1f2c3d-4e5b-5a6c-9d7e-1f2a3b4c5d6e', + 'Generate Asset returns it as "id".', +); diff --git a/test/generation-guards.mjs b/test/generation-guards.mjs index d07e3f6..1866837 100644 --- a/test/generation-guards.mjs +++ b/test/generation-guards.mjs @@ -109,12 +109,6 @@ await check('options that parse to an array are refused', async () => { assert.match(error.message, /not a JSON object/); }); -await check('an idempotency key rides along without losing the telemetry headers', async () => { - const { headers } = await send({ prompt: 'a cat', idempotencyKey: 'take-2' }); - assert.equal(headers['Idempotency-Key'], 'take-2'); - assert.equal(headers['x-shotstack-origin'], 'n8n'); -}); - await check('a cached generation comes back done, with no poll at all', async () => { const items = await wait({ id: 'abc', status: 'done', url: 'https://cdn/x.png' }); assert.equal(items[0].json.url, 'https://cdn/x.png'); From 6a4211ba75ab54f6ac025c4ba89776fd06fb852c Mon Sep 17 00:00:00 2001 From: Jesus Date: Tue, 6 Oct 2026 11:50:26 +0200 Subject: [PATCH 5/5] Read the 202 answer, bound the wait, and guard the values that reach the API --- MAINTAINING.md | 2 +- README.md | 1 + nodes/Shotstack/loadOptions/getModels.ts | 28 +++- .../resources/generation/postGenerate.ts | 110 +++++++++++++--- test/generation-guards.mjs | 123 +++++++++++++++++- 5 files changed, 232 insertions(+), 32 deletions(-) diff --git a/MAINTAINING.md b/MAINTAINING.md index 3dfa736..f9bd7a4 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -131,7 +131,7 @@ only eight. | `nodes/Shotstack/resources/render/postTemplateRender.ts` | the fields `templateId`, `mergeSource`, `mergeJson` and `merge`; the picker modes `list` and `id`; the collection key `mergeFields` and its fields `find` and `replace`; the values `fields` and `json` | | `nodes/Shotstack/resources/render/getRender.ts` | the fields `renderId`, `waitForCompletion`, `giveUpAfter`, `includeData` and `simple`; the Simplify keys `id`, `status`, `url`, `poster`, `thumbnail`, `duration`, `renderTime`, `error` and `data` | | `nodes/Shotstack/resources/asset/getAssetByRenderId.ts` | the fields `renderId`, `mainFileOnly` and `simple`; the Simplify keys `assetId`, `renderId`, `url`, `filename` and `status`. `assetId` departs from the spec on purpose: the spec calls it `id`, but only inside an asset object, and Simplify flattens that object away. Do not correct it back | -| `nodes/Shotstack/resources/generation/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length` and `waitForAsset`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | +| `nodes/Shotstack/resources/generation/postGenerate.ts` | the fields `assetType`, `prompt`, `model`, `modelOptions`, `length`, `waitForAsset` and `giveUpAfter`; the asset type values `image`, `video` and `audio`. These build the request body in code, so a rename here changes what a saved workflow sends, not just what it shows | | `nodes/Shotstack/resources/generation/getGenerate.ts` | the field `generationId` | | `nodes/Shotstack/telemetry.ts` | the origin value `n8n`. Shotstack keeps a fixed vocabulary of origins, and this node joins it beside the ones for the API, the CLI, the MCP server, Studio and the playground. Only this node can send it, so it is what separates this node from n8n traffic that is not this node | | `scripts/build-user-agent.mjs` | the product token `shotstack-n8n-node`. It writes the user agent file, which git ignores, so edit the generator and never the output. The version after the slash is meant to move | diff --git a/README.md b/README.md index ab1c9d2..265453d 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ whole edit. Use the URL it returns as an asset in a later render. | **Model Options** | Optional JSON of settings for the chosen model. **List Generation Models** returns what each one accepts. | | **Clip Length (Seconds)** | Only read by models that generate to a duration. 0 uses the model default. | | **Wait for the Asset** | On by default, so the step returns the finished URL rather than a job ID. Turn it off for a long video. | +| **Give Up After (Minutes)** | Only shown when waiting. 5 by default, 10 at most. n8n runs items one at a time, so six waiting items at the maximum reach its one hour limit. Giving up does not stop the generation, and Shotstack still bills it. | Shotstack bills generation in credits per asset, in Sandbox and in Production. Generating the same asset twice returns the first result and is not billed diff --git a/nodes/Shotstack/loadOptions/getModels.ts b/nodes/Shotstack/loadOptions/getModels.ts index 482eaf3..22114c8 100644 --- a/nodes/Shotstack/loadOptions/getModels.ts +++ b/nodes/Shotstack/loadOptions/getModels.ts @@ -3,12 +3,21 @@ import { TELEMETRY_HEADERS } from '../telemetry'; import { apiPathFor } from '../environment'; type GenerationModel = { - model: string; - type: string; - name?: string; - available?: boolean; + model?: unknown; + type?: unknown; + name?: unknown; + available?: unknown; }; +/** + * What the dropdown shows for a model. + * + * `||` not `??`: a model sent with an empty name has no name. `??` keeps the + * empty string, which sorts above everything and then renders as the model ID, + * so the list is visibly out of order. + */ +const label = (m: GenerationModel) => String(m.name || m.model || ''); + /** * Lists the generation models the account can use, for the Model dropdown. * @@ -28,17 +37,22 @@ export async function getModels(this: ILoadOptionsFunctions): Promise m && typeof m === 'object' && String(m.model ?? '')) // `available` is omitted when the account's access cannot be read. Only a // stated false means the plan does not include the model. .filter((m) => m.available !== false) .filter((m) => !assetType || m.type === assetType) - .sort((a, b) => (a.name ?? a.model).localeCompare(b.name ?? b.model)); + .sort((a, b) => label(a).localeCompare(label(b))); // Empty leaves `model` out of the request, so the API picks its own default // for the asset type. Listed first so it is the obvious choice. return [ { name: 'Default for This Asset Type', value: '' }, - ...models.map((m) => ({ name: m.name || m.model, value: m.model })), + ...models.map((m) => ({ name: label(m), value: String(m.model ?? '') })), ]; } diff --git a/nodes/Shotstack/resources/generation/postGenerate.ts b/nodes/Shotstack/resources/generation/postGenerate.ts index efd8d2e..222edc4 100644 --- a/nodes/Shotstack/resources/generation/postGenerate.ts +++ b/nodes/Shotstack/resources/generation/postGenerate.ts @@ -12,6 +12,7 @@ import type { import { TELEMETRY_HEADERS } from '../../telemetry'; import { isRateLimited, pollGapMs, RATE_LIMIT_HELP } from '../../polling'; import { jsonObjectParam } from '../jsonObject'; +import { isRenderId } from '../renderId'; const showOnly = { resource: ['generation'], @@ -28,6 +29,7 @@ const showForBoth = { const POLL_GAP_MS = 5000; const MAX_GAP_MS = 20000; const REQUEST_TIMEOUT_MS = 30000; +const MIN_MINUTES = 1; // The same ceiling as a render wait. n8n runs items one at a time, so a longer // one risks the 1 hour EXECUTIONS_TIMEOUT_MAX ending the whole run. const MAX_MINUTES = 10; @@ -44,7 +46,16 @@ const buildGenerationBody: PreSendAction = async function ( this: IExecuteSingleFunctions, requestOptions: IHttpRequestOptions, ) { - const prompt = String(this.getNodeParameter('prompt', '') ?? '').trim(); + // Not String(): an expression or an agent can hand over an object, and + // String({}) is "[object Object]", which is not empty and bills a credit. + const typed = this.getNodeParameter('prompt', ''); + if (typed !== undefined && typed !== null && typeof typed !== 'string') { + throw new NodeOperationError(this.getNode(), 'The Prompt field is not text', { + description: `A prompt is a sentence describing the asset. Got ${Array.isArray(typed) ? 'an array' : typeof typed}.`, + itemIndex: this.getItemIndex(), + }); + } + const prompt = String(typed ?? '').trim(); if (!prompt) { throw new NodeOperationError(this.getNode(), 'The Prompt field is empty', { description: 'Describe the asset to generate. For a speech model this is the text spoken.', @@ -73,8 +84,15 @@ const buildGenerationBody: PreSendAction = async function ( // Only a model that generates to a duration reads this; the rest ignore it. // Zero means not set, because the API rejects a length that is not positive // and every model has its own default. - const length = Number(this.getNodeParameter('length', 0)); - if (Number.isFinite(length) && length > 0) body.length = length; + const asked = this.getNodeParameter('length', 0); + const length = Number(asked); + if (!Number.isFinite(length)) { + throw new NodeOperationError(this.getNode(), 'Clip Length is not a number', { + description: `It is a count of seconds, so "5" not "5 seconds". Got "${String(asked)}".`, + itemIndex: this.getItemIndex(), + }); + } + if (length > 0) body.length = length; requestOptions.body = body; return requestOptions; @@ -98,7 +116,7 @@ const waitForGeneration = async function ( const id = String(job.id ?? ''); let last = String(job.status ?? 'unknown'); - const failed = (error: unknown): never => { + const failed: (error: unknown) => never = (error) => { throw new NodeOperationError(this.getNode(), 'The generation failed', { description: typeof error === 'string' && error @@ -112,10 +130,26 @@ const waitForGeneration = async function ( // A cache hit comes back done, with its URL already set. if (last === 'done' || !id) return items; + // The ID goes straight into a path, and this one comes from a response body + // rather than from the field requireGenerationId already guards. A value + // carrying a slash would point every poll at a different endpoint. + if (!isRenderId(id)) { + throw new NodeOperationError(this.getNode(), 'Shotstack returned a generation ID we cannot use', { + description: `Expected an ID like 8a1f2c3d-4e5b-5a6c-9d7e-1f2a3b4c5d6e, got "${id}".`, + itemIndex: this.getItemIndex(), + }); + } + + // typeOptions bounds the UI spinner only, and an expression can resolve to + // anything. Clamp it: a negative would skip the loop and still report a wait. + const asked = Number(this.getNodeParameter('giveUpAfter', 5)); + const minutes = Math.min(MAX_MINUTES, Math.max(MIN_MINUTES, Number.isFinite(asked) ? asked : 5)); + const url = `${String(requestData.options.baseURL ?? '')}/generate/${id}`; - const deadline = Date.now() + MAX_MINUTES * 60000; + const deadline = Date.now() + minutes * 60000; let response: { statusCode: number; body: IDataObject; headers: IDataObject } | undefined; let throttled = false; + let lastCode = 0; for (let attempt = 0; ; attempt++) { // Wait first. The submit already answered with a status, so polling @@ -134,33 +168,63 @@ const waitForGeneration = async function ( timeout: REQUEST_TIMEOUT_MS, headers: { ...TELEMETRY_HEADERS }, })) as { statusCode: number; body: IDataObject; headers: IDataObject }; + lastCode = response.statusCode; } catch { - // A dropped connection is not an answer. Keep waiting. + // A dropped connection is not an answer. Keep waiting, and clear the + // last answer so the checks below do not read it twice. + response = undefined; } if (isRateLimited(response)) throttled = true; - if (response?.statusCode === 200) { + // Neither answer turns into an asset by waiting, so stop on it rather than + // run out the clock and blame the wait. + if (response?.statusCode === 401 || response?.statusCode === 403) { + throw new NodeOperationError(this.getNode(), 'Shotstack refused the API key', { + description: + 'It accepted the key for the submit, then refused it while waiting. Check that the key is still active.', + itemIndex: this.getItemIndex(), + }); + } + // Not on the first poll. The job is seconds old, and a gateway or a + // read-after-write lag can answer 404 once. + if (attempt > 0 && response?.statusCode === 404) { + throw new NodeOperationError(this.getNode(), 'Shotstack has no generation with that ID', { + description: `It accepted the generation as ${id}, then reported no such job. Run the step again, and send this ID to Shotstack support if it repeats.`, + itemIndex: this.getItemIndex(), + }); + } + + // 202 is the job still processing and 200 is it finished, and both carry + // the job. Reading only one of them leaves the status at whatever the + // submit said, so a job that spent nine minutes processing still reports + // the word it was queued under. + if (response?.statusCode === 200 || response?.statusCode === 202) { // The generation endpoints answer with the job itself, not the // { success, message, response } envelope the rest of the Edit API // wraps its answers in. const body = (response.body ?? {}) as IDataObject; last = String(body.status ?? last); if (last === 'failed') failed(body.error); - if (last === 'done') return [{ json: body, pairedItem: { item: this.getItemIndex() } }]; + // Only a 200 is final. A 202 saying done is the job still being + // written, and its url may not be there yet. + if (response.statusCode === 200 && last === 'done') { + return [{ json: body, pairedItem: { item: this.getItemIndex() } }]; + } } } - throw new NodeOperationError( - this.getNode(), - `The asset is still ${last} after ${MAX_MINUTES} minutes`, - { - description: throttled - ? RATE_LIMIT_HELP - : 'Generation keeps going after this runs out. Turn off Wait for the Asset, keep the ID it returns, and read the result later with Get Generation Status.', - itemIndex: this.getItemIndex(), - }, - ); + // Name what happened. "Still queued" on a run where every poll was a 500 + // sends the user to raise a timeout that was never the problem. + const reached = lastCode + ? `The asset is still ${last} after ${minutes} minutes` + : `The status check never completed, ${minutes} minutes after the asset was submitted`; + throw new NodeOperationError(this.getNode(), reached, { + description: throttled + ? RATE_LIMIT_HELP + : `Shotstack last answered ${lastCode || 'nothing'}. Generation keeps going after this runs out, so turn off Wait for the Asset, keep the ID it returns, and read the result later with Get Generation Status.`, + itemIndex: this.getItemIndex(), + }); }; export const postGenerateDescription: INodeProperties[] = [ @@ -240,4 +304,14 @@ export const postGenerateDescription: INodeProperties[] = [ operations: { pagination: waitForGeneration }, }, }, + { + displayName: 'Give Up After (Minutes)', + name: 'giveUpAfter', + type: 'number', + default: 5, + typeOptions: { minValue: MIN_MINUTES, maxValue: MAX_MINUTES }, + displayOptions: { show: { ...showOnly, waitForAsset: [true] } }, + description: + 'How long to keep checking. n8n runs items one at a time, so six waiting items at the maximum reach its one hour execution limit and the whole run is lost. The generation keeps going after this runs out, and Shotstack still bills it', + }, ]; diff --git a/test/generation-guards.mjs b/test/generation-guards.mjs index 1866837..f5b14dc 100644 --- a/test/generation-guards.mjs +++ b/test/generation-guards.mjs @@ -23,6 +23,9 @@ const paginate = shownFor('waitForAsset', 'postGenerate').routing.operations.pag const node_ = () => ({ name: 'Shotstack' }); +// A real job id: the wait loop puts it in a path, so it checks the shape. +const JOB = '8a1f2c3d-4e5b-5a6c-9d7e-1f2a3b4c5d6e'; + const send = async (params) => await preSend.call( { @@ -50,14 +53,41 @@ const neverPolls = { }, }; -const wait = async (job, waitForAsset = true) => +// Answers each poll with the next status code, then with a finished job, so a +// loop that ignores an answer ends here instead of running out the clock. +const answering = (...codes) => { + let calls = 0; + return { + calls: () => calls, + httpRequestWithAuthentication: async () => { + const statusCode = codes[calls++]; + if (statusCode === 'drop') throw new Error('socket hang up'); + return statusCode + ? { statusCode, body: {}, headers: {} } + : { statusCode: 200, body: { id: JOB, status: 'done' }, headers: {} }; + }, + }; +}; + +// n8n's sleep is a setTimeout, so this makes every poll gap instant. The clock +// has to move with it: the loop's deadline is wall clock, so instant sleeps +// alone would spin the loop for the full five minutes instead of ending it. +let clock = Date.now(); +globalThis.setTimeout = (resolve, ms = 0) => { + clock += ms; + resolve(); +}; +Date.now = () => clock; + +const wait = async (job, waitForAsset = true, helpers = neverPolls) => await paginate.call( { makeRoutingRequest: async () => [{ json: job }], - getNodeParameter: (name, fallback) => (name === 'waitForAsset' ? waitForAsset : fallback), + getNodeParameter: (name, fallback) => + name === 'waitForAsset' ? waitForAsset : name === 'giveUpAfter' ? 5 : fallback, getNode: node_, getItemIndex: () => 0, - helpers: neverPolls, + helpers, }, { options: { baseURL: 'https://api.shotstack.io/edit/stage', url: '/generate' } }, ); @@ -110,20 +140,101 @@ await check('options that parse to an array are refused', async () => { }); await check('a cached generation comes back done, with no poll at all', async () => { - const items = await wait({ id: 'abc', status: 'done', url: 'https://cdn/x.png' }); + const items = await wait({ id: JOB, status: 'done', url: 'https://cdn/x.png' }); assert.equal(items[0].json.url, 'https://cdn/x.png'); }); await check('a generation that failed on submit stops the step', async () => { await assert.rejects( - async () => await wait({ id: 'abc', status: 'failed', error: 'the model refused it' }), + async () => await wait({ id: JOB, status: 'failed', error: 'the model refused it' }), /generation failed/, ); }); await check('waiting turned off hands back the job without polling', async () => { - const items = await wait({ id: 'abc', status: 'queued' }, false); + const items = await wait({ id: JOB, status: 'queued' }, false); assert.equal(items[0].json.status, 'queued'); }); +await check('a refused key stops the wait on the first poll', async () => { + const helpers = answering(401); + await assert.rejects( + async () => await wait({ id: JOB, status: 'queued' }, true, helpers), + /refused the API key/, + ); + assert.equal(helpers.calls(), 1); +}); + +await check('a 404 stops the wait, but not on the first poll or after a dropped one', async () => { + const helpers = answering(404, 'drop', 404); + await assert.rejects( + async () => await wait({ id: JOB, status: 'queued' }, true, helpers), + /no generation with that ID/, + ); + assert.equal(helpers.calls(), 3); +}); + +await check('a 202 keeps the status moving, so the timeout names the real one', async () => { + // The API answers 202 while the job is processing. A loop that only reads + // 200 reports whatever the submit said, however long it really ran. + const helpers = { + httpRequestWithAuthentication: async () => ({ + statusCode: 202, + body: { id: JOB, status: 'processing' }, + headers: {}, + }), + }; + await assert.rejects( + async () => await wait({ id: JOB, status: 'queued' }, true, helpers), + /still processing/, + ); +}); + +await check('a 202 saying done is not taken as final', async () => { + // The job is still being written at that point, so its url may be missing. + let calls = 0; + const helpers = { + httpRequestWithAuthentication: async () => { + calls += 1; + return calls === 1 + ? { statusCode: 202, body: { id: JOB, status: 'done' }, headers: {} } + : { + statusCode: 200, + body: { id: JOB, status: 'done', url: 'https://cdn/x.png' }, + headers: {}, + }; + }, + }; + const items = await wait({ id: JOB, status: 'queued' }, true, helpers); + assert.equal(items[0].json.url, 'https://cdn/x.png'); + assert.equal(calls, 2); +}); + +await check('an id the API sends back that is not an id never reaches a URL', async () => { + await assert.rejects( + async () => await wait({ id: '../../render/abc', status: 'queued' }), + /generation ID we cannot use/, + ); +}); + +await check('a prompt that is not text never bills a generation', async () => { + const error = await rejected({ prompt: { brief: 'a cat' } }); + assert.ok(error, 'the generation was sent anyway'); + assert.match(error.message, /not text/); +}); + +await check('a length that is not a number is named, not silently dropped', async () => { + const error = await rejected({ prompt: 'a cat', length: '5 seconds' }); + assert.ok(error, 'the generation was sent anyway'); + assert.match(error.message, /not a number/); +}); + +await check('the wait cannot be set past the point where six items eat the n8n hour', () => { + const field = properties.find( + (x) => x.name === 'giveUpAfter' && x.displayOptions?.show?.waitForAsset, + ); + assert.ok(field, 'Generate Asset has no Give Up After'); + assert.ok(field.typeOptions.maxValue * 6 <= 60, 'six waiting items can exceed the n8n hour'); +}); + console.log(`\n${passed} passing`);