Ролик из папки на Node.js
Готовый скрипт на Node.js — загружает все видео, фото и аудио из папки, показывает цену, собирает ролик, экспортирует и публикует его в YouTube.
На этой странице
Скрипт берёт папку с материалами — например, всё, что вы сняли за день на мероприятии, — и делает из неё один готовый вертикальный ролик: загружает файлы, называет цену и ждёт вашего «да», запускает монтаж, сохраняет результат рядом с папкой и при желании публикует его в YouTube Shorts.
Что понадобится: Node.js 20+, токен с набором «Монтаж» (для публикации — «Полный доступ» или области projects:write,
montage:run, publish) в переменной SLICER_TOKEN.
Скрипт
// 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(); // один Idempotency-Key на все попытки этого запроса
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. Какие файлы подходят — по каталогу Slicer
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(`В папке ${folder} нет подходящих файлов`);
console.log(`Материалов: ${files.length}`);
// 2. Загрузка: по три файла одновременно, каждый — по одноразовой ссылке
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)} с)`);
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. Цена и согласие
const request = {
uploadIds,
title: basename(folder),
brief: 'Самые яркие моменты дня, в конце — общий план',
settings: { templateId: 'bold', targetDurationSec: 30, direction: { mode: 'story', pace: 'dynamic' } },
};
const quote = await api('POST', '/projects', request);
if (!quote.enough) throw new Error(`Нужно ${quote.cost} токенов, доступно ${quote.available}`);
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`Монтаж стоит ${quote.free ? '0 (бесплатный ролик)' : `${quote.cost} токенов`}. Запустить? [y/N] `);
rl.close();
if (answer.trim().toLowerCase() !== 'y') process.exit(0);
// 4. Монтаж
let project = await api('POST', '/projects', { ...request, confirmCost: quote.cost });
console.log(`Проект ${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. Экспорт (и публикация в YouTube, если нужно)
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. Скачать
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(`Готово: ${output}`);
if (youtube) console.log('Публикация в YouTube началась — статус: GET /v1/projects/{id}/publications');Как это работает
- Отбор файлов — по расширениям из каталога
GET /v1/styles→materialLimits, поэтому скрипт не нужно менять, когда Slicer начнёт принимать новые форматы. Порядок файлов — по имени: так режиссёр видит их в порядке съёмки, если камера нумерует файлы. - Загрузка — по одноразовым ссылкам, по три файла одновременно. Токен не уходит в запрос с файлом.
- Цена — скрипт показывает её и ждёт подтверждения. Без
confirmCostмонтаж не запустится, поэтому случайно потратить токены нельзя. - Повторы — при
429и ошибках сервера запрос повторяется с тем жеIdempotency-Key, двойного запуска не будет. - Публикация — только если передан
--youtubeи YouTube-аккаунт подключён в приложении.