Skip to main content

Hash Your Character References Before Generating the Next Shot

Updated 7 min readMarkdown

A shot log can say that every clip used front.png while the actual file changed halfway through the project. Someone crops the image, exports over it, and keeps the filename. The next generation then has a different input even though the production notes look unchanged.

A small reference lock makes that change visible. This tutorial builds a local Node.js tool that records each reference file's byte count and SHA-256 digest, then compares the files with that saved baseline before another shot is generated. It needs no packages, image service, or API key.

1. Decide what the lock represents

Use this for a small folder of approved character references: a front view, side view, outfit detail, or another deliberately selected input. Keep the prompt, model settings, and shot decisions in a separate production log. The lock answers one narrower question: are these the same file bytes we approved?

A digest is not a face comparison. Two visually identical PNG exports can have different bytes because metadata or encoding changed. Conversely, matching input bytes do not guarantee matching generated characters or repeatable model output. This tool helps investigate input changes; it does not measure visual continuity.

Choose stable reference IDs and treat their filenames as part of the project format. If a reference needs a new crop, give that revision a new filename and review a new baseline rather than silently overwriting the old one.

2. Describe the approved reference set

Create a refs folder and put your approved images there. Save the following as refs.json outside that folder. The IDs describe roles, while filenames identify the actual local files.

[
  { "id": "front", "file": "front-v1.png" },
  { "id": "side", "file": "side-v1.png" }
]

This example intentionally accepts only simple basenames. It rejects subdirectories and duplicate filenames without regard to letter case, so a project cannot accidentally list both Front.png and front.png as separate references. IDs must be lowercase and begin with a letter; hyphens, underscores, and digits can follow.

Use a trusted local folder with small files. The implementation reads each file into memory and follows normal filesystem behavior, including symbolic links. The filename rules are a project convention, not a security sandbox or an image validation step.

3. Build the lock and comparison functions

Save this complete module as reference-lock.mjs. The .mjs extension enables ES modules without adding a package.json. The create operation prints JSON; verify prints a compact result and sets an exit code.

import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';

function validate(refs, locked = false) {
  if (!Array.isArray(refs) || refs.length === 0) throw Error('No references');
  const ids = new Set(), files = new Set();
  for (const ref of refs) {
    if (!ref || typeof ref.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(ref.id)) throw Error('Invalid id');
    if (typeof ref.file !== 'string' ||
        !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(ref.file)) throw Error('Use a basename');
    const key = ref.file.toLowerCase();
    if (ids.has(ref.id) || files.has(key)) throw Error('Duplicate reference');
    ids.add(ref.id); files.add(key);
    if (locked && (!Number.isSafeInteger(ref.bytes) || ref.bytes < 1 ||
        typeof ref.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ref.sha256))) throw Error('Invalid lock entry');
  }
}

export async function makeLock(folder, refs) {
  validate(refs);
  const references = [];
  for (const { id, file } of refs) {
    const data = await readFile(join(folder, file));
    if (data.length === 0) throw Error('Empty reference: ' + file);
    references.push({ id, file, bytes: data.length,
      sha256: createHash('sha256').update(data).digest('hex') });
  }
  return { version: 1, references };
}

export async function verifyLock(folder, lock) {
  if (lock?.version !== 1) throw Error('Unsupported lock version');
  validate(lock.references, true);
  const actual = await makeLock(folder, lock.references);
  return actual.references.filter((ref, i) =>
    ref.bytes !== lock.references[i].bytes ||
    ref.sha256 !== lock.references[i].sha256
  ).map(ref => ref.id);
}

if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
  const [mode, folder, jsonFile, ...extra] = process.argv.slice(2);
  if (!['create', 'verify'].includes(mode) || !folder || !jsonFile || extra.length) {
    console.error('Usage: node reference-lock.mjs create|verify folder json-file');
    process.exitCode = 2;
  } else {
    try {
      const input = JSON.parse(await readFile(jsonFile, 'utf8'));
      if (mode === 'create') {
        console.log(JSON.stringify(await makeLock(folder, input), null, 2));
      } else {
        const changed = await verifyLock(folder, input);
        console.log(changed.length ? 'CHANGED: ' + changed.join(', ') : 'OK: references match');
        if (changed.length) process.exitCode = 1;
      }
    } catch (error) {
      console.error(error.message);
      process.exitCode = 1;
    }
  }
}

The digest is calculated from the raw Buffer returned by readFile. There is no conversion to text, image decoding, resizing, or normalization before hashing. Byte count is included as a readable diagnostic; the SHA-256 digest is what detects a same-length replacement.

Verification checks the saved format before reading the files. It then processes references in their recorded order and reports the IDs whose size or digest differs. Missing or unreadable files and invalid JSON go through the error path, rather than being reported as a clean match.

4. Save a baseline and check it before a run

With your own approved images in place, run these commands from the directory containing the module and JSON file. Use a new output filename for each approved baseline, and keep that file with the shot's production notes.

node reference-lock.mjs create ./refs refs.json > reference-lock-v1.json
node reference-lock.mjs verify ./refs reference-lock-v1.json
# Unchanged files: OK: references match
# Changed front reference: CHANGED: front

An unchanged comparison exits with 0. A changed reference, missing file, malformed JSON, or invalid lock exits with 1. Incorrect command arguments exit with 2. In a batch script, let the nonzero result stop the next generation step until you decide which input revision belongs to that shot.

Do not redirect create into an existing baseline during verification. That would replace the evidence you meant to compare against. If you intentionally revise a reference, save a separate lock version and record why it changed.

5. Test a change that filenames and sizes miss

For a minimal programmatic check, import the functions in another .mjs file. The example below uses synthetic text bytes named as a PNG solely to exercise hashing; it does not create or decode a valid image. Run it in a disposable empty working folder.

import { mkdir, writeFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
import { makeLock, verifyLock } from './reference-lock.mjs';

await mkdir('fixture-refs', { recursive: true });
await writeFile('fixture-refs/front.png', 'ABCD');
const refs = [{ id: 'front', file: 'front.png' }];
const baseline = await makeLock('fixture-refs', refs);
assert.deepEqual(await verifyLock('fixture-refs', baseline), []);

await writeFile('fixture-refs/front.png', 'WXYZ'); // Still four bytes.
assert.deepEqual(await verifyLock('fixture-refs', baseline), ['front']);
console.log('Same filename and size; changed bytes detected.');

The accompanying local checks ran on Node.js 24.20.0 and covered clean matches, same-size replacements, changed lengths, duplicate IDs and case-insensitive filenames, invalid fields, empty and missing files, and CLI exit codes 0, 1, and 2. These were synthetic byte and command-line checks, not AI image generation, image decoding, or video quality tests.

6. Connect a lock version to each shot

For a three-shot sequence, record the reference ID set and lock filename beside every shot's prompt and settings. Before generating shot two, verify the files against the baseline intended for shot two. If verification fails, inspect the file change and decide whether to restore the previous input or approve a new revision.

When a close-up needs a tighter reference crop, keep both versions and describe the reason in the shot log. That makes a later comparison meaningful: a changed face might coincide with an intentional input change, while an unexpected overwrite becomes a concrete issue to investigate.

Keep visual decisions separate from the byte comparison. After generating, compare the character, outfit, framing, and transitions in the actual clips. The lock contributes a dependable record of local inputs to that workflow; it cannot judge the result or prove which file a remote service actually received.

AI assistance: AI helped draft this tutorial and code. The local checks described above used synthetic fixtures. No generated image or video result is presented as tested evidence.

Source: Adapted for developers from PixMind's How to Keep Character Consistency Across AI Video Shots. This version focuses on recording reference inputs and omits unverified model rankings, thresholds, and performance claims.

Technical reference: Node.js crypto.createHash documentation.

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.

More from this blog