# A reel from a folder with Node.js

> A ready-to-run Node.js script that uploads every video, photo and audio file in a folder, shows the price, makes a reel, exports and publishes it.

Source: https://docs.slicer.media/en/api/recipes/folder-to-reels/

The script takes a folder of materials — say, everything you shot at an event today — and turns it into one finished vertical video: it uploads the files, names the price and waits for your "yes", starts the montage, saves the result next to the folder and, if you like, publishes it to YouTube Shorts.

You will need: Node.js 20+ and a token with the **Montage** preset (for publishing — **Full access**, or the `projects:write`, `montage:run` and `publish` scopes) in `SLICER_TOKEN`.

## The script

folder-to-reel.mjs:

```js
// node folder-to-reel.mjs ./event-2026-10-16 [--youtube]
import { createWriteStream, openAsBlob } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import { basename, extname, join, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import { createInterface } from 'node:readline/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const API = 'https://api.slicer.media/v1';
const TOKEN = process.env.SLICER_TOKEN;
const folder = resolve(process.argv[2] ?? '.');
const publishToYoutube = process.argv.includes('--youtube');
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));

async function api(method, path, body) {
  const key = randomUUID(); // one Idempotency-Key for all attempts of this request
  for (let attempt = 1; ; attempt += 1) {
    const response = await fetch(`${API}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${TOKEN}`,
        ...(method !== 'GET' ? { 'Idempotency-Key': key } : {}),
        ...(body ? { 'Content-Type': 'application/json' } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    const json = await response.json();
    if (json.ok) return json.data;
    if ((response.status === 429 || response.status >= 500) && attempt < 5) {
      await sleep((Number(response.headers.get('Retry-After')) || 2 ** attempt) * 1000);
      continue;
    }
    throw new Error(`${response.status} ${json.error.code}: ${json.error.message}`);
  }
}

// 1. Which files fit — according to the Slicer catalog
const { materialLimits } = await api('GET', '/styles');
const allowed = new Set(
  [...materialLimits.videoExtensions, ...materialLimits.imageExtensions, ...materialLimits.audioExtensions].map((ext) => `.${ext}`),
);
const files = (await readdir(folder))
  .filter((name) => allowed.has(extname(name).toLowerCase()))
  .sort()
  .slice(0, materialLimits.maxFiles)
  .map((name) => join(folder, name));
if (files.length === 0) throw new Error(`No suitable files in ${folder}`);
console.log(`Materials: ${files.length}`);

// 2. Upload: three files at a time, each via a one-time link
async function upload(file) {
  const { size } = await stat(file);
  const created = await api('POST', '/uploads', { fileName: basename(file), size });
  const put = await fetch(created.uploadUrl, { method: 'PUT', body: await openAsBlob(file) });
  const putJson = await put.json();
  if (!putJson.ok) throw new Error(`${basename(file)}: ${putJson.error.message}`);
  let item = putJson.data;
  while (item.status === 'uploading' || item.status === 'processing') {
    await sleep(2000);
    item = await api('GET', `/uploads/${item.id}`);
  }
  if (item.status !== 'ready') throw new Error(`${basename(file)}: ${item.error ?? item.status}`);
  console.log(`  ✓ ${basename(file)} (${item.media.kind}, ${Math.round(item.media.durationSec)} s)`);
  return item.id;
}

const uploadIds = [];
for (let i = 0; i < files.length; i += 3) {
  uploadIds.push(...(await Promise.all(files.slice(i, i + 3).map(upload))));
}

// 3. Price and consent
const request = {
  uploadIds,
  title: basename(folder),
  brief: 'The brightest moments of the day, ending on a wide shot',
  settings: { templateId: 'bold', targetDurationSec: 30, direction: { mode: 'story', pace: 'dynamic' } },
};
const quote = await api('POST', '/projects', request);
if (!quote.enough) throw new Error(`Needs ${quote.cost} tokens, available ${quote.available}`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`The montage costs ${quote.free ? '0 (free video)' : `${quote.cost} tokens`}. Start? [y/N] `);
rl.close();
if (answer.trim().toLowerCase() !== 'y') process.exit(0);

// 4. Montage
let project = await api('POST', '/projects', { ...request, confirmCost: quote.cost });
console.log(`Project ${project.id}: ${project.appUrl}`);
while (project.status === 'queued' || project.status === 'processing') {
  await sleep(5000);
  project = await api('GET', `/projects/${project.id}`);
  process.stdout.write(`\r${project.stage ?? ''} ${project.progress}%   `);
}
console.log();
if (project.status !== 'succeeded') throw new Error(project.error?.message ?? project.status);

// 5. Export (and publish to YouTube if requested)
const youtube = publishToYoutube
  ? (await api('GET', '/social/accounts')).find((account) => account.platform === 'youtube' && account.apiPublishing)
  : null;
project = await api('POST', `/projects/${project.id}/export`, {
  profile: { resolution: '1080p', fps: 30, quality: 'high' },
  ...(youtube
    ? { publish: [{ accountId: youtube.id, title: request.title, caption: '#shorts', options: { youtube: { privacy: 'public', madeForKids: false } } }] }
    : {}),
});
while (project.status === 'queued' || project.status === 'processing') {
  await sleep(5000);
  project = await api('GET', `/projects/${project.id}`);
}

// 6. Download
const output = `${folder}.${project.result.container}`;
const video = await fetch(project.result.url, { headers: { Authorization: `Bearer ${TOKEN}` } });
await pipeline(Readable.fromWeb(video.body), createWriteStream(output));
console.log(`Done: ${output}`);
if (youtube) console.log('Publishing to YouTube has started — status: GET /v1/projects/{id}/publications');
```

## How it works

-   **Picking files** — by the extensions from the catalog `GET /v1/styles` → `materialLimits`, so the script needs no changes when Slicer starts accepting new formats. Files are ordered by name: if your camera numbers its files, the director sees them in shooting order.
-   **Uploading** — via one-time links, three files at a time. The token never goes into the request that carries the file.
-   **Price** — the script shows it and waits for confirmation. Without `confirmCost` the montage won't start, so tokens can't be spent by accident.
-   **Retries** — on `429` and server errors the request is repeated with the same `Idempotency-Key`, so there's no double start.
-   **Publishing** — only with `--youtube` and only if a YouTube account is connected in the app.

> **Tip.** The montage produces one version, already checked by the director, and that's what the script exports. If you rebuilt a project and want to export an earlier version, find it in `GET /v1/projects/{id}/versions` and call `POST …/versions/{versionId}/choose` before exporting — see [Video versions](https://docs.slicer.media/en/api/montage/#versions).

## What's next

[Batch processingA reel from every file in a folder — in Python.](https://docs.slicer.media/en/api/recipes/batch-python/)[Notifications via webhookA chat message instead of waiting in the terminal.](https://docs.slicer.media/en/api/recipes/webhook-notify/)
