@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.
- package/BINARY_SPEC.md +190 -0
- package/CANONICAL_SPEC.md +409 -0
- package/LICENSE +55 -0
- package/NOTICE +6 -0
- package/README.md +445 -2
- package/action-cases.json +13 -0
- package/artistic-text-cases.json +690 -0
- package/canonical-cases.json +6 -0
- package/keyframe-cases.json +32 -0
- package/keys.json +22 -0
- package/marks-cases.json +34 -0
- package/package.json +46 -3
- package/pattern-cases.json +552 -0
- package/schema/oim.schema.json +2920 -0
- package/schema/ovm.schema.json +2701 -0
- package/text-effect-cases.json +835 -0
- package/tools/parity-core.mjs +122 -0
- package/tools/parity.mjs +86 -0
- package/vectors/README.md +103 -0
- package/vectors/accessibility.expected.json +67 -0
- package/vectors/accessibility.oim.png +0 -0
- package/vectors/artistic-text.expected.json +84 -0
- package/vectors/artistic-text.oim.png +0 -0
- package/vectors/asymmetric-borders.expected.json +29 -0
- package/vectors/asymmetric-borders.oim.png +0 -0
- package/vectors/background-pattern-order.expected.json +95 -0
- package/vectors/background-pattern-order.oim.png +0 -0
- package/vectors/basic-text.expected.json +29 -0
- package/vectors/basic-text.oim.png +0 -0
- package/vectors/color-blending-opacity.expected.json +47 -0
- package/vectors/color-blending-opacity.oim.png +0 -0
- package/vectors/crop.expected.json +32 -0
- package/vectors/crop.oim.png +0 -0
- package/vectors/embedded-font-woff.expected.json +57 -0
- package/vectors/embedded-font-woff.oim.png +0 -0
- package/vectors/extreme-aspect-ratios.expected.json +29 -0
- package/vectors/extreme-aspect-ratios.oim.png +0 -0
- package/vectors/filter-effects.expected.json +33 -0
- package/vectors/filter-effects.oim.png +0 -0
- package/vectors/flip.expected.json +47 -0
- package/vectors/flip.oim.png +0 -0
- package/vectors/frame-shapes.expected.json +73 -0
- package/vectors/frame-shapes.oim.png +0 -0
- package/vectors/full-monty-composite.expected.json +96 -0
- package/vectors/full-monty-composite.oim.png +0 -0
- package/vectors/golden/accessibility.web.png +0 -0
- package/vectors/golden/artistic-text.web.png +0 -0
- package/vectors/golden/asymmetric-borders.web.png +0 -0
- package/vectors/golden/background-pattern-order.web.png +0 -0
- package/vectors/golden/basic-text.web.png +0 -0
- package/vectors/golden/color-blending-opacity.web.png +0 -0
- package/vectors/golden/crop.web.png +0 -0
- package/vectors/golden/embedded-font-woff.web.png +0 -0
- package/vectors/golden/exceptions.json +3 -0
- package/vectors/golden/extreme-aspect-ratios.web.png +0 -0
- package/vectors/golden/filter-effects.web.png +0 -0
- package/vectors/golden/flip.web.png +0 -0
- package/vectors/golden/frame-shapes.web.png +0 -0
- package/vectors/golden/full-monty-composite.web.png +0 -0
- package/vectors/golden/groups-and-parallax.web.png +0 -0
- package/vectors/golden/image-fill.web.png +0 -0
- package/vectors/golden/interactive-chained-actions.web.png +0 -0
- package/vectors/golden/interactive-hover.web.png +0 -0
- package/vectors/golden/interactive.web.png +0 -0
- package/vectors/golden/layer-blur.web.png +0 -0
- package/vectors/golden/layer-grain.web.png +0 -0
- package/vectors/golden/layer-ordering-zindex.web.png +0 -0
- package/vectors/golden/legacy-text-chunk.web.png +0 -0
- package/vectors/golden/media-filters.web.png +0 -0
- package/vectors/golden/multi-container-layout.web.png +0 -0
- package/vectors/golden/multilang-cjk-rtl.web.png +0 -0
- package/vectors/golden/nested-groups.web.png +0 -0
- package/vectors/golden/palettes.web.png +0 -0
- package/vectors/golden/parallax-infinite.web.png +0 -0
- package/vectors/golden/pattern-color-roles.web.png +0 -0
- package/vectors/golden/reedit-english.web.png +0 -0
- package/vectors/golden/reveal-and-play.web.png +0 -0
- package/vectors/golden/shadow-effects.web.png +0 -0
- package/vectors/golden/shape-shadows.web.png +0 -0
- package/vectors/golden/strokes-and-borders.web.png +0 -0
- package/vectors/golden/text-runs-precalculated.web.png +0 -0
- package/vectors/golden/transparent-background.web.png +0 -0
- package/vectors/golden/typography-styling.web.png +0 -0
- package/vectors/groups-and-parallax.expected.json +90 -0
- package/vectors/groups-and-parallax.oim.png +0 -0
- package/vectors/image-fill.expected.json +54 -0
- package/vectors/image-fill.oim.png +0 -0
- package/vectors/interactive-chained-actions.expected.json +77 -0
- package/vectors/interactive-chained-actions.oim.png +0 -0
- package/vectors/interactive-hover.expected.json +36 -0
- package/vectors/interactive-hover.oim.png +0 -0
- package/vectors/interactive.expected.json +121 -0
- package/vectors/interactive.oim.png +0 -0
- package/vectors/layer-blur.expected.json +48 -0
- package/vectors/layer-blur.oim.png +0 -0
- package/vectors/layer-grain.expected.json +52 -0
- package/vectors/layer-grain.oim.png +0 -0
- package/vectors/layer-ordering-zindex.expected.json +62 -0
- package/vectors/layer-ordering-zindex.oim.png +0 -0
- package/vectors/legacy-text-chunk.expected.json +29 -0
- package/vectors/legacy-text-chunk.oim.png +0 -0
- package/vectors/media-filters.expected.json +50 -0
- package/vectors/media-filters.oim.png +0 -0
- package/vectors/multi-container-layout.expected.json +45 -0
- package/vectors/multi-container-layout.oim.png +0 -0
- package/vectors/multilang-cjk-rtl.expected.json +30 -0
- package/vectors/multilang-cjk-rtl.oim.png +0 -0
- package/vectors/nested-groups.expected.json +68 -0
- package/vectors/nested-groups.oim.png +0 -0
- package/vectors/palettes.expected.json +57 -0
- package/vectors/palettes.oim.png +0 -0
- package/vectors/parallax-infinite.expected.json +39 -0
- package/vectors/parallax-infinite.oim.png +0 -0
- package/vectors/pattern-color-roles.expected.json +68 -0
- package/vectors/pattern-color-roles.oim.png +0 -0
- package/vectors/reedit-english.expected.json +45 -0
- package/vectors/reedit-english.oim.png +0 -0
- package/vectors/reveal-and-play.expected.json +107 -0
- package/vectors/reveal-and-play.oim.png +0 -0
- package/vectors/shadow-effects.expected.json +34 -0
- package/vectors/shadow-effects.oim.png +0 -0
- package/vectors/shape-shadows.expected.json +62 -0
- package/vectors/shape-shadows.oim.png +0 -0
- package/vectors/strokes-and-borders.expected.json +31 -0
- package/vectors/strokes-and-borders.oim.png +0 -0
- package/vectors/text-runs-precalculated.expected.json +46 -0
- package/vectors/text-runs-precalculated.oim.png +0 -0
- package/vectors/transparent-background.expected.json +30 -0
- package/vectors/transparent-background.oim.png +0 -0
- package/vectors/typography-styling.expected.json +34 -0
- 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
package/README.md
CHANGED
|
@@ -1,3 +1,446 @@
|
|
|
1
|
-
#
|
|
1
|
+
# oim-spec — the format contract
|
|
2
2
|
|
|
3
|
-
This
|
|
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
|
+
]
|