Hermes 인트로 시네마틱
Hermes Desktop에는 이미 22초짜리 인트로 필름이 내장되어 있습니다. 7비트의 비트, 라이브 큐브 뷰포트, 병렬 에이전트, Nous 브랜드, 합성 사운드로 구성되어 있습니다. 존재하지만 거의 아무도 두 번 보지 않습니다. 재생 조건은 guestOnboardingEnabled && !seen이기 때문입니다.
이 스킬은 인트로 필름이 매 실행마다 재생되도록 하고, 내장된 큐 위에 자신의 음성 해석 음성 트랙을 추가할 수 있게 합니다.
사용 시기
- "Hermes 시작 애니메이션을 매번 재생하게 만드세요."
- "부팅 애니메이션에 내 오디오를 넣으세요. 기존 효과음을 대체하지 말고 레이어로 추가하세요."
apps/desktop/src/components/intro-reveal/의 변경 사항.
현재 필름 상태
| 구성 요소 | 경로 |
|---|---|
| 게이트 (재생 시점) | apps/desktop/src/components/intro-reveal/index.tsx |
| 재생 자격 + 단계 저장소 | apps/desktop/src/store/intro-reveal.ts |
| 오버레이 부팅 | src/components/intro-reveal/intro-root.tsx |
| 시계, 점수, 캔버스 | use-intro-clock.ts, timeline.ts, viewport-cube.ts |
| 신디사이저 큐 | sound.ts |
| 네이티브 창 | electron/intro-reveal-window.ts, electron/main.ts |
타이밍 계산: INTRO_TOTAL_MS = 17 600은 점수 시간입니다. 실제 시간은 × INTRO_PACE (1.25) = 22 000ms이며, INTRO_EXIT_MS 900ms를 더하면 23 125ms가 총 재생 시간입니다.
매 실행마다 재생 — 세 가지 수정, 모두 필수
src/store/intro-reveal.ts—isIntroRevealEnabled()이 더 이상 게스트 온보딩을 요구하지 않아야 합니다.shouldPlayFirstRunIntro()가 온보딩 외부에서!playedThisSession을 반환하게 만듭니다. 모듈 수준의playedThisSession플래그는 필수입니다. 게이트의 효과는 단계가'hidden'으로 돌아올 때마다 다시 실행되는데, 이것이 필름이 끝날 때 정확히 발생하는 일입니다. 플래그가 없으면 무한 반복 루프가 발생합니다.src/components/intro-reveal/intro-root.tsx— 오버레이 창은isOnboardingEnabled()를 확인하는 가드를 가지고 있어, 이 조건이 true가 아니면 렌더링하지 않고 반환합니다. 메인 렌더러와 동일한 조건을 사용하도록 변경합니다.electron/main.ts—introRevealController는enabled: GUEST_ONBOARDING으로 생성되므로 메인 프로세스가open을 거부합니다.true로 설정합니다. 실제 재생 여부는 렌더러에서 결정됩니다.
온보딩의 beginOnboardingFlow() / queueGuideAfterIntro() / takeGuideShape()는 모두 isOnboardingEnabled()에서 자체 보호를 수행하므로, 온보딩이 비활성화되면 아무런 동작도 하지 않습니다. 추가 변경은 필요하지 않습니다.
비활성화 스위치: localStorage['hermes-intro-reveal-always-v1'] = '0' 또는 HERMES_SKIP_INTRO=1 / --skip-intro 플래그로 실행합니다.
오디오 트랙 레이어링
apps/desktop/public/intro-voiceover.mp3 파일을 넣습니다 (Vite는 public/을 dist/로 그대로 복사합니다). 그런 다음 sound.ts에서:
export function startVoiceover(cutAtMs: number): { stop: () => void }
— Audio 엘리먼트를 생성하고, volume을 설정하고, 재생을 시작하며, setTimeout(stop, cutAtMs - 300)으로 마지막 300ms 동안은 소리가 끊기지 않도록 램프 다운을 적용합니다. use-intro-clock.ts의 startPad() 옆에서 호출하고, 효과 정리(cleanup)에서 중지합니다.
<audio> 엘리먼트를 사용하세요. 절대 fetch() + decodeAudioData()를 사용하지 마세요. 패키징된 렌더러는 file://에서 실행되며, 여기서 fetch와 decodeAudioData는 CORS에 의해 차단되지만, 미디어 엘리먼트는 file:// 자산을 정상적으로 로드합니다.
두 트랙은 설계상 독립적입니다. 엘리먼트 오디오와 WebAudio 큐는 OS 레벨에서 믹싱됩니다. 아무것도 덕킹(ducking)하지 않습니다. 두 트랙 모두 $hapticsMuted에 연결하고, 한 번만 읽는 것이 아니라 구독(subscribe)해야 합니다. 그렇지 않으면 음소거 토글이 동기화되지 않습니다.
컷 포인트:
const cutMs = Math.round((INTRO_TOTAL_MS + INTRO_EXIT_MS) * INTRO_PACE) // 23 125
패키징된 앱에 적용
소스 코드 수정만으로는 asar가 다시 패킹될 때까지 적용되지 않습니다. 여기에는 두 가지 주의사항이 있으며, 반드시 확인할 가치가 있습니다.
사용자가 실제로 실행하는 앱을 찾으세요. /Applications라고 가정하지 마세요.
ps aux | grep '[H]ermes.app/Contents/MacOS/Hermes'
defaults read com.apple.dock persistent-apps | grep -o '/[^"]*Hermes\.app'
Hermes 업데이트는 ~/.hermes/hermes-agent*/apps/desktop/release/mac-arm64/Hermes.app에 설치되며, Dock이 그 위치를 가리키는 경우가 많습니다. /Applications/Hermes.app은 오래된 0.0.0 복사본일 수 있습니다. CFBundleShortVersionString으로 확인합니다 (0.17.x가 실제 버전, 0.0.0은 실제 버전이 아님).
app.asar.unpacked/dist/도 갱신해야 합니다. 패키징된 빌드는 asarUnpack에 dist/**를 포함하며, resolveRendererIndex()는 이 복사본을 선호합니다. asar만 다시 패킹하면 오래된 언패킹 트리가 새것을 가리게 됩니다.
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 # 메인 프로세스 수정에는 이 단계가 필요합니다
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" # 애드혹; Hermes는 애드혹 서명되어 배포됩니다
--ignore-scripts는 느린 네트워크에서 중요합니다. Electron 바이너리 다운로드는 빌드 또는 재패킹에는 필요하지 않으며, 실행에만 필요합니다.
Electron 없이 확인
일반 브라우저에서 필름을 렌더링합니다. 패키징, 바이너리 다운로드가 필요 없습니다.
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())")
라우트 자체의 마운트 호출은 브리지가 없으면 작동하지 않습니다. 스텁 후 수동 import가 실제로 화면에 렌더링됩니다. 오디오 트랙을 확인하려면 window.Audio를 스텁하고 인스턴스를 기록합니다. currentTime이 필름과 함께 진행되고, 컷 지점에서 paused === true, volume === 0인지 확인합니다.
src/store/intro-reveal.test.ts에 보관할 가치가 있는 계약 테스트:
- 일반 실행 시
hermes-intro-reveal-seen-v1이 설정되어 있어도 재생됩니다. - 정확히 한 번만 재생됩니다.
finishIntroReveal()이 다시 활성화하지 않아야 합니다. - 옵트아웃 키와
skipIntro실행 플래그를 존중합니다. - 온보딩의 첫 실행 시맨틱스를 건드리지 않습니다.
함정
playedThisSession을 제거하면 버그 없는 실행이 아닌 무한 재생 루프가 발생합니다.INTRO_TOTAL_MS는 점수 시간입니다.INTRO_PACE를 잊으면 음성 해석이 4.4초 일찍 잘립니다.- 메인 프로세스의
enabled플래그와 오버레이 자체 가드는 별개의 게이트입니다. 둘 중 하나를 놓치면 오류 없이 아무것도 렌더링되지 않습니다. - 화면 복귀를 유지하는 네 개의 독립적인 레이어가 있습니다 (일반 닫기, Esc + 1.2초 폴백, 로컬 데드맨, 34초 메인 프로세스 워치독). 타이밍을 변경할 때 하나를 제거하지 마세요.
hermes update는 공식 빌드로 앱을 교체합니다. 패치는 git에 유지됩니다. 업데이트 후 설치 프로그램을 다시 실행하세요.