Menu

Engine

On this page

The editing session of the desktop app: the project in memory, one write queue, incremental validation, other writers, batches, media jobs and the message protocol. Part of the architecture, the contract every change follows.

@elabora/engine keeps one project in memory for the desktop app: openProject(dir, options) returns a Session holding the files snapshot (what the journal knows of the folder), the project loaded from it, a validation context, the findings per screen and of the project checks, the journal’s entries and the open AI batch. Files on disk stay the source of truth: agents keep editing them through the elabora command, the MCP server or their own file tools. The session owns the write queue and project state; session-canvas.ts owns the canvas host and render cache, and session-voice.ts owns voice, alignment and transcription jobs; session-monitor.ts owns watcher observation and retry scheduling. Journal transactions and reconciliation stay in the session. Their writes go through the session’s queue.

Writes and reads

  • One write queue. Commands (run, the journaled names of the CLI and MCP: add_block, update_screen…), undo, undoBatch, mark, restores, migrations, builds, what other writers did and media results run one at a time. Reads (summary, readFile, listFiles, readScreen, screenParts, findText, conversions, translationStatus, translationTable, screenTranslation, diagnostics, history, renderScreen) answer from memory and never wait for writes.
  • A write: a human or system write closes the open AI batch first (its after-ai version); an auto version is taken when the newest is older than 5 minutes; the project lock is taken asynchronously (waiting for a CLI never blocks the event loop); the journal’s new entries and the files the watcher reported are taken in; the edit of @elabora/node runs with the session’s snapshot (files, validate: false, autoVersion: false); what it wrote (written) updates the snapshot without reading the folder; the session reads its own journal entry back, releases the lock, pushes the canvas, validates, and emits changed then diagnostics.
  • An edit that finds a file it touches changed on disk unseen asks the session for its snapshot again, with the stale paths: the session journals the difference as external first, and the edit is computed again on it.
  • Publishing (build: mediaQuality, release, profiles, name, all optional): the package as elabora build makes it (<project>/dist), in the write queue. The open AI batch closes, and what other writers left is journaled as theirs; the build then runs outside the project lock (encoding renditions can take minutes, and the CLI waits for the lock 40 s at most), with the session’s validation options by default; renditions it encodes are reported as mediaJob events. The state keys it adds to elabora.lock.json are journaled under the lock as one system change (command build), so the watcher never reports them as an agent’s external edit; a successful build takes a publish version, named after the day in the course language (« Publié le 7 octobre 2026 », “Published on 7 October 2026”) unless name is given. The result: ok, zip, size, report, summary, errors (what stopped it, waived findings left out), warnings (what did not: a package too large for some LMSs while none is chosen…), change, version. Media jobs still running stop the build (E_ASSET_MISSING): the app waits for their mediaJob events. The queue waits for the build (1 to 1.5 s on an 87-screen course, longer the first time a quality’s renditions are encoded).
  • A language’s package (buildTranslation: language, allowMissing, mediaQuality, release, profiles): the translated course generated as elabora translate build writes it, from the files on disk, into <data>/translations/<lang> (the project’s data folder: never copied, never watched), then built as build does into <project>/dist (<course>-<lang>-<version>-scorm12.zip), in the write queue. Nothing of the source is written or journaled: the copy is generated again every time. Texts to translate, to review or stale, and word anchors whose marker the translation has not placed (D28), stop it (refused) unless allowMissing (a draft: texts stay in the source language, anchors go to their line’s start); translations that break the text always do. The result: ok, zip, size, report, units, untranslated, unplaced, plan (the translated plan in the package: approved, or draft until a person approves it: command approve_translation), summary, errors, warnings (as build’s: the package’s findings for its LMS among them).

Incremental validation

Every check of @elabora/core/validate is declared, in the order of the report, with its scope (FILE_CHECKS run on any project, COURSE_CHECKS once course.json loads):

  • a screen check depends on one screen and on what screens share and rarely changes (course.json, plan, components, objects, theme, variables). It runs on the screens course.json lists, or with screens: 'all' on every screen file, listed or not (schema, declared id, backdrop variant). readsOtherScreens marks the few that also read blocks of other screens (events: visited conditions; figures and decoration objects: the first block of a duplicate id);
  • a project check compares screens with each other or with the plan (ids, coverage, usage, exposition streak, suspend data budget), or reads what no screen holds (object code, component definitions, theme, generators, media). readsScreens marks those an edited screen can change;
  • createValidationContext(project, options) builds what checks share once (registry, plan index, listed screens, blocks by id); validateScreen(context, screenId) gives the findings of one screen, validateProjectChecks(context) those of the project checks, and together they give the findings of validateProject;
  • loading, validating and compiling never throw on a project that parses but breaks its schema (a test runs seeded mutations through every reader): the validator reports it.

The session (project-state.ts) keeps the findings per screen and per project check and re-runs what a change can affect:

  • a screen file: that screen’s checks; the screens that mention an id whose element (block, layer, event, the screen itself) changed in it, as a JSON string in their file; the project checks marked readsScreens (the others keep their findings: object code and generators are not parsed again). When only screen files changed, the loaded project is the previous one with those screens replaced: the components, so the registry and the analyses of templates, stay the same objects;
  • an asset, or a change of the media status (a job under way, an unreadable file): the screens that name it and every project check;
  • any other file: everything. A test checks, edit after edit on two sample courses, that the findings equal those of a full load and validation.

Other writers

  • The journal first. New entries (the CLI, the MCP server, in other processes) are applied to the snapshot and never journaled again: each touched file is read back when its hash is the one the journal recorded after the change; a file written again since gets the journal’s version (the operations applied), and the difference is journaled as an external change.
  • External edits. The folder is watched (recursively) and listed every 15 s for what a watcher missed. openProject resolves once the folder’s watcher reports (onReady; 3 s at most), and its first listing comes after: a file written once the project is open is reported at once. On macOS the watchers of a thread share one FSEvents stream, built again whenever one of them starts or stops, and what is written meanwhile is never reported, for every watcher of the thread (measured: writes from about 16 ms before to 10 ms after). So FsStore watches each folder from a worker thread of its own: a preview started or stopped, or another project opened or closed, in the same process takes nothing from an open project (packages/engine/bench/watch-rebuild.ts, an agent writing a file every millisecond: 467 writes of 5786 lost over 10 previews started and stopped before, none since). The journal’s watcher stays in the engine’s thread; it is a shortcut: a change another Elabora tool journals also writes files, which the folder’s watcher reports, and the journal is read before them. Reported paths wait for a quiet moment (60 ms, at most 600 ms during a burst: an agent writing 50 files makes one change); then, under the project lock, the files whose content differs from the snapshot are diffed (diffFiles) and journaled as one external change. A binary file modified less than 400 ms ago is still being written: the burst waits for it, up to 2 s, so that a video copied with the screen that shows it is one change.
  • The session’s own writes come back from the watcher; they are recognised by content, not by time: a reported file that holds what the snapshot holds is settled without the lock.
  • A JSON file that does not parse (an agent mid-write) keeps its last valid content in the snapshot and the loaded project, with E_JSON_PARSE in its screen’s findings (or the project’s); nothing is journaled until it parses, and it is then diffed against its last valid content. A new file waits outside the snapshot until it parses (its creation is one change). A file broken since the session opened has no valid content to keep: its new text is taken without a journal entry.
  • Conflict copies of sync services are set apart (W_SYNC_CONFLICT_COPY); tool folders and temporary files are ignored. A project folder moved or deleted while open is reported (error event) and nothing is written there any more.

Batches and versions

  • An AI batch is open while AI changes (agent, external) come in. It closes after 5 minutes without one, at a human or system change, at a mark, or when the session closes: the session takes an after-ai version and emits batchClosed. mark(request) (« Demander à l’IA ») closes the batch, writes the mark that labels the next one, and takes a before-ai version (the newest version when nothing changed since).
  • Elabora’s chat: mark(request, author, chat) opens a turn (the mark’s id is the turn’s batch; a mark another process appends with its chat opens one too). A turn’s batch stays open through human changes, idle time and another AI’s work; endTurn({ turn }) closes it (batchClosed with reason turn, an after-ai version) and returns { batch, changes } (batch null when the agent wrote nothing). The next mark of the conversation or the session closing also close it. A session opened during a turn (no turn-end in the conversation’s transcript since its mark, less than a day ago) takes it up.
  • Versions: auto on open and before a write when the newest is older than 5 minutes, and 5 minutes after activity resumes; close when the session closes; migration before a format migration; publish after a successful build (named, so kept until the user deletes it); a restore (restoreBackup) is journaled as one system change, after the restore version, so it can be undone. The project’s data is pruned once per session (60 s after opening, or at close), under the project lock.

Media

The session runs the project’s media queue from opening. Files that unfinished jobs will write or replace are left to the job’s result: when a job is done its files are journaled (an import as its requester’s change; a conversion as a system change holding the master, the removal of the original and the references rewritten by replaceMediaReferences, the original stored first so that undo brings it back). Media files of assets/ carry their probe and verdict in the snapshot (new or changed files are probed, through the probe cache); the media status follows the jobs (the session’s own, and other processes’ at each listing), so I_MEDIA_PROCESSING replaces E_ASSET_MISSING while a job writes a file. Media that are not conformant masters are converted (option media.autoOptimize, on by default). At close, running conversions are cancelled (queued again at the next open, since the files are still not masters) unless close({ media: 'wait' }).

Format

A course in an older format is read migrated in memory (I_FORMAT_OUTDATED; summary().format says what the migration changes). Option migrate: ask (default: the migrate request writes the migration, and an edit through the session migrates first, as the CLI does), auto (migrated on open, after the open version), never (writes are refused). A migration is one system change after a migration version (migrateIfOutdated, given the session’s snapshot). A newer format is refused (E_FORMAT_NEWER).

Rendering and the canvas

renderScreen renders through one render context per language, kept while what screens share is unchanged (course.json, plan.json, elabora.lock.json, components/, object manifests, theme/, translations, the files its stylesheets name); renders are memoised per screen and view and forgotten when the screen file, a file they list (assets by hash, an object’s folder) or a shared file changes; what the player’s bars show that depends on every screen (the outline, the items that show, « IA et crédits », the languages) is kept apart per language, made again after a change to any screen or asset, and pushed with the screen shown when it changed. openCanvas starts the canvas host of the project (its page goes in a frame of the app); canvasView shows a screen or a language (a new render), or changes the state, step or layer shown (a view message: the page keeps its nodes); after every change that affects the screen shown the session pushes it again, with the change’s sequence number as render id; closeCanvas. The app’s « Lecteur » tab adds &chrome=live to the page URL: the player’s bars working around the screen, which speak elabora-player-preview/1 with the app (Rendering, The player’s preview); a change of player, the resources, the glossary or the credits reaches them as any change of course.json does.

Protocol

Transport-neutral and plain data (protocol.ts, types only plus a small client), carried by the app over Electron message ports and by a server over WebSockets:

  • a request { kind: 'request', id, type, params } gets one response { kind: 'response', id, type, ok: true, result } or { …, ok: false, error: { kind, message, code?, diagnostics? } }; kinds: refused (with the catalogue code: E_LOCKED, E_CONFLICT, E_SCHEMA, I_FORMAT_OUTDATED…), not-found, bad-request, not-available, closed, internal;
  • requests: summary (with each screen’s estimated seconds, project/duration.ts, D50), readFile, listFiles, readScreen, screenParts (what the events of a screen can name: its blocks with their controls, a decision’s options, a free object’s events and settings, its layers and steps, its narration’s lines, the course’s screens and variables), narration (a narrated screen as an editor shows it: its settings as they apply and its own, each line in a language with its audio’s state and its place on the timeline, the steps’ segments: @elabora/core narration/view.ts), findText (the course’s words that hold a text, where each is: not the plan’s validated texts), conversions (« Transformer en… », D63: what a block may become, { block, language } → { supported, from, targets: [{ to: { type, presentation? }, kept, lost, added, progress }], refused: [{ to, reason }] }, the sentences in language, fr or en; { probe: true } → { supported: true } with no targets; the change itself is run convert_block { block, to }), translationStatus, translationTable (a language as a bilingual table: each unit, its translation, what it asks for; a narration line’s word anchors with the words each is placed on, its texts with their markers), screenTranslation (one screen’s units in a language, with the block and field the canvas shows each at; canvasView { language } shows the screen in it), buildTranslation (a language as its own package: the translated course generated in the project’s data folder, built into dist/), diagnostics, history, run, undo, undoBatch, mark (with chat: a turn of the chat), endTurn, migrate, build, packageWeights (what a package would weigh at each media quality as the project is in memory, [{ quality, bytes, estimated }]: nothing encoded, compiled or written, @elabora/node packaging/weights.ts, docs/architecture/media.md), importMedia, mediaJobs, cancelMediaJob, backups, createBackup, restoreBackup, renderScreen, openCanvas, canvasView, closeCanvas, voices, installVoices, setSpeakerVoice, generateVoice (voiceProgress events), voiceTakes, cancelVoice (stops a screen’s generation, or every one and the downloads), voiceSample (a voice heard before it is chosen: WAV bytes), aligners (the word aligners and whether they are on this computer), alignNarration (voiced lines timed by forced alignment, D27: alignProgress events, one set_narration_words change a line), timelineMarks (a timed screen’s marks, the Revue’s storyboard: @elabora/core timelineMarks), narrationPeaks (the waveform of a screen’s lines, 0 to 255 per slice, cached in <data>/cache/peaks/), importNarrationFiles (recordings of several lines, each to the line its name gives or, with the word aligner, the line it says), planVoiceover (the voice-over the plan gives a screen), planVersions (the plan’s versions, its state, the changes since the validated version and in no version, what an export would show, D52), planDiff (from, to: a version, validated, latest, current), planDocument (the plan’s document as data, a version or current, the Document view’s model, D48), planDocumentPage (the same as one HTML page with its fonts, for the app to print with printToPDF, and the version it shows, for record_plan_send), exportPlanDocument (the document written as .docx, .html or .pdf, the send recorded on its version), dialogue (a dialogue block for its editor: its data, script, map and paths, and its voice lines with their audio’s state, D61), dialoguePlay (« Jouer un chemin »: the path of the choices given, its score, ending and duration), dialoguePage (its script and map as one HTML page, for the app to print), exportDialogue (the same written as .html or .pdf), effects (the effects a timing can take: presets and the theme’s, default durations, directions), transcribers (what can transcribe a language here: the local model, installed or its size, and ElevenLabs when a key is given), transcribeRecording (what a recording says, its words timed: on this computer, or ElevenLabs with the share approved, D46), transcriptionEstimate (what ElevenLabs would take of the quota for a file or a line’s recording), transcribeLine (a recorded line’s text now and what its recording says, nothing written), close;
  • events { kind: 'event', event }: changed (source engine, journal, watcher or media; the change with its author, batch, files and operations), diagnostics (the screens whose findings changed, removed screens, the project’s findings when they changed, the summary), mediaJob, backupTaken, batchClosed, error (a background task), closed.
// engine process
const session = await openProject(dir);
session.subscribe((message) => port.postMessage(message));
port.on('message', async ({ data }) => port.postMessage(await session.handle(data)));
// renderer
const client = new ProtocolClient((message) => port.postMessage(message));
port.onmessage = ({ data }) => client.receive(data);
const summary = await client.request('summary', {});

Measured

On the 87-screen course of scripts/make-large-course.ts (288 MB of video), on a busy machine (packages/engine/bench/measure.ts, test/e2e/engine-canvas.test.ts): open 30 to 90 ms (250 to 850 ms the first time, when every video is hashed), plus about 40 ms on macOS until the folder’s watcher reports (its thread starts, about 20 ms, then its probe is reported); a command to its changed event about 2 ms, to its diagnostics event 5 to 8 ms (median); a course.json edit (everything validated) about 50 ms; an agent’s own file write to its events about 75 ms (the debounce); a CLI edit is seen as soon as it is journaled; a command to the canvas DOM about 10 ms in Chrome; heap used after 150 commands +4 MB.