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.
On this page
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
// 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
confirmCostthe montage won't start, so tokens can't be spent by accident. - Retries — on
429and server errors the request is repeated with the sameIdempotency-Key, so there's no double start. - Publishing — only with
--youtubeand only if a YouTube account is connected in the app.