AbsoluteJS

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.

1
recorderRecord the run
The runner starts the recorder before step one — Playwright video for browser-only demos, or an ffmpeg screen recorder when real apps are on screen.
recorder.start(run)
2
voiceoverNarrate each beat
Each narrate step renders audio through the voiceover driver and adds a voiceover artifact carrying its durationMs. With voiceoverPlayback: 'wait-for-duration', the run paces itself to the audio.
narrate({ text: '…', emotion: 'confident' })
3
runnerStop and report
After the last step (or on failure) the recorder is stopped and its file joins report.artifacts, alongside timestamped run/step/artifact events.
const report = await runner.run(script)
4
ffmpegCompose the final video
Composition muxes the recording with every voiceover artifact, offsetting narration against the recorded screen using the demo timeline.
composeDemoWithFFmpeg(report, { outputPath: 'demo.mp4' })
5
manifestWrite the proof
The manifest is a self-describing proof-of-run document: the full report, a proof summary, and the timeline.
writeDemoManifest(report, 'demo.manifest.json')

#Recording

A recorder is anything implementing DemoRecorderstart(run), stop(reason?) returning a recording artifact, and an optional mark(label) for chapter markers. Three paths cover the practical cases:

RecorderBest forNotes
Playwright videoBrowser-only demosPass recordVideoDir to the session; close() returns the video as a recording artifact. Zero extra tooling.
createScreenRecorderReal apps on a displayffmpeg x11grab capture of the screen or one X window, with optional PulseAudio — records Discord, Meet, anything visible.
createCommandRecorderCustom toolingWraps any start/stop CLI — OBS command bridges or platform-native recorders.
TS
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.

OptionTypeDescription
outputPathstring | (run) => stringWhere the mp4 is written; a factory gets the DemoRunInfo.
displaystringX display to grab. Defaults to $DISPLAY or ":0" (WSLg).
windowIdstringGrab 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.
frameratenumberCapture framerate; defaults to 30.
audioboolean | { device? }PulseAudio capture — true for the default source, or a device such as a sink monitor to record playback (a live call).
ffmpegPathstringBinary 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:

FactoryTierNotes
createElevenLabsVoiceoverPremium — client demosHigh-fidelity mp3_44100_128 renders with the tuned Dealroom settings. The recommended tier.
createDeepgramAuraVoiceoverFast + cheap — internal runsDeepgram Aura linear16 PCM; faster and cheaper but reads more synthetic.
createVoiceTTSVoiceoverBring your own adapterWraps any @absolutejs/voice TTSAdapter; emotion maps to the prosody style.
TS
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:

SettingDefaultWhy
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 / style0.42 / 0.35The tuned expressive dials from the Dealroom voice upgrade.
similarityBoost / speed0.78 / 1Similarity boost keeps the voice on-character.
useSpeakerBoosttrueSpeaker boost on for presence.

input.emotion nudges only the two expressive dials, so a tuned base voice keeps its character:

'neutral'No change — the tuned base settings.
'calm'Stability 0.6, style 0.25 — steadier, more measured.
'confident'Stability 0.5, style 0.45 — assured delivery.
'excited'Stability 0.3, style 0.6 — the most animated read.
Two ElevenLabs keys
Set 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.

TS
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)
});
Never re-billed for the same line
Compose the pronunciation wrapper inside-out as shown so the cache keys on the spoken-as text. The ElevenLabs factory also accepts its own 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.

outputPathFinal video path. .mp4 defaults the video codec to libx264; anything else remuxes with copy.
recordingPathUse this file instead of the report’s recording artifact.
audioMode"voiceover-only" (default) mixes just the narration; "voiceover+recording" keeps the recording’s own captured audio (live call voices) under the narration; "none" strips audio.
voiceoverTiming"timeline" places each line at its run-clock offset; "sequential" plays lines back-to-back.
voiceoverGapMsGap between lines in sequential mode; defaults to 250.
voiceoverOffsetsMsPer-artifact-id manual offsets — always win over either timing mode.
extendVideoFreeze the last frame until the final narration ends (default on when narration exists).
outputDurationMsForce a total duration for the output.
videoCodec / audioCodec"copy" | "libx264" and "aac" | "copy" overrides.
overwrite / ffmpegPathOverwrite the output (default true) and the ffmpeg binary to run.
TS
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.

TS
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