@oim-media/spec 0.0.0-stage → 1.27.8

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 (130) hide show
  1. package/BINARY_SPEC.md +186 -0
  2. package/CANONICAL_SPEC.md +409 -0
  3. package/LICENSE +55 -0
  4. package/NOTICE +6 -0
  5. package/README.md +444 -3
  6. package/action-cases.json +13 -0
  7. package/artistic-text-cases.json +690 -0
  8. package/keyframe-cases.json +32 -0
  9. package/keys.json +22 -0
  10. package/marks-cases.json +34 -0
  11. package/package.json +45 -3
  12. package/pattern-cases.json +552 -0
  13. package/schema/oim.schema.json +2920 -0
  14. package/schema/ovm.schema.json +2701 -0
  15. package/text-effect-cases.json +835 -0
  16. package/tools/parity-core.mjs +122 -0
  17. package/tools/parity.mjs +86 -0
  18. package/vectors/README.md +103 -0
  19. package/vectors/accessibility.expected.json +67 -0
  20. package/vectors/accessibility.oim.png +0 -0
  21. package/vectors/artistic-text.expected.json +84 -0
  22. package/vectors/artistic-text.oim.png +0 -0
  23. package/vectors/asymmetric-borders.expected.json +29 -0
  24. package/vectors/asymmetric-borders.oim.png +0 -0
  25. package/vectors/background-pattern-order.expected.json +95 -0
  26. package/vectors/background-pattern-order.oim.png +0 -0
  27. package/vectors/basic-text.expected.json +29 -0
  28. package/vectors/basic-text.oim.png +0 -0
  29. package/vectors/color-blending-opacity.expected.json +47 -0
  30. package/vectors/color-blending-opacity.oim.png +0 -0
  31. package/vectors/crop.expected.json +32 -0
  32. package/vectors/crop.oim.png +0 -0
  33. package/vectors/embedded-font-woff.expected.json +57 -0
  34. package/vectors/embedded-font-woff.oim.png +0 -0
  35. package/vectors/extreme-aspect-ratios.expected.json +29 -0
  36. package/vectors/extreme-aspect-ratios.oim.png +0 -0
  37. package/vectors/filter-effects.expected.json +33 -0
  38. package/vectors/filter-effects.oim.png +0 -0
  39. package/vectors/flip.expected.json +47 -0
  40. package/vectors/flip.oim.png +0 -0
  41. package/vectors/frame-shapes.expected.json +73 -0
  42. package/vectors/frame-shapes.oim.png +0 -0
  43. package/vectors/full-monty-composite.expected.json +96 -0
  44. package/vectors/full-monty-composite.oim.png +0 -0
  45. package/vectors/golden/accessibility.web.png +0 -0
  46. package/vectors/golden/artistic-text.web.png +0 -0
  47. package/vectors/golden/asymmetric-borders.web.png +0 -0
  48. package/vectors/golden/background-pattern-order.web.png +0 -0
  49. package/vectors/golden/basic-text.web.png +0 -0
  50. package/vectors/golden/color-blending-opacity.web.png +0 -0
  51. package/vectors/golden/crop.web.png +0 -0
  52. package/vectors/golden/embedded-font-woff.web.png +0 -0
  53. package/vectors/golden/exceptions.json +3 -0
  54. package/vectors/golden/extreme-aspect-ratios.web.png +0 -0
  55. package/vectors/golden/filter-effects.web.png +0 -0
  56. package/vectors/golden/flip.web.png +0 -0
  57. package/vectors/golden/frame-shapes.web.png +0 -0
  58. package/vectors/golden/full-monty-composite.web.png +0 -0
  59. package/vectors/golden/groups-and-parallax.web.png +0 -0
  60. package/vectors/golden/image-fill.web.png +0 -0
  61. package/vectors/golden/interactive-chained-actions.web.png +0 -0
  62. package/vectors/golden/interactive-hover.web.png +0 -0
  63. package/vectors/golden/interactive.web.png +0 -0
  64. package/vectors/golden/layer-blur.web.png +0 -0
  65. package/vectors/golden/layer-grain.web.png +0 -0
  66. package/vectors/golden/layer-ordering-zindex.web.png +0 -0
  67. package/vectors/golden/legacy-text-chunk.web.png +0 -0
  68. package/vectors/golden/media-filters.web.png +0 -0
  69. package/vectors/golden/multi-container-layout.web.png +0 -0
  70. package/vectors/golden/multilang-cjk-rtl.web.png +0 -0
  71. package/vectors/golden/nested-groups.web.png +0 -0
  72. package/vectors/golden/palettes.web.png +0 -0
  73. package/vectors/golden/parallax-infinite.web.png +0 -0
  74. package/vectors/golden/pattern-color-roles.web.png +0 -0
  75. package/vectors/golden/reedit-english.web.png +0 -0
  76. package/vectors/golden/reveal-and-play.web.png +0 -0
  77. package/vectors/golden/shadow-effects.web.png +0 -0
  78. package/vectors/golden/shape-shadows.web.png +0 -0
  79. package/vectors/golden/strokes-and-borders.web.png +0 -0
  80. package/vectors/golden/text-runs-precalculated.web.png +0 -0
  81. package/vectors/golden/transparent-background.web.png +0 -0
  82. package/vectors/golden/typography-styling.web.png +0 -0
  83. package/vectors/groups-and-parallax.expected.json +90 -0
  84. package/vectors/groups-and-parallax.oim.png +0 -0
  85. package/vectors/image-fill.expected.json +54 -0
  86. package/vectors/image-fill.oim.png +0 -0
  87. package/vectors/interactive-chained-actions.expected.json +77 -0
  88. package/vectors/interactive-chained-actions.oim.png +0 -0
  89. package/vectors/interactive-hover.expected.json +36 -0
  90. package/vectors/interactive-hover.oim.png +0 -0
  91. package/vectors/interactive.expected.json +121 -0
  92. package/vectors/interactive.oim.png +0 -0
  93. package/vectors/layer-blur.expected.json +48 -0
  94. package/vectors/layer-blur.oim.png +0 -0
  95. package/vectors/layer-grain.expected.json +52 -0
  96. package/vectors/layer-grain.oim.png +0 -0
  97. package/vectors/layer-ordering-zindex.expected.json +62 -0
  98. package/vectors/layer-ordering-zindex.oim.png +0 -0
  99. package/vectors/legacy-text-chunk.expected.json +29 -0
  100. package/vectors/legacy-text-chunk.oim.png +0 -0
  101. package/vectors/media-filters.expected.json +50 -0
  102. package/vectors/media-filters.oim.png +0 -0
  103. package/vectors/multi-container-layout.expected.json +45 -0
  104. package/vectors/multi-container-layout.oim.png +0 -0
  105. package/vectors/multilang-cjk-rtl.expected.json +30 -0
  106. package/vectors/multilang-cjk-rtl.oim.png +0 -0
  107. package/vectors/nested-groups.expected.json +68 -0
  108. package/vectors/nested-groups.oim.png +0 -0
  109. package/vectors/palettes.expected.json +57 -0
  110. package/vectors/palettes.oim.png +0 -0
  111. package/vectors/parallax-infinite.expected.json +39 -0
  112. package/vectors/parallax-infinite.oim.png +0 -0
  113. package/vectors/pattern-color-roles.expected.json +68 -0
  114. package/vectors/pattern-color-roles.oim.png +0 -0
  115. package/vectors/reedit-english.expected.json +45 -0
  116. package/vectors/reedit-english.oim.png +0 -0
  117. package/vectors/reveal-and-play.expected.json +107 -0
  118. package/vectors/reveal-and-play.oim.png +0 -0
  119. package/vectors/shadow-effects.expected.json +34 -0
  120. package/vectors/shadow-effects.oim.png +0 -0
  121. package/vectors/shape-shadows.expected.json +62 -0
  122. package/vectors/shape-shadows.oim.png +0 -0
  123. package/vectors/strokes-and-borders.expected.json +31 -0
  124. package/vectors/strokes-and-borders.oim.png +0 -0
  125. package/vectors/text-runs-precalculated.expected.json +46 -0
  126. package/vectors/text-runs-precalculated.oim.png +0 -0
  127. package/vectors/transparent-background.expected.json +30 -0
  128. package/vectors/transparent-background.oim.png +0 -0
  129. package/vectors/typography-styling.expected.json +34 -0
  130. 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,444 @@
1
- # Temporary Holding Version
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.
1
+ # oim-spec — the format contract
2
+
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
+ vectors/ 34 signed .oim files, each with <name>.expected.json.
14
+ tools/ Read-only checks: contract-summary.mjs, canonical-check.mjs, style-usage.mjs.
15
+ ```
16
+
17
+ Published as `@oim-media/spec`. The tarball carries the schema, the vectors, both
18
+ specifications, `keys.json`, `marks-cases.json`, this README, LICENSE and NOTICE.
19
+
20
+ ## Versioning
21
+
22
+ `CONTRACT_VERSION` in `packages/core/src/compatibility.ts` is the version of the
23
+ **format**, not of any package. Each release tag names it: tag `vX.Y.Z` is valid only
24
+ when `X.Y.Z` equals `CONTRACT_VERSION`. `node tools/versions.mjs vX.Y.Z` checks this,
25
+ and the release workflow runs it before publishing. The `$id` and `specVersion` of
26
+ both schema files carry the same version. Every version has a one-line entry in the
27
+ history comment above `CONTRACT_VERSION`.
28
+
29
+ Packages can release without a format change. The root README describes what makes
30
+ a minor, a major or a patch.
31
+
32
+ ## Why it exists
33
+
34
+ The two repositories had copied the same code instead of sharing it, and the
35
+ copies drifted: `grupos.ts` accumulated 546 lines that differed between the
36
+ player and the editor, `layerComposition.ts` 775. A player in Swift or in Rust
37
+ would repeat that mistake once per language.
38
+
39
+ A contract in TypeScript only serves TypeScript. This directory is the part
40
+ that survives the language.
41
+
42
+ ## Who is the authority today
43
+
44
+ Today the source of truth is still `packages/core/src/schema.ts`: the JSON in
45
+ `schema/` is **generated** from that Zod schema, and a test fails if someone
46
+ edits the Zod without regenerating. This is deliberate — a generated contract
47
+ cannot drift from its implementation, which is exactly the failure this closes.
48
+
49
+ The day a second implementation exists, the direction reverses: the JSON
50
+ becomes the source and the TypeScript one more of the implementations that
51
+ satisfy it. Not before: maintaining by hand a specification that nobody else
52
+ reads is how contracts that lie accumulate.
53
+
54
+ ## Regenerate
55
+
56
+ ```
57
+ pnpm --filter @oim-media/core test -- -u
58
+ ```
59
+
60
+ ## Check whether the contract and the files have drifted
61
+
62
+ ```
63
+ node tools/contract-summary.mjs # readable report
64
+ node tools/contract-summary.mjs --json # the same data, for another tool
65
+ node tools/contract-summary.mjs --write # writes contract-summary.json
66
+
67
+ node tools/canonical-check.mjs # the signature: rebuilds the canonical form
68
+ # and verifies the real vectors
69
+ ```
70
+
71
+ A test watches that the JSON Schema follows the Zod, but **nothing compared the
72
+ contract against real signed files**. That gap had already let four divergences
73
+ through, found by hand on 18 September 2026; the worst, `reedicion` — who may
74
+ re-edit the work — goes in every file and is not declared anywhere.
75
+
76
+ The script reports, it does not fix or fail. Turning drift into a red test is
77
+ the right end state, but first the drift must be resolved: a test that starts
78
+ red teaches everyone to ignore it.
79
+
80
+ ## `content` and the main `*` (since 1.2.0)
81
+
82
+ On media layers (image, Lottie, vector), `content` can store the main media
83
+ under the key `"*"` and only the exceptions under their language code:
84
+
85
+ "content": { "*": "oim-media:abc", "pt": "oim-media:def" }
86
+
87
+ Reading rule: if `content` has `"*"`, the requested language uses **its own key
88
+ if it exists, even if empty**; if it does not exist, it uses `"*"`. There are no
89
+ further fallbacks. Without `"*"` (every file prior to 1.2.0, and every text),
90
+ the usual rule applies: the requested language, then `en`, `es`, `default` and
91
+ the first key.
92
+ ## `reedicion` and `name` at the root (since 1.4.0)
93
+
94
+ Since 1.23.0 the permission is written as `reedit` (see below); `reedicion` is
95
+ read as a legacy alias.
96
+
97
+ Both were in **every** published file and in **no** version of the contract. A
98
+ reader built by reading only the contract could not know they existed.
99
+
100
+ `reedicion` was the worse of the two because **it decides a permission**:
101
+
102
+ "reedicion": "publico" | "solo_autor" | "sellado"
103
+
104
+ An unknown value falls back to `solo_autor`, **never to `publico`**: the default
105
+ of a permission system is chosen by what happens when something goes wrong. A
106
+ reader that ignored it would treat as re-editable a work that is not.
107
+
108
+ It travels together with `immutable_lock`, and this is not redundancy:
109
+ `reedicion` is the declaration — it travels inside the file, so anyone who opens
110
+ it knows what its author intended — and `immutable_lock` is what the backend
111
+ stores and enforces. A declaration without enforcement is a suggestion; an
112
+ enforcement without a declaration is a closed door with no sign. `reeditPermission.ts`
113
+ is the canonical source of that rule, and it writes both fields at once so that
114
+ they cannot be separated.
115
+
116
+ `name` is the title of the work. Do not confuse it with `slot.name` or
117
+ `fonts[].name`.
118
+
119
+ ## Names in English (since 1.23.0)
120
+
121
+ The format keys are in English. `reedit` replaces `reedicion`, with the values
122
+ `public`, `author_only` and `sealed` in place of `publico`, `solo_autor` and
123
+ `sellado`. Writers emit only the English keys.
124
+
125
+ `reedicion` is read forever as a legacy alias. Every file signed before 1.23.0
126
+ carries it, and the signature covers the keys as they were written, so a reader
127
+ verifies the signature first and maps the legacy key afterwards
128
+ (`normalizeLegacyKeys`). An unknown value still falls back to `author_only`.
129
+
130
+ The exported names of the packages are English too. The Spanish ones remain as
131
+ deprecated aliases until 2.0.0. The player's `data-oim-*` attributes are doubled
132
+ in the same contract version: the English names are added and the Spanish ones
133
+ keep working.
134
+
135
+ ## Legacy keys on read
136
+
137
+ Files signed before 1.23.0 carry Spanish keys. A reader maps them to the English
138
+ ones after it has verified the signature, because the signature covers the keys as
139
+ they were written:
140
+
141
+ | Legacy key | English key | Legacy value | English value |
142
+ |---|---|---|---|
143
+ | `reedicion` | `reedit` | `publico` | `public` |
144
+ | | | `solo_autor` | `author_only` |
145
+ | | | `sellado` | `sealed` |
146
+
147
+ A missing or unknown value becomes `author_only`, never `public`. If a file has both
148
+ keys, `reedit` wins. The mapping is `normalizeLegacyKeys` in
149
+ `packages/core/src/legacyKeys.ts`. Writers emit only the English keys, and the
150
+ player's `data-oim-*` attributes keep their Spanish names as aliases until 2.0.0.
151
+
152
+ ## What is retired is marked, not deleted (since 1.4.0)
153
+
154
+ `terms_accepted` and `redirect_membership_verified` carry `deprecated: true` in
155
+ the JSON Schema, which is the **machine-readable** way of saying it: a new reader
156
+ does not have to interpret a comment to know it must not write them.
157
+
158
+ Version 1.1.0 left them behind when it retired `allow_external_urls`: their only
159
+ readers — the backend's signing paths — sit behind `if allow_external_urls`, a
160
+ key the contract no longer declares, so **the check cannot fire for a conforming
161
+ file**.
162
+
163
+ Marked and not deleted, on purpose. The backend still honours them if a file
164
+ carries the retired key; removing them from the contract would make it lie in
165
+ the other direction, which is the same failure with the sign flipped.
166
+
167
+ **`allowed_domains` is not on that list, although it has been said otherwise.**
168
+ It is still alive and the player enforces it: `worker.ts` turns it into
169
+ `isDomainAllowed` and `securityVerdict` answers `domain_blocked`. A test exists
170
+ that prevents it from being declared dead again.
171
+
172
+ Nothing is removed and `min_player` does not go up: an old file is still valid,
173
+ and a reader that ignores the four keys behaves exactly as before.
174
+
175
+ ## Embedded fonts (since 1.3.0)
176
+
177
+ The root may carry `fonts`: one entry per font variant the work uses, trimmed to
178
+ the characters it can display.
179
+
180
+ { "family": "Lora", "name": "oim-3f9a1c2b-Lora", "weight": 700,
181
+ "style": "italic", "format": "woff2", "data": "<base64>" }
182
+
183
+ A reader that understands it registers each entry under the name in `name` and
184
+ paints the layers of family `family` with that name first. A reader that does
185
+ not understand it ignores it: the layers still say `family` and are painted as
186
+ before. That is why `fonts` does not raise `min_player`.
187
+
188
+ **Online fonts are inherited behaviour** since 17 September 2026: the editor no
189
+ longer offers them and every new file carries its fonts inside. The reference
190
+ player keeps its Google loader only for files already published, and a new
191
+ reader does not have to implement it: without `fonts`, it is painted with the
192
+ system's fallback typeface. An `.oim` does not request anything from third
193
+ parties when it is opened, which is what 1.1.0 decided when it retired
194
+ `allow_external_urls`.
195
+
196
+ ## Style stops being free text (since 1.8.0)
197
+
198
+ Version 1.6 declared about forty style keys that the editor already wrote, but
199
+ almost all as free strings: declaring a key without saying what it accepts
200
+ documents that it exists, not how it is read. Version 1.8.0 gives them a shape:
201
+
202
+ - **A single colour type**, `cssColor`, for the sixteen colour keys. It accepts
203
+ hex, `rgb()`/`hsl()`, `transparent`, `none`, `currentColor`, `inherit` and
204
+ `var(--name)` with a fallback, because dynamic colours go into real files. It
205
+ is the portable twin of `isSafeCssColor`, the player's sanitiser: a test
206
+ requires that **everything the contract accepts, the player accepts**, never the
207
+ other way around.
208
+ - **Closed vocabularies** where the editor already closes them: colour roles, the
209
+ 14 patterns, the 8 pattern directions, the text gradient and text animation,
210
+ and `geoShape` gains `scribble`, which was written but not declared.
211
+ - **The three `any` have a shape**: `cuts` (mask cutouts), `fillGenerative`
212
+ (generator, seed, parameters and colours) and `fillGradient`.
213
+
214
+ `fillGradient` was the serious hole. It ends up in `background-image`, which
215
+ accepts a list of images, so `linear-gradient(red, blue), url(https://third-party/beacon)`
216
+ made opening the file request a resource from a third party. Now the contract
217
+ accepts **a single** gradient with colour functions inside, and the player discards
218
+ anything else even if the file does not validate against the contract.
219
+
220
+ Measured on 24 September 2026: the 42 `.oim` files on disk, including the 20
221
+ vectors, validate against 1.8.0.
222
+
223
+ ## No raw CSS (since 1.9.0)
224
+
225
+ `animation.custom_loop_css`, `custom_keyframes` and `custom_hover_css` carry
226
+ `deprecated: true` and **no player may apply them**. `loop: "custom"` and
227
+ `hover: "custom"` paint nothing.
228
+
229
+ They were CSS from the file pasted as-is into a style sheet. The filter only
230
+ rewrote `url(...)`, and there were three other ways of requesting a resource that
231
+ passed through intact: `@import "https://…"`, `image-set("https://…" 1x)` and a
232
+ `url` written with a CSS escape (`\75rl(…)`). `custom_hover_css` did not even go
233
+ through the filter and was pasted inside `{ … }`, so a `}` could leave the rule
234
+ and rewrite the entire player, including its own warnings.
235
+
236
+ The editor never wrote them and no file uses them: they are removed rather than
237
+ chasing the grammar of CSS with a blocklist. `cssAnimation` stays, restricted to
238
+ the only form the editor writes,
239
+ `background-animation-<direction> <n>s linear infinite`.
240
+
241
+ ## Named palettes (since 1.20.0)
242
+
243
+ The root of the `.oim` may carry `palettes` and `defaultPalette`:
244
+
245
+ - **`palettes`**: a map from name to palette. Each palette assigns colours to the
246
+ twelve roles of `DYNAMIC_COLOR_ROLES` (the eight from before plus `text-primary`,
247
+ `text-secondary`, `text-muted` and `text-on-accent`). At most six palettes. A
248
+ name matches `^[a-z][a-z0-9-]{0,23}$`. Colours are not validated in the
249
+ contract: the reader discards those that fail `isSafeCssColor` and keeps the
250
+ rest, the same as with a layer colour.
251
+ - **`defaultPalette`**: the name of the default palette. If it does not name a
252
+ palette in `palettes`, the reader ignores it.
253
+
254
+ **Resolution order** (`resolvePalette` in `@oim-media/core`):
255
+
256
+ 1. The palette requested by the host (the player's `palette` attribute).
257
+ 2. If the document carries `dark` and `light`, the one that `prefers-color-scheme` asks for.
258
+ 3. `defaultPalette`.
259
+ 4. None: each layer uses its fixed colour.
260
+
261
+ The result is written to the viewer as `--oim-palette-<role>`, and
262
+ `resolveCssColor(role, fixed)` emits `var(--oim-color-<role>, var(--oim-palette-<role>, fixed))`.
263
+ The host's `--oim-color-*` still win over the palette, and `allow-dynamic-colors="false"`
264
+ overrides all of them.
265
+
266
+ A 1.20.0 file read by an earlier player is shown with each layer's fixed
267
+ colours. Nothing breaks.
268
+
269
+ ## Conformance vectors
270
+
271
+ `vectors/` holds 36 signed `.oim` files and their expected reads. They are
272
+ generated by Xue Studios' signing service through the production pipeline and
273
+ committed to this repository; the generator itself is not part of it. See
274
+ [`vectors/README.md`](vectors/README.md).
275
+
276
+ ### How conformance uses them
277
+
278
+ `pnpm conformance`, run from the repository root, executes
279
+ `packages/core/src/conformance-vectors.test.ts`. For each `*.oim.png` in `vectors/`
280
+ the test extracts the metadata with `extractOIM`, parses it with the contract's
281
+ Zod schema, and compares the fields that two implementations can genuinely
282
+ disagree on: geometry already normalised, group hierarchy, parallax depth, typography,
283
+ declared interactivity and, from 1.23.0, the `reedit` permission. The test header
284
+ explains why the whole metadata is not compared.
285
+
286
+ A reader in another language should make the same comparison against the
287
+ `expected.json` next to each vector. [`vectors/README.md`](vectors/README.md)
288
+ lists the steps.
289
+
290
+ ### Visual conformance
291
+
292
+ Each vector also has a golden rendered by the web player, `vectors/golden/<vector>.web.png`,
293
+ and every other renderer is compared with it pixel by pixel. Procedure, metric and threshold:
294
+ [`CANONICAL_SPEC.md`](CANONICAL_SPEC.md) section 9.1.
295
+
296
+ ```bash
297
+ pnpm --filter @oim-media/spec goldens # regenerate the goldens (on the CI image only)
298
+ pnpm --filter @oim-media/spec parity <dir> --renderer flutter --out <diff-dir>
299
+ ```
300
+
301
+ CI runs it in the `parity` job, on the same runner as `test`. A self-hosted runner
302
+ (`vars.CI_RUNNER`) needs, once, with sudo: `sudo apt install unzip` (Flutter) and
303
+ `pnpm --filter @oim-media/spec exec playwright install-deps chromium` (Chromium's system libraries).
304
+ Known differences live in `vectors/golden/exceptions.json`, one entry per renderer and vector with
305
+ its reason; remove the entry in the same change that fixes the renderer.
306
+
307
+ ## Precedent
308
+
309
+ Lottie separated its specification from every implementation into `lottie/lottie-spec`
310
+ — `/schema` split by object, `/tests` with vectors — and let each player be
311
+ named as it wished in its ecosystem (`lottie-web`, `lottie-ios`,
312
+ `com.airbnb.android:lottie`). There is no `@lottie/*` scope: the identity
313
+ lives in the specification, not in the package name.
314
+
315
+ ## The canonical form of the signature
316
+
317
+ Without this section, a second implementation can read the entire format and
318
+ still **be unable to verify a single signature**. It is the only part of the
319
+ contract that completely blocks a new reader: everything else can be ignored and
320
+ degraded, the signature cannot.
321
+
322
+ The signature is **ECDSA P-256 with SHA-256**, in raw IEEE P1363 format — `r || s`,
323
+ 32 bytes each, 64 in total — encoded in base64 inside `signature`. It is not
324
+ DER: whoever uses a library that returns DER has to convert it.
325
+
326
+ ### The bytes that are signed
327
+
328
+ Neither the chunk nor the JSON as written in the file is signed. What is signed
329
+ is the result of these seven rules, in this order:
330
+
331
+ 1. **Start from the metadata object** already decoded from the chunk.
332
+ 2. **Remove from the root** `signature`, `key_version`, `timestamp` and
333
+ `creator_user_id`. Only from the root: if those names appear nested, they stay.
334
+ 3. **Normalise the numbers**: round to **6 decimals** and, if the result has no
335
+ fractional part, emit it as an integer (`2.0000001` → `2`).
336
+ 4. **Sort the keys** of all objects, recursively, by Unicode code point. Also
337
+ inside the objects that appear in arrays.
338
+ 5. **Serialise compactly**: no spaces, separators `,` and `:`.
339
+ 6. **Do not escape non-ASCII**: `Zürich` is emitted as-is, not as `Z\u00fcrich`.
340
+ 7. **Encode in UTF-8**. Those bytes are the ones that are signed and the ones
341
+ that are verified.
342
+
343
+ `signed_at` goes **inside** the signature and `timestamp` **outside**. This is
344
+ not an oversight: `timestamp` can be rewritten without breaking anything, and the
345
+ key-expiry check needs a date that cannot be forged.
346
+
347
+ `creator_user_id` is excluded because it is personal data, and a signed file is
348
+ exactly what its author can no longer delete.
349
+
350
+ ### The image seal
351
+
352
+ `image_hash` is the **hexadecimal SHA-256 of the concatenation of the payloads of
353
+ the `IDAT` chunks** of the PNG — only the data, without length, without type and
354
+ without CRC — and `image_hash_alg` is `sha256-idat`.
355
+
356
+ Because `image_hash` is one more metadata field, **the signature covers it**: with
357
+ a valid signature, the seal can no longer be changed. That is why the order
358
+ matters and is not negotiable: normalise the PNG, compute the seal, sign, and
359
+ insert the chunk **at byte level without re-encoding the image**. A simple "save
360
+ again" after signing changes the `IDAT` and the seal stops matching forever.
361
+
362
+ Files prior to 11 September 2026 carry no seal and are accepted without one.
363
+
364
+ ### Where this used to break
365
+
366
+ Until 20 September 2026 the two reference implementations — `serialize_canonical`
367
+ in Python and `serializeCanonical` in TypeScript — agreed **by construction, not
368
+ by agreement**, and disagreed on three inputs. With `packages/core/src/canonical.ts`
369
+ as the single source on the TypeScript side, measured again on 24 September:
370
+
371
+ | Input | Python | TypeScript | Status |
372
+ |---|---|---|---|
373
+ | `creator_user_id` at the root | removes it | removes it | **closed** |
374
+ | decimal with more than 6 digits | rounds | rounds | **closed** |
375
+ | keys outside the BMP | code point | code point | **closed** |
376
+ | decimal that becomes an integer when rounded | `2` | `2` | **closed on 24 Sept** |
377
+
378
+ The fourth was found by the review of 24 September and was an **ordering** issue
379
+ inside rule 3: `clean_floats` asked "is it an integer?" *before* rounding, and in a
380
+ single pass left `2.0000001` as `2.0` and `1e-7` as `0.0`, where TypeScript emits
381
+ `2` and `0`. The signer did not suffer it because it applies `clean_floats` twice;
382
+ whoever reimplemented the Python with a single pass did. It now rounds first, and
383
+ a test of the reference signer fixes the bytes.
384
+
385
+ **What governs is what is signed**: round to 6 decimals first and, if the result
386
+ has no fractional part, emit it as an integer. `-0` is emitted as `0`. No existing
387
+ signature changes: with two passes the result was already that.
388
+
389
+ A note on the citation: the comment in `canonical.ts` mentions RFC 8785 (JCS), but
390
+ JCS sorts keys by **UTF-16 units**, not by code point. OIM sorts by code point;
391
+ the two only differ with keys outside the BMP. The contract is this README, not the RFC.
392
+
393
+ ### What the script checks, and what it does not
394
+
395
+ node tools/canonical-check.mjs
396
+
397
+ It rebuilds the seven rules and **verifies the real signature of each vector**
398
+ with the v1 public key that the player carries inside. It does not compare against
399
+ a digest pointed to by hand: if the rules were wrong, the signature would not validate.
400
+
401
+ With the 20 vectors of the matrix — generated by the real pipeline and signed
402
+ with the production key — each rule was removed one at a time:
403
+
404
+ | Rule removed | Signatures that fail |
405
+ |---|---|
406
+ | 2 · root exclusions | 20 of 20 |
407
+ | 6 · unescaped UTF-8 | 8 of 20 |
408
+ | seal · SHA-256 of the `IDAT` | 19 of 19 sealed |
409
+ | 3 · numbers | **0** |
410
+ | 4 · key order | **0** |
411
+
412
+ Rule 6 and the seal, which before were only held up by known-answer cases,
413
+ **are now demonstrated by a signature**. Rules 3 and 4 are not, and **no vector
414
+ signed by our pipeline could ever do so**: the signer writes the file from its own
415
+ canonical bytes, so what remains in the chunk is already rounded and sorted. Those
416
+ two rules only show up when someone **serialises the metadata again** — an editor
417
+ that rewrites the chunk, another JSON library — and that is exactly where they
418
+ are needed.
419
+
420
+ That is why they remain covered by known-answer cases inside the script:
421
+ they **catch a regression in the script, they do not prove that the signer agrees**.
422
+ Proving it for real would require a hand-written vector, with unordered keys and a
423
+ long decimal, signed over its canonical form: a file the real pipeline never produces.
424
+
425
+ ## What each tool prints
426
+
427
+ | Command | Output |
428
+ |---|---|
429
+ | `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. |
430
+ | `node tools/contract-summary.mjs --json` | The same data as JSON on standard output. |
431
+ | `node tools/contract-summary.mjs --write` | Writes the JSON to `packages/spec/contract-summary.json`. |
432
+ | `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. |
433
+ | `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. |
434
+
435
+ The scripts are read-only, except `contract-summary.mjs --write`.
436
+
437
+ ## Embedded player keys
438
+
439
+ `keys.json` lists the public keys every player embeds (`kid`, `jwk`, and the
440
+ `not_after` / `verify_until` dates where they apply). At 1.25.0 it has `v1` and `v2`.
441
+ It is generated from the player's key set by `tools/keys-json.mjs` at the
442
+ repository root. `node tools/keys-json.mjs --check` exits 1 when the file has
443
+ drifted, and a test fails if the two disagree. Only public components are ever
444
+ 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
+ ]