Spaces:
Running
Running
File size: 14,668 Bytes
1210c33 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 | /*
* 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;
}
|