feat(web): export interactive gallery + encrypted share file
Adds a "导出图集" action at the bottom-right of the play canvas that
snapshots the current session into localStorage and opens
/gallery#id=<id> in a new tab — the original play page keeps running
untouched. In parallel, sends the doc to /api/gallery-pack and
downloads the result as a binary .infiplot file the player can send
to a friend.
The snapshot pulls in:
- Every visited scene's image + beat graph + recorded visit trail
- All AI-prefetched alternate scenes (a new resolvedPrefetchesRef in
PlayInner captures each prefetch as it resolves, so abandoned
branches the engine already paid to generate are kept)
- Character names + basePortraitUrl (voice base64 / styleReference
are stripped — they aren't needed for replay)
/gallery is a no-network interactive replay:
- Per-beat advance and per-choice navigation. Picked choices are
highlighted; unpicked choices are clickable when an alternate was
prefetched, greyed otherwise.
- Stack-based navigation for stepping into branches with one-tap
"返回主线" to collapse back to the main path.
- Top-bar batch download for scene images (including unique
AI-prefetched branch scenes, deduped against the main path) and
character portraits. Fetched with a per-file AbortController + 20s
timeout in a small concurrency pool, then clicked serially.
Prevents one slow CDN response from stranding the busy button.
- In-progress hint banner reminding the player to allow the
browser's "multiple downloads" prompt.
- F-key fullscreen with a top toolbar that auto-retracts after the
initial glance and pops back down on cursor approach.
- Per-scene dialogue panel (fa-clock-rotate-left, matching the
in-game history affordance).
- "导入分享文件" entry on the empty/error state — accepts a friend's
.infiplot, posts to /api/gallery-unpack, renders the decrypted doc.
Share-file format (.infiplot):
- AES-256-GCM via Web Crypto (portable to Cloudflare Workers).
- Layout: 4-byte magic "IFPL" + 1-byte version + 12-byte nonce +
ciphertext (includes 16-byte auth tag).
- Key derived from GALLERY_SECRET via SHA-256.
- GCM's auth tag gives tamper-detection for free; any flip in the
ciphertext/nonce surfaces as "文件校验失败" — same error as wrong-key,
so the distinction can't leak server config.
- Stateless: server keeps no record of issued files.
- GALLERY_SECRET unset → /api/gallery-pack returns 503, the play page
silently skips the share-file download, local view still works.
Rotating the secret invalidates every previously-issued file.
Retention: trimGalleryExports keeps only the 2 most recent localStorage
docs; older ones are evicted before each write so quota stays flat
regardless of how many times the player exports. Share files live on
the player's own disk — no retention concern.
Adds 'gallery_export' to the analytics event schema (scene_count only —
no free text).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Response> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1605,7 +1605,7 @@ export default function HomePage() {
|
||||
<div>
|
||||
<p className="text-[10px] smallcaps text-clay-500 mb-3">团 队</p>
|
||||
<p className="font-serif italic text-clay-700 text-base leading-relaxed">
|
||||
我们来自清华大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 <span className="not-italic">one-shot</span> 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。
|
||||
我们来自清华大学、兰州大学、西安交通大学等高校,希望探索多模态模型在「直接生成图片、视频」这类 <span className="not-italic">one-shot</span> 能力之外,更多的可能性。本项目目前仍处于早期阶段,我们还在招募成员,如果你也感兴趣,欢迎联系我们,期待你的加入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
+206
-1
@@ -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<string, PrefetchEntry>,
|
||||
// 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<string, Scene>,
|
||||
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<Map<string, PrefetchEntry>>(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<Map<string, Scene>>(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#<id> 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<string, GalleryScene> = {};
|
||||
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 · 键 · 全 · 屏
|
||||
</button>
|
||||
}
|
||||
belowCanvas={
|
||||
session && session.history.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportGallery}
|
||||
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
|
||||
aria-label="导出可交互图集"
|
||||
title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)"
|
||||
>
|
||||
<i className="fa-solid fa-link text-[10px]" />
|
||||
导 · 出 · 图 · 集
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
aboveCanvasLeft={
|
||||
<>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user