b0b5630a25
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>
74 lines
2.8 KiB
TypeScript
74 lines
2.8 KiB
TypeScript
// Privacy-first analytics. Sends only content-free, categorical events to
|
|
// Umami, and only when the tracker script is actually present (gated by the
|
|
// NEXT_PUBLIC_UMAMI_* env vars in components/Analytics.tsx). With no script
|
|
// loaded — local dev, forks, a non-matching data-domains host, or a visitor
|
|
// with Do Not Track — `window.umami` is undefined and every call here is a
|
|
// silent no-op: zero runtime impact, no errors.
|
|
//
|
|
// RULE: never pass free text (player prompts, custom world/style guides,
|
|
// uploaded images, vision output) or any per-user identifier. Only enums,
|
|
// indices, counts and booleans — that is what keeps these events as
|
|
// privacy-friendly as the cookieless page-view baseline.
|
|
|
|
import type { ArtStyle, Gender, Pacing, PlotStyle } from "./options";
|
|
|
|
declare global {
|
|
interface Window {
|
|
umami?: {
|
|
track: (event: string, data?: Record<string, unknown>) => void;
|
|
};
|
|
}
|
|
}
|
|
|
|
// Per-event payload schema. Fixing each event's allowed fields turns the RULE
|
|
// above into a compile-time guarantee: an event simply has no slot for a prompt,
|
|
// world/style guide or vision string, so free text can't be attached by mistake
|
|
// (a bare `Record<string, string>` would happily accept it). Every field is a
|
|
// literal union (shared with the selector UI via ./options), index, count or
|
|
// boolean — never a bare `string`. `never` marks events that carry no payload.
|
|
type AnalyticsEventData = {
|
|
game_start:
|
|
| {
|
|
source: "prompt";
|
|
gender: Gender;
|
|
art_style: ArtStyle;
|
|
plot_style: PlotStyle;
|
|
pacing: Pacing;
|
|
tts: boolean;
|
|
has_prompt: boolean;
|
|
has_style_ref: boolean;
|
|
}
|
|
| { source: "curated"; gender: Gender; tts: boolean; card: `${"m" | "f"}${number}` }
|
|
| { source: "custom" };
|
|
art_style_select: { style: ArtStyle };
|
|
style_image_upload: { ok: boolean };
|
|
scene_reached: { scene_index: number };
|
|
choice_select: {
|
|
scene_index: number;
|
|
choice_index: number;
|
|
kind: "advance-beat" | "change-scene";
|
|
};
|
|
vision_click: { result: "insert-beat" | "change-scene" };
|
|
tts_toggle: { muted: boolean };
|
|
fullscreen_toggle: { on: boolean };
|
|
play_heartbeat: never;
|
|
gallery_export: { scene_count: number };
|
|
};
|
|
|
|
export type AnalyticsEvent = keyof AnalyticsEventData;
|
|
|
|
// Payload is required for events that define one and forbidden for those typed
|
|
// `never` (the conditional rest tuple collapses to `[]`), so `track("game_start")`
|
|
// without data and `track("play_heartbeat", {...})` with data are both errors.
|
|
export function track<E extends AnalyticsEvent>(
|
|
event: E,
|
|
...[data]: AnalyticsEventData[E] extends never ? [] : [AnalyticsEventData[E]]
|
|
): void {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
window.umami?.track(event, data as Record<string, unknown> | undefined);
|
|
} catch {
|
|
// Analytics must never throw into the app.
|
|
}
|
|
}
|