.oim Format Specification
The .oim (Object Interactive Media) file format is an open, browser-native media specification that encapsulates rich interactive vector layers, multi-language localized text, keyframe animations, and cryptographic authenticity signatures directly inside a standard Portable Network Graphics (PNG) container.
Architectural Overview
An .oim document operates on a 3-Layer Separation Architecture:
+-------------------------------------------------------------------------+
| Layer +1: Dynamic Interactive Layer |
| - Rendered live inside Shadow DOM (<oim-player>) |
| - Fully accessible, SEO indexable, selectable localized text |
| - Vector shapes (geo), Lottie, WAAPI keyframes, 2.5D Parallax |
| - Filters and masks left live, resolved at runtime |
+-------------------------------------------------------------------------+
| Layer 0: Separator (symbolic, stores nothing) |
+-------------------------------------------------------------------------+
| Layer −1: Baked (Pixels) |
| - Lossless PNG pixels (IDAT), decoded natively by GPU / <img> |
| - Whatever the author fixed in the image (may include text) |
| - Sealed via SHA-256 image_hash |
+-------------------------------------------------------------------------+Every .oim file is a valid PNG. If opened in a legacy image viewer, operating system thumbnailer, or standard HTML <img> tag, the file displays the baked Layer −1 base raster image without visual corruption or errors.
Physical Container & Chunk Structure
.oim utilizes standard ISO/IEC 15948:2004 (RFC 2083) PNG chunk structures.
+-------------------------------------------------------------+
| PNG Signature (8 bytes): \x89PNG\r\n\x1a\n |
+-------------------------------------------------------------+
| IHDR Chunk: Image Dimensions, Color Type, Compression |
+-------------------------------------------------------------+
| zTXt Chunk: Keyword "OIM_Data\0", method 0 |
| Payload: zlib(MetadataJSON), binary |
+-------------------------------------------------------------+
| IDAT Chunks: Standard Layer −1 Baked Image Bitstream |
+-------------------------------------------------------------+
| IEND Chunk: Image Trailer |
+-------------------------------------------------------------+The zTXt Metadata Chunk
- Keyword:
OIM_Data(ASCII, null-terminated0x00). (Legacy reader fallback:OIM_JSON_DATA). - Payload: one compression-method byte (
0) followed by the zlib-compressed metadata JSON, stored as binary (no Base64). Files signed before 29 October 2026 carry atEXtchunk with Base64 of raw Deflate instead; readers keep accepting it. - Chunk Sanitization: To eliminate payload smuggling attacks, the OIM compiler purges all pre-existing text chunks (
tEXt,zTXt,iTXt) when creating an.oimfile, ensuring only the verifiedOIM_Datapayload exists alongside pixel data.
Compression & Encoding Pipeline
+-------------------+ +-------------------+ +-------------------+
| Metadata JSON | ---> | Raw Deflate | ---> | RFC 4648 Base64 |
| (UTF-8 string) | | (wbits = -15) | | (ASCII string) |
+-------------------+ +-------------------+ +-------------------+- Raw Deflate Compression: Metadata JSON is compressed using Deflate with window bits set to
-15(wbits=-15, RFC 1951). This removes standard 2-byte zlib wrapper headers and Adler-32 trailers, saving bytes. - Base64 Encoding: The resulting compressed binary stream is encoded using RFC 4648 Base64.
- Decompression Fallback Protocol: Decoders must implement a 3-stage fallback:
- Check if the text is uncompressed Base64 JSON (
rawText.trim().startsWith('{')). - Attempt decompression via
deflate(standard zlib header). - Fall back to
deflate-raw(wbits=-15).
- Check if the text is uncompressed Base64 JSON (
Security Limits & Boundary Guarantees
| Constraint | Enforcement Boundary | Limit / Rule | Rationale |
|---|---|---|---|
| Zip Bomb Defense | Packaging & Demuxer | Max 10 MB (10,485,760 bytes) | Prevents memory exhaustion attacks from recursive decompression bombs. |
| Max Slot Capacity | Compiler & Backend | Max 150 slots | Guarantees fluid 60 FPS DOM playback and limits memory footprint. |
| Slot ID Uniqueness | Compiler & Validator | Unique string (1-128 chars) | Prevents ID collisions across interactive state actions. |
| No Page Redirects | Schema & Compiler | link & target forbidden | Documents cannot hijack browser navigation; interactions emit oim:action. |
| Color Sanitization | Schema & CSS Resolver | Positive whitelist regex | Eliminates CSS injection, exfiltration beacons, and style escape attacks. |
| Keyframe Thresholds | Animation Schema | Max 240 keyframes ($t \le 60s$) | Prevents excessive memory consumption in WAAPI animation loops. |
| Author Privacy | Packaging Pipeline | creator_user_id excluded | GDPR compliance: personal IDs are held in revocable registries, not sealed in files. |
Authoritative Zod Schema Reference
import { z } from "zod";
export const keyframeSchema = z.object({
t: z.number().min(0).max(60), // Timeline offset in seconds
o: z.number().min(0).max(1).optional(), // Opacity (0.0 to 1.0)
s: z.number().min(0).max(10).optional(), // Scale factor (0.0 to 10.0)
r: z.number().min(-3600).max(3600).optional(), // Rotation in degrees
x: z.number().optional(), // Translation X (pixels or cqw)
y: z.number().optional(), // Translation Y (pixels or cqw)
b: z.number().min(0).max(50).optional(), // Blur filter in pixels
e: z.union([ // Easing function
z.enum(["linear", "ease-in", "ease-out", "ease-in-out", "steps(1, end)"]),
z.tuple([z.number(), z.number(), z.number(), z.number()]), // [p1x, p1y, p2x, p2y]
]).optional(),
});
export const stateActionSchema = z.object({
target: z.string().min(1).max(128),
type: z.enum(["show", "hide", "toggle", "setText", "play"]), // play (1.22.0): runs the target's animation from the start
value: z.string().max(10000).optional(),
});
// How the layer enters and leaves when an action shows or hides it (1.22.0)
export const revealSchema = z.object({
effect: z.enum(["fade", "slide-up", "slide-down", "slide-left", "slide-right", "scale"]),
duration: z.number().min(0).max(2000), // ms
easing: keyframeEasingSchema.optional(), // absent = ease-out
});
export const interactiveStateSchema = z.object({
scale: z.number().min(0).max(10).optional(),
rotation: z.number().min(-360).max(360).optional(),
opacity: z.number().min(0).max(100).optional(), // Opacity: 0 to 100 percentage
color: z.string().max(64).optional(),
transitionDuration: z.number().min(0).max(10000).optional(), // Milliseconds
actions: z.array(stateActionSchema).max(64).optional(),
}).passthrough();
export const slotStatesSchema = z.object({
hover: interactiveStateSchema.optional(), // Pointer enter/leave
active: interactiveStateSchema.optional(), // Click / Tap persistent state
}).passthrough();
export const slotSchema = z.object({
id: z.string().min(1).max(128),
name: z.string().max(256).optional(), // Author-assigned label
type: z.enum([
"text", "image", "lottie", "zone", "geo", "draw",
"filter", "container", "group", "component", "mask", "svg", "pattern"
]).optional(),
align: z.enum(["left", "center", "right"]).optional(),
content: z.record(z.string(), z.string().max(10000000)).optional(), // Multilingual dict
style: styleSchema.optional(),
position: z.tuple([z.number().min(0).max(100), z.number().min(0).max(100)]).optional(),
parallax: parallaxSchema.optional(),
tracking_data: z.array(z.array(z.number()).min(2)).optional(),
});
export const OimMetadataSchema = z.object({
oim: z.string().optional(), // Format version ("1.0")
version: z.string().optional(),
min_player: z.string().max(16).optional(), // Minimum player compatibility
oim_id: z.string().max(128).optional(), // Global document UUID
media_type: z.enum(["image", "video"]).optional(),
image_hash: z.string().max(64).optional(), // SHA-256 of the baked image (Layer −1)
immutable_lock: z.boolean().optional(), // Sealing lock
reedit: z.enum(["public", "author_only", "sealed"]).optional(), // Re-edit permission (1.23.0). Legacy `reedicion` (Spanish values) is still read until 2.0.0.
canvas: z.object({
natural_width: z.number().positive(),
natural_height: z.number().positive(),
}).optional(),
lang: z.array(z.string().max(5)).optional(), // Supported language codes
slots: z.array(slotSchema), // Max 150 slots
capabilities: z.array(z.enum(["vector_parallax", "infinite_parallax", "path_morph", "physics_2d"])).optional(),
grain: z.object({ // Procedural analog noise overlay
enabled: z.boolean(),
intensity: z.number().min(1).max(100),
baseFrequency: z.number().min(0.1).max(2.0),
blendMode: z.enum(["overlay", "screen", "multiply", "soft-light"]),
}).optional(),
signature: z.string().optional(), // ECDSA P-256 signature (Base64)
key_version: z.string().optional(),
timestamp: z.string().datetime({ offset: true }).optional(),
}).passthrough();Per-layer grain (contract 1.10.0)
A layer can carry its own photographic grain: monochrome fractal noise that only shows where the layer has ink (inside the letters of a text, the area of a shape). It is separate from the document-level grain above, which covers the whole canvas.
style field | Type | Default | Meaning |
|---|---|---|---|
grain | boolean | false | Turns the layer's grain on. |
grainIntensity | 1–100 | 20 | The grain's opacity. |
grainSize | 1–100 | 50 | Grain size: 1 finest, 100 coarsest. |
grainBlend | overlay | soft-light | multiply | overlay | How it blends with the layer. |
Layer types that take grain: text, artistic-text, geo, svg, draw, image, pattern. Readers map grainSize to feTurbulence's baseFrequency with grainFrequency(size) from @oim-media/core (2.0 at size 1 down to 0.2 at size 100) and build the filter with buildLayerGrainFilter, so every reader paints the same pixels. The change is additive and declares no capability: a reader that does not know it paints the layer without grain.
Text marks (contract 1.11.0)
A text can format some of its phrases: colour, bold, italic, underline or a highlight. The phrase is wrapped in a tag inside content, and the mark's style lives once in the layer's style.marks, shared by every language:
"content": { "es": "Te irás. {hl}No te llevarás nada.{/hl}", "en": "You will leave. {hl}You will take nothing.{/hl}" },
"style": { "marks": { "hl": { "color": "#5a7d1f", "bold": true,
"highlight": { "style": "marker", "color": "rgba(217,249,157,.6)" } } } }| Mark field | Type | Meaning |
|---|---|---|
color / colorRole | CSS colour / dynamic role | Text colour; the role wins when the reader knows it. |
bold, italic, underline | boolean | Weight, style, underline. |
highlight | { style, color?, colorRole?, paddingX?, paddingY?, borderRadius? } | Highlight behind the phrase, line by line. style: marker | box | pill | underline-thick. |
- Mark names match
^[a-z][a-z0-9-]{0,23}$; at most 8 marks per layer. {name}opens,{/name}closes; marks do not nest or overlap.- A literal brace is doubled:
{{is{and}}is}. Text typed by a person is always escaped, so it can never become a mark. - The backend refuses to sign a text whose tags are malformed (
OIM_E_TEXT_MARKS_INVALID), so a signed.oimonly carries well-formed texts. - Readers use the one interpretation in
@oim-media/core:parseMarks(text, Object.keys(marks))for the runs andmarkStyle(mark)for the style. A file using marks declares thetext_markscapability. style.textHighlightis retired (10 Oct 2026): it highlighted the whole text. Modern OIM generators no longer write it and emit marks instead; players keep reading it so files already published still render. New writers use a mark.- Drop cap (
style.dropCap): readers size the first letter withdropCapStyle(dropCap)from@oim-media/core(2.2× the font size for 2 lines, 3.2× for 3; line height 0.85; 8 px gap by default; bold).
Text animations (style.textAnimationType)
| Value | Effect | Unit |
|---|---|---|
typewriter | letters appear one by one, with an optional cursor | letter |
wave-chars | letters rise and fall in a sine wave, forever | letter |
glitch-cyberpunk, glitch-slice | bursts of colour split / horizontal shift | text |
fade-chars | each letter fades in once | letter |
slide-up | each word rises 0.6 em while fading in, once | word |
bounce-chars | each letter grows past full size and settles, once | letter |
reveal-mask | each word rises into view from under its own clip box, once (1.13.0) | word |
scale-in | each letter grows from 0.6 to full size while fading in, once (1.13.0) | letter |
rise-words | each word rises 0.8 em and straightens from 8°, once (1.13.0) | word |
- The six entrance effects (
fade-chars,slide-up,bounce-chars,reveal-mask,scale-in,rise-words) are defined as data in@oim-media/core(text-effects.ts): each unit takes 0.35 s and startstextAnimationDelay + index × textAnimationSpeedseconds in (default stagger 0.05 s). Readers sampleentranceFrame/entranceKeyframes; a frame only holds opacity, a vertical offset in em, a scale and a rotation, andentranceClipssays whether each unit sits behind a rectangular clip of its own box, so any renderer, web or native, can draw it. blur-charsis retired (10 Oct 2026): per-letter blur is not portable. It stays valid in the schema; readers draw the text still.- Animated text is drawn without its marks, and a static export shows the final state.
Retired in contract 1.12.0
These keys still validate, so files already published keep working, but no new file should write them. Readers ignore them unless noted.
| Key | Why | Use instead |
|---|---|---|
style.textTransform | No renderer applied it. | Write the text in the case you want. |
style.verticalAlign, style.shader_fx | No writer and no reader. | — |
style.blendMode | It never showed in the editor or the player. Now a closed list of 9 values. | — (the layer is drawn as normal) |
style.grain, grainIntensity, grainSize, grainBlend | A layer's grain is a texture effect. Players still draw it for old files. | style.effects with type: "texture" |
Root grain, root effects | The background grain is baked into the base image. Players still draw them for old files. | — (baked) |
Root elements, layers | slots is the one name. Readers still accept them. | slots |
Slot p, s, st, c, t | Short aliases no writer emitted. Readers no longer read them. | position, style, content, tracking_data |
A layer's texture effect is painted on the layer's own pixels (its SVG or picture), not on its whole box: layerTextureMask in @oim-media/core.
Accessibility (contract 1.16.0)
Layer style.alt ({ [lang]: string }, ≤ 300 each) describes a picture; style.decorative (boolean) makes screen readers skip a layer and wins over alt; the document root description ({ [lang]: string }, ≤ 1000 each) summarises the work. See Accessibility.
Image fill (contract 1.17.0)
A shape with an area (geo, not line or scribble) can be filled with a photo. style.fillType is "image" and style.fillImage is an embedded data:image/… URI: in a signed .oim it is always embedded, and the player never loads it from the network. The optional style.fillImageCrop ({ x: 0–100, y: 0–100, zoom: 1–10 }) is centred at zoom 1 by default. It follows the same rule as imageCrop: x and y are the image point that sits on the same point of the frame, and zoom scales around it.
The editor always writes style.fillColor as well. It is painted under the photo and shown when the photo is missing or fails to load; readers older than 1.17.0 show that colour. The image_fill capability sets min_player to 1.17.0, and only when it is used. alt and decorative (1.16.0) apply to photo frames too.
Media filters (contract 1.18.0)
style.filterPreset, filterBlur (0–100 px), filterBrightness, filterContrast, filterSaturate (0–500 %, 100 = unchanged) and filterHueRotate (0–360°) also apply to image layers and to the photo of a photo frame (fillType: "image"). On a frame the filter touches only the photo: never the stroke or the colour underneath, and a blur stays inside the shape. duotone is reserved for filter layers. The media_filters capability sets min_player to 1.18.0, only when a filter is actually set.
Reveal and play (contract 1.22.0)
style.reveal says how a layer enters and leaves when an action shows or hides it: effect (fade, slide-up, slide-down, slide-left, slide-right, scale), duration (0–2000 ms; 0 = instant) and an optional easing of the same type as a keyframe's e (default ease-out). Hiding plays the same effect in reverse. Without reveal the layer fades in over 300 ms and out over 250 ms with ease, as before. A slide travels 4 % of the image width; scale grows from 0.92.
style.animation.trigger is "load" (default: starts on load and loops) or "action": the layer rests at its first keyframe until a play action runs it once. The play action ({ "target": "<slot id>", "type": "play" }) restarts the target's animation from the start every time; on a looping layer it rewinds the loop. It does not show a hidden layer (pair it with show), and leaving a hover does not undo it. A reader older than 1.22.0 ignores all three: it fades, autoplays and skips play.
Frame shapes and custom SVG frames (contract 1.19.0)
style.geoShape accepts twelve more closed shapes for photo frames: pill, octagon, pentagon, cross, moon, cloud, drop, bolt, bubble, flower, burst and arch. A geo with style.svgPath and fillType: "image" paints the photo through that outline instead of a catalogue shape. svgPath takes path data only (commands M L H V C S Q T A Z, numbers and separators), at most 20 000 characters. The optional style.svgViewBox ("minX minY width height") gives its coordinate box; without it the box is 0–100. style.fillImageFlipX and fillImageFlipY mirror the photo inside the frame, not its outline. The frame_shapes capability sets min_player to 1.19.0, only when one of these is used. A reader without it paints the frame as a rectangle with the whole photo.
Rendering rules for readers
The web player is the reference renderer. The rules it follows without writing them in the schema are listed in CANONICAL_SPEC.md, section 4.8. In short: style.textRuns is ignored by any reader with a paragraph engine, letterSpacing needs a CSS unit (a bare number is ignored), strokes come only from the stroke* keys, opacity is 0–100, media filters apply only to images, and retired keys are not drawn.
Canonical JSON Contract Example
{
"oim": "1.0",
"version": "5.3",
"min_player": "1.0",
"oim_id": "oim_7f8a9b1c2d3e",
"media_type": "image",
"image_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"reedit": "public",
"immutable_lock": false,
"canvas": {
"natural_width": 1200,
"natural_height": 630
},
"lang": ["en", "es"],
"slots": [
{
"id": "slot_hero_title",
"name": "Hero Headline",
"type": "text",
"position": [50.0, 25.0],
"align": "center",
"content": {
"en": "Interactive Media for the Open Web",
"es": "Medios Interactivos para la Web Abierta"
},
"style": {
"font": "Bricolage Grotesque",
"fontSize": "4.5cqw",
"fontWeight": 800,
"color": "#f59e0b",
"animation": {
"duration": 3.0,
"keyframes": [
{ "t": 0, "o": 0, "y": 20 },
{ "t": 0.8, "o": 1, "y": 0, "e": "ease-out" }
]
}
}
},
{
"id": "btn_cta",
"name": "Explore Button",
"type": "geo",
"position": [50.0, 75.0],
"style": {
"isInteractive": true,
"states": {
"hover": {
"scale": 1.05,
"opacity": 95,
"transitionDuration": 200
},
"active": {
"actions": [
{ "target": "slot_hero_title", "type": "toggle" }
]
}
}
}
}
],
"signature": "MEQCID...64_byte_raw_ecdsa_signature...",
"key_version": "v1",
"timestamp": "2026-09-03T03:00:00.000000+00:00"
}Reference Implementation: Demuxing .oim in TypeScript
import { decompressBytes } from "@oim-media/core";
export async function extractOIM(blob: Blob): Promise<{ image: Blob; metadata: any }> {
const arrayBuffer = await blob.arrayBuffer();
const view = new DataView(arrayBuffer);
let offset = 8; // Skip PNG 8-byte magic header
let metadata: any = null;
const originalChunks: Uint8Array[] = [new Uint8Array(arrayBuffer, 0, 8)];
while (offset < arrayBuffer.byteLength) {
const length = view.getUint32(offset, false);
const type = String.fromCharCode(
view.getUint8(offset + 4), view.getUint8(offset + 5),
view.getUint8(offset + 6), view.getUint8(offset + 7)
);
const totalChunkSize = 12 + length;
if (type === "zTXt") {
const dataBytes = new Uint8Array(arrayBuffer, offset + 8, length);
const nullIdx = dataBytes.indexOf(0);
if (nullIdx !== -1) {
const keyword = new TextDecoder().decode(dataBytes.slice(0, nullIdx));
if (keyword === "OIM_Data" || keyword === "OIM_JSON_DATA") {
// Skip the NUL and the compression-method byte (0 = zlib)
const bytes = dataBytes.slice(nullIdx + 2);
const decompressed = await decompressBytes(bytes, "deflate");
metadata = JSON.parse(decompressed);
offset += totalChunkSize;
continue; // Strip metadata chunk from raster image
}
}
}
originalChunks.push(new Uint8Array(arrayBuffer, offset, totalChunkSize));
offset += totalChunkSize;
}
const cleanImageBlob = new Blob(originalChunks, { type: "image/png" });
return { image: cleanImageBlob, metadata };
}Format Licensing, Patents & Conformance Governance
The .oim and .ovm specifications, canonical conformance vectors (@oim-media/spec), core runtime (@oim-media/core), CLI (@oim-media/cli), and web component players (@oim-media/player-oim, @oim-media/player-ovm) are published by Xue Studios LLC under the Apache License, Version 2.0 (Apache-2.0):
- Copyright & Patent Grant (Apache-2.0 §§ 2–3): Anyone may build independent readers, writers, exporters, validators, or players for
.oimand.ovmfiles—commercial or open-source—free of royalties. Under Section 3 ofApache-2.0, contributors grant a perpetual, worldwide, royalty-free patent license to make, use, sell, and distribute implementations of the specification, subject to automatic defensive termination if an entity initiates patent litigation alleging that the format or reference packages infringe a patent. - Trademark & Conformance Policy (Apache-2.0 § 6): While the code and specification are open, the names and marks OIM™, Object Interactive Media™, OVM™, OIM Media™, and the OIM logos are trademarks of Xue Studios LLC. Third-party implementations may state compatibility (e.g., "compatible with the
.oimformat"), but may only claim "OIM Certified" or "OIM Conformant" status if they pass 100% of the canonical conformance test vectors in@oim-media/specwithout strippingOIM_Data, alteringimage_hash, or bypassingreedit/immutable_lockrules. - Full Legal Terms: See the Terms of Service — Section 2 (Open Standard & Trademark Conformance Policy), Privacy Policy, and Third-Party Licenses.