A shot description often starts as a useful set of notes and ends as a paragraph that is difficult to revise. Camera movement gets mixed into subject description, silence is confused with a missing audio instruction, and a change to one shot accidentally alters another. A small compiler can keep those decisions separate until the final text is assembled.
This tutorial builds a local Node.js program that turns a JSON shot list into one prompt string per shot. It checks the document shape, preserves explicit audio decisions, and keeps frame intervals next to the prose. You can inspect the output, edit the source record, and regenerate the same text without calling a video model.
1. Choose a record that preserves the creative decisions
Use one record per planned shot. The subject field describes the person, object, or environment. Camera describes framing, angle, lighting, and composition. Motion describes camera or subject movement. Dialogue records the intended line or explicitly states that there is none. Sound records ambience, effects, and music decisions. These are author-supplied notes; the program does not infer any of them from footage.
Timing uses half-open frame intervals: the start frame belongs to the shot and the end frame belongs to the next interval. At 24 fps, [0, 72) contains 72 frames, or three seconds. The next interval [72, 120) contains 48 frames, or two seconds. The pair covers 120 frames, or five seconds, without a gap or overlap.
The example is a fictional two-shot desk-lamp sequence. Save the following as shots.json. The close-up deliberately repeats the lamp identity and light direction; the compiler will not invent continuity details that the record omits.
{
"fps": 24,
"shots": [
{
"id": "lamp-wide",
"startFrame": 0,
"endFrame": 72,
"subject": "A matte blue desk lamp on an oak desk.",
"camera": "Wide eye-level view; soft window light from the left.",
"motion": "Static camera; a hand enters from the right and reaches toward the switch.",
"dialogue": "None.",
"sound": "Quiet room ambience; no music."
},
{
"id": "lamp-detail",
"startFrame": 72,
"endFrame": 120,
"subject": "The same blue lamp, with the hand on its switch.",
"camera": "Close-up of the switch; keep the light direction unchanged.",
"motion": "Static camera; the finger presses the switch once.",
"dialogue": "The speaker says: \"Light where you need it.\"",
"sound": "One switch click over quiet room ambience; no music."
}
]
}
Use a stable id so that review comments can refer to lamp-detail even after its wording changes. Keep the exact dialogue inside its field instead of burying it in a camera sentence. Write None. for an intentional absence; leaving a required field blank is treated as unfinished data.
2. Validate the notes before rendering the text
Save the next module as compile.mjs. It accepts integer frame rates from 1 to 120, between one and 1,000 shots, unique short ids, and contiguous positive intervals beginning at zero. It rejects unknown keys so that a typo such as camrea does not silently disappear from the result.
Each descriptive field must be a nonempty single line of at most 4,000 JavaScript code units. Trimming removes surrounding whitespace. The single-line rule keeps each labeled field on its own output line; it is a formatting contract, not an assessment of the meaning of the text.
const fields = ["subject", "camera", "motion", "dialogue", "sound"];
const shotKeys = ["id", "startFrame", "endFrame", ...fields];
function object(value, allowed, name) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(name + " must be an object");
}
if (Object.keys(value).some(key => !allowed.includes(key))) {
throw new Error(name + " has an unknown field");
}
}
export function compileShots(doc) {
object(doc, ["fps", "shots"], "document");
if (!Number.isInteger(doc.fps) || doc.fps < 1 || doc.fps > 120) {
throw new Error("fps must be an integer from 1 to 120");
}
if (!Array.isArray(doc.shots) || doc.shots.length < 1 || doc.shots.length > 1000) {
throw new Error("shots must contain 1 to 1000 entries");
}
const ids = new Set();
let cursor = 0;
const shots = doc.shots.map((shot, index) => {
object(shot, shotKeys, "shot " + index);
if (typeof shot.id !== "string" || !/^[A-Za-z0-9_-]{1,40}$/.test(shot.id) || ids.has(shot.id)) {
throw new Error("shot id must be unique and contain 1 to 40 letters, digits, _ or -");
}
ids.add(shot.id);
if (!Number.isSafeInteger(shot.startFrame) || !Number.isSafeInteger(shot.endFrame) ||
shot.startFrame !== cursor || shot.endFrame <= shot.startFrame) {
throw new Error("shots must be positive, contiguous safe-integer frame intervals starting at zero");
}
const clean = {};
for (const field of fields) {
const value = shot[field];
if (typeof value !== "string" || !value.trim() || value.length > 4000 || /[\r\n\u2028\u2029]/u.test(value)) {
throw new Error(field + " must be a nonempty single line of at most 4000 code units");
}
clean[field] = value.trim();
}
const durationFrames = shot.endFrame - shot.startFrame;
cursor = shot.endFrame;
const lines = [
"Shot " + shot.id,
"Frames: [" + shot.startFrame + ", " + shot.endFrame + ") at " + doc.fps + " fps",
"Duration: " + durationFrames + " frames (~" + (durationFrames / doc.fps).toFixed(3) + " s)"
];
for (const field of fields) lines.push(field + ": " + JSON.stringify(clean[field]));
return { id: shot.id, startFrame: shot.startFrame, endFrame: shot.endFrame, durationFrames, prompt: lines.join("\n") };
});
return { fps: doc.fps, totalFrames: cursor, shots };
}
The five descriptive values are rendered with JSON.stringify. That keeps embedded quotation marks visible as escaped characters and makes the boundary between a label and its value easy to inspect. It does not make the content trustworthy or turn the result into a provider-specific prompt format.
The frame count is the authoritative duration. The seconds string is explicitly approximate and rounded to three decimal places for human reading. This example intentionally excludes fractional frame rates such as 23.976; do not round such a project to 24 just to make it pass this contract.
3. Add a command-line wrapper with clear outcomes
Save this wrapper as cli.mjs in the same directory. It reads a UTF-8 JSON file, calls the pure compiler, and writes the result to standard output. The implementation uses built-in Node modules and needs no package installation.
import { readFile } from "node:fs/promises";
import { compileShots } from "./compile.mjs";
const args = process.argv.slice(2);
if (args.length !== 1) {
console.error("Usage: node cli.mjs shots.json");
process.exitCode = 2;
} else {
try {
const doc = JSON.parse(await readFile(args[0], "utf8"));
console.log(JSON.stringify(compileShots(doc), null, 2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
A successful run exits with code 0 and emits JSON. A missing or extra argument exits with code 2 and prints usage information to standard error. A file-reading error, malformed JSON, or rejected document exits with code 1. Keeping messages on standard error allows successful standard output to be redirected into a file.
This is a local handoff utility for a small shot list. The file is read before schema validation; the record-count and field-length rules are not a file-size limit. If you later wrap it in an upload service, decide on request and file-size handling as part of that service.
4. Run the example and inspect one shot at a time
Put compile.mjs, cli.mjs, and shots.json in one directory. Run the commands below from that directory with Node.js. The first command saves the generated object; the second prints the first prompt in its readable multiline form.
node cli.mjs shots.json > prompts.json
# To inspect one prompt without the surrounding JSON:
node --input-type=module -e 'import {readFile} from "node:fs/promises"; const r=JSON.parse(await readFile("prompts.json","utf8")); console.log(r.shots[0].prompt);'
The result contains fps, totalFrames, and a shots array. Each shot retains its id, startFrame, endFrame, and durationFrames beside its prompt. The first prompt reports 72 frames at 24 fps and approximately 3.000 seconds. The second reports 48 frames and approximately 2.000 seconds.
Review lamp-wide independently: does the camera stay static, does the hand enter from the intended side, and is the absence of dialogue explicit? Then review lamp-detail: does the subject description still identify the same lamp, and is the click distinct from the spoken line? These questions evaluate the notes you wrote. The compiler only checks structure.
After editing a field, run the compiler again and compare the corresponding prompt. The renderer follows a fixed field order and includes no timestamps or random values. For the same parsed input, it produces the same output, which makes ordinary text diffs useful during review.
5. Exercise failures before using the handoff
The accompanying local verification used Node.js 24.20.0 and passed 54 checks. They covered the synthetic example, stable rendering, whitespace trimming without input mutation, quote preservation, unique ids, unknown keys, empty and oversized fields, line separators, gaps, overlaps, unsafe frame numbers, and the supported frame-rate boundaries.
Separate command-line checks exercised successful JSON output, missing and extra arguments, a missing file, malformed JSON, and a document rejected by the schema. The observed exit codes were 0, 2, and 1 for the corresponding outcomes. All fixtures were invented metadata; no video was read and no model request was sent.
Try changing the second startFrame from 72 to 73. The compiler should reject the gap. Change it to 71 and it should reject the overlap. Restore 72, then duplicate lamp-wide as the second id: that should also fail. These changes test the handoff contract rather than the visual quality of a generated shot.
Do not use a passing result as evidence that the dialogue fits the available time or that a requested camera move is feasible. Play back or generate a real candidate in the relevant production workflow to evaluate those outcomes. This utility prepares and checks text before that work begins.
6. Hand off the source notes with the generated text
Keep shots.json and prompts.json together, along with the compiler version used to create the latter. The JSON source remains the editable record; the generated prompt is an output. When a reviewer requests a different line or framing choice, change that field and regenerate rather than editing only the output file.
If a target tool needs different wording or a separate audio field, add an explicit adapter after this neutral representation. Preserve the original fields so that removing an unsupported instruction from one output does not erase it from the creative record. No model capability, maximum duration, or audio behavior is assumed by this program.
The practical result is a small, inspectable handoff: two independently addressable shot descriptions with their timing and audio decisions intact. It is useful even when every observation was written manually, because the reviewable structure does not depend on automated extraction.
AI assistance disclosure: This tutorial and its example code were prepared with AI assistance and checked locally. The demonstration uses fictional shot metadata. No video analysis, prompt extraction, image or video generation, or visual-quality test was performed. There are no generated illustrations in this article.
Source: Adapted from PixMind’s Video-to-Prompt Complete Guide. This developer version implements a local structured-notes workflow and omits the source article’s unverified model comparisons, platform support, pricing, and extraction-quality claims.
Technical reference: Node.js documentation for the file-system promises API and command-line execution.
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/
Comments
Join the discussionNo comments yet. Be the first to comment.