MOSS-Layout-Studio / layout.mjs
alphabell-research's picture
Launch the reference-checked MOSS layout editor (#1)
1210c33
Raw History Blame Contribute Delete
14.7 kB
/*
* alphaBell MOSS Layout Studio geometry and schema helpers.
* Draft validation and export follow OpenMOSS/MOSS-Image compile_prompt.py,
* revision 52b87fca23acedc399d062d593bec388aee9926a (Apache-2.0).
* This browser implementation adds import, editing and local precision checks.
*/
export const SOURCE_REVISION = '52b87fca23acedc399d062d593bec388aee9926a';
export const SOURCE_URL = `https://github.com/OpenMOSS/MOSS-Image/blob/${SOURCE_REVISION}/skills/moss-image-prompt-enhancer/scripts/compile_prompt.py`;
export class LayoutError extends Error {
constructor(message, path = '') { super(path ? `${path}: ${message}` : message); this.name = 'LayoutError'; this.path = path; }
}
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
const fail = (condition, message, path = '') => { if (!condition) throw new LayoutError(message, path); };
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const copy = value => JSON.parse(JSON.stringify(value));
const NUMBER_SYNTAX = Symbol('JSON number syntax');
function fields(value, allowed, required, path) {
fail(object(value), 'must be an object', path);
const extra = Object.keys(value).filter(key => !allowed.includes(key));
fail(!extra.length, `unknown fields ${extra.join(', ')}`, path);
const missing = required.filter(key => !own(value, key));
fail(!missing.length, `missing fields ${missing.join(', ')}`, path);
}
// Match Python str.strip(), including NEL and the information separators.
const PYTHON_WHITESPACE = /^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*$/;
function text(value, path) { fail(typeof value === 'string' && !PYTHON_WHITESPACE.test(value), 'must be nonempty text', path); }
function strings(value, path) {
fail(Array.isArray(value), 'must be an array', path);
value.forEach((entry, i) => text(entry, `${path}[${i}]`));
}
export function validateDimensions(width, height) {
for (const [name, value] of [['width', width], ['height', height]]) {
fail(Number.isInteger(value) && value > 0 && value % 16 === 0, 'must be a positive integer multiple of 16', name);
fail(Number.isSafeInteger(value), 'exceeds the browser editor\'s safe integer precision', name);
}
return { width, height };
}
// Python's round(value, 9) uses the actual binary float and ties-to-even.
// Integer arithmetic avoids the decimal boundary drift of Math.round/toFixed.
function roundNine(value) {
if (value === 0) return value;
const buffer = new ArrayBuffer(8), view = new DataView(buffer);
view.setFloat64(0, Math.abs(value), false);
const bits = view.getBigUint64(0, false);
const exponent = Number((bits >> 52n) & 2047n);
const mantissa = (bits & ((1n << 52n) - 1n)) | (exponent ? 1n << 52n : 0n);
const shift = (exponent || 1) - 1075;
let numerator = mantissa * 1000000000n, denominator = 1n;
if (shift >= 0) numerator <<= BigInt(shift); else denominator <<= BigInt(-shift);
let quotient = numerator / denominator;
const remainder = numerator % denominator;
if (remainder * 2n > denominator || (remainder * 2n === denominator && quotient % 2n !== 0n)) quotient++;
return Number(`${value < 0 ? '-' : ''}${quotient}e-9`);
}
function checkedRect(rect, width, height, path = 'bbox') {
validateDimensions(width, height);
fail(Array.isArray(rect) && rect.length === 4, 'must contain four edges [x1, y1, x2, y2]', path);
fail(rect.every(value => typeof value === 'number' && Number.isFinite(value)), 'must contain four finite numbers', path);
const [x1, y1, x2, y2] = rect;
fail(x1 < x2 && y1 < y2, 'must have positive width and height', path);
fail(x1 >= 0 && y1 >= 0 && x2 <= width && y2 <= height, `exceeds the ${width} x ${height} canvas`, path);
return rect;
}
export function toCentralBox(rect, width, height) {
const edges = Array.isArray(rect) ? rect : [rect.x, rect.y, rect.x + rect.w, rect.y + rect.h];
const [x1, y1, x2, y2] = checkedRect(edges, width, height);
const result = [Math.floor(roundNine(y1)) - height / 2, Math.floor(roundNine(x1)) - width / 2,
Math.ceil(roundNine(y2)) - height / 2, Math.ceil(roundNine(x2)) - width / 2];
fail(result[0] < result[2] && result[1] < result[3], 'is too small at export precision', 'bbox');
return result;
}
export function fromCentralBox(box, width, height) {
validateDimensions(width, height);
fail(Array.isArray(box) && box.length === 4 && box.every(x => typeof x === 'number' && Number.isFinite(x)), 'must contain four finite numbers', 'bbox');
const [y1, x1, y2, x2] = box;
checkedRect([x1 + width / 2, y1 + height / 2, x2 + width / 2, y2 + height / 2], width, height);
return { x: x1 + width / 2, y: y1 + height / 2, w: x2 - x1, h: y2 - y1 };
}
export function toNormalizedRect(rect, width, height) {
checkedRect([rect.x, rect.y, rect.x + rect.w, rect.y + rect.h], width, height);
return { x: rect.x / width, y: rect.y / height, w: rect.w / width, h: rect.h / height };
}
export function fromNormalizedRect(rect, width, height) {
const right = rect.x + rect.w, bottom = rect.y + rect.h;
// Editing arithmetic can add a few ulps at a canvas edge. Snap only this
// normalized helper's tiny arithmetic spill, never imported JSON geometry.
const boundedRight = right > 1 && right <= 1 + 2 * Number.EPSILON ? 1 : right;
const boundedBottom = bottom > 1 && bottom <= 1 + 2 * Number.EPSILON ? 1 : bottom;
const x = rect.x * width, y = rect.y * height;
const result = { x, y, w: boundedRight * width - x, h: boundedBottom * height - y };
checkedRect([result.x, result.y, result.x + result.w, result.y + result.h], width, height);
return result;
}
function checkSummary(summary) {
fields(summary, ['caption', 'meta', 'canvas', 'palette'], ['caption'], 'summary');
text(summary.caption, 'summary.caption');
if (own(summary, 'canvas')) fail(['single', 'multi_panel', 'collage'].includes(summary.canvas), 'must be single, multi_panel or collage', 'summary.canvas');
for (const key of ['meta', 'palette']) if (own(summary, key)) strings(summary[key], `summary.${key}`);
}
function checkItems(items, width, height, centralized = false) {
fail(Array.isArray(items) && items.length > 0, 'must be a nonempty flat array', 'items');
return items.map((item, i) => {
const path = `items[${i}]`;
fields(item, ['label', 'caption', 'bbox', 'text', 'tags', 'palette'], ['label', 'caption', 'bbox'], path);
fail(['object', 'character', 'text'].includes(item.label), 'must be object, character or text', `${path}.label`);
text(item.caption, `${path}.caption`);
if (item.label === 'text') fail(own(item, 'text'), 'text items require literal text', path);
if (own(item, 'text')) text(item.text, `${path}.text`);
for (const key of ['tags', 'palette']) if (own(item, key)) strings(item[key], `${path}.${key}`);
try {
const bbox = centralized ? item.bbox : toCentralBox(checkedRect(item.bbox, width, height, `${path}.bbox`), width, height);
if (centralized) fromCentralBox(bbox, width, height);
return { ...copy(item), bbox: [...bbox] };
} catch (error) { throw new LayoutError(error.message.replace(/^bbox: /, ''), `${path}.bbox`); }
});
}
export function compileDraft(draft) {
const keys = ['width', 'height', 'bbox_coord_space', 'bbox_format', 'summary', 'items'];
fields(draft, keys, keys, 'Layout draft');
const { width, height } = draft;
for (const key of ['width', 'height']) if (draft[NUMBER_SYNTAX]?.[key] === 'float') throw new LayoutError('must use an integer JSON literal, without a decimal point or exponent', key);
validateDimensions(width, height);
fail(draft.bbox_coord_space === 'abs', 'must be abs', 'bbox_coord_space');
fail(draft.bbox_format === 'xyxy', 'must be xyxy', 'bbox_format');
checkSummary(draft.summary);
return { summary: copy(draft.summary), items: checkItems(draft.items, width, height), bbox_coord_space: 'abs_centralized', bbox_format: 'yxyx' };
}
export function validateDraft(draft) {
try {
compileDraft(draft);
const pixels = draft.width * draft.height;
const warnings = [];
if (pixels < 512 * 512 || pixels > 4 * 1024 * 1024) warnings.push('This canvas is outside the 0.25M to 4M pixel extrapolation range described in the upstream user guide. This is guidance, not a layout schema rule.');
return { valid: true, errors: [], warnings };
} catch (error) { return { valid: false, errors: [error.message], warnings: [] }; }
}
export function makeDraft(state) {
const summary = { caption: state.caption };
for (const key of ['meta', 'canvas', 'palette']) if (own(state, key) && state[key] !== undefined) summary[key] = copy(state[key]);
const items = state.items.map(item => {
const result = { label: item.label, caption: item.caption, bbox: [item.x, item.y, item.x + item.w, item.y + item.h] };
for (const key of ['text', 'tags', 'palette']) if (own(item, key) && item[key] !== undefined) result[key] = copy(item[key]);
return result;
});
const draft = { width: state.width, height: state.height, bbox_coord_space: 'abs', bbox_format: 'xyxy', summary, items };
compileDraft(draft);
return draft;
}
export function makeLayout(state) { return compileDraft(makeDraft(state)); }
// Reject duplicate keys instead of letting JSON.parse silently discard content.
export function parseStrictJSON(source) {
fail(typeof source === 'string', 'input must be JSON text');
let index = 0;
const skip = () => { while (/[\t\r\n ]/.test(source[index] || '\u0000')) index++; };
function string() {
const start = index++;
while (index < source.length) {
const char = source[index++];
if (char === '\\') { index++; continue; }
if (char === '"') return JSON.parse(source.slice(start, index));
}
throw new LayoutError('Unterminated JSON string');
}
function value() {
skip();
if (source[index] === '"') return string();
if (source[index] === '{') {
index++; skip(); const result = {}, seen = new Set();
if (source[index] === '}') { index++; return result; }
while (true) {
skip(); fail(source[index] === '"', 'Expected a JSON object key');
const key = string(); fail(!seen.has(key), `Duplicate JSON key: ${key}`); seen.add(key);
skip(); fail(source[index++] === ':', 'Expected a colon after JSON key');
skip(); const valueStart = index, child = value();
const token = source.slice(valueStart, index);
if (typeof child === 'number' && /[.eE]/.test(token)) {
if (!result[NUMBER_SYNTAX]) Object.defineProperty(result, NUMBER_SYNTAX, { value: Object.create(null) });
result[NUMBER_SYNTAX][key] = 'float';
}
Object.defineProperty(result, key, { value: child, enumerable: true, writable: true, configurable: true });
skip(); const separator = source[index++];
if (separator === '}') return result;
fail(separator === ',', 'Expected a comma or closing object brace');
}
}
if (source[index] === '[') {
index++; skip(); const result = [];
if (source[index] === ']') { index++; return result; }
while (true) {
result.push(value()); skip(); const separator = source[index++];
if (separator === ']') return result;
fail(separator === ',', 'Expected a comma or closing array bracket');
}
}
const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(source.slice(index));
fail(match, 'Expected a JSON value'); index += match[0].length;
const result = JSON.parse(match[0]);
fail(typeof result !== 'number' || Number.isFinite(result), 'JSON number exceeds browser numeric precision');
return result;
}
try { const result = value(); skip(); fail(index === source.length, 'Unexpected content after JSON value'); return result; }
catch (error) { if (error instanceof LayoutError) throw error; throw new LayoutError(`Invalid JSON: ${error.message}`); }
}
export function parseLayout(json, dimensions = {}) {
const source = typeof json === 'string' ? parseStrictJSON(json) : copy(json);
fail(object(source), 'Layout must be a JSON object');
let width, height, summary, items;
if (source.bbox_coord_space === 'abs') {
compileDraft(source);
({ width, height, summary, items } = source);
items = items.map(item => ({ ...item, rect: { x: item.bbox[0], y: item.bbox[1], w: item.bbox[2] - item.bbox[0], h: item.bbox[3] - item.bbox[1] } }));
} else {
const keys = ['summary', 'items', 'bbox_coord_space', 'bbox_format'];
fields(source, keys, keys, 'Compiled layout');
fail(source.bbox_coord_space === 'abs_centralized', 'must be abs or abs_centralized', 'bbox_coord_space');
fail(source.bbox_format === 'yxyx', 'must be yxyx for a compiled layout', 'bbox_format');
({ width, height } = dimensions); validateDimensions(width, height);
checkSummary(source.summary); summary = source.summary;
items = checkItems(source.items, width, height, true).map(item => ({ ...item, rect: fromCentralBox(item.bbox, width, height) }));
}
const state = { width, height, caption: summary.caption, items: [] };
for (const key of ['meta', 'canvas', 'palette']) if (own(summary, key)) state[key] = copy(summary[key]);
state.items = items.map((item, index) => {
const result = { id: `item-${index + 1}`, label: item.label, caption: item.caption, ...item.rect };
for (const key of ['text', 'tags', 'palette']) if (own(item, key)) result[key] = copy(item[key]);
return result;
});
return state;
}
// This is an editor interaction policy, not an upstream compiler transformation.
export function clampRect(rect, width, height, minimum = 1) {
validateDimensions(width, height);
fail([rect.x, rect.y, rect.w, rect.h, minimum].every(Number.isFinite), 'Editor rectangle must contain finite numbers');
fail(minimum > 0, 'Editor minimum size must be positive');
const w = Math.min(width, Math.max(Math.min(minimum, width), rect.w));
const h = Math.min(height, Math.max(Math.min(minimum, height), rect.h));
return { x: Math.max(0, Math.min(width - w, rect.x)), y: Math.max(0, Math.min(height - h, rect.y)), w, h };
}
export function resizeLayout(state, width, height, { scale = true } = {}) {
makeDraft(state); validateDimensions(width, height);
const result = copy(state);
result.items = state.items.map(item => ({ ...copy(item), ...(scale
? fromNormalizedRect(toNormalizedRect(item, state.width, state.height), width, height)
: clampRect(item, width, height)) }));
result.width = width; result.height = height;
makeDraft(result);
return result;
}