mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-06 22:57:12 +00:00
* feat(cli): move media-use engine into the CLI * fix(cli): replace media-use compatibility asset * fix(cli): replace moved media-use tree * fix(cli): copy moved media-use files cleanly * fix(cli): copy media-use engine files individually * fix(cli): keep audio helper compatibility import * fix(cli): clear copied skill destinations * test(cli): preserve moved media-use test paths * fix(cli): preserve media-use script compatibility imports * fix(cli): preserve media-use telemetry shim * fix(cli): resolve packaged media-use LUT assets * fix(cli): resolve packaged bundled SFX assets * chore(skills): regenerate media-use manifest * fix(cli): preserve media-use parity coverage * fix(cli): cover relocated media-use tests * chore(skills): regenerate media-use manifest * fix(cli): format bundled media provider * fix(ci): run relocated media-use tests with node * fix(ci): preserve fallow script exemptions * fix(ci): scope media-use fallow exemption * refactor(cli): trim media-use essay comments * feat(cli): index media-use assets for catalog search * feat(catalog): commit media-use search vectors * chore(catalog): track media vector metadata * fix(catalog): validate media vector row order * fix(catalog): validate media artifact freshness * fix(catalog): cover media metadata and credits * fix(cli): reject malformed media rows * fix(catalog): compare complete media rows * feat(cli): route media search through media-use * refactor(cli): trim relocated media-use comments * fix(cli): make media search runtime-safe * fix(cli): preserve sfx fallback diagnostics * fix(cli): probe sfx fallback provider explicitly * chore(skills): regenerate manifest * refactor(cli): keep typed media ranker source * fix(cli): align media search checks with local-first flow * fix(cli): scope vector audit exceptions * fix(cli): ship resolver registry beside skill * fix(cli): keep media-use skill scripts standalone * test(cli): cover local vector validation * test(cli): correct media vector fixtures * fix(cli): prefer exact media keys * fix(cli): stem exact media keys * test(cli): skip media lint for snippets without media * fix(cli): align skills test discovery Keep the CI skills job's bash discovery as the single source of truth so its empty-list guard covers the exact files Node executes. Make hasMediaVectors private because it has no external callers and needs no Fallow suppression. * fix(cli): close media-use review gaps Prioritize literal ids before stemmed ids and cover the collision alongside the plural regression. Add command seam tests for engine discovery, actionable failure, and argv passthrough. Add a deterministic parity check for standalone media-use copies, with documented allowlists for the standalone media-fetch shim and self-contained npx resolver. Keep the bash-discovered skills test list as the CI source of truth. * fix(cli): lower parity check complexity Split missing-copy and unallowlisted-drift checks so the parity guard keeps its behavior without a threshold CRAP score. * fix(cli): close media-use review seams Run the parity test in the skills job so skills-only edits exercise the same check. Export the verb mapping and inject command dispatch for direct coverage. Keep missing-copy diagnostics separate from drift reads.
231 lines
6.5 KiB
JavaScript
231 lines
6.5 KiB
JavaScript
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;
|
|
}
|