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](MCPelabora_media_import, and the app’s import) probes each file at once (a file that is not media fails then), reservesassets/<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-waitleaves the jobs to a detachedelabora 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+faststartand 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
.movwith 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>](MCPelabora_set_captions, engine commandset_captions: the app’s « Sous-titres » field and a file dropped on the video) gives a video block its captions: the text written underassets/captions/<block>.vtt(a SubRip file converted to WebVTT, the same text already there reused, another one numbered), the block’scaptionsnaming it, in one journaled change (set_captions, undoable). It answersE_VIDEO_CAPTIONS_MISSING.- A media file of
assets/that is not conformant getsW_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 leftrunningby a process that is gone goes back to the queue,cancelreaches a job run by another process. Outputs are written in<data>/media/tmp/and moved into place when complete.elabora media status|cancel|worker, MCPelabora_media_status. - While a job runs, references to its output get
I_MEDIA_PROCESSINGinstead ofE_ASSET_MISSING; the preview shows a placeholder;elabora build(andelabora_build) waits for the jobs, and--no-waitrefuses 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/corenarration/voice-lines.tsscreenVoiceLines(project, screen)gives each line with its track (screen,layer,dialogue:dialogueLineSource), its translation unit and its JSON pointer;VOICE_LINE_SOURCESis 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/corenarration/: paths,narrationTextHash,narrationLines(project, screen, language)(each line’s text in that language, from the screen or its translation, its files and whether they areok,missing,staleoruntranslated, 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, andreport.tsforelabora 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 ofassets/changes.- Writing a line’s audio is one journaled change (
set_narration_audio):@elabora/nodenarration/audio.tssetNarrationAudiokeeps 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);importNarrationis a recording (provider: "recording", not generated, silence at both ends trimmed to 0.1 s). CLIelabora narration import, MCPelabora_import_narration; the voice tools call the same. - The mouth’s envelope (D61):
@elabora/corenarration/mouth.tsmouthEnvelope(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/nodenarration/mouth.tsdecodes the audio with ffmpeg (mono, 16 kHz,decodeSamplesofnarration/tracks.ts, whichpeaks.tsuses too) and passes it the words (aligned, else placed along the text).setNarrationAudiowrites it into the sidecar (mouth) with the audio,alignNarrationagain with the words it timed (set_narration_wordstakesmouth). - A dialogue’s lines in a package (D61):
buildasksnarration/dialogue-audio.tsfor each line of a dialogue whose audio is there: the master athigh, else its rendition at the package’s preset (ensureRendition, as any media), shipped asnarration/<screen>.<block>/<line>.m4a(narration/<language>/…for another language); its envelope from the sidecar, else measured and cached in<data>/cache/narration/<hash>.mouth.compileCourseDatatakes them (dialogueAudio) and gives each line itsvoice(@elabora/coredialogue/voices.ts: the audio’s path, its duration, its words, its envelope; without audio, the estimate of@elabora/playercharacter/textVoice). The build id includes them, the report’snarration.lineslists them, and a generated voice in a dialogue is ingeneratedMedia. - Tracks.
buildasksnarration/tracks.tsfor one track per narrated screen, and one per narrated layer (narration/<screen>.<layer>.m4aand.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>.m4awith the lines’ measured durations next to it (.json). The package ships it asnarration/<screen>.m4a(from the cache, like a media master: streamed into the ZIP, served by the preview, cloned into folders) withnarration/<screen>.vtt, and the course data gets the cue times from the decoded samples (compileCourseDataoptionnarration). The build id includes the tracks’ keys. Package limits count the tracks (their seconds in the heaviest files); the report’snarrationgives bytes, seconds, tracks and silent screens. A track that cannot be made isE_NARRATION_TRACK. - Optional model versions (D55). Desired releases and exact compatible descriptors live in each model manifest.
models/versions.tsselects 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/nodevoice/: aVoiceProviderlists 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 frompackages/node/voices/pocket-tts/(embeddings made once byscripts/pocket-voices.ts).voice/generate.tsgenerateNarrationtakes 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 throughset_narration_audiowith itstake,creditandmodel; 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).voiceSamplesays a few words with a voice, written nowhere. CLIelabora voice …, MCPelabora_voice_*, engine requestsvoices,installVoices,setSpeakerVoice,generateVoice,voiceTakes,cancelVoice,voiceSample. The sidecar keeps the text the audio says (text). - Optional downloads (D38).
@elabora/nodedownloads/: 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 usesmodels/download.tsfor its files (pinned, checked, resumed; a lock beside the folder; holds while in use; removal refused while held). CLIelabora downloads, MCPelabora_downloads,elabora_download_install,elabora_download_uninstall; the app lists them in Elabora settings. - ElevenLabs (D29).
voice/elevenlabs/: a paid provider (VoiceProvider.paid, withsearch,lookup,account,estimate,synthesizeDialogue).key.tsfinds the author’s key (the app’s when the author keeps it in the keychain, given in memory bysetSessionKeyorsetVoiceKeyonce the author first calls the service, D40;ELEVENLABS_API_KEY; the 0600 fileelabora voice key setwrites in the local data, where the app keeps it when the author’s AI may use it) and masks it everywhere;api.tscalls the API (ELABORA_ELEVENLABS_URLelsewhere), 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.generateNarrationplans 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/coreai/costs.tsandai/cost-ledger.generated.ts, generated fromai-costs/, D36) and the author’s own requests, whosecharacter-costheader is kept in<cache>/charges.json, a library voice’s own rate in<cache>/voice-rates.json; the quota read from/v1/user(itssubscription, 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 needsapprovedPercent(@elabora/coreai/quota.ts, D30), and it stops (overBudget) once the service charged half as much again as it was allowed. A dialogue is onetext-to-dialoguerequest cut into lines at its turns, the silences before each written as the lines’pauseBefore(VoiceWriter.updateLinePause). Stage directions (@elabora/corenarration/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. CLIelabora voice search|estimate|account|key, MCPelabora_voice_search,elabora_voice_estimate,elabora_voice_account, engine requestssetVoiceKey,voiceAccount,voiceSearch,voiceEstimate. - Word times (D27).
@elabora/nodealign/: forced alignment of a line’s text with its audio.spoken.tsturns the words ofnarrationWordsinto what a voice says (numbers, times and symbols spelt out in French and English, elisions run on) and the model’s characters;wav2vec2.tsruns a wav2vec2 CTC character model (ONNX Runtime, 16 kHz mono from ffmpeg) for the probabilities of each character every 20 ms;ctc.tsfinds the text in them (Viterbi,forcedAlign);align.tsgives 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, onmodels/process.ts, starts it with the program that runs the engine,ELECTRON_RUN_AS_NODE;worker.tsis 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.tsreads 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.tspins one model per language, downloaded once into<local app data>/aligners/<model>/bymodels/download.ts(shared with the voices), held while it aligns and removable like every optional download (downloads/, D38).setNarrationAudiotimes a new line’s audio in the same change when its language’s aligner is installed (ELABORA_ALIGN=0turns it off);alignNarrationtimes the others through the commandset_narration_words(words,aligner,textinto the sidecar), downloading the aligner first, with anAlignWriterfor the engine’s session. CLIelabora narration align, MCPelabora_align_narration, engine requestsaligners,alignNarration(alignProgressevents). A word scored under 0.25 is one the voice may have swallowed (W_NARRATION_WORD_SWALLOWED). - What a recording says (D46).
@elabora/nodetranscribe/:parakeet.tsruns 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.tsmakes its pieces a text (punctuation, capitals, French spaces) and words;worker.tsandprocess.tsrun it in a process of its own onmodels/process.ts(E_TRANSCRIBE_CRASHED);models.tspins the model, one download for 25 languages (transcriber, D38);elevenlabs.tsasks ElevenLabs’ Scribe v2 when the author chooses it, its share of the quota told first and its transcript deleted after;index.tsgivestranscribeAudio,transcribersandestimateTranscription.narration/from-audio.tsmakes a line from a recording (add_narration_line_with_audio: the line and its audio in one change,@elabora/coreaddNarrationLineWithAudio) and gives a recorded line the text its recording says (set_narration_text_from_audio: the text throughupdate_narration_line, the sidecar throughkeep_narration_audio, the words throughset_narration_words, in one change), each timed by the aligner when it is here. CLIelabora narration transcribe | add-from-audio | retranscribe, MCPelabora_transcribe,elabora_narration_line_from_audio,elabora_narration_text_from_audio, engine requeststranscribers,transcribeRecording,transcriptionEstimate,transcribeLine(transcribeProgressevents). - The timeline of a timed screen (D28):
@elabora/corenarration/timeline.tslays 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’swords, followed through a changed text by a word diff, placed in proportion to the text without them) and resolves every anchor (timing.in/outof 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’slayer); an anchor on a line of another timeline falls back with a problem of kindtrack(E_TIMELINE_ANCHOR_TRACK).timelineView,screenAt,timelineMarks,narrationView,resolveAtandtimelineReporttakelayer.narration/view.tsgives editorstimelineView(lines, words and their offsets in the text, timed blocks, cues) andscreenAt(what is seen at a time);narration/anchor-text.tsreads and writes anchors as tools type them (l2:imprimante+0.3s). Commands:set_block_timing;update_narration_linetakes word anchors along (reanchorLine);split_stepcuts a step at a line (later steps, their blocks, settings, anchors andreveal-stepactions move one on);narration_from_planwrites the script from the plan items’voiceover;duplicate_screencopies the lines’ audio;keep_narration_audiokeeps the audio of a line whose text was only corrected;set_plan_voiceoverwrites a plan item’s voice-over;write_planwrites the plan (D52), and takes the screens of the items it moves along (project/plan-order.ts, D50), asrevert_plan_changedoes. The canvas takesatandghostfrom the app’s ownelabora/viewmessages and reportsBlockLayout.timed(state and progress of each timed block); the timeline’s findings carrydetails.timelineMarkslists 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 --sheetcaptures 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.tsreads the narration’s<audio>as its clock (docs/decisions.md D24);timed-blocks.tssets the timed blocks of the screen shown to its time at every move (Web Animations created paused,currentTimefrom the playhead;data-t-timedandinertbefore and after), and the cues the compiler resolved fire when playback crosses them (aseek-timelinejump crosses nothing). Previews and captures (elabora screenshot,elabora fit) and the LMS emulator setwindow.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 anothercreateTimelinewith 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/playersounds.ts: oneAudioContextfor 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.onVolumesays 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/coresounds/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’ssounds.interface: the volume and each role’s file; nothing when they do not sound). The player’s own files arepackages/player/src/interface-sounds/<role>.m4a, made bypnpm run soundsfrom the originalssources.jsonnames (pinned by SHA-256);buildships those the course data names aselabora/sounds/<role>.m4a(playerSoundFile), and the sound of the course a role plays instead as any asset. - Sounds of the course.
@elabora/coresounds/sounds.ts:soundAssets(the files ofassets/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 isinTrack),compileSounds(the course data’ssounds:filesby id,backgroundwith their screens,music),backgroundTracks,soundCreditsText(the package’sCREDITS.txt);sounds/licences.ts:classifyLicence,soundCredit.fileMedia(info, path)takes a sound ofassets/sounds/kept as FLAC or as its lossy original as a master;renditionPathandpackageMediagive audio that is neither AAC nor MP3 an M4A rendition athightoo. A narration’s sound lines are laid among its voice lines by@elabora/corenarration/narration.tstimelineLines(the timeline and the track read them through it;screenVoiceLinesnever gives them: no voice says them), decoded into the track at theirvolume. - The engine’s other layers.
sounds.tsplays 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 (enterScreenstops 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.tsreports every media element of the page that plays). - Adding sounds.
@elabora/nodesounds/:library.ts(« Sons courants »: the core’spackages/node/sounds/, then each folderELABORA_SOUND_LIBRARIESnames, as PATH does, with its owncatalogue.jsonandfiles/: the desktop app’s, whose CC BY sounds carry theircredit),openverse.ts,search.ts(searchSounds: the library or Openverse in one shape,SoundFound, with whatadd_soundtakes;soundPreview: a sound’s bytes to hear it before adding it),add.ts(addSound, the changeadd_sound),generate.ts(generateSound, orgenerateSoundWithand a writer of its own),types.ts(the types that leave the process, without Node). The narration’s commands take sound lines:add_narration_linewith{ sound, caption?, volume? },update_narration_linewithsound,caption(empty: none),volume,stepandpauseBefore, andmove_narration_lineandremove_narration_lineas for a voice’s;update_coursewritessoundswhole. Engine requestssoundSearch,soundPreview(soundProgressevents 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: itsadd_soundgoes through the session’s queue); Openverse’s refusals areE_SOUND_OPENVERSEandE_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.