Durable multi-format transcoding with Temporal and Ittybit
You need to deliver the same video in five formats — MP4/H.264 for browsers, WebM/VP9 for bandwidth-sensitive clients, HLS for streaming, a 720p proxy for previews, and a 360p mobile variant. If the worker crashes after three formats finish, you don’t want to re-encode those three. Temporal tracks workflow state across restarts, so the workflow picks up exactly where it left off.
Project setup
npm init -y
npm install @temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity
src/
activities.ts # Ittybit API calls
workflows.ts # Multi-format orchestration
worker.ts # Temporal worker
client.ts # Start a workflow
Set your environment variables:
ITTYBIT_API_KEY=your_ittybit_api_key
Activities
Each activity submits a single transcode task to Ittybit and polls until it reaches a terminal state. Temporal retries the activity if the worker crashes mid-poll — the heartbeat timeout detects the stall.
// src/activities.ts
import { heartbeat } from '@temporalio/activity';
const ITTYBIT_API = 'https://api.ittybit.com';
interface TranscodeSpec {
format: string;
codec: string;
width: number;
height: number;
label: string;
}
interface TaskResult {
id: string;
status: 'queued' | 'processing' | 'completed' | 'failed';
output?: { url: string };
}
async function createAndAwaitTask(sourceUrl: string, spec: TranscodeSpec): Promise<TaskResult> {
const res = await fetch(`${ITTYBIT_API}/tasks`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
input: sourceUrl,
kind: 'video',
options: {
format: spec.format,
codec: spec.codec,
width: spec.width,
height: spec.height,
},
}),
});
if (!res.ok) {
throw new Error(`Ittybit returned ${res.status}: ${await res.text()}`);
}
const task: TaskResult = await res.json();
while (task.status === 'queued' || task.status === 'processing') {
await new Promise((r) => setTimeout(r, 3_000));
heartbeat();
const poll = await fetch(`${ITTYBIT_API}/tasks/${task.id}`, {
headers: { Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}` },
});
const updated: TaskResult = await poll.json();
task.status = updated.status;
task.output = updated.output;
}
if (task.status === 'failed') {
throw new Error(`Task ${task.id} failed for format ${spec.label}`);
}
return task;
}
export async function transcodeFormat(
sourceUrl: string,
spec: TranscodeSpec,
): Promise<{ label: string; url: string }> {
const task = await createAndAwaitTask(sourceUrl, spec);
return { label: spec.label, url: task.output!.url };
}
One activity, one format. If the H.264 encode succeeds but the VP9 encode fails, Temporal retries only the VP9 activity.
Workflow
The workflow fans out all formats with Promise.allSettled, so a single failure doesn’t cancel the others. Completed formats are tracked in workflow state — if the worker crashes and Temporal replays the workflow, the already-completed activities return their cached results instantly.
// src/workflows.ts
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as activities from './activities';
const { transcodeFormat } = proxyActivities<typeof activities>({
startToCloseTimeout: '20 minutes',
heartbeatTimeout: '30 seconds',
retry: {
maximumAttempts: 3,
initialInterval: '5s',
backoffCoefficient: 2,
},
});
interface TranscodeSpec {
format: string;
codec: string;
width: number;
height: number;
label: string;
}
interface FormatResult {
label: string;
url: string;
}
interface TranscodeOutput {
completed: FormatResult[];
failed: string[];
}
export async function durableTranscode(
sourceUrl: string,
specs: TranscodeSpec[],
): Promise<TranscodeOutput> {
// Each activity is independently retried and checkpointed.
// If the worker dies after 3 of 5 finish, Temporal replays
// the workflow -- the 3 completed activities resolve from
// history, and only the remaining 2 are dispatched.
const results = await Promise.allSettled(specs.map((spec) => transcodeFormat(sourceUrl, spec)));
const completed: FormatResult[] = [];
const failed: string[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled') {
completed.push(result.value);
} else {
failed.push(specs[i].label);
}
}
if (failed.length === specs.length) {
throw ApplicationFailure.nonRetryable(`All formats failed: ${failed.join(', ')}`);
}
return { completed, failed };
}
Promise.allSettled is key. With Promise.all, a single VP9 failure would reject the entire batch and you’d lose the URLs from the formats that succeeded. allSettled lets you collect partial results and report exactly which formats failed.
Worker
// src/worker.ts
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'transcode',
});
await worker.run();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Start a workflow
// src/client.ts
import { Client } from '@temporalio/client';
import { durableTranscode } from './workflows';
const specs = [
{ format: 'mp4', codec: 'h264', width: 1920, height: 1080, label: '1080p-h264' },
{ format: 'webm', codec: 'vp9', width: 1920, height: 1080, label: '1080p-vp9' },
{ format: 'mp4', codec: 'h264', width: 1280, height: 720, label: '720p-proxy' },
{ format: 'mp4', codec: 'h264', width: 640, height: 360, label: '360p-mobile' },
{ format: 'mp4', codec: 'h265', width: 1920, height: 1080, label: '1080p-hevc' },
];
async function run() {
const client = new Client();
const result = await client.workflow.execute(durableTranscode, {
taskQueue: 'transcode',
workflowId: `transcode-${Date.now()}`,
args: ['https://example.com/raw/interview.mov', specs],
});
console.log(
'Completed:',
result.completed.map((r) => r.label),
);
if (result.failed.length > 0) {
console.warn('Failed:', result.failed);
}
}
run().catch(console.error);
Start the worker, run the client. If you kill the worker mid-run and restart it, the workflow resumes — formats that already completed are replayed from history and don’t hit the Ittybit API again.
Tuning retry per format
Some codecs are slower or flakier than others. Override the activity options for specific formats by creating separate proxies.
// src/workflows.ts
import { proxyActivities, ApplicationFailure } from '@temporalio/workflow';
import type * as activities from './activities';
const defaultOpts = {
startToCloseTimeout: '20 minutes',
heartbeatTimeout: '30 seconds',
retry: { maximumAttempts: 3, initialInterval: '5s', backoffCoefficient: 2 },
} as const;
const { transcodeFormat } = proxyActivities<typeof activities>(defaultOpts);
// VP9 encodes take longer -- give them more time and retries
const { transcodeFormat: transcodeVp9 } = proxyActivities<typeof activities>({
...defaultOpts,
startToCloseTimeout: '40 minutes',
retry: { maximumAttempts: 5, initialInterval: '10s', backoffCoefficient: 2 },
});
// In the workflow, route by codec:
function transcodeWithPolicy(sourceUrl: string, spec: activities.TranscodeSpec) {
if (spec.codec === 'vp9') {
return transcodeVp9(sourceUrl, spec);
}
return transcodeFormat(sourceUrl, spec);
}
See also
- Temporal TypeScript SDK docs
- Durable media pipeline with Temporal — sequential multi-stage pipeline
- Ittybit Task API reference
- HLS streaming — adaptive bitrate output