<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[PixMind Blog]]></title><description><![CDATA[Practical tutorials from the PixMind team on AI image and video creation, editing workflows, and creative APIs. Read the originals at https://www.pixmind.io/pos]]></description><link>https://pixmind.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 01:28:40 GMT</lastBuildDate><atom:link href="https://pixmind.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Validate an Ordered Product Gallery Manifest with Node.js]]></title><description><![CDATA[A storefront gallery often comes from a JSON file, even when the editorial plan started in a spreadsheet. If the frontend renders the list in order, one…]]></description><link>https://pixmind.hashnode.dev/validate-an-ordered-product-gallery-manifest-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/validate-an-ordered-product-gallery-manifest-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Wed, 04 Nov 2026 00:19:33 GMT</pubDate><content:encoded><![CDATA[<p>A storefront gallery often comes from a JSON file, even when the editorial plan started in a spreadsheet. If the frontend renders the list in order, one accidental reorder can replace the lead photo. A repeated file or question can also slip through a visual review. This small Node.js check turns those editorial decisions into errors a reviewer can act on.</p>
<h2>1. Write down the gallery contract</h2>
<p>For this project, array order is display order. The first entry must be the only hero. Each entry names a relative image file and one stable question identifier. A spec entry also names an evidence record so a reviewer has a place to look up the underlying measurements. These are project conventions. Adapt the roles and filename pattern to your own renderer.</p>
<h2>2. Create a fixture with three distinct questions</h2>
<p>Save the following as gallery.json. The fictional kettle gallery answers what the product is, how the lid is built, and whether it fits the intended space. The question identifiers stand for editorial decisions, so use the same identifier if two entries address the same question.</p>
<pre><code class="language-json">{
  "images": [
    {
      "role": "hero",
      "asset": "images/kettle-front.webp",
      "question": "identity"
    },
    {
      "role": "detail",
      "asset": "images/kettle-lid.webp",
      "question": "lid-design"
    },
    {
      "role": "spec",
      "asset": "images/kettle-fit.webp",
      "question": "counter-fit",
      "evidence": "spec-sheet-v3"
    }
  ]
}
</code></pre>
<h2>3. Validate the order and identifiers</h2>
<p>Save this as gallery.mjs. The validator collects every error it can locate in one pass. The asset pattern accepts a narrow set of local-style paths so two spellings of the same path cannot pass as distinct valid values. A Set tracks repeated assets and repeated question identifiers separately.</p>
<pre><code class="language-javascript">const roles = new Set(["hero", "detail", "scene", "spec"]);
const id = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const assetPath = /^images\/[a-z0-9]+(?:-[a-z0-9]+)*\.(?:webp|png|jpg)$/;

export function validateGallery(manifest) {
  const images = manifest?.images;
  if (!Array.isArray(images) || images.length === 0) {
    return ["images must be a nonempty array"];
  }

  const errors = [];
  const assets = new Set();
  const questions = new Set();
  for (const [index, row] of images.entries()) {
    const at = "images[" + index + "]";
    if (!row || typeof row !== "object" || Array.isArray(row)) {
      errors.push(at + " must be an object");
      continue;
    }
    if (!roles.has(row.role)) errors.push(at + ".role is unknown");
    if ((index === 0) !== (row.role === "hero")) {
      errors.push(at + " must follow the rule: hero first and only once");
    }
    if (typeof row.asset !== "string" || !assetPath.test(row.asset)) {
      errors.push(at + ".asset must be an images/ filename");
    } else {
      if (assets.has(row.asset)) errors.push(at + ".asset is repeated");
      assets.add(row.asset);
    }
    if (typeof row.question !== "string" || !id.test(row.question)) {
      errors.push(at + ".question must be a lowercase identifier");
    } else {
      if (questions.has(row.question)) errors.push(at + ".question is repeated");
      questions.add(row.question);
    }
    if (row.role === "spec" &amp;&amp;
        (typeof row.evidence !== "string" || !id.test(row.evidence))) {
      errors.push(at + ".evidence must name a spec record");
    }
  }
  return errors;
}
</code></pre>
<h2>4. Give your build a distinct failure code</h2>
<p>Save this as check-gallery.mjs. The command reads one UTF-8 JSON file. A valid manifest exits with 0. A parsed manifest that violates the contract exits with 1. A missing argument, unreadable file, or invalid JSON exits with 2. A CI job can fail on any nonzero status while preserving the distinction in its logs.</p>
<pre><code class="language-javascript">import { readFile } from "node:fs/promises";
import { validateGallery } from "./gallery.mjs";

const args = process.argv.slice(2);
if (args.length !== 1) {
  console.error("Usage: node check-gallery.mjs gallery.json");
  process.exitCode = 2;
} else {
  try {
    const manifest = JSON.parse(await readFile(args[0], "utf8"));
    const errors = validateGallery(manifest);
    for (const error of errors) console.error(error);
    if (errors.length) process.exitCode = 1;
    else console.log("Gallery manifest passed");
  } catch (error) {
    console.error(error.message);
    process.exitCode = 2;
  }
}
</code></pre>
<h2>5. Run a passing and failing example</h2>
<p>Run the checker from the directory where you saved the three files. The fixture prints Gallery manifest passed. To exercise the duplicate question check, copy it to broken-order.json and change the second entry’s question to identity. The second invocation reports images[1].question is repeated and exits with 1.</p>
<pre><code class="language-bash">node check-gallery.mjs gallery.json
node check-gallery.mjs broken-order.json
</code></pre>
<h2>6. Keep the remaining review checks separate</h2>
<p>A valid manifest tells you that the intended order and identifiers are internally consistent. Pair this check with an asset existence check if you need to catch missing image files. Review the rendered gallery for legibility and verify each spec against the evidence record. The checker reads metadata and does not open images or verify claims in the artwork.</p>
<p>Source: <a href="https://www.pixmind.io/posts/product-listing-image-order">PixMind’s product listing image order guide</a>. Reference: <a href="https://nodejs.org/docs/latest-v24.x/api/fs.html#fspromisesreadfilepath-options">Node.js file system promises documentation</a>.</p>
<p>AI disclosure: AI assisted the drafting. The manifest is fictional. The Node.js validator and command-line examples were tested with synthetic JSON.</p>
]]></content:encoded></item><item><title><![CDATA[Read Encoded GIF Frame Delays with Node.js]]></title><description><![CDATA[Two GIF exports can have the same frame count and different timing. An encoder can hold one image longer than another, so dividing frame count by an…]]></description><link>https://pixmind.hashnode.dev/read-encoded-gif-frame-delays-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/read-encoded-gif-frame-delays-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Tue, 03 Nov 2026 22:20:03 GMT</pubDate><content:encoded><![CDATA[<p>Two GIF exports can have the same frame count and different timing. An encoder can hold one image longer than another, so dividing frame count by an assumed frame rate can hide the difference. Read the stored delay for each image block before you decide what to adjust in the next export.</p>
<p>This walkthrough builds a small Node.js inspector. It reads the delays in a local GIF and reports an encoded duration for one pass when every image has an explicit positive delay and no user input flag. Browser playback can differ from the encoded timing.</p>
<h2>1. Locate the delay in the stream</h2>
<p>The GIF89a Graphic Control Extension contains a two-byte delay in hundredths of a second. Read it as an unsigned little-endian integer. A delay of 7 represents 70 milliseconds. The control applies to the next graphic rendering block, so a comment between that extension and the image should not consume it.</p>
<p>The header and logical screen descriptor come first. The packed bits tell you whether a global color table follows and how many entries to skip. An image descriptor can add its own local table. The image payload is stored in length-prefixed sub-blocks that end with a zero byte.</p>
<h2>2. Walk the blocks without decoding pixels</h2>
<p>Save this as gif-delays.mjs. The reader checks each slice before advancing. It supports image blocks, graphic controls, comments, and application extensions. It rejects plain-text rendering blocks and unknown extensions because they can fall outside this reader’s timing assumptions.</p>
<pre><code class="language-js">export function gifDelays(buffer) {
  let offset = 0;
  const take = count =&gt; {
    if (count &gt; buffer.length - offset) throw new Error('Truncated GIF');
    const start = offset;
    offset += count;
    return buffer.subarray(start, offset);
  };
  const byte = () =&gt; take(1)[0];
  const skipSubblocks = () =&gt; {
    let size = byte();
    while (size !== 0) {
      take(size);
      size = byte();
    }
  };
  const skipColorTable = packed =&gt; {
    if (packed &amp; 0x80) take(3 * 2 ** ((packed &amp; 7) + 1));
  };

  const header = take(6).toString('latin1');
  if (header !== 'GIF87a' &amp;&amp; header !== 'GIF89a') throw new Error('Invalid GIF header');
  skipColorTable(take(7)[4]);

  const frames = [];
  let pending = null;
  while (true) {
    const marker = byte();
    if (marker === 0x3b) {
      if (frames.length === 0 || pending || offset !== buffer.length) {
        throw new Error('Unexpected GIF trailer');
      }
      return frames;
    }
    if (marker === 0x2c) {
      skipColorTable(take(9)[8]);
      byte();
      skipSubblocks();
      frames.push(pending ?? { delayCs: null, userInput: false });
      pending = null;
      continue;
    }
    if (marker !== 0x21) throw new Error('Unsupported GIF block');
    const label = byte();
    if (label === 0xf9) {
      if (pending || byte() !== 4) throw new Error('Invalid Graphic Control Extension');
      const control = take(4);
      if (byte() !== 0) throw new Error('Invalid control terminator');
      pending = { delayCs: control.readUInt16LE(1), userInput: Boolean(control[0] &amp; 2) };
    } else if (label === 0xfe || label === 0xff) {
      skipSubblocks();
    } else {
      throw new Error('Unsupported GIF extension');
    }
  }
}
</code></pre>
<p>The function expects a Node Buffer containing one stream. It skips compressed image data without validating the LZW codes or compositing images. The goal is to inspect encoded metadata from a local export.</p>
<h2>3. Keep missing timing separate from zero</h2>
<p>Save this as inspect-gif.mjs. A missing Graphic Control Extension produces null for that image. A zero field is reported as zero. A user input flag can affect when the next rendering step occurs, so the command omits the aggregate in any of these cases. It also leaves application loop metadata out of the total.</p>
<pre><code class="language-js">import { readFileSync } from 'node:fs';
import { gifDelays } from './gif-delays.mjs';

const [path] = process.argv.slice(2);
if (process.argv.length !== 3) {
  console.error('Usage: node inspect-gif.mjs file.gif');
  process.exitCode = 2;
} else {
  try {
    const frames = gifDelays(readFileSync(path));
    const allTimed = frames.every(frame =&gt; frame.delayCs &gt; 0 &amp;&amp; !frame.userInput);
    const encodedPassMs = allTimed
      ? frames.reduce((sum, frame) =&gt; sum + frame.delayCs * 10, 0)
      : null;
    console.log(JSON.stringify({ frames, encodedPassMs }, null, 2));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}
</code></pre>
<h2>4. Generate a two-frame sample</h2>
<p>Save this as make-sample.mjs. It writes a tiny one-pixel GIF in the current directory. The first control contains the delay bytes 07 00. The second contains 2c 01, which is 300 in little-endian order. Run the script in an empty scratch directory to keep the sample separate from your exports.</p>
<pre><code class="language-js">import { writeFileSync } from 'node:fs';

const header = '47494638396101000100800000000000ffffff';
const image = '2c0000000001000100000202440100';
const delayA = '21f9040007000000';
const delayB = '21f904002c010000';
writeFileSync('sample.gif', Buffer.from(header + delayA + image + delayB + image + '3b', 'hex'));
</code></pre>
<p>Run node make-sample.mjs and then node inspect-gif.mjs sample.gif. The encoded pass is 70 plus 3000 milliseconds, giving 3070 milliseconds.</p>
<pre><code class="language-json">{
  "frames": [
    {
      "delayCs": 7,
      "userInput": false
    },
    {
      "delayCs": 300,
      "userInput": false
    }
  ],
  "encodedPassMs": 3070
}
</code></pre>
<h2>5. Use the output to locate the frame to review</h2>
<p>Run the inspector on two exports of the same clip. If a pause moved, compare the delay arrays and check the corresponding image near that point in the animation. Keep the export settings with the results so you can reproduce the change.</p>
<p>If a player appears to pause longer than the metadata suggests, check its playback in the intended destination. This command reports the stored fields and leaves viewer timing, frame disposal, and repeat behavior to a playback check.</p>
<h2>6. Verify the fixture and failure paths</h2>
<p>Node.js 24.20.0 passed 63 checks covering truncation, local and global palettes, comments, missing controls, little-endian delays, and CLI exit codes. A separate System.Drawing GIF reader confirmed the two image frames and both delay values in the generated sample. The check used synthetic GIF bytes. No source video was converted or browser playback timed.</p>
<p>This tutorial was prepared with AI assistance. The export workflow context comes from <a href="https://www.pixmind.io/posts/how-to-convert-video-to-gif">PixMind’s video-to-GIF guide</a>. The binary layout follows the <a href="https://www.w3.org/Graphics/GIF/spec-gif89a.txt">GIF89a specification</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Check a Bottom Crop Against Subtitle and Subject Bounds with Node.js]]></title><description><![CDATA[Before cropping burned-in subtitles off a video, check whether the same crop would remove something you need to keep. A subtitle may move between shots…]]></description><link>https://pixmind.hashnode.dev/check-a-bottom-crop-against-subtitle-and-subject-bounds-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/check-a-bottom-crop-against-subtitle-and-subject-bounds-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Tue, 03 Nov 2026 20:18:14 GMT</pubDate><content:encoded><![CDATA[<p>Before cropping burned-in subtitles off a video, check whether the same crop would remove something you need to keep. A subtitle may move between shots, and a face or product detail may sit lower in a different frame. A bottom crop is useful when the subtitle rows stay below all of the rows that matter.</p>
<p>This tutorial builds a small Node.js check for a fixed crop. It uses row bounds you collect in an editor or annotation tool. It prints a plan you can review before you change a clip.</p>
<h2>1. Measure rows in one coordinate system</h2>
<p>Use the displayed frame after any rotation or scaling that precedes the crop. Number rows from zero at the top. For each relevant subtitle instance, record its first occupied row, including any outline or shadow. For a subject that must survive, record the first row after its last occupied row. Using this exclusive bottom bound lets you keep rows from zero up to the cut without counting the boundary twice.</p>
<p>Measure every subtitle position you intend to cover, including multi-line captions and transitions. Check the lowest edges of the subjects you need to keep across the same range. A single crop can fail even when no individual frame contains both the highest subtitle and the lowest subject.</p>
<h2>2. Find the latest legal cut</h2>
<p>Save this as plan-crop.mjs. The smallest subtitle top gives the latest possible cut. Subtract a margin if you want to remove extra rows above the measured subtitle. The minimum retained height and the largest protected bottom give the earliest possible cut. A step can enforce a project-specific height increment. Round the latest cut down to that step so alignment keeps the subtitle rows excluded.</p>
<pre><code class="language-js">function integer(value, min, max, label) {
  if (!Number.isInteger(value) || value &lt; min || value &gt; max) {
    throw new RangeError(label + ' must be an integer from ' + min + ' to ' + max);
  }
  return value;
}

export function planCrop(input) {
  const { height, subtitleTops, protectedBottoms, minKeep, step = 1, margin = 0 } = input ?? {};
  integer(height, 1, 16384, 'height');
  integer(minKeep, 1, height, 'minKeep');
  integer(step, 1, height, 'step');
  integer(margin, 0, height, 'margin');
  if (!Array.isArray(subtitleTops) || subtitleTops.length === 0 ||
      !Array.isArray(protectedBottoms)) {
    throw new TypeError('Expected subtitleTops and protectedBottoms arrays');
  }

  let latestCut = height;
  for (const top of subtitleTops) {
    latestCut = Math.min(latestCut, integer(top, 0, height - 1, 'subtitle top'));
  }
  let requiredKeep = minKeep;
  for (const bottom of protectedBottoms) {
    requiredKeep = Math.max(requiredKeep, integer(bottom, 1, height, 'protected bottom'));
  }

  latestCut = Math.max(0, latestCut - margin);
  const keepHeight = Math.floor(latestCut / step) * step;
  if (keepHeight &lt; requiredKeep) {
    return { fits: false, candidateKeepHeight: keepHeight, requiredKeep };
  }
  return { fits: true, keepHeight, cropBottom: height - keepHeight };
}
</code></pre>
<p>The tool reports fits only if the aligned cut preserves the required rows. Width is unchanged. A step of 2 is an example constraint on the retained height. It does not check the rest of an encoder or delivery profile.</p>
<h2>3. Add a command that reports whether the plan fits</h2>
<p>Save this as bottom-crop.mjs beside the module. It reads a small local JSON file. Exit 0 means the plan fits the supplied bounds. Exit 1 means the bounds or height requirement prevent the crop. Exit 2 means the command or input could not be used.</p>
<pre><code class="language-js">import { readFileSync } from 'node:fs';
import { planCrop } from './plan-crop.mjs';

const args = process.argv.slice(2);
if (args.length !== 1) {
  console.error('Usage: node bottom-crop.mjs plan.json');
  process.exitCode = 2;
} else {
  try {
    const input = JSON.parse(readFileSync(args[0], 'utf8'));
    const result = planCrop(input);
    console.log(JSON.stringify(result, null, 2));
    process.exitCode = result.fits ? 0 : 1;
  } catch (error) {
    console.error(error.message);
    process.exitCode = 2;
  }
}
</code></pre>
<h2>4. Try a synthetic 1080-row example</h2>
<p>Save this as plan.json. The earliest subtitle top is row 939. The two-row margin puts the limit at 937. Rounding down to the next multiple of two keeps 936 rows and removes 144 from the bottom. The protected content ends before row 928, so it remains inside the retained frame.</p>
<pre><code class="language-json">{
  "height": 1080,
  "subtitleTops": [
    939,
    957
  ],
  "protectedBottoms": [
    890,
    928
  ],
  "minKeep": 900,
  "step": 2,
  "margin": 2
}
</code></pre>
<p>Run node bottom-crop.mjs plan.json. Expect this result.</p>
<pre><code class="language-json">{
  "fits": true,
  "keepHeight": 936,
  "cropBottom": 144
}
</code></pre>
<p>If a protected bottom changes to 937, the 936-row candidate no longer fits. Check the measurements and project constraints. When they are correct, plan a different treatment for that shot, such as a cover placed over the subtitle area or a replacement source without the burned-in text.</p>
<h2>5. Apply the result to a duplicate edit</h2>
<p>If your editor asks for a bottom crop percentage, divide cropBottom by the input height and multiply by 100. Here 144 divided by 1080 is about 13.333 percent. Work on a duplicate edit and inspect whether rounding in the editor leaves any visible outline. Also check how the project scales or pads the cropped clip inside the delivery frame.</p>
<p>Check the first and last subtitle frames, the highest caption position, and the lowest protected subject again in the result. Keep the original source available and save the measured rows alongside the edit so another editor can reproduce the decision.</p>
<h2>6. Check the boundary cases before using the plan</h2>
<p>Node.js 24.20.0 passed 6,064 cases. A brute-force reference tried every eligible retained height on small synthetic frames. Extra checks covered protected rows, malformed input, and CLI exit codes. The checks used numeric bounds. No video was decoded, edited, or exported.</p>
<p>This tutorial was prepared with AI assistance. Its starting context is PixMind’s <a href="https://www.pixmind.io/posts/remove-hardcoded-subtitles">guide to handling burned-in subtitles</a>. For editor controls that express crop as percentages, see <a href="https://helpx.adobe.com/premiere/desktop/edit-projects/intro-to-editing/edit-video-using-the-properties-panel.html">Adobe’s Premiere Properties panel documentation</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Balance First Views Across Three Video Variants with Node.js]]></title><description><![CDATA[If reviewers watch three openings in succession, the first clip can teach them what to look for in the others. For a small comprehension review, give each…]]></description><link>https://pixmind.hashnode.dev/balance-first-views-across-three-video-variants-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/balance-first-views-across-three-video-variants-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Tue, 03 Nov 2026 17:18:49 GMT</pubDate><content:encoded><![CDATA[<p>If reviewers watch three openings in succession, the first clip can teach them what to look for in the others. For a small comprehension review, give each reviewer one first viewing and record which variant they saw. Assigning in shuffled groups of three keeps the planned counts close even when recruitment stops partway through a group.</p>
<p>This tutorial builds the assignment list in Node.js. The output is a set of numbered slots for fictional variants A, B, and C. It can be handed to a moderator before the review begins.</p>
<h2>1. Decide what the assignment controls</h2>
<p>Suppose three videos share the same explanation and ending. Each has a different opening. Define one observation before recruitment starts, such as whether someone can describe the product benefit after a single muted viewing. Use the same question, player, and playback conditions for all three versions.</p>
<p>A slot is an assignment, separate from a response. Assign the next unused slot to an eligible reviewer before playback. Record the slot number, the version shown, and whether the review was completed. Keep missing responses visible in your records so you can compare planned assignments with completed reviews later.</p>
<h2>2. Shuffle within groups of three</h2>
<p>Save the following as plan.mjs. Each group starts with one copy of A, B, and C. A Fisher–Yates shuffle draws an index from zero through the current index, then swaps those two positions. The default draw uses Node’s randomInt with an exclusive upper bound. The optional draw lets a test exercise a specific sequence of swaps.</p>
<pre><code class="language-js">import { randomInt } from 'node:crypto';

export function makePlan(count, draw = randomInt) {
  if (!Number.isInteger(count) || count &lt; 0 || count &gt; 10000) {
    throw new RangeError('Count must be an integer from 0 to 10000');
  }
  const plan = [];
  for (let offset = 0; offset &lt; count; offset += 3) {
    const block = ['A', 'B', 'C'];
    for (let i = block.length - 1; i &gt; 0; i--) {
      const j = draw(i + 1);
      if (!Number.isInteger(j) || j &lt; 0 || j &gt; i) {
        throw new RangeError('Random draw is outside the requested range');
      }
      [block[i], block[j]] = [block[j], block[i]];
    }
    for (const variant of block.slice(0, count - offset)) {
      plan.push({ slot: plan.length + 1, variant });
    }
  }
  return plan;
}
</code></pre>
<p>Every full group contributes one slot per version. A partial group contributes at most one extra slot for any version, so the difference between the largest and smallest planned totals stays at most one at every stopping point. For eight slots, the totals are 3, 3, and 2 in some order. The shuffled last group determines which version receives two.</p>
<h2>3. Print the plan before recruitment</h2>
<p>Save this as first-views.mjs next to the module. Run node first-views.mjs 8 to print eight assignments as JSON. Redirect the output to a local file after checking that the process completed successfully. Exit code 2 indicates invalid command syntax. An integer outside the accepted range returns 1.</p>
<pre><code class="language-js">import { makePlan } from './plan.mjs';

const args = process.argv.slice(2);
if (args.length !== 1 || !/^(0|[1-9][0-9]*)$/.test(args[0])) {
  console.error('Usage: node first-views.mjs &lt;count&gt;');
  process.exitCode = 2;
} else {
  try {
    console.log(JSON.stringify(makePlan(Number(args[0])), null, 2));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}
</code></pre>
<p>Generate the list once and save it with the review materials. A later run uses fresh random draws and can produce a different list. If multiple moderators share the plan, use one record that prevents two moderators from assigning the same slot. Keep upcoming assignments out of recruiting decisions, especially since the final slot in each group can be inferred from the first two.</p>
<h2>4. Reproduce the eight-slot example</h2>
<p>Save this as demo.mjs. The fixed draw always selects the index being swapped, so it leaves the three labels in their starting order. This is an example for checking the expected output. The normal CLI uses the random draw.</p>
<pre><code class="language-js">import assert from 'node:assert/strict';
import { makePlan } from './plan.mjs';

const fixedDraw = max =&gt; max - 1;
const plan = makePlan(8, fixedDraw);
assert.deepEqual(plan.map(row =&gt; row.variant), ['A','B','C','A','B','C','A','B']);
assert.deepEqual(plan.map(row =&gt; row.slot), [1,2,3,4,5,6,7,8]);
console.log(plan.map(row =&gt; row.variant).join(' '));
</code></pre>
<p>Running node demo.mjs prints the following sequence.</p>
<pre><code class="language-text">A B C A B C A B
</code></pre>
<h2>5. Check what happens when people drop out</h2>
<p>Suppose B has three assigned slots and one reviewer stops before answering. Keep all three B assignments in the assignment record and mark the one missing response. Compare the number assigned and the number completed for each version. If you continue recruiting, follow the saved plan instead of filling gaps based on how people responded.</p>
<p>Equal planned totals can coexist with differences in completed reviews or reviewer characteristics. For this small workflow, use responses to identify confusing wording or visuals. A larger study needs a sample plan and an analysis suited to its question.</p>
<h2>6. Verify the code with synthetic slots</h2>
<p>Node.js 24.20.0 passed 142 cases. The checks enumerated all six possible groups, verified balance at each prefix for sizes from zero through 120, and exercised invalid counts, invalid draws, and CLI exit codes. These checks used synthetic assignments. No video was generated, shown to viewers, or evaluated for performance.</p>
<p>This tutorial was prepared with AI assistance. Its starting context is PixMind’s <a href="https://www.pixmind.io/posts/social-video-hooks-with-wan-2-7-first-last-frame">guide to opening and closing frames for short videos</a>. See the <a href="https://nodejs.org/docs/latest-v24.x/api/crypto.html#cryptorandomintmin-max-callback">Node.js randomInt documentation</a> for the random draw used in the shuffle.</p>
]]></content:encoded></item><item><title><![CDATA[Wrap Plain Narration in SSML Safely with Node.js]]></title><description><![CDATA[A narration script can contain an ampersand, a model name in angle brackets, or text that happens to look like a voice tag. If you interpolate that script…]]></description><link>https://pixmind.hashnode.dev/wrap-plain-narration-in-ssml-safely-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/wrap-plain-narration-in-ssml-safely-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Tue, 03 Nov 2026 09:07:58 GMT</pubDate><content:encoded><![CDATA[<p>A narration script can contain an ampersand, a model name in angle brackets, or text that happens to look like a voice tag. If you interpolate that script directly into XML, the parser may reject it or treat part of the narration as markup. The handoff should keep the editor’s text separate from markup that your application controls.</p>
<p>This walkthrough builds a small SSML 1.1 document from plain narration in Node.js. It produces XML you can inspect before connecting a speech service. The example stops at the text handoff.</p>
<h2>1. Give the input two fields</h2>
<p>The input is a JSON object with language and text. This example accepts en-US, fr-FR, and ja-JP as a local configuration choice. That list only controls this utility. Check the voice and markup options offered by the speech engine you eventually connect.</p>
<p>Keep a plain-text field in your data model. If a separate workflow accepts author-written SSML, give it its own field and validator. Running an already-escaped string through the plain-text path deliberately escapes its ampersands again so that the original spelling survives XML parsing.</p>
<h2>2. Validate characters before escaping markup</h2>
<p>Save this module as build-ssml.mjs. It checks the language against fixed values and rejects a blank script. The for-of loop traverses Unicode code points, so a valid supplementary character can pass while an isolated surrogate or a disallowed XML 1.0 character fails. Carriage returns become line feeds to match the newline convention used in the output.</p>
<pre><code class="language-js">const languages = new Set(['en-US', 'fr-FR', 'ja-JP']);

export function buildSsml(input) {
  const { language, text } = input ?? {};
  if (!languages.has(language)) {
    throw new RangeError('Choose en-US, fr-FR, or ja-JP');
  }
  if (typeof text !== 'string' || text.trim().length === 0) {
    throw new TypeError('Expected nonblank narration text');
  }

  const narration = text.replace(/\r\n?/g, '\n');
  for (const character of narration) {
    const cp = character.codePointAt(0);
    const legal = cp === 0x9 || cp === 0xa ||
      (cp &gt;= 0x20 &amp;&amp; cp &lt;= 0xd7ff) ||
      (cp &gt;= 0xe000 &amp;&amp; cp &lt;= 0xfffd) ||
      (cp &gt;= 0x10000 &amp;&amp; cp &lt;= 0x10ffff);
    if (!legal) {
      throw new RangeError('Illegal XML 1.0 character U+' + cp.toString(16).toUpperCase());
    }
  }

  const escaped = narration.replaceAll('&amp;', '&amp;amp;')
    .replaceAll('&lt;', '&amp;lt;')
    .replaceAll('&gt;', '&amp;gt;');

  return '&lt;?xml version="1.0" encoding="UTF-8"?&gt;\n' +
    '&lt;speak version="1.1" xmlns="http://www.w3.org/2001/10/synthesis" ' +
    'xml:lang="' + language + '"&gt;' + escaped + '&lt;/speak&gt;\n';
}
</code></pre>
<p>Escape ampersands first so the replacements for angle brackets are applied only once. Escaping the greater-than sign also keeps the sequence of two closing brackets followed by a greater-than sign from appearing literally inside XML character data. Quotes can remain in element text. The only dynamic attribute value comes from the fixed language set.</p>
<p>The return value adds the SSML namespace, a version, and the language on the speak element. The body contains text only. Keep engine-specific tags in a separate layer that takes explicit structured options.</p>
<h2>3. Add a file-to-stdout CLI</h2>
<p>Save this second file as narration-ssml.mjs beside the module. It decodes UTF-8 strictly, parses JSON, and writes the generated document to stdout. A failed read or invalid input sets exit code 1 and writes the diagnostic to stderr. Calling it with the wrong number of arguments returns 2.</p>
<pre><code class="language-js">import { readFileSync } from 'node:fs';
import { buildSsml } from './build-ssml.mjs';

const args = process.argv.slice(2);
if (args.length !== 1) {
  console.error('Usage: node narration-ssml.mjs input.json');
  process.exitCode = 2;
} else {
  try {
    const bytes = readFileSync(args[0]);
    const json = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
    process.stdout.write(buildSsml(JSON.parse(json)));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}
</code></pre>
<p>Run the CLI on local narration files. It reads the entire file into memory, so add a byte limit at your application boundary if you accept uploads or large jobs. Before redirecting stdout into a release artifact, check the exit status or use a temporary output path and rename it after success.</p>
<h2>4. Check a small narration fixture</h2>
<p>Save this sample as narration.json. The ampersand, brackets, accents, and emoji give you several ways to verify that a parser recovers the original text. An engine may pronounce or omit symbols according to its own behavior.</p>
<pre><code class="language-json">{
  "language": "en-US",
  "text": "R&amp;D says: model &lt;A&gt; is ready. Café demo 🚲."
}
</code></pre>
<p>Run node narration-ssml.mjs narration.json. The output should be the document below. The visible entity references are part of XML serialization. An XML parser resolves them back to the script’s original characters.</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;speak version="1.1" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="en-US"&gt;R&amp;amp;D says: model &amp;lt;A&amp;gt; is ready. Café demo 🚲.&lt;/speak&gt;
</code></pre>
<h2>5. Check the parsed document before adding a voice</h2>
<p>Open the result with an XML parser. Check the root name and namespace, the xml:lang value, and the text returned by the parser. A string such as a literal opening voice tag should return as text and produce no child elements. Test a string that already contains an entity spelling, plus a line ending and a supplementary Unicode character.</p>
<p>This example was run on Node.js 24.20.0. There were 31 build and CLI cases, including invalid Unicode, invalid UTF-8, incorrect input, and exit codes 0, 1, and 2. A separate .NET XML parser checked 12 text round trips, including language and namespace attributes and the absence of child markup.</p>
<h2>6. Keep delivery checks separate from serialization</h2>
<p>Once the document parses as intended, check which SSML elements and versions your chosen provider accepts. If the provider expects plain text, hand it the original script through its text field. For an SSML integration, test a short sample with the chosen voice and listen for names, abbreviations, and numbers before saving an audio deliverable.</p>
<p>This walkthrough used synthetic text and XML checks. It did not call a speech service, generate audio, or test pronunciation or MP3 output.</p>
<p>This tutorial was prepared with AI assistance. Its source is PixMind’s <a href="https://www.pixmind.io/posts/how-to-convert-text-to-speech-mp3">text-to-speech preparation guide</a>. The markup and character rules are described in the <a href="https://www.w3.org/TR/speech-synthesis11/">W3C SSML 1.1 recommendation</a> and the <a href="https://www.w3.org/TR/xml/#charsets">XML 1.0 recommendation</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Remove Isolated Speckles from a Binary Logo Mask with Node.js]]></title><description><![CDATA[A logo trace can turn two stray pixels into two extra paths. If your pipeline already produces a foreground mask, you can inspect those islands before…]]></description><link>https://pixmind.hashnode.dev/remove-isolated-speckles-from-a-binary-logo-mask-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/remove-isolated-speckles-from-a-binary-logo-mask-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Tue, 03 Nov 2026 07:00:43 GMT</pubDate><content:encoded><![CDATA[<p>A logo trace can turn two stray pixels into two extra paths. If your pipeline already produces a foreground mask, you can inspect those islands before handing the image to a tracer. This walkthrough uses a text fixture so you can reproduce the decision without installing an image library or uploading a logo.</p>
<h2>1. Decide what counts as a separate mark</h2>
<p>In this fixture, # is foreground and . is background. Two foreground cells belong to the same component when they share an edge. Cells touching only at a corner remain separate. That is four-neighbor connectivity. If your lettering relies on diagonal joins, test that choice before applying the filter.</p>
<p>Area means the number of foreground cells in a component. With a threshold of 3, components with 1 or 2 cells are removed and components with 3 or more stay. A threshold of 1 keeps every foreground cell.</p>
<h2>2. Walk each component once</h2>
<p>Save the function below as clean-mask.mjs. It validates a small rectangular mask, tracks visited cells, and copies the input so the original remains available for comparison. The component array doubles as a work queue. Marking a cell when it enters the queue prevents two neighbors from adding it twice.</p>
<pre><code class="language-js">export function cleanMask(rows, minArea) {
  const width = rows?.[0]?.length;
  if (!Array.isArray(rows) || rows.length &gt; 1024 ||
      !Number.isInteger(width) || width &lt; 1 || width &gt; 1024 ||
      !Array.from(rows).every(row =&gt; typeof row === 'string' &amp;&amp;
        row.length === width &amp;&amp; !/[^.#]/.test(row))) {
    throw new TypeError('Expected a rectangular mask of . and #, up to 1024 by 1024');
  }
  if (!Number.isSafeInteger(minArea) || minArea &lt; 1) {
    throw new RangeError('Expected a positive integer area threshold');
  }

  const output = rows.map(row =&gt; [...row]);
  const seen = new Uint8Array(width * rows.length);
  const removed = [];
  for (let y = 0; y &lt; rows.length; y++) {
    for (let x = 0; x &lt; width; x++) {
      const start = y * width + x;
      if (rows[y][x] !== '#' || seen[start]) continue;
      const component = [start];
      seen[start] = 1;
      for (let i = 0; i &lt; component.length; i++) {
        const index = component[i];
        const col = index % width;
        const row = Math.floor(index / width);
        for (const [nx, ny] of [[col-1,row], [col+1,row], [col,row-1], [col,row+1]]) {
          if (nx &lt; 0 || nx &gt;= width || ny &lt; 0 || ny &gt;= rows.length) continue;
          const neighbor = ny * width + nx;
          if (seen[neighbor] || rows[ny][nx] !== '#') continue;
          seen[neighbor] = 1;
          component.push(neighbor);
        }
      }
      if (component.length &gt;= minArea) continue;
      removed.push({ x, y, area: component.length });
      for (const index of component) {
        output[Math.floor(index / width)][index % width] = '.';
      }
    }
  }
  return { rows: output.map(row =&gt; row.join('')), removed };
}
</code></pre>
<p>The report uses zero-based coordinates from the top left. The x and y for a removed component identify its first cell in row-major scan order. They describe where to start inspecting, rather than the center of the mark or its bounding box.</p>
<p>The scan and the total neighbor work are linear in the number of cells. The copies, visited map, and largest component queue also use at most linear space. The size limit applies to the parsed mask. This small local CLI reads the file into memory first, so give it fixtures from your own workflow.</p>
<h2>3. Add a CLI that leaves the original alone</h2>
<p>Save this as clean-logo.mjs beside the function. It reads a UTF-8 text file, accepts LF and CRLF, and removes at most one final line ending. Blank rows inside the mask are invalid. The result goes to standard output. Usage errors return 2 and read or validation errors return 1.</p>
<pre><code class="language-js">import { readFileSync } from 'node:fs';
import { cleanMask } from './clean-mask.mjs';

const args = process.argv.slice(2);
if (args.length !== 2 || !/^[1-9][0-9]*$/.test(args[1])) {
  console.error('Usage: node clean-logo.mjs &lt;mask.txt&gt; &lt;min-area&gt;');
  process.exitCode = 2;
} else {
  try {
    const text = readFileSync(args[0], 'utf8');
    const rows = text.replace(/\r?\n$/, '').split(/\r?\n/);
    console.log(JSON.stringify(cleanMask(rows, Number(args[1])), null, 2));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}
</code></pre>
<h2>4. Run a fixture with three components</h2>
<p>Save the following as mask.txt. The rectangle is 6 cells, the dot is 1, and the horizontal stroke is 2. Run node clean-logo.mjs mask.txt 3. Keep the input file alongside the JSON so reviewers can reproduce the comparison.</p>
<pre><code class="language-text">.........
.###...#.
.###.....
.....##..
.........
</code></pre>
<p>Expect this output. At threshold 2, only the dot is removed. That smaller setting is worth checking when a mark is small enough that two cells could carry real meaning.</p>
<pre><code class="language-json">{
  "rows": [
    ".........",
    ".###.....",
    ".###.....",
    ".........",
    "........."
  ],
  "removed": [
    {
      "x": 7,
      "y": 1,
      "area": 1
    },
    {
      "x": 5,
      "y": 3,
      "area": 2
    }
  ]
}
</code></pre>
<h2>5. Review punctuation and diagonals before tracing</h2>
<p>Compare each removed location with the original logo. A detached letter dot, trademark mark, or decorative accent can be small and intentional. Lower the threshold or keep the original mask for an asset if the removed list contains one of those details.</p>
<p>This operation removes entire disconnected components. A speck attached to a larger stroke by one edge stays attached, and a hole is background so it remains a hole. A change to threshold does not repair jagged edges, recover missing detail, or smooth a trace.</p>
<p>When the same logo is supplied at different scales, evaluate each mask size. A fixed area threshold can erase a mark in a smaller bitmap while leaving it in a larger one. Record the size, threshold, and connectivity so a reviewer can repeat the decision.</p>
<h2>6. Record what was tested</h2>
<p>The example was run on Node.js 24.20.0. The function was also checked against an independent edge-merging reference for all 512 binary masks of size 3 by 3 at four thresholds. Additional checks covered invalid shapes and thresholds and CLI exit codes. These checks used synthetic text fixtures. Image decoding, raster-to-mask conversion, SVG tracing, and visual quality were outside the test.</p>
<p>The next pipeline step can convert the cleaned mask into the raster format your tracer accepts. Keep the original asset, the mask, and the report together so a reviewer can undo a threshold choice without asking for a fresh export.</p>
<p>This tutorial was prepared with AI assistance. Its source is PixMind’s <a href="https://www.pixmind.io/posts/clean-up-logo-before-vectorizing">guide to cleaning up logo artwork before vectorization</a>. See the <a href="https://nodejs.org/docs/latest-v24.x/api/fs.html#fsreadfilesyncpath-options">Node.js file system documentation</a> for the CLI’s file read.</p>
]]></content:encoded></item><item><title><![CDATA[Compare Two PNG Endpoints of a GIF Candidate with Sharp]]></title><description><![CDATA[When a short animation jumps back to its first frame, a moved pointer or changed panel can make the restart conspicuous. A small endpoint comparison can…]]></description><link>https://pixmind.hashnode.dev/compare-two-png-endpoints-of-a-gif-candidate-with-sharp</link><guid isPermaLink="true">https://pixmind.hashnode.dev/compare-two-png-endpoints-of-a-gif-candidate-with-sharp</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Mon, 02 Nov 2026 20:01:43 GMT</pubDate><content:encoded><![CDATA[<p>When a short animation jumps back to its first frame, a moved pointer or changed panel can make the restart conspicuous. A small endpoint comparison can give an editor concrete coordinates to inspect. It should remain a numeric diagnostic: a low pixel difference is not a promise of a natural loop.</p>
<p>This tutorial compares two PNG frames that you have already exported from a candidate clip. It uses Node.js and Sharp to produce a report of channel differences on a fixed white background. It does not read a GIF or video, extract frames, generate media, or decide which creative ending is best.</p>
<h2>1. Define the comparison and prepare the inputs</h2>
<p>Export the candidate’s first and final displayed frames as separate single-page PNG files. Use the same dimensions and framing for both. Keep their source timestamps or frame numbers beside them so that a reported difference can be traced back to the edit. The sample program will reject different dimensions.</p>
<p>The comparison converts both inputs to 8-bit sRGB RGBA bytes. It composites each sample over white using its alpha value, rounds each resulting channel to an integer, and measures absolute channel differences. This is an explicit numerical convention in encoded sRGB, not a perceptual or linear-light color measurement.</p>
<p>A pixel is counted as changed when its largest channel difference is greater than 32. Exactly 32 is within the threshold. The report also retains the mean difference across all three channels of every pixel and the maximum single-channel difference. The threshold is a project choice in this example, not a visual-quality standard.</p>
<h2>2. Keep the byte comparison separate from decoding</h2>
<p>Save the following as compare.mjs. It accepts equally sized RGBA arrays, positive integer dimensions, and at most four million pixels. The first five changed coordinates are examples rather than a complete defect map.</p>
<pre><code class="language-javascript">export function compareFrames(first, last, width, height, threshold = 32) {
  const pixels = width * height;
  if (![first, last].every(x =&gt; x instanceof Uint8Array) ||
      !Number.isInteger(width) || !Number.isInteger(height) || width &lt; 1 || height &lt; 1 ||
      pixels &gt; 4_000_000 || first.length !== pixels * 4 || last.length !== pixels * 4) {
    throw new Error("Expected equal, tightly packed RGBA frames of 1 to 4000000 pixels");
  }
  if (!Number.isInteger(threshold) || threshold &lt; 0 || threshold &gt; 255) {
    throw new Error("threshold must be an integer from 0 to 255");
  }
  let sum = 0, maximum = 0, changedPixels = 0;
  const examples = [];
  for (let pixel = 0; pixel &lt; pixels; pixel++) {
    const offset = pixel * 4;
    const a = first[offset + 3] / 255;
    const b = last[offset + 3] / 255;
    let localMax = 0;
    for (let c = 0; c &lt; 3; c++) {
      const left = Math.round(first[offset + c] * a + 255 * (1 - a));
      const right = Math.round(last[offset + c] * b + 255 * (1 - b));
      const delta = Math.abs(left - right);
      sum += delta;
      localMax = Math.max(localMax, delta);
    }
    maximum = Math.max(maximum, localMax);
    if (localMax &gt; threshold) {
      changedPixels++;
      if (examples.length &lt; 5) examples.push({ x: pixel % width, y: Math.floor(pixel / width), delta: localMax });
    }
  }
  return {
    width, height, background: "white", threshold, pixels, changedPixels,
    changedFraction: changedPixels / pixels,
    meanChannelDelta: sum / (pixels * 3), maxChannelDelta: maximum,
    withinThreshold: changedPixels === 0, examples
  };
}
</code></pre>
<p>Compositing makes the treatment of transparency reproducible. Two fully transparent pixels with different hidden RGB values compare as white under this convention. Partially transparent pixels contribute according to their opacity. If your animation will appear on a dark background, this white-background report may not represent that presentation.</p>
<p>The function does not change either input. Coordinates use x from left to right and y from top to bottom. Pixel (2, 1) is the third column in the second row, with counting beginning at zero. These coordinates help you find a pointer or object edge that moved between endpoints.</p>
<p>The maximum and mean describe different things. One strongly changed pixel may produce a high maximum but a low mean in a large image. Keep the changed count and fraction alongside those values instead of choosing one score as the entire conclusion.</p>
<h2>3. Read the PNG pair and expose clear command outcomes</h2>
<p>Save this wrapper as compare-png.mjs. It checks the detected input format, requests sRGB RGBA raw output, and compares the decoded dimensions. Pixel coordinates refer to the decoded raster; orientation metadata is not applied. Export consistently oriented frames before running the utility.</p>
<pre><code class="language-javascript">import sharp from "sharp";
import { compareFrames } from "./compare.mjs";
async function readFrame(path) {
  const image = sharp(path, { limitInputPixels: 4_000_000 });
  const meta = await image.metadata();
  if (meta.format !== "png" || (meta.pages ?? 1) !== 1) throw new Error("Expected a single-page PNG");
  const { data, info } = await image.toColourspace("srgb").ensureAlpha()
    .raw({ depth: "uchar" }).toBuffer({ resolveWithObject: true });
  if (info.channels !== 4) throw new Error("Expected RGBA output");
  return { data, width: info.width, height: info.height };
}
const args = process.argv.slice(2);
if (args.length !== 2) {
  console.error("Usage: node compare-png.mjs first.png last.png");
  process.exitCode = 2;
} else {
  try {
    const [first, last] = await Promise.all(args.map(readFrame));
    if (first.width !== last.width || first.height !== last.height) throw new Error("Frame dimensions differ");
    const report = compareFrames(first.data, last.data, first.width, first.height);
    console.log(JSON.stringify(report, null, 2));
    process.exitCode = report.withinThreshold ? 0 : 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 2;
  }
}
</code></pre>
<p>Code 0 means every pixel stayed within the selected threshold; it does not mean the frames are identical. Code 1 means the comparison completed and found at least one pixel beyond the threshold. Code 2 means usage or processing failed. Both completed comparisons emit JSON, while errors go to standard error.</p>
<p>The decoded pixel limit is not an encoded-file byte limit. This example is a local utility for a pair of known frame exports. If you put it behind a service, decide how that service accepts and limits uploaded files separately.</p>
<h2>4. Exercise an endpoint change that has a known answer</h2>
<p>Save the next file as demo.mjs. It creates a white 4-by-4 frame and a second frame with one opaque black pixel at (2, 1). The code writes actual PNG files and verifies the expected report from the source arrays. The command-line run below then decodes those files for the same comparison.</p>
<pre><code class="language-javascript">import sharp from "sharp";
import { compareFrames } from "./compare.mjs";
const first = Buffer.alloc(4 * 4 * 4, 255);
const last = Buffer.from(first);
last.set([0, 0, 0, 255], (1 * 4 + 2) * 4);
for (const [name, data] of [["first", first], ["last", last]]) {
  await sharp(data, { raw: { width: 4, height: 4, channels: 4 } }).png().toFile(name + ".png");
}
const report = compareFrames(first, last, 4, 4);
if (report.changedPixels !== 1 || report.changedFraction !== 1 / 16 ||
    report.meanChannelDelta !== 15.9375 || report.maxChannelDelta !== 255) {
  throw new Error("Unexpected fixture result");
}
console.log(report);
</code></pre>
<p>There are 16 pixels. One is changed, so changedFraction is 1/16, or 0.0625. Each of its three channel differences is 255. The mean over all 48 channels is therefore 765 divided by 48, or 15.9375. The maximum is 255 and the report does not pass the threshold.</p>
<p>Keep the three modules in one directory, install Sharp, and run these commands with Node.js. Local verification used Node.js 24.20.0 and Sharp 0.34.5. Follow your package manager’s dependency-build policy when installing the package.</p>
<pre><code class="language-sh">pnpm add sharp@0.34.5 --save-exact
node demo.mjs
node compare-png.mjs first.png last.png
</code></pre>
<p>The final command is expected to exit with code 1 because the fixture deliberately contains a change. To exercise a threshold-passing report, compare first.png with itself. If you see code 2, inspect the error message before interpreting any image result.</p>
<h2>5. Test the boundaries before relying on a report</h2>
<p>Local verification passed 43 checks. They covered identical arrays, a one-pixel change, strict threshold boundaries, fully and partially transparent pixels, capped example coordinates, narrow images, invalid dimensions and lengths, invalid thresholds, and preservation of the input bytes.</p>
<p>The integration checks wrote and decoded synthetic PNG pairs and exercised CLI codes 0, 1, and 2. They included different dimensions, missing files, a corrupt PNG, a non-PNG file, and missing or extra arguments. The independent demo also passed. No real clip, GIF decoder, motion analysis, or human seam-quality evaluation was used.</p>
<p>For real exported endpoints, inspect the coordinates and then watch the full animation across its restart. A pointer jump may be distracting even when it occupies only a small fraction of the image. Conversely, a deliberate reset from a result screen to a starting screen can create a large numeric difference while serving the instructional purpose.</p>
<p>Matching endpoints cannot show that the motion between them is plausible. A loop can end where it began and still contain an awkward pause or confusing action. Use this report to focus a review, and use playback to evaluate timing and meaning.</p>
<h2>6. Keep the report attached to the edit decision</h2>
<p>Store the two endpoint files, their source positions, and the report together. Give the candidate a version name. If you change the trim, export new endpoints and rerun the comparison instead of keeping a report for the old pair.</p>
<p>When a review identifies a problem, name the affected feature and location, such as “the pointer jumps from the button to the corner at restart.” Adjust the source selection or the planned return action, then compare a new candidate. The utility itself does not modify the animation.</p>
<p>A useful outcome is a repeatable record of what changed numerically and where to look. It complements the editorial decision about whether the candidate should loop continuously or intentionally reset. It does not replace that decision with an arbitrary pass/fail quality label.</p>
<p>AI assistance disclosure: This tutorial and its code were prepared with AI assistance and checked locally. Tests used synthetic RGBA arrays and actual synthetic PNG pairs. No source video was downloaded, no GIF or video was decoded, no model was called, and no visual loop-quality test was performed. There are no illustrative images in this article.</p>
<p>Source: Adapted from <a href="https://www.pixmind.io/posts/make-gif-from-youtube-tiktok-video">PixMind’s “How to Make a GIF from a YouTube or TikTok Video.”</a> This developer version implements a local endpoint diagnostic and omits unverified downloader support, watermark advice, platform export rules, legal assurances, and pricing claims.</p>
<p>Technical references: Sharp documentation for <a href="https://sharp.pixelplumbing.com/api-output/#raw">raw pixel output</a>, <a href="https://sharp.pixelplumbing.com/api-colour/#tocolourspace">output color space</a>, and <a href="https://sharp.pixelplumbing.com/api-channel/#ensurealpha">alpha-channel handling</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Check a Product PNG Border for Transparency and Off-White Pixels with Sharp]]></title><description><![CDATA[A product export can look white in a preview while its outermost pixels contain a small gray line, a transparent corner, or a remnant from a previous…]]></description><link>https://pixmind.hashnode.dev/check-a-product-png-border-for-transparency-and-off-white-pixels-with-sharp</link><guid isPermaLink="true">https://pixmind.hashnode.dev/check-a-product-png-border-for-transparency-and-off-white-pixels-with-sharp</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Mon, 02 Nov 2026 19:00:06 GMT</pubDate><content:encoded><![CDATA[<p>A product export can look white in a preview while its outermost pixels contain a small gray line, a transparent corner, or a remnant from a previous background. A pixel-level check can make those specific defects easier to reproduce. It should report exactly what it inspected instead of declaring the entire image correct.</p>
<p>This tutorial builds a local Node.js checker for single-page PNG files. It reads an 8-bit sRGB RGBA representation with Sharp, checks each pixel on the outermost border once, and reports transparency separately from opaque colors. The result is an image-pipeline diagnostic, not a marketplace approval or a background-removal score.</p>
<h2>1. Define the border test before reading an image</h2>
<p>The strict rule in this example is simple: every sampled border pixel must have alpha 255 and RGB values of 255, 255, 255. A tolerance parameter can lower the minimum allowed value for each RGB channel. At tolerance 1, channels of 254 or 255 pass; a channel of 253 does not.</p>
<p>Transparency is a separate category. An alpha value below 255 is reported even if the RGB channels are white. A fully transparent black pixel is counted as transparent rather than as an opaque black defect. This matters because hidden color values and visible opacity answer different questions.</p>
<p>The sampling region is only the outside row and column of pixels. Corners are counted once. A 4-by-4 image therefore has 12 border pixels; its four interior pixels do not affect this report. For a one-pixel-wide image, every pixel belongs to the border.</p>
<p>The checker accepts at most 16 million decoded pixels and only one PNG page. Pixel coordinates refer to the decoded raster; the program does not apply orientation metadata. Use a consistently oriented export when comparing these coordinates with another tool.</p>
<h2>2. Implement the classification as a pure function</h2>
<p>Save this function as border.mjs. It expects tightly packed RGBA bytes and checks the supplied dimensions against the byte count. Keeping this step separate from decoding lets you test classification with small byte arrays before involving image files.</p>
<pre><code class="language-javascript">export function inspectBorder(data, width, height, tolerance = 0) {
  const pixels = width * height;
  if (!(data instanceof Uint8Array) || !Number.isInteger(width) || !Number.isInteger(height) ||
      width &lt; 1 || height &lt; 1 || pixels &gt; 16_000_000 || data.length !== pixels * 4) {
    throw new Error("Expected tightly packed RGBA bytes for 1 to 16000000 pixels");
  }
  if (!Number.isInteger(tolerance) || tolerance &lt; 0 || tolerance &gt; 32) {
    throw new Error("tolerance must be an integer from 0 to 32");
  }
  let borderPixels = 0, transparent = 0, nonwhiteOpaque = 0;
  const examples = [];
  for (let y = 0; y &lt; height; y++) {
    for (let x = 0; x &lt; width; x++) {
      if (x !== 0 &amp;&amp; x !== width - 1 &amp;&amp; y !== 0 &amp;&amp; y !== height - 1) continue;
      borderPixels++;
      const i = (y * width + x) * 4;
      const rgba = Array.from(data.subarray(i, i + 4));
      let kind;
      if (rgba[3] !== 255) {
        transparent++;
        kind = "alpha";
      } else if (rgba.slice(0, 3).some(v =&gt; v &lt; 255 - tolerance)) {
        nonwhiteOpaque++;
        kind = "color";
      }
      if (kind &amp;&amp; examples.length &lt; 5) examples.push({ x, y, kind, rgba });
    }
  }
  return {
    width, height, tolerance, borderPixels, transparent, nonwhiteOpaque,
    allBorderOpaqueWhite: transparent === 0 &amp;&amp; nonwhiteOpaque === 0,
    examples
  };
}
</code></pre>
<p>Each sample belongs to one of three groups: transparent, opaque but outside the color threshold, or opaque and within the threshold. The groups are mutually exclusive. The first five failing coordinates are included as examples; they are not the complete list of defects.</p>
<p>The allBorderOpaqueWhite field means that the border passes the selected rule. At the default tolerance of zero, that means exact white and full opacity. With a nonzero tolerance, it means full opacity and near-white channel values according to that tolerance. Keep the tolerance next to the result when you store a report.</p>
<p>The function neither edits the input nor changes pixels. It also does not inspect interior regions. A colored product that touches the edge will produce a border finding even when the export is intentional; a white outer border can pass while an unwanted gray patch remains farther inside.</p>
<h2>3. Decode one PNG and expose a command-line result</h2>
<p>Save the following as check-png.mjs. Sharp supplies the decoded width, height, and channel count. The pipeline requests sRGB output, ensures an alpha channel, and requests raw unsigned 8-bit bytes. The four-channel assertion makes the indexing assumption explicit.</p>
<pre><code class="language-javascript">import sharp from "sharp";
import { inspectBorder } from "./border.mjs";
const args = process.argv.slice(2);
if (args.length &lt; 1 || args.length &gt; 2 ||
    (args[1] !== undefined &amp;&amp; !/^(0|[1-9][0-9]?)$/.test(args[1]))) {
  console.error("Usage: node check-png.mjs image.png [tolerance:0..32]");
  process.exitCode = 2;
} else {
  try {
    const tolerance = Number(args[1] ?? 0);
    if (tolerance &gt; 32) throw new Error("tolerance must be 0..32");
    const image = sharp(args[0], { limitInputPixels: 16_000_000 });
    const meta = await image.metadata();
    if (meta.format !== "png" || (meta.pages ?? 1) !== 1) {
      throw new Error("Expected a single-page PNG");
    }
    const { data, info } = await image.toColourspace("srgb").ensureAlpha()
      .raw({ depth: "uchar" }).toBuffer({ resolveWithObject: true });
    if (info.channels !== 4) throw new Error("Expected four output channels");
    const report = inspectBorder(data, info.width, info.height, tolerance);
    console.log(JSON.stringify(report, null, 2));
    process.exitCode = report.allBorderOpaqueWhite ? 0 : 1;
  } catch (error) {
    console.error(error instanceof Error ? error.message : String(error));
    process.exitCode = 2;
  }
}
</code></pre>
<p>The command exits with code 0 when the selected border rule passes, code 1 when decoding succeeded but a border finding exists, and code 2 for usage or processing errors. Both passing and flagged images produce a JSON report on standard output. Errors go to standard error.</p>
<p>This PNG-only contract avoids treating an accepted filename extension as proof of the decoded format. The metadata check examines the detected format instead. Missing files, corrupt data, unsupported formats, and invalid tolerance values produce processing errors rather than a misleading clean result.</p>
<p>The pixel limit constrains the decoded image dimensions; it is not an encoded-file byte limit. This example is a local utility for known export files. If you place it behind an upload endpoint, define the endpoint’s file-size handling separately.</p>
<h2>4. Create a small PNG with a known border defect</h2>
<p>Save the next example as demo.mjs. It creates a 4-by-4 opaque white image, adds one colored interior pixel as a stand-in for a product, and changes one border red channel to 254. It writes an actual PNG, decodes it again, and checks both the strict and tolerance-1 outcomes.</p>
<pre><code class="language-javascript">import sharp from "sharp";
import { writeFile } from "node:fs/promises";
import { inspectBorder } from "./border.mjs";
const width = 4, height = 4;
const bytes = Buffer.alloc(width * height * 4, 255);
bytes.set([20, 80, 160, 255], (1 * width + 1) * 4); // Interior object.
bytes.set([254, 255, 255, 255], (0 * width + 2) * 4); // Off-white border.
await sharp(bytes, { raw: { width, height, channels: 4 } }).png().toFile("fixture.png");
const { data, info } = await sharp("fixture.png").toColourspace("srgb")
  .ensureAlpha().raw({ depth: "uchar" }).toBuffer({ resolveWithObject: true });
const strict = inspectBorder(data, info.width, info.height);
const relaxed = inspectBorder(data, info.width, info.height, 1);
if (strict.borderPixels !== 12 || strict.nonwhiteOpaque !== 1 || strict.allBorderOpaqueWhite ||
    !relaxed.allBorderOpaqueWhite) throw new Error("Unexpected demo result");
await writeFile("demo-report.json", JSON.stringify({ strict, relaxed }, null, 2));
console.log({ strict: strict.allBorderOpaqueWhite, relaxed: relaxed.allBorderOpaqueWhite });
</code></pre>
<p>The strict report should contain 12 border samples, zero transparent samples, and one nonwhite opaque sample. Its example coordinate is x=2, y=0. The interior colored pixel at x=1, y=1 is deliberately ignored. At tolerance 1, the same border passes the configured rule.</p>
<p>This fixture distinguishes two behaviors that are easy to confuse: ignoring an interior product pixel and accepting an almost-white border under an explicit threshold. Neither behavior requires a real product photograph or a segmentation model.</p>
<p>Install the pinned dependency in a working directory, keep border.mjs, check-png.mjs, and demo.mjs together, and run the following commands with Node.js. Respect your package manager’s dependency-build policy if it asks for approval. Local verification used Node.js 24.20.0 and Sharp 0.34.5.</p>
<pre><code class="language-sh">pnpm add sharp@0.34.5 --save-exact
node demo.mjs
node check-png.mjs fixture.png
node check-png.mjs fixture.png 1
</code></pre>
<p>The demo prints strict: false and relaxed: true. The strict command for fixture.png exits with code 1; the command with tolerance 1 exits with code 0. A nonzero exit in the strict case is the expected finding, not evidence that the PNG could not be read.</p>
<h2>5. Test edge cases and interpret the coordinates</h2>
<p>Local verification passed 54 checks covering one-pixel and narrow images, corner counting, interior exclusion, exact and tolerant color thresholds, transparent and partially transparent samples, capped examples, and unchanged input bytes. It also rejected mismatched dimensions, incorrect byte counts, invalid tolerances, and inputs above the pixel contract.</p>
<p>The file tests encoded and decoded synthetic PNGs, including opaque RGB, RGBA, and grayscale inputs. Command-line checks covered clean and flagged reports, missing arguments, extra arguments, a missing file, a corrupt image, a non-PNG input, and invalid tolerance strings. The independent demo also passed.</p>
<p>For a flagged export, open the image in an editor that can show pixel coordinates and inspect the reported location. If it is a transparent corner, investigate the alpha export. If it is an opaque gray line, inspect the source canvas or export boundary. If the product itself touches the border, decide whether that framing was intended before changing the image.</p>
<p>A clean border does not establish that all background pixels are white, that an object was cut out correctly, or that a shadow is suitable. Those questions need a different sampling region or a visual review. No real product photographs, segmentation quality, marketplace acceptance, or visual fidelity were tested here.</p>
<h2>6. Store the report beside the exported asset</h2>
<p>Run the check on the exported file that will be handed off, then store its report with the filename and the chosen tolerance. If you regenerate the file, regenerate the report too. A result from a previous export does not describe newly written bytes.</p>
<p>When this becomes part of a build, distinguish a border finding from a processing failure. A review queue may accept code 1 as a request to inspect the asset, while code 2 means the diagnostic itself did not finish. Avoid silently treating either outcome as a successful image check.</p>
<p>Keep the utility narrow: it tells you whether the decoded outer border satisfies an explicit opacity and color rule and gives you a few coordinates to inspect. That small, reproducible answer can be useful without making claims about the rest of the product image.</p>
<p>AI assistance disclosure: This tutorial and its example code were prepared with AI assistance and checked locally. Tests used synthetic pixel arrays and actual synthetic PNG files. No model was called, no product background was removed, and no real image quality or marketplace outcome was evaluated. There are no illustrative images in this article.</p>
<p>Source: Adapted from <a href="https://www.pixmind.io/posts/product-photo-background-requirements-amazon-shopify-etsy">PixMind’s Product Photo Background Requirements article</a>. This developer version omits marketplace rules, paid-plan limits, compliance assurances, and quality claims; it implements a local PNG border diagnostic.</p>
<p>Technical references: Sharp documentation for <a href="https://sharp.pixelplumbing.com/api-output/#raw">raw output</a>, <a href="https://sharp.pixelplumbing.com/api-colour/#tocolourspace">output color space</a>, and <a href="https://sharp.pixelplumbing.com/api-channel/#ensurealpha">alpha-channel handling</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Compile Structured Shot Notes into Reviewable Video Prompts with Node.js]]></title><description><![CDATA[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><link>https://pixmind.hashnode.dev/compile-structured-shot-notes-into-reviewable-video-prompts-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/compile-structured-shot-notes-into-reviewable-video-prompts-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Mon, 02 Nov 2026 16:57:24 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>1. Choose a record that preserves the creative decisions</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<pre><code class="language-json">{
  "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."
    }
  ]
}
</code></pre>
<p>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.</p>
<h2>2. Validate the notes before rendering the text</h2>
<p>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.</p>
<p>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.</p>
<pre><code class="language-javascript">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 =&gt; !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 &lt; 1 || doc.fps &gt; 120) {
    throw new Error("fps must be an integer from 1 to 120");
  }
  if (!Array.isArray(doc.shots) || doc.shots.length &lt; 1 || doc.shots.length &gt; 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) =&gt; {
    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 &lt;= 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 &gt; 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 };
}
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>3. Add a command-line wrapper with clear outcomes</h2>
<p>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.</p>
<pre><code class="language-javascript">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;
  }
}
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>4. Run the example and inspect one shot at a time</h2>
<p>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.</p>
<pre><code class="language-sh">node cli.mjs shots.json &gt; 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);'
</code></pre>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>5. Exercise failures before using the handoff</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>6. Hand off the source notes with the generated text</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>Source: Adapted from <a href="https://www.pixmind.io/posts/video-to-prompt-complete-guide-2026">PixMind’s Video-to-Prompt Complete Guide</a>. 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.</p>
<p>Technical reference: Node.js documentation for the <a href="https://nodejs.org/api/fs.html#promises-api">file-system promises API</a> and <a href="https://nodejs.org/api/cli.html">command-line execution</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Calculate a Previs Timeline in Integer Frames with Node.js]]></title><description><![CDATA[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…]]></description><link>https://pixmind.hashnode.dev/calculate-a-previs-timeline-in-integer-frames-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/calculate-a-previs-timeline-in-integer-frames-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Mon, 02 Nov 2026 01:41:30 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>1. Define what an overlap means in this manifest</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>2. Validate and calculate the frame intervals</h2>
<p>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.</p>
<pre><code class="language-javascript">export function planTimeline(input) {
  const fail = text =&gt; { throw new Error(text); };
  const integer = (n, min) =&gt; Number.isSafeInteger(n) &amp;&amp; n &gt;= min;
  if (!input || typeof input !== 'object' || Array.isArray(input)) fail('Expected object');
  const { fps, shots, overlaps } = input;
  if (!integer(fps, 1) || fps &gt; 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 &lt; shots.length; i++) {
    const incoming = i ? overlaps[i - 1] : 0;
    const outgoing = i &lt; overlaps.length ? overlaps[i] : 0;
    const { id, frames } = shots[i];
    if (incoming + outgoing &gt;= 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 };
}
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>3. Reproduce four shots with three overlaps</h2>
<p>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.</p>
<pre><code class="language-javascript">import { writeFile } from 'node:fs/promises';
import { planTimeline } from './timeline.mjs';

const plan = {
  fps: 24,
  shots: ['H01', 'H02', 'H03', 'H04'].map(id =&gt; ({ 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));
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>4. Read a saved manifest from the command line</h2>
<p>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.</p>
<pre><code class="language-javascript">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;
  }
}
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>5. Check hard cuts, overlaps, and rejection paths</h2>
<p>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.</p>
<pre><code class="language-javascript">import assert from 'node:assert/strict';
import { planTimeline } from './timeline.mjs';

const shots = [96, 120, 72, 144].map((frames, i) =&gt; ({ 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 =&gt; s.start), [0, 96, 216, 288]);
assert.throws(() =&gt; planTimeline({ fps: 24, shots, overlaps: [0, 0] }));
console.log('Article assertions passed');
</code></pre>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>6. Hand the report to the editor with its assumptions</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>Adapted and substantially rewritten for developers from the PixMind official article: <a href="https://www.pixmind.io/posts/cinematic-previsualization-with-wan-2-7">Cinematic Previsualization With Wan 2.7: A 4-Shot Case Study</a>. Model prices, internal test figures, performance rankings, and production outcomes from the original are not used as verified results here.</p>
<p>Technical references: <a href="https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.issafeinteger">ECMAScript Number.isSafeInteger</a> for integer-range validation, and <a href="https://nodejs.org/api/fs.html#fspromisesreadfilepath-options">Node.js readFile</a> for local JSON input. The manifest schema and overlap policy are choices made for this tutorial.</p>
]]></content:encoded></item><item><title><![CDATA[Measure a Voiceover WAV Before Scheduling an Avatar Shot with Node.js]]></title><description><![CDATA[An audio-driven talking-head handoff can contain a portrait, a script, and a file named voice-final.wav. The filename does not tell the next developer…]]></description><link>https://pixmind.hashnode.dev/measure-a-voiceover-wav-before-scheduling-an-avatar-shot-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/measure-a-voiceover-wav-before-scheduling-an-avatar-shot-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sun, 01 Nov 2026 21:37:23 GMT</pubDate><content:encoded><![CDATA[<p>An audio-driven talking-head handoff can contain a portrait, a script, and a file named voice-final.wav. The filename does not tell the next developer whether the recording has two seconds or twenty, or whether its channel and sample-rate fields agree with its payload. A small local check can make those facts explicit before anyone schedules a shot.</p>
<p>This tutorial builds a dependency-free Node.js command that reads a limited PCM WAV profile, measures duration from stored sample frames, and prints JSON. It also creates a synthetic silent file so you can reproduce the result without downloading a recording or calling a generation service.</p>
<h2>1. Define the handoff profile before parsing bytes</h2>
<p>The project profile here accepts little-endian RIFF/WAVE files with exactly one 16-byte fmt chunk, exactly one nonempty data chunk, format tag 1, 16 bits per sample, and one or two channels. Other chunks may appear between them. A sample frame contains one sample for each channel, so a stereo frame occupies four bytes in this profile.</p>
<p>Treat that as a deliberately narrow project contract. A valid WAV can use a different encoding, an extended format header, or another container variant and still be rejected by this tool. Re-export such a file through your audio editor if this project needs PCM16; changing its filename will not convert its contents.</p>
<p>The timing formula is sample frames divided by sample rate. For 48,000 Hz mono PCM16, 192,000 audio bytes represent 96,000 sample frames and two seconds. File size includes headers and optional metadata, so dividing the total file size by the byte rate would overstate duration.</p>
<p>These choices are local workflow assumptions, not upload specifications for an avatar model. Keep the destination service settings in a separate handoff note and check them when you actually use that service.</p>
<h2>2. Walk chunks instead of assuming a 44-byte header</h2>
<p>Save the following as wav-info.mjs. It reads the RIFF length, walks each chunk, accounts for the padding after an odd-length payload, and checks that no chunk extends beyond the declared file. It can find data before fmt because it validates the collected fields after the walk.</p>
<pre><code class="language-javascript">export function inspectWav(b) {
  const fail = message =&gt; { throw new Error(message); };
  if (!Buffer.isBuffer(b) || b.length &lt; 12) fail('Short RIFF header');
  const id = at =&gt; b.toString('latin1', at, at + 4);
  if (id(0) !== 'RIFF' || id(8) !== 'WAVE') fail('Expected RIFF/WAVE');
  if (b.readUInt32LE(4) + 8 !== b.length) fail('RIFF size mismatch');
  let fmt, dataBytes;
  for (let at = 12; at &lt; b.length;) {
    if (at + 8 &gt; b.length) fail('Short chunk header');
    const name = id(at), size = b.readUInt32LE(at + 4);
    const start = at + 8, end = start + size;
    const next = end + (size % 2);
    if (next &gt; b.length) fail('Truncated chunk or pad');
    if (name === 'fmt ') {
      if (fmt || size !== 16) fail('Expected one 16-byte fmt chunk');
      fmt = {
        tag: b.readUInt16LE(start), channels: b.readUInt16LE(start + 2),
        rate: b.readUInt32LE(start + 4), byteRate: b.readUInt32LE(start + 8),
        align: b.readUInt16LE(start + 12), bits: b.readUInt16LE(start + 14)
      };
    } else if (name === 'data') {
      if (dataBytes !== undefined) fail('Duplicate data chunk');
      dataBytes = size;
    }
    at = next;
  }
  if (!fmt || dataBytes === undefined) fail('Missing fmt or data');
  const { tag, channels, rate, byteRate, align, bits } = fmt;
  if (tag !== 1 || bits !== 16 || ![1, 2].includes(channels)) {
    fail('Only mono/stereo PCM16 is supported');
  }
  if (!rate || align !== channels * 2 || byteRate !== rate * align) {
    fail('Inconsistent PCM format');
  }
  if (!dataBytes || dataBytes % align) fail('Empty or partial sample frame');
  const sampleFrames = dataBytes / align;
  return { channels, sampleRate: rate, bits, dataBytes,
    sampleFrames, durationSeconds: sampleFrames / rate };
}
</code></pre>
<p>The parser requires the RIFF length to match the buffer exactly, including chunk padding. This strict choice rejects trailing bytes as well as truncation. An unknown chunk is skipped after its bounds are checked; its internal structure is not validated. The parser does not modify the audio or attempt to interpret every possible WAV extension.</p>
<p>For the accepted PCM profile, block alignment must equal channels times two bytes, and byte rate must equal sample rate times block alignment. Data must contain a positive whole number of sample frames. A contradictory header should produce an error rather than a plausible-looking duration.</p>
<h2>3. Add a command that produces a reusable report</h2>
<p>Save this wrapper as cli.mjs in the same folder. Run node cli.mjs voice.wav. On success it writes one JSON object to standard output and exits with status 0. Invalid bytes or an unreadable file use status 1; a missing or extra argument uses status 2.</p>
<pre><code class="language-javascript">import { readFile } from 'node:fs/promises';
import { inspectWav } from './wav-info.mjs';

if (process.argv.length !== 3) {
  console.error('Usage: node cli.mjs voice.wav');
  process.exitCode = 2;
} else {
  try {
    const bytes = await readFile(process.argv[2]);
    if (bytes.length &gt; 25 * 1024 * 1024) throw new Error('25 MiB project limit');
    console.log(JSON.stringify(inspectWav(bytes), null, 2));
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  }
}
</code></pre>
<p>The 25 MiB threshold is a chosen project limit, not a provider limit. This small local example reads the whole file before applying that check, so it does not cap allocation before reading. Use it with local handoff files; a production upload service needs its own bounded input and resource handling.</p>
<p>A successful report includes channels, sampleRate, bits, dataBytes, sampleFrames, and durationSeconds. Keep the full floating-point duration in machine-readable data. Round only the display value shown to an editor, otherwise a series of rounded clip durations can accumulate timeline differences.</p>
<h2>4. Reproduce two seconds with a synthetic WAV</h2>
<p>Save the next script as demo.mjs and run node demo.mjs. It writes silence.wav in the current working folder, replacing a file of that name if one already exists. Use a scratch folder for the example. Buffer.alloc fills the sample payload with zeros, which represents silence for signed PCM16.</p>
<pre><code class="language-javascript">import { writeFile } from 'node:fs/promises';
import { inspectWav } from './wav-info.mjs';

const rate = 48000, channels = 1, seconds = 2;
const dataBytes = rate * channels * 2 * seconds;
const wav = Buffer.alloc(44 + dataBytes);
wav.write('RIFF', 0); wav.writeUInt32LE(wav.length - 8, 4);
wav.write('WAVEfmt ', 8); wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20); wav.writeUInt16LE(channels, 22);
wav.writeUInt32LE(rate, 24); wav.writeUInt32LE(rate * channels * 2, 28);
wav.writeUInt16LE(channels * 2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(dataBytes, 40);
await writeFile('silence.wav', wav);
console.log(inspectWav(wav));
</code></pre>
<p>The expected report is channels 1, sampleRate 48000, bits 16, dataBytes 192000, sampleFrames 96000, and durationSeconds 2. Running node cli.mjs silence.wav should report the same six fields. The file occupies 192,044 bytes because its minimal headers add 44 bytes.</p>
<p>This exercise creates and reads actual synthetic WAV bytes, but it does not record a microphone, play the file, judge sound quality, or render a talking head. Silence is useful for a deterministic metadata fixture; it is not a useful voice-performance test.</p>
<h2>5. Make malformed files fail before the handoff</h2>
<p>Save this small check as example-checks.mjs and run node example-checks.mjs after the demo. It verifies the duration and changes the stored byte rate without changing the other PCM fields. That mutation should be rejected.</p>
<pre><code class="language-javascript">import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { inspectWav } from './wav-info.mjs';

const wav = await readFile('silence.wav');
assert.equal(inspectWav(wav).durationSeconds, 2);
const broken = Buffer.from(wav);
broken.writeUInt32LE(1, 28); // Contradict the PCM byte rate.
assert.throws(() =&gt; inspectWav(broken), /Inconsistent PCM format/);
console.log('Example assertions passed');
</code></pre>
<p>For a broader local run, also exercise an odd-length unknown chunk with a pad byte, data before fmt, stereo, and a 44,100 Hz header with its matching byte rate. Reject a short RIFF header, incorrect file size, truncated chunk, missing pad, duplicate fmt or data chunks, unsupported encoding, inconsistent block alignment, empty audio, and partial sample frames.</p>
<p>The accompanying implementation was run on Node.js 24.20.0 with 40 checks across those structural cases, report values, and CLI paths, plus the standalone demo and the assertions above. The fixtures were synthetic. The result supports this parser behavior and duration arithmetic; it says nothing about pronunciation, background noise, or lip synchronization.</p>
<h2>6. Put measured duration into the shot handoff</h2>
<p>If a measured file is two seconds long and your timeline reserves 0.4 seconds before it and 0.6 seconds afterward, the slot is three seconds. Those margins are editorial choices; the parser does not add silence or change an avatar service request. Record both the measured audio duration and the intended timeline offset so the editor can reproduce the placement.</p>
<p>For example, a cue annotated at 1.25 seconds inside that recording lands at timeline time 1.65 seconds after the 0.4-second offset. Verify the audible cue in your editor before using it to place a caption. This command measures stored duration and cannot detect where a spoken word begins.</p>
<p>A practical handoff includes the exact audio filename, the JSON report, the script revision, the portrait reference, the opening offset, and the closing margin. If the recording is replaced, rerun the command and update the timing fields together. Listening and reviewing the actual generated clip remain separate production steps.</p>
<p>AI assistance disclosure: This tutorial and code were prepared with AI assistance and checked with the synthetic Node.js examples described above. No images, recorded speech, avatar generation, or visual lip-sync testing were used for this article.</p>
<p>Adapted and substantially rewritten for developers from the PixMind official article: <a href="https://www.pixmind.io/posts/audio-driven-avatar-videos-wan-2-7">Audio-Driven Avatar Videos With Wan 2.7: A Talking-Head Workflow</a>. The original model-specific parameters, prices, performance claims, and internal test figures are not used here.</p>
<p>Technical references: <a href="https://learn.microsoft.com/en-us/windows/win32/xaudio2/resource-interchange-file-format--riff-">Microsoft RIFF chunk layout</a>, <a href="https://learn.microsoft.com/en-us/windows/win32/api/mmeapi/ns-mmeapi-waveformatex">Microsoft WAVEFORMATEX fields</a>, and <a href="https://nodejs.org/api/buffer.html">Node.js Buffer methods</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Check First-and-Last-Frame Landmark Drift with Node.js]]></title><description><![CDATA[Two product keyframes may look similar while a supposedly fixed bottle moves several pixels between them. A developer can make that preparation error…]]></description><link>https://pixmind.hashnode.dev/check-first-and-last-frame-landmark-drift-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/check-first-and-last-frame-landmark-drift-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sun, 01 Nov 2026 19:35:55 GMT</pubDate><content:encoded><![CDATA[<p>Two product keyframes may look similar while a supposedly fixed bottle moves several pixels between them. A developer can make that preparation error visible with a small coordinate check before submitting the pair to a video workflow.</p>
<p>This tutorial compares manually supplied landmarks against expected movement vectors. It produces a JSON report and a process exit code. It does not open image files, detect landmarks, align images, or measure a generated video. The sample uses synthetic annotations for a cap-lift product reveal.</p>
<h2>1. Give the pair one coordinate contract</h2>
<p>Use the same prepared canvas for both endpoint images. Here it is 1080 pixels wide and 1920 pixels high. The origin is the top-left corner; x increases to the right and y increases downward. Coordinates describe pixel locations, so the right and bottom boundaries are excluded.</p>
<p>Choose landmarks whose intended behavior is clear. The bottle base and a label corner should remain fixed. The cap center should rise 240 pixels. An expected vector of [0, 0] describes a fixed point, while [0, -240] describes that upward cap movement.</p>
<p>Save this as plan.json. The two-pixel tolerance is a project choice for this example, not an image-generation quality threshold. It applies to the distance between the measured displacement and the expected displacement.</p>
<pre><code class="language-json">{
  "canvas": {
    "width": 1080,
    "height": 1920
  },
  "landmarks": [
    {
      "name": "bottle-base",
      "start": [
        540,
        1500
      ],
      "end": [
        540,
        1500
      ],
      "expected": [
        0,
        0
      ],
      "tolerance": 2
    },
    {
      "name": "label-left",
      "start": [
        400,
        1050
      ],
      "end": [
        400,
        1050
      ],
      "expected": [
        0,
        0
      ],
      "tolerance": 2
    },
    {
      "name": "cap-center",
      "start": [
        540,
        660
      ],
      "end": [
        540,
        420
      ],
      "expected": [
        0,
        -240
      ],
      "tolerance": 2
    }
  ]
}
</code></pre>
<p>Record the coordinates from the actual prepared images when using this on a project. Naming a canvas in JSON does not prove that the files have that size. If one endpoint has been cropped or rotated differently, correct the shared coordinate system before comparing the numbers.</p>
<h2>2. Validate the annotations before comparing them</h2>
<p>Save the following module as landmarks.cjs. It uses only Node.js and JavaScript built-ins, with no package installation. Positive integer canvas dimensions, finite coordinates, distinct names, and nonnegative tolerances are checked before a report is returned.</p>
<pre><code class="language-javascript">function auditLandmarks(plan) {
  const size = plan?.canvas;
  if (!size || !Number.isSafeInteger(size.width) || size.width &lt;= 0 ||
      !Number.isSafeInteger(size.height) || size.height &lt;= 0) {
    throw new Error('Canvas dimensions must be positive safe integers');
  }
  const points = plan.landmarks;
  if (!Array.isArray(points) || points.length &lt; 1 || points.length &gt; 256) {
    throw new Error('Expected 1 to 256 landmarks');
  }
  const names = new Set();
  function pair(value) {
    return Array.isArray(value) &amp;&amp; value.length === 2 &amp;&amp;
      value.every(Number.isFinite);
  }
  function inside(value) {
    return pair(value) &amp;&amp; value[0] &gt;= 0 &amp;&amp; value[0] &lt; size.width &amp;&amp;
      value[1] &gt;= 0 &amp;&amp; value[1] &lt; size.height;
  }
  const rows = points.map(point =&gt; {
    if (!point || typeof point.name !== 'string' ||
        !/^[a-z][a-z0-9-]{0,63}$/.test(point.name) || names.has(point.name)) {
      throw new Error('Landmark names must be unique readable identifiers');
    }
    names.add(point.name);
    if (!inside(point.start) || !inside(point.end) || !pair(point.expected) ||
        Math.abs(point.expected[0]) &gt;= size.width ||
        Math.abs(point.expected[1]) &gt;= size.height ||
        !Number.isFinite(point.tolerance) || point.tolerance &lt; 0) {
      throw new Error('Invalid coordinates, expected vector, or tolerance');
    }
    const dx = point.end[0] - point.start[0];
    const dy = point.end[1] - point.start[1];
    const errorPx = Math.hypot(dx - point.expected[0], dy - point.expected[1]);
    return { name: point.name, dx, dy, errorPx,
      pass: errorPx &lt;= point.tolerance };
  });
  return { pass: rows.every(row =&gt; row.pass), rows };
}
module.exports = { auditLandmarks };
</code></pre>
<p>The example accepts between one and 256 landmarks. Every point must have a two-number start position, end position, and expected vector. Expected movement components must be smaller than the corresponding canvas dimension in magnitude; an impossible whole-canvas displacement is rejected as an input error.</p>
<p>Readable names make the report useful to the editor who measured the landmarks. The name rule accepts lowercase letters, digits, and hyphens, beginning with a letter, up to 64 characters. Duplicate names are rejected so that two rows cannot silently refer to the same identifier.</p>
<h2>3. Compare movement with the intended change</h2>
<p>For each landmark, subtract start from end to get dx and dy. Then subtract the expected vector from that actual vector. Math.hypot computes the length of the remaining two-dimensional error. A row passes when this error is less than or equal to its declared tolerance.</p>
<p>For the cap, (540, 420) minus (540, 660) gives (0, -240). Subtracting the expected (0, -240) leaves no error. For the fixed bottle base, a three-pixel shift to the right produces an error of three pixels and fails a tolerance of two.</p>
<p>This is a radial tolerance, not a separate allowance on each axis. A shift of two pixels horizontally and two vertically has an error of about 2.83 pixels, so it fails. That distinction matters when a diagonal move appears small in each individual coordinate.</p>
<p>The function leaves the supplied plan unchanged and returns one row per declared landmark. The overall pass value requires every row to pass. A passing cap does not hide a drifting bottle base.</p>
<h2>4. Expose the result through a small CLI</h2>
<p>Save this as run.cjs beside the module and plan. Run node run.cjs plan.json from that directory. Standard output contains the report as JSON; the command reads the plan and does not rewrite it or any image asset.</p>
<pre><code class="language-javascript">const { readFile } = require('node:fs/promises');
const { auditLandmarks } = require('./landmarks.cjs');

async function main() {
  const args = process.argv.slice(2);
  if (args.length !== 1) throw new Error('Usage: node run.cjs plan.json');
  const input = await readFile(args[0], 'utf8');
  const report = auditLandmarks(JSON.parse(input.replace(/^\uFEFF/, '')));
  console.log(JSON.stringify(report, null, 2));
  process.exitCode = report.pass ? 0 : 1;
}
main().catch(error =&gt; {
  console.error(error.message);
  process.exitCode = 2;
});
</code></pre>
<p>Exit code 0 means all declared landmarks passed. Exit code 1 means the input was valid but at least one landmark missed its tolerance. Exit code 2 means usage, file reading, JSON parsing, or annotation validation failed. A caller can distinguish a proposed pair that needs alignment from a malformed worksheet.</p>
<p>The baseline report has three rows. The cap row has dx = 0, dy = -240, errorPx = 0, and pass = true. The two fixed landmarks also have zero error. Preserve the full report instead of recording only a green pass value, so that later changes can be compared by name.</p>
<h2>5. Check the boundaries and the failure path</h2>
<p>Save this short example as sample.cjs and run node sample.cjs. It checks the intended movement, a passing tolerance boundary, a failing drift, and an out-of-canvas coordinate. The tests operate on the worksheet data, not on product pixels.</p>
<pre><code class="language-javascript">const assert = require('node:assert/strict');
const { auditLandmarks } = require('./landmarks.cjs');
const plan = require('./plan.json');

assert.equal(auditLandmarks(plan).pass, true);
assert.equal(auditLandmarks(plan).rows[2].dy, -240);
const shifted = structuredClone(plan);
shifted.landmarks[0].end[0] += 3;
assert.equal(auditLandmarks(shifted).pass, false);
const boundary = structuredClone(plan);
boundary.landmarks[0].end[0] += 2;
assert.equal(auditLandmarks(boundary).pass, true);
const invalid = structuredClone(plan);
invalid.landmarks[0].end[0] = plan.canvas.width;
assert.throws(() =&gt; auditLandmarks(invalid), /Invalid coordinates/);
console.log('Landmark movement, tolerance, and bounds checks passed');
</code></pre>
<p>A broader local check set passed 53 checks with Node.js 24.20.0. It covered report values, input immutability, horizontal and diagonal tolerances, invalid dimensions, duplicate names, malformed vectors, invalid tolerances, and the CLI 0/1/2 paths. A UTF-8 byte-order mark and invalid JSON were also exercised.</p>
<p>Keep those categories in your own regression checks if you change the contract. For example, allowing coordinates on the right boundary would change what x = width means. Increasing a tolerance changes the project decision even when the comparison algorithm remains identical.</p>
<h2>6. Use the report as preparation evidence</h2>
<p>If a fixed landmark fails, inspect the two prepared frames and determine whether the measurement, crop, or object placement changed. Update the images or correct the annotation, then rerun the comparison. Do not increase the tolerance solely to make an unexplained shift disappear from the report.</p>
<p>A few landmarks cannot establish that an entire product is unchanged. A label can deform between its corners, lighting can shift, and a generated transition can fail even when both endpoints fit the plan. Pair this numerical preparation check with viewing the actual images and, later, the resulting clip.</p>
<p>Store the plan and report beside the endpoint filenames and prompt version in the project handoff. This gives a teammate a repeatable answer to a narrow question: did the declared point movements match this particular preparation plan? It is not a model benchmark or automatic visual similarity score.</p>
<p>AI assistance: This developer tutorial was drafted and edited with AI assistance. The Node.js code, synthetic coordinate checks, and CLI paths were run locally. No image decoding, landmark detection, AI generation, video rendering, or visual quality test is presented.</p>
<p>Source: Developer adaptation of <a href="https://www.pixmind.io/posts/first-last-frame-product-reveals-instagram-ads">PixMind’s “First-Last-Frame Product Reveals for Instagram Ads: A Wan 2.7 Case Study.”</a> This article turns the endpoint-alignment idea into an annotation validator and omits unverified pricing, internal tests, and exact generation guarantees.</p>
<p>Reference: <a href="https://nodejs.org/api/assert.html">Node.js documentation for strict assertions</a>, used in the displayed checks.</p>
]]></content:encoded></item><item><title><![CDATA[Export White and Dark Product Backgrounds from One PNG with Sharp]]></title><description><![CDATA[A background-removal tool produces a cutout, but a storefront usually needs several deliverables: a transparent master, a white catalog image, and a dark…]]></description><link>https://pixmind.hashnode.dev/export-white-and-dark-product-backgrounds-from-one-png-with-sharp</link><guid isPermaLink="true">https://pixmind.hashnode.dev/export-white-and-dark-product-backgrounds-from-one-png-with-sharp</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sun, 01 Nov 2026 17:30:11 GMT</pubDate><content:encoded><![CDATA[<p>A background-removal tool produces a cutout, but a storefront usually needs several deliverables: a transparent master, a white catalog image, and a dark promotional image. A developer can automate the last two without changing the original cutout.</p>
<p>This tutorial builds a local Node.js CLI that reads one PNG, checks its alpha, and writes two opaque PNGs plus a small report. It starts after the subject has been separated from its background. It does not detect a subject, remove a photographed backdrop, or repair a halo.</p>
<h2>1. Define the input and output contract</h2>
<p>Use a single, already-oriented product PNG with meaningful transparency. Retain the original as the reusable master. The white and dark files are destination-specific exports; neither should replace that master. The example keeps the encoded width and height and does not resize or rotate the image.</p>
<p>The contract rejects a non-PNG input, a reported multipage input, a missing alpha channel, and an alpha channel whose pixels are all opaque. A PNG extension alone does not establish transparency. Fully hidden and partially transparent pixels are counted separately so the report can distinguish the two cases.</p>
<p>Start in a new working directory with Node.js and pnpm available. Save the following JavaScript files beside one another. The commands pin the Sharp version used for the checks described here; they are instructions for a separate tutorial project, not changes required in an existing application.</p>
<pre><code class="language-bash">pnpm init
pnpm add sharp@0.34.5
# After saving the three files below:
node sample.cjs
node run.cjs cutout.png output-lamp-001
</code></pre>
<h2>2. Decode once and inspect actual alpha values</h2>
<p>Save this as product-backgrounds.cjs. Reading metadata establishes the format and whether alpha exists. Decoding to sRGB RGBA then provides the actual channel values for counting. Metadata alone cannot tell us that an alpha channel contains useful transparency.</p>
<pre><code class="language-javascript">const sharp = require('sharp');

async function buildBackgrounds(input) {
  if (!Buffer.isBuffer(input) || input.length === 0) {
    throw new TypeError('Expected a nonempty PNG Buffer');
  }
  const options = { limitInputPixels: 16_000_000 };
  const meta = await sharp(input, options).metadata();
  if (meta.format !== 'png' || (meta.pages || 1) !== 1) {
    throw new Error('Expected one PNG image');
  }
  if (!meta.hasAlpha) throw new Error('Expected an alpha channel');
  const { data, info } = await sharp(input, options)
    .toColourspace('srgb').ensureAlpha().raw()
    .toBuffer({ resolveWithObject: true });
  let hidden = 0;
  let partial = 0;
  for (let i = 3; i &lt; data.length; i += 4) {
    if (data[i] === 0) hidden++;
    else if (data[i] &lt; 255) partial++;
  }
  if (hidden + partial === 0) {
    throw new Error('Alpha is present but every pixel is opaque');
  }
  const encode = background =&gt; sharp(data, {
    raw: { width: info.width, height: info.height, channels: 4 }
  }).flatten({ background }).png().toBuffer();
  const white = await encode('#ffffff');
  const dark = await encode('#161616');
  return {
    white, dark,
    report: { width: info.width, height: info.height,
      hidden, partial, pixels: info.width * info.height }
  };
}

module.exports = { buildBackgrounds };
</code></pre>
<p>The raw buffer has four channels per pixel, so the loop visits indices 3, 7, 11, and so on. A value of zero contributes to hidden; a value between zero and 255 contributes to partial. Both categories can exist in a valid product cutout. The report describes stored alpha values, not whether the product was selected correctly.</p>
<p>The 16-million-pixel input bound limits the size accepted by this example. The raw RGBA allocation can still be substantial: four bytes per pixel before other buffers and decoder overhead. This CLI processes one image at a time; choose separate resource limits before adapting it for a concurrent upload service.</p>
<p>Each destination starts from the same decoded buffer. An explicit background is supplied to flatten so the output color never depends on its default. Flatten combines the existing alpha with that color and removes alpha from the result. Removing alpha without that merge would express a different operation.</p>
<h2>3. Keep the file writes separate from image processing</h2>
<p>Save this wrapper as run.cjs. A successful call writes product-white.png, product-dark.png, and report.json to a new output directory. The input path is only read. Requiring a new directory makes accidental replacement of previous deliverables less likely and gives each run a clear location.</p>
<pre><code class="language-javascript">const { readFile, mkdir, writeFile } = require('node:fs/promises');
const { join } = require('node:path');
const { buildBackgrounds } = require('./product-backgrounds.cjs');

async function main() {
  const [inputPath, outputDir, ...extra] = process.argv.slice(2);
  if (!inputPath || !outputDir || extra.length) {
    console.error('Usage: node run.cjs cutout.png NEW-output-directory');
    process.exitCode = 2;
    return;
  }
  const result = await buildBackgrounds(await readFile(inputPath));
  // Refuse to overwrite an existing directory, including the source folder.
  await mkdir(outputDir);
  await writeFile(join(outputDir, 'product-white.png'), result.white,
    { flag: 'wx' });
  await writeFile(join(outputDir, 'product-dark.png'), result.dark,
    { flag: 'wx' });
  await writeFile(join(outputDir, 'report.json'),
    JSON.stringify(result.report, null, 2), { flag: 'wx' });
  console.log(JSON.stringify(result.report));
}
main().catch(error =&gt; {
  console.error(error.message);
  process.exitCode = 1;
});
</code></pre>
<p>Exit code 2 means the argument shape is wrong; exit code 1 reports an input, decoding, or filesystem error; exit code 0 means the writes completed. The output directory must have an existing parent. Passing an existing directory fails instead of merging files into it.</p>
<p>The writes are sequential rather than transactional. A disk error after the first write can leave an incomplete new directory. Check the exit code and all three expected files before using that directory downstream. Choose a fresh output name for another run instead of treating a partial directory as a completed job.</p>
<h2>4. Prove the compositing behavior with three synthetic pixels</h2>
<p>Save this as sample.cjs. Its tiny PNG contains an opaque red pixel, a half-transparent red pixel, and a fully hidden red pixel. That makes the expected foreground, mixed edge, and pure background outcomes explicit without relying on a subjective product photograph.</p>
<pre><code class="language-javascript">const assert = require('node:assert/strict');
const sharp = require('sharp');
const { buildBackgrounds } = require('./product-backgrounds.cjs');

(async () =&gt; {
  const rgba = Buffer.from([
    255, 0, 0, 255, // solid red
    255, 0, 0, 128, // partial alpha
    255, 0, 0, 0    // hidden red
  ]);
  const input = await sharp(rgba,
    { raw: { width: 3, height: 1, channels: 4 } }).png().toBuffer();
  const result = await buildBackgrounds(input);
  assert.deepEqual(result.report,
    { width: 3, height: 1, hidden: 1, partial: 1, pixels: 3 });
  for (const [name, background] of [['white', 255], ['dark', 22]]) {
    const { data, info } = await sharp(result[name]).raw()
      .toBuffer({ resolveWithObject: true });
    assert.equal(info.channels, 3);
    assert.deepEqual([...data.subarray(0, 3)], [255, 0, 0]);
    assert.deepEqual([...data.subarray(6, 9)], Array(3).fill(background));
    const expected = [255, 0, 0].map(channel =&gt;
      (channel * 128 + background * 127) / 255);
    expected.forEach((value, c) =&gt;
      assert.ok(Math.abs(data[3 + c] - value) &lt;= 1));
  }
  console.log('Synthetic alpha and two-background checks passed');
})().catch(error =&gt; { console.error(error); process.exitCode = 1; });
</code></pre>
<p>The opaque pixel stays red in both exports. The hidden pixel becomes the chosen background. The partial pixel is compared with the alpha-weighted channel calculation, allowing one integer level for rounding. For the dark export the background channels are 22, matching hexadecimal 16.</p>
<p>The sample also checks that both decoded outputs have three channels. A file can be PNG and still be completely opaque; PNG is the container choice here, not a promise that every generated file retains transparency. The transparent master remains the original input.</p>
<h2>5. Exercise the CLI before connecting it to a catalog</h2>
<p>The local checks used Node.js 24.20.0 and Sharp 0.34.5. In addition to the displayed sample, 41 checks passed for input immutability, report fields, both output formats and dimensions, decoded pixel behavior, rejected inputs, and CLI success and failure paths.</p>
<p>The rejected cases included empty and non-buffer input, unsupported bytes, a JPEG, an RGB PNG without alpha, and a fully opaque RGBA PNG. CLI checks covered missing or extra arguments, an unreadable input path, and an existing output directory. No product photographs or background-removal models were exercised.</p>
<p>For your own image, open both exports in the layouts where they will appear. Check thin details against each color and compare the dimensions with the delivery brief. A successful pixel test proves the small fixture and code path; it does not prove that a real cutout has clean hair, accurate translucent material, or a complete cable.</p>
<p>If an input with partial alpha produces a pale edge on dark, return to the cutout or mask rather than expecting flatten to remove color contamination. The compositor combines the visible input colors with a background; it does not infer the original unpolluted edge color.</p>
<h2>6. Hand off the master, destinations, and report</h2>
<p>Keep the original transparent PNG, the two opaque exports, and the report together under a product identifier. Use the report for dimension and alpha-count checks, while retaining visual inspection for the product boundary. Those are different kinds of evidence and both are useful in an asset handoff.</p>
<p>This is a narrow local conversion step. It deliberately avoids resizing, subject extraction, model calls, and quality rankings. Once that step is stable, a catalog job can invoke it for each approved cutout and record the resulting paths alongside the product record.</p>
<p>AI assistance: This developer tutorial was drafted and edited with AI assistance. The code and synthetic PNG checks were run locally as described. No real product-image quality test, background-removal test, or AI-generated illustration is presented.</p>
<p>Source: Developer adaptation of <a href="https://www.pixmind.io/posts/how-to-remove-background-without-photoshop">PixMind’s “How to Remove a Background Without Photoshop.”</a> The original discussion of reusable cutouts is expanded here into an executable export workflow; unverified quota, speed, and quality-equivalence claims are omitted.</p>
<p>References: Sharp documentation for <a href="https://sharp.pixelplumbing.com/api-input/#metadata">input metadata</a>, <a href="https://sharp.pixelplumbing.com/api-operation/#flatten">flatten</a>, and <a href="https://sharp.pixelplumbing.com/api-output/#tobuffer">output buffers</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Build a Prompt Variant Generator That Changes One Field at a Time]]></title><description><![CDATA[When a generated image looks better after a prompt rewrite, it is easy to credit the wrong edit. You may have changed lighting, framing, and style…]]></description><link>https://pixmind.hashnode.dev/build-a-prompt-variant-generator-that-changes-one-field-at-a-time</link><guid isPermaLink="true">https://pixmind.hashnode.dev/build-a-prompt-variant-generator-that-changes-one-field-at-a-time</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sun, 01 Nov 2026 01:14:57 GMT</pubDate><content:encoded><![CDATA[<p>When a generated image looks better after a prompt rewrite, it is easy to credit the wrong edit. You may have changed lighting, framing, and style together. A developer can prevent that accidental bundle of changes before sending any request to an image model.</p>
<p>This tutorial creates a small JavaScript prompt generator. It takes a structured brief, preserves a baseline, and emits variants that change exactly one field. It prepares text only: there is no model integration, API key, image generation, or claim that a wording change improves the resulting image.</p>
<h2>1. Define an explicit brief format</h2>
<p>Use six fields for this example: subject, action, setting, composition, lighting, and style. They are a project schema, not a universal model specification. Keeping the fields separate makes it possible to identify the intended change without comparing two freely rewritten paragraphs.</p>
<p>Save the following as brief.mjs. The example describes an imaginary mug; it does not represent a generated image or a reference photograph.</p>
<pre><code class="language-javascript">export const brief = {
  subject: 'a matte blue ceramic mug with a round handle',
  action: 'standing upright',
  setting: 'a plain pale gray tabletop and backdrop',
  composition: 'single mug centered, handle facing right, room around the rim',
  lighting: 'soft light from the left',
  style: 'product photograph with restrained surface texture'
};
</code></pre>
<p>Keep model controls such as aspect ratio, seed, image references, and output size outside these text fields. If you later connect a provider, pass those controls through that provider's documented request format. This example does not invent shared parameter syntax for different models.</p>
<p>The field labels are simply readable text in the emitted prompt. They do not give a model extra processing capacity, enforce a physical camera setup, or guarantee that it follows each instruction. Their immediate value is to the person reviewing the proposed requests.</p>
<h2>2. Normalize and compile the brief</h2>
<p>Save this complete implementation as prompt-variants.mjs. The normalizer requires exactly the six supported keys and nonempty string values. It trims whitespace and replaces internal whitespace runs with a single space, then compiles fields in a fixed order.</p>
<pre><code class="language-javascript">export const fields = Object.freeze([
  'subject', 'action', 'setting', 'composition', 'lighting', 'style'
]);

function clean(value) {
  if (typeof value !== 'string' || !value.trim()) throw Error('Use nonempty text');
  return value.trim().replace(/\s+/g, ' ');
}

export function normalizeBrief(brief) {
  if (!brief || typeof brief !== 'object' || Array.isArray(brief)) throw Error('Use a brief object');
  const keys = Object.keys(brief);
  if (keys.length !== fields.length || keys.some(key =&gt; !fields.includes(key))) {
    throw Error('Use exactly the six supported fields');
  }
  return Object.fromEntries(fields.map(key =&gt; [key, clean(brief[key])]));
}

export function compileBrief(brief) {
  const normalized = normalizeBrief(brief);
  return fields.map(key =&gt; key + ': ' + normalized[key]).join('\n');
}

export function makeVariants(brief, field, values) {
  const baseline = normalizeBrief(brief);
  if (!fields.includes(field)) throw Error('Unknown variation field');
  if (!Array.isArray(values) || values.length === 0) throw Error('Provide variation values');
  const seen = new Set([baseline[field]]);
  const variants = values.map((value, index) =&gt; {
    const normalized = clean(value);
    if (seen.has(normalized)) throw Error('Duplicate or unchanged variation');
    seen.add(normalized);
    const next = { ...baseline, [field]: normalized };
    return { id: field + '-' + (index + 1), changedField: field,
      brief: next, prompt: compileBrief(next) };
  });
  return [{ id: 'baseline', changedField: null,
    brief: baseline, prompt: compileBrief(baseline) }, ...variants];
}
</code></pre>
<p>The fixed order makes two equivalent brief objects produce the same prompt even when their JSON keys arrived in a different order. Whitespace normalization is deliberate: this format is for short descriptive phrases, not poetry, code, or instructions whose line breaks need to survive.</p>
<p>Notice that every variant is created from the normalized baseline. The second candidate does not inherit the first candidate's replacement. This avoids a common experiment bug in which each loop iteration modifies the result of the preceding iteration.</p>
<p>The returned brief objects are ordinary mutable objects. The generator does not mutate the input, but downstream code can still modify its returned records. Treat the batch as a prepared snapshot or store a serialized copy before another stage changes it.</p>
<h2>3. Generate a baseline and two lighting variants</h2>
<p>Save this as demo.mjs in the same directory and run node demo.mjs. To save the JSON output locally, redirect it to a new filename with node demo.mjs &gt; lighting-batch.json. This command prints a proposed batch and does not submit generation jobs.</p>
<pre><code class="language-javascript">import { brief } from './brief.mjs';
import { makeVariants } from './prompt-variants.mjs';

const batch = makeVariants(brief, 'lighting', [
  'soft light from the right',
  'diffuse overhead light'
]);
console.log(JSON.stringify(batch, null, 2));
</code></pre>
<p>The result has three records: baseline, lighting-1, and lighting-2. Each contains its brief and compiled prompt. The baseline has changedField set to null; the other records identify lighting as the intended variation. The mug description, tabletop, framing, action, and style stay the same.</p>
<p>An unchanged lighting phrase or an exact duplicate after whitespace normalization is rejected. This prevents two identically prepared candidates from being mistaken for different experiments. The check is case-sensitive and textual; it does not decide whether two different phrases have the same meaning.</p>
<p>If you want to vary composition next, start a separate batch from the chosen baseline and change the field argument. Avoid combining every lighting option with every style option until you intentionally want a multi-variable experiment with a different review question.</p>
<h2>4. Test the invariant that matters</h2>
<p>These assertions check the property the generator exists to preserve: one named field changes, and the original input remains intact. Save them as assertions.mjs next to the other files and run node assertions.mjs. A successful run finishes without output.</p>
<pre><code class="language-javascript">import assert from 'node:assert/strict';
import { brief } from './brief.mjs';
import { fields, makeVariants } from './prompt-variants.mjs';

const before = structuredClone(brief);
const batch = makeVariants(brief, 'lighting', ['diffuse overhead light']);
assert.deepEqual(brief, before);
assert.deepEqual(fields.filter(key =&gt; batch[1].brief[key] !== batch[0].brief[key]), ['lighting']);
assert.equal(batch[0].changedField, null);
assert.throws(() =&gt; makeVariants(brief, 'lighting', [brief.lighting]));
assert.throws(() =&gt; makeVariants(brief, 'model', ['another model']));
</code></pre>
<p>Using fields.filter to compare the two briefs makes the assertion about observable output. It will fail if a later refactor accidentally changes a second field. The input snapshot assertion checks a separate failure mode: a generator that returns plausible variants while corrupting its caller's baseline.</p>
<p>The local verification for this tutorial ran on Node.js 24.20.0. It passed 35 checks covering all six variation fields, stable compilation order, whitespace handling, input preservation, expected record IDs, and invalid or repeated values. The standalone demo and the assertions shown above also ran successfully.</p>
<p>These tests verify text preparation and rejection behavior, not image quality, provider compatibility, or visual consistency. A valid batch can still describe a contradictory scene; the code does not understand the meaning of the phrases.</p>
<h2>5. Add a provider boundary deliberately</h2>
<p>When you integrate this helper into an actual application, keep preparation and submission as separate stages. Show the compiled baseline and variant text in the review UI before a user starts generation. The helper itself makes no network requests and cannot estimate credits or availability.</p>
<p>Record the exact request text together with the model identifier and the other controls actually sent by your integration. Store the returned job or image ID beside the batch record ID. A name such as lighting-1 is only local to this batch, so add a project or run identifier in your application if you need to join results across runs.</p>
<p>Changing one text field does not make the generated images a deterministic scientific comparison. Model randomness, reference handling, and other request settings can affect results. Keep those settings visible and judge actual outputs against the same concrete task, such as whether the mug handle and rim remain readable.</p>
<h2>6. Preserve a useful experiment history</h2>
<p>Keep the original brief, emitted batch JSON, and outcome notes together. A useful note says which requirement improved or failed; it does not just label an image better. For example, record that a rim became harder to distinguish from the background rather than assuming a lighting phrase always produces that effect.</p>
<p>If you change the schema or compiler formatting, version that change in your application so old and new batches are distinguishable. Re-run the invariant checks when adding fields, changing normalization, or introducing optional values. The current implementation intentionally rejects missing fields rather than silently inventing defaults.</p>
<p>This gives a developer a controlled way to prepare prompt candidates while leaving image evaluation to an explicit later step. The benefit you can verify immediately is that the text changes you intended are the text changes the batch contains.</p>
<p>AI assistance: AI helped prepare this tutorial and code. Verification used structured text fixtures only; no model calls or image generation tests were performed.</p>
<p>Source: Developer adaptation of PixMind's <a href="https://www.pixmind.io/posts/how-to-write-effective-image-prompt">How to Write Effective Image Prompts</a>. Unverified model rankings, token-order claims, keyword-based resolution promises, and mixed provider parameter syntax from the source are omitted.</p>
<p>Technical reference: <a href="https://nodejs.org/api/assert.html#strict-assertion-mode">Node.js strict assertion documentation</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Calculate object-fit: cover Crops Before Shipping a Video Card]]></title><description><![CDATA[A video can arrive at the right resolution and still lose the product label when a web card crops it. Increasing the source from 720p to 1080p does not…]]></description><link>https://pixmind.hashnode.dev/calculate-object-fit-cover-crops-before-shipping-a-video-card</link><guid isPermaLink="true">https://pixmind.hashnode.dev/calculate-object-fit-cover-crops-before-shipping-a-video-card</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sat, 31 Oct 2026 23:19:42 GMT</pubDate><content:encoded><![CDATA[<p>A video can arrive at the right resolution and still lose the product label when a web card crops it. Increasing the source from 720p to 1080p does not move that label back into view. The useful debugging question is which source rectangle your card actually displays.</p>
<p>This tutorial builds a small JavaScript geometry helper for a centered video card. It calculates the visible source rectangle and projects an annotated region into the card. You can run it in Node.js without dependencies before connecting it to a browser layout or an asset pipeline.</p>
<h2>1. Make the layout assumption explicit</h2>
<p>The helper models object-fit: cover with object-position: 50% 50%. Cover preserves the source aspect ratio while filling the element's content box; the excess is clipped. The explicit position centers it. These properties are defined in the CSS Images specification linked below.</p>
<p>Here is the layout we will calculate: a 360 × 450 CSS-pixel video content box. Apply the class to a wrapper containing your own video element; the rule targets its direct video child.</p>
<pre><code class="language-css">.video-card {
  width: 360px;
  height: 450px;
}
.video-card &gt; video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
}
</code></pre>
<p>The numbers passed to the helper must describe the content box, excluding borders and padding. Source coordinates must use a display-oriented, square-pixel frame. Normalize rotation and unusual pixel aspect ratios before treating encoded width and height as this coordinate system.</p>
<p>This example does not cover a different object-position, CSS transforms, or contain mode. Keep those assumptions with the calculated result so another component cannot reuse it under a different layout and silently get the wrong crop.</p>
<h2>2. Calculate the crop in source coordinates</h2>
<p>For our 1920 × 1080 source, filling a 360 × 450 card requires a scale of max(360/1920, 450/1080), or 5/12. That produces an 800 × 450 rendered image. Centering it places the left edge at -220 CSS pixels, leaving part of the source outside each side of the card.</p>
<p>Convert the clipped margin back into source coordinates by dividing by the scale. The card begins at source x=528 and shows a rectangle 864 pixels wide and 1080 pixels high. The source's full width remains in the file, but only 45% of it is visible in this centered card.</p>
<p>Save the following as cover-plan.mjs. planCover produces a transform and source crop. projectRegion accepts that plan and a positive, in-bounds source rectangle, then reports the fraction of its rectangular area that survives clipping.</p>
<pre><code class="language-javascript">function positive(value) {
  return Number.isFinite(value) &amp;&amp; value &gt; 0;
}

export function planCover(sw, sh, cw, ch) {
  if (![sw, sh, cw, ch].every(positive)) throw Error('Use positive finite dimensions');
  const scale = Math.max(cw / sw, ch / sh);
  const rw = sw * scale, rh = sh * scale;
  const x = (cw - rw) / 2, y = (ch - rh) / 2;
  const crop = { x: -x / scale, y: -y / scale,
    width: cw / scale, height: ch / scale };
  if (![scale, rw, rh, crop.width, crop.height].every(positive) ||
      ![x, y, crop.x, crop.y].every(Number.isFinite)) throw Error('Dimensions overflow');
  return { sourceWidth: sw, sourceHeight: sh, width: cw, height: ch,
    scale, x, y, crop };
}

export function projectRegion(plan, region) {
  const { x, y, width, height } = region;
  if (![x, y].every(Number.isFinite) || x &lt; 0 || y &lt; 0 ||
      ![width, height].every(positive) ||
      x + width &gt; plan.sourceWidth || y + height &gt; plan.sourceHeight) {
    throw Error('Region must be inside the source');
  }
  const left = x * plan.scale + plan.x;
  const top = y * plan.scale + plan.y;
  const w = width * plan.scale, h = height * plan.scale;
  const visibleWidth = Math.max(0, Math.min(left + w, plan.width) - Math.max(left, 0));
  const visibleHeight = Math.max(0, Math.min(top + h, plan.height) - Math.max(top, 0));
  // Normalize each axis before multiplying to avoid squaring large dimensions.
  const visibleFraction = Math.min(1, (visibleWidth / w) * (visibleHeight / h));
  return { left, top, width: w, height: h, visibleFraction };
}
</code></pre>
<p>Keep the full floating-point values during calculations. Round only when formatting a diagnostic label. A tiny difference around an edge is not evidence that the browser cut off a meaningful part of a subject; use a small numeric tolerance when comparing expected coordinates.</p>
<p>The region is an annotation you provide, such as a product label's bounding rectangle in a chosen frame. It is not detected by this code. visibleFraction measures rectangle overlap, not text legibility, face visibility, or the proportion of a moving object visible throughout a video.</p>
<h2>3. Project a region that crosses the crop edge</h2>
<p>Create demo.mjs next to the module and run node demo.mjs. This example uses only numbers; it does not open a video file or decode a frame.</p>
<pre><code class="language-javascript">import { planCover, projectRegion } from './cover-plan.mjs';

const plan = planCover(1920, 1080, 360, 450);
console.log(plan.crop);
// { x: 528, y: 0, width: 864, height: 1080 } (within floating-point rounding)
console.log(projectRegion(plan, { x: 400, y: 200, width: 400, height: 600 }));
// visibleFraction is about 0.68: part of the region falls outside the card.
</code></pre>
<p>The region starts at x=400, while the visible crop starts at x=528. That removes 128 of its 400 source pixels horizontally. Its vertical span fits, so 272/400, or 68%, of the annotated rectangle remains. The returned left value is about -53.33 CSS pixels, which makes the reason for the clipping easy to inspect.</p>
<p>If a required label occupies that region, a higher-resolution source with the same composition will still clip it proportionally. Change the composition, the card's shape, or the intended positioning. If you choose a different object-position, extend the model before trusting its coordinates for that layout.</p>
<h2>4. Check more than one card shape</h2>
<p>An equal-ratio 1280 × 720 source in a 640 × 360 card keeps the full source rectangle. A 1080 × 1920 portrait source in the 360 × 450 card crops vertically instead: the calculated source crop begins at y=285 and has height 1350. These give useful opposing fixtures for a test suite.</p>
<p>A 1280 × 720 landscape source in the original portrait card keeps a 576 × 720 source rectangle. Its source crop is smaller in pixels than the 1080p version's 864 × 1080 crop, but both retain the same fraction of the composition. Resolution changes the sampling budget; the chosen source and destination aspect ratios determine this centered crop geometry.</p>
<p>Repeat the calculation for the actual content-box dimensions used at each breakpoint. If the card scales proportionally, the source crop stays the same while projected CSS coordinates scale. If its aspect ratio changes, recompute the source crop and the projected annotations.</p>
<h2>5. Turn the worked example into assertions</h2>
<p>Save these checks in another .mjs file alongside the module, then run it with Node.js. They test the central and partially clipped cases and verify rejection of a zero dimension.</p>
<pre><code class="language-javascript">import assert from 'node:assert/strict';
import { planCover, projectRegion } from './cover-plan.mjs';

const p = planCover(1920, 1080, 360, 450);
assert.ok(Math.abs(p.crop.x - 528) &lt; 1e-8);
assert.ok(Math.abs(p.crop.width - 864) &lt; 1e-8);
const central = projectRegion(p, { x: 760, y: 200, width: 400, height: 600 });
assert.ok(Math.abs(central.visibleFraction - 1) &lt; 1e-8);
const partial = projectRegion(p, { x: 400, y: 200, width: 400, height: 600 });
assert.ok(Math.abs(partial.visibleFraction - 0.68) &lt; 1e-8);
assert.throws(() =&gt; planCover(0, 1080, 360, 450));
</code></pre>
<p>The local verification for this tutorial ran on Node.js 24.20.0. It passed 49 checks covering worked coordinates, fully visible and excluded regions, invalid dimensions and regions, and crop containment plus round-trip dimensions across five landscape, portrait, square, and fractional-ratio fixtures. The standalone demo and the assertions shown here also ran successfully.</p>
<p>These checks exercise geometry and input handling. No browser rendering, real video decoding, device-pixel-ratio behavior, or generated video quality was tested. Pair the numerical checks with a preview of your actual media when integrating the helper.</p>
<h2>6. Use the result in an asset review</h2>
<p>Store the intended card shape and annotation coordinates with the asset decision. For a moving label or subject, sample more than one relevant frame and supply a rectangle for each; this helper does not track motion between annotations. A fully retained rectangle can still contain unreadable small text, so inspect the rendered result at its intended size.</p>
<p>When an asset fails the composition check, choose an intentional fix before requesting another resolution variant. You might select a shot with more central framing, change the component's aspect ratio, or use a layout that retains the full frame. Keep the geometry calculation aligned with whichever implementation you ship.</p>
<p>AI assistance: AI helped prepare this tutorial and code. Verification used synthetic dimensions and region annotations, not rendered or generated media.</p>
<p>Source: Developer adaptation of PixMind's <a href="https://www.pixmind.io/posts/720p-vs-1080p-ai-video-tradeoff">720p vs 1080p AI video trade-off guide</a>. Unverified model pricing, render-time benchmarks, and visual comparison claims from the source are omitted.</p>
<p>Technical reference: <a href="https://www.w3.org/TR/css-images-3/#the-object-fit">CSS Images Module Level 3, object-fit and object-position</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Hash Your Character References Before Generating the Next Shot]]></title><description><![CDATA[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…]]></description><link>https://pixmind.hashnode.dev/hash-your-character-references-before-generating-the-next-shot</link><guid isPermaLink="true">https://pixmind.hashnode.dev/hash-your-character-references-before-generating-the-next-shot</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sat, 31 Oct 2026 21:16:44 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>1. Decide what the lock represents</h2>
<p>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?</p>
<p>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.</p>
<p>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.</p>
<h2>2. Describe the approved reference set</h2>
<p>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.</p>
<pre><code class="language-json">[
  { "id": "front", "file": "front-v1.png" },
  { "id": "side", "file": "side-v1.png" }
]
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>3. Build the lock and comparison functions</h2>
<p>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.</p>
<pre><code class="language-javascript">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 &amp;&amp; (!Number.isSafeInteger(ref.bytes) || ref.bytes &lt; 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) =&gt;
    ref.bytes !== lock.references[i].bytes ||
    ref.sha256 !== lock.references[i].sha256
  ).map(ref =&gt; ref.id);
}

if (process.argv[1] &amp;&amp; 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;
    }
  }
}
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>4. Save a baseline and check it before a run</h2>
<p>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.</p>
<pre><code class="language-sh">node reference-lock.mjs create ./refs refs.json &gt; reference-lock-v1.json
node reference-lock.mjs verify ./refs reference-lock-v1.json
# Unchanged files: OK: references match
# Changed front reference: CHANGED: front
</code></pre>
<p>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.</p>
<p>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.</p>
<h2>5. Test a change that filenames and sizes miss</h2>
<p>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.</p>
<pre><code class="language-javascript">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.');
</code></pre>
<p>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.</p>
<h2>6. Connect a lock version to each shot</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>Source: Adapted for developers from PixMind's <a href="https://www.pixmind.io/posts/ai-video-character-consistency-guide">How to Keep Character Consistency Across AI Video Shots</a>. This version focuses on recording reference inputs and omits unverified model rankings, thresholds, and performance claims.</p>
<p>Technical reference: <a href="https://nodejs.org/api/crypto.html#cryptocreatehashalgorithm-options">Node.js crypto.createHash documentation</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Validate a Multilingual Voiceover Handoff with Node.js]]></title><description><![CDATA[A localized video handoff can contain every expected audio file and still be inconsistent. One take may use an old script, two worksheet rows may point to…]]></description><link>https://pixmind.hashnode.dev/validate-a-multilingual-voiceover-handoff-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/validate-a-multilingual-voiceover-handoff-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sat, 31 Oct 2026 19:16:49 GMT</pubDate><content:encoded><![CDATA[<p>A localized video handoff can contain every expected audio file and still be inconsistent. One take may use an old script, two worksheet rows may point to the same filename, or one language may be missing a demonstration segment. These are metadata problems a small preflight check can catch before an editor begins arranging clips.</p>
<p>This Node.js example audits a local JSON handoff. It checks declared locale/shot coverage, script-version agreement, unique MP3 basenames, and declared duration against a shot window. It does not synthesize speech, decode audio, inspect pronunciation, or verify that files actually exist.</p>
<h2>1. Define the handoff contract before writing the check</h2>
<p>Use one row per locale and shot. The example has two locales, en-US and es-MX, and two shots, S01 and S02. That means four take rows are required. If a locale intentionally omits narration, change the contract explicitly rather than inserting a silent placeholder and calling it complete.</p>
<p>This implementation accepts a deliberately narrow language-REGION label, such as en-US. It is not a validator for every BCP 47 language tag. Shot IDs begin with an uppercase letter and use uppercase letters, digits, or underscores. Durations and version numbers are positive safe integers; time is stored in whole milliseconds.</p>
<p>The approved script version comes from the reviewed localization worksheet. The rendered script version records which script was used for that take. Matching numbers are a consistency check on the record, not proof of what someone actually said in the MP3.</p>
<h2>2. Audit coverage, versions, and filenames</h2>
<p>Save the following module as handoff.mjs. Sets detect duplicate locales, duplicate locale/shot pairs, and reused filenames. A Map stores each shot’s declared window. An incomplete matrix or inconsistent version fails immediately with a message identifying the problem.</p>
<pre><code class="language-javascript">function requireValue(ok, message) {
  if (!ok) throw new Error(message);
}
const positiveInt = n =&gt; Number.isSafeInteger(n) &amp;&amp; n &gt; 0;

export function auditHandoff(data) {
  requireValue(data &amp;&amp; typeof data === "object", "Expected an object");
  requireValue(Array.isArray(data.locales) &amp;&amp; data.locales.length &gt; 0,
    "locales must be a non-empty array");
  const locales = new Set();
  for (const locale of data.locales) {
    requireValue(typeof locale === "string" &amp;&amp; /^[a-z]{2,3}-[A-Z]{2}$/.test(locale),
      "Use a language-REGION locale such as en-US");
    requireValue(!locales.has(locale), "Duplicate locale: " + locale);
    locales.add(locale);
  }
  requireValue(Array.isArray(data.shots) &amp;&amp; data.shots.length &gt; 0,
    "shots must be a non-empty array");
  const shots = new Map();
  for (const shot of data.shots) {
    requireValue(shot &amp;&amp; typeof shot.id === "string" &amp;&amp;
      /^[A-Z][A-Z0-9_]{0,15}$/.test(shot.id), "Invalid shot ID");
    requireValue(!shots.has(shot.id), "Duplicate shot: " + shot.id);
    requireValue(positiveInt(shot.windowMs), "Invalid windowMs: " + shot.id);
    shots.set(shot.id, shot.windowMs);
  }
  requireValue(Array.isArray(data.takes), "takes must be an array");
  const seen = new Set();
  const files = new Set();
  const warnings = [];
  for (const take of data.takes) {
    requireValue(take &amp;&amp; locales.has(take.locale) &amp;&amp; shots.has(take.shotId),
      "Take refers to an unknown locale or shot");
    const key = take.locale + "/" + take.shotId;
    requireValue(!seen.has(key), "Duplicate take: " + key);
    seen.add(key);
    requireValue(positiveInt(take.approvedScriptVersion) &amp;&amp;
      positiveInt(take.renderedScriptVersion) &amp;&amp;
      take.approvedScriptVersion === take.renderedScriptVersion,
      "Stale or invalid script version: " + key);
    requireValue(positiveInt(take.durationMs), "Invalid durationMs: " + key);
    requireValue(typeof take.file === "string" &amp;&amp;
      /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.mp3$/i.test(take.file),
      "Use a plain MP3 basename: " + key);
    const fileKey = take.file.toLowerCase();
    requireValue(!files.has(fileKey), "Reused filename: " + take.file);
    files.add(fileKey);
    const overrunMs = take.durationMs - shots.get(take.shotId);
    if (overrunMs &gt; 0) warnings.push({ key, overrunMs });
  }
  for (const locale of locales) {
    for (const shotId of shots.keys()) {
      requireValue(seen.has(locale + "/" + shotId),
        "Missing take: " + locale + "/" + shotId);
    }
  }
  return { takeCount: seen.size, expectedCount: locales.size * shots.size,
    warnings };
}
</code></pre>
<p>Filename comparisons are case-insensitive by project convention. That catches two records that differ only by letter case, which is easy to miss during a handoff. The basename rule excludes directory separators. It describes this manifest format; it is not a filesystem sandbox or an audio-format inspection.</p>
<p>Timing is reported differently from a structural error. A complete take can be longer than its shot window and still be a legitimate candidate for editorial revision. The function returns the overrun as a warning instead of silently trimming the take or rejecting the entire locale.</p>
<h2>3. Use a fixture that exercises a real mismatch</h2>
<p>Save this synthetic example as handoff.json. All four combinations are present and their approved/rendered versions agree. The last take declares 10,200 milliseconds for a 9,000-millisecond shot, so its overrun is 1,200 milliseconds. These are invented durations; no real audio was measured for this fixture.</p>
<pre><code class="language-json">{
  "locales": [
    "en-US",
    "es-MX"
  ],
  "shots": [
    {
      "id": "S01",
      "windowMs": 4000
    },
    {
      "id": "S02",
      "windowMs": 9000
    }
  ],
  "takes": [
    {
      "locale": "en-US",
      "shotId": "S01",
      "approvedScriptVersion": 2,
      "renderedScriptVersion": 2,
      "durationMs": 3400,
      "file": "lamp_en-US_S01_v2.mp3"
    },
    {
      "locale": "en-US",
      "shotId": "S02",
      "approvedScriptVersion": 2,
      "renderedScriptVersion": 2,
      "durationMs": 8200,
      "file": "lamp_en-US_S02_v2.mp3"
    },
    {
      "locale": "es-MX",
      "shotId": "S01",
      "approvedScriptVersion": 3,
      "renderedScriptVersion": 3,
      "durationMs": 3900,
      "file": "lamp_es-MX_S01_v3.mp3"
    },
    {
      "locale": "es-MX",
      "shotId": "S02",
      "approvedScriptVersion": 3,
      "renderedScriptVersion": 3,
      "durationMs": 10200,
      "file": "lamp_es-MX_S02_v3.mp3"
    }
  ]
}
</code></pre>
<p>Versions may differ between locales. English version 2 and Mexican Spanish version 3 are not inherently inconsistent: each localized script has its own revision history. The comparison is between the approved script and the rendered take within the same row.</p>
<p>Keep the visible product spelling and pronunciation instructions in the source worksheet. This manifest is a small technical view of that process, not a substitute for the approved words, speaker direction, or final audio review.</p>
<h2>4. Return an exit code your pipeline can distinguish</h2>
<p>Save this command-line wrapper as audit.mjs next to the module. It reads the JSON using Node’s promise-based readFile API, prints the successful report, and keeps input errors on standard error. There is no network request and no generated audio cost.</p>
<pre><code class="language-javascript">import { readFile } from "node:fs/promises";
import { auditHandoff } from "./handoff.mjs";

try {
  if (!process.argv[2]) throw new Error("Usage: node audit.mjs handoff.json");
  const data = JSON.parse(await readFile(process.argv[2], "utf8"));
  const report = auditHandoff(data);
  console.log(JSON.stringify(report, null, 2));
  process.exitCode = report.warnings.length ? 2 : 0;
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}
</code></pre>
<pre><code class="language-sh">node audit.mjs handoff.json
</code></pre>
<p>The fixture returns takeCount 4, expectedCount 4, and one warning for es-MX/S02 with overrunMs 1200. Exit code 0 means the declared handoff is complete with no overruns. Exit code 1 means invalid JSON, a missing argument, or a contract failure. Exit code 2 means the records are structurally complete but timing needs a decision.</p>
<p>Treat code 2 as a request for review in your own build or handoff process. Do not automatically speed up, crop, or discard audio based on this report. The checker has no information about spoken meaning or whether an important product action can be extended.</p>
<h2>5. Test missing and stale records, not only the happy path</h2>
<p>Check the four-row count and the exact 1,200-millisecond warning. Set the last duration to exactly 9,000 milliseconds and verify that no overrun remains. The equality boundary matters: a take that exactly fills the declared window should not be reported as too long.</p>
<p>Rejection fixtures should cover an empty locale list, a duplicate locale, an unsupported locale-label shape, a duplicate shot, a bad shot window, an unknown locale, an unknown shot, a duplicate take, a missing take, a stale rendered version, a zero approved version, a fractional duration, a directory in the filename, and a filename reused with different letter case.</p>
<p>The module shown here was checked locally with Node.js v24.20.0 using four report assertions and fourteen rejection fixtures. The command-line clean-success, timing-warning, and missing-input paths were also checked. These tests validate declared data and exit codes, not the contents, availability, or sound quality of any MP3.</p>
<h2>6. Connect the report back to the editing decision</h2>
<p>For a missing row, locate the absent locale/shot take before packaging the handoff. For a stale version, confirm which approved script should be rendered and replace the recorded take after that work is complete. Changing a version number in JSON without doing the corresponding audio work only hides the discrepancy.</p>
<p>For the 1.2-second overrun, return to the spoken copy and the shot context. A reviewer might shorten a nonessential phrase, or the editor might give the action more room. After a new take or edit is approved, update the recorded duration and shot window and run the check again.</p>
<p>Build or retime each locale’s captions against its final cut, then package the approved worksheet, the matching files, and this report together. A complete metadata matrix is a useful handoff gate; listening and final-cut checks still determine whether the localized video communicates correctly.</p>
<p>Editorial note: This developer tutorial was adapted with AI-assisted writing from the PixMind article linked below. Its Node.js implementation and synthetic fixtures were checked locally as described. No actual audio was generated or measured, and no illustrative images are used.</p>
<p>Original source: <a href="https://www.pixmind.io/posts/multilingual-voiceover-for-product-videos">How to Produce Multilingual Voiceovers for the Same Product Video</a>. Technical reference: <a href="https://nodejs.org/api/fs.html#fspromisesreadfilepath-options">Node.js file-system documentation</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Build a Timestamped Video Study Index in Markdown with Node.js]]></title><description><![CDATA[A chapter list tells you where a topic begins. A useful study note also records what you still need to verify and what you want to practise. Keeping those…]]></description><link>https://pixmind.hashnode.dev/build-a-timestamped-video-study-index-in-markdown-with-node-js</link><guid isPermaLink="true">https://pixmind.hashnode.dev/build-a-timestamped-video-study-index-in-markdown-with-node-js</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sat, 31 Oct 2026 17:20:19 GMT</pubDate><content:encoded><![CDATA[<p>A chapter list tells you where a topic begins. A useful study note also records what you still need to verify and what you want to practise. Keeping those jobs separate makes it easier to return to a tutorial without treating its summary as a verbatim transcript.</p>
<p>This small Node.js utility turns a manually reviewed set of timestamped points into a Markdown rewatch index. It works from a local JSON file. It does not download a video, call a transcription service, infer missing chapters, or decide whether a summary is accurate.</p>
<h2>1. Use a deliberately narrow input contract</h2>
<p>The input contains one video ID, a title, a duration in whole seconds, and a non-empty list of points. Each point has an m:ss timestamp and a one-line label. Minutes may have one to four digits; seconds must have exactly two digits between 00 and 59. Hours should be converted to total minutes before using this format.</p>
<p>Require points to arrive in strictly increasing order. Reject duplicates rather than sorting them silently: two differently worded notes at the same time may be intentional, but deciding whether to combine them belongs in the review step. A timestamp equal to the video duration is outside the playable interval used by this contract.</p>
<p>The example ID below is a synthetic, eleven-character fixture. It is not a recommendation or a claim that a particular video exists. Substitute the ID from the tutorial you are documenting, and record its duration from the actual source.</p>
<h2>2. Build the Markdown from validated fields</h2>
<p>Save the following module as notes.mjs. The text helper rejects empty labels and control characters, then escapes Markdown punctuation and HTML angle brackets. That prevents a label copied from a transcript from accidentally becoming a Markdown link or a raw HTML element in this generated document.</p>
<pre><code class="language-javascript">function checkedText(value, name) {
  if (typeof value !== "string" || !value.trim() ||
      /[\u0000-\u001f\u007f]/.test(value)) {
    throw new Error(name + " must be one non-empty line");
  }
  return value.trim();
}

function markdownText(value) {
  return value.replaceAll("&amp;", "&amp;amp;")
    .replaceAll("&lt;", "&amp;lt;").replaceAll("&gt;", "&amp;gt;")
    .replace(/[\\\x60*_{}\[\]()#+.!|~\-]/g, "\\$&amp;");
}

export function buildNotes(data) {
  if (!data || typeof data !== "object" || typeof data.videoId !== "string" ||
      !/^[A-Za-z0-9_-]{11}$/.test(data.videoId)) {
    throw new Error("videoId must have 11 URL-safe characters");
  }
  const duration = data.durationSeconds;
  if (!Number.isSafeInteger(duration) || duration &lt;= 0) {
    throw new Error("durationSeconds must be a positive safe integer");
  }
  const title = markdownText(checkedText(data.title, "title"));
  if (!Array.isArray(data.points) || data.points.length === 0) {
    throw new Error("points must be a non-empty array");
  }
  const base = new URL("https://www.youtube.com/watch");
  base.searchParams.set("v", data.videoId);
  let previous = -1;
  const lines = data.points.map((point, index) =&gt; {
    if (!point || typeof point.at !== "string" ||
        !/^\d{1,4}:[0-5]\d$/.test(point.at)) {
      throw new Error("Invalid m:ss timestamp at point " + index);
    }
    const [minutes, seconds] = point.at.split(":").map(Number);
    const at = minutes * 60 + seconds;
    if (at &gt;= duration || at &lt;= previous) {
      throw new Error("Timestamps must increase and be inside duration");
    }
    previous = at;
    const label = markdownText(checkedText(point.label, "label"));
    const link = new URL(base);
    link.searchParams.set("t", String(at));
    return "- [" + point.at + "](" + link.href + ") — " + label;
  });
  return "# " + title + "\n\nSource: [Open video](" + base.href +
    ")\n\n## Rewatch index\n" + lines.join("\n") +
    "\n\n## Verified\n\n## To practice\n";
}
</code></pre>
<p>The URL constructor and searchParams setters keep URL construction separate from label formatting. The output uses a watch URL with a t parameter containing whole seconds. This is a navigation hint, not a frame-accurate edit or a promise about playback in every client. Check the resulting links in the player you will actually use.</p>
<p>The title and labels are escaped as document text. This helper is not a general HTML sanitizer or a Markdown renderer. If you extend the format to accept arbitrary links, embedded HTML, or other output contexts, define and test those rules separately.</p>
<h2>3. Keep the worked input small</h2>
<p>Save this fixture as example.json. The second point is at two minutes and thirty seconds, so the generated URL must carry t=150. The 400-second duration is just a fixture bound, not a measurement of a real recording.</p>
<pre><code class="language-json">{
  "videoId": "abcdefghijk",
  "title": "Animation curve study notes",
  "durationSeconds": 400,
  "points": [
    { "at": "0:12", "label": "Create the first keyframe" },
    { "at": "2:30", "label": "Compare the easing curves" }
  ]
}
</code></pre>
<p>A point label should identify a question or operation worth returning to. “Compare the easing curves” is a useful index label. A claimed explanation of which curve parameter does what should remain in your own notes only after you have checked the matching demonstration.</p>
<h2>4. Add a command-line wrapper</h2>
<p>Save this wrapper as render-notes.mjs beside the module. It reads the local JSON, prints Markdown to standard output, and reports invalid input on standard error with a nonzero exit status. It does not write over the source JSON.</p>
<pre><code class="language-javascript">import { readFile } from "node:fs/promises";
import { buildNotes } from "./notes.mjs";

try {
  const input = process.argv[2];
  if (!input) throw new Error("Usage: node render-notes.mjs input.json");
  const data = JSON.parse(await readFile(input, "utf8"));
  process.stdout.write(buildNotes(data));
} catch (error) {
  console.error(error.message);
  process.exitCode = 1;
}
</code></pre>
<p>Run the following command from that directory. The shell redirection creates the Markdown file; choose a new output filename if an existing file must be kept. The wrapper prints nothing to standard output until the input has passed validation, although a shell may create or truncate the redirected file before Node runs.</p>
<pre><code class="language-sh">node render-notes.mjs example.json &gt; study-notes.md
</code></pre>
<p>The result has a title, a source link, a Rewatch index with the two linked points, and empty Verified and To practice sections. Those empty sections are deliberate: the program can format your reviewed points, but it has not watched the tutorial or performed the exercise.</p>
<h2>5. Test the failures that would make a note misleading</h2>
<p>For the module, check a zero-second starting point, the 2:30-to-150 conversion, and the number of generated index rows. Include a label such as <code>[click](x) &lt;script&gt; &amp; note</code> and assert that the output escapes its Markdown syntax and angle brackets instead of emitting an active link or raw element.</p>
<p>Reject malformed IDs, a zero or fractional duration, a multiline title, an empty point list, a seconds field of 60, a negative timestamp, a point at the exact duration, duplicate times, decreasing times, an empty label, and a null point. These failures distinguish bad input from a note that merely happens to be empty.</p>
<p>For this article, the published module was checked locally on Node.js v24.20.0 with five output assertions and twelve rejection fixtures. The command-line success and missing-input paths were also checked. These checks cover document generation and validation; they do not verify video availability, seeking behavior, transcript accuracy, or any speech-generation service.</p>
<h2>6. Use the index as a route back to evidence</h2>
<p>Start from the chapters or timestamped transcript you already have. Select the points that answer your learning questions, normalize their times to the input format, and run the utility. Keep the original transcript or export alongside the note so that a shortened label never becomes the only surviving record of the explanation.</p>
<p>Open a linked point in the original video, watch the complete operation, and add the checked explanation under Verified. Add a concrete exercise under To practice, such as reproducing the curve change in a small project. The index locates the evidence; your rewatch and exercise turn it into a working reference.</p>
<p>YouTube’s own sharing panel also offers a Start at option for copying a link to a chosen moment. Use that native feature to compare a generated link with a link from the player when checking your note. Retain a plain timestamp beside each label so the reference remains understandable even when a player does not follow the time parameter.</p>
<p>Editorial note: This developer tutorial was adapted with AI assistance from the PixMind article linked below. The code and synthetic fixtures were checked locally as described above. No real video was downloaded or transcribed for these checks, and no illustrative images are included.</p>
<p>Original source: <a href="https://www.pixmind.io/posts/youtube-video-to-timestamped-notes">How to Turn a YouTube Video into Timestamped Study Notes</a>. Technical references: <a href="https://nodejs.org/api/url.html">Node.js URL documentation</a> and <a href="https://support.google.com/youtube/answer/57741?hl=en">YouTube sharing help</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Audit a Layered PSD Handoff with Node.js Before Opening the Editor]]></title><description><![CDATA[AI disclosure: Prepared with AI-assisted writing and code verification. The examples audit PSD structure using synthetic fixtures. They do not measure…]]></description><link>https://pixmind.hashnode.dev/audit-a-layered-psd-handoff-with-node-js-before-opening-the-editor</link><guid isPermaLink="true">https://pixmind.hashnode.dev/audit-a-layered-psd-handoff-with-node-js-before-opening-the-editor</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Sat, 31 Oct 2026 01:02:16 GMT</pubDate><content:encoded><![CDATA[<p>AI disclosure: Prepared with AI-assisted writing and code verification. The examples audit PSD structure using synthetic fixtures. They do not measure image quality, recover source typography, or claim a successful export from a particular generation run. No AI-generated images are used.</p>
<p>A file called final.psd is not enough to establish that a design handoff has the expected canvas and independent layers. A flattened image can live inside a PSD, and a useful object can be hidden inside a group. Before opening the file for visual work, a small Node.js audit can make those structural facts inspectable.</p>
<p>This developer adaptation of our flat-image-to-layered-PSD tutorial adds a local inspection step. It reads the file, checks the dimensions and minimum leaf-layer count from your brief, and prints a tree with names, bounds, inherited visibility, and detected text metadata. The program never saves changes to the input PSD.</p>
<h2>Define the structural contract first</h2>
<p>Suppose a handoff brief asks for an eight-by-six-pixel test canvas and at least three leaf layers. Those intentionally tiny numbers are for the synthetic fixture below. For a real design, use the canvas size and independent components agreed with the recipient. A minimum count is a coarse check, not proof that the product, shadow, and background were separated correctly.</p>
<p>Count groups and leaves separately. A folder containing two pixel layers is one group and two leaves; treating all three as independent visual assets overstates the decomposition. An empty group still remains a group in this report.</p>
<p>Keep pixel layers and live text distinct. A separated headline can remain a raster image even when it has its own layer. Detecting text metadata is useful information, but neither its presence nor its absence establishes complete font recovery or compatibility with every editor.</p>
<h2>Read structure without decoding the bitmaps</h2>
<p>Use Node.js and ag-psd. The example was checked with ag-psd 31.0.2. In a separate working directory, initialise a package and install that version with pnpm. Keep both CommonJS files below in that directory.</p>
<pre><code class="language-bash">pnpm init
pnpm add ag-psd@31.0.2
</code></pre>
<p>The <a href="https://github.com/Agamnentzar/ag-psd">ag-psd project</a> documents a structure-only read that skips layer images, the merged image, and thumbnails. This audit also skips linked-file payloads. It uses the parsed tree rather than rendering a preview, so no canvas dependency is needed for this inspection path.</p>
<p>Save the following as psd-structure-audit.cjs. Expected dimensions and minimum leaf count must be positive integers. Parser errors and mismatched expectations produce a failing command instead of a report that looks successful.</p>
<pre><code class="language-javascript">const { readPsd } = require('ag-psd');

function auditPsd(bytes, expected) {
  for (const key of ['width', 'height', 'minLeaves']) {
    if (!Number.isSafeInteger(expected?.[key]) || expected[key] &lt; 1) {
      throw new TypeError(key + ' must be a positive integer');
    }
  }
  const psd = readPsd(bytes, {
    skipLayerImageData: true,
    skipCompositeImageData: true,
    skipThumbnail: true,
    skipLinkedFilesData: true
  });
  if (psd.width !== expected.width || psd.height !== expected.height) {
    throw new Error('Canvas dimensions differ from the handoff brief');
  }
  const rows = [];
  function visit(children, prefix = [], parentHidden = false) {
    for (const [index, layer] of (children || []).entries()) {
      const path = [...prefix, index];
      const hidden = parentHidden || !!layer.hidden;
      const group = Array.isArray(layer.children);
      rows.push({
        path: path.join('.'),
        name: layer.name || '(unnamed)',
        kind: group ? 'group' : 'leaf',
        hidden, hasTextData: !!layer.text,
        bounds: [layer.left ?? 0, layer.top ?? 0,
          layer.right ?? 0, layer.bottom ?? 0]
      });
      if (group) visit(layer.children, path, hidden);
    }
  }
  visit(psd.children);
  const leafCount = rows.filter(row =&gt; row.kind === 'leaf').length;
  if (leafCount &lt; expected.minLeaves) {
    throw new Error('Too few leaf layers for the handoff brief');
  }
  return { width: psd.width, height: psd.height, leafCount, rows };
}

module.exports = { auditPsd };

if (require.main === module) {
  const { readFile } = require('node:fs/promises');
  const [file, width, height, minLeaves] = process.argv.slice(2);
  if (!file) throw new Error('Usage: node psd-structure-audit.cjs file.psd width height minLeaves');
  readFile(file)
    .then(bytes =&gt; auditPsd(bytes, {
      width: Number(width), height: Number(height),
      minLeaves: Number(minLeaves)
    }))
    .then(report =&gt; console.log(JSON.stringify(report, null, 2)))
    .catch(error =&gt; { console.error(error.message); process.exitCode = 1; });
}
</code></pre>
<p>The dotted path is a structural index, such as 1.0 for the first child of the second root entry. It remains usable when two layers share a name. It is not a durable layer identifier across later edits, and the rows should not be reinterpreted as an instruction to reverse the PSD stacking order.</p>
<p>The hidden field includes the effect of parent groups. A child with its own hidden flag unset is still effectively hidden when its parent group is hidden. This report does not calculate every other reason a visible layer might contribute no pixels, such as opacity, masks, clipping, or transparent content.</p>
<h2>Run against a file and keep the JSON with the handoff</h2>
<p>For an actual 1600-by-1200 design that requires at least four independent leaf layers, run the command below. Replace these example expectations with your own brief. Output redirection creates a separate JSON report; it does not rewrite design.psd.</p>
<pre><code class="language-bash">node psd-structure-audit.cjs ./design.psd 1600 1200 4 &gt; design.audit.json
</code></pre>
<p>Inspect the rows as well as the exit status. A passing minimum count can still include decorative fragments instead of the components you need. Check names and groups against the intended edits: moving the subject, changing a background, or replacing a headline.</p>
<p>Bounds are reported as left, top, right, and bottom in document coordinates. Their width and height are right minus left and bottom minus top. Off-canvas coordinates can be intentional, so the audit reports them without treating every negative coordinate or oversized object as corrupt.</p>
<h2>Exercise the parser path with a synthetic PSD</h2>
<p>Save this as psd-structure-audit-check.cjs. It creates an eight-by-six document containing three one-pixel raster leaves, two inside a hidden group. The Product leaf sits at coordinate two, one. The fixture is real PSD bytes written by the library, but it is not a generated design or a visual-fidelity sample.</p>
<pre><code class="language-javascript">const assert = require('node:assert/strict');
const { writePsdBuffer } = require('ag-psd');
const { auditPsd } = require('./psd-structure-audit.cjs');
const { writeFileSync } = require('node:fs');

const pixel = () =&gt; ({ width: 1, height: 1,
  data: new Uint8ClampedArray([255, 0, 0, 255]) });
const bytes = writePsdBuffer({
  width: 8, height: 6,
  children: [
    { name: 'Background', imageData: pixel() },
    { name: 'Alternates', hidden: true, children: [
      { name: 'Product', left: 2, top: 1, imageData: pixel() },
      { name: 'Decoration', imageData: pixel() }
    ] }
  ]
});
const expected = { width: 8, height: 6, minLeaves: 3 };
const report = auditPsd(bytes, expected);
assert.equal(report.leafCount, 3);
assert.equal(report.rows[1].kind, 'group');
assert.equal(report.rows[2].path, '1.0');
assert.equal(report.rows[2].hidden, true);
assert.deepEqual(report.rows[2].bounds, [2, 1, 3, 2]);
assert.equal(report.rows.every(row =&gt; !row.hasTextData), true);
assert.throws(() =&gt; auditPsd(bytes, { ...expected, width: 9 }));
assert.throws(() =&gt; auditPsd(bytes, { ...expected, minLeaves: 4 }));
assert.throws(() =&gt; auditPsd(bytes, { ...expected, height: 0 }), TypeError);
assert.throws(() =&gt; auditPsd(Buffer.from('not a PSD'), expected));
writeFileSync('synthetic-layer-fixture.psd', bytes);
console.log('10 checks passed; synthetic PSD structure, not visual fidelity');
console.log(JSON.stringify(report, null, 2));
</code></pre>
<p>Run node psd-structure-audit-check.cjs. Ten assertions cover the leaf count, group classification, indexed path, inherited hidden state, preserved bounds, absence of text metadata in these raster fixtures, and four rejection paths: wrong canvas width, insufficient leaves, a nonpositive expected height, and input that is not a PSD.</p>
<p>The check writes synthetic-layer-fixture.psd into the working directory. You can also run the audit CLI against it with expectations 8 6 3 and inspect the JSON. The Product entry should have path 1.0, hidden true, and bounds [2, 1, 3, 2]. These are structural assertions, not evidence of correct colour compositing.</p>
<h2>Separate a passing report from a finished design</h2>
<p>The library has format and feature limits, so a parse failure should be investigated rather than automatically labelled a broken source file. The audit deliberately skips pixel decoding and cannot establish smooth alpha edges, faithful hidden regions, readable raster text, or a matching merged preview.</p>
<p>Open the actual PSD in the target editor after the structural pass. Hide and move the parts that the brief says are independent, inspect newly exposed overlaps, and compare the recomposed image with the supplied preview. A layer with the right name can still contain the wrong pixels.</p>
<p>For a headline that needs new wording, check whether the target editor can edit it as text. If it is a pixel layer, rebuild the type or choose a workflow that produces text objects. A successful JSON report should never be described as recovery of the original fonts, vector paths, Smart Objects, or editing history.</p>
<h2>Deliver the file, the report, and the remaining work</h2>
<p>Keep the original PSD, the JSON audit, and a flat preview together. Add a short note distinguishing structural checks from visual checks and listing any text or edges that still need rebuilding. That gives the next developer or designer a reproducible starting point instead of relying on the filename alone.</p>
<p>Adapted from <a href="https://www.pixmind.io/posts/ai-image-to-editable-psd-layers">PixMind: Convert JPG/PNG to Editable PSD with AI Layers</a>. This edition adds a standalone Node.js structure audit and tested synthetic fixtures rather than repeating a generation walkthrough.</p>
]]></content:encoded></item><item><title><![CDATA[Track GIF Export Experiments with a Small Node.js Manifest]]></title><description><![CDATA[Record source intervals, requested width and frame rate, actual bytes, and visual checks alongside GIF exports using a tested Node.js helper.]]></description><link>https://pixmind.hashnode.dev/track-gif-export-experiments-with-a-small-node-js-manifest</link><guid isPermaLink="true">https://pixmind.hashnode.dev/track-gif-export-experiments-with-a-small-node-js-manifest</guid><dc:creator><![CDATA[PixMind]]></dc:creator><pubDate>Fri, 30 Oct 2026 21:01:04 GMT</pubDate><content:encoded><![CDATA[<p>AI disclosure: Prepared with AI-assisted writing. The Node.js examples below record export metadata; they do not convert or decode video. Tests use synthetic byte fixtures, not measured GIF-quality results. No AI-generated images are used.</p>
<p>You export a smaller GIF, then forget which source interval, width, and frame rate produced it. The next edit becomes another guessing session. A tiny manifest can preserve those decisions alongside the file.</p>
<p>This developer adaptation of our MP4-to-GIF guide focuses on reproducible handoff. Keep the conversion in your existing tool; add a record containing the settings you requested, the file size you actually measured, and the visual checks you performed.</p>
<h2>Separate requested settings from measured results</h2>
<p>A width of 640 and a frame rate of 12 are example inputs, not universal requirements for documentation. Decide the display job first. A small interface demonstration and a detailed diagram may need different compromises between readability, motion, and file size.</p>
<p>Record the source interval before changing anything else. If one export uses four seconds and another uses eight, comparing only their byte sizes mixes two experiments. For a useful comparison, keep the source moment fixed while changing one setting, then review both outputs at the same display size.</p>
<p>Name fields honestly. The code below stores widthRequested and fpsRequested because it has not decoded the result to verify either. outputBytes comes from the actual file. readable and seamOK are explicit human observations, not scores inferred from file size.</p>
<h2>Capture a small record with Node.js</h2>
<p>Save this as <code>gif-export-record.mjs</code>. It reads file metadata and returns an ordinary JavaScript object; it does not rewrite the exported image.</p>
<pre><code class="language-javascript">import { stat } from 'node:fs/promises';
import { basename } from 'node:path';

export async function captureExport(job) {
  if (!job || typeof job !== 'object') throw new TypeError('job required');
  for (const key of ['sourceName', 'resultPath']) {
    if (typeof job[key] !== 'string' || !job[key].trim()) {
      throw new TypeError(key + ' must be a nonempty string');
    }
  }
  if (!Number.isSafeInteger(job.startMs) || job.startMs &lt; 0 ||
      !Number.isSafeInteger(job.endMs) || job.endMs &lt;= job.startMs) {
    throw new RangeError('invalid interval');
  }
  if (!Number.isSafeInteger(job.width) || job.width &lt; 1 || job.width &gt; 4096) {
    throw new RangeError('width must be an integer from 1 to 4096');
  }
  if (!Number.isFinite(job.fps) || job.fps &lt;= 0 || job.fps &gt; 60) {
    throw new RangeError('fps must be greater than 0 and at most 60');
  }
  if (typeof job.readable !== 'boolean' || typeof job.seamOK !== 'boolean') {
    throw new TypeError('record both visual review decisions');
  }
  const info = await stat(job.resultPath);
  if (!info.isFile() || info.size &lt; 1) throw new Error('nonempty file required');
  return {
    sourceName: job.sourceName,
    outputName: basename(job.resultPath),
    startMs: job.startMs,
    endMs: job.endMs,
    widthRequested: job.width,
    fpsRequested: job.fps,
    outputBytes: info.size,
    readable: job.readable,
    seamOK: job.seamOK
  };
}
</code></pre>
<p>The numeric bounds in this example are local validation choices. They are not claims about a converter's supported settings. The interval is in integer milliseconds; keep that unit consistent with the rest of your workflow.</p>
<p>The <a href="https://nodejs.org/api/fs.html">Node.js file-system documentation</a> describes the stat API used to obtain the file size and check whether the path names a file. A nonempty file is only a useful prerequisite. This function does not validate a GIF signature, decode every frame, or establish that a browser can play it.</p>
<h2>Add the record after reviewing an export</h2>
<p>Save this as <code>record-gif.mjs</code>. Replace the source name, interval, dimensions, frame rate, and review decisions with the values from your own job.</p>
<pre><code class="language-javascript">import { captureExport } from './gif-export-record.mjs';

const resultPath = process.argv[2];
if (!resultPath) throw new Error('Pass the exported GIF path');
const record = await captureExport({
  sourceName: 'demo.mp4', resultPath,
  startMs: 1000, endMs: 5000, width: 640, fps: 12,
  readable: true, seamOK: true
});
console.log(JSON.stringify(record, null, 2));
</code></pre>
<p>After creating and inspecting your own GIF, run <code>node record-gif.mjs ./demo.gif</code>. The program prints JSON that you can save with that export. The true review values in the example are placeholders for a completed review, not a reason to skip looking at the file.</p>
<p>Watch at least two loops and inspect the restart. Check whether small text remains readable at the size used in the page or message. A smooth loop with illegible text is a different failure from a sharp first frame with a distracting jump at the seam; separate fields preserve that distinction.</p>
<h2>Exercise the metadata code without an encoder</h2>
<p>Save this as <code>gif-export-record-check.mjs</code> and run it with <code>node gif-export-record-check.mjs</code>. It creates a small fixture directory in the working folder. The files are synthetic bytes and are deliberately not presented as playable media.</p>
<pre><code class="language-javascript">import assert from 'node:assert/strict';
import { mkdir, writeFile } from 'node:fs/promises';
import { captureExport } from './gif-export-record.mjs';

await mkdir('./gif-record-fixtures', { recursive: true });
await writeFile('./gif-record-fixtures/sample.bin', new Uint8Array(16));
await writeFile('./gif-record-fixtures/empty.bin', new Uint8Array(0));
const job = {
  sourceName: 'synthetic-example.mp4', startMs: 1000, endMs: 5000,
  width: 640, fps: 12, readable: true, seamOK: false,
  resultPath: './gif-record-fixtures/sample.bin'
};
const record = await captureExport(job);
assert.equal(record.outputBytes, 16);
assert.equal(record.outputName, 'sample.bin');
assert.equal(record.seamOK, false);
assert.equal(record.widthRequested, 640);
await assert.rejects(captureExport({ ...job, endMs: 1000 }), RangeError);
await assert.rejects(captureExport({ ...job, width: 0 }), RangeError);
await assert.rejects(captureExport({ ...job, fps: NaN }), RangeError);
await assert.rejects(captureExport({ ...job, readable: 'yes' }), TypeError);
await assert.rejects(captureExport({ ...job, resultPath: './gif-record-fixtures/empty.bin' }));
await assert.rejects(captureExport({ ...job, resultPath: './gif-record-fixtures/missing.bin' }));
console.log('6 behavior cases passed; synthetic byte fixtures, not encoded media');
console.log(JSON.stringify(record, null, 2));
</code></pre>
<p>The checks verify a valid record and six rejection cases: an invalid interval, invalid width, invalid frame rate, an unreviewed boolean represented as text, an empty file, and a missing file. They confirm that the bookkeeping behaves as intended. They say nothing about palette handling, compression efficiency, or visual quality.</p>
<p>Keeping that distinction explicit lets you test the integration without installing an encoder or claiming a conversion benchmark you have not run. Media validation and visual review need their own real input and output files.</p>
<h2>Compare exports without inventing a quality score</h2>
<p>Store one record per candidate and give each output a distinct filename. For example, experiment labels can identify a smaller width or a lower frame rate while preserving the source interval. The candidate names are for traceability; their meaning should also appear in the structured settings.</p>
<p>When a destination has a byte limit, first remove candidates above that limit. Then compare the remaining outputs for readable content and a usable seam. The smallest file is not automatically the best result, and a higher requested frame rate is not automatically the best use of the available bytes.</p>
<p>If every candidate fails, revisit the selected source moment or the delivery format instead of endlessly changing settings. Keep the source MP4 and the export notes together so a future revision starts from the original rather than from an already exported GIF.</p>
<h2>Hand off both the asset and the decision</h2>
<p>The recipient needs to know which file is intended for use, which source produced it, and whether an outstanding issue remains. A record with seamOK set to false is useful when it accurately marks work that still needs attention. Do not silently convert every review flag to true to make a report look complete.</p>
<p>The resulting handoff is small: the selected GIF, its manifest, and the source reference. It turns “which settings did we use?” into an answer you can inspect, while leaving visual judgment with the person who actually viewed the output.</p>
<p>Adapted from <a href="https://www.pixmind.io/posts/mp4-to-gif">PixMind: MP4 to GIF</a>. This version adds a standalone Node.js export-record workflow and synthetic filesystem checks instead of repeating a converter walkthrough.</p>
]]></content:encoded></item></channel></rss>