@oim-media/spec 0.0.0-stage → 1.27.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/BINARY_SPEC.md +190 -0
  2. package/CANONICAL_SPEC.md +409 -0
  3. package/LICENSE +55 -0
  4. package/NOTICE +6 -0
  5. package/README.md +445 -2
  6. package/action-cases.json +13 -0
  7. package/artistic-text-cases.json +690 -0
  8. package/canonical-cases.json +6 -0
  9. package/keyframe-cases.json +32 -0
  10. package/keys.json +22 -0
  11. package/marks-cases.json +34 -0
  12. package/package.json +46 -3
  13. package/pattern-cases.json +552 -0
  14. package/schema/oim.schema.json +2920 -0
  15. package/schema/ovm.schema.json +2701 -0
  16. package/text-effect-cases.json +835 -0
  17. package/tools/parity-core.mjs +122 -0
  18. package/tools/parity.mjs +86 -0
  19. package/vectors/README.md +103 -0
  20. package/vectors/accessibility.expected.json +67 -0
  21. package/vectors/accessibility.oim.png +0 -0
  22. package/vectors/artistic-text.expected.json +84 -0
  23. package/vectors/artistic-text.oim.png +0 -0
  24. package/vectors/asymmetric-borders.expected.json +29 -0
  25. package/vectors/asymmetric-borders.oim.png +0 -0
  26. package/vectors/background-pattern-order.expected.json +95 -0
  27. package/vectors/background-pattern-order.oim.png +0 -0
  28. package/vectors/basic-text.expected.json +29 -0
  29. package/vectors/basic-text.oim.png +0 -0
  30. package/vectors/color-blending-opacity.expected.json +47 -0
  31. package/vectors/color-blending-opacity.oim.png +0 -0
  32. package/vectors/crop.expected.json +32 -0
  33. package/vectors/crop.oim.png +0 -0
  34. package/vectors/embedded-font-woff.expected.json +57 -0
  35. package/vectors/embedded-font-woff.oim.png +0 -0
  36. package/vectors/extreme-aspect-ratios.expected.json +29 -0
  37. package/vectors/extreme-aspect-ratios.oim.png +0 -0
  38. package/vectors/filter-effects.expected.json +33 -0
  39. package/vectors/filter-effects.oim.png +0 -0
  40. package/vectors/flip.expected.json +47 -0
  41. package/vectors/flip.oim.png +0 -0
  42. package/vectors/frame-shapes.expected.json +73 -0
  43. package/vectors/frame-shapes.oim.png +0 -0
  44. package/vectors/full-monty-composite.expected.json +96 -0
  45. package/vectors/full-monty-composite.oim.png +0 -0
  46. package/vectors/golden/accessibility.web.png +0 -0
  47. package/vectors/golden/artistic-text.web.png +0 -0
  48. package/vectors/golden/asymmetric-borders.web.png +0 -0
  49. package/vectors/golden/background-pattern-order.web.png +0 -0
  50. package/vectors/golden/basic-text.web.png +0 -0
  51. package/vectors/golden/color-blending-opacity.web.png +0 -0
  52. package/vectors/golden/crop.web.png +0 -0
  53. package/vectors/golden/embedded-font-woff.web.png +0 -0
  54. package/vectors/golden/exceptions.json +3 -0
  55. package/vectors/golden/extreme-aspect-ratios.web.png +0 -0
  56. package/vectors/golden/filter-effects.web.png +0 -0
  57. package/vectors/golden/flip.web.png +0 -0
  58. package/vectors/golden/frame-shapes.web.png +0 -0
  59. package/vectors/golden/full-monty-composite.web.png +0 -0
  60. package/vectors/golden/groups-and-parallax.web.png +0 -0
  61. package/vectors/golden/image-fill.web.png +0 -0
  62. package/vectors/golden/interactive-chained-actions.web.png +0 -0
  63. package/vectors/golden/interactive-hover.web.png +0 -0
  64. package/vectors/golden/interactive.web.png +0 -0
  65. package/vectors/golden/layer-blur.web.png +0 -0
  66. package/vectors/golden/layer-grain.web.png +0 -0
  67. package/vectors/golden/layer-ordering-zindex.web.png +0 -0
  68. package/vectors/golden/legacy-text-chunk.web.png +0 -0
  69. package/vectors/golden/media-filters.web.png +0 -0
  70. package/vectors/golden/multi-container-layout.web.png +0 -0
  71. package/vectors/golden/multilang-cjk-rtl.web.png +0 -0
  72. package/vectors/golden/nested-groups.web.png +0 -0
  73. package/vectors/golden/palettes.web.png +0 -0
  74. package/vectors/golden/parallax-infinite.web.png +0 -0
  75. package/vectors/golden/pattern-color-roles.web.png +0 -0
  76. package/vectors/golden/reedit-english.web.png +0 -0
  77. package/vectors/golden/reveal-and-play.web.png +0 -0
  78. package/vectors/golden/shadow-effects.web.png +0 -0
  79. package/vectors/golden/shape-shadows.web.png +0 -0
  80. package/vectors/golden/strokes-and-borders.web.png +0 -0
  81. package/vectors/golden/text-runs-precalculated.web.png +0 -0
  82. package/vectors/golden/transparent-background.web.png +0 -0
  83. package/vectors/golden/typography-styling.web.png +0 -0
  84. package/vectors/groups-and-parallax.expected.json +90 -0
  85. package/vectors/groups-and-parallax.oim.png +0 -0
  86. package/vectors/image-fill.expected.json +54 -0
  87. package/vectors/image-fill.oim.png +0 -0
  88. package/vectors/interactive-chained-actions.expected.json +77 -0
  89. package/vectors/interactive-chained-actions.oim.png +0 -0
  90. package/vectors/interactive-hover.expected.json +36 -0
  91. package/vectors/interactive-hover.oim.png +0 -0
  92. package/vectors/interactive.expected.json +121 -0
  93. package/vectors/interactive.oim.png +0 -0
  94. package/vectors/layer-blur.expected.json +48 -0
  95. package/vectors/layer-blur.oim.png +0 -0
  96. package/vectors/layer-grain.expected.json +52 -0
  97. package/vectors/layer-grain.oim.png +0 -0
  98. package/vectors/layer-ordering-zindex.expected.json +62 -0
  99. package/vectors/layer-ordering-zindex.oim.png +0 -0
  100. package/vectors/legacy-text-chunk.expected.json +29 -0
  101. package/vectors/legacy-text-chunk.oim.png +0 -0
  102. package/vectors/media-filters.expected.json +50 -0
  103. package/vectors/media-filters.oim.png +0 -0
  104. package/vectors/multi-container-layout.expected.json +45 -0
  105. package/vectors/multi-container-layout.oim.png +0 -0
  106. package/vectors/multilang-cjk-rtl.expected.json +30 -0
  107. package/vectors/multilang-cjk-rtl.oim.png +0 -0
  108. package/vectors/nested-groups.expected.json +68 -0
  109. package/vectors/nested-groups.oim.png +0 -0
  110. package/vectors/palettes.expected.json +57 -0
  111. package/vectors/palettes.oim.png +0 -0
  112. package/vectors/parallax-infinite.expected.json +39 -0
  113. package/vectors/parallax-infinite.oim.png +0 -0
  114. package/vectors/pattern-color-roles.expected.json +68 -0
  115. package/vectors/pattern-color-roles.oim.png +0 -0
  116. package/vectors/reedit-english.expected.json +45 -0
  117. package/vectors/reedit-english.oim.png +0 -0
  118. package/vectors/reveal-and-play.expected.json +107 -0
  119. package/vectors/reveal-and-play.oim.png +0 -0
  120. package/vectors/shadow-effects.expected.json +34 -0
  121. package/vectors/shadow-effects.oim.png +0 -0
  122. package/vectors/shape-shadows.expected.json +62 -0
  123. package/vectors/shape-shadows.oim.png +0 -0
  124. package/vectors/strokes-and-borders.expected.json +31 -0
  125. package/vectors/strokes-and-borders.oim.png +0 -0
  126. package/vectors/text-runs-precalculated.expected.json +46 -0
  127. package/vectors/text-runs-precalculated.oim.png +0 -0
  128. package/vectors/transparent-background.expected.json +30 -0
  129. package/vectors/transparent-background.oim.png +0 -0
  130. package/vectors/typography-styling.expected.json +34 -0
  131. package/vectors/typography-styling.oim.png +0 -0
package/LICENSE ADDED
@@ -0,0 +1,55 @@
1
+ Apache License
2
+
3
+ Version 2.0, January 2004
4
+
5
+ http://www.apache.org/licenses/
6
+
7
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
8
+
9
+ 1. Definitions.
10
+
11
+ "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
16
+
17
+ "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
18
+
19
+ "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
20
+
21
+ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
22
+
23
+ "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
24
+
25
+ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
26
+
27
+ "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
28
+
29
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
30
+
31
+ 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
32
+
33
+ 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
34
+
35
+ 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
36
+
37
+ You must give any other recipients of the Work or Derivative Works a copy of this License; and
38
+
39
+ You must cause any modified files to carry prominent notices stating that You changed the files; and
40
+
41
+ You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
42
+
43
+ If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
44
+
45
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
46
+
47
+ 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
48
+
49
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
50
+
51
+ 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
52
+
53
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
54
+
55
+ END OF TERMS AND CONDITIONS
package/NOTICE ADDED
@@ -0,0 +1,6 @@
1
+ OIM Ecosystem
2
+ Copyright 2026 Xue Studios LLC
3
+
4
+ This product includes software developed at Xue Studios LLC.
5
+
6
+ "OIM" and the "OIM compatible" mark are not licensed under Apache-2.0.
package/README.md CHANGED
@@ -1,3 +1,446 @@
1
- # Temporary Holding Version
1
+ # oim-spec — the format contract
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ This is **not** a library. It is the definition of the `.oim` format, in data,
4
+ so that an implementation in any language can be correct without reading the
5
+ TypeScript.
6
+
7
+ ```
8
+ BINARY_SPEC.md Binary layout of the PNG container, compression and the image seal.
9
+ CANONICAL_SPEC.md Canonical form of the metadata, the bytes a signature covers.
10
+ schema/ oim.schema.json and ovm.schema.json: JSON Schema, generated from @oim-media/core.
11
+ keys.json Public keys every player embeds (kid, jwk and key dates). Public parts only.
12
+ marks-cases.json Known-answer cases for text marks, shared with the backend's validator tests.
13
+ canonical-cases.json Known-answer case for the canonical form, checked by the TypeScript, Rust and Dart implementations.
14
+ vectors/ 37 signed .oim files, each with <name>.expected.json.
15
+ tools/ Read-only checks: contract-summary.mjs, canonical-check.mjs, style-usage.mjs.
16
+ ```
17
+
18
+ Published as `@oim-media/spec`. The tarball carries the schema, the vectors, both
19
+ specifications, `keys.json`, `marks-cases.json`, this README, LICENSE and NOTICE.
20
+
21
+ ## Versioning
22
+
23
+ `CONTRACT_VERSION` in `packages/core/src/compatibility.ts` is the version of the
24
+ **format**, not of any package. Each release tag names it: tag `vX.Y.Z` is valid only
25
+ when `X.Y.Z` equals `CONTRACT_VERSION`. `node tools/versions.mjs vX.Y.Z` checks this,
26
+ and the release workflow runs it before publishing. The `$id` and `specVersion` of
27
+ both schema files carry the same version. Every version has a one-line entry in the
28
+ history comment above `CONTRACT_VERSION`.
29
+
30
+ Packages can release without a format change. The root README describes what makes
31
+ a minor, a major or a patch.
32
+
33
+ ## Why it exists
34
+
35
+ The two repositories had copied the same code instead of sharing it, and the
36
+ copies drifted: `grupos.ts` accumulated 546 lines that differed between the
37
+ player and the editor, `layerComposition.ts` 775. A player in Swift or in Rust
38
+ would repeat that mistake once per language.
39
+
40
+ A contract in TypeScript only serves TypeScript. This directory is the part
41
+ that survives the language.
42
+
43
+ ## Who is the authority today
44
+
45
+ Today the source of truth is still `packages/core/src/schema.ts`: the JSON in
46
+ `schema/` is **generated** from that Zod schema, and a test fails if someone
47
+ edits the Zod without regenerating. This is deliberate — a generated contract
48
+ cannot drift from its implementation, which is exactly the failure this closes.
49
+
50
+ Other implementations now exist in this repository: the Rust crate and the
51
+ Flutter SDK read the same files. They are held to the generated JSON Schema, the
52
+ two specifications, the conformance vectors and the shared case files, not to
53
+ the TypeScript source. The direction has not been reversed yet: the JSON Schema
54
+ is still generated from the Zod schema rather than written first.
55
+
56
+ ## Regenerate
57
+
58
+ ```
59
+ pnpm --filter @oim-media/core test -- -u
60
+ ```
61
+
62
+ ## Check whether the contract and the files have drifted
63
+
64
+ ```
65
+ node tools/contract-summary.mjs # readable report
66
+ node tools/contract-summary.mjs --json # the same data, for another tool
67
+ node tools/contract-summary.mjs --write # writes contract-summary.json
68
+
69
+ node tools/canonical-check.mjs # the signature: rebuilds the canonical form
70
+ # and verifies the real vectors
71
+ ```
72
+
73
+ A test watches that the JSON Schema follows the Zod, but **nothing compared the
74
+ contract against real signed files**. That gap had already let four divergences
75
+ through, found by hand on 18 September 2026; the worst, `reedicion` — who may
76
+ re-edit the work — goes in every file and is not declared anywhere.
77
+
78
+ The script reports, it does not fix or fail. Turning drift into a red test is
79
+ the right end state, but first the drift must be resolved: a test that starts
80
+ red teaches everyone to ignore it.
81
+
82
+ ## `content` and the main `*` (since 1.2.0)
83
+
84
+ On media layers (image, Lottie, vector), `content` can store the main media
85
+ under the key `"*"` and only the exceptions under their language code:
86
+
87
+ "content": { "*": "oim-media:abc", "pt": "oim-media:def" }
88
+
89
+ Reading rule: if `content` has `"*"`, the requested language uses **its own key
90
+ if it exists, even if empty**; if it does not exist, it uses `"*"`. There are no
91
+ further fallbacks. Without `"*"` (every file prior to 1.2.0, and every text),
92
+ the usual rule applies: the requested language, then `en`, `es`, `default` and
93
+ the first key.
94
+ ## `reedicion` and `name` at the root (since 1.4.0)
95
+
96
+ Since 1.23.0 the permission is written as `reedit` (see below); `reedicion` is
97
+ read as a legacy alias.
98
+
99
+ Both were in **every** published file and in **no** version of the contract. A
100
+ reader built by reading only the contract could not know they existed.
101
+
102
+ `reedicion` was the worse of the two because **it decides a permission**:
103
+
104
+ "reedicion": "publico" | "solo_autor" | "sellado"
105
+
106
+ An unknown value falls back to `solo_autor`, **never to `publico`**: the default
107
+ of a permission system is chosen by what happens when something goes wrong. A
108
+ reader that ignored it would treat as re-editable a work that is not.
109
+
110
+ It travels together with `immutable_lock`, and this is not redundancy:
111
+ `reedicion` is the declaration — it travels inside the file, so anyone who opens
112
+ it knows what its author intended — and `immutable_lock` is what the backend
113
+ stores and enforces. A declaration without enforcement is a suggestion; an
114
+ enforcement without a declaration is a closed door with no sign. `reeditPermission.ts`
115
+ is the canonical source of that rule, and it writes both fields at once so that
116
+ they cannot be separated.
117
+
118
+ `name` is the title of the work. Do not confuse it with `slot.name` or
119
+ `fonts[].name`.
120
+
121
+ ## Names in English (since 1.23.0)
122
+
123
+ The format keys are in English. `reedit` replaces `reedicion`, with the values
124
+ `public`, `author_only` and `sealed` in place of `publico`, `solo_autor` and
125
+ `sellado`. Writers emit only the English keys.
126
+
127
+ `reedicion` is read forever as a legacy alias. Every file signed before 1.23.0
128
+ carries it, and the signature covers the keys as they were written, so a reader
129
+ verifies the signature first and maps the legacy key afterwards
130
+ (`normalizeLegacyKeys`). An unknown value still falls back to `author_only`.
131
+
132
+ The exported names of the packages are English too. The Spanish ones remain as
133
+ deprecated aliases until 2.0.0. The player's `data-oim-*` attributes are doubled
134
+ in the same contract version: the English names are added and the Spanish ones
135
+ keep working.
136
+
137
+ ## Legacy keys on read
138
+
139
+ Files signed before 1.23.0 carry Spanish keys. A reader maps them to the English
140
+ ones after it has verified the signature, because the signature covers the keys as
141
+ they were written:
142
+
143
+ | Legacy key | English key | Legacy value | English value |
144
+ |---|---|---|---|
145
+ | `reedicion` | `reedit` | `publico` | `public` |
146
+ | | | `solo_autor` | `author_only` |
147
+ | | | `sellado` | `sealed` |
148
+
149
+ A missing or unknown value becomes `author_only`, never `public`. If a file has both
150
+ keys, `reedit` wins. The mapping is `normalizeLegacyKeys` in
151
+ `packages/core/src/legacyKeys.ts`. Writers emit only the English keys, and the
152
+ player's `data-oim-*` attributes keep their Spanish names as aliases until 2.0.0.
153
+
154
+ ## What is retired is marked, not deleted (since 1.4.0)
155
+
156
+ `terms_accepted` and `redirect_membership_verified` carry `deprecated: true` in
157
+ the JSON Schema, which is the **machine-readable** way of saying it: a new reader
158
+ does not have to interpret a comment to know it must not write them.
159
+
160
+ Version 1.1.0 left them behind when it retired `allow_external_urls`: their only
161
+ readers — the backend's signing paths — sit behind `if allow_external_urls`, a
162
+ key the contract no longer declares, so **the check cannot fire for a conforming
163
+ file**.
164
+
165
+ Marked and not deleted, on purpose. The backend still honours them if a file
166
+ carries the retired key; removing them from the contract would make it lie in
167
+ the other direction, which is the same failure with the sign flipped.
168
+
169
+ **`allowed_domains` is not on that list, although it has been said otherwise.**
170
+ It is still alive and the player enforces it: `worker.ts` turns it into
171
+ `isDomainAllowed` and `securityVerdict` answers `domain_blocked`. A test exists
172
+ that prevents it from being declared dead again.
173
+
174
+ Nothing is removed and `min_player` does not go up: an old file is still valid,
175
+ and a reader that ignores the four keys behaves exactly as before.
176
+
177
+ ## Embedded fonts (since 1.3.0)
178
+
179
+ The root may carry `fonts`: one entry per font variant the work uses, trimmed to
180
+ the characters it can display.
181
+
182
+ { "family": "Lora", "name": "oim-3f9a1c2b-Lora", "weight": 700,
183
+ "style": "italic", "format": "woff2", "data": "<base64>" }
184
+
185
+ A reader that understands it registers each entry under the name in `name` and
186
+ paints the layers of family `family` with that name first. A reader that does
187
+ not understand it ignores it: the layers still say `family` and are painted as
188
+ before. That is why `fonts` does not raise `min_player`.
189
+
190
+ **Online fonts are inherited behaviour** since 17 September 2026: the editor no
191
+ longer offers them and every new file carries its fonts inside. The reference
192
+ player keeps its Google loader only for files already published, and a new
193
+ reader does not have to implement it: without `fonts`, it is painted with the
194
+ system's fallback typeface. An `.oim` does not request anything from third
195
+ parties when it is opened, which is what 1.1.0 decided when it retired
196
+ `allow_external_urls`.
197
+
198
+ ## Style stops being free text (since 1.8.0)
199
+
200
+ Version 1.6 declared about forty style keys that the editor already wrote, but
201
+ almost all as free strings: declaring a key without saying what it accepts
202
+ documents that it exists, not how it is read. Version 1.8.0 gives them a shape:
203
+
204
+ - **A single colour type**, `cssColor`, for the sixteen colour keys. It accepts
205
+ hex, `rgb()`/`hsl()`, `transparent`, `none`, `currentColor`, `inherit` and
206
+ `var(--name)` with a fallback, because dynamic colours go into real files. It
207
+ is the portable twin of `isSafeCssColor`, the player's sanitiser: a test
208
+ requires that **everything the contract accepts, the player accepts**, never the
209
+ other way around.
210
+ - **Closed vocabularies** where the editor already closes them: colour roles, the
211
+ 14 patterns, the 8 pattern directions, the text gradient and text animation,
212
+ and `geoShape` gains `scribble`, which was written but not declared.
213
+ - **The three `any` have a shape**: `cuts` (mask cutouts), `fillGenerative`
214
+ (generator, seed, parameters and colours) and `fillGradient`.
215
+
216
+ `fillGradient` was the serious hole. It ends up in `background-image`, which
217
+ accepts a list of images, so `linear-gradient(red, blue), url(https://third-party/beacon)`
218
+ made opening the file request a resource from a third party. Now the contract
219
+ accepts **a single** gradient with colour functions inside, and the player discards
220
+ anything else even if the file does not validate against the contract.
221
+
222
+ Measured on 24 September 2026: the 42 `.oim` files on disk, including the 20
223
+ vectors, validate against 1.8.0.
224
+
225
+ ## No raw CSS (since 1.9.0)
226
+
227
+ `animation.custom_loop_css`, `custom_keyframes` and `custom_hover_css` carry
228
+ `deprecated: true` and **no player may apply them**. `loop: "custom"` and
229
+ `hover: "custom"` paint nothing.
230
+
231
+ They were CSS from the file pasted as-is into a style sheet. The filter only
232
+ rewrote `url(...)`, and there were three other ways of requesting a resource that
233
+ passed through intact: `@import "https://…"`, `image-set("https://…" 1x)` and a
234
+ `url` written with a CSS escape (`\75rl(…)`). `custom_hover_css` did not even go
235
+ through the filter and was pasted inside `{ … }`, so a `}` could leave the rule
236
+ and rewrite the entire player, including its own warnings.
237
+
238
+ The editor never wrote them and no file uses them: they are removed rather than
239
+ chasing the grammar of CSS with a blocklist. `cssAnimation` stays, restricted to
240
+ the only form the editor writes,
241
+ `background-animation-<direction> <n>s linear infinite`.
242
+
243
+ ## Named palettes (since 1.20.0)
244
+
245
+ The root of the `.oim` may carry `palettes` and `defaultPalette`:
246
+
247
+ - **`palettes`**: a map from name to palette. Each palette assigns colours to the
248
+ twelve roles of `DYNAMIC_COLOR_ROLES` (the eight from before plus `text-primary`,
249
+ `text-secondary`, `text-muted` and `text-on-accent`). At most six palettes. A
250
+ name matches `^[a-z][a-z0-9-]{0,23}$`. Colours are not validated in the
251
+ contract: the reader discards those that fail `isSafeCssColor` and keeps the
252
+ rest, the same as with a layer colour.
253
+ - **`defaultPalette`**: the name of the default palette. If it does not name a
254
+ palette in `palettes`, the reader ignores it.
255
+
256
+ **Resolution order** (`resolvePalette` in `@oim-media/core`):
257
+
258
+ 1. The palette requested by the host (the player's `palette` attribute).
259
+ 2. If the document carries `dark` and `light`, the one that `prefers-color-scheme` asks for.
260
+ 3. `defaultPalette`.
261
+ 4. None: each layer uses its fixed colour.
262
+
263
+ The result is written to the viewer as `--oim-palette-<role>`, and
264
+ `resolveCssColor(role, fixed)` emits `var(--oim-color-<role>, var(--oim-palette-<role>, fixed))`.
265
+ The host's `--oim-color-*` still win over the palette, and `allow-dynamic-colors="false"`
266
+ overrides all of them.
267
+
268
+ A 1.20.0 file read by an earlier player is shown with each layer's fixed
269
+ colours. Nothing breaks.
270
+
271
+ ## Conformance vectors
272
+
273
+ `vectors/` holds 37 signed `.oim` files and their expected reads. They are
274
+ generated by Xue Studios' signing service through the production pipeline and
275
+ committed to this repository; the generator itself is not part of it. See
276
+ [`vectors/README.md`](vectors/README.md).
277
+
278
+ ### How conformance uses them
279
+
280
+ `pnpm conformance`, run from the repository root, executes
281
+ `packages/core/src/conformance-vectors.test.ts`. For each `*.oim.png` in `vectors/`
282
+ the test extracts the metadata with `extractOIM`, parses it with the contract's
283
+ Zod schema, and compares the fields that two implementations can genuinely
284
+ disagree on: geometry already normalised, group hierarchy, parallax depth, typography,
285
+ declared interactivity and, from 1.23.0, the `reedit` permission. The test header
286
+ explains why the whole metadata is not compared.
287
+
288
+ A reader in another language should make the same comparison against the
289
+ `expected.json` next to each vector. [`vectors/README.md`](vectors/README.md)
290
+ lists the steps.
291
+
292
+ ### Visual conformance
293
+
294
+ Each vector also has a golden rendered by the web player, `vectors/golden/<vector>.web.png`,
295
+ and every other renderer is compared with it pixel by pixel. Procedure, metric and threshold:
296
+ [`CANONICAL_SPEC.md`](CANONICAL_SPEC.md) section 9.1.
297
+
298
+ ```bash
299
+ pnpm --filter @oim-media/spec goldens # regenerate the goldens (on the CI image only)
300
+ pnpm --filter @oim-media/spec parity <dir> --renderer flutter --out <diff-dir>
301
+ ```
302
+
303
+ CI runs it in the `parity` job, on the same runner as `test`. A self-hosted runner
304
+ (`vars.CI_RUNNER`) needs, once, with sudo: `sudo apt install unzip` (Flutter) and
305
+ `pnpm --filter @oim-media/spec exec playwright install-deps chromium` (Chromium's system libraries).
306
+ Known differences live in `vectors/golden/exceptions.json`, one entry per renderer and vector with
307
+ its reason; remove the entry in the same change that fixes the renderer.
308
+
309
+ ## Precedent
310
+
311
+ Lottie separated its specification from every implementation into `lottie/lottie-spec`
312
+ — `/schema` split by object, `/tests` with vectors — and let each player be
313
+ named as it wished in its ecosystem (`lottie-web`, `lottie-ios`,
314
+ `com.airbnb.android:lottie`). There is no `@lottie/*` scope: the identity
315
+ lives in the specification, not in the package name.
316
+
317
+ ## The canonical form of the signature
318
+
319
+ Without this section, a second implementation can read the entire format and
320
+ still **be unable to verify a single signature**. It is the only part of the
321
+ contract that completely blocks a new reader: everything else can be ignored and
322
+ degraded, the signature cannot.
323
+
324
+ The signature is **ECDSA P-256 with SHA-256**, in raw IEEE P1363 format — `r || s`,
325
+ 32 bytes each, 64 in total — encoded in base64 inside `signature`. It is not
326
+ DER: whoever uses a library that returns DER has to convert it.
327
+
328
+ ### The bytes that are signed
329
+
330
+ Neither the chunk nor the JSON as written in the file is signed. What is signed
331
+ is the result of these seven rules, in this order:
332
+
333
+ 1. **Start from the metadata object** already decoded from the chunk.
334
+ 2. **Remove from the root** `signature`, `key_version`, `timestamp` and
335
+ `creator_user_id`. Only from the root: if those names appear nested, they stay.
336
+ 3. **Normalise the numbers**: round to **6 decimals** and, if the result has no
337
+ fractional part, emit it as an integer (`2.0000001` → `2`).
338
+ 4. **Sort the keys** of all objects, recursively, by Unicode code point. Also
339
+ inside the objects that appear in arrays.
340
+ 5. **Serialise compactly**: no spaces, separators `,` and `:`.
341
+ 6. **Do not escape non-ASCII**: `Zürich` is emitted as-is, not as `Z\u00fcrich`.
342
+ 7. **Encode in UTF-8**. Those bytes are the ones that are signed and the ones
343
+ that are verified.
344
+
345
+ `signed_at` goes **inside** the signature and `timestamp` **outside**. This is
346
+ not an oversight: `timestamp` can be rewritten without breaking anything, and the
347
+ key-expiry check needs a date that cannot be forged.
348
+
349
+ `creator_user_id` is excluded because it is personal data, and a signed file is
350
+ exactly what its author can no longer delete.
351
+
352
+ ### The image seal
353
+
354
+ `image_hash` is the **hexadecimal SHA-256 of the concatenation of the payloads of
355
+ the `IDAT` chunks** of the PNG — only the data, without length, without type and
356
+ without CRC — and `image_hash_alg` is `sha256-idat`.
357
+
358
+ Because `image_hash` is one more metadata field, **the signature covers it**: with
359
+ a valid signature, the seal can no longer be changed. That is why the order
360
+ matters and is not negotiable: normalise the PNG, compute the seal, sign, and
361
+ insert the chunk **at byte level without re-encoding the image**. A simple "save
362
+ again" after signing changes the `IDAT` and the seal stops matching forever.
363
+
364
+ Files prior to 11 September 2026 carry no seal and are accepted without one.
365
+
366
+ ### Where this used to break
367
+
368
+ Until 20 September 2026 the two reference implementations — `serialize_canonical`
369
+ in Python and `serializeCanonical` in TypeScript — agreed **by construction, not
370
+ by agreement**, and disagreed on three inputs. With `packages/core/src/canonical.ts`
371
+ as the single source on the TypeScript side, measured again on 24 September:
372
+
373
+ | Input | Python | TypeScript | Status |
374
+ |---|---|---|---|
375
+ | `creator_user_id` at the root | removes it | removes it | **closed** |
376
+ | decimal with more than 6 digits | rounds | rounds | **closed** |
377
+ | keys outside the BMP | code point | code point | **closed** |
378
+ | decimal that becomes an integer when rounded | `2` | `2` | **closed on 24 Sept** |
379
+
380
+ The fourth was found by the review of 24 September and was an **ordering** issue
381
+ inside rule 3: `clean_floats` asked "is it an integer?" *before* rounding, and in a
382
+ single pass left `2.0000001` as `2.0` and `1e-7` as `0.0`, where TypeScript emits
383
+ `2` and `0`. The signer did not suffer it because it applies `clean_floats` twice;
384
+ whoever reimplemented the Python with a single pass did. It now rounds first, and
385
+ a test of the reference signer fixes the bytes.
386
+
387
+ **What governs is what is signed**: round to 6 decimals first and, if the result
388
+ has no fractional part, emit it as an integer. `-0` is emitted as `0`. No existing
389
+ signature changes: with two passes the result was already that.
390
+
391
+ A note on the citation: the comment in `canonical.ts` mentions RFC 8785 (JCS), but
392
+ JCS sorts keys by **UTF-16 units**, not by code point. OIM sorts by code point;
393
+ the two only differ with keys outside the BMP. The contract is this README, not the RFC.
394
+
395
+ ### What the script checks, and what it does not
396
+
397
+ node tools/canonical-check.mjs
398
+
399
+ It rebuilds the seven rules and **verifies the real signature of each vector**
400
+ with the v1 public key that the player carries inside. It does not compare against
401
+ a digest pointed to by hand: if the rules were wrong, the signature would not validate.
402
+
403
+ With the 20 vectors of the matrix — generated by the real pipeline and signed
404
+ with the production key — each rule was removed one at a time:
405
+
406
+ | Rule removed | Signatures that fail |
407
+ |---|---|
408
+ | 2 · root exclusions | 20 of 20 |
409
+ | 6 · unescaped UTF-8 | 8 of 20 |
410
+ | seal · SHA-256 of the `IDAT` | 19 of 19 sealed |
411
+ | 3 · numbers | **0** |
412
+ | 4 · key order | **0** |
413
+
414
+ Rule 6 and the seal, which before were only held up by known-answer cases,
415
+ **are now demonstrated by a signature**. Rules 3 and 4 are not, and **no vector
416
+ signed by our pipeline could ever do so**: the signer writes the file from its own
417
+ canonical bytes, so what remains in the chunk is already rounded and sorted. Those
418
+ two rules only show up when someone **serialises the metadata again** — an editor
419
+ that rewrites the chunk, another JSON library — and that is exactly where they
420
+ are needed.
421
+
422
+ That is why they remain covered by known-answer cases inside the script:
423
+ they **catch a regression in the script, they do not prove that the signer agrees**.
424
+ Proving it for real would require a hand-written vector, with unordered keys and a
425
+ long decimal, signed over its canonical form: a file the real pipeline never produces.
426
+
427
+ ## What each tool prints
428
+
429
+ | Command | Output |
430
+ |---|---|
431
+ | `node tools/contract-summary.mjs` | The contract's root keys, which are required, whether undeclared keys are accepted and its sub-definitions. Then the keys the vectors carry, and three kinds of drift: keys in the files but not in the contract, retired keys still declared, and keys in force that no vector exercises. It reports and exits 0. |
432
+ | `node tools/contract-summary.mjs --json` | The same data as JSON on standard output. |
433
+ | `node tools/contract-summary.mjs --write` | Writes the JSON to `packages/spec/contract-summary.json`. |
434
+ | `node tools/canonical-check.mjs` | Rebuilds the seven canonical-form rules and verifies each vector's signature with the v1 public key. One line per vector; a vector without a signature is shown as `unsigned`. Exits 1 if any signature fails. |
435
+ | `node tools/style-usage.mjs <file-or-url> ...` | For each file, its date, slot count and style-key count. Then the counts of keys declared in the contract, read by the player and present in the files, and the keys present but undeclared, most used first. `--vectors` runs it on the conformance vectors. |
436
+
437
+ The scripts are read-only, except `contract-summary.mjs --write`.
438
+
439
+ ## Embedded player keys
440
+
441
+ `keys.json` lists the public keys every player embeds (`kid`, `jwk`, and the
442
+ `not_after` / `verify_until` dates where they apply). At 1.25.0 it has `v1` and `v2`.
443
+ It is generated from the player's key set by `tools/keys-json.mjs` at the
444
+ repository root. `node tools/keys-json.mjs --check` exits 1 when the file has
445
+ drifted, and a test fails if the two disagree. Only public components are ever
446
+ written.
@@ -0,0 +1,13 @@
1
+ [
2
+ { "name": "show", "actions": [{ "target": "a", "type": "show" }], "current": { "a": false }, "expected": { "visibility": { "a": true }, "texts": {} } },
3
+ { "name": "hide", "actions": [{ "target": "a", "type": "hide" }], "current": { "a": true }, "expected": { "visibility": { "a": false }, "texts": {} } },
4
+ { "name": "toggle a visible layer", "actions": [{ "target": "a", "type": "toggle" }], "current": { "a": true }, "expected": { "visibility": { "a": false }, "texts": {} } },
5
+ { "name": "toggle a hidden layer", "actions": [{ "target": "a", "type": "toggle" }], "current": { "a": false }, "expected": { "visibility": { "a": true }, "texts": {} } },
6
+ { "name": "toggle an unknown layer counts it as visible", "actions": [{ "target": "a", "type": "toggle" }], "current": {}, "expected": { "visibility": { "a": false }, "texts": {} } },
7
+ { "name": "setText with a value", "actions": [{ "target": "t", "type": "setText", "value": "Hola" }], "current": {}, "expected": { "visibility": {}, "texts": { "t": "Hola" } } },
8
+ { "name": "setText without a value clears", "actions": [{ "target": "t", "type": "setText" }], "current": {}, "expected": { "visibility": {}, "texts": { "t": "" } } },
9
+ { "name": "play changes no visibility", "actions": [{ "target": "i", "type": "play" }], "current": { "i": true }, "expected": { "visibility": {}, "texts": {} } },
10
+ { "name": "an empty target is skipped", "actions": [{ "target": "", "type": "hide" }], "current": {}, "expected": { "visibility": {}, "texts": {} } },
11
+ { "name": "chained toggle and hide", "actions": [{ "target": "panel", "type": "toggle" }, { "target": "alerta", "type": "hide" }], "current": { "panel": true, "alerta": true }, "expected": { "visibility": { "panel": false, "alerta": false }, "texts": {} } },
12
+ { "name": "the last action on a target wins", "actions": [{ "target": "a", "type": "hide" }, { "target": "a", "type": "show" }], "current": { "a": true }, "expected": { "visibility": { "a": true }, "texts": {} } }
13
+ ]