diff --git a/.env.example b/.env.example index 6d04fa6..35ae802 100644 --- a/.env.example +++ b/.env.example @@ -124,3 +124,14 @@ NEXT_PUBLIC_UMAMI_WEBSITE_ID= # domain. Comma-separated, exact match: apex ≠ www (list both), no wildcards. # Blank → track on all hosts. e.g. infiplot.com,www.infiplot.com NEXT_PUBLIC_UMAMI_DOMAINS= + +# ---- 7. Gallery share files (optional — leave blank to disable) ---- +# Server-side secret used to AES-256-GCM encrypt a played session into a +# binary `.infiplot` share file the player can send to a friend. Friends drop +# the file into /gallery; the server decrypts and renders the same interactive +# replay. GCM's built-in auth tag also gives tamper-detection for free. +# Blank → "导出分享文件" is hidden, only the same-browser localStorage flow +# remains. Set to any high-entropy string ≥ 32 chars (e.g. `openssl rand -hex 32`). +# WARNING: rotating this secret invalidates every share file ever issued +# (decryption will fail with "文件校验失败"). Only change when you're OK with that. +GALLERY_SECRET= diff --git a/README.md b/README.md index a432089..0b086b6 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ docker compose up -d ## 团队与愿景 -我们是一群来自清华大学等高校的年轻人。 +我们是一群来自清华大学、兰州大学、西安交通大学等高校的年轻人。 一方面,我们本来就是galgame、乙女游戏、FMV、AI角色扮演游戏这类游戏的深度用户,在享受游戏体验的同时,也会想象如果能选择不被预设的剧情选项,或者和对话的AI角色深度互动而不只是通过聊天软件聊天,该是多么愉快刺激的体验。 diff --git a/app/api/gallery-pack/route.ts b/app/api/gallery-pack/route.ts new file mode 100644 index 0000000..9fd5667 --- /dev/null +++ b/app/api/gallery-pack/route.ts @@ -0,0 +1,50 @@ +import { packDoc } from "@/lib/galleryCrypto"; + +export const runtime = "nodejs"; + +const MAX_DOC_BYTES = 5_000_000; + +// Encrypt a gallery doc into the shareable `.infiplot` binary format. +// Stateless: input is the doc string, output is the encrypted bytes — server +// keeps nothing. The secret must be configured (no insecure fallback). +export async function POST(req: Request): Promise { + const secret = process.env.GALLERY_SECRET; + if (!secret) { + return Response.json( + { error: "图集分享未启用 (GALLERY_SECRET 未配置)" }, + { status: 503 }, + ); + } + + let docStr: string; + try { + const body = (await req.json()) as { docStr?: unknown }; + if (typeof body.docStr !== "string") { + return Response.json({ error: "Missing docStr" }, { status: 400 }); + } + docStr = body.docStr; + } catch { + return Response.json({ error: "Bad JSON" }, { status: 400 }); + } + + if (docStr.length > MAX_DOC_BYTES) { + return Response.json( + { error: "图集数据太大,无法打包分享" }, + { status: 413 }, + ); + } + + const bytes = await packDoc(docStr, secret); + // Copy into a fresh ArrayBuffer so TS 5.7's stricter BodyInit typing accepts + // it (Uint8Array.buffer is ArrayBufferLike, which the BodyInit overloads + // don't narrow). Cheap — one extra alloc + memcpy of ~50-200KB. + const ab = new ArrayBuffer(bytes.byteLength); + new Uint8Array(ab).set(bytes); + return new Response(ab, { + status: 200, + headers: { + "Content-Type": "application/octet-stream", + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/gallery-unpack/route.ts b/app/api/gallery-unpack/route.ts new file mode 100644 index 0000000..a2cfa78 --- /dev/null +++ b/app/api/gallery-unpack/route.ts @@ -0,0 +1,46 @@ +import { unpackDoc } from "@/lib/galleryCrypto"; + +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; + +// 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 +// JSON.parse without sniffing the response type. Errors are deliberately +// generic — we don't distinguish "wrong key" from "tampered file" because the +// distinction would leak server config. +export async function POST(req: Request): Promise { + const secret = process.env.GALLERY_SECRET; + if (!secret) { + return Response.json( + { error: "图集分享未启用 (GALLERY_SECRET 未配置)" }, + { status: 503 }, + ); + } + + let ab: ArrayBuffer; + try { + ab = await req.arrayBuffer(); + } catch { + return Response.json({ error: "Bad request body" }, { status: 400 }); + } + if (ab.byteLength > MAX_FILE_BYTES) { + return Response.json({ error: "文件太大" }, { status: 413 }); + } + if (ab.byteLength === 0) { + return Response.json({ error: "文件为空" }, { status: 400 }); + } + + try { + const docStr = await unpackDoc(new Uint8Array(ab), secret); + return Response.json({ docStr }); + } catch (e) { + return Response.json( + { error: e instanceof Error ? e.message : "解包失败" }, + { status: 400 }, + ); + } +} diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx new file mode 100644 index 0000000..164f12e --- /dev/null +++ b/app/gallery/page.tsx @@ -0,0 +1,1252 @@ +"use client"; + +import Link from "next/link"; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { + Beat, + BeatChoice, + Orientation, + SceneExit, +} from "@infiplot/types"; + +// ────────────────────────────────────────────────────────────────────── +// Gallery — an offline-only replay of a played session. Entered from +// /play via the 导出图集 button, which strips the live Session to the +// GalleryDoc fields below (no voice base64 / no style reference), writes +// it to localStorage under a one-shot id, then opens /gallery#id= +// in a new tab. +// +// No engine calls happen here. Every scene image is a Runware CDN link +// the browser already loaded once during play. Choices are clickable: +// - advance-beat choices are pure local jumps (the beats live in the +// scene already) +// - change-scene choices are looked up in `alternates` — main-path picks +// resolve to the next visited scene, and any AI-prefetched-but-not-taken +// alternates also live there so the player can explore branches the +// engine already paid to generate +// Choices with no recorded alternate are greyed (no way to navigate +// forward without re-calling the engine, which we deliberately don't do). +// ────────────────────────────────────────────────────────────────────── + +export type GalleryScene = { + /** Scene id from the original engine. Used to key into `alternates` and + * to detect when an alternate happens to be a main-path scene. */ + id?: string; + imageUrl: string; + sceneKey?: string; + orientation?: Orientation; + beats: Beat[]; + entryBeatId: string; + /** Beat ids the player walked, in order. Set for main-path scenes; + * absent for prefetched alternates the player never entered. */ + visitedBeatIds?: string[]; + /** How the player left the scene. Same scoping as visitedBeatIds. */ + exit?: SceneExit; +}; + +export type GalleryDoc = { + /** v1 = scenes only (initial export). v2 = + alternates + characters. */ + v: 1 | 2; + id: string; + createdAt: number; + orientation: Orientation; + scenes: GalleryScene[]; + /** Key: `${parentSceneId}:${choiceId}` → reachable scene. Includes both + * main-path picks and AI-prefetched alternates the player abandoned. */ + alternates?: Record; + /** Cast for the "下载角色图" button. Name + CDN URL only. */ + characters?: { name: string; basePortraitUrl?: string }[]; +}; + +const STORAGE_PREFIX = "infiplot:gallery:"; + +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)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +function detectOrientation(): Orientation { + if (typeof window === "undefined") return "landscape"; + const portrait = window.matchMedia("(orientation: portrait)").matches; + const coarse = window.matchMedia("(pointer: coarse)").matches; + return portrait && coarse ? "portrait" : "landscape"; +} + +function findBeat(scene: GalleryScene, beatId: string): Beat | undefined { + return scene.beats.find((b) => b.id === beatId); +} + +// ── Identify which choice the player picked at this beat on the main path. +// For advance-beat picks we match by the next visited beat id; for change- +// scene picks we use the scene's recorded `exit`. Returns null when the +// scene is not on the main path (no visitedBeatIds), or when the beat is +// not on the visited trail. +function pickedChoiceIdAt( + scene: GalleryScene, + beatId: string, +): string | null { + if (!scene.visitedBeatIds) return null; + const visited = scene.visitedBeatIds; + const idx = visited.indexOf(beatId); + if (idx < 0) return null; + const beat = findBeat(scene, beatId); + if (!beat || beat.next.type !== "choice") return null; + const nextVisited = visited[idx + 1]; + if (nextVisited) { + const c = beat.next.choices.find( + (c) => c.effect.kind === "advance-beat" && c.effect.targetBeatId === nextVisited, + ); + if (c) return c.id; + } + if ( + scene.exit?.kind === "choice" && + idx === visited.length - 1 + ) { + return scene.exit.choiceId; + } + return null; +} + +// ── Download a batch of image URLs as separate browser downloads. +// Runware CDN sends Access-Control-Allow-Origin (the annotate flow already +// relies on this) so fetch().blob() works cross-origin without a proxy. +// +// Each fetch has its own AbortController + per-file timeout — without that +// a single slow/hung CDN response strands the whole loop, the caller's busy +// flag never clears, and the button looks "stuck" (the original "下载完按钮就没了" +// report). Fetches run in a small concurrency pool to keep total time +// reasonable for ~10-30 portraits; the actual clicks remain +// serial with a small gap so Chrome's "allow multiple downloads" prompt +// fires once instead of being coalesced or dropped. +async function downloadImages( + files: { url: string; name: string }[], +): Promise { + const CONCURRENT_FETCH = 4; + const FETCH_TIMEOUT_MS = 20_000; + + async function fetchOne( + file: { url: string; name: string }, + ): Promise<{ blobUrl: string; name: string } | null> { + const { url, name } = file; + if (!url) return null; + if (url.startsWith("data:")) return { blobUrl: url, name }; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + try { + const r = await fetch(url, { mode: "cors", signal: ctrl.signal }); + if (!r.ok) return null; + const blob = await r.blob(); + return { blobUrl: URL.createObjectURL(blob), name }; + } catch { + return null; + } finally { + clearTimeout(timer); + } + } + + const queue = [...files]; + const ready: ({ blobUrl: string; name: string } | null)[] = []; + await Promise.all( + Array.from({ length: CONCURRENT_FETCH }, async () => { + while (queue.length > 0) { + const f = queue.shift(); + if (!f) break; + ready.push(await fetchOne(f)); + } + }), + ); + + for (const item of ready) { + if (!item) continue; + const { blobUrl, name } = item; + const a = document.createElement("a"); + a.href = blobUrl; + a.download = name; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + if (blobUrl.startsWith("blob:")) { + setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); + } + await new Promise((r) => setTimeout(r, 250)); + } +} + +// ────────────────────────────────────────────────────────────────────── +// Dialogue panel — full beat trail of the current scene +// ────────────────────────────────────────────────────────────────────── + +function DialoguePanel({ + scene, + portrait, + onClose, +}: { + scene: GalleryScene; + portrait: boolean; + onClose: () => void; +}) { + // Use visitedBeatIds when present (main path); else walk the entry chain + // through `continue` beats (alternates have no visit trail so we just show + // their establishing beat — choice beats can't be auto-resolved). + const beatIds = useMemo(() => { + if (scene.visitedBeatIds && scene.visitedBeatIds.length > 0) { + return scene.visitedBeatIds; + } + const chain: string[] = []; + let cur = scene.entryBeatId; + const guard = new Set(); + while (cur && !guard.has(cur)) { + chain.push(cur); + guard.add(cur); + const b = findBeat(scene, cur); + if (!b || b.next.type !== "continue") break; + cur = b.next.nextBeatId; + } + return chain; + }, [scene]); + + return ( +
+
e.stopPropagation()} + style={{ + background: "rgba(14, 10, 6, 0.92)", + border: "1.5px solid rgba(175, 138, 72, 0.72)", + borderRadius: "6px", + backdropFilter: "blur(14px)", + WebkitBackdropFilter: "blur(14px)", + boxShadow: "0 10px 42px rgba(0,0,0,0.62)", + }} + role="dialog" + aria-modal="true" + aria-label="本幕对话" + > +
+
+ + 本 · 幕 · 对 · 话 +
+ +
+
+
+ {beatIds.map((bid, i) => { + const beat = findBeat(scene, bid); + if (!beat) return null; + const body = beat.speaker ? beat.line : beat.narration; + const narration = beat.speaker ? beat.narration : undefined; + return ( +
+
+ + 第 {String(i + 1).padStart(2, "0")} 拍 + + {beat.speaker && ( + + {beat.speaker} + + )} +
+ {body && ( +

+ {body} +

+ )} + {narration && ( +

+ {narration} +

+ )} +
+ ); + })} + {scene.exit?.kind === "choice" && ( +

+ 选择 + {scene.exit.label} +

+ )} + {scene.exit?.kind === "freeform" && ( +

+ 行动 + {scene.exit.action} +

+ )} +
+
+
+
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// Choice — rendered above the dialogue card +// ────────────────────────────────────────────────────────────────────── + +type ChoiceState = "picked" | "navigable" | "dead"; + +function ChoiceButton({ + choice, + state, + vertical, + onClick, +}: { + choice: BeatChoice; + state: ChoiceState; + vertical: boolean; + onClick: () => void; +}) { + const picked = state === "picked"; + const dead = state === "dead"; + return ( + + ); +} + +// ────────────────────────────────────────────────────────────────────── +// Slide — one scene + its current beat. All interaction lives here. +// ────────────────────────────────────────────────────────────────────── + +function Slide({ + scene, + beatId, + orientation, + alternates, + dialogueOpen, + setDialogueOpen, + onAdvanceBeat, + onChoice, +}: { + scene: GalleryScene; + beatId: string; + orientation: Orientation; + alternates: Record; + dialogueOpen: boolean; + setDialogueOpen: (b: boolean) => void; + onAdvanceBeat: (nextBeatId: string) => void; + onChoice: (choice: BeatChoice) => void; +}) { + const portrait = orientation === "portrait"; + const intrinsicW = portrait ? 1024 : 1792; + const intrinsicH = portrait ? 1792 : 1024; + + const beat = findBeat(scene, beatId) ?? findBeat(scene, scene.entryBeatId); + + const choices: BeatChoice[] = + beat?.next.type === "choice" + ? (beat.next as { type: "choice"; choices: BeatChoice[] }).choices + : []; + const pickedId = beat ? pickedChoiceIdAt(scene, beat.id) : null; + + const sizeStyle: React.CSSProperties = portrait + ? { width: "100vw", height: "100dvh", objectFit: "cover" } + : { maxWidth: "100vw", maxHeight: "100dvh" }; + + function choiceState(c: BeatChoice): ChoiceState { + if (c.id === pickedId) return "picked"; + if (c.effect.kind === "advance-beat") { + // Beats are local; always navigable. + return "navigable"; + } + // change-scene: needs an alternate. + if (scene.id && alternates[`${scene.id}:${c.id}`]) return "navigable"; + return "dead"; + } + + function handleChoiceClick(c: BeatChoice) { + const st = choiceState(c); + if (st === "dead") return; + onChoice(c); + } + + function handleImageClick() { + if (!beat) return; + if (beat.next.type === "continue") { + onAdvanceBeat(beat.next.nextBeatId); + } + // Choice beats: do nothing — let the player click a choice. + } + + return ( +
+ Scene + + {beat && ( +
+ {choices.length > 0 && ( +
+ {choices.map((choice) => ( + handleChoiceClick(choice)} + /> + ))} +
+ )} + + {(beat.narration || beat.line) && ( +
{ + e.stopPropagation(); + handleImageClick(); + }} + style={{ + background: "rgba(14, 10, 6, 0.72)", + border: "1.5px solid rgba(175, 138, 72, 0.60)", + borderRadius: "6px", + backdropFilter: "blur(10px)", + WebkitBackdropFilter: "blur(10px)", + boxShadow: + "0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(200,165,90,0.10)", + }} + > + {beat.speaker && ( +

+ {beat.speaker} +

+ )} +

+ {beat.speaker ? beat.line : beat.narration} + {beat.speaker && beat.narration && ( + + {beat.narration} + + )} +

+ + {beat.next.type === "continue" && ( + + ▼ + + )} + + +
+ )} +
+ )} + + {dialogueOpen && ( + setDialogueOpen(false)} + /> + )} +
+ ); +} + +// ────────────────────────────────────────────────────────────────────── +// Page — owns the navigation stack +// ────────────────────────────────────────────────────────────────────── + +type Frame = { + scene: GalleryScene; + beatId: string; + // Index in the main path array when this frame IS the main-path scene at + // that index. null when the frame represents an alternate the player has + // stepped into. + mainIdx: number | null; +}; + +function GalleryInner() { + const [doc, setDoc] = useState(null); + const [missingId, setMissingId] = useState(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [stack, setStack] = useState([]); + const [dialogueOpen, setDialogueOpen] = useState(false); + const [downloadingScenes, setDownloadingScenes] = useState(false); + const [downloadingPortraits, setDownloadingPortraits] = useState(false); + const [orientation, setOrientation] = useState("landscape"); + const [presentation, setPresentation] = useState(false); + // Top toolbar auto-hide while in fullscreen — it shows briefly on entry, + // retracts upward, and pops back down when the cursor approaches the top + // edge. Outside presentation mode the bar is always visible. + const [toolbarVisible, setToolbarVisible] = useState(true); + const hideTimerRef = useRef | null>(null); + const preloadedRef = useRef>(new Set()); + + // Mirror /play's fullscreen behavior — request browser fullscreen so the + // tab chrome disappears, with the F key as a shortcut and Esc to exit. + // The gallery viewport is already `fixed inset-0`, so this only removes + // the browser's own UI, not anything we render. + const togglePresentation = useCallback(async () => { + const entering = !presentation; + if (entering) { + try { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen(); + } + } catch { + // ignore — fall back to chrome-less mode anyway + } + setPresentation(true); + } else { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + } catch { + // ignore + } + setPresentation(false); + } + }, [presentation]); + + useEffect(() => { + const hash = window.location.hash.replace(/^#/, ""); + const id = new URLSearchParams(hash).get("id") || hash; + if (!id) { + setMissingId(""); + return; + } + const d = readDoc(id); + if (!d || d.scenes.length === 0) { + setMissingId(id); + return; + } + setDoc(d); + setOrientation(d.orientation ?? detectOrientation()); + const first = d.scenes[0]!; + setStack([{ scene: first, beatId: first.entryBeatId, mainIdx: 0 }]); + }, []); + + // Prefer the doc's stored orientation; fall back to the device. + const top = stack[stack.length - 1] ?? null; + const alternates = doc?.alternates ?? {}; + + // Pre-warm the next + previous main scene images so prev/next never flashes. + useEffect(() => { + if (!doc || !top) return; + const set = preloadedRef.current; + const candidates: string[] = [top.scene.imageUrl]; + if (top.mainIdx !== null) { + const prev = doc.scenes[top.mainIdx - 1]; + const next = doc.scenes[top.mainIdx + 1]; + if (prev?.imageUrl) candidates.push(prev.imageUrl); + if (next?.imageUrl) candidates.push(next.imageUrl); + } + for (const url of candidates) { + if (!url || set.has(url)) continue; + set.add(url); + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = url; + } + }, [doc, top]); + + // Mainline position for the header counter. Walk the stack from the top + // down to the most recent main-path frame; if the player has stepped into + // an alternate the counter still shows the last main-path index they were + // on, plus a "支线" tag. + const mainContextIdx = useMemo(() => { + for (let i = stack.length - 1; i >= 0; i--) { + const f = stack[i]!; + if (f.mainIdx !== null) return f.mainIdx; + } + return null; + }, [stack]); + const offMain = top?.mainIdx === null; + + // ── Navigation actions ────────────────────────────────────────────── + + const onAdvanceBeat = useCallback((nextBeatId: string) => { + setStack((s) => { + if (s.length === 0) return s; + const t = s[s.length - 1]!; + if (!findBeat(t.scene, nextBeatId)) return s; + return [...s.slice(0, -1), { ...t, beatId: nextBeatId }]; + }); + setDialogueOpen(false); + }, []); + + const onChoice = useCallback( + (choice: BeatChoice) => { + setDialogueOpen(false); + if (choice.effect.kind === "advance-beat") { + onAdvanceBeat(choice.effect.targetBeatId); + return; + } + // change-scene: resolve via alternates map. + const t = stack[stack.length - 1]; + if (!t || !t.scene.id) return; + const alt = alternates[`${t.scene.id}:${choice.id}`]; + if (!alt) return; + // If this alternate IS the next main-path scene (the typical case for + // the choice the player actually picked), advance mainIdx; otherwise + // mark the new frame as off-main. + const expectedMainIdx = + t.mainIdx !== null ? t.mainIdx + 1 : null; + const isMain = + expectedMainIdx !== null && + doc?.scenes[expectedMainIdx]?.id === alt.id; + setStack((s) => [ + ...s, + { + scene: alt, + beatId: alt.entryBeatId, + mainIdx: isMain ? expectedMainIdx : null, + }, + ]); + }, + [alternates, doc, onAdvanceBeat, stack], + ); + + // Prev / next at the scene level (slideshow-style edges + arrow keys). + // Implementation: prev pops a stack frame (so alternates back out one step, + // then we step back through main path); next walks forward by following the + // recorded path — picked choice on main, entry beat advance otherwise. + const goPrev = useCallback(() => { + setStack((s) => { + if (s.length === 0) return s; + if (s.length > 1) return s.slice(0, -1); + // Single frame: step back along main path. + const t = s[0]!; + if (t.mainIdx === null || t.mainIdx === 0) return s; + const prevIdx = t.mainIdx - 1; + const prevScene = doc?.scenes[prevIdx]; + if (!prevScene) return s; + return [ + { scene: prevScene, beatId: prevScene.entryBeatId, mainIdx: prevIdx }, + ]; + }); + setDialogueOpen(false); + }, [doc]); + + const goNext = useCallback(() => { + setStack((s) => { + if (s.length === 0) return s; + const t = s[s.length - 1]!; + // If on main and there's a next main scene, jump there directly. + if (t.mainIdx !== null && doc) { + const nextIdx = t.mainIdx + 1; + const nextScene = doc.scenes[nextIdx]; + if (nextScene) { + return [ + { + scene: nextScene, + beatId: nextScene.entryBeatId, + mainIdx: nextIdx, + }, + ]; + } + } + // Off-main: try advancing the current beat (only meaningful for + // continue beats; choice beats are no-ops at the scene-level). + const beat = findBeat(t.scene, t.beatId); + if (beat && beat.next.type === "continue") { + return [...s.slice(0, -1), { ...t, beatId: beat.next.nextBeatId }]; + } + return s; + }); + setDialogueOpen(false); + }, [doc]); + + // "返回主线" — collapse the stack to its bottom-most main-path frame. + const goBackToMain = useCallback(() => { + setStack((s) => { + for (let i = s.length - 1; i >= 0; i--) { + if (s[i]!.mainIdx !== null) return s.slice(0, i + 1); + } + return s; + }); + setDialogueOpen(false); + }, []); + + // On entering presentation: show the bar, then retract after a moment so + // the player gets a glance at the controls without them blocking the image. + // On leaving: always-visible again. Clears any pending hide timer between + // transitions so we never retract back in windowed mode. + useEffect(() => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + if (!presentation) { + setToolbarVisible(true); + return; + } + setToolbarVisible(true); + hideTimerRef.current = setTimeout(() => { + setToolbarVisible(false); + hideTimerRef.current = null; + }, 2200); + return () => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [presentation]); + + // Mouse-driven reveal while in presentation: cursor near the top edge + // re-shows the bar; moving away starts a short hide countdown. + useEffect(() => { + if (!presentation) return; + const SHOW_ZONE = 96; + const HIDE_DELAY = 1400; + function onMove(e: MouseEvent) { + if (e.clientY < SHOW_ZONE) { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + setToolbarVisible(true); + } else if (!hideTimerRef.current) { + hideTimerRef.current = setTimeout(() => { + setToolbarVisible(false); + hideTimerRef.current = null; + }, HIDE_DELAY); + } + } + window.addEventListener("mousemove", onMove); + return () => window.removeEventListener("mousemove", onMove); + }, [presentation]); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "ArrowLeft") goPrev(); + else if (e.key === "ArrowRight") goNext(); + else if (e.key === "f" || e.key === "F") { + if (e.metaKey || e.ctrlKey || e.altKey) return; + e.preventDefault(); + void togglePresentation(); + } else if (e.key === "Escape") { + if (dialogueOpen) setDialogueOpen(false); + else if (presentation) setPresentation(false); + } + } + function onFullscreenChange() { + if (!document.fullscreenElement && presentation) setPresentation(false); + } + window.addEventListener("keydown", onKey); + document.addEventListener("fullscreenchange", onFullscreenChange); + return () => { + window.removeEventListener("keydown", onKey); + document.removeEventListener("fullscreenchange", onFullscreenChange); + }; + }, [goPrev, goNext, dialogueOpen, presentation, togglePresentation]); + + const handleDownloadScenes = useCallback(async () => { + if (!doc || downloadingScenes) return; + setDownloadingScenes(true); + try { + function extOf(url: string): string { + if (url.startsWith("data:image/svg")) return "svg"; + if (url.startsWith("data:image/")) { + return url.slice(11, url.indexOf(";")) || "png"; + } + return "jpg"; + } + // Main path + every unique alternate (AI-prefetched branches the player + // didn't take). Dedupe by URL — the picked choice's alternate IS the + // next main scene, so they overlap, and we never want the same image + // saved twice. Main scenes get `scene-NNN`; uniquely-alternate scenes + // get `branch-NNN` so the filenames hint at provenance. + const seen = new Set(); + const files: { url: string; name: string }[] = []; + let sceneN = 0; + for (const sc of doc.scenes) { + if (!sc.imageUrl || seen.has(sc.imageUrl)) continue; + seen.add(sc.imageUrl); + sceneN++; + files.push({ + url: sc.imageUrl, + name: `infiplot-scene-${String(sceneN).padStart(3, "0")}.${extOf(sc.imageUrl)}`, + }); + } + let branchN = 0; + for (const alt of Object.values(doc.alternates ?? {})) { + if (!alt.imageUrl || seen.has(alt.imageUrl)) continue; + seen.add(alt.imageUrl); + branchN++; + files.push({ + url: alt.imageUrl, + name: `infiplot-branch-${String(branchN).padStart(3, "0")}.${extOf(alt.imageUrl)}`, + }); + } + await downloadImages(files); + } finally { + setDownloadingScenes(false); + } + }, [doc, downloadingScenes]); + + // ── Import a friend-shared `.infiplot` file ────────────────────────── + // The file is AES-GCM ciphertext only this deployment can decrypt; we POST + // the raw bytes to /api/gallery-unpack and let the server hand us back the + // doc as a JSON string. GCM's auth tag means a tampered or wrong-key file + // surfaces as a 400 with a human-readable error here — no need to verify + // anything client-side. + const loadDocFromFile = useCallback(async (file: File) => { + setImporting(true); + setImportError(null); + try { + const ab = await file.arrayBuffer(); + const r = await fetch("/api/gallery-unpack", { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: ab, + }); + if (!r.ok) { + const j = (await r.json().catch(() => ({}))) as { error?: string }; + setImportError(j.error ?? `导入失败 (HTTP ${r.status})`); + return; + } + const { docStr } = (await r.json()) as { docStr?: string }; + if (!docStr) { + setImportError("服务端返回为空"); + return; + } + let parsed: GalleryDoc; + try { + parsed = JSON.parse(docStr) as GalleryDoc; + } catch { + setImportError("解密后的内容不是有效的图集数据"); + return; + } + if ( + (parsed.v !== 1 && parsed.v !== 2) || + !Array.isArray(parsed.scenes) || + parsed.scenes.length === 0 + ) { + setImportError("图集数据格式不被支持"); + return; + } + setDoc(parsed); + setOrientation(parsed.orientation ?? detectOrientation()); + const first = parsed.scenes[0]!; + setStack([{ scene: first, beatId: first.entryBeatId, mainIdx: 0 }]); + setMissingId(null); + } catch (e) { + setImportError(e instanceof Error ? e.message : "导入失败"); + } finally { + setImporting(false); + } + }, []); + + const handleDownloadPortraits = useCallback(async () => { + if (!doc || downloadingPortraits) return; + const list = doc.characters ?? []; + const files = list + .filter((c) => !!c.basePortraitUrl) + .map((c, i) => { + const safeName = c.name.replace(/[^a-zA-Z0-9一-龥_-]/g, "_"); + return { + url: c.basePortraitUrl as string, + name: `infiplot-character-${String(i + 1).padStart(2, "0")}-${safeName || "char"}.jpg`, + }; + }); + if (files.length === 0) return; + setDownloadingPortraits(true); + try { + await downloadImages(files); + } finally { + setDownloadingPortraits(false); + } + }, [doc, downloadingPortraits]); + + // ── Render ────────────────────────────────────────────────────────── + + if (missingId !== null) { + return ( +
+

+ 图 · 集 · 找 · 不 · 到 +

+

+ {missingId + ? "这份图集存在本机浏览器里,可能已被清理,或不在当前设备上。" + : "想看朋友分享的图集?选他发给你的 .infiplot 文件;想自己导出?去游戏页点「导出图集」。"} +

+ + + + {importError && ( +

+ {importError} +

+ )} + + + + 返回 + +
+ ); + } + + if (!doc || !top) { + return ( +
+ + 载 · 入 · 中 + +
+ ); + } + + const total = doc.scenes.length; + const counterIdx = mainContextIdx !== null ? mainContextIdx : 0; + const portraitCount = (doc.characters ?? []).filter( + (c) => !!c.basePortraitUrl, + ).length; + + // Prev disabled at the very start of the main path with a length-1 stack. + const atVeryStart = + stack.length === 1 && stack[0]!.mainIdx === 0; + // Next disabled at the last main scene's terminal beat (or any time there's + // no main-next AND no beat to advance to). + const beatAtTop = findBeat(top.scene, top.beatId); + const hasMainNext = + top.mainIdx !== null && top.mainIdx < total - 1; + const hasBeatNext = beatAtTop?.next.type === "continue"; + const atVeryEnd = !hasMainNext && !hasBeatNext; + + return ( +
+ + + {/* Top bar — auto-hides in fullscreen presentation mode (see toolbarVisible) */} +
+ + + 返回 + + +
+ + 第 · {String(counterIdx + 1).padStart(3, "0")} · 幕 + / + {String(total).padStart(3, "0")} + + {offMain && ( + + )} +
+ +
+ + {portraitCount > 0 && ( + + )} + +
+
+ + {/* Download-in-progress hint — Chrome/Edge/Firefox throw a "允许此网站 + 下载多个文件" prompt after the first
.click(); without + this banner most users miss it and only the first file lands. */} + {(downloadingScenes || downloadingPortraits) && ( +
+ + + 浏览器顶部如弹出「允许此网站下载多个文件」,请点「允许」,否则只能下到第一张 + +
+ )} + + {/* Left / Right slide nav */} + + + + {/* Bottom hint */} +
+ + ← · → · 切 · 幕 · · F · 全 · 屏 · · ▼ · 推 · 进 · · 选 · 项 · 探 · 支 · 线 + +
+
+ ); +} + +export default function GalleryPage() { + return ( + + + 载 · 入 · 中 + + + } + > + + + ); +} diff --git a/app/page.tsx b/app/page.tsx index 896e44a..34029e6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1605,7 +1605,7 @@ export default function HomePage() {

团 队

- 我们来自清华大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 one-shot 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。 + 我们来自清华大学、兰州大学、西安交通大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 one-shot 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。

diff --git a/app/play/page.tsx b/app/play/page.tsx index e21ea3d..e42761f 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -16,6 +16,7 @@ import { type Phase, } from "@/components/PlayCanvas"; import type { DialogueHistoryItem } from "@/components/DialogueHistoryModal"; +import type { GalleryDoc, GalleryScene } from "@/app/gallery/page"; import { TtsKeyModal } from "@/components/TtsKeyModal"; import { annotateClick } from "@/lib/annotateClient"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; @@ -367,6 +368,14 @@ function findSoleChangeSceneChoice(scene: Scene): BeatChoice | null { function prefetchScenePath( pool: Map, + // Resolved-prefetch sink for the gallery export. Every successful resolve + // is recorded here keyed by `${parentSceneId}:${choiceId}` so the gallery + // can let the player click any choice whose alternate the AI already paid + // to generate — even ones that were later abandoned mid-play because the + // player took a different branch. Survives `consumeChoice`'s abort sweep: + // a prefetch that's already resolved when its parent choice is abandoned + // still leaves the result here. + resolvedSink: Map, baseSession: Session, steps: ScenePathStep[], depth: number, @@ -393,6 +402,16 @@ function prefetchScenePath( } const data = (await res.json()) as SceneResponse; + // Record this resolved alternate for the gallery export. Key is + // (parent scene id at the choice point) : (choice id). Includes the + // CDN imageUrl on the Scene so the gallery has everything it needs to + // render without any further info from the engine. + const lastStep = steps[steps.length - 1]!; + resolvedSink.set(`${lastStep.fromScene.id}:${lastStep.exit.choiceId}`, { + ...data.scene, + imageUrl: data.imageUrl, + }); + // Kick off the blob fetch for this URL so when the player eventually // picks this choice, transitioning is a no-op cache lookup instead of a // fresh CDN download. Don't await — let it run in the background; the @@ -431,6 +450,7 @@ function prefetchScenePath( }; prefetchScenePath( pool, + resolvedSink, carriedBase, [...steps, nextStep], depth + 1, @@ -564,6 +584,12 @@ function PlayInner() { const startedRef = useRef(false); const poolRef = useRef>(new Map()); + // Accumulator for resolved prefetches across the whole session — every + // `prefetchScenePath` resolution writes here, keyed by parent-scene + choice. + // Survives `consumeChoice`'s pool sweep (an already-resolved promise is not + // un-resolved by aborting its controller), so abandoned alternates remain + // available for the gallery export. Cleared only on unmount. + const resolvedPrefetchesRef = useRef>(new Map()); // Lazy per-beat audio fetches keyed by beat.id. Aborted when the scene // changes so stale in-flight requests can't poison the new scene's map // (beat ids like "b1" are scene-local and would collide across scenes). @@ -850,6 +876,164 @@ function PlayInner() { [prefetchSceneAudio], ); + // ── Export to interactive gallery (PPT-style replay) ───────────────── + // Drop all but the (keepCount) most-recent gallery exports from localStorage, + // ordered by their stored createdAt. Called right before writing a new + // export so the cap is enforced strictly (≤ keepCount + 1 transiently → ≤ N + // once write completes). Corrupt entries (un-parseable / no createdAt) sort + // last and get evicted first. + const trimGalleryExports = useCallback((keepCount: number) => { + try { + const prefix = "infiplot:gallery:"; + const entries: { key: string; createdAt: number }[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const k = window.localStorage.key(i); + if (!k || !k.startsWith(prefix)) continue; + let createdAt = 0; + try { + const raw = window.localStorage.getItem(k); + if (raw) { + const parsed = JSON.parse(raw) as { createdAt?: number }; + createdAt = parsed.createdAt ?? 0; + } + } catch { + createdAt = 0; + } + entries.push({ key: k, createdAt }); + } + entries.sort((a, b) => b.createdAt - a.createdAt); + for (const e of entries.slice(keepCount)) { + window.localStorage.removeItem(e.key); + } + } catch { + // best-effort — quota or disabled storage shouldn't block the export + } + }, []); + + // Strips the live Session to a small GalleryDoc — only scene images + + // dialogue text + recorded choices, no voice base64 / portraits / style + // reference (those are tens-to-hundreds of KB each). Writes it to + // localStorage under a one-shot id and opens /gallery# in a new tab + // so the play session keeps running. + const handleExportGallery = useCallback(() => { + const s = sessionRef.current; + if (!s) return; + const scenes: GalleryScene[] = s.history + .map((h) => ({ + id: h.scene.id, + imageUrl: h.scene.imageUrl ?? "", + sceneKey: h.scene.sceneKey, + orientation: h.scene.orientation, + beats: h.scene.beats, + entryBeatId: h.scene.entryBeatId, + visitedBeatIds: h.visitedBeatIds, + exit: h.exit, + })) + .filter((sc) => sc.imageUrl); + if (scenes.length === 0) return; + + // Alternates: ${parentSceneId}:${choiceId} → reachable scene. Two sources, + // merged with main-path winning ties (it always agrees with prefetch when + // prefetch was actually used, so the override is a no-op in the common case; + // it differs only when the player took a cold path and the prefetch had + // resolved to something the engine later regenerated): + // 1. Every resolved prefetch (including alternates the player never took) + // 2. Main path: every history step's choice exit → the next visited scene + const alternates: Record = {}; + for (const [key, scene] of resolvedPrefetchesRef.current) { + if (!scene.imageUrl) continue; + alternates[key] = { + id: scene.id, + imageUrl: scene.imageUrl, + sceneKey: scene.sceneKey, + orientation: scene.orientation, + beats: scene.beats, + entryBeatId: scene.entryBeatId, + }; + } + for (let i = 0; i < s.history.length - 1; i++) { + const h = s.history[i]!; + const nextH = s.history[i + 1]!; + if ( + h.exit?.kind === "choice" && + h.scene.id && + nextH.scene.imageUrl + ) { + alternates[`${h.scene.id}:${h.exit.choiceId}`] = { + id: nextH.scene.id, + imageUrl: nextH.scene.imageUrl, + sceneKey: nextH.scene.sceneKey, + orientation: nextH.scene.orientation, + beats: nextH.scene.beats, + entryBeatId: nextH.scene.entryBeatId, + }; + } + } + + // Character portraits — names + CDN URLs only. The big voice base64s are + // intentionally dropped (the gallery only needs the portraits for download). + const characters = s.characters + .filter((c) => c.basePortraitUrl) + .map((c) => ({ + name: c.name, + basePortraitUrl: c.basePortraitUrl as string, + })); + + const id = `${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; + const doc: GalleryDoc = { + v: 2, + id, + createdAt: Date.now(), + orientation: s.orientation ?? "landscape", + scenes, + alternates, + characters, + }; + // Cap retained gallery exports at the most recent 2. Drop everything + // older BEFORE writing the new doc so we never transiently exceed the cap + // (and so a near-quota localStorage has headroom for the new entry). + trimGalleryExports(1); + const docStr = JSON.stringify(doc); + try { + window.localStorage.setItem(`infiplot:gallery:${id}`, docStr); + } catch { + // localStorage full or disabled — silently bail; the player keeps playing. + return; + } + track("gallery_export", { scene_count: scenes.length }); + window.open(`/gallery#id=${id}`, "_blank", "noopener"); + + // Fire-and-forget: also pack an encrypted `.infiplot` share file for the + // player to send to a friend. The local-tab view above is instant either + // way; this happens in the background. Server returns 503 if + // GALLERY_SECRET isn't configured, in which case we silently skip — the + // local view still works, just no share file. + void (async () => { + try { + const r = await fetch("/api/gallery-pack", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ docStr }), + }); + if (!r.ok) return; + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `infiplot-${id}.infiplot`; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); + } catch { + // network / decrypt error — local view above already worked + } + })(); + }, [trimGalleryExports]); + // ── Presentation mode toggle ───────────────────────────────────────── const togglePresentation = useCallback(async () => { const entering = !presentation; @@ -1073,7 +1257,14 @@ function PlayInner() { nextSceneSeed: choice.effect.nextSceneSeed, }, }; - prefetchScenePath(poolRef.current, s, [step], 0, !!byoTtsRef.current); + prefetchScenePath( + poolRef.current, + resolvedPrefetchesRef.current, + s, + [step], + 0, + !!byoTtsRef.current, + ); } }, [currentScene?.id, session?.id]); @@ -1521,6 +1712,20 @@ function PlayInner() { F · 键 · 全 · 屏 } + belowCanvas={ + session && session.history.length > 0 ? ( + + ) : null + } aboveCanvasLeft={ <>