Spaces:
Running
Running
Launch the reference-checked MOSS layout editor
#1
by alphabell-research - opened
- .gitattributes +1 -0
- .github/workflows/checks.yml +28 -0
- .gitignore +7 -0
- LICENSE +202 -0
- NOTICE +10 -0
- README.md +77 -5
- app.js +439 -0
- favicon.svg +1 -0
- index.html +66 -17
- layout.mjs +261 -0
- preview.png +3 -0
- requirements-dev.txt +1 -0
- style.css +2 -28
- tests/bridge.mjs +6 -0
- tests/layout.test.mjs +78 -0
- tests/reference_check.py +96 -0
- tests/vendor/compile_prompt.py +146 -0
- thumbnail.png +0 -0
- verify.py +265 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+

|
| 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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
playwright==1.64.0
|
style.css
CHANGED
|
@@ -1,28 +1,2 @@
|
|
| 1 |
-
body {
|
| 2 |
-
|
| 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()
|