Demo — Recording & Composition
The output side of @absolutejs/demo: capture the screen while the runner drives the product, render AI narration per step, then compose both into a final video with ffmpeg — narration offset against the recorded screen by the run's own timeline, and the whole run documented in a proof-of-run manifest.
#The Pipeline
Recording, voiceover, and composition are three independent drivers that meet in the DemoRunReport: the recorder contributes a recording artifact, each narration a voiceover artifact with its duration, and the event log timestamps everything so composition knows where each line belongs.
recorder.start(run)narrate({ text: '…', emotion: 'confident' })const report = await runner.run(script)composeDemoWithFFmpeg(report, { outputPath: 'demo.mp4' })writeDemoManifest(report, 'demo.manifest.json')#Recording
A recorder is anything implementing DemoRecorder — start(run), stop(reason?) returning a recording artifact, and an optional mark(label) for chapter markers. Three paths cover the practical cases:
| Recorder | Best for | Notes |
|---|---|---|
Playwright video | Browser-only demos | Pass recordVideoDir to the session; close() returns the video as a recording artifact. Zero extra tooling. |
createScreenRecorder | Real apps on a display | ffmpeg x11grab capture of the screen or one X window, with optional PulseAudio — records Discord, Meet, anything visible. |
createCommandRecorder | Custom tooling | Wraps any start/stop CLI — OBS command bridges or platform-native recorders. |
import {
createCommandRecorder,
createPlaywrightVideoArtifact,
createScreenRecorder
} from '@absolutejs/demo/recording';
// Wrap any CLI recorder — ffmpeg invocations, OBS command bridges, or
// platform-native recorders. start() spawns; stop() runs stopCommand
// (or SIGINTs the start process) and returns a recording artifact.
const obsRecorder = createCommandRecorder({
startCommand: (run) => ['obs-cmd', 'recording', 'start'],
stopCommand: (run) => ['obs-cmd', 'recording', 'stop'],
outputPath: (run) => '.demo-video/' + run.id + '.mp4'
});
// Full-screen X11 recorder — captures REAL apps on a display, not just
// the Playwright browser. x11grab (+ optional PulseAudio) → mp4.
const screenRecorder = createScreenRecorder({
outputPath: (run) => '.demo-video/' + run.id + '.mp4',
display: ':0', // defaults to $DISPLAY or ':0' (WSLg)
windowId: '0x1200004', // grab one X window (WSLg root is black)
cropRect: { x: 0, y: 88, width: 1600, height: 812 }, // drop chrome
framerate: 30,
// PulseAudio: true → default source; or a sink monitor to record
// playback (a live Meet/Discord call). Omit for video-only.
audio: { device: 'rdp-sink.monitor' }
});
const runner = createDemoRunner({ recorder: screenRecorder });
// recorder.start() fires before step one; stop() runs after the last
// step (or on failure) and its artifact joins report.artifacts.
// Browser-only demos can skip a recorder entirely: pass recordVideoDir
// to createPlaywrightDemoSession and session.close() returns the video
// as a recording artifact — or wrap an existing file yourself:
const artifact = createPlaywrightVideoArtifact(
'crm-demo-recording',
'.demo-video/abc123.webm',
{ source: 'playwright' }
);createScreenRecorder() is hardened for long captures: ffmpeg's progress output is discarded so it can never fill a pipe and stall the grab, stop SIGINTs and awaits a clean finalize (escalating to SIGKILL on a bounded timeout), and the fragmented-mp4 flags keep the file playable even if ffmpeg dies without a trailer.
| Option | Type | Description |
|---|---|---|
outputPath | string | (run) => string | Where the mp4 is written; a factory gets the DemoRunInfo. |
display | string | X display to grab. Defaults to $DISPLAY or ":0" (WSLg). |
windowId | string | Grab a single X window instead of the root — on WSLg the root is black, but a window grab captures correctly. |
videoSize | 'WIDTHxHEIGHT' | Fixed region grab; ignored when windowId is set. |
cropRect | { x, y, width, height } | Crop after grabbing — e.g. drop the browser chrome and keep the viewport. Rounded to even dimensions for libx264. |
framerate | number | Capture framerate; defaults to 30. |
audio | boolean | { device? } | PulseAudio capture — true for the default source, or a device such as a sink monitor to record playback (a live call). |
ffmpegPath | string | Binary override; defaults to "ffmpeg" on PATH. |
#AI Voiceover
A voiceover is a single method — speak(input) — that renders one narration line to an audio file and returns it as a voiceover artifact. Three factories ship in @absolutejs/demo/voiceover:
| Factory | Tier | Notes |
|---|---|---|
createElevenLabsVoiceover | Premium — client demos | High-fidelity mp3_44100_128 renders with the tuned Dealroom settings. The recommended tier. |
createDeepgramAuraVoiceover | Fast + cheap — internal runs | Deepgram Aura linear16 PCM; faster and cheaper but reads more synthetic. |
createVoiceTTSVoiceover | Bring your own adapter | Wraps any @absolutejs/voice TTSAdapter; emotion maps to the prosody style. |
import {
createDeepgramAuraVoiceover,
createElevenLabsVoiceover,
createVoiceTTSVoiceover
} from '@absolutejs/demo/voiceover';
// Premium tier — the recommended default for client demos.
const premium = createElevenLabsVoiceover({
apiKey: process.env.ELEVENLABS_API_KEY ?? '',
outputDir: '.demo-voiceover',
// Everything below is optional — these ARE the defaults:
voiceId: '21m00Tcm4TlvDq8ikWAM', // Rachel (American)
modelId: 'eleven_flash_v2_5',
outputFormat: 'mp3_44100_128', // CBR → duration derivable
voiceSettings: {
stability: 0.42,
similarityBoost: 0.78,
style: 0.35,
speed: 1,
useSpeakerBoost: true
},
cacheDir: '.demo-voiceover/cache' // content-addressed render cache
});
// Faster and cheaper, reads more synthetic — fine for internal runs.
const fast = createDeepgramAuraVoiceover({
apiKey: process.env.DEEPGRAM_API_KEY ?? '',
outputDir: '.demo-voiceover',
model: 'aura-asteria-en',
sampleRateHz: 24000 // 8000 | 16000 | 24000 | 48000
});
// Or wrap any @absolutejs/voice TTSAdapter.
const adapter = createVoiceTTSVoiceover({
tts: myTtsAdapter,
outputDir: '.demo-voiceover'
});
// Narration steps drive whichever voiceover the runner holds:
narrate({
text: 'Here is the live pipeline view.',
emotion: 'confident', // 'neutral'|'confident'|'excited'|'calm'
voice: 'pNInz6obpgDQGcFmaJgB' // per-line voice id override
});ElevenLabs defaults come from the Dealroom voice upgrade — exported as DEFAULT_ELEVENLABS_VOICE_ID, DEFAULT_ELEVENLABS_MODEL_ID, and DEFAULT_ELEVENLABS_OUTPUT_FORMAT:
| Setting | Default | Why |
|---|---|---|
voiceId | '21m00Tcm4TlvDq8ikWAM' | Rachel — the American voice tuned for client demos. Override per line via input.voice. |
modelId | 'eleven_flash_v2_5' | DEFAULT_ELEVENLABS_MODEL_ID. |
outputFormat | 'mp3_44100_128' | CBR mp3 — duration is derivable from byte length and the file drops straight into ffmpeg composition. |
stability / style | 0.42 / 0.35 | The tuned expressive dials from the Dealroom voice upgrade. |
similarityBoost / speed | 0.78 / 1 | Similarity boost keeps the voice on-character. |
useSpeakerBoost | true | Speaker boost on for presence. |
input.emotion nudges only the two expressive dials, so a tuned base voice keeps its character:
ELEVENLABS_API_KEY (a restricted synthesis key) for rendering. ELEVENLABS_ADMIN_API_KEY (write-capable) is reserved for future pronunciation-dictionary sync and should stay out of the synthesis path.#Pronunciation & Caching
Both wrappers are provider-agnostic — they compose over the ElevenLabs, Aura, and generic-adapter voiceovers alike. withPronunciationAliases() rewrites demo vocabulary before TTS using DEFAULT_PRONUNCIATION_RULES (onSpark, AbsoluteJS, PDL, CRM, …); withRenderCache() synthesizes each unique line once and replays it from disk on later runs.
import {
applyPronunciationAliases,
createElevenLabsVoiceover,
DEFAULT_PRONUNCIATION_RULES,
withPronunciationAliases,
withRenderCache
} from '@absolutejs/demo/voiceover';
// Demo-vocabulary pronunciation fixes (onSpark, AbsoluteJS, PDL, …)
// applied before TTS, and identical lines cached so re-runs skip
// re-synthesis. Both wrappers are provider-agnostic.
const voiceover = withRenderCache(
withPronunciationAliases(
createElevenLabsVoiceover({
apiKey: process.env.ELEVENLABS_API_KEY ?? '',
outputDir: '.demo-voiceover'
})
),
{ cacheDir: '.demo-voiceover/cache', salt: 'rachel:flash_v2_5' }
);
// Add product vocabulary on top of the shipped defaults.
withPronunciationAliases(voiceover, [
...DEFAULT_PRONUNCIATION_RULES,
{ match: 'K8s', replacement: 'kubernetes' }
]);
// Or rewrite text directly (word-boundary, case-insensitive):
applyPronunciationAliases('AbsoluteJS ships onSpark');
// → 'absolute jay ess ships on spark'
// Cache controls:
withRenderCache(voiceover, {
cacheDir: '.demo-voiceover/cache',
// Bump whenever voice/model/settings change so stale audio is
// never replayed — the salt folds into the cache key.
salt: 'rachel:flash_v2_5',
// Return null to bypass the cache for a line (dynamic narration).
keyFor: (input) => (input.metadata?.dynamic ? null : input.text)
});cacheDir — a content-addressed cache keyed on the full request signature (voice, model, format, settings, language, seed, text) with an exact-signature guard against hash collisions.#Composition
composeDemoWithFFmpeg() (from @absolutejs/demo/composition) creates the final video artifact from the run recording and voiceover artifacts. Each line is delayed to its offset, the mix never clips the recording short, and the video is padded so narration never outruns the screen.
import { composeDemoWithFFmpeg } from '@absolutejs/demo/composition';
// Mux the run recording and every voiceover artifact into one final
// video. Narration is offset against the recorded screen.
const finalVideo = await composeDemoWithFFmpeg(report, {
outputPath: '.demo-artifacts/crm-demo.mp4'
});
// → { id: '<run>-composition', kind: 'composition', path, metadata }
// Every dial:
await composeDemoWithFFmpeg(report, {
outputPath: '.demo-artifacts/crm-demo.mp4',
audioMode: 'voiceover+recording', // keep live captured audio too
voiceoverTiming: 'timeline', // place lines by run-clock offsets
voiceoverGapMs: 250, // gap between lines in 'sequential' mode
voiceoverOffsetsMs: { 'voiceover-3': 12400 }, // manual override wins
videoCodec: 'libx264', // default for .mp4 output; 'copy' remuxes
audioCodec: 'aac',
extendVideo: true, // freeze the last frame until narration ends
outputDurationMs: 95000,
recordingPath: '.demo-video/override.webm', // bypass the artifact
overwrite: true
});
// Or hold it behind the DemoComposer interface:
import { createFFmpegDemoComposer } from '@absolutejs/demo';
const composer = createFFmpegDemoComposer();
await composer.compose(report, { outputPath: 'final.mp4' });#Timeline & Manifest
createDemoTimeline() flattens the report's event log into offset-from-start entries — it is what timeline-mode composition consults, and getDemoArtifactOffsetMs() answers the one-artifact question directly. writeDemoManifest() emits the shareable proof-of-run document, and createDemoSyncPlan() authors a narration/visual schedule ahead of a run.
import {
createDemoManifest,
createDemoSyncPlan,
createDemoTimeline,
getDemoArtifactOffsetMs,
writeDemoManifest
} from '@absolutejs/demo';
// Timeline — run events + artifacts as offset-from-start entries.
const timeline = createDemoTimeline(report, { includeSteps: true });
// [{ id, type, at, offsetMs, label?, artifact? }, ...]
// type: 'run' | 'step' | 'artifact' | 'voiceover' | 'recording'
// | 'screenshot' | 'composition'
// Where one artifact landed on the run clock.
const offsetMs = getDemoArtifactOffsetMs(report, 'voiceover-3');
// Manifest — a self-describing proof-of-run JSON document.
const manifest = await writeDemoManifest(
report,
'.demo-artifacts/crm-demo.manifest.json',
{ environment: 'staging', gitSha: process.env.GIT_SHA }
);
// manifest.proof → { status, durationMs, artifactCount, eventCount,
// hasRecording, hasVoiceover }
// createDemoManifest(report, options) builds it without writing.
// Sync plan — author a narration/visual schedule ahead of a run. Items
// publish ReactiveEvents through @absolutejs/sync as they are added.
const plan = createDemoSyncPlan('crm-demo');
plan.addVoiceover({
id: 'intro',
text: 'Here is the live pipeline view.',
artifact: voiceArtifact,
durationMs: 5200
});
plan.addVisual({ id: 'callout', label: 'Spotlight', durationMs: 1800 });
plan.durationMs; // running end of the schedule
plan.events(); // the published ReactiveEvents