# Media

How images, audio, video and narration enter a course, the canonical formats and quality presets, the encoder, and how media reach packages and previews. Part of the [architecture](../architecture.md), the contract every change follows.

Media is part of the package. The project keeps **one master per media file, already in a web format at the `high` preset**. The original that the user imported is never copied into the project. The pure part (canonical formats, presets, `conformance(info)`, the plans that turn an input into a master or a master into a rendition, size estimates) is `@elabora/core` `media/`; probing, encoding and jobs are `@elabora/node` `media/`.

## Import and optimisation

- `elabora media import [dir] <file…> [--to assets/video] [--name intro] [--no-wait]` (MCP `elabora_media_import`, and the app's import) probes each file at once (a file that is not media fails then), reserves `assets/<video|audio|images>/<slug>.<ext>` under the project lock (a name never taken twice, even by a job still running) and queues a job; it prints the paths, then waits, showing progress on stderr. `--no-wait` leaves the jobs to a detached `elabora media worker`.
- What a job does with a file:
  - **copy**: SVG, and a conformant MP3, byte for byte.
  - **remux** (no quality loss): a conformant MP4 or M4A, or a file whose streams are already right (H.264 and AAC in a `.mov`), is rewritten with `+faststart` and without metadata; a conformant JPEG, PNG or WebP loses its metadata without re-encoding (EXIF, XMP, IPTC, comments, JPEG multi-picture previews and motion-photo videos after the image), keeping its orientation (as a minimal EXIF block) and its colour profile.
  - **encode**: only the streams that need it (a `.mov` with PCM audio keeps its H.264 video). Video: H.264 High yuv420p, AAC, downscale only (aspect kept, even sides, square pixels), rotation applied; HDR (PQ, HLG) is tone-mapped to SDR when ffmpeg has zscale or libplacebo, else converted and labelled BT.709 with a warning. Audio: AAC in M4A, stereo at most (mono at half the bitrate), no cover art. Images: WebP (transparency and animation kept); HEIF and AVIF photos (tile grids) included.
  Outputs never carry metadata (`-map_metadata -1`, `+bitexact`): phone videos and photos hold GPS positions.
- `elabora media captions [dir] <block> <file.vtt|.srt> [--name <name>]` (MCP `elabora_set_captions`, engine command `set_captions`: the app's « Sous-titres » field and a file dropped on the video) gives a video block its captions: the text written under `assets/captions/<block>.vtt` (a SubRip file converted to WebVTT, the same text already there reused, another one numbered), the block's `captions` naming it, in one journaled change (`set_captions`, undoable). It answers `E_VIDEO_CAPTIONS_MISSING`.
- A media file of `assets/` that is not conformant gets `W_MEDIA_NOT_OPTIMIZED` (a GPS position is reported as a privacy matter). `elabora media optimize [dir] [<path>…|--all]` converts it next to itself and, when the extension changes, rewrites the references and removes the old file in one journaled change (`optimize_media`, undoable).
- Jobs are files in `<data>/media/jobs/<id>.json` (`queued`, `running`, `done`, `failed`, `cancelled`, with progress, error, warnings and the input and output hashes), claimed by a process with an exclusive lock file, one at a time by default; a job left `running` by a process that is gone goes back to the queue, `cancel` reaches a job run by another process. Outputs are written in `<data>/media/tmp/` and moved into place when complete. `elabora media status|cancel|worker`, MCP `elabora_media_status`.
- While a job runs, references to its output get `I_MEDIA_PROCESSING` instead of `E_ASSET_MISSING`; the preview shows a placeholder; `elabora build` (and `elabora_build`) waits for the jobs, and `--no-wait` refuses to build.
- Backups skip non-conformant media: they are inputs waiting for conversion, not sources.

## Narration (D24)

The voice of a narrated screen is one file per line and language, found by convention next to the script (`screen.narration.lines`): `assets/narration/<language>/<screen>/<line>.m4a` and its sidecar `<line>.json` (SPEC §4.7). They are media masters of `assets/` (probed, hashed, backed up) that no asset prop names, so packages never ship them. A layer's lines (`layers[].narration.lines`, D62), and a dialogue's (D61: its lines with a speaker, and with `learnerVoice` the learner's answers as `<node>.<option>`), are voice lines too: ids unique in the screen, every track counted, their audio at the same path.
- **Every voice line of a screen** (D62): `@elabora/core` `narration/voice-lines.ts` `screenVoiceLines(project, screen)` gives each line with its track (`screen`, `layer`, `dialogue`: `dialogueLineSource`), its translation unit and its JSON pointer; `VOICE_LINE_SOURCES` is the one place a new holder of lines is added. `narrationLines`, the voice tools, alignment, recordings and transcription, translation units and anchors, the report and the engine read lines through it.
- `@elabora/core` `narration/`: paths, `narrationTextHash`, `narrationLines(project, screen, language)` (each line's text in that language, from the screen or its translation, its files and whether they are `ok`, `missing`, `stale` or `untranslated`, with why), `screenTiming` (the timeline from the lines' durations: the file's probe, else its sidecar, else an estimate from the text), `narrationTiming`, `captionCues`, `narrationVtt`, and `report.ts` for `elabora timeline`. Its check is a project check (`narration-audio`): the files are not named in the screen, and the engine runs project checks again when a file of `assets/` changes.
- Writing a line's audio is one journaled change (`set_narration_audio`): `@elabora/node` `narration/audio.ts` `setNarrationAudio` keeps an M4A in AAC mono with its index first as it is, converts anything else (AAC mono 96 kb/s, `+faststart`, no metadata), measures it, and the core command writes the file and its sidecar (the text hash in that language, the duration, the audio hash, the voice); `importNarration` is a recording (`provider: "recording"`, not generated, silence at both ends trimmed to 0.1 s). CLI `elabora narration import`, MCP `elabora_import_narration`; the voice tools call the same.
- **The mouth's envelope** (D61): `@elabora/core` `narration/mouth.ts` `mouthEnvelope(samples, rate, words)`, pure and deterministic, gives one letter per 1/30 s (`c`, `s`, `o`, `w`) from the line's samples and words; `@elabora/node` `narration/mouth.ts` decodes the audio with ffmpeg (mono, 16 kHz, `decodeSamples` of `narration/tracks.ts`, which `peaks.ts` uses too) and passes it the words (aligned, else placed along the text). `setNarrationAudio` writes it into the sidecar (`mouth`) with the audio, `alignNarration` again with the words it timed (`set_narration_words` takes `mouth`).
- **A dialogue's lines in a package** (D61): `build` asks `narration/dialogue-audio.ts` for each line of a dialogue whose audio is there: the master at `high`, else its rendition at the package's preset (`ensureRendition`, as any media), shipped as `narration/<screen>.<block>/<line>.m4a` (`narration/<language>/…` for another language); its envelope from the sidecar, else measured and cached in `<data>/cache/narration/<hash>.mouth`. `compileCourseData` takes them (`dialogueAudio`) and gives each line its `voice` (`@elabora/core` `dialogue/voices.ts`: the audio's path, its duration, its words, its envelope; without audio, the estimate of `@elabora/player` `character/` `textVoice`). The build id includes them, the report's `narration.lines` lists them, and a generated voice in a dialogue is in `generatedMedia`.
- **Tracks.** `build` asks `narration/tracks.ts` for one track per narrated screen, and one per narrated layer (`narration/<screen>.<layer>.m4a` and `.vtt`, D62), whose lines all have their audio in the course's language: each line decoded to PCM (mono, 48 kHz, 16 bits) by ffmpeg, the pauses written as silence, the whole encoded once (AAC-LC, 64 kb/s, `+bitexact`, `+faststart`) into `<data>/cache/narration/<inputs hash>.m4a` with the lines' measured durations next to it (`.json`). The package ships it as `narration/<screen>.m4a` (from the cache, like a media master: streamed into the ZIP, served by the preview, cloned into folders) with `narration/<screen>.vtt`, and the course data gets the cue times from the decoded samples (`compileCourseData` option `narration`). The build id includes the tracks' keys. Package limits count the tracks (their seconds in the heaviest files); the report's `narration` gives bytes, seconds, tracks and silent screens. A track that cannot be made is `E_NARRATION_TRACK`.
- **Optional model versions (D55).** Desired releases and exact compatible descriptors live in each model manifest. `models/versions.ts` selects checked installations, stages upgrades beside active files, gives inference calls directory leases and removes old releases only after use. A family lease coordinates installation/removal across app, CLI and MCP; update-only intent cannot reinstall an absent element. Workers switch when their directory changes and provenance records the selected model. Automatic network-cost policy and the shared app/update/quota panel belong to the desktop app, not the core.
- **Local AI platform policy (D54).** Mac Intel keeps editing, existing audio and word timings, recordings, FFmpeg processing and online services. Local voice generation, forced word alignment and local transcription are unavailable on darwin/x64: downloaded models stay listed for removal, but installation and inference are refused by the core as well as the app. The release carries no Intel ONNX Runtime binding. The Intel prebuilt FFmpeg remains pinned independently.
- **Generated voices (D25).** `@elabora/node` `voice/`: a `VoiceProvider` lists its voices, says whether its model is on this computer (`status`), installs it and says a text (`synthesize`: mono PCM, the model, the credit, failed parts said again). The local one, `voice/pocket/`, runs Kyutai's Pocket TTS with ONNX Runtime (`onnxruntime-node`, optional, required when a voice speaks), its model downloaded once per language into `<local app data>/voices/pocket-tts/<bundle>/` (`models.ts`: pinned files, SHA-256, resumed, one download across processes; removed on request, never while a generation holds it, D37), its voices from `packages/node/voices/pocket-tts/` (embeddings made once by `scripts/pocket-voices.ts`). `voice/generate.ts` `generateNarration` takes a screen's lines (missing or out of date, all, or some), says each with its speaker's voice (`speakers[].voices[<language>]`; the text after the pronunciations, the settings with the line's delivery: `spokenText`), keeps the result as a take in `<data>/voice-takes/<language>/<screen>/<line>/take-<n>.wav` (and `.json`), and writes it through `set_narration_audio` with its `take`, `credit` and `model`; a writer (`VoiceWriter`) lets the engine send that through its session. The Pocket model speaks in a worker thread (`voice/pocket/thread.ts`, `worker.ts`): the process that asks (the engine, the MCP server) goes on answering while it speaks, and a signal stops it between two of its steps (a stopped generation keeps the lines said so far, `cancelled`). `voiceSample` says a few words with a voice, written nowhere. CLI `elabora voice …`, MCP `elabora_voice_*`, engine requests `voices`, `installVoices`, `setSpeakerVoice`, `generateVoice`, `voiceTakes`, `cancelVoice`, `voiceSample`. The sidecar keeps the text the audio says (`text`).
- **Optional downloads (D38).** `@elabora/node` `downloads/`: the registry of what Elabora downloads once per computer and keeps out of every course (the local voices' models, the word aligners, the transcriber), by kind and language, with what each serves, what happens without it, its state (size on disk or to download, a download under way in any process, in use) and its install and removal. A kind registers there and uses `models/download.ts` for its files (pinned, checked, resumed; a lock beside the folder; holds while in use; removal refused while held). CLI `elabora downloads`, MCP `elabora_downloads`, `elabora_download_install`, `elabora_download_uninstall`; the app lists them in Elabora settings.
- **ElevenLabs (D29).** `voice/elevenlabs/`: a paid provider (`VoiceProvider.paid`, with `search`, `lookup`, `account`, `estimate`, `synthesizeDialogue`). `key.ts` finds the author's key (the app's when the author keeps it in the keychain, given in memory by `setSessionKey` or `setVoiceKey` once the author first calls the service, D40; `ELEVENLABS_API_KEY`; the 0600 file `elabora voice key set` writes in the local data, where the app keeps it when the author's AI may use it) and masks it everywhere; `api.ts` calls the API (`ELABORA_ELEVENLABS_URL` elsewhere), maps its refusals to codes (`E_VOICE_KEY_MISSING`, `_INVALID`, `_PERMISSION`, `E_VOICE_CREDITS`, `E_VOICE_BUSY`…) and keeps every generation's answer in `<local data>/elevenlabs/cache/` keyed by the request, so the same request is never paid twice. `generateNarration` plans first (`planNarration`, `estimateNarration`): the lines, the takes given back free, the dialogue runs, the credits about and at most, and their share of the month's quota (`costs.ts`: the share of its list price a model is charged on the author's plan, from the cost ledger's averages for the current price period (`@elabora/core` `ai/costs.ts` and `ai/cost-ledger.generated.ts`, generated from `ai-costs/`, D36) and the author's own requests, whose `character-cost` header is kept in `<cache>/charges.json`, a library voice's own rate in `<cache>/voice-rates.json`; the quota read from `/v1/user` (its `subscription`, in 0.25 s where `/v1/user/subscription`, which adds the invoices, takes 1.3 to 1.9 s) with what this process spent added until the account's count catches up). Under 1 % of the quota it runs; from 1 % it needs `approvedPercent` (`@elabora/core` `ai/quota.ts`, D30), and it stops (`overBudget`) once the service charged half as much again as it was allowed. A dialogue is one `text-to-dialogue` request cut into lines at its turns, the silences before each written as the lines' `pauseBefore` (`VoiceWriter.updateLinePause`). Stage directions (`@elabora/core` `narration/directions.ts`) stay in the line's text; words, captions, the player's text, durations and pronunciations skip them; XLIFF wraps them in `<mrk type="elabora:direction">`; a voice that cannot play them gets the text without them. CLI `elabora voice search|estimate|account|key`, MCP `elabora_voice_search`, `elabora_voice_estimate`, `elabora_voice_account`, engine requests `setVoiceKey`, `voiceAccount`, `voiceSearch`, `voiceEstimate`.
- **Word times (D27).** `@elabora/node` `align/`: forced alignment of a line's text with its audio. `spoken.ts` turns the words of `narrationWords` into what a voice says (numbers, times and symbols spelt out in French and English, elisions run on) and the model's characters; `wav2vec2.ts` runs a wav2vec2 CTC character model (ONNX Runtime, 16 kHz mono from ffmpeg) for the probabilities of each character every 20 ms; `ctc.ts` finds the text in them (Viterbi, `forcedAlign`); `align.ts` gives each word its start, end and score (the model's measured lateness of starts and earliness of ends corrected). The model runs in a process of its own (`process.ts`, on `models/process.ts`, starts it with the program that runs the engine, `ELECTRON_RUN_AS_NODE`; `worker.ts` is what it serves) that keeps it and ends after two idle minutes: an allocation ONNX Runtime cannot make aborts that process and fails the line (`E_ALIGN_CRASHED`), not the engine; `wav2vec2.ts` reads audio in windows of 10 s (the outer 2 s of each overlap given up) with ONNX Runtime's memory arena off, a recording of 90 s peaking at 1.1 GB instead of 2.4; `models.ts` pins one model per language, downloaded once into `<local app data>/aligners/<model>/` by `models/download.ts` (shared with the voices), held while it aligns and removable like every optional download (`downloads/`, D38). `setNarrationAudio` times a new line's audio in the same change when its language's aligner is installed (`ELABORA_ALIGN=0` turns it off); `alignNarration` times the others through the command `set_narration_words` (words, `aligner`, `text` into the sidecar), downloading the aligner first, with an `AlignWriter` for the engine's session. CLI `elabora narration align`, MCP `elabora_align_narration`, engine requests `aligners`, `alignNarration` (`alignProgress` events). A word scored under 0.25 is one the voice may have swallowed (`W_NARRATION_WORD_SWALLOWED`).
- **What a recording says (D46).** `@elabora/node` `transcribe/`: `parakeet.ts` runs NVIDIA's Parakeet TDT 0.6B v3 (ONNX Runtime: NeMo's mel features, the encoder, the decoder and joint network decoded greedily, a token and how many 80 ms frames to move on), in windows of 30 s at most cut in the quietest moment; `text.ts` makes its pieces a text (punctuation, capitals, French spaces) and words; `worker.ts` and `process.ts` run it in a process of its own on `models/process.ts` (`E_TRANSCRIBE_CRASHED`); `models.ts` pins the model, one download for 25 languages (`transcriber`, D38); `elevenlabs.ts` asks ElevenLabs' Scribe v2 when the author chooses it, its share of the quota told first and its transcript deleted after; `index.ts` gives `transcribeAudio`, `transcribers` and `estimateTranscription`. `narration/from-audio.ts` makes a line from a recording (`add_narration_line_with_audio`: the line and its audio in one change, `@elabora/core` `addNarrationLineWithAudio`) and gives a recorded line the text its recording says (`set_narration_text_from_audio`: the text through `update_narration_line`, the sidecar through `keep_narration_audio`, the words through `set_narration_words`, in one change), each timed by the aligner when it is here. CLI `elabora narration transcribe | add-from-audio | retranscribe`, MCP `elabora_transcribe`, `elabora_narration_line_from_audio`, `elabora_narration_text_from_audio`, engine requests `transcribers`, `transcribeRecording`, `transcriptionEstimate`, `transcribeLine` (`transcribeProgress` events).
- **The timeline of a timed screen** (D28): `@elabora/core` `narration/timeline.ts` lays out a screen's steps, its lines and their words (`narration/words.ts`: one tokenizer for anchors, the aligner and the editor; a line's word times from its sidecar's `words`, followed through a changed text by a word diff, placed in proportion to the text without them) and resolves every anchor (`timing.in`/`out` of blocks, event cues, `seek-timeline`) to seconds: `screenTimeline`, `blockTimings`, `anchorTime`. A step's stretch ends when its last line or its last effect anchored there ends, so the stop at its end never cuts an effect; the build lays the track's silences from it (`TimelineLayout.gaps`) and the course data's times follow those silences. Screens without narration but with blocks or cues in seconds have a timeline too (no track). A layer has a timeline of its own (D62: `screenTimeline(…, { layer })`, `timedLayers`): its lines from its opening in one stretch, its blocks and the cues on its lines (or with the trigger's `layer`); an anchor on a line of another timeline falls back with a problem of kind `track` (`E_TIMELINE_ANCHOR_TRACK`). `timelineView`, `screenAt`, `timelineMarks`, `narrationView`, `resolveAt` and `timelineReport` take `layer`. `narration/view.ts` gives editors `timelineView` (lines, words and their offsets in the text, timed blocks, cues) and `screenAt` (what is seen at a time); `narration/anchor-text.ts` reads and writes anchors as tools type them (`l2:imprimante+0.3s`). Commands: `set_block_timing`; `update_narration_line` takes word anchors along (`reanchorLine`); `split_step` cuts a step at a line (later steps, their blocks, settings, anchors and `reveal-step` actions move one on); `narration_from_plan` writes the script from the plan items' `voiceover`; `duplicate_screen` copies the lines' audio; `keep_narration_audio` keeps the audio of a line whose text was only corrected; `set_plan_voiceover` writes a plan item's voice-over; `write_plan` writes the plan (D52), and takes the screens of the items it moves along (`project/plan-order.ts`, D50), as `revert_plan_change` does. The canvas takes `at` and `ghost` from the app's own `elabora/view` messages and reports `BlockLayout.timed` (state and progress of each timed block); the timeline's findings carry `details`. `timelineMarks` lists a timed screen's marks (its start, each block that comes or goes, each cue, each step's end) with the time to show each: `elabora look --sheet` captures one per mark on a contact sheet (`look.ts`). Checks: `validate/timeline.ts` (a screen check on what is written, a project check on the times).
- **The player**'s `timeline.ts` reads the narration's `<audio>` as its clock (docs/decisions.md D24); `timed-blocks.ts` sets the timed blocks of the screen shown to its time at every move (Web Animations created paused, `currentTime` from the playhead; `data-t-timed` and `inert` before and after), and the cues the compiler resolved fire when playback crosses them (a `seek-timeline` jump crosses nothing). Previews and captures (`elabora screenshot`, `elabora fit`) and the LMS emulator set `window.ELABORA_TEST = { virtualTime: true }` before the player starts: nothing plays by itself, captures show a time (`__elabora.timeline.at(line)`), the emulator plays a screen to its end (`__elabora.timeline.toEnd()`, closing the dialogs its cues open; a dialog's close event comes with the next frame). The editor canvas shows a line's moment (`RenderView.at`, on the timeline of the layer shown when it has one) and never plays. A layer's timeline is another `createTimeline` with a compact bar in the layer (`.t-layer-timeline`), started at 0 when it opens and disposed when it closes; while it speaks the screen's timeline is held (`hold('layer:<id>')` for a dialog, `hold('voice:<id>')` for a panel until its narration ends); tests drive it with `__elabora.timeline.layer(id)`.

Automatic word alignment announces its work through `NarrationAudioArgs.onAlign`; voice generation forwards it as `voiceProgress` phase `align`, including when selecting an earlier take. If alignment fails, the audio is still written and `alignmentError` is returned with the line's generation result. Editors must tell the author that the words remain estimated and offer another alignment attempt.

## Sounds (D67)

- **The engine.** `@elabora/player` `sounds.ts`: one `AudioContext` for the page, made at the learner's first pointer or key press (never at load, so that no browser keeps it suspended), its master gain the player's volume and mute (the narration's controls: `TimelineOptions.onVolume` says when they change). Interface sounds are decoded once and played at once or not at all. `__elabora.sounds.state()` gives the context's state and what was played, for tests.
- **Interface sounds.** `@elabora/core` `sounds/sounds.ts`: `interfaceSounds(project)` (`when`, whether they sound, the volume, each role's source: the player's file, a sound of the course, or none), `courseHasSound`, `compileSounds` (the course data's `sounds.interface`: the volume and each role's file; nothing when they do not sound). The player's own files are `packages/player/src/interface-sounds/<role>.m4a`, made by `pnpm run sounds` from the originals `sources.json` names (pinned by SHA-256); `build` ships those the course data names as `elabora/sounds/<role>.m4a` (`playerSoundFile`), and the sound of the course a role plays instead as any asset.
- **Sounds of the course.** `@elabora/core` `sounds/sounds.ts`: `soundAssets` (the files of `assets/sounds/` by id), `soundSidecar`, `soundUses` (every place the course names a sound: an interface role, an action, a sound line, a background track; a narration's sound line is `inTrack`), `compileSounds` (the course data's `sounds`: `files` by id, `background` with their screens, `music`), `backgroundTracks`, `soundCreditsText` (the package's `CREDITS.txt`); `sounds/licences.ts`: `classifyLicence`, `soundCredit`. `fileMedia(info, path)` takes a sound of `assets/sounds/` kept as FLAC or as its lossy original as a master; `renditionPath` and `packageMedia` give audio that is neither AAC nor MP3 an M4A rendition at `high` too. A narration's sound lines are laid among its voice lines by `@elabora/core` `narration/narration.ts` `timelineLines` (the timeline and the track read them through it; `screenVoiceLines` never gives them: no voice says them), decoded into the track at their `volume`.
- **The engine's other layers.** `sounds.ts` plays sounds of the story once (`play`, `stop`; a request before the first gesture is dropped after 1.5 s), loops ambiences and background tracks with a crossfade on the context's clock (`enterScreen` stops the screen's sounds and starts or keeps the tracks of the screen's range), and lowers music and ambiences by 20 dB while a voice speaks (`voice`: `player.ts` reports every media element of the page that plays).
- **Adding sounds.** `@elabora/node` `sounds/`: `library.ts` (« Sons courants »: the core's `packages/node/sounds/`, then each folder `ELABORA_SOUND_LIBRARIES` names, as PATH does, with its own `catalogue.json` and `files/`: the desktop app's, whose CC BY sounds carry their `credit`), `openverse.ts`, `search.ts` (`searchSounds`: the library or Openverse in one shape, `SoundFound`, with what `add_sound` takes; `soundPreview`: a sound's bytes to hear it before adding it), `add.ts` (`addSound`, the change `add_sound`), `generate.ts` (`generateSound`, or `generateSoundWith` and a writer of its own), `types.ts` (the types that leave the process, without Node). The narration's commands take sound lines: `add_narration_line` with `{ sound, caption?, volume? }`, `update_narration_line` with `sound`, `caption` (empty: none), `volume`, `step` and `pauseBefore`, and `move_narration_line` and `remove_narration_line` as for a voice's; `update_course` writes `sounds` whole. Engine requests `soundSearch`, `soundPreview` (`soundProgress` events while an Openverse sound downloads), `courseSounds` (the module's sounds with their sidecars: licence, credit, source, author, suggested caption, uses), `soundEstimate`, `generateSound` (`session-sounds.ts`: its `add_sound` goes through the session's queue); Openverse's refusals are `E_SOUND_OPENVERSE` and `E_SOUND_OPENVERSE_LIMIT`.

## In the snapshot

`readProjectFiles` gives every media file of `assets/` its probe and verdict (`BinaryFile.media`: `MediaInfo` with `conformant` and `reasons`), from `<data>/media/probe-cache.json` keyed by path, size and modification time: only new or changed files go through ffprobe, synchronously (unreadable files are cached with their error, `E_MEDIA_UNREADABLE`). Without ffprobe cached verdicts stay, new files have none, and the validator says `I_MEDIA_TOOLS_MISSING`. The snapshot also gives `MediaStatus` (`Project.media`): the outputs of unfinished jobs (read only when an asset is missing), unreadable files, tools missing. Hashes of binary files come from the snapshot's hash cache (`<data>/cache/hashes.json`), shared with the jobs and the renditions.

## Canonical formats

| Kind | Master format | Conformant when |
|---|---|---|
| Video | MP4 (`+faststart`), H.264, yuv420p, AAC | MP4 container with the index first, H.264 Constrained Baseline, Main or High (8-bit 4:2:0: what browsers play; the encoder writes High), yuv420p, no HDR, long side ≤ 1920, bitrate ≤ 10 Mb/s, AAC audio, no GPS position |
| Audio | M4A, AAC | AAC (or MP3) ≤ 192 kb/s, no cover art, no GPS position |
| Raster image | WebP (JPEG and PNG also accepted) | WebP, JPEG or PNG, long side ≤ 3840, ≤ 8 MB, no GPS position |
| Vector image | SVG, unchanged | always |

The limits are looser than the `high` preset on purpose: a file a little above it is kept rather than re-encoded with generation loss.

## Quality presets

`course.json` `delivery.mediaQuality` (`high` by default), `elabora build --media-quality` and `elabora_build` `mediaQuality` choose the preset of the package. `high` ships the masters. `medium` and `low` ship **renditions** encoded from the masters and cached in `<data>/cache/media/<master hash>-<preset>-<parameters hash>.<ext>` (the parameters of the preset for that kind of media, and a pipeline version). Streams already within the preset are copied (a 720p master at `medium` only gets its audio re-encoded), a master entirely within it ships as is, and a rendition that would be larger than its master is the master. A rendition under another extension (an MP3 shipped as M4A, a PNG as WebP) takes that name in the package (`assets/audio/voice.m4a`) and the compiled course data points at it. `pruneProjectData` removes renditions no package can use (their master was replaced or deleted, or the preset's parameters changed) once they are a week old: a deletion undone meanwhile finds its rendition again.

| Preset | Video | Audio | Raster images |
|---|---|---|---|
| high | long side ≤ 1920, CRF 21, ≤ 8 Mb/s, AAC 160 kb/s | AAC 160 kb/s | long side ≤ 3200, WebP q 88 |
| medium | ≤ 1280, CRF 24, ≤ 4 Mb/s, AAC 128 kb/s | AAC 128 kb/s | ≤ 2400, q 80 |
| low | ≤ 854, CRF 27, ≤ 1.5 Mb/s, ≤ 30 fps, AAC 96 kb/s | AAC 96 kb/s | ≤ 1600, q 72 |

A package's weight before it is built (`@elabora/node` `packaging/weights.ts`, the engine's `packageWeights`, the app's « Publier »): what one learner loads to go through the module in its own language, at each preset, with nothing encoded, compiled or written. The assets it ships (`packaging/assets.ts`, the list the build ships), each media at the preset as a cached rendition, else estimated from its bitrates (`estimateBytes`); the narration tracks as cached, else their duration at 64 kb/s; the dialogue lines as media; the player's runtime deflated, the interface sounds it plays, three.js for 3D objects, the fonts of `theme/`, the free objects' files and the course's texts deflated. It stays within about 2 % of the ZIP the build writes (its headers and the compiled course data); `estimated` says some part comes from bitrates.

Package limits (checklist item 8, `@elabora/core` `lms/package-limits.ts`): the size of the ZIP, its size once extracted, its number of files and its compression ratio, against the LMSs the course targets (`--profile`, else `course.json` `delivery.lms`, else the unknown LMS). The limit of a chosen LMS stops the build (`E_PACKAGE_TOO_LARGE`, `E_PACKAGE_TOO_MANY_FILES`, `E_COMPRESSION_RATIO`). The unknown LMS assumes no package limit: they range from 100 MB (Teach Up) to 4 GB extracted (360Learning), too far apart to stop a course on a guess. The build warns instead with the documented LMSs that would refuse the package (`W_PACKAGE_TOO_LARGE_FOR_SOME_LMS`, `W_PACKAGE_TOO_MANY_FILES_FOR_SOME_LMS`, `W_COMPRESSION_RATIO_FOR_SOME_LMS`) until the course names its LMS. A finding's `details` (`PackageLimitDetails`) give the measure, its value, the lowest limit passed, the LMSs that set it (id, name, limit) and whether they were chosen, the media quality, the package's size at each preset (measured when renditions exist, estimated from bitrates otherwise, never above the masters), the best other preset under the limit (`fits`; null: none) and the heaviest files (path, asset, size, kind, duration). The message says the same in English, and what to do when no preset is enough: shorten the video, host it on a video platform and link to it, or split the course. A rendition that cannot be made (no ffmpeg) is `E_MEDIA_RENDITION`.

## Encoder

The node package finds ffmpeg and ffprobe through `ELABORA_FFMPEG` / `ELABORA_FFPROBE`, then `PATH`, and reads their encoders and filters once. It prefers `libx264` (CRF), then the hardware encoders (`h264_videotoolbox` on macOS, `h264_mf` on Windows; an average bitrate under the preset's cap), then `libopenh264`; an encoder that fails on the machine passes to the next. WebP needs ffmpeg's libwebp: without it, images become JPEG, or PNG (APNG) when they have transparency or animation, both accepted masters. The desktop app bundles a GPL build with libx264, libwebp and zimg, run as a separate program (D21); CI uses the same builds (BtbN/FFmpeg-Builds) on Linux and Windows.

## Packaging and preview

- Packages are written as **streams** (`packaging/zip-writer.ts`): media read from disk are stored without compression, entries in memory (generated files, text, fonts read by the design layer and the licence files found next to them, which every packaged font carries) are deflated at level 9 unless that does not help, entries keep sorted names and fixed timestamps, so builds stay byte-identical (D8), and a package without media keeps the bytes of the in-memory writer. The ZIP is planned first, so its exact size is known before anything is written; each file is read twice (sizes and CRC, then the data, checked against the first pass). No ZIP64: archives of 4 GB or more are refused. Nothing loads a whole media file into memory; folder builds clone media (copy-on-write where the file system can).
- The preview never zips and never copies media: it serves the generated files from its folder and the media from the project folder, through `http/files.ts` (streams, single range requests, content types), with a placeholder for media a job is still writing; its pages reload after each rebuild. The emulator serves packages the same way.
