feat(play): add encrypted story sharing

This commit is contained in:
baizhi958216
2026-06-07 17:13:27 +08:00
parent 3fc8d21b23
commit 0abd5f1525
8 changed files with 677 additions and 16 deletions
+4
View File
@@ -50,6 +50,8 @@ At session start, `startSession()` runs Architect first to create `storyState`;
`Scene` is an image plus a graph of `Beat` nodes. `Beat.next` is either `continue` or `choice`. A scene should have at least one meaningful `change-scene` exit toward a new scene. Beat ids are graph keys; keep them unique and repair references when coercing LLM output.
`SceneHistoryEntry.storyStateAfter` snapshots the story memory after each scene is generated. Keep it when exporting/importing playable story JSON or replaying shared sessions so continuing from a replayed prefix uses the right narrative context.
`StoryState` has stable and volatile zones. Stable fields are set by Architect and must not be patched by Writer: `logline`, `genreTags`, `protagonist`, `castNotes`. Volatile fields may be rewritten every scene: `synopsis`, `openThreads`, `relationships`, `nextHook`. If adding a field, classify it and update `applyStoryStatePatch()` plus Writer coercion.
Characters are identified by `name`. `mergeCharacters()` preserves existing portrait and voice fields when a later design omits them. Do not casually change character matching without checking Writer, Director, and Painter reference handling.
@@ -91,6 +93,7 @@ Common routes live under `app/api/`:
- `POST /api/insert-beat`: creates a transient beat without image generation.
- `POST /api/beat-audio`: lazy TTS for a displayed beat; returns binary audio, or `204` when silent.
- `POST /api/parse-style-image`: extracts a style prompt from uploaded reference art.
- `POST /api/story-pack` / `POST /api/story-unpack`: stateless AES-GCM packing/unpacking for playable story share `.infiplot` files; uses `GALLERY_SECRET`.
When changing public types or route payloads, update all route callers and client consumers in the same change.
@@ -139,6 +142,7 @@ Use `.env.example` as the source of truth. Never commit `.env.local`, API keys,
- `MOCK_IMAGE=true` skips image generation and returns a placeholder for cheap local iteration.
- `NEXT_PUBLIC_IMAGE_PROXY_URL` and `NEXT_PUBLIC_IMAGE_PROXY_ALLOWED_HOSTS` opt into browser-side image proxying for allowed hosts.
- Analytics uses optional Umami `NEXT_PUBLIC_UMAMI_*` values and must stay content-free/privacy-preserving.
- `GALLERY_SECRET` enables encrypted `.infiplot` share files for gallery and playable story export/import.
- `NEXT_PUBLIC_*` values are inlined at build time.
## File Dependency Map
+44
View File
@@ -0,0 +1,44 @@
import { packDoc } from "@/lib/galleryCrypto";
export const runtime = "nodejs";
const MAX_DOC_BYTES = 12_000_000;
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 (new TextEncoder().encode(docStr).byteLength > MAX_DOC_BYTES) {
return Response.json(
{ error: "剧情数据太大,无法打包分享" },
{ status: 413 },
);
}
const bytes = await packDoc(docStr, secret);
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",
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { unpackDoc } from "@/lib/galleryCrypto";
export const runtime = "nodejs";
const MAX_FILE_BYTES = 13_000_000;
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 },
);
}
}
+63
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/options";
import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
/* ============================================================================
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -1249,6 +1250,8 @@ export default function HomePage() {
const [customStyleGuide, setCustomStyleGuide] = useState("");
const [customStyleRefImage, setCustomStyleRefImage] = useState<string>("");
const inputRef = useRef<HTMLTextAreaElement>(null);
const storyImportRef = useRef<HTMLInputElement>(null);
const [storyImportError, setStoryImportError] = useState<string | null>(null);
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
const [hintClosed, setHintClosed] = useState(false);
@@ -1396,6 +1399,44 @@ export default function HomePage() {
router.push("/play?custom=1");
};
const handleStoryImport = async (file: File | undefined) => {
setStoryImportError(null);
if (!file) return;
if (file.size <= 0) {
setStoryImportError("这个剧情文件是空的。");
return;
}
if (file.size > 12_000_000) {
setStoryImportError("剧情文件太大,无法载入。");
return;
}
try {
let text: string;
if (file.name.toLowerCase().endsWith(".json") || file.type === "application/json") {
text = await file.text();
} else {
const r = await fetch("/api/story-unpack", {
method: "POST",
body: await file.arrayBuffer(),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
throw new Error(j.error ?? "剧情文件解包失败。");
}
const j = (await r.json()) as { docStr?: unknown };
if (typeof j.docStr !== "string") throw new Error("剧情文件解包失败。");
text = j.docStr;
}
const doc = parseStoryShareDoc(JSON.parse(text));
window.sessionStorage.setItem(STORY_SHARE_STORAGE_KEY, JSON.stringify(doc));
router.push("/play?share=1");
} catch (e) {
setStoryImportError(e instanceof Error ? e.message : "剧情文件解析失败。");
} finally {
if (storyImportRef.current) storyImportRef.current.value = "";
}
};
const stories = STORIES[galleryGender];
const imgPrefix = galleryGender === "女性向" ? "f" : "m";
const analyticsOn = Boolean(
@@ -1511,6 +1552,28 @@ export default function HomePage() {
<i className="fa-solid fa-arrow-right text-xs" />
</button>
</div>
<div className="mt-4 flex flex-col items-center gap-2">
<input
ref={storyImportRef}
type="file"
accept=".infiplot,application/octet-stream,.json,application/json"
className="hidden"
onChange={(e) => void handleStoryImport(e.target.files?.[0])}
/>
<button
type="button"
onClick={() => storyImportRef.current?.click()}
className="inline-flex items-center gap-2 text-[10px] smallcaps text-clay-500 transition-colors hover:text-ember-500"
>
<i className="fa-solid fa-file-import text-[10px]" />
· · ·
</button>
{storyImportError && (
<p className="max-w-[520px] text-center text-xs leading-relaxed text-ember-500">
{storyImportError}
</p>
)}
</div>
{prompt && (
<p className="mt-2 text-right text-xs text-clay-400">
Enter · Shift+Enter
+293 -11
View File
@@ -21,6 +21,12 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { PRESETS } from "@/lib/presets";
import {
STORY_SHARE_STORAGE_KEY,
createStoryShareDoc,
parseStoryShareDoc,
storyShareFilename,
} from "@/lib/storyShare";
import { provisionVoice, synthesize } from "@infiplot/tts-client";
import type {
Beat,
@@ -621,6 +627,9 @@ function PlayInner() {
const currentSceneRef = useRef<Scene | null>(null);
const currentBeatRef = useRef<Beat | null>(null);
const visitedBeatsRef = useRef<string[]>([]);
const replaySourceRef = useRef<Session | null>(null);
const replayIndexRef = useRef(-1);
const replayActiveRef = useRef(false);
// Original (CDN) URL of the currently-rendered scene image. Used as the key
// to revoke its blob: URL when the scene swaps. We track the ORIGINAL URL,
// not the blob URL, because blobUrlCache is keyed by original URL.
@@ -876,6 +885,13 @@ function PlayInner() {
[prefetchSceneAudio],
);
function detachRecordedReplay(): void {
replayActiveRef.current = false;
replaySourceRef.current = null;
replayIndexRef.current = -1;
clearPool(poolRef.current);
}
// ── 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
@@ -1034,6 +1050,42 @@ function PlayInner() {
})();
}, [trimGalleryExports]);
const handleExportStory = useCallback(() => {
const s = sessionRef.current;
if (!s || s.history.length === 0) return;
const sceneIndex = Math.max(0, s.history.length - 1);
const doc = createStoryShareDoc(s, {
sceneIndex,
beatId: currentBeatRef.current?.id ?? s.history[sceneIndex]?.scene.entryBeatId,
});
void (async () => {
try {
const r = await fetch("/api/story-pack", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ docStr: JSON.stringify(doc) }),
});
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { error?: string };
window.alert(j.error ?? "剧情分享打包失败");
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = storyShareFilename(doc);
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch {
window.alert("剧情分享打包失败");
}
})();
}, []);
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
const entering = !presentation;
@@ -1098,9 +1150,57 @@ function PlayInner() {
// ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成)
// ?custom=1 → 用户自定义 promptsessionStorage 取 ws/sg
// 后走 /api/start 现场生成
// ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放
const cardName = params.get("card");
const presetId = params.get("preset");
const isCustom = params.get("custom") === "1";
const isShare = params.get("share") === "1";
if (isShare) {
(async () => {
try {
const raw = sessionStorage.getItem(STORY_SHARE_STORAGE_KEY);
if (!raw) throw new Error("没有找到要载入的剧情文件。");
const doc = parseStoryShareDoc(JSON.parse(raw));
const imported = doc.session;
const first = imported.history[0];
if (!first) throw new Error("剧情分享文件没有可载入的剧情。");
if (!first.scene.imageUrl) throw new Error("剧情分享文件缺少第一幕图片。");
const sessionOrientation =
first.scene.orientation ?? imported.orientation ?? detectOrientation();
setOrientation(sessionOrientation);
const blobUrl = await getOrCreateBlobUrl(first.scene.imageUrl);
lastImageOriginalUrlRef.current = first.scene.imageUrl;
const initial: Session = {
...imported,
history: [
{
...first,
visitedBeatIds: [first.scene.entryBeatId],
exit: undefined,
},
],
storyState: first.storyStateAfter ?? imported.storyState,
orientation: sessionOrientation,
};
replaySourceRef.current = imported;
replayIndexRef.current = 0;
replayActiveRef.current = imported.history.length > 1;
visitedBeatsRef.current = [first.scene.entryBeatId];
setSession(initial);
setCurrentScene(first.scene);
setCurrentBeatId(first.scene.entryBeatId);
setImageUrl(blobUrl);
setPhase("ready");
track("scene_reached", { scene_index: 1 });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
})();
return;
}
let livePayload: {
worldSetting: string;
@@ -1224,6 +1324,7 @@ function PlayInner() {
{
scene: data.scene,
visitedBeatIds: [data.scene.entryBeatId],
storyStateAfter: data.storyState,
},
],
characters: data.characters,
@@ -1250,6 +1351,7 @@ function PlayInner() {
const s = session;
const scene = currentScene;
if (!s || !scene) return;
if (isRecordedReplayLockedAt(currentBeat)) return;
const exits = findAllChangeSceneChoices(scene);
for (const choice of exits) {
@@ -1273,7 +1375,7 @@ function PlayInner() {
!!byoTtsRef.current,
);
}
}, [currentScene?.id, session?.id]);
}, [currentScene?.id, currentBeat?.id, session?.id]);
// Abort all in-flight speculative prefetches when the page unmounts, so we
// stop paying for background scene/image generation. Empty deps → fires only
@@ -1346,6 +1448,7 @@ function PlayInner() {
{
scene: result.scene,
visitedBeatIds: [result.scene.entryBeatId],
storyStateAfter: result.storyState,
},
],
characters: mergeCharactersPreserveVoice(
@@ -1373,8 +1476,140 @@ function PlayInner() {
}
}
function tryRecordedSceneTransition(
choice: BeatChoice,
exit: SceneExit,
visitedForCurrent: string[],
): boolean {
const source = replaySourceRef.current;
const idx = replayIndexRef.current;
if (!source || idx < 0 || !isRecordedReplayLockedAt(currentBeatRef.current)) {
return false;
}
const recorded = source.history[idx];
const next = source.history[idx + 1];
if (
!recorded ||
!next ||
recorded.exit?.kind !== "choice" ||
recorded.exit.choiceId !== choice.id
) {
detachRecordedReplay();
return false;
}
void (async () => {
setPhase("transitioning");
setPendingClick(null);
try {
if (!next.scene.imageUrl) throw new Error("剧情分享文件缺少下一幕图片。");
const blobUrl = await getOrCreateBlobUrl(next.scene.imageUrl);
const priorOriginal = lastImageOriginalUrlRef.current;
if (priorOriginal && priorOriginal !== next.scene.imageUrl) {
revokeBlobUrlFor(priorOriginal);
}
lastImageOriginalUrlRef.current = next.scene.imageUrl;
const base = sessionRef.current;
if (!base) throw new Error("Session lost mid-replay");
const closedHistory = base.history.map((h, i, arr) =>
i === arr.length - 1
? { ...h, visitedBeatIds: visitedForCurrent, exit }
: h,
);
const nextIndex = idx + 1;
const nextSession: Session = {
...base,
history: [
...closedHistory,
{
...next,
visitedBeatIds: [next.scene.entryBeatId],
exit: undefined,
},
],
characters: source.characters,
storyState: next.storyStateAfter ?? base.storyState,
orientation: next.scene.orientation ?? base.orientation,
};
replayIndexRef.current = nextIndex;
replayActiveRef.current = true;
visitedBeatsRef.current = [next.scene.entryBeatId];
setSession(nextSession);
setCurrentScene(next.scene);
setCurrentBeatId(next.scene.entryBeatId);
setImageUrl(blobUrl);
setLastExitLabel(choice.label);
setPhase("ready");
track("scene_reached", { scene_index: nextSession.history.length });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setPhase("ready");
}
})();
return true;
}
function recordedAllowedChoiceIds(beat: Beat | null): Set<string> | null {
if (!replaySourceRef.current || !beat || beat.next.type !== "choice") return null;
const source = replaySourceRef.current;
const recorded = source?.history[replayIndexRef.current];
if (!recorded) return new Set();
const visited = recorded.visitedBeatIds;
const beatIdx = visited.indexOf(beat.id);
if (beatIdx < 0) return null;
const nextVisited = beatIdx >= 0 ? visited[beatIdx + 1] : undefined;
const allowed = new Set<string>();
if (nextVisited) {
for (const choice of beat.next.choices) {
if (
choice.effect.kind === "advance-beat" &&
choice.effect.targetBeatId === nextVisited
) {
allowed.add(choice.id);
}
}
return allowed;
}
if (
beatIdx === visited.length - 1 &&
recorded.exit?.kind === "choice" &&
source.history[replayIndexRef.current + 1]
) {
allowed.add(recorded.exit.choiceId);
return allowed;
}
return null;
}
function isRecordedReplayLockedAt(beat: Beat | null): boolean {
if (!replaySourceRef.current || !beat) return false;
const recorded = replaySourceRef.current.history[replayIndexRef.current];
if (!recorded) return false;
const beatIdx = recorded.visitedBeatIds.indexOf(beat.id);
if (beatIdx < 0) return false;
return Boolean(
recorded.visitedBeatIds[beatIdx + 1] ||
(
beatIdx === recorded.visitedBeatIds.length - 1 &&
recorded.exit?.kind === "choice" &&
replaySourceRef.current.history[replayIndexRef.current + 1]
),
);
}
function isDisabledByRecordedReplay(choice: BeatChoice): boolean {
const allowed = recordedAllowedChoiceIds(currentBeatRef.current);
return allowed !== null && !allowed.has(choice.id);
}
function onSelectChoice(choice: BeatChoice) {
if (phase !== "ready" || !session || !currentScene) return;
if (isDisabledByRecordedReplay(choice)) return;
const beatNext = currentBeatRef.current?.next;
const choiceIndex =
@@ -1390,6 +1625,23 @@ function PlayInner() {
}
if (choice.effect.kind === "advance-beat") {
if (replayActiveRef.current && currentBeatRef.current) {
const source = replaySourceRef.current;
const idx = replayIndexRef.current;
const recorded = source?.history[idx];
const recordedVisited = recorded?.visitedBeatIds ?? [];
const beatIdx = recordedVisited.indexOf(currentBeatRef.current.id);
const recordedNext = beatIdx >= 0 ? recordedVisited[beatIdx + 1] : undefined;
if (recordedNext && recordedNext !== choice.effect.targetBeatId) {
detachRecordedReplay();
}
}
if (
replaySourceRef.current &&
!isRecordedReplayLockedAt(currentBeatRef.current)
) {
detachRecordedReplay();
}
// Pure local jump. No network. No pool changes.
setCurrentBeatId(choice.effect.targetBeatId);
return;
@@ -1403,6 +1655,9 @@ function PlayInner() {
nextSceneSeed: choice.effect.nextSceneSeed,
};
if (tryRecordedSceneTransition(choice, exit, visited)) return;
if (replaySourceRef.current) detachRecordedReplay();
const cached = consumeChoice(poolRef.current, choice.id);
if (cached) {
void performSceneTransition(cached, exit, visited, choice.label);
@@ -1445,6 +1700,7 @@ function PlayInner() {
async function onFreeformInput(text: string) {
if (phase !== "ready" || !session || !currentScene) return;
if (replayActiveRef.current) detachRecordedReplay();
track("freeform_input", {
scene_index: session.history.length,
@@ -1576,6 +1832,7 @@ function PlayInner() {
async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
if (replayActiveRef.current) detachRecordedReplay();
setPhase("vision-thinking");
setPendingClick(click);
@@ -1720,6 +1977,15 @@ function PlayInner() {
// ── Render ────────────────────────────────────────────────────────────
const replayAllowedChoiceIds = recordedAllowedChoiceIds(currentBeat);
const disabledReplayChoiceIds =
replayAllowedChoiceIds && currentBeat?.next.type === "choice"
? currentBeat.next.choices
.filter((choice) => !replayAllowedChoiceIds.has(choice.id))
.map((choice) => choice.id)
: [];
const replayLocked = isRecordedReplayLockedAt(currentBeat);
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center px-8">
@@ -1768,6 +2034,8 @@ function PlayInner() {
onOpenSettings={() => setSettingsOpen(true)}
fullViewport
dialogueHistory={dialogueHistory}
disabledChoiceIds={disabledReplayChoiceIds}
freeformDisabled={replayLocked}
/>
{orientation === "portrait" && (
<div
@@ -1854,6 +2122,8 @@ function PlayInner() {
visionClickEnabled={visionClickEnabled}
onOpenSettings={() => setSettingsOpen(true)}
dialogueHistory={dialogueHistory}
disabledChoiceIds={disabledReplayChoiceIds}
freeformDisabled={replayLocked}
aboveCanvas={
<button
type="button"
@@ -1868,16 +2138,28 @@ function PlayInner() {
}
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>
<>
<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>
<button
type="button"
onClick={handleExportStory}
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
aria-label="分享当前剧情"
title="导出本局为可继续游玩的剧情 JSON"
>
<i className="fa-solid fa-share-nodes text-[10px]" />
· · ·
</button>
</>
) : null
}
aboveCanvasLeft={
+16 -5
View File
@@ -113,12 +113,14 @@ function ChoiceButton({
index,
label,
disabled,
disabledTitle,
vertical,
onClick,
}: {
index: number;
label: string;
disabled: boolean;
disabledTitle?: string;
vertical: boolean;
onClick: () => void;
}) {
@@ -126,9 +128,10 @@ function ChoiceButton({
<button
type="button"
disabled={disabled}
title={disabledTitle}
onClick={onClick}
className={`group relative ${vertical ? "w-full" : "flex-1 min-w-0"} px-4 py-3 text-left transition-all duration-200
disabled:opacity-50 disabled:cursor-wait`}
disabled:opacity-45 disabled:cursor-not-allowed`}
style={{
background: "rgba(20, 14, 8, 0.68)",
border: "1.5px solid rgba(180, 140, 80, 0.65)",
@@ -184,6 +187,8 @@ export function PlayCanvas({
aboveCanvasLeft,
belowCanvas,
dialogueHistory = [],
disabledChoiceIds = [],
freeformDisabled = false,
}: {
imageUrl: string | null;
audioSrc: string | null;
@@ -209,6 +214,8 @@ export function PlayCanvas({
// 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。
belowCanvas?: ReactNode;
dialogueHistory?: DialogueHistoryItem[];
disabledChoiceIds?: readonly string[];
freeformDisabled?: boolean;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
@@ -226,6 +233,7 @@ export function PlayCanvas({
const choices: BeatChoice[] = isChoiceBeat
? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices
: [];
const disabledChoices = new Set(disabledChoiceIds);
const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? "";
const { shown: typedBody, done: typingDone, skip: skipTypewriter } =
@@ -290,7 +298,7 @@ export function PlayCanvas({
onAdvance();
return;
}
if (!visionClickEnabled || !imgRef.current) return;
if (freeformDisabled || !visionClickEnabled || !imgRef.current) return;
const el = imgRef.current;
const rect = el.getBoundingClientRect();
let x: number;
@@ -328,7 +336,9 @@ export function PlayCanvas({
const interactive = phase === "ready" && !!imageUrl;
const imageClickable =
interactive &&
(!typingDone || beat?.next.type === "continue" || visionClickEnabled);
(!typingDone ||
beat?.next.type === "continue" ||
(visionClickEnabled && !freeformDisabled));
const dimmed = phase === "transitioning";
const portrait = orientation === "portrait";
@@ -511,12 +521,13 @@ export function PlayCanvas({
key={choice.id}
index={i}
label={choice.label}
disabled={phase !== "ready"}
disabled={phase !== "ready" || disabledChoices.has(choice.id)}
disabledTitle={disabledChoices.has(choice.id) ? "分享剧情未包含这条分支" : undefined}
vertical={portrait}
onClick={() => onSelectChoice(choice)}
/>
))}
{onFreeformInput && (
{onFreeformInput && !freeformDisabled && (
<button
type="button"
disabled={phase !== "ready"}
+215
View File
@@ -0,0 +1,215 @@
import type {
Beat,
Character,
Orientation,
Scene,
SceneExit,
Session,
StoryState,
} from "@infiplot/types";
export const STORY_SHARE_STORAGE_KEY = "infiplot:story-import";
export type StoryShareDoc = {
v: 1;
kind: "infiplot-story";
exportedAt: number;
current: {
sceneIndex: number;
beatId?: string;
};
session: Session;
};
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isOrientation(value: unknown): value is Orientation {
return value === "portrait" || value === "landscape";
}
function isStoryState(value: unknown): value is StoryState {
if (!isRecord(value)) return false;
return (
typeof value.logline === "string" &&
typeof value.genreTags === "string" &&
typeof value.protagonist === "string" &&
typeof value.synopsis === "string" &&
(value.castNotes === undefined || typeof value.castNotes === "string") &&
(value.openThreads === undefined || isStringArray(value.openThreads)) &&
(value.relationships === undefined || isStringArray(value.relationships)) &&
(value.nextHook === undefined || typeof value.nextHook === "string")
);
}
function isBeat(value: unknown): value is Beat {
if (!isRecord(value) || typeof value.id !== "string") return false;
if (value.narration !== undefined && typeof value.narration !== "string") return false;
if (value.speaker !== undefined && typeof value.speaker !== "string") return false;
if (value.line !== undefined && typeof value.line !== "string") return false;
if (value.lineDelivery !== undefined && typeof value.lineDelivery !== "string") return false;
if (value.activeCharacters !== undefined) {
if (!Array.isArray(value.activeCharacters)) return false;
for (const c of value.activeCharacters) {
if (!isRecord(c) || typeof c.name !== "string") return false;
if (c.pose !== undefined && typeof c.pose !== "string") return false;
}
}
const next = value.next;
if (!isRecord(next) || typeof next.type !== "string") return false;
if (next.type === "continue") return typeof next.nextBeatId === "string";
if (next.type !== "choice" || !Array.isArray(next.choices)) return false;
return next.choices.every((choice) => {
if (!isRecord(choice) || typeof choice.id !== "string" || typeof choice.label !== "string") {
return false;
}
const effect = choice.effect;
if (!isRecord(effect) || typeof effect.kind !== "string") return false;
if (effect.kind === "advance-beat") return typeof effect.targetBeatId === "string";
if (effect.kind === "change-scene") return typeof effect.nextSceneSeed === "string";
return false;
});
}
function isScene(value: unknown): value is Scene {
if (!isRecord(value)) return false;
if (typeof value.id !== "string" || typeof value.scenePrompt !== "string") return false;
if (!Array.isArray(value.beats) || value.beats.length === 0) return false;
if (!value.beats.every(isBeat)) return false;
if (typeof value.entryBeatId !== "string") return false;
if (!value.beats.some((beat) => beat.id === value.entryBeatId)) return false;
if (value.imageUrl !== undefined && typeof value.imageUrl !== "string") return false;
if (value.imageUrl === "") return false;
if (value.sceneKey !== undefined && typeof value.sceneKey !== "string") return false;
if (value.imageUuid !== undefined && typeof value.imageUuid !== "string") return false;
if (value.orientation !== undefined && !isOrientation(value.orientation)) return false;
return true;
}
function isSceneExit(value: unknown): value is SceneExit {
if (!isRecord(value) || typeof value.kind !== "string") return false;
if (value.kind === "freeform") return typeof value.action === "string";
return (
value.kind === "choice" &&
typeof value.choiceId === "string" &&
typeof value.label === "string" &&
typeof value.nextSceneSeed === "string"
);
}
function isCharacter(value: unknown): value is Character {
if (!isRecord(value)) return false;
return (
typeof value.name === "string" &&
typeof value.voiceDescription === "string" &&
(value.visualDescription === undefined || typeof value.visualDescription === "string") &&
(value.basePortraitUuid === undefined || typeof value.basePortraitUuid === "string") &&
(value.basePortraitUrl === undefined || typeof value.basePortraitUrl === "string")
);
}
function stripCharacterVoices(characters: Character[]): Character[] {
return characters.map((character) => {
const { voice: _voice, ...rest } = character;
return rest;
});
}
function sanitizeSessionForShare(session: Session): Session {
return {
...session,
characters: stripCharacterVoices(session.characters),
};
}
export function createStoryShareDoc(
session: Session,
current: { sceneIndex: number; beatId?: string },
): StoryShareDoc {
return {
v: 1,
kind: "infiplot-story",
exportedAt: Date.now(),
current,
session: sanitizeSessionForShare(session),
};
}
export function storyShareFilename(doc: StoryShareDoc): string {
return `infiplot-story-${doc.exportedAt.toString(36)}.infiplot`;
}
export function parseStoryShareDoc(value: unknown): StoryShareDoc {
if (!isRecord(value)) throw new Error("这不是有效的剧情分享文件");
if (value.kind !== "infiplot-story" || value.v !== 1) {
throw new Error("剧情分享文件格式不支持");
}
if (typeof value.exportedAt !== "number" || !Number.isFinite(value.exportedAt)) {
throw new Error("剧情分享文件缺少导出时间");
}
if (!isRecord(value.current) || typeof value.current.sceneIndex !== "number") {
throw new Error("剧情分享文件缺少当前位置");
}
if (
value.current.beatId !== undefined &&
typeof value.current.beatId !== "string"
) {
throw new Error("剧情分享文件当前位置不合法");
}
const session = value.session;
if (!isRecord(session)) throw new Error("剧情分享文件缺少会话数据");
if (typeof session.id !== "string" || typeof session.createdAt !== "number") {
throw new Error("剧情分享文件会话信息不完整");
}
if (typeof session.worldSetting !== "string" || typeof session.styleGuide !== "string") {
throw new Error("剧情分享文件缺少故事设定");
}
if (!Array.isArray(session.history) || session.history.length === 0) {
throw new Error("剧情分享文件没有可载入的剧情");
}
if (!Array.isArray(session.characters) || !session.characters.every(isCharacter)) {
throw new Error("剧情分享文件角色数据不合法");
}
if (session.storyState !== undefined && !isStoryState(session.storyState)) {
throw new Error("剧情分享文件剧情记忆不合法");
}
if (session.styleReferenceImage !== undefined && typeof session.styleReferenceImage !== "string") {
throw new Error("剧情分享文件风格参考图不合法");
}
if (session.orientation !== undefined && !isOrientation(session.orientation)) {
throw new Error("剧情分享文件画面方向不合法");
}
if (session.playerName !== undefined && typeof session.playerName !== "string") {
throw new Error("剧情分享文件玩家名不合法");
}
for (const entry of session.history) {
if (!isRecord(entry) || !isScene(entry.scene)) {
throw new Error("剧情分享文件场景数据不合法");
}
if (!isStringArray(entry.visitedBeatIds) || entry.visitedBeatIds.length === 0) {
throw new Error("剧情分享文件游玩路径不合法");
}
if (entry.exit !== undefined && !isSceneExit(entry.exit)) {
throw new Error("剧情分享文件场景出口不合法");
}
if (entry.storyStateAfter !== undefined && !isStoryState(entry.storyStateAfter)) {
throw new Error("剧情分享文件剧情记忆快照不合法");
}
}
const doc = value as StoryShareDoc;
return {
...doc,
session: sanitizeSessionForShare(doc.session),
};
}
+4
View File
@@ -113,6 +113,10 @@ export type SceneHistoryEntry = {
scene: Scene;
visitedBeatIds: string[];
exit?: SceneExit;
/** Story memory immediately after this scene was generated. Used by imported
* story replays so continuing from an earlier shared scene preserves the
* right narrative context instead of jumping to the export-time final state. */
storyStateAfter?: StoryState;
};
// ──────────────────────────────────────────────────────────────────────