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;
}