Skip to main content

Make a highlight reel from your LosslessCut projects

· 2 min read

If you've marked the best moments of many videos as segments in LosslessCut, this script joins them all into one highlight reel, without exporting each file first.

LosslessCut saves the segments you mark in a project file (.llc) next to each video. The script reads every .llc file in a folder, takes the segments from each one, scales them to the same size (adding black bars when the aspect ratio differs) and joins them with FFmpeg into highlight-reel.mp4 in the same folder.

Unlike LosslessCut's own export, this re-encodes the video, so it works across videos with different sizes, frame rates and codecs. It's video only: audio is left out, since clips from different cameras rarely mix well anyway.

Usage​

You need Node.js 20 or newer and FFmpeg (brew install ffmpeg on a Mac).

mkdir highlight-reel && cd highlight-reel
curl -fsSLO https://mifi.no/scripts/losslesscut-highlight-reel.mjs
npm install json5

node losslesscut-highlight-reel.mjs /path/to/videos

Options:

  • --shuffle: random order instead of by file name.
  • --max 20: use at most 20 segments (after shuffling, so --shuffle --max 20 makes a random selection).
  • --width 1920 --height 1080: output size (default 1280×720).

A segment without an end point lasts until the end of its video.

FFmpeg opens all the segments at once, so hundreds of 4K clips can use a lot of memory. Use --max (or a smaller output size) if it runs out.

The script​

losslesscut-highlight-reel.mjs
#!/usr/bin/env node
// Makes a highlight reel from LosslessCut projects: the cut segments of every .llc project file in a folder,
// scaled to the same size and joined into one video (video only, no audio).
// Usage: node losslesscut-highlight-reel.mjs <folder> [--shuffle] [--max 20] [--width 1280] [--height 720]
// Needs Node.js 20+, ffmpeg, and the json5 package (npm install json5) next to this script.
// Original: https://mifi.no/scripts/losslesscut-highlight-reel.mjs
// See https://mifi.no/blog/losslesscut-highlight-reel/
import { spawn } from 'node:child_process';
import { readdir, readFile, access } from 'node:fs/promises';
import { join } from 'node:path';
import { parseArgs } from 'node:util';
import JSON5 from 'json5';

const { positionals: [dir], values } = parseArgs({
allowPositionals: true,
options: {
shuffle: { type: 'boolean', default: false },
max: { type: 'string' },
width: { type: 'string', default: '1280' },
height: { type: 'string', default: '720' },
},
});
if (!dir) {
console.error('Usage: node losslesscut-highlight-reel.mjs <folder> [--shuffle] [--max 20] [--width 1280] [--height 720]');
process.exit(1);
}

// Collect segments from the project files, in file name order
let clips = [];
for (const name of (await readdir(dir)).filter((n) => n.endsWith('.llc')).sort()) {
const { mediaFileName, cutSegments = [] } = JSON5.parse(await readFile(join(dir, name), 'utf8'));
const filePath = join(dir, mediaFileName);
if (await access(filePath).then(() => true, () => false)) {
clips.push(...cutSegments.map(({ start, end }) => ({ filePath, start, end })));
} else {
console.warn(`Skipping ${name}: ${mediaFileName} not found`);
}
}

if (values.shuffle) clips = clips.map((c) => [Math.random(), c]).sort(([a], [b]) => a - b).map(([, c]) => c);
if (values.max) clips = clips.slice(0, Number(values.max));
if (clips.length === 0) throw new Error('No segments found');
console.log(`Joining ${clips.length} segments`);

const { width, height } = values;
const outputPath = join(dir, 'highlight-reel.mp4');

const args = [
// One input per segment, seeking fast to its start (a segment without an end lasts to the end of the file)
...clips.flatMap(({ filePath, start, end }) => ['-ss', String(start ?? 0), ...(end != null ? ['-to', String(end)] : []), '-i', filePath]),
'-filter_complex', [
// Fit each segment inside width x height, with black bars where the aspect ratio differs
...clips.map((_, i) => `[${i}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,setpts=PTS-STARTPTS[v${i}]`),
`${clips.map((_, i) => `[v${i}]`).join('')}concat=n=${clips.length}:v=1:a=0[out]`,
].join(';'),
'-map', '[out]', '-c:v', 'libx264', '-crf', '20', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-y', outputPath,
];

const ffmpeg = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-stats', ...args], { stdio: 'inherit' });
const code = await new Promise((resolve) => ffmpeg.on('close', resolve));
if (code !== 0) throw new Error(`ffmpeg failed with exit code ${code}`);
console.log(`Done: ${outputPath}`);