Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MAINTAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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/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/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 |

Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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. |
| **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
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
Expand Down
10 changes: 10 additions & 0 deletions nodes/Shotstack/Shotstack.node.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { NodeConnectionTypes, type INodeType, type INodeTypeDescription } from 'n8n-workflow';
import { renderDescription } from './resources/render';
import { assetDescription } from './resources/asset';
import { generationDescription } from './resources/generation';
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 = {
Expand Down Expand Up @@ -53,6 +55,10 @@ export class Shotstack implements INodeType {
name: 'Asset',
value: 'asset',
},
{
name: 'Generation',
value: 'generation',
},
{
name: 'Render',
value: 'render',
Expand All @@ -62,12 +68,16 @@ export class Shotstack implements INodeType {
},
...renderDescription,
...assetDescription,
...generationDescription,
],
};

methods = {
listSearch: {
getTemplates,
},
loadOptions: {
getModels,
},
};
}
58 changes: 58 additions & 0 deletions nodes/Shotstack/loadOptions/getModels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { IDataObject, ILoadOptionsFunctions, INodePropertyOptions } from 'n8n-workflow';
import { TELEMETRY_HEADERS } from '../telemetry';
import { apiPathFor } from '../environment';

type GenerationModel = {
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.
*
* 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<INodePropertyOptions[]> {
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 listed = Array.isArray(response?.models) ? (response.models as GenerationModel[]) : [];
const models = listed
// `model` is what gets sent, so a row without one cannot be chosen. One
// malformed row must not take the whole picker down with it either: this
// is a live API response, not something the types can promise.
.filter((m) => 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) => 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: label(m), value: String(m.model ?? '') })),
];
}
29 changes: 29 additions & 0 deletions nodes/Shotstack/resources/generation/getGenerate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import { requireGenerationId } from '../renderId';

const showOnly = {
resource: ['generation'],
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],
},
},
},
];
91 changes: 91 additions & 0 deletions nodes/Shotstack/resources/generation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { INodeProperties } from 'n8n-workflow';
import { postGenerateDescription } from './postGenerate';
import { getGenerateDescription } from './getGenerate';

const showOnlyForGeneration = {
resource: ['generation'],
};

// 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 generationDescription: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: { show: showOnlyForGeneration },
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,
];
Loading
Loading