# Ролик из папки на Node.js

> Готовый скрипт на Node.js — загружает все видео, фото и аудио из папки, показывает цену, собирает ролик, экспортирует и публикует его в YouTube.

Источник: https://docs.slicer.media/api/recipes/folder-to-reels/

Скрипт берёт папку с материалами — например, всё, что вы сняли за день на мероприятии, — и делает из неё один готовый вертикальный ролик: загружает файлы, называет цену и ждёт вашего «да», запускает монтаж, сохраняет результат рядом с папкой и при желании публикует его в YouTube Shorts.

Что понадобится: Node.js 20+, токен с набором «Монтаж» (для публикации — «Полный доступ» или области `projects:write`, `montage:run`, `publish`) в переменной `SLICER_TOKEN`.

## Скрипт

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(); // один 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-аккаунт подключён в приложении.

> **Совет.** Монтаж отдаёт одну версию ролика, уже проверенную режиссёром, — её скрипт и экспортирует. Если вы пересобирали проект и хотите экспортировать прежнюю версию, найдите её в `GET /v1/projects/{id}/versions` и вызовите `POST …/versions/{versionId}/choose` перед экспортом — см. [Версии ролика](https://docs.slicer.media/api/montage/#versions).

## Что дальше

[Пакетная обработкаРолик из каждого файла папки — на Python.](https://docs.slicer.media/api/recipes/batch-python/)[Уведомления через вебхукСообщение в чат вместо ожидания в терминале.](https://docs.slicer.media/api/recipes/webhook-notify/)
