Tutorial Video Workbench
Architecture and rationale live in tools/tutorial_videos/README.md — read
it first. This skill is the operational runbook.
Build videos
make tutorial_video TUTORIAL_LOCALE=de # one locale (~5-8 min)
make tutorial_video TUTORIAL_LOCALE=de TUTORIAL_DEVICE=mobile # phone-shaped window, `_mobile`-suffixed output
make tutorial_videos_all # all TUTORIAL_LOCALES
TUTORIAL_DEVICE (desktop|mobile, default desktop) picks the capture
window size — see the README's "Desktop vs. mobile" section for the
breakpoint rationale and the tutorialDeviceIsMobile() test hook.
Preconditions (fail fast if missing):
.env at repo root with GEMINI_API_KEY, MELIOUS_API_KEY,
MELIOUS_BASE_URL. If absent, the keys can be extracted from the dev
app's ai_config.sqlite provider rows (never print or commit them).
- Sibling
../OpenMontage checkout at the commit pinned in
tools/tutorial_videos/config/openmontage.pin, bootstrapped via
make -C ../OpenMontage setup.
Xvfb, ffmpeg, pactl, paplay, x11-utils installed; Flutter via
fvm.
Output lands in build/tutorial_videos/<scenario>_<locale>.mp4. Verify with
frame extraction (ffmpeg -ss <t> -frames:v 1) and check: cursor visible and
gliding, HUD clock top-center, transcript/proposals on screen, narration
audible at step starts (volumedetect), duration ≈ warped timeline total.
Publish to R2
make tutorial_video_publish TUTORIAL_SCENARIO=create_task_from_audio TUTORIAL_LOCALE=de
make tutorial_video_publish TUTORIAL_SCENARIO=create_task_from_audio TUTORIAL_LOCALE=de TUTORIAL_DEVICE=mobile
Uploads build/tutorial_videos/<scenario>_<locale>.mp4 (or, with
TUTORIAL_DEVICE=mobile, <scenario>_<locale>_mobile.mp4) to Cloudflare R2
at the matching tutorial-videos/... key and prints its public r2.dev URL —
this is the URL the docs-site TutorialVideo component builds from
TUTORIAL_VIDEO_BASE_URL/tutorialVideoBaseUrl. TUTORIAL_DEVICE must
match whatever --device the video was built with, or publish will look for
a file that was never produced. Full one-time Cloudflare setup (API token,
enabling the bucket's public r2.dev URL) and the .env variable list are in
the README's "Publishing to Cloudflare R2" section — read it before the
first publish.
boto3 (needed only by publish, not by build/tts/validate) is not installed
into the system Python on this host (PEP 668). Either run publish through
tools/tutorial_videos/.venv (.venv/bin/python3 -m tutorial_videos publish --scenario ... --locale ..., created via python3 -m venv .venv && .venv/bin/pip install boto3 pyyaml), or install boto3 into whatever
Python make tutorial_video_publish resolves to.
The pipeline in one paragraph
python3 -m tutorial_videos build (run from tools/tutorial_videos/) does:
Gemini-TTS pre-pass (cached by content hash → durations manifest) → Xvfb +
virtual-mic null sink + ffmpeg x11grab capture → fvm flutter drive runs
integration_test/tutorial/<scenario>_tutorial_test.dart against the real
app (penguin demo world + Melious/Voxtral/Qwen agent config seeded, UI
language via MANUAL_LANGUAGE settings row) → the test emits
timeline.json (step boundaries, wait spans, zero epoch) → timewarp.py
plans fast-forward segments for the waits (narration never overlaps) →
compose.py warps the capture and drives the pinned OpenMontage headlessly
(narration placement + mux) → ffprobe validation.
Debugging a failing run
- The run log tail shows the failing step and a
Diagnostics: line
(current route, detail stack, key widget presence).
- Look at the failure screenshot first:
build/tutorial_videos/failure_<context>.png is captured at the moment of
every timeout — it almost always explains the failure instantly.
- Reproduce faster without video: run the same test via
flutter drive
directly (see the doc comment in
integration_test/tutorial/tutorial_harness.dart for the env contract);
Xvfb + sink setup must match session.py.
- Common causes, in order of historical frequency: a finder matching an
offstage duplicate (scope to the visible page +
.hitTestable()), a
scroll helper given a non-vertical scrollable, a modal/dialog blocking
the UI (onboarding, discard-recording), the agent's
automaticUpdatesEnabled flag not sticking (set-and-verify), cloud
latency beyond a pumpUntil timeout.
Adding a locale
- Add the locale's text to
tools/tutorial_videos/config/scenarios/<scenario>.yaml: title,
dictionary, every step's narration, the dictation step's
dictation_text. Write informal register (du/tu) per repo l10n rules;
keep the penguin vocabulary.
- Add per-stream
style lines for the locale in config/voices.yaml
(style prompts in the target language — Voxtral/Gemini behave better).
python3 -m tutorial_videos validate --scenario <s> --locale <l> must
pass, then build. The app UI localizes itself via LOTTI_MANUAL_LOCALE
(same mechanism as manual screenshots; for a wholly new app locale, run
the add-flutter-docusaurus-locale skill first).
- Localized finder strings in the test (
localized(en:, de:) /
manualScreenshotText) need the new locale only if its UI labels are
used as finders — prefer keys over text finders where possible.
Adding a scenario
- New YAML in
config/scenarios/<name>.yaml — schema documented in
create_task_from_audio.yaml (unique step ids, exactly one
dictation: true step, per-locale text, min_duration floors).
- New
integration_test/tutorial/<name>_tutorial_test.dart built on
TutorialAppHarness + TutorialDriver. Copy the structure of
create_task_from_audio_tutorial_test.dart:
- one
driver.step(id, action) per YAML step, ids matching exactly;
- interactions via
driver.tapLikeUser (cursor glide), waits via
driver.pumpUntil* (records fast-forwardable wait spans), scrolling
via driver.scrollIntoView with a vertical scrollable finder;
- anything that must be SEEN gets asserted via visible widgets, not just
the DB;
- wire the
diagnostics and onTimeout hooks for failure screenshots.
- Iterate with builds; expect the first runs to fail — read the failure
screenshots, they carry the answer.
Non-negotiable gotchas
The full list with rationale is in the README's "Hard-won constraints" —
the ones that bite hardest: flutter drive not flutter test (no window
otherwise); GDK_BACKEND=x11 + empty WAYLAND_DISPLAY (or the app opens on
the real desktop); Voxtral prompts in the audio's language (else it
translates); automaticUpdatesEnabled set-and-verify (initial wake clobbers
early writes); 16ms pump cadence during holds (else animations render
choppy); timeline starts after startup settles; assert transcript keywords,
not exact strings.
Adapting / extending
- Compositor changes: all OpenMontage API usage is isolated in
tutorial_videos/om_compose_driver.py; bump the pin in
config/openmontage.pin deliberately and re-verify determinism
(compose twice, compare ffmpeg -f framemd5 of the streams — never file
hashes).
- TTS vendor: implement the
TtsEngine protocol (tts/base.py) next to
tts/gemini.py; voices/styles per locale in config/voices.yaml.
- Speed/pacing knobs:
timewarp.py (MAX_SPEED, lead-in, narration
gap); per-step floors in the scenario YAML.
- Character overlay (future): render transparent PNG frames via the
character film-strip harness and composite as an extra layer keyed to
timeline.json step ids.
1---2name: tutorial-videos3description: Build, debug, extend, localize, or publish the automated tutorial videos — the workbench that drives the real Linux app under Xvfb (virtual mic, live Voxtral transcription, task-agent proposals), records the screen, narrates with Gemini TTS, fast-forwards waits, composes MP4s via OpenMontage, and uploads finished videos to Cloudflare R2 for docs-site embedding. Use when asked to "build/regenerate the tutorial video(s)", add a tutorial scenario, add a tutorial locale, fix a broken tutorial run, adapt the pipeline, or upload/publish a video.4---56# Tutorial Video Workbench78Architecture and rationale live in `tools/tutorial_videos/README.md` — read9it first. This skill is the operational runbook.1011## Build videos1213```sh14make tutorial_video TUTORIAL_LOCALE=de # one locale (~5-8 min)15make tutorial_video TUTORIAL_LOCALE=de TUTORIAL_DEVICE=mobile # phone-shaped window, `_mobile`-suffixed output16make tutorial_videos_all # all TUTORIAL_LOCALES17```1819`TUTORIAL_DEVICE` (`desktop`|`mobile`, default `desktop`) picks the capture20window size — see the README's "Desktop vs. mobile" section for the21breakpoint rationale and the `tutorialDeviceIsMobile()` test hook.2223Preconditions (fail fast if missing):24- `.env` at repo root with `GEMINI_API_KEY`, `MELIOUS_API_KEY`,25 `MELIOUS_BASE_URL`. If absent, the keys can be extracted from the dev26 app's `ai_config.sqlite` provider rows (never print or commit them).27- Sibling `../OpenMontage` checkout at the commit pinned in28 `tools/tutorial_videos/config/openmontage.pin`, bootstrapped via29 `make -C ../OpenMontage setup`.30- `Xvfb`, `ffmpeg`, `pactl`, `paplay`, `x11-utils` installed; Flutter via31 `fvm`.3233Output lands in `build/tutorial_videos/<scenario>_<locale>.mp4`. Verify with34frame extraction (`ffmpeg -ss <t> -frames:v 1`) and check: cursor visible and35gliding, HUD clock top-center, transcript/proposals on screen, narration36audible at step starts (`volumedetect`), duration ≈ warped timeline total.3738## Publish to R23940```sh41make tutorial_video_publish TUTORIAL_SCENARIO=create_task_from_audio TUTORIAL_LOCALE=de42make tutorial_video_publish TUTORIAL_SCENARIO=create_task_from_audio TUTORIAL_LOCALE=de TUTORIAL_DEVICE=mobile43```4445Uploads `build/tutorial_videos/<scenario>_<locale>.mp4` (or, with46`TUTORIAL_DEVICE=mobile`, `<scenario>_<locale>_mobile.mp4`) to Cloudflare R247at the matching `tutorial-videos/...` key and prints its public r2.dev URL —48this is the URL the docs-site `TutorialVideo` component builds from49`TUTORIAL_VIDEO_BASE_URL`/`tutorialVideoBaseUrl`. `TUTORIAL_DEVICE` must50match whatever `--device` the video was built with, or publish will look for51a file that was never produced. Full one-time Cloudflare setup (API token,52enabling the bucket's public r2.dev URL) and the `.env` variable list are in53the README's "Publishing to Cloudflare R2" section — read it before the54first publish.5556`boto3` (needed only by publish, not by build/tts/validate) is not installed57into the system Python on this host (PEP 668). Either run publish through58`tools/tutorial_videos/.venv` (`.venv/bin/python3 -m tutorial_videos publish59--scenario ... --locale ...`, created via `python3 -m venv .venv &&60.venv/bin/pip install boto3 pyyaml`), or install `boto3` into whatever61Python `make tutorial_video_publish` resolves to.6263## The pipeline in one paragraph6465`python3 -m tutorial_videos build` (run from `tools/tutorial_videos/`) does:66Gemini-TTS pre-pass (cached by content hash → durations manifest) → Xvfb +67virtual-mic null sink + ffmpeg x11grab capture → `fvm flutter drive` runs68`integration_test/tutorial/<scenario>_tutorial_test.dart` against the real69app (penguin demo world + Melious/Voxtral/Qwen agent config seeded, UI70language via `MANUAL_LANGUAGE` settings row) → the test emits71`timeline.json` (step boundaries, wait spans, zero epoch) → `timewarp.py`72plans fast-forward segments for the waits (narration never overlaps) →73`compose.py` warps the capture and drives the pinned OpenMontage headlessly74(narration placement + mux) → ffprobe validation.7576## Debugging a failing run77781. The run log tail shows the failing step and a `Diagnostics:` line79 (current route, detail stack, key widget presence).802. **Look at the failure screenshot first**:81 `build/tutorial_videos/failure_<context>.png` is captured at the moment of82 every timeout — it almost always explains the failure instantly.833. Reproduce faster without video: run the same test via `flutter drive`84 directly (see the doc comment in85 `integration_test/tutorial/tutorial_harness.dart` for the env contract);86 Xvfb + sink setup must match `session.py`.874. Common causes, in order of historical frequency: a finder matching an88 offstage duplicate (scope to the visible page + `.hitTestable()`), a89 scroll helper given a non-vertical scrollable, a modal/dialog blocking90 the UI (onboarding, discard-recording), the agent's91 `automaticUpdatesEnabled` flag not sticking (set-and-verify), cloud92 latency beyond a `pumpUntil` timeout.9394## Adding a locale95961. Add the locale's text to97 `tools/tutorial_videos/config/scenarios/<scenario>.yaml`: `title`,98 `dictionary`, every step's `narration`, the dictation step's99 `dictation_text`. Write informal register (du/tu) per repo l10n rules;100 keep the penguin vocabulary.1012. Add per-stream `style` lines for the locale in `config/voices.yaml`102 (style prompts in the target language — Voxtral/Gemini behave better).1033. `python3 -m tutorial_videos validate --scenario <s> --locale <l>` must104 pass, then build. The app UI localizes itself via `LOTTI_MANUAL_LOCALE`105 (same mechanism as manual screenshots; for a wholly new app locale, run106 the `add-flutter-docusaurus-locale` skill first).1074. Localized finder strings in the test (`localized(en:, de:)` /108 `manualScreenshotText`) need the new locale only if its UI labels are109 used as finders — prefer keys over text finders where possible.110111## Adding a scenario1121131. New YAML in `config/scenarios/<name>.yaml` — schema documented in114 `create_task_from_audio.yaml` (unique step ids, exactly one115 `dictation: true` step, per-locale text, `min_duration` floors).1162. New `integration_test/tutorial/<name>_tutorial_test.dart` built on117 `TutorialAppHarness` + `TutorialDriver`. Copy the structure of118 `create_task_from_audio_tutorial_test.dart`:119 - one `driver.step(id, action)` per YAML step, ids matching exactly;120 - interactions via `driver.tapLikeUser` (cursor glide), waits via121 `driver.pumpUntil*` (records fast-forwardable wait spans), scrolling122 via `driver.scrollIntoView` with a **vertical** scrollable finder;123 - anything that must be SEEN gets asserted via visible widgets, not just124 the DB;125 - wire the `diagnostics` and `onTimeout` hooks for failure screenshots.1263. Iterate with builds; expect the first runs to fail — read the failure127 screenshots, they carry the answer.128129## Non-negotiable gotchas130131The full list with rationale is in the README's "Hard-won constraints" —132the ones that bite hardest: `flutter drive` not `flutter test` (no window133otherwise); `GDK_BACKEND=x11` + empty `WAYLAND_DISPLAY` (or the app opens on134the real desktop); Voxtral prompts in the audio's language (else it135translates); `automaticUpdatesEnabled` set-and-verify (initial wake clobbers136early writes); 16ms pump cadence during holds (else animations render137choppy); timeline starts after startup settles; assert transcript keywords,138not exact strings.139140## Adapting / extending141142- **Compositor changes**: all OpenMontage API usage is isolated in143 `tutorial_videos/om_compose_driver.py`; bump the pin in144 `config/openmontage.pin` deliberately and re-verify determinism145 (compose twice, compare `ffmpeg -f framemd5` of the streams — never file146 hashes).147- **TTS vendor**: implement the `TtsEngine` protocol (`tts/base.py`) next to148 `tts/gemini.py`; voices/styles per locale in `config/voices.yaml`.149- **Speed/pacing knobs**: `timewarp.py` (`MAX_SPEED`, lead-in, narration150 gap); per-step floors in the scenario YAML.151- **Character overlay (future)**: render transparent PNG frames via the152 character film-strip harness and composite as an extra layer keyed to153 `timeline.json` step ids.