Skip to main content

Calculate a Previs Timeline in Integer Frames with Node.js

Updated 8 min readMarkdown

Four five-second shots do not always make a twenty-second reel. If three adjacent joins each overlap by half a second, the proposed timeline is eighteen and a half seconds. A developer can catch that arithmetic mismatch in a small shot manifest before an editor or generator touches the footage.

This tutorial builds a dependency-free Node.js planner that accepts shot durations and overlaps as integer frame counts. It returns stable shot IDs, start and end boundaries, total frames, and a display duration. All examples use synthetic metadata; the command never opens a video or creates a rendered edit.

1. Define what an overlap means in this manifest

Each shot has a unique ID and a positive frame count. The overlaps array contains one nonnegative frame count for each adjacent pair. Zero means a hard cut. Twelve overlap frames at 24 fps mean that the next shot starts half a second before the preceding shot ends.

These overlaps shorten the sum of the chosen source spans. They do not describe an editor adding a dissolve with extra source handles while preserving an existing cut length. Record that distinction in the handoff so that the person using the JSON knows which timing model it represents.

The example accepts integer frame rates from 1 through 120 and safe-integer frame counts. It deliberately rejects rates such as 23.976 and 29.97. Rational frame-rate handling and drop-frame timecode need a different representation; rounding those rates to integers would change the timing.

This project also requires each shot to retain at least one frame outside its incoming and outgoing overlaps. That conservative rule prevents an entire middle shot from being consumed by transitions. It is a local editing policy, not a universal restriction imposed by video software.

2. Validate and calculate the frame intervals

Save the following as timeline.mjs. A row uses a half-open interval: start is included and end is excluded. A shot with start 0 and end 120 contains 120 frames. The next start is the previous end minus the incoming overlap.

export function planTimeline(input) {
  const fail = text => { throw new Error(text); };
  const integer = (n, min) => Number.isSafeInteger(n) && n >= min;
  if (!input || typeof input !== 'object' || Array.isArray(input)) fail('Expected object');
  const { fps, shots, overlaps } = input;
  if (!integer(fps, 1) || fps > 120) fail('fps must be an integer from 1 to 120');
  if (!Array.isArray(shots) || !shots.length) fail('Expected nonempty shots');
  if (!Array.isArray(overlaps) || overlaps.length !== shots.length - 1) {
    fail('Expected one overlap per adjacent pair');
  }
  const ids = new Set();
  for (const shot of shots) {
    if (!shot || typeof shot.id !== 'string' ||
        !/^[A-Za-z][A-Za-z0-9_-]{0,31}$/.test(shot.id) || ids.has(shot.id)) {
      fail('Invalid or duplicate shot ID');
    }
    ids.add(shot.id);
    if (!integer(shot.frames, 1)) fail('Shot frames must be positive safe integers');
  }
  for (const value of overlaps) {
    if (!integer(value, 0)) fail('Overlaps must be nonnegative safe integers');
  }
  let end = 0;
  const rows = [];
  for (let i = 0; i < shots.length; i++) {
    const incoming = i ? overlaps[i - 1] : 0;
    const outgoing = i < overlaps.length ? overlaps[i] : 0;
    const { id, frames } = shots[i];
    if (incoming + outgoing >= frames) fail('Overlaps consume a whole shot');
    const start = end - incoming;
    end = start + frames;
    if (!integer(end, 1)) fail('Timeline exceeds safe integer range');
    rows.push({ id, start, end, frames });
  }
  return { fps, shots: rows, totalFrames: end, totalSeconds: end / fps };
}

The ID pattern makes IDs short and predictable for this worksheet. The function rejects duplicates rather than silently merging two entries. Keep an ID attached to the same shot when reordering a scene, so that notes about H03 do not suddenly refer to a different piece of footage.

Number.isSafeInteger rejects fractional, nonnumeric, and out-of-range values. The output boundary is checked after each addition as well. Keep the integer boundaries as the data used for later calculations; totalSeconds is a convenient display value obtained by dividing the final frame count by fps.

3. Reproduce four shots with three overlaps

Save the next script as demo.mjs beside timeline.mjs and run node demo.mjs in a scratch folder. It prints a table and writes plan.json in the working folder, replacing that filename if it already exists. The manifest contains four 120-frame source spans and three 12-frame overlaps.

import { writeFile } from 'node:fs/promises';
import { planTimeline } from './timeline.mjs';

const plan = {
  fps: 24,
  shots: ['H01', 'H02', 'H03', 'H04'].map(id => ({ id, frames: 120 })),
  overlaps: [12, 12, 12]
};
const result = planTimeline(plan);
console.table(result.shots);
console.log(result.totalFrames, result.totalSeconds); // 444, 18.5
await writeFile('plan.json', JSON.stringify(plan, null, 2));

The expected rows are H01 [0,120), H02 [108,228), H03 [216,336), and H04 [324,444). Each shot still spans 120 frames. The starts move earlier because adjacent source spans overlap; the last ending boundary is 444.

At 24 fps, 444 frames equal 18.5 seconds. The same result follows from 480 total source frames minus 36 overlap frames. A value of eighteen seconds would require a different selection of spans or overlaps. The planner makes the chosen assumptions inspectable rather than guessing which edit was intended.

4. Read a saved manifest from the command line

Save this wrapper as cli.mjs. After running the demo, run node cli.mjs plan.json. It reads local UTF-8 JSON, calls the planner, and writes the result to standard output. It does not overwrite the input manifest.

import { readFile } from 'node:fs/promises';
import { planTimeline } from './timeline.mjs';

if (process.argv.length !== 3) {
  console.error('Usage: node cli.mjs plan.json');
  process.exitCode = 2;
} else {
  try {
    const input = JSON.parse(await readFile(process.argv[2], 'utf8'));
    console.log(JSON.stringify(planTimeline(input), null, 2));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}

The command exits with status 0 for a valid plan, status 1 for unreadable files, invalid JSON, or invalid plan values, and status 2 for an incorrect number of arguments. That makes it usable in a local preparation script without parsing a human-readable success message.

This small wrapper reads the whole manifest into memory. Use it for ordinary local shot lists; an upload service would need separate input-size and resource limits. Neither the parser nor the planner checks whether the named shot exists or whether its actual media has the frame count listed in the JSON.

5. Check hard cuts, overlaps, and rejection paths

Save the following as example-checks.mjs and run node example-checks.mjs. This second fixture uses unequal shot lengths with hard cuts: 96, 120, 72, and 144 frames. It also checks that a missing overlap entry is rejected.

import assert from 'node:assert/strict';
import { planTimeline } from './timeline.mjs';

const shots = [96, 120, 72, 144].map((frames, i) => ({ id: `H0${i + 1}`, frames }));
const cut = planTimeline({ fps: 24, shots, overlaps: [0, 0, 0] });
assert.equal(cut.totalFrames, 432);
assert.equal(cut.totalSeconds, 18);
assert.deepEqual(cut.shots.map(s => s.start), [0, 96, 216, 288]);
assert.throws(() => planTimeline({ fps: 24, shots, overlaps: [0, 0] }));
console.log('Article assertions passed');

The expected hard-cut boundaries are [0,96), [96,216), [216,288), and [288,432). The proposed sequence lasts eighteen seconds at 24 fps. These durations describe the intended timeline; they are not measurements of generated clips.

The broader local test run on Node.js 24.20.0 passed 76 checks, plus the standalone demo and the article assertions. It covered totals at several frame rates, single-shot plans, unchanged input data, duplicate or malformed IDs, wrong overlap counts, fractional and unsafe integers, empty inputs, consumed middle shots, cumulative overflow, and CLI status 0, 1, and 2 paths.

For your own scene, add a fixture containing its real manifest and an independently calculated expected total. When a transition policy changes, update the expected result intentionally. A test that merely accepts whatever the current implementation prints will not catch an editing assumption changing underneath the project.

6. Hand the report to the editor with its assumptions

Keep plan.json and its output beside the storyboard or shot cards. State the frame rate, whether end boundaries are exclusive, and that overlaps consume the chosen source spans. Include the selected asset filename and version in a separate shot record keyed by the same ID.

If the editor uses additional source handles to keep cut points fixed, make a new manifest describing that edit instead of treating this output as an interchange file. The JSON is a planning report; it is not an EDL, an XML timeline, or a promise that a particular editor will import the plan.

After actual clips are available, compare their measured durations with the planned spans and record any trimming or padding decisions in the edit. This script can make a timing inconsistency visible, but judging the pacing and the transitions still requires viewing the assembled sequence.

AI assistance disclosure: This tutorial and code were prepared with AI assistance and checked using the Node.js examples described above. Only synthetic shot metadata, integer-frame arithmetic, and command-line behavior were tested. No video decoding, generation, rendering, editor import, or visual evaluation was performed. There are no illustrative images in this complete text-and-code edition.

Adapted and substantially rewritten for developers from the PixMind official article: Cinematic Previsualization With Wan 2.7: A 4-Shot Case Study. Model prices, internal test figures, performance rankings, and production outcomes from the original are not used as verified results here.

Technical references: ECMAScript Number.isSafeInteger for integer-range validation, and Node.js readFile for local JSON input. The manifest schema and overlap policy are choices made for this tutorial.

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.