mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
feat(cli): add agent-first media treatment tools
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user