Skip to main content

Plan Video Segments Before Encoding: Three Cut Strategies in JavaScript

Updated 7 min readMarkdown

AI disclosure: This developer tutorial was prepared with AI-assisted writing. Its code is a standalone teaching implementation, not PixMind's production scene detector. No AI-generated images are used.

A video splitter has two jobs that are easy to mix together: decide where segments begin and end, then render files for those intervals. Keeping a small, testable segment plan between those jobs makes the workflow easier to debug.

This adaptation of our scene-splitting guide focuses on that planning layer. It accepts a known duration and one of three policies. It does not decode video, detect visual cuts, or export an MP4. Those remain separate integrations.

Give every stage the same unit

Use integer milliseconds for this example. Each output record contains a one-based index, a start time, an end time, and a duration. Interpret the interval as start-inclusive and end-exclusive. Adjacent intervals can then share a boundary without adding an extra time step.

A plan covering seventy seconds must begin at zero and end at 70000. If every adjacent pair meets at exactly the same timestamp, there are no gaps in the plan. These arithmetic properties are useful before any encoder starts; they do not establish frame accuracy in the encoded files.

Keep conversion at the integration boundary. If a detector returns seconds, convert them deliberately before calling this function. The example drops non-integer scene candidates, including numeric strings. A real application should report rejected detector values when that helps diagnosis.

Implement three policies as one pure function

Save this as segment-plan.mjs. It has no package dependencies and does not mutate the caller's scene-candidate array.

export function planSegments(durationMs, options) {
  const integer = n => Number.isSafeInteger(n);
  if (!integer(durationMs) || durationMs < 1 || durationMs > 120000) {
    throw new RangeError('durationMs must be an integer from 1 to 120000');
  }
  if (!options || typeof options !== 'object') {
    throw new TypeError('options must be an object');
  }
  let cuts = [];
  if (options.mode === 'fixed') {
    const step = options.stepMs;
    if (!integer(step) || step < 1 || step > 120000) {
      throw new RangeError('stepMs must be an integer from 1 to 120000');
    }
    if (Math.ceil(durationMs / step) > 1000) {
      throw new RangeError('example planner allows at most 1000 segments');
    }
    for (let t = step; t < durationMs; t += step) cuts.push(t);
  } else if (options.mode === 'equal') {
    const count = options.count;
    if (!integer(count) || count < 2 || count > 20 || count > durationMs) {
      throw new RangeError('count must be 2 to 20, with at least 1 ms per part');
    }
    for (let i = 1; i < count; i++) {
      cuts.push(Math.floor(i * durationMs / count));
    }
  } else if (options.mode === 'scene') {
    if (!Array.isArray(options.cutsMs)) {
      throw new TypeError('cutsMs must be an array');
    }
    cuts = options.cutsMs.filter(t => integer(t) && t > 0 && t < durationMs);
    cuts = [...new Set(cuts)].sort((a, b) => a - b);
    if (cuts.length > 999) throw new RangeError('too many scene boundaries');
  } else {
    throw new TypeError('unknown mode');
  }
  const boundaries = [0, ...cuts, durationMs];
  return boundaries.slice(1).map((endMs, i) => ({
    index: i + 1,
    startMs: boundaries[i],
    endMs,
    durationMs: endMs - boundaries[i]
  }));
}

Fixed mode produces repeated lengths and a possible shorter tail. Equal mode places boundaries using fractions of the full duration; flooring each boundary keeps integer milliseconds while preserving the final end time. Scene mode accepts boundaries supplied by another component, removes exact duplicates and out-of-range values, then sorts them numerically.

The 120000-millisecond duration limit and equal-count range make this example suitable for short clips. The 1000-segment ceiling is an explicit safeguard for this teaching implementation, not a statement about PixMind's product limits. Adjust limits to your own UI and resource budget.

Inspect the plan before creating files

A seventy-second source divided every fifteen seconds should produce five records, with a ten-second final segment. Save this as example.mjs alongside the planner:

import { planSegments } from './segment-plan.mjs';
const segments = planSegments(70000, { mode: 'fixed', stepMs: 15000 });
console.table(segments);
// Durations: 15000, 15000, 15000, 15000, 10000 milliseconds.

Run node example.mjs and inspect the table. Give the UI the same records you will pass to the renderer, so a preview label cannot silently use a different calculation. If the requested step is longer than the source, this implementation deliberately returns one full-length segment.

For equal parts, a 10001-millisecond source divided into three sections produces durations of 3333, 3334, and 3334 milliseconds. The sections differ by at most one millisecond because the total cannot be divided into three identical integer durations. Keeping the endpoint exact is more useful than rounding every duration independently and losing the remainder.

Test the boundaries and coverage

Save this as segment-plan-check.mjs. The checks cover remainder handling, exact division, a step longer than the source, uneven equal parts, unordered scene candidates, no detected cuts, invalid input, and a deliberately excessive output count.

import assert from 'node:assert/strict';
import { planSegments } from './segment-plan.mjs';

const fixed = planSegments(70000, { mode: 'fixed', stepMs: 15000 });
assert.deepEqual(fixed.map(s => s.durationMs), [15000, 15000, 15000, 15000, 10000]);
assert.equal(planSegments(60000, { mode: 'fixed', stepMs: 15000 }).length, 4);
assert.equal(planSegments(5000, { mode: 'fixed', stepMs: 15000 }).length, 1);
const equal = planSegments(10001, { mode: 'equal', count: 3 });
assert.deepEqual(equal.map(s => s.durationMs), [3333, 3334, 3334]);
const scene = planSegments(10000, {
  mode: 'scene', cutsMs: [7000, 0, 3000, 3000, -1, 10000, NaN, '4000']
});
assert.deepEqual(scene.map(s => s.startMs), [0, 3000, 7000]);
assert.equal(planSegments(10000, { mode: 'scene', cutsMs: [] }).length, 1);
assert.throws(() => planSegments(NaN, { mode: 'fixed', stepMs: 10 }), RangeError);
assert.throws(() => planSegments(10000, { mode: 'fixed', stepMs: 0 }), RangeError);
assert.throws(() => planSegments(10000, { mode: 'equal', count: 1 }), RangeError);
assert.throws(() => planSegments(10000, { mode: 'other' }), TypeError);
assert.throws(() => planSegments(120000, { mode: 'fixed', stepMs: 1 }), RangeError);
for (const segments of [fixed, equal, scene]) {
  assert.equal(segments[0].startMs, 0);
  assert.ok(segments.every(s => s.durationMs > 0));
  for (let i = 1; i < segments.length; i++) {
    assert.equal(segments[i - 1].endMs, segments[i].startMs);
  }
  assert.equal(segments.reduce((n, s) => n + s.durationMs, 0), segments.at(-1).endMs);
}
console.log('11 behavior cases plus 3 coverage checks passed');

Run the examples with Node.js from that directory:

node segment-plan-check.mjs
node example.mjs

The built-in Node.js strict assertion module provides the assertions used here. The coverage checks verify the beginning of the plan, positive durations, adjacency, and the sum of durations. They test the time plan only; they are not codec tests or a scene-detection benchmark.

Keep visual detection and editorial decisions separate

Sorting timestamps does not determine whether they are good cuts. A flash can still be a false positive, and a gradual dissolve can still be missed. Nearby but distinct candidates remain separate in this example; production logic may need a minimum scene length or a clustering policy before planning.

A recording can change topics without a visual edit. If you need chapters based on speech, supply boundaries from that analysis rather than treating image changes as semantic understanding. Returning one interval when the candidate list is empty is a predictable fallback, not evidence that the recording contains only one idea.

Pass a stable manifest to the encoder

Once the plan is accepted, store the source identifier, the requested policy, and the resulting intervals together. Convert milliseconds to the units required by your encoding integration exactly once. Associate each completed output with its record index instead of inferring order from asynchronous completion time.

An encoder can seek, decode, and re-encode according to its own time base. Inspect exported files for clean starts, audio continuity, expected count, and approximate durations; do not claim that the integer plan alone guarantees frame-exact output. Keep the original source so a revised plan does not require encoding an already compressed clip again.

A small manifest makes failures easier to locate: an incorrect interval is a planning problem, a correct interval with a broken MP4 is an export problem, and an unwanted visual boundary belongs to detection or editorial review. Each stage has a concrete input and an output you can inspect.

Adapted from PixMind: How to Split a Video into Clips Automatically by Scene. This version adds a standalone JavaScript planning layer and boundary tests instead of repeating the product walkthrough.

3 views

About the author

  • The PixMind team shares practical AI image and video workflows. PixMind brings text-to-image, reference-based creation, editing, and video tools into one workspace. Website: https://www.pixmind.io/

No comments yet. Be the first to comment.