Menu

Media

On this page

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