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