Communitygithub.com

xbmaxx/hermes-intro-cinematic

Use when adding a startup cinematic to Hermes Desktop.

What is hermes-intro-cinematic?

hermes-intro-cinematic is a Claude Code agent skill that use when adding a startup cinematic to Hermes Desktop.

Works with~Claude Code~Codex CLI~Cursor
npx skills add xbmaxx/hermes-intro-cinematic

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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

PiecePath
Gate (when it plays)apps/desktop/src/components/intro-reveal/index.tsx
Eligibility + phase storeapps/desktop/src/store/intro-reveal.ts
Overlay bootsrc/components/intro-reveal/intro-root.tsx
Clock, score, canvasuse-intro-clock.ts, timeline.ts, viewport-cube.ts
Synth cuessound.ts
Native windowelectron/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

  1. src/store/intro-reveal.ts — isIntroRevealEnabled() must stop requiring guest onboarding; shouldPlayFirstRunIntro() returns !playedThisSession outside onboarding. A module-level playedThisSession flag 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.
  2. src/components/intro-reveal/intro-root.tsx — the overlay window guards on isOnboardingEnabled() and would return before painting. Switch it to the same predicate as the main renderer.
  3. electron/main.ts — introRevealController is constructed with enabled: GUEST_ONBOARDING, so the main process rejects open. Set it to true; 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-v1 is set
  • plays exactly once — finishIntroReveal() must not re-arm it
  • honors the opt-out key and the skipIntro launch flag
  • leaves onboarding's first-run semantics untouched

Pitfalls

  • Removing playedThisSession gives an infinite replay loop, not a bug-free launch.
  • INTRO_TOTAL_MS is score time. Forgetting INTRO_PACE cuts the voiceover 4.4 s early.
  • The main-process enabled flag 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 update swaps the app for the official build. The patch persists in git; rerun the installer after updating.

Related Skills