# Durable multi-format transcoding with Temporal and Ittybit

Transcode video to multiple formats with per-format retry and crash recovery via Temporal

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

```bash
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:

```bash
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.

```typescript
// src/activities.ts

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.

```typescript
// src/workflows.ts

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

```typescript
// src/worker.ts

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

```typescript
// src/client.ts

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.

```typescript
// src/workflows.ts

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](https://docs.temporal.io/develop/typescript)
- [Durable media pipeline with Temporal](/guides/durable-media-pipeline-with-temporal) -- sequential multi-stage pipeline
- [Ittybit Task API reference](/reference/tasks)
- [HLS streaming](/guides/create-hls-streams) -- adaptive bitrate output