Media

Media — images first, audio and video beside them — enters the model through two doors, both already in the format’s grain: a media block is a void-shaped block whose attributes carry the source, and an inline image is an atomic object run inside a text block. The bytes themselves are never in the model: the format stores references (paths), resolved relative to the work file.

Media blocks

A media block is a block type with shape: "void" whose instances carry a src. There is no new shape and no new BlockDef field: image, audio, and video are ordinary block types, declared in config.blocks like any other, with their identity in their selector.

The recommended kinds — just data:

{ "id": "image", "shape": "void", "selector": "img" },
{ "id": "audio", "shape": "void", "selector": "audio" },
{ "id": "video", "shape": "void", "selector": "video" }

with their presentation in the sheet:

img, video { max-width: 100%; height: auto; display: block; }
audio { width: 100%; display: block; }

Three kinds, not one media kind — they style differently, edit differently (only video takes a poster), and degrade differently. But a renderer never keys on the kind ids: what a media block is resolves, in order, from

  1. its selector’s write tag (img / audio / video),
  2. else the major type of its type attribute (image/… / audio/… / video/…),
  3. else media-type inference from the src extension.

So a format that renames or re-declares the kinds loses nothing, and a void block with a src and no recognizable tag still renders as something.

In the file, <audio> and <video> are void blocks that take a real close tag; an <audio>/<video> with <source> children promotes the first source’s src + type on parse (the rest, and <track>, drop — a documented lossy import), and a <picture> resolves to its inner <img>.

Inline images — the object run

A run is either a stretch of text or an atomic inline object — exactly one of text / object is present:

Run = { text?: string, object?: InlineObject, marks: MarkUse[] }

InlineImageObject = {
  type: "image",
  src: string,        // same value grammar as a block src (below)
  alt?: string,
  title?: string,     // CommonMark ![alt](src "title") parity
  width?: number,     // intrinsic CSS px
  height?: number,
  // shared ElementAttrs: class, style, role, epubType, aria, lang, dir, data
}

InlineObject is the union of this image member and the mathematical-expression member.

An object run may carry marks like any run — a linked inline image is an object run under an href mark. Its shared element attributes belong to the image itself, not its paragraph or enclosing mark — which is what lets an ornamental or drop-cap image keep the publisher class whose CSS floats and sizes it while the paragraph text wraps alongside it.

Two projections are normative:

  • As an editing position the object is one caret position and fully atomic: a splice overlapping it removes it whole, a slice never cuts it, and a mark applied across it applies to the whole object.
  • As plain text the two uses differ deliberately: inference projects the empty string (a paragraph opening with a decorative image still matches its prefix/caps rules), while content-derived export serializes alt ?? "" (a plain-text reader gets the description, not a silent hole).

Inline media stops at images: inline audio or video is not representable, by design. InlineObject.type is the extension point if that ever changes.

The media attributes

The attribute vocabulary additions, all on blocks:

Attribute Type On Description
src string void (media) blocks The media source — a reference, never bytes.
alt string void (media) blocks Accessible description. Images: the alt attribute; audio/video: an aria-label.
type string void (media) blocks Explicit media type (MIME), e.g. "image/svg+xml". Usually inferred from the src extension; state it for extensionless sources and unusual data: payloads.
width, height number void (media) blocks Intrinsic dimensions in CSS px (integers). A content fact, not styling — they keep layout stable before the media loads and size media on paged/e-ink surfaces. Presentation sizing stays in style.
poster string video blocks Poster frame (same value grammar as src). Also the video’s visual stand-in on surfaces that can’t play it.
controls boolean audio/video blocks Playback controls. Defaults true — a reader must be able to operate the media.
loop, muted boolean audio/video blocks Default false.
narration { src?, start, end } any block A narration clip — this block’s slice of the work’s narration audio.

There is deliberately no autoplay (an accessibility hazard) and no srcset / multi-source list in this revision — one source per block.

Figures & captions

A captioned figure is not a new construct — it is the existing single-cell grid: a figure grid block whose one cell holds the void media block plus a figcaption-tagged text block. The media attributes stay on the inner void block; the figure grid carries none of them.

{ "kind": "figure", "rows": [[ { "blocks": [
    { "kind": "image",   "attrs": { "src": "assets/moth.png", "alt": "A moth on linen",
                                    "width": 800, "height": 600 } },
    { "kind": "caption", "runs": [ { "text": "Fig. 1 — the moth.", "marks": [] } ] }
] } ]] }

In the file this is <figure><img …/><figcaption>…</figcaption></figure>, exactly as a browser expects. The same shape captions audio and video.

Narration alignment

The bridge to audiobooks. A work may carry a narration trackmeta.narration, a source reference to the master audio (with meta.narrator naming the narrator) — plus, on any block, a clip into it:

// the head:  <meta name="narration" content="assets/narration/book.m4a" />
//            <meta name="narrator" content="Ruby Dee" />

// a narrated block (spelled data-narration="#t=12.25,17.5"):
{ "kind": "paragraph", "runs": [ … ],
  "attrs": { "narration": { "start": 12.25, "end": 17.5 } } }
  • start / end are seconds (fractional). narration.src overrides the document-level track per block — real audiobooks clip one master file per chapter, so most blocks state only their times.
  • The clip lives in attrs, per block — not in a config-side map — so alignment survives block moves, splits, and merges through ordinary editing, and no block needs a persisted id just to be narrated.
  • This is exactly the granularity of an EPUB 3 Media Overlay: one SMIL <par> per narrated block. Emitting one is a package encoder’s capability (app tooling), not a model concern. A renderer with audio support MAY offer read-aloud (play the clip, highlight the block); every other renderer ignores the attribute.

Asset references

Every asset reference — block src, poster, narration.src, inline object.src, meta.cover, meta.narration, and stylesheet url() values for fonts — takes one of three forms:

  1. A relative path — the canonical, preferred form. POSIX separators, resolved against the work file’s folder (plain browser semantics: a .spub.html must open from disk, unmodified, with its images working). By convention assets live in an assets/ folder beside the file, and a cover is just an asset (meta.cover: "assets/cover.jpg").
  2. A data: URI — valid everywhere a reference is; the self-contained form. An exporter MAY embed a document’s assets as data: URIs to produce a single portable file; tooling SHOULD warn above ~1 MB per asset. data: URIs pass through untouched, never inflated or extracted.
  3. An absolute http(s) URL — valid but non-portable.

The format never reads bytes. The engine’s part is pure reference collection — collectAssets(doc) walks a document (content, grid cells, inline objects, meta.cover, meta.narration, every poster and narration.src, CSS url()) and returns the deduplicated relative references in document order, skipping data: and absolute URLs. Resolving a reference to bytes — an application’s asset store, packaging, export — is tooling outside this specification.

Media-type inference

A shared, normative extension → MIME table; an explicit type attribute always wins:

Extensions Media type
png image/png
jpg jpeg image/jpeg
gif image/gif
webp image/webp
avif image/avif
svg image/svg+xml
mp3 audio/mpeg
m4a aac audio/mp4
wav audio/wav
flac audio/flac
ogg oga audio/ogg
mp4 m4v video/mp4
webm video/webm
smil application/smil+xml

SVG

SVG is a first-class image format — referenced like any raster (src: "assets/diagram.svg", type image/svg+xml), and the right choice for line art on e-ink. Because SVG is markup that can carry script, the rules are:

  • The format never embeds SVG markup inline — reference only; there is no raw-markup block.
  • Rendering contexts treat a referenced SVG as an image (an <img src>), which browsers script-neuter.
  • Import tooling SHOULD sanitize SVG — strip script elements, event attributes, and external references. (Informative; tooling-level, like all byte handling.)

Degradation by syntax

Media follows the standard tiered-persistence posture. The .spub.html file stores everything natively; foreign-format conversion degrades:

Syntax Block media Inline images Media attrs narration
the file (.spub.html) full (<img> <audio> <video>) full (inline <img>) full full (data-narration)
md image blocks full (![alt](src)); audio/video/figure via one-element HTML passthrough full (![alt](src "title")) width/height/type/flags drop drops
txt / fountain / fdx drops (nothing emitted) serialized as alt ?? "" drop drops

No syntax invents a convention it doesn’t own (there is no Fountain [[image: …]]); the words a reader sees always survive, and everything else survives exactly where it can be stored.