alphabell-research commited on
Commit
1210c33
·
1 Parent(s): 8730f06

Launch the reference-checked MOSS layout editor (#1)

Browse files

- Launch the reference-checked MOSS layout editor (9128b4788a5a1e021a6f5310a6c7e59c98291729)

.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ preview.png filter=lfs diff=lfs merge=lfs -text
.github/workflows/checks.yml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Checks
2
+ on:
3
+ push:
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ verify:
9
+ runs-on: ubuntu-latest
10
+ timeout-minutes: 15
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: '22'
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: '3.11'
19
+ - name: Validate geometry and schema
20
+ run: node --test tests/layout.test.mjs
21
+ - name: Compare the pinned upstream compiler
22
+ run: python3 tests/reference_check.py
23
+ - name: Install browser test dependencies
24
+ run: |
25
+ python3 -m pip install -r requirements-dev.txt
26
+ python3 -m playwright install --with-deps chromium
27
+ - name: Exercise the browser editor
28
+ run: python3 verify.py
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .cache/
5
+ qa-output/
6
+ test-results/
7
+ playwright-report/
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
NOTICE ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ MOSS Layout Studio by alphaBell.
2
+
3
+ The layout compiler behavior is adapted from OpenMOSS/MOSS-Image,
4
+ revision 52b87fca23acedc399d062d593bec388aee9926a, licensed under Apache-2.0.
5
+ The unchanged reference compiler is included only in tests/vendor/compile_prompt.py.
6
+ The JavaScript module is a browser adaptation with import, editing and validation
7
+ helpers. OpenMOSS does not endorse this independent project.
8
+
9
+ Source:
10
+ https://github.com/OpenMOSS/MOSS-Image/blob/52b87fca23acedc399d062d593bec388aee9926a/skills/moss-image-prompt-enhancer/scripts/compile_prompt.py
README.md CHANGED
@@ -1,10 +1,82 @@
1
  ---
2
  title: MOSS Layout Studio
3
- emoji: 🐨
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: static
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: MOSS Layout Studio
3
+ emoji: 🧩
4
+ colorFrom: gray
5
+ colorTo: purple
6
  sdk: static
7
+ app_file: index.html
8
+ pinned: true
9
+ thumbnail: https://huggingface.co/spaces/alphabell/MOSS-Layout-Studio/resolve/main/thumbnail.png
10
+ license: apache-2.0
11
+ models:
12
+ - OpenMOSS-Team/MOSS-Image-Base
13
+ tags:
14
+ - moss-image
15
+ - layout
16
+ - text-to-image
17
+ - prompt-engineering
18
+ - debugging
19
+ short_description: Draw layouts and export MOSS-Image prompt JSON
20
  ---
21
 
22
+ # MOSS Layout Studio
23
+
24
+ Draw a poster, place exact text, and export the JSON layout for MOSS-Image-Base's experimental layout prompts. An independent alphaBell project.
25
+
26
+ **[Try MOSS Layout Studio](https://huggingface.co/spaces/alphabell/MOSS-Layout-Studio)** · [Source and checks](https://github.com/alphabell-research/moss-layout-studio)
27
+
28
+ ![MOSS Layout Studio with an editable travel poster, exact text boxes and a centered model-coordinate readout.](preview.png)
29
+
30
+ Move and resize text, object and character boxes on a canvas. Keep Chinese copy, emoji and intended line breaks intact. Import an existing layout to edit it, or start from a composition preset.
31
+
32
+ The editor runs in your browser. It prepares layouts without model inference. Its wireframe shows placement and text, rather than a generated image. Drafts remain in memory until you download them; there is no autosave, analytics or uploaded prompt content.
33
+
34
+ ## Two exports for two jobs
35
+
36
+ **Editable draft** keeps the canvas `width` and `height`, with top-left-origin absolute-pixel boxes in `[x_min, y_min, x_max, y_max]` order. Save this format to keep editing.
37
+
38
+ **Model prompt** uses center-origin absolute pixels in `[y_min, x_min, y_max, x_max]` order. It omits dimensions, following the upstream compiler. Use it with the same output width and height in your MOSS-Image generation settings.
39
+
40
+ For example, on a 1536 by 1024 canvas, a cup occupying `[176, 224, 800, 864]` in the draft becomes `[-288, -592, 352, 32]` in the model prompt. Fractional draft edges are enclosed at pixel boundaries before the canvas center is subtracted.
41
+
42
+ When importing a model prompt, choose its original canvas dimensions first. An editable draft carries its own dimensions. Resizing the editor canvas scales the composition when **Scale boxes with the canvas** is selected. With it off, pixel positions stay fixed; a resize that would put boxes outside the canvas is rejected. Changing dimensions in an upstream draft alone does not rescale its boxes.
43
+
44
+ Known optional metadata, palettes and item tags survive import and export. Unknown fields and duplicate JSON keys produce an error instead of silently discarding content. Editor IDs stay out of both exports. Overlapping items are allowed.
45
+
46
+ ## Format reference
47
+
48
+ The coordinate convention follows the official [MOSS-Image user guide](https://github.com/OpenMOSS/MOSS-Image/blob/52b87fca23acedc399d062d593bec388aee9926a/USERGUIDE.md#layout-prompts-experimental). Editable-draft validation and compilation follow the [layout reference](https://github.com/OpenMOSS/MOSS-Image/blob/52b87fca23acedc399d062d593bec388aee9926a/skills/moss-image-prompt-enhancer/references/layout.md) and [compiler](https://github.com/OpenMOSS/MOSS-Image/blob/52b87fca23acedc399d062d593bec388aee9926a/skills/moss-image-prompt-enhancer/scripts/compile_prompt.py) at revision `52b87fca23acedc399d062d593bec388aee9926a`.
49
+
50
+ Dimensions must be positive integer multiples of 16. Resolution recommendations appear separately from format validation. The browser editor additionally requires dimensions within JavaScript's safe integer precision. Layout adherence and text rendering are properties of the image model and generation settings; this tool checks the layout representation.
51
+
52
+ The visual editor accepts canvas edges up to 8192 px and imported layouts with up to 200 elements in files smaller than 1 MB. These are interactive-editor limits, separate from the upstream schema and your generation hardware's capacity.
53
+
54
+ ## Run and verify
55
+
56
+ Serve this directory with a static HTTP server, then open `index.html`.
57
+
58
+ ```sh
59
+ python3 -m http.server 8000
60
+ ```
61
+
62
+ The test suite needs Node.js and Python 3, with no model weights or third-party Python packages:
63
+
64
+ ```sh
65
+ node --test tests/layout.test.mjs
66
+ python3 tests/reference_check.py
67
+ ```
68
+
69
+ For the browser checks, install Playwright and Chromium, then run:
70
+
71
+ ```sh
72
+ python3 -m pip install -r requirements-dev.txt
73
+ python3 verify.py
74
+ ```
75
+
76
+ The browser suite covers all three presets, exact JSON round trips and downloads, keyboard movement and resizing, real touch dragging and resizing, mobile layout, undo and redo, strict imports, literal HTML text, clipboard denial and outbound network isolation. It also accepts `--base-url` to check a hosted static deployment.
77
+
78
+ The reference check compares 494 accepted and rejected draft inputs with an unchanged, hash-checked copy of the pinned upstream Python compiler. It covers non-square canvases, random layouts, edge rounding, Unicode, optional metadata, invalid geometry, duplicate keys and integer-literal dimensions. The separate module tests exercise import/export and editor resize behavior.
79
+
80
+ ## License
81
+
82
+ Apache-2.0. See `LICENSE` and `NOTICE` for the upstream compiler reference and browser adaptation.
app.js ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { makeDraft, makeLayout, parseLayout, toCentralBox, resizeLayout, validateDraft } from './layout.mjs';
2
+
3
+ const $ = id => document.getElementById(id);
4
+ const copy = value => JSON.parse(JSON.stringify(value));
5
+ const examples = {
6
+ poster: {
7
+ width: 1024, height: 1024, meta: ['travel poster'], canvas: 'single',
8
+ caption: 'A minimalist travel poster on warm cream paper. A bold dark-blue title sits above a flat blue mountain illustration. A small Japanese subtitle anchors the lower edge. Generous negative space, clean geometric shapes, restrained ink texture.',
9
+ items: [
10
+ { id: 'p-title', label: 'text', text: 'EXPLORE', caption: 'Large dark-blue uppercase title, centered, crisp heavy lettering with generous spacing.', x: 128, y: 96, w: 768, h: 128 },
11
+ { id: 'p-mountain', label: 'object', caption: 'A flat blue mountain illustration with angular peaks and a pale-blue rising sun, filling the lower center.', x: 208, y: 304, w: 608, h: 480 },
12
+ { id: 'p-subtitle', label: 'text', text: 'まだ見ぬ場所へ', caption: 'A small dark-blue Japanese subtitle, centered in light, clean lettering.', x: 224, y: 864, w: 576, h: 64 }
13
+ ]
14
+ },
15
+ cup: {
16
+ width: 1536, height: 1024, meta: ['product campaign', 'studio photography'], canvas: 'single',
17
+ caption: 'A warm studio campaign for a handmade ceramic cup, with an ivory backdrop, soft side lighting and restrained dark-green typography. Keep the ceramic texture tactile and the composition calm.',
18
+ items: [
19
+ { id: 'c-cup', label: 'object', caption: 'An ivory ceramic cup with a rounded handle, fine speckled glaze and a soft contact shadow, photographed at a three-quarter angle.', x: 176, y: 224, w: 624, h: 640 },
20
+ { id: 'c-title', label: 'text', text: '把日常,过成诗。', caption: 'Large dark-green Chinese title in crisp, moderately heavy lettering, with generous spacing and no backing panel.', x: 928, y: 272, w: 480, h: 176 },
21
+ { id: 'c-copy', label: 'text', text: '一杯温暖\n一刻从容', caption: 'Smaller dark-green Chinese supporting copy, comfortably legible on two lines.', x: 928, y: 512, w: 448, h: 144 }
22
+ ]
23
+ },
24
+ type: {
25
+ width: 1024, height: 1280, meta: ['typographic poster'], canvas: 'single',
26
+ caption: 'An expressive typographic poster on pale-yellow paper. Three independent text blocks in English, Arabic and Japanese share an asymmetric composition with a small pink abstract sculpture. Preserve the exact characters and line breaks. Airy spacing, ink-blue and hot-pink accents.',
27
+ items: [
28
+ { id: 't-en', label: 'text', text: 'MAKE\nROOM.', caption: 'Oversized dark-blue condensed Latin lettering, left aligned, two lines.', x: 80, y: 96, w: 704, h: 320 },
29
+ { id: 't-ar', label: 'text', text: 'مساحة للخيال', caption: 'Dark-blue Arabic lettering with clear connected forms, right aligned.', x: 176, y: 496, w: 736, h: 160 },
30
+ { id: 't-ja', label: 'text', text: '想像の余白', caption: 'Small dark-blue Japanese lettering with generous spacing.', x: 96, y: 1056, w: 544, h: 96 },
31
+ { id: 't-sculpture', label: 'object', caption: 'A playful pink abstract folded-paper sculpture, casting one soft shadow.', x: 592, y: 752, w: 288, h: 256 }
32
+ ]
33
+ }
34
+ };
35
+
36
+ let currentExample = 'poster';
37
+ let state = copy(examples.poster);
38
+ let selectedId = state.items[0].id;
39
+ let activeTab = 'prompt';
40
+ let history = [copy(state)];
41
+ let historyIndex = 0;
42
+ let nextId = 1;
43
+ let pointerAction = null;
44
+ let compiled = null;
45
+ let draft = null;
46
+ let exportError = '';
47
+
48
+ function announce(message, error = false) {
49
+ $('status').textContent = message;
50
+ $('status').classList.toggle('error', error);
51
+ }
52
+
53
+ function selected() { return state.items.find(item => item.id === selectedId); }
54
+
55
+ function pushHistory() {
56
+ if (JSON.stringify(state) === JSON.stringify(history[historyIndex])) return;
57
+ history = history.slice(0, historyIndex + 1);
58
+ history.push(copy(state));
59
+ if (history.length > 60) history.shift();
60
+ historyIndex = history.length - 1;
61
+ renderHistory();
62
+ }
63
+
64
+ function renderHistory() {
65
+ $('undo').disabled = historyIndex < 1;
66
+ $('redo').disabled = historyIndex >= history.length - 1;
67
+ }
68
+
69
+ function renderCanvasSize() {
70
+ $('canvas-width').value = state.width;
71
+ $('canvas-height').value = state.height;
72
+ $('canvas-readout').textContent = `${state.width} × ${state.height} px`;
73
+ $('stage').style.aspectRatio = `${state.width} / ${state.height}`;
74
+ // Preserve the actual aspect ratio even when a tall canvas meets the height limit.
75
+ $('stage').style.maxWidth = `${620 * state.width / state.height}px`;
76
+ }
77
+
78
+ function renderScene() {
79
+ $('scene-caption').value = state.caption;
80
+ $('canvas-kind').value = state.canvas || 'single';
81
+ $('scene-meta').value = '';
82
+ const fragment = document.createDocumentFragment();
83
+ (state.meta || []).forEach((tag, index) => {
84
+ const chip = document.createElement('span'); chip.className = 'tag-chip';
85
+ const text = document.createElement('span'); text.textContent = tag;
86
+ const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '×'; remove.setAttribute('aria-label', `Remove genre tag ${tag}`);
87
+ remove.addEventListener('click', () => {
88
+ state.meta.splice(index, 1); pushHistory(); renderScene(); renderExport(); announce('Genre tag removed.');
89
+ });
90
+ chip.append(text, remove); fragment.append(chip);
91
+ });
92
+ $('scene-tags').replaceChildren(fragment);
93
+ }
94
+
95
+ function nameOf(item) {
96
+ return item.label === 'text' ? item.text || 'Untitled text' : item.caption || `New ${item.label}`;
97
+ }
98
+
99
+ function renderLayers() {
100
+ $('element-count').textContent = state.items.length;
101
+ const fragment = document.createDocumentFragment();
102
+ state.items.forEach((item, index) => {
103
+ const button = document.createElement('button');
104
+ button.type = 'button'; button.className = 'layer';
105
+ button.dataset.itemId = item.id;
106
+ button.setAttribute('aria-pressed', String(item.id === selectedId));
107
+ button.setAttribute('aria-label', `Select ${item.label} ${index + 1}: ${nameOf(item)}`);
108
+ const dot = document.createElement('span'); dot.className = `dot ${item.label}-dot`; dot.setAttribute('aria-hidden', 'true');
109
+ const title = document.createElement('span'); title.className = 'layer-label'; title.textContent = `${index + 1}. ${nameOf(item)}`;
110
+ const type = document.createElement('span'); type.className = 'layer-type'; type.textContent = item.label;
111
+ button.append(dot, title, type);
112
+ button.addEventListener('click', () => selectItem(item.id, false));
113
+ fragment.append(button);
114
+ });
115
+ $('layers').replaceChildren(fragment);
116
+ }
117
+
118
+ function paintBox(element, item) {
119
+ element.style.left = `${100 * item.x / state.width}%`;
120
+ element.style.top = `${100 * item.y / state.height}%`;
121
+ element.style.width = `${100 * item.w / state.width}%`;
122
+ element.style.height = `${100 * item.h / state.height}%`;
123
+ element.setAttribute('aria-pressed', String(item.id === selectedId));
124
+ const content = element.querySelector('.box-content');
125
+ if (item.label === 'text') {
126
+ const lines = (item.text || '').split('\n');
127
+ const longest = Math.max(1, ...lines.map(line => Array.from(line).length));
128
+ const boardWidth = $('stage').getBoundingClientRect().width;
129
+ const pxWidth = boardWidth * item.w / state.width;
130
+ const pxHeight = boardWidth * item.h / state.width;
131
+ content.style.fontSize = `${Math.max(8, Math.min(62, pxWidth / (longest * .64), pxHeight / (lines.length * 1.45)))}px`;
132
+ }
133
+ }
134
+
135
+ function renderStage() {
136
+ const fragment = document.createDocumentFragment();
137
+ state.items.forEach((item, index) => {
138
+ const box = document.createElement('button');
139
+ box.type = 'button'; box.className = `layout-box ${item.label}`; box.dataset.itemId = item.id;
140
+ box.setAttribute('aria-label', `${item.label} ${index + 1}: ${nameOf(item)}. Arrow keys move; Alt plus arrows resize.`);
141
+ box.title = `${item.label} ${index + 1}. Drag to move. Alt + arrows to resize.`;
142
+ const number = document.createElement('span'); number.className = 'box-number'; number.textContent = `${String(index + 1).padStart(2, '0')} / ${item.label.toUpperCase()}`;
143
+ const content = document.createElement('span'); content.className = 'box-content'; content.dir = 'auto';
144
+ content.textContent = item.label === 'text' ? item.text : item.caption;
145
+ const handle = document.createElement('span'); handle.className = 'resize-handle'; handle.setAttribute('aria-hidden', 'true');
146
+ box.append(number, content, handle);
147
+ box.addEventListener('pointerdown', startPointer);
148
+ box.addEventListener('click', () => selectItem(item.id, false));
149
+ box.addEventListener('keydown', boxKeydown);
150
+ box.addEventListener('focus', () => {
151
+ if (selectedId !== item.id) selectItem(item.id);
152
+ });
153
+ paintBox(box, item);
154
+ fragment.append(box);
155
+ });
156
+ $('stage').replaceChildren(fragment);
157
+ }
158
+
159
+ function renderInspector() {
160
+ const item = selected();
161
+ $('empty-selection').hidden = Boolean(item);
162
+ $('selection-fields').hidden = !item;
163
+ if (!item) return;
164
+ $('item-label').value = item.label;
165
+ $('literal-field').hidden = item.label !== 'text';
166
+ $('item-text').value = item.text || '';
167
+ $('item-caption').value = item.caption;
168
+ ['x', 'y', 'w', 'h'].forEach(key => { $(`box-${key}`).value = item[key]; });
169
+ try { $('centered-readout').textContent = `[${toCentralBox(item, state.width, state.height).join(', ')}]`; }
170
+ catch { $('centered-readout').textContent = 'Review box geometry'; }
171
+ }
172
+
173
+ function renderExport() {
174
+ exportError = '';
175
+ let warnings = [];
176
+ try {
177
+ draft = makeDraft(state);
178
+ const validation = validateDraft(draft);
179
+ if (!validation.valid) throw new Error(validation.errors.join(' '));
180
+ warnings = validation.warnings;
181
+ compiled = makeLayout(state);
182
+ } catch (error) {
183
+ compiled = null; draft = null; exportError = error.message;
184
+ }
185
+ const isPrompt = activeTab === 'prompt';
186
+ $('json-output').value = exportError ? `Draft needs attention:\n${exportError}` : JSON.stringify(isPrompt ? compiled : draft, null, 2);
187
+ $('json-output').setAttribute('aria-label', isPrompt ? 'Compiled MOSS model prompt' : 'Editable MOSS layout draft');
188
+ $('copy').textContent = isPrompt ? 'Copy model prompt ↗' : 'Copy editable draft ↗';
189
+ $('copy').disabled = $('download').disabled = Boolean(exportError);
190
+ $('layout-warning').hidden = !warnings.length;
191
+ $('layout-warning').textContent = warnings.join(' ');
192
+ $('format-help').textContent = isPrompt ? 'Centered pixels · yxyx · image dimensions stay in your generation settings' : 'Top-left pixels · xyxy · includes image dimensions for editing';
193
+ for (const tab of ['prompt', 'draft']) {
194
+ $(`tab-${tab}`).setAttribute('aria-selected', String(tab === activeTab));
195
+ $(`tab-${tab}`).tabIndex = tab === activeTab ? 0 : -1;
196
+ }
197
+ }
198
+
199
+ function renderAll() {
200
+ renderCanvasSize(); renderScene(); renderLayers(); renderStage(); renderInspector(); renderExport(); renderHistory();
201
+ }
202
+
203
+ function selectItem(id, focus = false, keepStage = false) {
204
+ selectedId = id;
205
+ renderLayers(); renderInspector();
206
+ if (!keepStage) {
207
+ for (const box of $('stage').querySelectorAll('.layout-box')) box.setAttribute('aria-pressed', String(box.dataset.itemId === id));
208
+ }
209
+ if (focus) $('stage').querySelector(`[data-item-id="${CSS.escape(id)}"]`)?.focus({ preventScroll: true });
210
+ }
211
+
212
+ function saveEdit(message) {
213
+ pushHistory(); renderAll();
214
+ if (exportError) announce(exportError, true); else if (message) announce(message);
215
+ }
216
+
217
+ function startPointer(event) {
218
+ if (event.button !== 0) return;
219
+ const box = event.currentTarget;
220
+ const item = state.items.find(candidate => candidate.id === box.dataset.itemId);
221
+ selectItem(item.id);
222
+ box.focus({ preventScroll: true });
223
+ const rect = $('stage').getBoundingClientRect();
224
+ pointerAction = {
225
+ id: item.id, pointerId: event.pointerId,
226
+ startX: event.clientX, startY: event.clientY,
227
+ initial: copy(item), scaleX: state.width / rect.width, scaleY: state.height / rect.height,
228
+ resize: event.target.classList.contains('resize-handle'), changed: false
229
+ };
230
+ box.classList.add('dragging');
231
+ box.setPointerCapture(event.pointerId);
232
+ event.preventDefault();
233
+ }
234
+
235
+ $('stage').addEventListener('pointermove', event => {
236
+ if (!pointerAction || pointerAction.pointerId !== event.pointerId) return;
237
+ const action = pointerAction;
238
+ const item = state.items.find(candidate => candidate.id === action.id);
239
+ const dx = Math.round((event.clientX - action.startX) * action.scaleX);
240
+ const dy = Math.round((event.clientY - action.startY) * action.scaleY);
241
+ const before = action.initial;
242
+ if (action.resize) {
243
+ item.w = Math.max(1, Math.min(state.width - before.x, Math.round(before.w + dx)));
244
+ item.h = Math.max(1, Math.min(state.height - before.y, Math.round(before.h + dy)));
245
+ } else {
246
+ item.x = Math.max(0, Math.min(state.width - before.w, Math.round(before.x + dx)));
247
+ item.y = Math.max(0, Math.min(state.height - before.h, Math.round(before.y + dy)));
248
+ }
249
+ action.changed = true;
250
+ paintBox(event.target.closest('.layout-box'), item);
251
+ renderInspector(); renderExport();
252
+ });
253
+
254
+ function endPointer(event) {
255
+ if (!pointerAction || event.pointerId !== pointerAction.pointerId) return;
256
+ const changed = pointerAction.changed;
257
+ pointerAction = null;
258
+ for (const box of $('stage').querySelectorAll('.dragging')) box.classList.remove('dragging');
259
+ if (changed) {
260
+ pushHistory(); renderLayers(); renderExport();
261
+ announce('Box updated. Model coordinates and JSON are ready.');
262
+ }
263
+ }
264
+ $('stage').addEventListener('pointerup', endPointer);
265
+ $('stage').addEventListener('pointercancel', endPointer);
266
+ $('stage').addEventListener('lostpointercapture', endPointer);
267
+
268
+ function boxKeydown(event) {
269
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
270
+ event.preventDefault();
271
+ const item = state.items.find(candidate => candidate.id === event.currentTarget.dataset.itemId);
272
+ selectedId = item.id;
273
+ const step = event.shiftKey ? 10 : 1;
274
+ const horizontal = event.key === 'ArrowLeft' || event.key === 'ArrowRight';
275
+ const sign = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1;
276
+ if (event.altKey) {
277
+ const key = horizontal ? 'w' : 'h';
278
+ item[key] = Math.max(1, Math.min((horizontal ? state.width - item.x : state.height - item.y), item[key] + step * sign));
279
+ } else {
280
+ const key = horizontal ? 'x' : 'y';
281
+ item[key] = Math.max(0, Math.min((horizontal ? state.width - item.w : state.height - item.h), item[key] + step * sign));
282
+ }
283
+ paintBox(event.currentTarget, item);
284
+ renderInspector(); renderExport(); pushHistory();
285
+ }
286
+
287
+ for (const [field, key] of [['scene-caption', 'caption'], ['canvas-kind', 'canvas']]) {
288
+ $(field).addEventListener('change', () => {
289
+ state[key] = $(field).value;
290
+ pushHistory(); renderExport();
291
+ announce(exportError || 'Scene updated.', Boolean(exportError));
292
+ });
293
+ }
294
+ function addGenre() {
295
+ const tag = $('scene-meta').value;
296
+ if (!tag.trim()) return;
297
+ if (!state.meta) state.meta = [];
298
+ state.meta.push(tag); pushHistory(); renderScene(); renderExport(); announce('Genre tag added.');
299
+ }
300
+ $('add-meta').addEventListener('click', addGenre);
301
+ $('scene-meta').addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); addGenre(); } });
302
+
303
+ $('apply-size').addEventListener('click', () => {
304
+ const width = Number($('canvas-width').value), height = Number($('canvas-height').value);
305
+ try {
306
+ if (width > 8192 || height > 8192) throw new Error('This editor supports canvas edges up to 8192 px. MOSS hardware requirements depend on your generation setup.');
307
+ if (!$('scale-boxes').checked && state.items.some(item => item.x + item.w > width || item.y + item.h > height)) {
308
+ throw new Error('Some boxes would fall outside the new canvas. Enable scaling, or move and resize those boxes first.');
309
+ }
310
+ state = resizeLayout(state, width, height, { scale: $('scale-boxes').checked });
311
+ saveEdit(`Canvas set to ${width} × ${height} px. ${$('scale-boxes').checked ? 'Boxes scaled proportionally.' : 'Box pixel positions preserved.'}`);
312
+ } catch (error) { announce(error.message, true); }
313
+ });
314
+
315
+ $('show-grid').addEventListener('change', () => $('stage').classList.toggle('grid', $('show-grid').checked));
316
+
317
+ for (const label of ['text', 'object', 'character']) {
318
+ $(`add-${label}`).addEventListener('click', () => {
319
+ const w = Math.max(1, Math.round(state.width * .4)), h = Math.max(1, Math.round(state.height * (label === 'text' ? .15 : .35)));
320
+ const item = { id: `new-${nextId++}`, label, caption: label === 'text' ? 'Clear dark lettering with a readable size and generous spacing.' : label === 'character' ? 'A friendly illustrated character with a simple expressive pose.' : 'A geometric sculptural object with soft lighting and a clean silhouette.', x: Math.round((state.width - w) / 2), y: Math.round((state.height - h) / 2), w, h };
321
+ if (label === 'text') item.text = 'YOUR WORDS';
322
+ state.items.push(item); selectedId = item.id; saveEdit(`${label[0].toUpperCase() + label.slice(1)} added.`); selectItem(item.id, true);
323
+ });
324
+ }
325
+
326
+ $('item-label').addEventListener('change', () => {
327
+ const item = selected(); if (!item) return;
328
+ item.label = $('item-label').value;
329
+ if (item.label === 'text' && !item.text) item.text = 'YOUR WORDS';
330
+ saveEdit('Element type updated.');
331
+ });
332
+ for (const [field, key] of [['item-text', 'text'], ['item-caption', 'caption']]) {
333
+ $(field).addEventListener('change', () => {
334
+ const item = selected(); if (!item) return;
335
+ item[key] = $(field).value;
336
+ pushHistory(); renderLayers(); renderStage(); renderExport();
337
+ announce(exportError || 'Element updated.', Boolean(exportError));
338
+ });
339
+ }
340
+
341
+ for (const key of ['x', 'y', 'w', 'h']) {
342
+ $(`box-${key}`).addEventListener('change', () => {
343
+ const item = selected(); if (!item) return;
344
+ const value = Number($(`box-${key}`).value);
345
+ const candidate = { ...item, [key]: value };
346
+ if (!Number.isFinite(value) || $(`box-${key}`).value.trim() === '' || candidate.x < 0 || candidate.y < 0 || candidate.w <= 0 || candidate.h <= 0 || candidate.x + candidate.w > state.width || candidate.y + candidate.h > state.height) {
347
+ announce('Use finite pixel values with a positive box size fully inside the canvas.', true); renderInspector(); return;
348
+ }
349
+ item[key] = value;
350
+ pushHistory(); renderStage(); renderInspector(); renderExport(); announce('Exact box geometry updated.');
351
+ });
352
+ }
353
+
354
+ $('duplicate').addEventListener('click', () => {
355
+ const item = selected(); if (!item) return;
356
+ const duplicate = copy(item); duplicate.id = `new-${nextId++}`;
357
+ duplicate.x = Math.min(state.width - duplicate.w, duplicate.x + 24);
358
+ duplicate.y = Math.min(state.height - duplicate.h, duplicate.y + 24);
359
+ state.items.push(duplicate); selectedId = duplicate.id; saveEdit('Element duplicated.');
360
+ });
361
+ $('delete').addEventListener('click', () => {
362
+ if (state.items.length <= 1) { announce('Keep at least one element in a layout. You can change this element instead.', true); return; }
363
+ const index = state.items.findIndex(item => item.id === selectedId);
364
+ state.items.splice(index, 1); selectedId = state.items[Math.min(index, state.items.length - 1)].id; saveEdit('Element removed. Undo restores it.');
365
+ });
366
+
367
+ function loadExample(name) {
368
+ state = copy(examples[name]); currentExample = name; selectedId = state.items[0].id;
369
+ document.querySelectorAll('[data-example]').forEach(button => button.classList.toggle('active', button.dataset.example === name));
370
+ saveEdit(`Loaded ${name === 'cup' ? 'the official ceramic campaign example' : name === 'type' ? 'the Unicode type playground' : 'the travel poster'}. Undo restores your previous draft.`);
371
+ }
372
+ document.querySelectorAll('[data-example]').forEach(button => button.addEventListener('click', () => loadExample(button.dataset.example)));
373
+ $('reset').addEventListener('click', () => loadExample(currentExample));
374
+
375
+ for (const [id, direction] of [['undo', -1], ['redo', 1]]) {
376
+ $(id).addEventListener('click', () => {
377
+ const index = historyIndex + direction;
378
+ if (index < 0 || index >= history.length) return;
379
+ historyIndex = index; state = copy(history[index]);
380
+ if (!selected()) selectedId = state.items[0].id;
381
+ renderAll(); announce(direction < 0 ? 'Edit undone.' : 'Edit restored.');
382
+ });
383
+ }
384
+
385
+ function switchTab(tab) { activeTab = tab; renderExport(); }
386
+ for (const tab of ['prompt', 'draft']) {
387
+ $(`tab-${tab}`).addEventListener('click', () => switchTab(tab));
388
+ $(`tab-${tab}`).addEventListener('keydown', event => {
389
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
390
+ event.preventDefault(); const next = event.key === 'Home' ? 'prompt' : event.key === 'End' ? 'draft' : tab === 'prompt' ? 'draft' : 'prompt';
391
+ switchTab(next); $(`tab-${next}`).focus();
392
+ });
393
+ }
394
+
395
+ $('copy').addEventListener('click', async () => {
396
+ if (exportError) return;
397
+ try {
398
+ await navigator.clipboard.writeText($('json-output').value);
399
+ announce(activeTab === 'prompt' ? `Model prompt copied. Generate at ${state.width} × ${state.height} px.` : 'Editable draft copied, including the canvas dimensions.');
400
+ } catch {
401
+ $('json-output').focus(); $('json-output').select();
402
+ announce('Clipboard access is unavailable. The JSON is selected; use your device’s Copy command.');
403
+ }
404
+ });
405
+
406
+ $('download').addEventListener('click', () => {
407
+ if (exportError) return;
408
+ const blob = new Blob([$('json-output').value + '\n'], { type: 'application/json;charset=utf-8' });
409
+ const url = URL.createObjectURL(blob);
410
+ const anchor = document.createElement('a'); anchor.href = url; anchor.download = `moss-${activeTab}-${state.width}x${state.height}.json`;
411
+ anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
412
+ announce(`${activeTab === 'prompt' ? 'Model prompt' : 'Editable draft'} downloaded.`);
413
+ });
414
+
415
+ $('import').addEventListener('change', async event => {
416
+ const file = event.target.files?.[0]; if (!file) return;
417
+ try {
418
+ if (file.size > 1_000_000) throw new Error('Import a layout JSON file smaller than 1 MB.');
419
+ const imported = parseLayout(await file.text(), { width: state.width, height: state.height });
420
+ if (imported.items.length > 200) throw new Error('This visual editor supports up to 200 layout elements.');
421
+ if (imported.width > 8192 || imported.height > 8192) throw new Error('This visual editor supports canvas edges up to 8192 px.');
422
+ state = imported;
423
+ // Assign fresh internal IDs. These are never written into either JSON format.
424
+ state.items.forEach(item => { item.id = `import-${nextId++}`; });
425
+ selectedId = state.items[0].id;
426
+ saveEdit(`Layout imported at ${state.width} × ${state.height} px. Compiled prompts use the current canvas dimensions because they contain none.`);
427
+ document.querySelectorAll('[data-example]').forEach(button => button.classList.remove('active'));
428
+ } catch (error) { announce(`Import failed: ${error.message}`, true); }
429
+ event.target.value = '';
430
+ });
431
+
432
+ new ResizeObserver(() => {
433
+ for (const box of $('stage').querySelectorAll('.layout-box')) {
434
+ const item = state.items.find(candidate => candidate.id === box.dataset.itemId);
435
+ if (item) paintBox(box, item);
436
+ }
437
+ }).observe($('stage'));
438
+
439
+ renderAll();
favicon.svg ADDED
index.html CHANGED
@@ -1,19 +1,68 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="A browser-only layout editor for MOSS-Image-Base. Place text and objects, then export the exact model prompt JSON.">
7
+ <meta property="og:title" content="MOSS Layout Studio by alphaBell">
8
+ <meta property="og:description" content="Draw text and object boxes. Export the exact layout JSON for MOSS-Image-Base.">
9
+ <meta property="og:image" content="https://huggingface.co/spaces/alphabell/MOSS-Layout-Studio/resolve/main/thumbnail.png">
10
+ <meta property="og:url" content="https://huggingface.co/spaces/alphabell/MOSS-Layout-Studio">
11
+ <meta name="twitter:card" content="summary_large_image">
12
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'">
13
+ <title>MOSS Layout Studio · alphaBell</title>
14
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
15
+ <link rel="stylesheet" href="style.css">
16
+ </head>
17
+ <body>
18
+ <a class="skip" href="#stage">Skip to layout editor</a>
19
+ <header class="masthead">
20
+ <a class="brand" href="https://huggingface.co/alphabell" target="_blank" rel="noreferrer"><span class="bell" aria-hidden="true">aB</span>alphaBell <span class="brand-lab">APPLIED MODEL LAB</span></a>
21
+ <span class="local-badge"><span aria-hidden="true"></span> Browser only. Your draft stays here.</span>
22
+ </header>
23
+ <main>
24
+ <section class="intro" aria-labelledby="title">
25
+ <div><p class="eyebrow">A SMALL TOOL FOR A BIG IMAGE MODEL</p><h1 id="title">MOSS Layout <span>Studio.</span></h1><p class="dek">Put the words where you want them. Draw a composition, then export the exact layout prompt for MOSS-Image-Base.</p></div>
26
+ <div class="intro-note"><span class="note-index">01 / COMPOSE</span><p>A layout sketch, not a generated image. MOSS-Image-Base layout control is experimental.</p><a href="https://github.com/OpenMOSS/MOSS-Image/blob/52b87fca23acedc399d062d593bec388aee9926a/USERGUIDE.md#layout-prompts-experimental" target="_blank" rel="noreferrer">Read the official format ↗</a></div>
27
+ </section>
28
+ <div class="workspace-toolbar"><div class="segmented" aria-label="Example layouts"><button type="button" data-example="poster" class="active">Travel poster</button><button type="button" data-example="cup">Ceramic campaign</button><button type="button" data-example="type">Type playground</button></div><div class="history"><button id="undo" type="button" title="Undo last edit" disabled>↶ <span>Undo</span></button><button id="redo" type="button" title="Redo last edit" disabled>↷ <span>Redo</span></button><button id="reset" type="button">Reset example</button></div></div>
29
+ <section class="workspace" aria-label="Layout workspace">
30
+ <aside class="panel setup" aria-labelledby="canvas-title">
31
+ <div class="panel-heading"><span class="section-number">01</span><h2 id="canvas-title">Canvas & scene</h2></div>
32
+ <div class="dimension-row"><label>Width, px<input id="canvas-width" type="number" min="16" max="8192" step="16" value="1024"></label><span aria-hidden="true">×</span><label>Height, px<input id="canvas-height" type="number" min="16" max="8192" step="16" value="1024"></label></div>
33
+ <p class="hint">Multiples of 16. Use these dimensions when you generate.</p>
34
+ <label class="check-row"><input id="scale-boxes" type="checkbox" checked> Scale boxes with the canvas</label><button id="apply-size" class="secondary full" type="button">Apply canvas size</button>
35
+ <label class="field-label">Overall scene<textarea id="scene-caption" rows="5"></textarea></label>
36
+ <div class="two-fields"><label>Composition<select id="canvas-kind"><option value="single">Single image</option><option value="multi_panel">Multiple panels</option><option value="collage">Collage</option></select></label><div class="genre-field"><label for="scene-meta">Medium / genre</label><div id="scene-tags" class="scene-tags" aria-label="Genre tags"></div><div class="tag-add"><input id="scene-meta" type="text" aria-describedby="meta-help" placeholder="Add a genre"><button id="add-meta" type="button" aria-label="Add genre tag">+</button></div></div></div><p class="hint" id="meta-help">Type a tag and press Enter. Remove a tag with its × button.</p>
37
+ <div class="layers-heading"><h3>Elements <span id="element-count">2</span></h3><div class="add-buttons"><button type="button" id="add-text">+ Text</button><button type="button" id="add-object">+ Object</button><button type="button" id="add-character">+ Character</button></div></div>
38
+ <div id="layers" class="layers" aria-label="Layout elements"></div>
39
+ <p class="hint keyboard-hint">Select a box. Arrow keys move it, Shift moves 10 px. Alt + arrows resize it. Numeric fields give exact control.</p>
40
+ </aside>
41
+ <section class="stage-panel" aria-label="Visual composition">
42
+ <div class="stage-top"><span id="canvas-readout">1024 × 1024 px</span><label class="check-row"><input id="show-grid" type="checkbox" checked> Grid</label></div>
43
+ <div class="stage-surround"><div id="stage" class="stage grid" role="group" aria-label="Layout canvas. Select an element to move or resize it."></div></div>
44
+ <div class="stage-bottom"><span><span class="dot text-dot"></span>Text <span class="dot object-dot"></span>Object <span class="dot character-dot"></span>Character</span><span>Schematic preview</span></div>
45
+ <div class="coordinate-note"><span class="coordinate-icon" aria-hidden="true">↗</span><p>You place boxes from the top left.<br><strong>We convert to centered [y₁, x₁, y₂, x₂].</strong></p></div>
46
+ </section>
47
+ <aside class="panel inspector" aria-labelledby="element-title"><div class="panel-heading"><span class="section-number">02</span><h2 id="element-title">Selected element</h2></div>
48
+ <div id="empty-selection" hidden>Select an element to edit its appearance and position.</div>
49
+ <div id="selection-fields"><label>Element type<select id="item-label"><option value="text">Text</option><option value="object">Object</option><option value="character">Character</option></select></label>
50
+ <label id="literal-field">Exact visible text<textarea id="item-text" rows="3" spellcheck="false"></textarea><span class="hint">Characters and line breaks are preserved. Unicode is welcome.</span></label>
51
+ <label>Appearance<textarea id="item-caption" rows="4"></textarea></label>
52
+ <div class="box-fields"><label>X, px<input id="box-x" type="number" min="0" step="1"></label><label>Y, px<input id="box-y" type="number" min="0" step="1"></label><label>Width, px<input id="box-w" type="number" min="1" step="1"></label><label>Height, px<input id="box-h" type="number" min="1" step="1"></label></div>
53
+ <div class="coord-card"><span>MODEL COORDINATES · YXYX</span><output id="centered-readout"></output></div>
54
+ <div class="item-actions"><button id="duplicate" type="button" class="secondary">Duplicate</button><button id="delete" type="button" class="danger">Remove</button></div>
55
+ </div>
56
+ </aside>
57
+ </section>
58
+ <p id="status" class="status" role="status" aria-live="polite">Ready to compose.</p>
59
+ <p id="layout-warning" class="layout-warning" hidden></p>
60
+ <section class="export-panel" aria-labelledby="export-title"><div class="export-intro"><span class="section-number">03</span><h2 id="export-title">Take it to MOSS.</h2><p>Save the editable draft, or copy the compiled JSON into MOSS-Image-Base’s prompt field. Set the same image dimensions in your generation tool.</p><p class="save-note">Drafts stay in memory. Download yours before closing this page.</p><div class="export-facts"><span>No account</span><span>No inference</span><span>No uploads</span></div></div>
61
+ <div class="export-code"><div class="code-tabs" role="tablist" aria-label="JSON format"><button type="button" id="tab-prompt" role="tab" aria-selected="true" aria-controls="json-output">Model prompt</button><button type="button" id="tab-draft" role="tab" aria-selected="false" aria-controls="json-output" tabindex="-1">Editable draft</button></div><p id="format-help" class="format-help">Centered pixels · yxyx · image dimensions stay in your generation settings</p><textarea id="json-output" readonly spellcheck="false" aria-label="Compiled MOSS model prompt"></textarea><div class="export-actions"><button id="copy" type="button" class="primary">Copy model prompt <span aria-hidden="true">↗</span></button><button id="download" type="button" class="secondary">Download JSON</button><label class="file-button secondary">Import draft<input id="import" type="file" accept=".json,application/json"></label></div></div>
62
+ </section>
63
+ <section class="how-it-works" aria-label="Using the exported layout"><article><span>1</span><div><h3>Compose in pixels</h3><p>Drag and resize boxes. Write the exact visible words and describe each element’s appearance.</p></div></article><article><span>2</span><div><h3>Copy the model prompt</h3><p>The editor compiles absolute xyxy boxes into MOSS’s centered yxyx format. Dimensions are omitted from the prompt.</p></div></article><article><span>3</span><div><h3>Generate, then compare</h3><p>Use the same canvas dimensions in MOSS-Image. The sketch describes intent; the model’s output may differ.</p></div></article></section>
64
+ </main>
65
+ <footer><div><strong>alphaBell</strong><p>Small tools. Useful experiments.</p></div><p>Independent tooling for OpenMOSS’s experimental layout format.<br>Source checked against <a href="https://github.com/OpenMOSS/MOSS-Image/tree/52b87fca23acedc399d062d593bec388aee9926a/skills/moss-image-prompt-enhancer" target="_blank" rel="noreferrer">OpenMOSS’s compiler and format reference ↗</a></p><nav aria-label="Project links"><a href="https://github.com/alphabell-research/moss-layout-studio" target="_blank" rel="noreferrer">Source and checks ↗</a><a href="https://huggingface.co/OpenMOSS-Team/MOSS-Image-Base" target="_blank" rel="noreferrer">MOSS-Image-Base ↗</a><a href="https://huggingface.co/alphabell" target="_blank" rel="noreferrer">More from alphaBell ↗</a></nav></footer>
66
+ <script src="app.js" type="module"></script>
67
+ </body>
68
  </html>
layout.mjs ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * alphaBell MOSS Layout Studio geometry and schema helpers.
3
+ * Draft validation and export follow OpenMOSS/MOSS-Image compile_prompt.py,
4
+ * revision 52b87fca23acedc399d062d593bec388aee9926a (Apache-2.0).
5
+ * This browser implementation adds import, editing and local precision checks.
6
+ */
7
+
8
+ export const SOURCE_REVISION = '52b87fca23acedc399d062d593bec388aee9926a';
9
+ export const SOURCE_URL = `https://github.com/OpenMOSS/MOSS-Image/blob/${SOURCE_REVISION}/skills/moss-image-prompt-enhancer/scripts/compile_prompt.py`;
10
+
11
+ export class LayoutError extends Error {
12
+ constructor(message, path = '') { super(path ? `${path}: ${message}` : message); this.name = 'LayoutError'; this.path = path; }
13
+ }
14
+ const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
15
+ const fail = (condition, message, path = '') => { if (!condition) throw new LayoutError(message, path); };
16
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ const copy = value => JSON.parse(JSON.stringify(value));
18
+ const NUMBER_SYNTAX = Symbol('JSON number syntax');
19
+
20
+ function fields(value, allowed, required, path) {
21
+ fail(object(value), 'must be an object', path);
22
+ const extra = Object.keys(value).filter(key => !allowed.includes(key));
23
+ fail(!extra.length, `unknown fields ${extra.join(', ')}`, path);
24
+ const missing = required.filter(key => !own(value, key));
25
+ fail(!missing.length, `missing fields ${missing.join(', ')}`, path);
26
+ }
27
+ // Match Python str.strip(), including NEL and the information separators.
28
+ const PYTHON_WHITESPACE = /^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*$/;
29
+ function text(value, path) { fail(typeof value === 'string' && !PYTHON_WHITESPACE.test(value), 'must be nonempty text', path); }
30
+ function strings(value, path) {
31
+ fail(Array.isArray(value), 'must be an array', path);
32
+ value.forEach((entry, i) => text(entry, `${path}[${i}]`));
33
+ }
34
+ export function validateDimensions(width, height) {
35
+ for (const [name, value] of [['width', width], ['height', height]]) {
36
+ fail(Number.isInteger(value) && value > 0 && value % 16 === 0, 'must be a positive integer multiple of 16', name);
37
+ fail(Number.isSafeInteger(value), 'exceeds the browser editor\'s safe integer precision', name);
38
+ }
39
+ return { width, height };
40
+ }
41
+
42
+ // Python's round(value, 9) uses the actual binary float and ties-to-even.
43
+ // Integer arithmetic avoids the decimal boundary drift of Math.round/toFixed.
44
+ function roundNine(value) {
45
+ if (value === 0) return value;
46
+ const buffer = new ArrayBuffer(8), view = new DataView(buffer);
47
+ view.setFloat64(0, Math.abs(value), false);
48
+ const bits = view.getBigUint64(0, false);
49
+ const exponent = Number((bits >> 52n) & 2047n);
50
+ const mantissa = (bits & ((1n << 52n) - 1n)) | (exponent ? 1n << 52n : 0n);
51
+ const shift = (exponent || 1) - 1075;
52
+ let numerator = mantissa * 1000000000n, denominator = 1n;
53
+ if (shift >= 0) numerator <<= BigInt(shift); else denominator <<= BigInt(-shift);
54
+ let quotient = numerator / denominator;
55
+ const remainder = numerator % denominator;
56
+ if (remainder * 2n > denominator || (remainder * 2n === denominator && quotient % 2n !== 0n)) quotient++;
57
+ return Number(`${value < 0 ? '-' : ''}${quotient}e-9`);
58
+ }
59
+
60
+ function checkedRect(rect, width, height, path = 'bbox') {
61
+ validateDimensions(width, height);
62
+ fail(Array.isArray(rect) && rect.length === 4, 'must contain four edges [x1, y1, x2, y2]', path);
63
+ fail(rect.every(value => typeof value === 'number' && Number.isFinite(value)), 'must contain four finite numbers', path);
64
+ const [x1, y1, x2, y2] = rect;
65
+ fail(x1 < x2 && y1 < y2, 'must have positive width and height', path);
66
+ fail(x1 >= 0 && y1 >= 0 && x2 <= width && y2 <= height, `exceeds the ${width} x ${height} canvas`, path);
67
+ return rect;
68
+ }
69
+
70
+ export function toCentralBox(rect, width, height) {
71
+ const edges = Array.isArray(rect) ? rect : [rect.x, rect.y, rect.x + rect.w, rect.y + rect.h];
72
+ const [x1, y1, x2, y2] = checkedRect(edges, width, height);
73
+ const result = [Math.floor(roundNine(y1)) - height / 2, Math.floor(roundNine(x1)) - width / 2,
74
+ Math.ceil(roundNine(y2)) - height / 2, Math.ceil(roundNine(x2)) - width / 2];
75
+ fail(result[0] < result[2] && result[1] < result[3], 'is too small at export precision', 'bbox');
76
+ return result;
77
+ }
78
+ export function fromCentralBox(box, width, height) {
79
+ validateDimensions(width, height);
80
+ fail(Array.isArray(box) && box.length === 4 && box.every(x => typeof x === 'number' && Number.isFinite(x)), 'must contain four finite numbers', 'bbox');
81
+ const [y1, x1, y2, x2] = box;
82
+ checkedRect([x1 + width / 2, y1 + height / 2, x2 + width / 2, y2 + height / 2], width, height);
83
+ return { x: x1 + width / 2, y: y1 + height / 2, w: x2 - x1, h: y2 - y1 };
84
+ }
85
+ export function toNormalizedRect(rect, width, height) {
86
+ checkedRect([rect.x, rect.y, rect.x + rect.w, rect.y + rect.h], width, height);
87
+ return { x: rect.x / width, y: rect.y / height, w: rect.w / width, h: rect.h / height };
88
+ }
89
+ export function fromNormalizedRect(rect, width, height) {
90
+ const right = rect.x + rect.w, bottom = rect.y + rect.h;
91
+ // Editing arithmetic can add a few ulps at a canvas edge. Snap only this
92
+ // normalized helper's tiny arithmetic spill, never imported JSON geometry.
93
+ const boundedRight = right > 1 && right <= 1 + 2 * Number.EPSILON ? 1 : right;
94
+ const boundedBottom = bottom > 1 && bottom <= 1 + 2 * Number.EPSILON ? 1 : bottom;
95
+ const x = rect.x * width, y = rect.y * height;
96
+ const result = { x, y, w: boundedRight * width - x, h: boundedBottom * height - y };
97
+ checkedRect([result.x, result.y, result.x + result.w, result.y + result.h], width, height);
98
+ return result;
99
+ }
100
+
101
+ function checkSummary(summary) {
102
+ fields(summary, ['caption', 'meta', 'canvas', 'palette'], ['caption'], 'summary');
103
+ text(summary.caption, 'summary.caption');
104
+ if (own(summary, 'canvas')) fail(['single', 'multi_panel', 'collage'].includes(summary.canvas), 'must be single, multi_panel or collage', 'summary.canvas');
105
+ for (const key of ['meta', 'palette']) if (own(summary, key)) strings(summary[key], `summary.${key}`);
106
+ }
107
+ function checkItems(items, width, height, centralized = false) {
108
+ fail(Array.isArray(items) && items.length > 0, 'must be a nonempty flat array', 'items');
109
+ return items.map((item, i) => {
110
+ const path = `items[${i}]`;
111
+ fields(item, ['label', 'caption', 'bbox', 'text', 'tags', 'palette'], ['label', 'caption', 'bbox'], path);
112
+ fail(['object', 'character', 'text'].includes(item.label), 'must be object, character or text', `${path}.label`);
113
+ text(item.caption, `${path}.caption`);
114
+ if (item.label === 'text') fail(own(item, 'text'), 'text items require literal text', path);
115
+ if (own(item, 'text')) text(item.text, `${path}.text`);
116
+ for (const key of ['tags', 'palette']) if (own(item, key)) strings(item[key], `${path}.${key}`);
117
+ try {
118
+ const bbox = centralized ? item.bbox : toCentralBox(checkedRect(item.bbox, width, height, `${path}.bbox`), width, height);
119
+ if (centralized) fromCentralBox(bbox, width, height);
120
+ return { ...copy(item), bbox: [...bbox] };
121
+ } catch (error) { throw new LayoutError(error.message.replace(/^bbox: /, ''), `${path}.bbox`); }
122
+ });
123
+ }
124
+
125
+ export function compileDraft(draft) {
126
+ const keys = ['width', 'height', 'bbox_coord_space', 'bbox_format', 'summary', 'items'];
127
+ fields(draft, keys, keys, 'Layout draft');
128
+ const { width, height } = draft;
129
+ 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);
130
+ validateDimensions(width, height);
131
+ fail(draft.bbox_coord_space === 'abs', 'must be abs', 'bbox_coord_space');
132
+ fail(draft.bbox_format === 'xyxy', 'must be xyxy', 'bbox_format');
133
+ checkSummary(draft.summary);
134
+ return { summary: copy(draft.summary), items: checkItems(draft.items, width, height), bbox_coord_space: 'abs_centralized', bbox_format: 'yxyx' };
135
+ }
136
+
137
+ export function validateDraft(draft) {
138
+ try {
139
+ compileDraft(draft);
140
+ const pixels = draft.width * draft.height;
141
+ const warnings = [];
142
+ 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.');
143
+ return { valid: true, errors: [], warnings };
144
+ } catch (error) { return { valid: false, errors: [error.message], warnings: [] }; }
145
+ }
146
+
147
+ export function makeDraft(state) {
148
+ const summary = { caption: state.caption };
149
+ for (const key of ['meta', 'canvas', 'palette']) if (own(state, key) && state[key] !== undefined) summary[key] = copy(state[key]);
150
+ const items = state.items.map(item => {
151
+ const result = { label: item.label, caption: item.caption, bbox: [item.x, item.y, item.x + item.w, item.y + item.h] };
152
+ for (const key of ['text', 'tags', 'palette']) if (own(item, key) && item[key] !== undefined) result[key] = copy(item[key]);
153
+ return result;
154
+ });
155
+ const draft = { width: state.width, height: state.height, bbox_coord_space: 'abs', bbox_format: 'xyxy', summary, items };
156
+ compileDraft(draft);
157
+ return draft;
158
+ }
159
+ export function makeLayout(state) { return compileDraft(makeDraft(state)); }
160
+
161
+ // Reject duplicate keys instead of letting JSON.parse silently discard content.
162
+ export function parseStrictJSON(source) {
163
+ fail(typeof source === 'string', 'input must be JSON text');
164
+ let index = 0;
165
+ const skip = () => { while (/[\t\r\n ]/.test(source[index] || '\u0000')) index++; };
166
+ function string() {
167
+ const start = index++;
168
+ while (index < source.length) {
169
+ const char = source[index++];
170
+ if (char === '\\') { index++; continue; }
171
+ if (char === '"') return JSON.parse(source.slice(start, index));
172
+ }
173
+ throw new LayoutError('Unterminated JSON string');
174
+ }
175
+ function value() {
176
+ skip();
177
+ if (source[index] === '"') return string();
178
+ if (source[index] === '{') {
179
+ index++; skip(); const result = {}, seen = new Set();
180
+ if (source[index] === '}') { index++; return result; }
181
+ while (true) {
182
+ skip(); fail(source[index] === '"', 'Expected a JSON object key');
183
+ const key = string(); fail(!seen.has(key), `Duplicate JSON key: ${key}`); seen.add(key);
184
+ skip(); fail(source[index++] === ':', 'Expected a colon after JSON key');
185
+ skip(); const valueStart = index, child = value();
186
+ const token = source.slice(valueStart, index);
187
+ if (typeof child === 'number' && /[.eE]/.test(token)) {
188
+ if (!result[NUMBER_SYNTAX]) Object.defineProperty(result, NUMBER_SYNTAX, { value: Object.create(null) });
189
+ result[NUMBER_SYNTAX][key] = 'float';
190
+ }
191
+ Object.defineProperty(result, key, { value: child, enumerable: true, writable: true, configurable: true });
192
+ skip(); const separator = source[index++];
193
+ if (separator === '}') return result;
194
+ fail(separator === ',', 'Expected a comma or closing object brace');
195
+ }
196
+ }
197
+ if (source[index] === '[') {
198
+ index++; skip(); const result = [];
199
+ if (source[index] === ']') { index++; return result; }
200
+ while (true) {
201
+ result.push(value()); skip(); const separator = source[index++];
202
+ if (separator === ']') return result;
203
+ fail(separator === ',', 'Expected a comma or closing array bracket');
204
+ }
205
+ }
206
+ const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(source.slice(index));
207
+ fail(match, 'Expected a JSON value'); index += match[0].length;
208
+ const result = JSON.parse(match[0]);
209
+ fail(typeof result !== 'number' || Number.isFinite(result), 'JSON number exceeds browser numeric precision');
210
+ return result;
211
+ }
212
+ try { const result = value(); skip(); fail(index === source.length, 'Unexpected content after JSON value'); return result; }
213
+ catch (error) { if (error instanceof LayoutError) throw error; throw new LayoutError(`Invalid JSON: ${error.message}`); }
214
+ }
215
+
216
+ export function parseLayout(json, dimensions = {}) {
217
+ const source = typeof json === 'string' ? parseStrictJSON(json) : copy(json);
218
+ fail(object(source), 'Layout must be a JSON object');
219
+ let width, height, summary, items;
220
+ if (source.bbox_coord_space === 'abs') {
221
+ compileDraft(source);
222
+ ({ width, height, summary, items } = source);
223
+ 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] } }));
224
+ } else {
225
+ const keys = ['summary', 'items', 'bbox_coord_space', 'bbox_format'];
226
+ fields(source, keys, keys, 'Compiled layout');
227
+ fail(source.bbox_coord_space === 'abs_centralized', 'must be abs or abs_centralized', 'bbox_coord_space');
228
+ fail(source.bbox_format === 'yxyx', 'must be yxyx for a compiled layout', 'bbox_format');
229
+ ({ width, height } = dimensions); validateDimensions(width, height);
230
+ checkSummary(source.summary); summary = source.summary;
231
+ items = checkItems(source.items, width, height, true).map(item => ({ ...item, rect: fromCentralBox(item.bbox, width, height) }));
232
+ }
233
+ const state = { width, height, caption: summary.caption, items: [] };
234
+ for (const key of ['meta', 'canvas', 'palette']) if (own(summary, key)) state[key] = copy(summary[key]);
235
+ state.items = items.map((item, index) => {
236
+ const result = { id: `item-${index + 1}`, label: item.label, caption: item.caption, ...item.rect };
237
+ for (const key of ['text', 'tags', 'palette']) if (own(item, key)) result[key] = copy(item[key]);
238
+ return result;
239
+ });
240
+ return state;
241
+ }
242
+
243
+ // This is an editor interaction policy, not an upstream compiler transformation.
244
+ export function clampRect(rect, width, height, minimum = 1) {
245
+ validateDimensions(width, height);
246
+ fail([rect.x, rect.y, rect.w, rect.h, minimum].every(Number.isFinite), 'Editor rectangle must contain finite numbers');
247
+ fail(minimum > 0, 'Editor minimum size must be positive');
248
+ const w = Math.min(width, Math.max(Math.min(minimum, width), rect.w));
249
+ const h = Math.min(height, Math.max(Math.min(minimum, height), rect.h));
250
+ return { x: Math.max(0, Math.min(width - w, rect.x)), y: Math.max(0, Math.min(height - h, rect.y)), w, h };
251
+ }
252
+ export function resizeLayout(state, width, height, { scale = true } = {}) {
253
+ makeDraft(state); validateDimensions(width, height);
254
+ const result = copy(state);
255
+ result.items = state.items.map(item => ({ ...copy(item), ...(scale
256
+ ? fromNormalizedRect(toNormalizedRect(item, state.width, state.height), width, height)
257
+ : clampRect(item, width, height)) }));
258
+ result.width = width; result.height = height;
259
+ makeDraft(result);
260
+ return result;
261
+ }
preview.png ADDED

Git LFS Details

  • SHA256: e540f6b76bedb34dda66e4aaf25b7eb5bb892484df8434ca383b46cf4689e836
  • Pointer size: 131 Bytes
  • Size of remote file: 281 kB
requirements-dev.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ playwright==1.64.0
style.css CHANGED
@@ -1,28 +1,2 @@
1
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
16
- }
17
-
18
- .card {
19
- max-width: 620px;
20
- margin: 0 auto;
21
- padding: 16px;
22
- border: 1px solid lightgray;
23
- border-radius: 16px;
24
- }
25
-
26
- .card p:last-child {
27
- margin-bottom: 0;
28
- }
 
1
+ :root{--ink:#22251e;--muted:#686a5e;--paper:#faf8ee;--line:#d8d9cd;--green:#dfff78;--pink:#ff9bb1;--blue:#9bc5ff;--card:#fffdf5;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:4px}button{cursor:pointer}button:disabled{opacity:.38;cursor:default}button{border:1px solid var(--line);background:transparent;border-radius:5px;padding:9px 12px;color:inherit}button:hover:not(:disabled),.file-button:hover{border-color:var(--ink);background:#f1f0e5}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #3068d5}a{color:inherit;text-underline-offset:3px}input,select,textarea{border:1px solid #cccfc0;border-radius:4px;padding:10px 11px;background:var(--card);color:var(--ink);min-width:0;width:100%}textarea{resize:vertical;line-height:1.5}label{display:block;font-size:12px;font-weight:700;line-height:1.5}label input,label select,label textarea{margin-top:5px}label .hint{display:block}input[type=checkbox]{width:15px;height:15px;accent-color:var(--ink);margin:0}.check-row{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:400}.hint{font-size:11px;line-height:1.6;color:var(--muted);font-weight:400;margin:7px 0 13px}.skip{position:fixed;top:-100px;left:16px;background:var(--green);padding:14px;z-index:100}.skip:focus{top:12px}.masthead{height:81px;display:flex;align-items:center;justify-content:space-between;max-width:1552px;margin:auto;padding:0 32px;border-bottom:1px solid var(--line)}.brand{font-size:21px;letter-spacing:-.8px;text-decoration:none;display:flex;align-items:center;gap:10px;font-weight:800}.bell{display:flex;align-items:center;justify-content:center;font-family:Georgia,serif;letter-spacing:-3px;padding-right:4px;background:var(--ink);color:var(--green);width:34px;height:34px;border-radius:50%;font-size:23px}.brand-lab{font-size:9px;letter-spacing:1.4px;margin-left:13px;font-weight:500;color:var(--muted)}.local-badge{font-size:11px;display:flex;align-items:center;gap:7px}.local-badge>span{width:7px;height:7px;border-radius:50%;background:#82ad35}main{max-width:1552px;margin:auto;padding:0 32px}.intro{display:flex;justify-content:space-between;gap:48px;align-items:end;padding:48px 0 38px}.eyebrow{font-size:10px;letter-spacing:2px;font-weight:700;margin:0 0 15px}h1{font-size:clamp(38px,4vw,64px);font-weight:500;letter-spacing:-3px;margin:0 0 17px;line-height:1}h1 span{font-family:Georgia,'Times New Roman',serif;font-style:italic}.dek{font-size:15px;line-height:1.7;max-width:590px;margin:0;color:#52564b}.intro-note{width:245px;flex-shrink:0;border-left:1px solid var(--line);padding:0 0 0 20px}.note-index{font-size:9px;letter-spacing:1.4px;font-weight:700}.intro-note p{font-size:12px;line-height:1.6;margin:8px 0}.intro-note a{font-size:10px}.workspace-toolbar{display:flex;justify-content:space-between;gap:18px;align-items:center;border-top:1px solid var(--line);padding:15px 0}.segmented{display:flex;gap:3px;background:#efeee4;padding:3px;border-radius:6px}.segmented button{border:0;font-size:11px;padding:9px 13px}.segmented button.active{background:var(--ink);color:var(--paper);box-shadow:0 1px 3px #0002}.history{display:flex;gap:7px;font-size:10px}.history button{padding:7px 10px}.workspace{border:1px solid var(--line);border-radius:7px;display:grid;grid-template-columns:265px minmax(250px,1fr) 246px;overflow:hidden;background:var(--card)}.panel{padding:20px 18px}.panel-heading{display:flex;align-items:center;gap:9px;margin:0 0 20px}.section-number{font-family:monospace;font-size:10px;font-weight:700;background:var(--green);border-radius:3px;padding:5px}h2{font-size:15px;font-weight:600;letter-spacing:-.3px;margin:0}.dimension-row{display:grid;grid-template-columns:1fr 12px 1fr;gap:5px;align-items:end}.dimension-row>span{padding-bottom:12px;color:var(--muted);text-align:center}.dimension-row input{font-size:13px;padding:8px}.full{width:100%;margin:11px 0 23px}.secondary{background:#fffdf5;font-size:11px}.field-label{margin-bottom:16px}.two-fields{display:grid;gap:12px}.two-fields label{font-size:11px}.two-fields select,.two-fields input{font-size:11px;padding:8px}.layers-heading{border-top:1px solid var(--line);padding-top:18px;margin-top:19px}h3{font-size:12px;margin:0 0 12px}.layers-heading h3 span{font-size:10px;color:var(--muted);padding-left:5px}.add-buttons{display:flex;gap:5px;flex-wrap:wrap}.add-buttons button{font-size:10px;padding:7px 8px}.layers{display:grid;gap:5px;margin-top:12px}.layer{display:flex;align-items:center;gap:8px;width:100%;font-size:11px;max-width:100%;text-align:left;padding:10px 8px;background:#f4f3e9;border-color:transparent}.layer[aria-pressed=true]{border-color:var(--ink);background:#fffdf5}.layer-label{white-space:nowrap;text-overflow:ellipsis;overflow:hidden;flex:1}.layer-type{font-size:8px;color:var(--muted);text-transform:uppercase}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;margin-right:3px}.text-dot{background:#c37a94}.object-dot{background:#719dde}.character-dot{background:#a3ba59}.keyboard-hint{padding-top:6px;margin-bottom:0}.stage-panel{background:#e9ebe2;border-left:1px solid var(--line);border-right:1px solid var(--line);padding:16px 20px 21px;display:flex;flex-direction:column;justify-content:flex-start;min-width:0}.stage-top{display:flex;justify-content:space-between;align-items:center;font-family:monospace;font-size:10px;margin-bottom:18px}.stage-surround{display:flex;justify-content:center;align-items:center;min-height:360px;flex:1}.stage{background-color:#fffdf5;position:relative;width:100%;aspect-ratio:1;box-shadow:0 3px 15px #343c1d19;touch-action:none;overflow:hidden;border:1px solid #bfc3b4;isolation:isolate;max-height:620px}.stage.grid{background-image:linear-gradient(#84917c19 1px,transparent 1px),linear-gradient(90deg,#84917c19 1px,transparent 1px);background-size:6.25% 6.25%}.layout-box{position:absolute;display:block;appearance:none;border:1px solid #be7290;background:#ff9bb12d;border-radius:0;padding:0;text-align:center;touch-action:none;overflow:visible;color:#522f3b;user-select:none;cursor:grab;min-width:0;min-height:0}.layout-box:hover:not(:disabled){background:#ff9bb146;border-color:#9b4567}.layout-box.object{border-color:#678bc0;background:#9bc5ff36;color:#38537e}.layout-box.character{border-color:#91a950;background:#dfff7838;color:#56652a}.layout-box[aria-pressed=true]{outline:2px solid var(--ink);outline-offset:2px;z-index:3}.layout-box.dragging{cursor:grabbing}.box-content{display:flex;align-items:center;justify-content:center;position:absolute;inset:6px;overflow:hidden;font-family:Georgia,'Times New Roman',serif;line-height:1.12;white-space:pre-wrap;overflow-wrap:anywhere;pointer-events:none}.layout-box:not(.text) .box-content{font-family:Arial,sans-serif;font-size:12px;padding:8px;line-height:1.5}.box-number{position:absolute;left:-1px;top:-17px;font:9px monospace;letter-spacing:.4px;background:#fffdf5e8;border:1px solid currentColor;border-bottom:0;padding:2px 4px;line-height:12px;pointer-events:none;white-space:nowrap}.resize-handle{position:absolute;right:-8px;bottom:-8px;width:16px;height:16px;cursor:nwse-resize;background:var(--ink);border:3px solid #fffdf5;box-shadow:0 0 0 1px var(--ink);display:none;touch-action:none;z-index:5}.layout-box[aria-pressed=true] .resize-handle{display:block}.stage-bottom{display:flex;justify-content:space-between;gap:5px;font-size:9px;color:var(--muted);margin:17px 0 0;line-height:1.7}.stage-bottom .dot{width:6px;height:6px;margin-left:5px}.stage-bottom .dot:first-child{margin-left:0}.coordinate-note{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:24px}.coordinate-note p{font-size:10px;line-height:1.7;margin:0;color:var(--muted)}.coordinate-note strong{font-weight:500;color:var(--ink)}.coordinate-icon{border:1px solid #b6bca9;border-radius:50%;width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:17px}.inspector label{margin-bottom:18px;font-size:11px}.inspector input,.inspector select,.inspector textarea{font-size:12px}.inspector textarea{font-size:12px}.box-fields{display:grid;grid-template-columns:1fr 1fr;gap:0 10px}.box-fields label{margin-bottom:12px}.box-fields input{padding:8px}.coord-card{background:#eff2e4;border:1px solid #d8ddc9;padding:12px 10px;margin:7px 0 19px}.coord-card>span{display:block;font-size:8px;font-weight:700;letter-spacing:1px;color:var(--muted);margin-bottom:8px}.coord-card output{font:11px monospace;white-space:nowrap}.item-actions{display:flex;justify-content:space-between;gap:8px}.danger{color:#9f3e46;border-color:#e4c7c5;font-size:10px}.status{font-size:11px;line-height:1.6;min-height:18px;margin:11px 0 30px;color:var(--muted)}.status.error{color:#a53139;font-weight:700}.export-panel{display:grid;grid-template-columns:265px 1fr;background:var(--ink);border-radius:7px;color:var(--paper);overflow:hidden}.export-intro{padding:27px 25px}.export-intro h2{font-family:Georgia,serif;font-size:29px;font-weight:400;letter-spacing:-1px;margin:15px 0}.export-intro .section-number{color:var(--ink)}.export-intro p{font-size:12px;line-height:1.75;color:#d1d5c7}.export-facts{display:flex;gap:6px;flex-wrap:wrap;font-size:9px;margin-top:22px}.export-facts span{border:1px solid #646b56;padding:5px 7px;border-radius:3px}.export-code{padding:21px 24px 24px;border-left:1px solid #4a5141;min-width:0}.code-tabs{display:flex;gap:17px;border-bottom:1px solid #555c4a}.code-tabs button{border:0;border-radius:0;color:#b9beaf;font-size:12px;padding:8px 0 12px}.code-tabs button[aria-selected=true]{border-bottom:2px solid var(--green);color:var(--green)}.code-tabs button:hover:not(:disabled){background:transparent;color:#fff}.format-help{color:#b9beaf;font-size:10px;line-height:1.6;margin:13px 0}.export-code textarea{background:#1b2017;color:#e3e9d7;font:11px/1.55 monospace;height:237px;border-color:#4b5440;white-space:pre;padding:14px;tab-size:2}.export-actions{display:flex;gap:8px;margin-top:14px;flex-wrap:wrap}.primary{background:var(--green);color:var(--ink);border-color:var(--green);font-size:11px;font-weight:700;padding:11px 15px}.primary:hover:not(:disabled){background:#efffb2}.primary span{margin-left:14px}.export-actions .secondary{background:transparent;border-color:#747c65;color:var(--paper);font-size:10px;padding:11px 13px}.export-actions .secondary:hover{color:var(--ink);background:var(--paper)}.file-button{cursor:pointer;display:inline-block;border:1px solid;border-radius:5px;line-height:normal;font-weight:400}.file-button input{position:absolute;width:1px;height:1px;opacity:0;overflow:hidden}.file-button:focus-within{outline:3px solid #8caeeb;outline-offset:4px}.how-it-works{display:grid;grid-template-columns:repeat(3,1fr);gap:30px;margin:34px 0 49px}.how-it-works article{display:flex;gap:11px}.how-it-works article>span{font:11px monospace;border:1px solid var(--line);border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.how-it-works h3{font-size:12px;margin:4px 0 8px}.how-it-works p{font-size:11px;line-height:1.8;color:var(--muted);margin:0;max-width:340px}footer{max-width:1488px;margin:0 auto;border-top:1px solid var(--line);padding:27px 0 35px;display:flex;justify-content:space-between;gap:30px;font-size:10px;line-height:1.8;color:var(--muted)}footer strong{font-size:16px;letter-spacing:-.5px;color:var(--ink)}footer p{margin:4px 0}footer>p{margin-top:0}footer nav{display:flex;flex-direction:column;align-items:end;gap:3px}[hidden]{display:none!important}@media(min-width:1600px){.workspace{grid-template-columns:285px minmax(250px,1fr) 265px}}@media(max-width:1140px){.workspace{grid-template-columns:235px minmax(230px,1fr) 220px}.panel{padding:18px 14px}.stage-panel{padding:16px 14px}.brand-lab{display:none}main{padding:0 22px}.masthead{padding:0 22px}footer{margin:0 22px}.stage-surround{min-height:280px}.export-panel{grid-template-columns:235px 1fr}}@media(max-width:900px){.workspace{grid-template-columns:230px minmax(240px,1fr)}.inspector{grid-column:1/-1;border-top:1px solid var(--line)}.inspector #selection-fields{display:grid;grid-template-columns:1fr 1fr 1fr;gap:0 20px}.inspector #item-caption{min-height:110px}.inspector .box-fields{grid-column:2;grid-row:2}.inspector .coord-card{grid-column:3;grid-row:2}.inspector .item-actions{grid-column:1;grid-row:2;align-items:start}.inspector .panel-heading{margin-bottom:15px}.inspector label{margin-bottom:12px}.inspector label:first-child{grid-column:1}.stage-panel{border-right:0}.stage-surround{min-height:360px}.intro{gap:25px}.intro-note{width:215px}h1{font-size:48px}.how-it-works{gap:18px}.export-intro{padding:24px 20px}}@media(max-width:650px){.masthead{height:68px;padding:0 16px}.local-badge{font-size:9px;max-width:138px;line-height:1.5}.local-badge>span{flex-shrink:0}.brand{font-size:19px;gap:7px}main{padding:0 14px}.intro{display:block;padding:32px 0 25px}.eyebrow{font-size:8px;letter-spacing:1.7px}h1{font-size:43px;letter-spacing:-2.3px;margin-bottom:14px}.dek{font-size:13px;line-height:1.65}.intro-note{border:0;padding:12px 0 0;width:auto}.intro-note .note-index{display:none}.intro-note p{font-size:10px;margin:0}.intro-note a{font-size:9px;display:inline-block;margin-top:3px}.workspace-toolbar{align-items:start;flex-direction:column;gap:10px;padding:11px 0}.segmented{width:100%;justify-content:space-between}.segmented button{font-size:10px;padding:8px 10px}.history{align-self:end}.history button{font-size:9px;padding:5px 9px}.workspace{display:flex;flex-direction:column}.setup{order:2}.stage-panel{order:1;border:0;border-bottom:1px solid var(--line);padding:14px 18px 18px}.stage-surround{min-height:0;flex:none}.stage{max-height:520px}.stage-top{margin-bottom:18px}.coordinate-note{margin-top:14px}.stage-bottom{font-size:8px}.panel{padding:20px 17px}.setup{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}.setup>.panel-heading{grid-column:1/-1}.setup>.dimension-row{grid-column:1}.setup>.hint{grid-column:1}.setup>.check-row{grid-column:1}.setup>.full{grid-column:1}.setup>.field-label{grid-column:2;grid-row:2/6}.setup>.two-fields{grid-column:1/-1;grid-template-columns:1fr 1fr}.setup>#meta-help{grid-column:2}.setup>.layers-heading,.setup>.layers,.setup>.keyboard-hint{grid-column:1/-1}.setup>.layers-heading{margin-top:8px}.setup>.layers{grid-template-columns:1fr 1fr}.setup .full{margin:8px 0 17px;font-size:10px}.setup>.field-label textarea{height:176px;font-size:11px}.dimension-row input{font-size:12px}.dimension-row label{font-size:10px}.inspector{order:3}.inspector #selection-fields{grid-template-columns:1fr 1fr;gap:0 15px}.inspector #literal-field{grid-column:2;grid-row:1/3}.inspector label:has(#item-caption){grid-column:1;grid-row:2/4}.inspector #selection-fields>.box-fields{grid-column:2;grid-row:3/5;align-content:start}.inspector .coord-card{grid-column:1/-1;grid-row:5}.inspector .item-actions{grid-column:1/-1;grid-row:6}.inspector #item-caption{height:135px}.inspector #item-text{height:110px}.coord-card output{font-size:12px}.status{margin:10px 0 22px;font-size:10px}.export-panel{display:block}.export-intro{padding:23px 20px 17px}.export-intro h2{font-size:28px;display:inline-block;margin:0 0 0 10px}.export-intro p{font-size:11px;margin:13px 0}.export-facts{margin-top:13px}.export-code{padding:14px 17px 20px;border-left:0;border-top:1px solid #4a5141}.export-code textarea{height:245px;font-size:10px}.export-actions{gap:6px}.export-actions button,.export-actions .secondary{font-size:9px;padding:10px 11px}.primary span{margin-left:5px}.how-it-works{display:block;margin:27px 3px 34px}.how-it-works article{margin-bottom:20px}.how-it-works p{max-width:none}.how-it-works h3{font-size:12px;margin-top:3px}footer{margin:0 17px;padding:23px 0 30px;display:block;font-size:9px}footer>p{margin:16px 0}footer nav{align-items:start}.box-number{font-size:8px;top:-16px}.layout-box:not(.text) .box-content{font-size:9px;padding:3px}.resize-handle{width:22px;height:22px;right:-11px;bottom:-11px}}
2
+ .scene-tags{display:flex;gap:4px;flex-wrap:wrap;margin:5px 0}.tag-chip{display:inline-flex;align-items:center;gap:3px;max-width:100%;background:#edf0e0;border:1px solid #d4dbc3;border-radius:4px;font-size:9px;padding-left:6px}.tag-chip>span{overflow-wrap:anywhere;white-space:pre-wrap;min-width:0}.tag-chip>button{font-size:13px;padding:0 5px;border:0;line-height:22px}.tag-add{display:flex;gap:5px}.tag-add input{font-size:11px;padding:8px;width:100%}.tag-add button{padding:4px 9px;font-size:15px}.genre-field>label{font-size:11px}.layout-warning{font-size:11px;line-height:1.7;color:#725520;background:#fff0c5;border:1px solid #dbc685;border-radius:4px;padding:10px 13px;margin:-16px 0 25px}.layout-box.object:hover:not(:disabled){background:#9bc5ff4f;border-color:#4676b9}.layout-box.character:hover:not(:disabled){background:#dfff7852;border-color:#738f2c}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/bridge.mjs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { createInterface } from 'node:readline';
2
+ import { compileDraft, parseStrictJSON } from '../layout.mjs';
3
+ for await (const line of createInterface({ input:process.stdin })) {
4
+ try { console.log(JSON.stringify({ok:true,result:compileDraft(parseStrictJSON(line))})); }
5
+ catch(error) { console.log(JSON.stringify({ok:false,error:error.message})); }
6
+ }
tests/layout.test.mjs ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { compileDraft, makeDraft, makeLayout, parseLayout, parseStrictJSON, toCentralBox, fromCentralBox, resizeLayout, validateDraft, clampRect } from '../layout.mjs';
4
+
5
+ const draft = () => ({ width: 1536, height: 1024, bbox_coord_space: 'abs', bbox_format: 'xyxy', summary: { caption: 'A ceramic campaign.', meta: ['poster'], canvas: 'single', palette: ['ivory'] }, items: [{ label: 'text', caption: 'Dark-green lettering.', text: '把日常,过成诗。\n"MOSS" 🪴', bbox: [928, 272, 1408, 448], tags: ['title'], palette: ['green'] }] });
6
+
7
+ test('official landscape example converts to centered yxyx without content loss or mutation', () => {
8
+ const source = draft(), before = structuredClone(source), out = compileDraft(source);
9
+ assert.deepEqual(out.items[0].bbox, [-240, 160, -64, 640]);
10
+ assert.deepEqual(out.summary, source.summary); assert.equal(out.items[0].text, source.items[0].text);
11
+ assert.deepEqual(out.items[0].tags, ['title']); assert.deepEqual(out.items[0].palette, ['green']);
12
+ assert.equal(out.bbox_coord_space, 'abs_centralized'); assert.equal(out.bbox_format, 'yxyx');
13
+ assert.equal('width' in out, false); assert.equal('height' in out, false); assert.deepEqual(source, before);
14
+ });
15
+ test('portrait, fractions and compiler precision enclose before center subtraction', () => {
16
+ assert.deepEqual(toCentralBox([0, 0, 768, 1344], 768, 1344), [-672, -384, 672, 384]);
17
+ assert.deepEqual(toCentralBox([10.9, 20.1, 100.01, 200.99], 768, 1344), [-652, -374, -471, -283]);
18
+ assert.deepEqual(toCentralBox([1.00000000001, 2, 100, 200], 768, 1344), [-670, -383, -472, -284]);
19
+ assert.deepEqual(toCentralBox([0, 0, 99.99999999999, 199.99999999999], 768, 1344), [-672, -384, -472, -284]);
20
+ });
21
+ test('draft and compiled round trips retain optional creative fields and literal Unicode', () => {
22
+ const source = draft(), state = parseLayout(JSON.stringify(source));
23
+ assert.deepEqual(makeDraft(state), source);
24
+ assert.deepEqual(makeLayout(parseLayout(JSON.stringify(compileDraft(source)), { width: 1536, height: 1024 })), compileDraft(source));
25
+ assert.equal('id' in makeDraft(state).items[0], false);
26
+ assert.equal(state.items[0].text, source.items[0].text);
27
+ });
28
+ test('compiled import requires dimensions and honors non-square axis order', () => {
29
+ const source = compileDraft(draft()); assert.throws(() => parseLayout(JSON.stringify(source)), /width/);
30
+ const r = fromCentralBox([-288, -592, 352, 32], 1536, 1024);
31
+ assert.deepEqual(r, { x: 176, y: 224, w: 624, h: 640 });
32
+ });
33
+ test('invalid numeric geometry, zero area and out-of-canvas boxes are rejected', () => {
34
+ for (const box of [[-1,0,100,100], [0,0,1537,100], [100,0,50,100], [0,0,0,100], [0,0,100], [true,0,100,100], [0,0,NaN,100], [0,0,Infinity,100], [1,0,1.00000000001,100]]) {
35
+ const source = draft(); source.items[0].bbox = box; assert.throws(() => compileDraft(source));
36
+ }
37
+ const source = draft(); source.width = 1535; assert.equal(validateDraft(source).valid, false);
38
+ });
39
+ test('unknown fields, nested layouts and blank content cannot be silently lost', () => {
40
+ const modifications = [s=>s.other='unknown', s=>s.items[0].children=[], s=>s.items[0].id='editor-id', s=>s.summary.meta=[''], s=>s.summary.caption=' ', s=>s.items[0].text='', s=>s.items=[], s=>s.items[0].label='face', s=>s.summary.canvas='other'];
41
+ for (const modify of modifications) { const source = draft(); modify(source); assert.throws(() => compileDraft(source)); }
42
+ const source = draft(); delete source.items[0].text; assert.throws(() => compileDraft(source), /literal text/);
43
+ });
44
+ test('strict import rejects duplicate keys including escaped keys and invalid JSON constants', () => {
45
+ for (const value of ['{"x":1,"x":2}', '{"x":1,"\\u0078":2}', '{"a":{"x":1,"x":2}}', '[NaN]', '{"x":Infinity}', '{"x":1e400}', '{"x":01}', '{"x":1,}', 'null trailing', '"unterminated']) assert.throws(() => parseStrictJSON(value));
46
+ assert.deepEqual(parseStrictJSON('{"__proto__":{"polluted":true}}'), JSON.parse('{"__proto__":{"polluted":true}}'));
47
+ assert.equal({}.polluted, undefined);
48
+ });
49
+ test('width and height retain JSON integer-literal semantics', () => {
50
+ const source = JSON.stringify(draft());
51
+ assert.throws(() => parseLayout(source.replace('"width":1536', '"width":1536.0')), /integer JSON literal/);
52
+ assert.throws(() => parseLayout(source.replace('"height":1024', '"height":1.024e3')), /integer JSON literal/);
53
+ assert.equal(parseLayout(source).width, 1536);
54
+ });
55
+ test('resolution recommendations produce warnings and are separate from accepted schema', () => {
56
+ const source = draft(); source.width = source.height = 8192;
57
+ const result = validateDraft(source); assert.equal(result.valid, true); assert.equal(result.warnings.length, 1);
58
+ });
59
+ test('text validation follows Python Unicode whitespace without normalization', () => {
60
+ for (const space of ['\u0085', '\u001c', '\u2007', '\u3000']) {
61
+ const source = draft(); source.summary.caption = space; assert.equal(validateDraft(source).valid, false);
62
+ }
63
+ const source = draft(); source.summary.caption = '\ufeff';
64
+ assert.equal(compileDraft(source).summary.caption, '\ufeff');
65
+ });
66
+ test('overlap remains permitted and exact text, tags and arrays retain order', () => {
67
+ const source = draft(); source.items.push({ ...structuredClone(source.items[0]), text: 'Second\nline' });
68
+ assert.deepEqual(compileDraft(source).items.map(x=>x.bbox), [[-240,160,-64,640],[-240,160,-64,640]]);
69
+ assert.deepEqual(makeDraft(parseLayout(JSON.stringify(source))), source);
70
+ });
71
+ test('explicit scaling versus local clamp policy preserves inputs and enforces edit bounds', () => {
72
+ const state = parseLayout(JSON.stringify(draft())), before = structuredClone(state);
73
+ const half = resizeLayout(state, 768, 512);
74
+ assert.equal(half.items[0].x, state.items[0].x/2); assert.equal(half.items[0].h, state.items[0].h/2);
75
+ const clamped = resizeLayout(state, 768, 512, { scale:false });
76
+ assert.equal(clamped.items[0].x+clamped.items[0].w,768); assert.equal(clamped.items[0].y+clamped.items[0].h,448);
77
+ assert.deepEqual(state,before); assert.deepEqual(clampRect({x:-9,y:999,w:50,h:50},1024,1024), {x:0,y:974,w:50,h:50});
78
+ });
tests/reference_check.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare browser compilation with the pinned, unmodified OpenMOSS compiler.
2
+
3
+ No network, model weights, inference runtime or credentials are needed.
4
+ This tests accepted layout drafts and compiled output, not generated images.
5
+ """
6
+ import copy
7
+ import hashlib
8
+ import importlib.util
9
+ import json
10
+ from pathlib import Path
11
+ import random
12
+ import subprocess
13
+
14
+ ROOT = Path(__file__).resolve().parent.parent
15
+ REFERENCE = ROOT / 'tests/vendor/compile_prompt.py'
16
+ EXPECTED_SHA256 = '1d75780cf9f22f9ebed1a33d8100158f8d73ebfe59a5d435c1cc8c8e1374a8a3'
17
+ assert hashlib.sha256(REFERENCE.read_bytes()).hexdigest() == EXPECTED_SHA256
18
+ spec = importlib.util.spec_from_file_location('upstream_compile_prompt', REFERENCE)
19
+ upstream = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(upstream)
21
+
22
+ BASE = {'width': 1536, 'height': 1024, 'bbox_coord_space': 'abs', 'bbox_format': 'xyxy',
23
+ 'summary': {'caption': 'A ceramic campaign.', 'meta': ['poster'], 'canvas': 'single', 'palette': ['ivory']},
24
+ 'items': [{'label': 'text', 'caption': 'Green type.', 'text': '把日常,过成诗。\n"MOSS" 🪴',
25
+ 'bbox': [928, 272, 1408, 448], 'tags': ['title'], 'palette': ['green']}]}
26
+ cases = []
27
+
28
+ def add(name, source):
29
+ cases.append((name, json.dumps(source, ensure_ascii=False, allow_nan=False, separators=(',', ':'))))
30
+
31
+ add('landscape with Unicode and optional metadata', BASE)
32
+ rng = random.Random(20261023)
33
+ for i in range(400):
34
+ source = copy.deepcopy(BASE)
35
+ width, height = rng.choice([(1536, 1024), (768, 1344), (1024, 1024), (512, 512), (2048, 2048)])
36
+ source['width'], source['height'] = width, height
37
+ source['items'] = []
38
+ for j in range(rng.randint(1, 5)):
39
+ x1, x2 = sorted(rng.uniform(0, width) for _ in range(2))
40
+ y1, y2 = sorted(rng.uniform(0, height) for _ in range(2))
41
+ source['items'].append({'label': rng.choice(['object', 'character', 'text']), 'caption': 'A deliberately overlapping layer.', 'text': f'行 {i}\n🌿 {j}', 'bbox': [x1, y1, x2, y2]})
42
+ add(f'random layout {i}', source)
43
+
44
+ # Fractional coordinates around integer edges exercise Python round(..., 9).
45
+ for edge in [0, 1, 2, 99, 768, 1023]:
46
+ for offset in [-1e-8, -1e-9, -5e-10, -1e-11, 0, 1e-11, 5e-10, 1e-9, 1e-8]:
47
+ source = copy.deepcopy(BASE)
48
+ x1 = max(0, edge + offset)
49
+ source['items'][0]['bbox'] = [x1, 0.0009765625, min(1536, x1 + 10), 100.0009765625]
50
+ add(f'precision edge {edge} {offset}', source)
51
+
52
+ mutations = [
53
+ lambda d: d.update(width=1535), lambda d: d.update(width=True), lambda d: d.update(width=0),
54
+ lambda d: d.update(bbox_format='yxyx'), lambda d: d.update(bbox_coord_space='relative'),
55
+ lambda d: d.update(extra='unknown'), lambda d: d.update(items=[]),
56
+ lambda d: d['summary'].update(caption=' '), lambda d: d['summary'].update(meta=[None]),
57
+ lambda d: d['summary'].update(canvas='unknown'), lambda d: d['summary'].update(id=1),
58
+ lambda d: d['items'][0].update(bbox=[0, 0, 0, 1]),
59
+ lambda d: d['items'][0].update(bbox=[0, 0, 1537, 100]),
60
+ lambda d: d['items'][0].update(bbox=[False, 0, 100, 100]),
61
+ lambda d: d['items'][0].update(bbox=[1, 0, 1.00000000001, 100]),
62
+ lambda d: d['items'][0].update(label='face'), lambda d: d['items'][0].update(text=''),
63
+ lambda d: d['items'][0].pop('text'), lambda d: d['items'][0].update(tags=[3]),
64
+ lambda d: d['items'][0].update(children=[]), lambda d: d['items'][0].update(id='ui-id'),
65
+ ]
66
+ for i, mutate in enumerate(mutations):
67
+ source = copy.deepcopy(BASE); mutate(source); add(f'invalid draft {i}', source)
68
+ for point in [0x09, 0x1c, 0x20, 0x85, 0xa0, 0x1680, 0x2000, 0x2007, 0x2028, 0x202f, 0x205f, 0x3000, 0xfeff]:
69
+ source = copy.deepcopy(BASE); source['summary']['caption'] = chr(point)
70
+ add(f'Unicode whitespace U+{point:04X}', source)
71
+ base_json = json.dumps(BASE, ensure_ascii=False, separators=(',', ':'))
72
+ cases.extend([
73
+ ('float width literal', base_json.replace('"width":1536', '"width":1536.0')),
74
+ ('exponent height literal', base_json.replace('"height":1024', '"height":1.024e3')),
75
+ ('duplicate root key', base_json.replace('"width":1536', '"width":1536,"width":1024')),
76
+ ('escaped duplicate key', base_json.replace('"width":1536', '"width":1536,"\\u0077idth":1024')),
77
+ ('duplicate nested key', base_json.replace('"canvas":"single"', '"canvas":"single","canvas":"collage"')),
78
+ ])
79
+
80
+ proc = subprocess.run(['node', str(ROOT / 'tests/bridge.mjs')], input='\n'.join(raw for _, raw in cases)+'\n', text=True, capture_output=True, check=True)
81
+ actual = [json.loads(line) for line in proc.stdout.splitlines()]
82
+ assert len(actual) == len(cases), (len(actual), len(cases))
83
+ errors = []; accepted = rejected = 0
84
+ for (name, raw), result in zip(cases, actual):
85
+ try:
86
+ source = json.loads(raw, object_pairs_hook=upstream.unique_object, parse_constant=upstream.reject_constant)
87
+ expected = json.loads(upstream.compile_prompt(source)); ok = True
88
+ except (ValueError, TypeError, OverflowError):
89
+ ok = False; expected = None
90
+ accepted += ok; rejected += not ok
91
+ if ok != result['ok'] or (ok and expected != result.get('result')):
92
+ errors.append({'name': name, 'expected_ok': ok, 'expected': expected, 'actual': result})
93
+ report = {'source_revision': '52b87fca23acedc399d062d593bec388aee9926a', 'comparisons': len(cases), 'accepted': accepted, 'rejected': rejected, 'mismatches': len(errors), 'scope': 'Pinned layout compiler acceptance and parsed output equivalence, not image-generation quality'}
94
+ print(json.dumps(report, indent=2))
95
+ if errors:
96
+ print(json.dumps(errors[:5], ensure_ascii=False, indent=2)); raise SystemExit(1)
tests/vendor/compile_prompt.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Compile a caption or absolute-pixel Layout draft into a MOSS-Image prompt."""
3
+
4
+ import argparse
5
+ import json
6
+ import math
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def require(condition, message):
12
+ if not condition:
13
+ raise ValueError(message)
14
+
15
+
16
+ def fields(value, allowed, required, location):
17
+ require(isinstance(value, dict), f"{location} must be an object")
18
+ unknown = value.keys() - allowed
19
+ missing = required - value.keys()
20
+ require(not unknown, f"{location}: unknown fields {sorted(unknown)}")
21
+ require(not missing, f"{location}: missing fields {sorted(missing)}")
22
+
23
+
24
+ def text(value, location):
25
+ require(isinstance(value, str) and bool(value.strip()), f"{location} must be nonempty text")
26
+
27
+
28
+ def strings(value, location):
29
+ require(isinstance(value, list), f"{location} must be an array")
30
+ for index, item in enumerate(value):
31
+ text(item, f"{location}[{index}]")
32
+
33
+
34
+ def centered_box(box, width, height, location):
35
+ require(isinstance(box, list) and len(box) == 4, f"{location} must be [x1,y1,x2,y2]")
36
+ require(
37
+ all(type(v) in (int, float) and math.isfinite(v) for v in box),
38
+ f"{location} must contain four finite numbers",
39
+ )
40
+ x1, y1, x2, y2 = box
41
+ require(x1 < x2 and y1 < y2, f"{location} must have positive width and height")
42
+ require(
43
+ 0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height,
44
+ f"{location} exceeds the {width}x{height} canvas",
45
+ )
46
+ # Enclose in pixel edges before centering, as in Echidna generation export.
47
+ result = [
48
+ math.floor(round(y1, 9)) - height // 2,
49
+ math.floor(round(x1, 9)) - width // 2,
50
+ math.ceil(round(y2, 9)) - height // 2,
51
+ math.ceil(round(x2, 9)) - width // 2,
52
+ ]
53
+ require(result[0] < result[2] and result[1] < result[3],
54
+ f"{location} is too small at export precision")
55
+ return result
56
+
57
+
58
+ def compile_prompt(draft):
59
+ require(isinstance(draft, dict), "Draft must be a JSON object")
60
+ if "caption" in draft:
61
+ fields(draft, {"caption"}, {"caption"}, "Caption draft")
62
+ text(draft["caption"], "caption")
63
+ return draft["caption"]
64
+
65
+ root_fields = {"width", "height", "bbox_coord_space", "bbox_format", "summary", "items"}
66
+ fields(draft, root_fields, root_fields, "Layout draft")
67
+ width, height = draft["width"], draft["height"]
68
+ for name, value in (("width", width), ("height", height)):
69
+ require(type(value) is int and value > 0 and value % 16 == 0,
70
+ f"{name} must be a positive integer multiple of 16")
71
+ require(draft["bbox_coord_space"] == "abs", "Draft bbox_coord_space must be abs")
72
+ require(draft["bbox_format"] == "xyxy", "Draft bbox_format must be xyxy")
73
+
74
+ summary = draft["summary"]
75
+ fields(summary, {"caption", "meta", "canvas", "palette"}, {"caption"}, "summary")
76
+ text(summary["caption"], "summary.caption")
77
+ if "canvas" in summary:
78
+ require(summary["canvas"] in ("single", "multi_panel", "collage"),
79
+ "summary.canvas must be single, multi_panel or collage")
80
+ for key in ("meta", "palette"):
81
+ if key in summary:
82
+ strings(summary[key], f"summary.{key}")
83
+
84
+ items = draft["items"]
85
+ require(isinstance(items, list) and bool(items), "items must be a nonempty flat array")
86
+ compiled_items = []
87
+ for index, item in enumerate(items):
88
+ location = f"items[{index}]"
89
+ fields(item, {"label", "caption", "bbox", "text", "tags", "palette"},
90
+ {"label", "caption", "bbox"}, location)
91
+ require(item["label"] in ("object", "character", "text"),
92
+ f"{location}.label must be object, character or text")
93
+ text(item["caption"], f"{location}.caption")
94
+ if item["label"] == "text":
95
+ require("text" in item, f"{location}: text items require literal text")
96
+ if "text" in item:
97
+ text(item["text"], f"{location}.text")
98
+ for key in ("tags", "palette"):
99
+ if key in item:
100
+ strings(item[key], f"{location}.{key}")
101
+ compiled_items.append({
102
+ key: centered_box(value, width, height, f"{location}.bbox") if key == "bbox" else value
103
+ for key, value in item.items()
104
+ })
105
+
106
+ result = {"summary": summary, "items": compiled_items,
107
+ "bbox_coord_space": "abs_centralized", "bbox_format": "yxyx"}
108
+ return json.dumps(result, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
109
+
110
+
111
+ def unique_object(pairs):
112
+ result = {}
113
+ for key, value in pairs:
114
+ require(key not in result, f"Duplicate JSON key: {key}")
115
+ result[key] = value
116
+ return result
117
+
118
+
119
+ def reject_constant(value):
120
+ raise ValueError(f"Invalid JSON number: {value}")
121
+
122
+
123
+ def main():
124
+ parser = argparse.ArgumentParser(description=__doc__)
125
+ parser.add_argument("draft", type=Path, help="UTF-8 caption or Layout JSON draft")
126
+ parser.add_argument("--output", type=Path, help="Write prompt to a file instead of stdout")
127
+ args = parser.parse_args()
128
+ try:
129
+ if args.output:
130
+ require(args.output.resolve() != args.draft.resolve(),
131
+ "Output must be separate from the editable draft")
132
+ draft = json.loads(args.draft.read_text(encoding="utf-8"),
133
+ object_pairs_hook=unique_object, parse_constant=reject_constant)
134
+ prompt = compile_prompt(draft)
135
+ if args.output:
136
+ args.output.parent.mkdir(parents=True, exist_ok=True)
137
+ args.output.write_text(prompt + "\n", encoding="utf-8")
138
+ print(f"Wrote {args.output}", file=sys.stderr)
139
+ else:
140
+ print(prompt)
141
+ except (OSError, ValueError, OverflowError) as error:
142
+ parser.exit(2, f"Error: {error}\n")
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
thumbnail.png ADDED
verify.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exercise the actual browser editor, with no model inference."""
2
+ import argparse
3
+ import functools
4
+ import http.server
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ import tempfile
9
+ import threading
10
+
11
+ from playwright.sync_api import sync_playwright, expect
12
+
13
+
14
+ ROOT = Path(__file__).parent
15
+
16
+
17
+ def main():
18
+ parser = argparse.ArgumentParser()
19
+ parser.add_argument('--base-url')
20
+ parser.add_argument('--artifacts', type=Path)
21
+ args = parser.parse_args()
22
+ server = None
23
+ if args.base_url:
24
+ base_url = args.base_url.rstrip('/') + '/'
25
+ else:
26
+ class QuietHandler(http.server.SimpleHTTPRequestHandler):
27
+ def log_message(self, *unused):
28
+ pass
29
+ handler = functools.partial(QuietHandler, directory=str(ROOT))
30
+ server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), handler)
31
+ threading.Thread(target=server.serve_forever, daemon=True).start()
32
+ base_url = f'http://127.0.0.1:{server.server_port}/'
33
+ checks = []
34
+ errors = []
35
+ requests = []
36
+ try:
37
+ with sync_playwright() as p:
38
+ launch_options = {'headless': True, 'args': ['--no-sandbox']}
39
+ if Path('/usr/bin/chromium').is_file():
40
+ launch_options['executable_path'] = '/usr/bin/chromium'
41
+ browser = p.chromium.launch(**launch_options)
42
+ try:
43
+ context = browser.new_context(viewport={'width': 1440, 'height': 1100}, reduced_motion='reduce', accept_downloads=True)
44
+ page = context.new_page()
45
+ page.on('pageerror', lambda error: errors.append(str(error)))
46
+ page.on('request', lambda request: requests.append(request.url))
47
+ page.goto(base_url, wait_until='networkidle')
48
+ page.wait_for_function('() => document.getElementById("json-output").value.startsWith("{")')
49
+
50
+ def output():
51
+ return json.loads(page.locator('#json-output').input_value())
52
+
53
+ def tab(name):
54
+ page.locator('#tab-' + name).click()
55
+
56
+ def check(name):
57
+ checks.append(name)
58
+
59
+ first = output()
60
+ assert first['bbox_coord_space'] == 'abs_centralized'
61
+ assert first['bbox_format'] == 'yxyx'
62
+ assert 'width' not in first and 'height' not in first
63
+ assert first['items'][0]['bbox'] == [-416, -384, -288, 384]
64
+ assert first['items'][2]['text'] == 'まだ見ぬ場所へ'
65
+ check('initial compiled layout and centered coordinates')
66
+
67
+ for example in ['poster', 'cup', 'type']:
68
+ page.locator(f'[data-example="{example}"]').click()
69
+ compiled = output()
70
+ assert compiled['items'] and all(item['label'] in ['text', 'object', 'character'] for item in compiled['items'])
71
+ assert all('id' not in item for item in compiled['items'])
72
+ tab('draft')
73
+ draft = output()
74
+ assert draft['width'] % 16 == draft['height'] % 16 == 0
75
+ assert draft['bbox_coord_space'] == 'abs' and draft['bbox_format'] == 'xyxy'
76
+ if example == 'cup':
77
+ assert compiled['items'][0]['bbox'] == [-288, -592, 352, 32]
78
+ assert draft['items'][1]['text'] == '把日常,过成诗。'
79
+ assert draft['items'][2]['text'] == '一杯温暖\n一刻从容'
80
+ with tempfile.TemporaryDirectory() as directory:
81
+ path = Path(directory) / 'draft.json'
82
+ path.write_text(json.dumps(draft, ensure_ascii=False))
83
+ page.locator('#import').set_input_files(path)
84
+ page.wait_for_function('() => document.getElementById("status").textContent.startsWith("Layout imported")')
85
+ assert output() == draft
86
+ tab('prompt')
87
+ assert output() == compiled
88
+ check('all three presets and exact editable JSON round trips')
89
+
90
+ page.locator('[data-example="poster"]').click()
91
+ box = page.locator('.layout-box').first
92
+ box.focus()
93
+ box.press('ArrowRight')
94
+ assert page.locator('#box-x').input_value() == '129'
95
+ box.press('Shift+ArrowDown')
96
+ assert page.locator('#box-y').input_value() == '106'
97
+ box.press('Alt+ArrowRight')
98
+ assert page.locator('#box-w').input_value() == '769'
99
+ page.locator('#undo').click()
100
+ assert page.locator('#box-w').input_value() == '768'
101
+ page.locator('#redo').click()
102
+ assert page.locator('#box-w').input_value() == '769'
103
+ box = page.locator('.layout-box').nth(1)
104
+ box.focus()
105
+ assert box.get_attribute('aria-pressed') == 'true'
106
+ assert page.locator('.layout-box[aria-pressed=true]').count() == 1
107
+ check('keyboard movement, resizing, focus selection, undo and redo')
108
+
109
+ page.locator('[data-example="poster"]').click()
110
+ box = page.locator('.layout-box').first
111
+ rect = box.bounding_box()
112
+ page.mouse.move(rect['x'] + rect['width'] / 2, rect['y'] + rect['height'] / 2)
113
+ page.mouse.down()
114
+ page.mouse.move(rect['x'] + rect['width'] / 2 + 22, rect['y'] + rect['height'] / 2 + 12, steps=4)
115
+ page.mouse.up()
116
+ assert float(page.locator('#box-x').input_value()) > 128
117
+ handle = page.locator('.layout-box').first.locator('.resize-handle').bounding_box()
118
+ old_w = float(page.locator('#box-w').input_value())
119
+ page.mouse.move(handle['x'] + handle['width'] / 2, handle['y'] + handle['height'] / 2)
120
+ page.mouse.down()
121
+ page.mouse.move(handle['x'] + handle['width'] / 2 - 18, handle['y'] + handle['height'] / 2 + 10, steps=3)
122
+ page.mouse.up()
123
+ assert float(page.locator('#box-w').input_value()) < old_w
124
+ check('pointer dragging and corner resizing')
125
+
126
+ before_x = page.locator('#box-x').input_value()
127
+ page.locator('#box-x').fill('999999')
128
+ page.locator('#box-x').press('Tab')
129
+ assert page.locator('#box-x').input_value() == before_x
130
+ assert 'inside the canvas' in page.locator('#status').inner_text()
131
+ page.locator('#scene-caption').fill('')
132
+ page.locator('#scene-caption').press('Tab')
133
+ assert page.locator('#copy').is_disabled() and page.locator('#download').is_disabled()
134
+ page.locator('#scene-caption').fill('A test composition with exact words.')
135
+ page.locator('#scene-caption').press('Tab')
136
+ assert page.locator('#copy').is_enabled()
137
+ check('invalid geometry rejected and incomplete export disabled')
138
+
139
+ page.locator('[data-example="poster"]').click()
140
+ page.locator('#canvas-width').fill('1536')
141
+ page.locator('#apply-size').click()
142
+ assert page.locator('#box-x').input_value() == '192'
143
+ assert page.locator('#box-w').input_value() == '1152'
144
+ page.locator('#scale-boxes').uncheck()
145
+ page.locator('#canvas-width').fill('1024')
146
+ page.locator('#apply-size').click()
147
+ assert 'fall outside' in page.locator('#status').inner_text()
148
+ assert page.locator('#canvas-readout').inner_text() == '1536 × 1024 px'
149
+ page.locator('#canvas-width').fill('2048')
150
+ page.locator('#apply-size').click()
151
+ assert page.locator('#box-x').input_value() == '192'
152
+ assert page.locator('#box-w').input_value() == '1152'
153
+ page.locator('#scale-boxes').check()
154
+ check('explicit proportional resize and preserved pixel positions')
155
+
156
+ page.locator('[data-example="cup"]').click()
157
+ tab('draft')
158
+ expected = page.locator('#json-output').input_value() + '\n'
159
+ with page.expect_download() as event:
160
+ page.locator('#download').click()
161
+ download = event.value
162
+ assert download.suggested_filename == 'moss-draft-1536x1024.json'
163
+ assert Path(download.path()).read_text() == expected
164
+ page.evaluate('Object.defineProperty(navigator,"clipboard",{configurable:true,value:{writeText:()=>Promise.reject(new Error("denied"))}})')
165
+ page.locator('#copy').click()
166
+ assert 'JSON is selected' in page.locator('#status').inner_text()
167
+ assert page.locator('#json-output').evaluate('(el)=>el.selectionEnd-el.selectionStart') == len(expected.rstrip('\n'))
168
+ check('exact Unicode download and clipboard denial fallback')
169
+
170
+ safe = output()
171
+ safe['summary']['palette'] = ['ivory', 'green']
172
+ safe['summary']['meta'] = ['print, editorial', 'studio\nphotography']
173
+ safe['items'][0]['tags'] = ['ceramic', 'handmade']
174
+ safe['items'][0]['palette'] = ['ivory']
175
+ safe['items'][1]['text'] = '<img src=x onerror="window.xss=1">\nمرحبا 🌿'
176
+ with tempfile.TemporaryDirectory() as directory:
177
+ path = Path(directory) / 'draft.json'
178
+ path.write_text(json.dumps(safe, ensure_ascii=False))
179
+ page.locator('#import').set_input_files(path)
180
+ page.wait_for_function('() => document.getElementById("status").textContent.startsWith("Layout imported")')
181
+ assert output() == safe
182
+ assert page.locator('#stage img').count() == 0
183
+ assert page.evaluate('window.xss') is None
184
+ assert '<img' in page.locator('.layout-box.text').first.inner_text()
185
+ page.locator('#scene-meta').fill('new, exact tag')
186
+ page.locator('#scene-meta').press('Enter')
187
+ safe['summary']['meta'].append('new, exact tag')
188
+ assert output() == safe
189
+ path.write_text(json.dumps({**safe, 'unexpected': True}))
190
+ page.locator('#import').set_input_files(path)
191
+ page.wait_for_function('() => document.getElementById("status").textContent.startsWith("Import failed")')
192
+ assert output() == safe
193
+ path.write_text('{"width":1024,"width":2048}')
194
+ page.locator('#import').set_input_files(path)
195
+ expect(page.locator('#status')).to_contain_text(re.compile('duplicate', re.I))
196
+ check('strict imports, optional fields preserved, literal HTML cannot execute')
197
+
198
+ page.locator('#add-character').click()
199
+ assert page.locator('#item-label').input_value() == 'character'
200
+ count = page.locator('.layout-box').count()
201
+ page.locator('#duplicate').click()
202
+ assert page.locator('.layout-box').count() == count + 1
203
+ page.locator('#delete').click()
204
+ assert page.locator('.layout-box').count() == count
205
+ page.locator('#undo').click()
206
+ assert page.locator('.layout-box').count() == count + 1
207
+ check('add, duplicate, remove and restore elements')
208
+
209
+ page.locator('[data-example="poster"]').click()
210
+ tab('prompt')
211
+ if args.artifacts:
212
+ args.artifacts.mkdir(parents=True, exist_ok=True)
213
+ page.evaluate('document.activeElement?.blur(); document.getElementById("json-output").scrollTop=0; window.scrollTo(0,0)')
214
+ page.screenshot(path=str(args.artifacts / 'moss-desktop.png'), full_page=True)
215
+
216
+ mobile = browser.new_context(viewport={'width': 390, 'height': 844}, device_scale_factor=1, is_mobile=True, has_touch=True, reduced_motion='reduce')
217
+ phone = mobile.new_page()
218
+ phone.on('pageerror', lambda error: errors.append(str(error)))
219
+ phone.on('request', lambda request: requests.append(request.url))
220
+ phone.goto(base_url, wait_until='networkidle')
221
+ phone.wait_for_function('() => document.getElementById("json-output").value.startsWith("{")')
222
+ assert phone.evaluate('document.documentElement.scrollWidth <= window.innerWidth')
223
+ phone.locator('.layout-box').first.scroll_into_view_if_needed()
224
+ r = phone.locator('.layout-box').first.bounding_box()
225
+ cdp = mobile.new_cdp_session(phone)
226
+ x, y = r['x'] + r['width'] / 2, r['y'] + r['height'] / 2
227
+ cdp.send('Input.dispatchTouchEvent', {'type': 'touchStart', 'touchPoints': [{'x': x, 'y': y}]})
228
+ cdp.send('Input.dispatchTouchEvent', {'type': 'touchMove', 'touchPoints': [{'x': x + 14, 'y': y + 12}]})
229
+ cdp.send('Input.dispatchTouchEvent', {'type': 'touchEnd', 'touchPoints': []})
230
+ assert float(phone.locator('#box-x').input_value()) > 128
231
+ phone.locator('.layout-box').first.scroll_into_view_if_needed()
232
+ r = phone.locator('.layout-box').first.locator('.resize-handle').bounding_box()
233
+ old_w = float(phone.locator('#box-w').input_value())
234
+ x, y = r['x'] + r['width'] / 2, r['y'] + r['height'] / 2
235
+ cdp.send('Input.dispatchTouchEvent', {'type': 'touchStart', 'touchPoints': [{'x': x, 'y': y}]})
236
+ cdp.send('Input.dispatchTouchEvent', {'type': 'touchMove', 'touchPoints': [{'x': x - 15, 'y': y + 10}]})
237
+ cdp.send('Input.dispatchTouchEvent', {'type': 'touchEnd', 'touchPoints': []})
238
+ assert float(phone.locator('#box-w').input_value()) < old_w
239
+ assert phone.evaluate('document.documentElement.scrollWidth <= window.innerWidth')
240
+ phone.locator('[data-example="type"]').click()
241
+ phone.locator('.layout-box').first.scroll_into_view_if_needed()
242
+ board = phone.locator('#stage').bounding_box()
243
+ assert abs(board['width'] / board['height'] - 1024 / 1280) < .01
244
+ check('mobile layout, actual touch drag and resize, portrait aspect ratio')
245
+ if args.artifacts:
246
+ phone.evaluate('document.activeElement?.blur(); document.getElementById("json-output").scrollTop=0; window.scrollTo(0,0)')
247
+ phone.screenshot(path=str(args.artifacts / 'moss-phone.png'), full_page=True)
248
+ mobile.close()
249
+ allowed_origin = '/'.join(base_url.split('/')[:3])
250
+ foreign = [url for url in requests if not url.startswith(allowed_origin + '/') and not url.startswith('blob:') and not url.startswith('data:')]
251
+ assert not foreign, foreign
252
+ assert not errors, errors
253
+ check('no external network requests or browser runtime errors')
254
+ context.close()
255
+ finally:
256
+ browser.close()
257
+ finally:
258
+ if server:
259
+ server.shutdown()
260
+ server.server_close()
261
+ print(json.dumps({'passed': len(checks), 'checks': checks, 'runtime_errors': errors, 'base_url': args.base_url or 'local'}, indent=2))
262
+
263
+
264
+ if __name__ == '__main__':
265
+ main()