From 621f83c47bc6daa1096bd822f6ef976e230f4f6b Mon Sep 17 00:00:00 2001 From: "DESKTOP-I1T6TF3\\Q" <2291969160@qq.com> Date: Thu, 11 Jun 2026 09:29:16 +0800 Subject: [PATCH] feat(web): embed beat audio into gallery and infiplot exports Walk every speaking beat at export time, reuse current scene's beatAudioMap, and synth the rest via BYO TTS or /api/beat-audio with concurrency 4. Show a progress toast on the play page while collecting. Gallery export keeps audio in a sidecar localStorage key so the first paint is not blocked by JSON.parse-ing several MB of base64; the gallery lazy-loads it after the first scene image, then plays per-beat audio with a mute toggle persisted to localStorage. .infiplot share files embed audioByBeatId in the doc itself (v2); on import the data URIs survive scene swaps and feed back into the per-beat audio map so replayers hear the original voices for free. Co-Authored-By: Claude Opus 4.7 --- app/api/gallery-unpack/route.ts | 4 +- app/gallery/page.tsx | 111 ++++++++++++++- app/play/page.tsx | 244 +++++++++++++++++++++++++------- lib/analytics.ts | 2 +- lib/exportAudio.ts | 199 ++++++++++++++++++++++++++ lib/storyShare.ts | 27 +++- 6 files changed, 528 insertions(+), 59 deletions(-) create mode 100644 lib/exportAudio.ts diff --git a/app/api/gallery-unpack/route.ts b/app/api/gallery-unpack/route.ts index a2cfa78..8e1703c 100644 --- a/app/api/gallery-unpack/route.ts +++ b/app/api/gallery-unpack/route.ts @@ -4,8 +4,8 @@ export const runtime = "nodejs"; // Cap a bit above pack's MAX_DOC_BYTES — ciphertext adds the 16-byte GCM tag // and the 17-byte header; some slack accommodates near-cap docs without -// rejecting them at unpack time. -const MAX_FILE_BYTES = 6_000_000; +// rejecting them at unpack time. Bumped to fit pre-baked beat audio. +const MAX_FILE_BYTES = 13_000_000; // Decrypt a `.infiplot` share file back to its doc JSON string. Returns the // plaintext as a JSON field (not raw bytes) so the client can chain it through diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index d79361f..b37556c 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -57,8 +57,11 @@ export type GalleryScene = { }; export type GalleryDoc = { - /** v1 = scenes only (initial export). v2 = + alternates + characters. */ - v: 1 | 2; + /** v1 = scenes only (initial export). v2 = + alternates + characters. + * v3 = + beat audio (stored in a sidecar localStorage key so the main + * doc stays small and the first paint isn't blocked by JSON.parse-ing + * several MB of base64). */ + v: 1 | 2 | 3; id: string; createdAt: number; orientation: Orientation; @@ -71,13 +74,18 @@ export type GalleryDoc = { }; const STORAGE_PREFIX = "infiplot:gallery:"; +const AUDIO_SUFFIX = ":audio"; +const MUTED_STORAGE_KEY = "infiplot:gallery:muted"; function readDoc(id: string): GalleryDoc | null { try { const raw = window.localStorage.getItem(STORAGE_PREFIX + id); if (!raw) return null; const parsed = JSON.parse(raw) as GalleryDoc; - if ((parsed.v !== 1 && parsed.v !== 2) || !Array.isArray(parsed.scenes)) { + if ( + (parsed.v !== 1 && parsed.v !== 2 && parsed.v !== 3) || + !Array.isArray(parsed.scenes) + ) { return null; } return parsed; @@ -86,6 +94,23 @@ function readDoc(id: string): GalleryDoc | null { } } +function readSidecarAudio(id: string): Record { + try { + const raw = window.localStorage.getItem( + STORAGE_PREFIX + id + AUDIO_SUFFIX, + ); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed)) { + if (typeof v === "string" && v.startsWith("data:")) out[k] = v; + } + return out; + } catch { + return {}; + } +} + function detectOrientation(): Orientation { if (typeof window === "undefined") return "landscape"; const portrait = window.matchMedia("(orientation: portrait)").matches; @@ -352,6 +377,8 @@ function Slide({ beatId, orientation, alternates, + audioByBeatId, + muted, dialogueOpen, setDialogueOpen, onAdvanceBeat, @@ -361,6 +388,8 @@ function Slide({ beatId: string; orientation: Orientation; alternates: Record; + audioByBeatId: Record; + muted: boolean; dialogueOpen: boolean; setDialogueOpen: (b: boolean) => void; onAdvanceBeat: (nextBeatId: string) => void; @@ -372,6 +401,24 @@ function Slide({ const beat = findBeat(scene, beatId) ?? findBeat(scene, scene.entryBeatId); + const audioSrc = + beat && scene.id && !muted + ? (audioByBeatId[`${scene.id}:${beat.id}`] ?? null) + : null; + const audioRef = useRef(null); + useEffect(() => { + const el = audioRef.current; + if (!el) return; + if (!audioSrc) { + el.pause(); + return; + } + el.currentTime = 0; + void el.play().catch(() => { + // Browsers can refuse autoplay until user interacts — silent fail is fine. + }); + }, [audioSrc]); + const choices: BeatChoice[] = beat?.next.type === "choice" ? (beat.next as { type: "choice"; choices: BeatChoice[] }).choices @@ -533,6 +580,16 @@ function Slide({ onClose={() => setDialogueOpen(false)} /> )} + + {audioSrc && ( +