Skip to content

.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:

text
+-------------------------------------------------------------------------+
| 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.

text
+-------------------------------------------------------------+
| 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-terminated 0x00). (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 a tEXt chunk 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 .oim file, ensuring only the verified OIM_Data payload exists alongside pixel data.

Compression & Encoding Pipeline ​

text
+-------------------+      +-------------------+      +-------------------+
|   Metadata JSON   | ---> |    Raw Deflate    | ---> |   RFC 4648 Base64 |
|   (UTF-8 string)  |      |   (wbits = -15)   |      |   (ASCII string)  |
+-------------------+      +-------------------+      +-------------------+
  1. 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.
  2. Base64 Encoding: The resulting compressed binary stream is encoded using RFC 4648 Base64.
  3. 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).

Security Limits & Boundary Guarantees ​

ConstraintEnforcement BoundaryLimit / RuleRationale
Zip Bomb DefensePackaging & DemuxerMax 10 MB (10,485,760 bytes)Prevents memory exhaustion attacks from recursive decompression bombs.
Max Slot CapacityCompiler & BackendMax 150 slotsGuarantees fluid 60 FPS DOM playback and limits memory footprint.
Slot ID UniquenessCompiler & ValidatorUnique string (1-128 chars)Prevents ID collisions across interactive state actions.
No Page RedirectsSchema & Compilerlink & target forbiddenDocuments cannot hijack browser navigation; interactions emit oim:action.
Color SanitizationSchema & CSS ResolverPositive whitelist regexEliminates CSS injection, exfiltration beacons, and style escape attacks.
Keyframe ThresholdsAnimation SchemaMax 240 keyframes ($t \le 60s$)Prevents excessive memory consumption in WAAPI animation loops.
Author PrivacyPackaging Pipelinecreator_user_id excludedGDPR compliance: personal IDs are held in revocable registries, not sealed in files.

Authoritative Zod Schema Reference ​

typescript
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 fieldTypeDefaultMeaning
grainbooleanfalseTurns the layer's grain on.
grainIntensity1–10020The grain's opacity.
grainSize1–10050Grain size: 1 finest, 100 coarsest.
grainBlendoverlay | soft-light | multiplyoverlayHow 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:

json
"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 fieldTypeMeaning
color / colorRoleCSS colour / dynamic roleText colour; the role wins when the reader knows it.
bold, italic, underlinebooleanWeight, 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 .oim only carries well-formed texts.
  • Readers use the one interpretation in @oim-media/core: parseMarks(text, Object.keys(marks)) for the runs and markStyle(mark) for the style. A file using marks declares the text_marks capability.
  • style.textHighlight is 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 with dropCapStyle(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) ​

ValueEffectUnit
typewriterletters appear one by one, with an optional cursorletter
wave-charsletters rise and fall in a sine wave, foreverletter
glitch-cyberpunk, glitch-slicebursts of colour split / horizontal shifttext
fade-charseach letter fades in onceletter
slide-upeach word rises 0.6 em while fading in, onceword
bounce-charseach letter grows past full size and settles, onceletter
reveal-maskeach word rises into view from under its own clip box, once (1.13.0)word
scale-ineach letter grows from 0.6 to full size while fading in, once (1.13.0)letter
rise-wordseach 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 starts textAnimationDelay + index × textAnimationSpeed seconds in (default stagger 0.05 s). Readers sample entranceFrame / entranceKeyframes; a frame only holds opacity, a vertical offset in em, a scale and a rotation, and entranceClips says whether each unit sits behind a rectangular clip of its own box, so any renderer, web or native, can draw it.
  • blur-chars is 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.

KeyWhyUse instead
style.textTransformNo renderer applied it.Write the text in the case you want.
style.verticalAlign, style.shader_fxNo writer and no reader.—
style.blendModeIt 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, grainBlendA layer's grain is a texture effect. Players still draw it for old files.style.effects with type: "texture"
Root grain, root effectsThe background grain is baked into the base image. Players still draw them for old files.— (baked)
Root elements, layersslots is the one name. Readers still accept them.slots
Slot p, s, st, c, tShort 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 ​

json
{
  "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 ​

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 .oim and .ovm files—commercial or open-source—free of royalties. Under Section 3 of Apache-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 .oim format"), but may only claim "OIM Certified" or "OIM Conformant" status if they pass 100% of the canonical conformance test vectors in @oim-media/spec without stripping OIM_Data, altering image_hash, or bypassing reedit / immutable_lock rules.
  • Full Legal Terms: See the Terms of Service — Section 2 (Open Standard & Trademark Conformance Policy), Privacy Policy, and Third-Party Licenses.