feat(cli): add agent-first media treatment tools

This commit is contained in:
ukimsanov
2026-07-24 18:42:07 -07:00
parent e710a1686f
commit 4582881d00
28 changed files with 3402 additions and 62 deletions
@@ -0,0 +1,230 @@
export const ERROR_DIFFUSION_ALGORITHMS = {
"floyd-steinberg": {
kernel: [
[1, 0, 7],
[-1, 1, 3],
[0, 1, 5],
[1, 1, 1],
],
divisor: 16,
},
atkinson: {
kernel: [
[1, 0, 1],
[2, 0, 1],
[-1, 1, 1],
[0, 1, 1],
[1, 1, 1],
[0, 2, 1],
],
divisor: 8,
},
"jarvis-judice-ninke": {
kernel: [
[1, 0, 7],
[2, 0, 5],
[-2, 1, 3],
[-1, 1, 5],
[0, 1, 7],
[1, 1, 5],
[2, 1, 3],
[-2, 2, 1],
[-1, 2, 3],
[0, 2, 5],
[1, 2, 3],
[2, 2, 1],
],
divisor: 48,
},
stucki: {
kernel: [
[1, 0, 8],
[2, 0, 4],
[-2, 1, 2],
[-1, 1, 4],
[0, 1, 8],
[1, 1, 4],
[2, 1, 2],
[-2, 2, 1],
[-1, 2, 2],
[0, 2, 4],
[1, 2, 2],
[2, 2, 1],
],
divisor: 42,
},
burkes: {
kernel: [
[1, 0, 8],
[2, 0, 4],
[-2, 1, 2],
[-1, 1, 4],
[0, 1, 8],
[1, 1, 4],
[2, 1, 2],
],
divisor: 32,
},
sierra: {
kernel: [
[1, 0, 5],
[2, 0, 3],
[-2, 1, 2],
[-1, 1, 4],
[0, 1, 5],
[1, 1, 4],
[2, 1, 2],
[-1, 2, 2],
[0, 2, 3],
[1, 2, 2],
],
divisor: 32,
},
"sierra-lite": {
kernel: [
[1, 0, 2],
[-1, 1, 1],
[0, 1, 1],
],
divisor: 4,
},
"two-row-sierra": {
kernel: [
[1, 0, 4],
[2, 0, 3],
[-2, 1, 1],
[-1, 1, 2],
[0, 1, 3],
[1, 1, 2],
[2, 1, 1],
],
divisor: 16,
},
};
const DEFAULTS = {
algorithm: "floyd-steinberg",
brightness: 1,
contrast: 1.2,
detail: 1,
palette: ["#000000", "#ffffff"],
pointSize: 3,
};
export function errorDiffusionBufferLength(width, height, pointSize) {
return Math.ceil(width / pointSize) * Math.ceil(height / pointSize) * 3;
}
export function applyErrorDiffusionRgba(data, width, height, options = {}, errorBuffer) {
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
throw new Error("width and height must be positive integers");
}
if (!data || data.length !== width * height * 4) {
throw new Error(`RGBA data must contain ${width * height * 4} bytes`);
}
const algorithm = options.algorithm ?? DEFAULTS.algorithm;
const diffusion = ERROR_DIFFUSION_ALGORITHMS[algorithm];
if (!diffusion) throw new Error(`unknown error-diffusion algorithm: ${algorithm}`);
const pointSize = integerInRange(options.pointSize ?? DEFAULTS.pointSize, 1, 20, "pointSize");
const brightness = numberInRange(options.brightness ?? DEFAULTS.brightness, 0.5, 2, "brightness");
const contrast = numberInRange(options.contrast ?? DEFAULTS.contrast, 0.5, 2, "contrast");
const detail = numberInRange(options.detail ?? DEFAULTS.detail, 0.1, 1, "detail");
const palette = parsePalette(options.palette ?? DEFAULTS.palette);
const blockColumns = Math.ceil(width / pointSize);
const blockRows = Math.ceil(height / pointSize);
const errorLength = errorDiffusionBufferLength(width, height, pointSize);
const errors = errorBuffer ?? new Float32Array(errorLength);
if (!(errors instanceof Float32Array) || errors.length !== errorLength) {
throw new Error(`errorBuffer must be a Float32Array of length ${errorLength}`);
}
errors.fill(0);
const centerOffset = Math.floor(pointSize / 2);
for (let blockRow = 0; blockRow < blockRows; blockRow++) {
const blockY = blockRow * pointSize;
for (let blockColumn = 0; blockColumn < blockColumns; blockColumn++) {
const blockX = blockColumn * pointSize;
const centerX = Math.min(blockX + centerOffset, width - 1);
const centerY = Math.min(blockY + centerOffset, height - 1);
const rgbaIndex = (centerY * width + centerX) * 4;
const errorIndex = (blockRow * blockColumns + blockColumn) * 3;
const red = correctedChannel(data[rgbaIndex], errors[errorIndex], brightness, contrast);
const green = correctedChannel(
data[rgbaIndex + 1],
errors[errorIndex + 1],
brightness,
contrast,
);
const blue = correctedChannel(
data[rgbaIndex + 2],
errors[errorIndex + 2],
brightness,
contrast,
);
const luminance = 0.299 * red + 0.587 * green + 0.114 * blue;
const output = palette[Math.min(palette.length - 1, Math.floor(luminance * palette.length))];
for (let y = blockY; y < Math.min(blockY + pointSize, height); y++) {
for (let x = blockX; x < Math.min(blockX + pointSize, width); x++) {
const outputIndex = (y * width + x) * 4;
data[outputIndex] = Math.round(output[0] * 255);
data[outputIndex + 1] = Math.round(output[1] * 255);
data[outputIndex + 2] = Math.round(output[2] * 255);
}
}
for (const [dx, dy, weight] of diffusion.kernel) {
const targetColumn = blockColumn + dx;
const targetRow = blockRow + dy;
if (
targetColumn < 0 ||
targetColumn >= blockColumns ||
targetRow < 0 ||
targetRow >= blockRows
) {
continue;
}
const target = (targetRow * blockColumns + targetColumn) * 3;
const scale = (weight / diffusion.divisor) * detail;
errors[target] += (red - output[0]) * scale;
errors[target + 1] += (green - output[1]) * scale;
errors[target + 2] += (blue - output[2]) * scale;
}
}
}
return data;
}
function correctedChannel(byte, error, brightness, contrast) {
return Math.min(1, Math.max(0, ((byte / 255 - 0.5) * contrast + 0.5) * brightness + error));
}
function parsePalette(colors) {
if (!Array.isArray(colors) || colors.length < 2 || colors.length > 6) {
throw new Error("palette must contain 2 to 6 colors");
}
return colors.map((color) => {
const match = /^#([0-9a-f]{6})$/i.exec(color);
if (!match) throw new Error(`palette color must use #rrggbb: ${color}`);
const value = Number.parseInt(match[1], 16);
return [(value >> 16) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
});
}
function numberInRange(value, min, max, name) {
const number = Number(value);
if (!Number.isFinite(number) || number < min || number > max) {
throw new Error(`${name} must be between ${min} and ${max}`);
}
return number;
}
function integerInRange(value, min, max, name) {
const number = Number(value);
if (!Number.isInteger(number) || number < min || number > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}`);
}
return number;
}
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ERROR_DIFFUSION_ALGORITHMS, applyErrorDiffusionRgba } from "./error-diffusion.mjs";
const EXPECTED_GRADIENTS = {
"floyd-steinberg": "00000101/00010101/00100101/00010111/01010101/01011011",
atkinson: "00000011/00001100/00010011/00010111/01001101/00111011",
"jarvis-judice-ninke": "00000011/00001011/00011001/00101111/00100111/01011011",
stucki: "00000011/00010101/00010110/00101011/00101101/01010111",
burkes: "00000101/00010011/00010110/00101011/01010111/00101011",
sierra: "00000011/00010101/00010110/00100111/00110111/00101101",
"sierra-lite": "00000101/00010101/00100101/00010110/01010111/01010101",
"two-row-sierra": "00000101/00010011/00010110/00101011/00101101/01011011",
};
test("exposes the eight article error-diffusion algorithms", () => {
assert.deepEqual(Object.keys(ERROR_DIFFUSION_ALGORITHMS), Object.keys(EXPECTED_GRADIENTS));
});
test("matches deterministic golden patterns for every diffusion kernel", () => {
const width = 8;
const height = 6;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const value = Math.round((255 * (x + y * 0.7)) / (width - 1 + (height - 1) * 0.7));
const offset = (y * width + x) * 4;
source[offset] = value;
source[offset + 1] = Math.round(value * 0.8);
source[offset + 2] = Math.round(value * 0.55);
source[offset + 3] = 17 + x + y;
}
}
for (const [algorithm, expected] of Object.entries(EXPECTED_GRADIENTS)) {
const output = source.slice();
applyErrorDiffusionRgba(output, width, height, {
algorithm,
brightness: 1,
contrast: 1,
detail: 1,
palette: ["#000000", "#ffffff"],
pointSize: 1,
});
const rows = [];
for (let y = 0; y < height; y++) {
let row = "";
for (let x = 0; x < width; x++) row += output[(y * width + x) * 4] ? "1" : "0";
rows.push(row);
}
assert.equal(rows.join("/"), expected, algorithm);
for (let i = 0; i < width * height; i++) assert.equal(output[i * 4 + 3], source[i * 4 + 3]);
}
});
test("fills point-size blocks from their center sample", () => {
const data = new Uint8ClampedArray([
0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 5, 255, 255, 255, 6, 0, 0, 0, 7, 255,
255, 255, 8,
]);
applyErrorDiffusionRgba(
data,
4,
2,
{
algorithm: "floyd-steinberg",
brightness: 1,
contrast: 1,
detail: 1,
palette: ["#000000", "#ffffff"],
pointSize: 2,
},
new Float32Array(6),
);
assert.deepEqual(
[...data],
[
255, 255, 255, 1, 255, 255, 255, 2, 255, 255, 255, 3, 255, 255, 255, 4, 255, 255, 255, 5, 255,
255, 255, 6, 255, 255, 255, 7, 255, 255, 255, 8,
],
);
});
test("preserves authored palette order and validates the public contract", () => {
const reversed = new Uint8ClampedArray([0, 0, 0, 255]);
applyErrorDiffusionRgba(reversed, 1, 1, {
palette: ["#ffffff", "#000000"],
});
assert.deepEqual([...reversed], [255, 255, 255, 255]);
assert.throws(
() => applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, { palette: ["#000000"] }),
/2 to 6 colors/,
);
assert.throws(
() =>
applyErrorDiffusionRgba(new Uint8ClampedArray(4), 1, 1, {
algorithm: "ordered-bayer",
}),
/unknown error-diffusion algorithm/,
);
});
@@ -27,6 +27,11 @@ export const RESOLVABLE_PRESET_IDS = [
"soft-boost",
"bright-pop",
"deep-contrast",
"creator-camcorder",
"vhs-playback",
"home-movie-8mm",
"editorial-halftone",
"two-ink-print",
];
const PRESET_SYNONYMS = {
@@ -76,6 +81,11 @@ const PRESET_SYNONYMS = {
"clean colorful",
],
"deep-contrast": ["deep contrast", "high contrast punchy", "punchy contrast", "bold contrast"],
"creator-camcorder": ["creator camcorder", "creator video", "ugc camera", "handheld creator"],
"vhs-playback": ["vhs playback", "vhs tape", "analog tape", "degraded tape"],
"home-movie-8mm": ["8mm home movie", "8mm film", "family film", "small gauge film"],
"editorial-halftone": ["editorial halftone", "halftone", "print dots", "newsprint"],
"two-ink-print": ["two ink print", "two ink editorial", "duotone print", "poster print"],
};
function presetCandidates() {
@@ -122,10 +132,8 @@ export function matchColorLook(intent) {
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
for (const candidate of presetCandidates()) {
if (candidate.preset === normalized) {
return { kind: "preset", preset: candidate.preset, score: 99 };
}
if (RESOLVABLE_PRESET_IDS.includes(normalized)) {
return { kind: "preset", preset: normalized, score: 99 };
}
const candidates = [...presetCandidates(), ...libraryCandidates()]
@@ -42,6 +42,11 @@ test("removed preset phrases resolve to surviving looks", () => {
assert.equal(matchColorLook("cool clean").preset, "clean-studio");
});
test("complete-filter intent aliases resolve deterministically", () => {
assert.equal(matchColorLook("analog tape").preset, "vhs-playback");
assert.equal(matchColorLook("creator video").preset, "creator-camcorder");
});
test("library look freezes a validated cube from params offline (--local-only)", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "mu-lut-provider-"));
try {