Hermes Intro Cinematic
Hermes Desktop already ships a 22-second intro film — seven beats, a live cube
viewport, parallel agents, the Nous brand — with synthesized sound. It exists but
almost nobody sees it twice: eligibility is guestOnboardingEnabled && !seen.
This skill makes the film play on every launch, and lets you layer your own voiceover track under the built-in cues.
When to use
- "Make the Hermes startup animation play every time."
- "Put my audio on the boot animation — layered, not replacing the sound effects."
- Any change to
apps/desktop/src/components/intro-reveal/.
What the film already is
| Piece | Path |
|---|---|
| Gate (when it plays) | apps/desktop/src/components/intro-reveal/index.tsx |
| Eligibility + phase store | apps/desktop/src/store/intro-reveal.ts |
| Overlay boot | src/components/intro-reveal/intro-root.tsx |
| Clock, score, canvas | use-intro-clock.ts, timeline.ts, viewport-cube.ts |
| Synth cues | sound.ts |
| Native window | electron/intro-reveal-window.ts, electron/main.ts |
Timing math: INTRO_TOTAL_MS = 17 600 is score time; wall time is
× INTRO_PACE (1.25) = 22 000 ms, plus INTRO_EXIT_MS 900 ms → 23 125 ms total.
Replay on every launch — three edits, all required
src/store/intro-reveal.ts—isIntroRevealEnabled()must stop requiring guest onboarding;shouldPlayFirstRunIntro()returns!playedThisSessionoutside onboarding. A module-levelplayedThisSessionflag is mandatory: the gate's effect re-runs whenever the phase returns to'hidden', which is exactly what finishing the film does — without the flag it loops forever.src/components/intro-reveal/intro-root.tsx— the overlay window guards onisOnboardingEnabled()and would return before painting. Switch it to the same predicate as the main renderer.electron/main.ts—introRevealControlleris constructed withenabled: GUEST_ONBOARDING, so the main process rejectsopen. Set it totrue; liveness is decided in the renderer.
Onboarding's beginOnboardingFlow() / queueGuideAfterIntro() /
takeGuideShape() all self-guard on isOnboardingEnabled(), so they no-op when
onboarding is off — no extra change needed.
Off switch: localStorage['hermes-intro-reveal-always-v1'] = '0', or launch with
HERMES_SKIP_INTRO=1 / --skip-intro.
Layering an audio track
Put a file at apps/desktop/public/intro-voiceover.mp3 (vite copies public/ to
dist/ verbatim), then in sound.ts:
export function startVoiceover(cutAtMs: number): { stop: () => void }
— create an Audio element, set volume, start it, and setTimeout(stop, cutAtMs - 300)
so the last 300 ms ramps down instead of clicking. Call it next to startPad() in
use-intro-clock.ts and stop it in the effect cleanup.
Use an <audio> element, never fetch() + decodeAudioData(). The packaged
renderer runs on file://, where fetch and decodeAudioData are blocked by CORS;
media elements load file:// assets fine.
The two tracks are independent by construction — element audio and WebAudio cues
mix at the OS level. Nothing ducks anything. Wire $hapticsMuted to both, and
subscribe rather than reading it once, or the mute toggle desyncs.
Cut point:
const cutMs = Math.round((INTRO_TOTAL_MS + INTRO_EXIT_MS) * INTRO_PACE) // 23 125
Ship it to a packaged app
Source edits do nothing until the asar is repacked. Two traps here, both worth the check:
Find the app the user actually launches — never assume /Applications.
ps aux | grep '[H]ermes.app/Contents/MacOS/Hermes'
defaults read com.apple.dock persistent-apps | grep -o '/[^"]*Hermes\.app'
Hermes updates land in ~/.hermes/hermes-agent*/apps/desktop/release/mac-arm64/Hermes.app
and the Dock often points there, while /Applications/Hermes.app can be a stale
0.0.0 copy. Confirm with CFBundleShortVersionString (0.17.x is real, 0.0.0 is not).
Refresh app.asar.unpacked/dist/ too. Packaged builds list dist/** in
asarUnpack, and resolveRendererIndex() prefers that copy — repack the asar
alone and the stale unpacked tree shadows it.
npm install --workspace apps/desktop --ignore-scripts \
--engine-strict=false --min-release-age=0
cd apps/desktop && node ../../node_modules/vite/bin/vite.js build
node scripts/bundle-electron-main.mjs # main-process edits need this step too
asar extract "$RES/app.asar" "$WORK"
rm -rf "$WORK/dist" && cp -R apps/desktop/dist "$WORK/dist"
asar pack "$WORK" "$WORK/app.asar"
mv -f "$WORK/app.asar" "$RES/app.asar"
cp -R apps/desktop/dist/. "$RES/app.asar.unpacked/dist/"
codesign --force --deep -s - "$APP" # ad-hoc; Hermes ships ad-hoc signed
--ignore-scripts matters on slow networks: the Electron binary download is not
needed to build or repack, only to run dev.
Verify without Electron
Render the film in a plain browser — no packaging, no binary download:
cd apps/desktop && node ../../node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5174
page.add_init_script("""
window.hermesDesktop = {
guestOnboardingEnabled: true, skipIntro: false,
introReveal: { ready(){}, skip(){},
open(){ return Promise.resolve() }, close(){ return Promise.resolve() },
onSkip(){ return () => {} }, onClosed(){ return () => {} } }
};
""")
page.goto("http://127.0.0.1:5174/?win=intro")
page.evaluate("import('/src/components/intro-reveal/intro-root.tsx').then(m => m.mountIntroReveal())")
The route's own mount call no-ops without the bridge; the manual import after
stubbing is what actually paints. To check the audio track, stub window.Audio
and record instances — assert currentTime advances with the film and that
paused === true, volume === 0 at the cut.
Contract tests worth keeping in src/store/intro-reveal.test.ts:
- plays on a normal launch even when
hermes-intro-reveal-seen-v1is set - plays exactly once —
finishIntroReveal()must not re-arm it - honors the opt-out key and the
skipIntrolaunch flag - leaves onboarding's first-run semantics untouched
Pitfalls
- Removing
playedThisSessiongives an infinite replay loop, not a bug-free launch. INTRO_TOTAL_MSis score time. ForgettingINTRO_PACEcuts the voiceover 4.4 s early.- The main-process
enabledflag and the overlay's own guard are separate gates; miss either and nothing renders, with no error. - Four independent layers keep the screen coming back (normal close, Esc + 1.2 s fallback, local deadman, 34 s main-process watchdog). Don't remove one while changing timing.
hermes updateswaps the app for the official build. The patch persists in git; rerun the installer after updating.