Pipelines
A pipeline is a graph — resize, convert, and where the results come out. Submit one against an uploaded input and each output becomes its own job. One graph can produce a whole responsive image set in a single call.
The shape
Under the hood that graph is a DAG (directed acyclic graph): nodes wired by edges, no loops. You write it as { nodes, edges }. Nodes reference a type and hold config; edges wire them together. Every graph starts at a pipeline.input and ends at one or more pipeline.outputnodes. From here on we'll say graph.
import type { Pipeline } from '@transform-kit/sdk';
// Resize to fit 1600px, encode as WebP, emit one output.
export const webPipeline: Pipeline = {
nodes: [
{ id: 'in', type: 'pipeline.input' },
{
id: 'resize',
type: 'image.resize',
config: {
mode: { value: 'pixels' },
width: { value: 1600 },
height: { value: 1600 },
fit: { value: 'contain' },
},
},
{
id: 'convert',
type: 'image.convert',
config: {
format: { value: 'webp' },
quality: { value: 82 },
},
},
{
id: 'out',
type: 'pipeline.output',
config: { suffix: { value: 'web' } },
},
],
edges: [
{ source: 'in', target: 'resize' },
{ source: 'resize', target: 'convert' },
{ source: 'convert', target: 'out' },
],
};Config is { value, editable }
Each config field is an object, not a bare value — that shape is shared with the visual pipeline editor. Prefer not to hand-write it? UsemergePipelineNodeConfig (below) to fill it from defaults.One output = one job = one credit
The server splits your graph into a self-contained job per pipeline.output, so a multi-output pipeline fans out in a single submit. Each output is billed one credit and retries on its own. Over a batch it multiplies: M files × N outputs credits.
import type { Pipeline } from '@transform-kit/sdk';
// One input → two outputs (a thumbnail and a hero). Two jobs per file.
export const responsivePipeline: Pipeline = {
nodes: [
{ id: 'in', type: 'pipeline.input' },
// Thumbnail branch — square crop, small WebP.
{ id: 'thumb-resize', type: 'image.resize', config: {
mode: { value: 'pixels' },
width: { value: 320 },
height: { value: 320 },
fit: { value: 'cover' },
} },
{ id: 'thumb-convert', type: 'image.convert', config: {
format: { value: 'webp' },
quality: { value: 80 },
} },
{ id: 'thumb-out', type: 'pipeline.output', config: {
suffix: { value: 'thumb' },
} },
// Hero branch — larger WebP, fit inside 1600px.
{ id: 'hero-resize', type: 'image.resize', config: {
mode: { value: 'pixels' },
width: { value: 1600 },
height: { value: 1600 },
fit: { value: 'contain' },
} },
{ id: 'hero-convert', type: 'image.convert', config: {
format: { value: 'webp' },
quality: { value: 82 },
} },
{ id: 'hero-out', type: 'pipeline.output', config: {
suffix: { value: 'hero' },
} },
],
edges: [
{ source: 'in', target: 'thumb-resize' },
{ source: 'thumb-resize', target: 'thumb-convert' },
{ source: 'thumb-convert', target: 'thumb-out' },
{ source: 'in', target: 'hero-resize' },
{ source: 'hero-resize', target: 'hero-convert' },
{ source: 'hero-convert', target: 'hero-out' },
],
};Skip the boilerplate
mergePipelineNodeConfigfills a node's config from the type defaults, so you only pass what you want to change.
import { mergePipelineNodeConfig, type Pipeline } from '@transform-kit/sdk';
const convert = {
id: 'convert',
type: 'image.convert',
config: mergePipelineNodeConfig('image.convert', { format: 'webp', quality: 82 }),
};Run a pipeline
Give runPipeline files and a graph — it validates, uploads, submits, and awaits every output. Batch behaviour and progress callbacks are the same as Core concepts.
const results = await tk.runPipeline(
[file1, file2, file3], // bytes, { bytes, filename }, or a lazy loader
responsivePipeline,
{
concurrency: 6, // files in flight at once (default 6)
onProgress: (e) => console.log(e.stage, e.filename ?? e.index),
},
);
for (const r of results) {
if (!r.ok) {
console.error(r.filename, r.error);
continue;
}
for (const out of r.outputs) {
console.log(r.filename, out.output, out.media.url);
}
}Each result is one input's outcome:
type PipelineRunResult = {
index: number; // position in the input array
filename?: string;
ok: boolean; // false isolates this file's failure
outputs: { job_id: string; output?: string; media: { url: string; /* … */ } }[];
error?: string;
};Lazy loaders keep big batches bounded
A file can be a function that returns its bytes — it is only called when the file reaches an open slot, so a 500-file run never holds 500 files in memory at once. This is where a server reads from disk without pulling node:fs into the SDK.
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
const paths = ['a.png', 'b.jpg', 'c.webp'];
await tk.runPipeline(
paths.map((p) => async () => ({ bytes: await readFile(p), filename: basename(p) })),
webPipeline,
{ concurrency: 8 },
);Validate first
runPipeline validates locally before any upload and throws invalid_pipeline if the graph is malformed. To check a graph you built yourself (missing input/output, a cycle, a duplicate id, an unknown node type), call validatePipeline directly. Pass ALLOWED_NODE_TYPES so any node outside the supported set is flagged.
import { validatePipeline, ALLOWED_NODE_TYPES } from '@transform-kit/sdk';
const { valid, errors } = validatePipeline(myPipeline, ALLOWED_NODE_TYPES);
if (!valid) console.error(errors); // [{ code: 'NO_OUTPUT', message: '…' }]Node reference
Every graph starts at one pipeline.input and ends at one or more pipeline.output nodes. Between them go that medium's transform nodes — the Nodes group in the sidebar has a reference page per node (config fields, defaults, and JSON), and Images, Video, Audio, and Documents each list what their medium can do.
Next steps
- Recipes — fan-out and batch patterns you can paste.
- Next.js — Server Actions, Route Handlers, and streaming progress.
- HTTP & curl — the same flow without the SDK.