Skip to content
Merged
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