diff --git a/docs/overnight/LOG.md b/docs/overnight/LOG.md index c275a64..2e72dc0 100644 --- a/docs/overnight/LOG.md +++ b/docs/overnight/LOG.md @@ -47,3 +47,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page). - **Known weaknesses:** the 92%-wide panel hides the board while reading (visible at chapter entry, in gaps between case studies and at exit); phone screenshots read dim; ~140 px on phones; test-only telemetry. - Screenshots: docs/overnight/round5/. + +## Round 6: Dashcam (photo mode + postcard) +- **What:** a camera button in the drive controls (or key `c`) folds the reading plates away, closes letterbox bars, and shows a dashcam frame with live telemetry (REC dot on the bass, chapter + exit, Bucharest clock, route %, coordinates + name). The lens drifts with the pointer (+-1.6 units, re-centres after 3 s idle). "Save postcard" downloads a 1600x900 PNG of the live scene with credit line, a white shutter flash and a synthesized shutter click when sound is on. Tab title becomes "DASHCAM · 47% · INFRASTRUCTURE · 03:51" while open. Esc/c/Close exits and returns focus. Absent for reduced motion/static/visitor-pause and below 768 px. +- **Why:** the recurring weakness of rounds 1-5: the reading panels hide the world. This gives a juror one deliberate full-screen moment, and a shareable artefact. +- **Build:** journey/dashcam-feed.ts, scene/world/dashcam-lens.ts, composables/useDashcam.ts, lazy components/DashcamFrame.vue + lib/dashcam-postcard.ts + audio/switchboard-click.ts, styles/dashcam.css. 820 tests, initial JS 117.73 KB gz (2.3 KB left), lazy chunk 3.7 KB gz. +- **Verdict: KEEP with fixes** (Fable, hardware GL): Contact bars were striped (aubergine outro bars under black), now frame draws its own bars; canvas resized on open due to overflow lock (removed); focus dropped after save; `c` worked on phones where the button is hidden; round 5's HUD exit tab had been dropped, restored; REC dot got a no-music blink. +- **Known weaknesses:** postcard is the 1440x900 buffer scaled to 1600 (slightly soft) and keeps the lens bars; no phone version; shutter click only while soundtrack runs; two chapter-label vocabularies in the frame. +- Screenshots: docs/overnight/round6/ (postcard.webp is the real downloaded file). diff --git a/docs/overnight/round6/before-1440-infra-reading.webp b/docs/overnight/round6/before-1440-infra-reading.webp new file mode 100644 index 0000000..31b1ddb Binary files /dev/null and b/docs/overnight/round6/before-1440-infra-reading.webp differ diff --git a/docs/overnight/round6/builds-open-billboard.webp b/docs/overnight/round6/builds-open-billboard.webp new file mode 100644 index 0000000..ac85e51 Binary files /dev/null and b/docs/overnight/round6/builds-open-billboard.webp differ diff --git a/docs/overnight/round6/contact-before-fix-stripes.webp b/docs/overnight/round6/contact-before-fix-stripes.webp new file mode 100644 index 0000000..d93adac Binary files /dev/null and b/docs/overnight/round6/contact-before-fix-stripes.webp differ diff --git a/docs/overnight/round6/contact-open-dawn.webp b/docs/overnight/round6/contact-open-dawn.webp new file mode 100644 index 0000000..5b3bf6e Binary files /dev/null and b/docs/overnight/round6/contact-open-dawn.webp differ diff --git a/docs/overnight/round6/infra-drift-left.webp b/docs/overnight/round6/infra-drift-left.webp new file mode 100644 index 0000000..4623bf7 Binary files /dev/null and b/docs/overnight/round6/infra-drift-left.webp differ diff --git a/docs/overnight/round6/infra-open.webp b/docs/overnight/round6/infra-open.webp new file mode 100644 index 0000000..f215f58 Binary files /dev/null and b/docs/overnight/round6/infra-open.webp differ diff --git a/docs/overnight/round6/open-1024x700.webp b/docs/overnight/round6/open-1024x700.webp new file mode 100644 index 0000000..cd0a6c8 Binary files /dev/null and b/docs/overnight/round6/open-1024x700.webp differ diff --git a/docs/overnight/round6/postcard.webp b/docs/overnight/round6/postcard.webp new file mode 100644 index 0000000..59713b1 Binary files /dev/null and b/docs/overnight/round6/postcard.webp differ diff --git a/scripts/check-bundle-budgets.mjs b/scripts/check-bundle-budgets.mjs index 8d9be79..cc13ddc 100644 --- a/scripts/check-bundle-budgets.mjs +++ b/scripts/check-bundle-budgets.mjs @@ -145,7 +145,7 @@ const totals = { firstLiveScene: sum('first-live-scene'), deferredProjectVisuals: sum('project-still') + sum('chart'), highTierExtras: sum('high-tier-extras'), - deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart'), + deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart') + sum('deferred-non-video'), deferredAudio: sum('soundtrack-audio'), }; diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index bbdbc08..17c4839 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -38,6 +38,8 @@ export const classifyArtifact = (path, source = '') => { if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js'; if (/^assets\/(?:environment-high|high-tier)-[^/]+\.(?:js|avif|webp|png|jpe?g)$/u.test(path)) return 'high-tier-extras'; if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene'; + // DASHCAM: the photo-mode frame and postcard, loaded on first open. + if (/^assets\/DashcamFrame-[^/]+\.(?:js|css)$/u.test(path)) return 'deferred-non-video'; if (/^assets\/nightdrive-(?:grand-tourer|districts)-[^/]+\.glb$/u.test(path)) return 'first-live-scene'; if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts'; if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster'; diff --git a/src/App.vue b/src/App.vue index 0c037dc..c1a8ed9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -19,6 +19,7 @@ import './styles/print.css'; import './styles/headlight.css'; import './styles/route-log.css'; import './styles/billboard.css'; +import './styles/dashcam.css'; defineProps<{ inputs: Readonly }>(); diff --git a/src/audio/music-pulse.ts b/src/audio/music-pulse.ts index 5c56625..b52a037 100644 --- a/src/audio/music-pulse.ts +++ b/src/audio/music-pulse.ts @@ -9,6 +9,7 @@ let audible = false; export function attachMusicPulse(node: AnalyserNode | null): void { analyser = node; + if (node === null) bus = null; bins = node === null ? null : new Uint8Array(new ArrayBuffer(node.frequencyBinCount)); } @@ -16,6 +17,13 @@ export function setMusicPulseAudible(value: boolean): void { audible = value; } +/** + * DASHCAM: the mix bus (the tap after the mixer's sum, before the output + * gain), so a shutter click obeys mute and volume. Null unless audible. + */ +let bus: AudioNode | null = null; +export const readSwitchboard = (): AudioNode | null => (audible ? bus : null); + /** Bass energy in [0, 1]: the mean of roughly 90-380 Hz. */ export function readMusicPulse(): number { if (!audible || analyser === null || bins === null) return 0; @@ -37,5 +45,6 @@ export function createPulseTap(context: BaseAudioContext, destination: AudioNode } catch { attachMusicPulse(null); } + bus = tap; return tap; } diff --git a/src/audio/switchboard-click.ts b/src/audio/switchboard-click.ts new file mode 100644 index 0000000..36ade59 --- /dev/null +++ b/src/audio/switchboard-click.ts @@ -0,0 +1,48 @@ +import { readSwitchboard } from '@/audio/music-pulse'; + +/** Shutter click length, seconds. */ +export const SHUTTER_SECONDS = 0.03; +/** The switchboard input's level on the mix bus. */ +export const SWITCHBOARD_GAIN = 0.18; + +/** + * DASHCAM's shutter: a ~30 ms burst of decaying noise with a fast gain + * envelope, through a switchboard input (gain 0.18) onto the mix bus, so + * mute and volume apply through the output gain. Lives in the lazy dashcam chunk. Plays only while + * the soundtrack is running and audible; never creates an AudioContext and + * never throws. + */ +export function playShutterClick(): boolean { + try { + const input = readSwitchboard(); + if (input === null || input.context.state !== 'running') return false; + const context = input.context; + const length = Math.max(1, Math.round(context.sampleRate * SHUTTER_SECONDS)); + const buffer = context.createBuffer(1, length, context.sampleRate); + const data = buffer.getChannelData(0); + // Two clicks a few ms apart (curtain open, curtain close) over decaying noise. + for (let i = 0; i < length; i += 1) { + const t = i / length; + const curtain = t > 0.55 && t < 0.62 ? 1.6 : 1; + data[i] = (Math.random() * 2 - 1) * (1 - t) ** 3 * curtain; + } + const source = context.createBufferSource(); + source.buffer = buffer; + const envelope = context.createGain(); + const start = context.currentTime + 0.005; + envelope.gain.setValueAtTime(0, start); + envelope.gain.linearRampToValueAtTime(SWITCHBOARD_GAIN, start + 0.002); + envelope.gain.exponentialRampToValueAtTime(0.0002, start + SHUTTER_SECONDS); + source.connect(envelope); + envelope.connect(input); + source.onended = () => { + source.disconnect(); + envelope.disconnect(); + }; + source.start(start); + source.stop(start + SHUTTER_SECONDS + 0.01); + return true; + } catch { + return false; + } +} diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index cc72621..0ee394e 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -24,6 +24,7 @@ import { useMotionPreferences } from '@/composables/useMotionPreferences'; import { useRouteMemory } from '@/composables/useRouteMemory'; import { useRouteLog } from '@/composables/useRouteLog'; import { useBillboard } from '@/composables/useBillboard'; +import { useDashcam } from '@/composables/useDashcam'; import { assetUrl } from '@/content/asset-url'; import { signRouteFor } from '@/journey/sign-routes'; import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn'; @@ -160,6 +161,16 @@ const headlight = useHeadlightBeam({ quality: actualQuality, activeSectionId: () => journey.value.activeSectionId, }); +/* DASHCAM: a full-screen photo mode over the live scene (lazy frame). */ +const dashcam = useDashcam({ + allowed: computed(() => controlsReady.value + && motion.policy.value.reason === 'full' + && motion.policy.value.allowSceneRequest + && actualQuality.value !== 'static' + && pageLifecycle.value === 'active'), + sceneRunning: computed(() => sceneState.value === 'running'), + drift: () => actualQuality.value !== 'low' && matchMedia('(pointer: fine)').matches, +}); const activeSectionLabel = computed(() => sectionLabel(journey.value.activeSectionId)); const serverDefaultPolicy: MotionPolicy = { allowSceneRequest: false, @@ -376,6 +387,7 @@ onUnmounted(() => removePageListeners()); :audio-state="audio.state.value" :audio-muted="audio.muted.value" :audio-volume="controlsReady ? motion.volume.value : 0.4" + :dashcam="dashcam.available.value" @pause="pauseMotion" @resume="resumeMotion" @enable="enableMotion" @@ -384,5 +396,17 @@ onUnmounted(() => removePageListeners()); @audio-resume="audio.resumeFromGesture" @audio-mute-toggle="toggleAudioMuted" @audio-volume-change="updateAudioVolume" + @dashcam="dashcam.show" + @dashcam-prefetch="dashcam.prefetch" + /> + diff --git a/src/components/DashcamFrame.vue b/src/components/DashcamFrame.vue new file mode 100644 index 0000000..798a062 --- /dev/null +++ b/src/components/DashcamFrame.vue @@ -0,0 +1,221 @@ + + + + diff --git a/src/components/DriveControls.vue b/src/components/DriveControls.vue index 74cf9aa..e8a8bbd 100644 --- a/src/components/DriveControls.vue +++ b/src/components/DriveControls.vue @@ -19,11 +19,14 @@ const props = withDefaults( audioState?: AudioUiState; audioMuted?: boolean; audioVolume?: number; + /** DASHCAM: offer the shutter (live scene, motion allowed). */ + dashcam?: boolean; }>(), { audioState: () => ({ kind: "idle" }), audioMuted: false, audioVolume: 0.4, + dashcam: false, }, ); @@ -36,6 +39,8 @@ const emit = defineEmits<{ audioResume: []; audioMuteToggle: []; audioVolumeChange: [volume: number]; + dashcam: []; + dashcamPrefetch: []; }>(); const controlRoot = ref(null); @@ -282,6 +287,21 @@ onBeforeUnmount(() => { + +