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. `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. `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. 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/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/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/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. 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. - `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. - `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. - 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. - `NEXT_PUBLIC_*` values are inlined at build time.
## File Dependency Map ## 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"; } from "@/lib/options";
import { readStoredTtsConfig } from "@/lib/clientTtsConfig"; import { readStoredTtsConfig } from "@/lib/clientTtsConfig";
import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal"; import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/components/SettingsModal";
import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare";
/* ============================================================================ /* ============================================================================
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型) InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
@@ -1249,6 +1250,8 @@ export default function HomePage() {
const [customStyleGuide, setCustomStyleGuide] = useState(""); const [customStyleGuide, setCustomStyleGuide] = useState("");
const [customStyleRefImage, setCustomStyleRefImage] = useState<string>(""); const [customStyleRefImage, setCustomStyleRefImage] = useState<string>("");
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const storyImportRef = useRef<HTMLInputElement>(null);
const [storyImportError, setStoryImportError] = useState<string | null>(null);
// 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。 // 顶部使用提示:默认展示,用户可点 × 永久关闭(localStorage:infiplot:hintClosed)。
const [hintClosed, setHintClosed] = useState(false); const [hintClosed, setHintClosed] = useState(false);
@@ -1396,6 +1399,44 @@ export default function HomePage() {
router.push("/play?custom=1"); 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 stories = STORIES[galleryGender];
const imgPrefix = galleryGender === "女性向" ? "f" : "m"; const imgPrefix = galleryGender === "女性向" ? "f" : "m";
const analyticsOn = Boolean( const analyticsOn = Boolean(
@@ -1511,6 +1552,28 @@ export default function HomePage() {
<i className="fa-solid fa-arrow-right text-xs" /> <i className="fa-solid fa-arrow-right text-xs" />
</button> </button>
</div> </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 && ( {prompt && (
<p className="mt-2 text-right text-xs text-clay-400"> <p className="mt-2 text-right text-xs text-clay-400">
Enter · Shift+Enter Enter · Shift+Enter
+293 -11
View File
@@ -21,6 +21,12 @@ import { SettingsModal, readStoredPlayerName, readStoredVisionClick } from "@/co
import { annotateClick } from "@/lib/annotateClient"; import { annotateClick } from "@/lib/annotateClient";
import { loadClientTtsConfig } from "@/lib/clientTtsConfig"; import { loadClientTtsConfig } from "@/lib/clientTtsConfig";
import { PRESETS } from "@/lib/presets"; import { PRESETS } from "@/lib/presets";
import {
STORY_SHARE_STORAGE_KEY,
createStoryShareDoc,
parseStoryShareDoc,
storyShareFilename,
} from "@/lib/storyShare";
import { provisionVoice, synthesize } from "@infiplot/tts-client"; import { provisionVoice, synthesize } from "@infiplot/tts-client";
import type { import type {
Beat, Beat,
@@ -621,6 +627,9 @@ function PlayInner() {
const currentSceneRef = useRef<Scene | null>(null); const currentSceneRef = useRef<Scene | null>(null);
const currentBeatRef = useRef<Beat | null>(null); const currentBeatRef = useRef<Beat | null>(null);
const visitedBeatsRef = useRef<string[]>([]); 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 // 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, // 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. // not the blob URL, because blobUrlCache is keyed by original URL.
@@ -876,6 +885,13 @@ function PlayInner() {
[prefetchSceneAudio], [prefetchSceneAudio],
); );
function detachRecordedReplay(): void {
replayActiveRef.current = false;
replaySourceRef.current = null;
replayIndexRef.current = -1;
clearPool(poolRef.current);
}
// ── Export to interactive gallery (PPT-style replay) ───────────────── // ── Export to interactive gallery (PPT-style replay) ─────────────────
// Drop all but the (keepCount) most-recent gallery exports from localStorage, // Drop all but the (keepCount) most-recent gallery exports from localStorage,
// ordered by their stored createdAt. Called right before writing a new // ordered by their stored createdAt. Called right before writing a new
@@ -1034,6 +1050,42 @@ function PlayInner() {
})(); })();
}, [trimGalleryExports]); }, [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 ───────────────────────────────────────── // ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => { const togglePresentation = useCallback(async () => {
const entering = !presentation; const entering = !presentation;
@@ -1098,9 +1150,57 @@ function PlayInner() {
// ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成) // ?preset=<id> → 内置 PRESETS(仍走 /api/start 现场生成)
// ?custom=1 → 用户自定义 promptsessionStorage 取 ws/sg // ?custom=1 → 用户自定义 promptsessionStorage 取 ws/sg
// 后走 /api/start 现场生成 // 后走 /api/start 现场生成
// ?share=1 → 首页上传的剧情分享 JSON,从第一幕开始本地回放
const cardName = params.get("card"); const cardName = params.get("card");
const presetId = params.get("preset"); const presetId = params.get("preset");
const isCustom = params.get("custom") === "1"; 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: { let livePayload: {
worldSetting: string; worldSetting: string;
@@ -1224,6 +1324,7 @@ function PlayInner() {
{ {
scene: data.scene, scene: data.scene,
visitedBeatIds: [data.scene.entryBeatId], visitedBeatIds: [data.scene.entryBeatId],
storyStateAfter: data.storyState,
}, },
], ],
characters: data.characters, characters: data.characters,
@@ -1250,6 +1351,7 @@ function PlayInner() {
const s = session; const s = session;
const scene = currentScene; const scene = currentScene;
if (!s || !scene) return; if (!s || !scene) return;
if (isRecordedReplayLockedAt(currentBeat)) return;
const exits = findAllChangeSceneChoices(scene); const exits = findAllChangeSceneChoices(scene);
for (const choice of exits) { for (const choice of exits) {
@@ -1273,7 +1375,7 @@ function PlayInner() {
!!byoTtsRef.current, !!byoTtsRef.current,
); );
} }
}, [currentScene?.id, session?.id]); }, [currentScene?.id, currentBeat?.id, session?.id]);
// Abort all in-flight speculative prefetches when the page unmounts, so we // Abort all in-flight speculative prefetches when the page unmounts, so we
// stop paying for background scene/image generation. Empty deps → fires only // stop paying for background scene/image generation. Empty deps → fires only
@@ -1346,6 +1448,7 @@ function PlayInner() {
{ {
scene: result.scene, scene: result.scene,
visitedBeatIds: [result.scene.entryBeatId], visitedBeatIds: [result.scene.entryBeatId],
storyStateAfter: result.storyState,
}, },
], ],
characters: mergeCharactersPreserveVoice( 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) { function onSelectChoice(choice: BeatChoice) {
if (phase !== "ready" || !session || !currentScene) return; if (phase !== "ready" || !session || !currentScene) return;
if (isDisabledByRecordedReplay(choice)) return;
const beatNext = currentBeatRef.current?.next; const beatNext = currentBeatRef.current?.next;
const choiceIndex = const choiceIndex =
@@ -1390,6 +1625,23 @@ function PlayInner() {
} }
if (choice.effect.kind === "advance-beat") { 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. // Pure local jump. No network. No pool changes.
setCurrentBeatId(choice.effect.targetBeatId); setCurrentBeatId(choice.effect.targetBeatId);
return; return;
@@ -1403,6 +1655,9 @@ function PlayInner() {
nextSceneSeed: choice.effect.nextSceneSeed, nextSceneSeed: choice.effect.nextSceneSeed,
}; };
if (tryRecordedSceneTransition(choice, exit, visited)) return;
if (replaySourceRef.current) detachRecordedReplay();
const cached = consumeChoice(poolRef.current, choice.id); const cached = consumeChoice(poolRef.current, choice.id);
if (cached) { if (cached) {
void performSceneTransition(cached, exit, visited, choice.label); void performSceneTransition(cached, exit, visited, choice.label);
@@ -1445,6 +1700,7 @@ function PlayInner() {
async function onFreeformInput(text: string) { async function onFreeformInput(text: string) {
if (phase !== "ready" || !session || !currentScene) return; if (phase !== "ready" || !session || !currentScene) return;
if (replayActiveRef.current) detachRecordedReplay();
track("freeform_input", { track("freeform_input", {
scene_index: session.history.length, scene_index: session.history.length,
@@ -1576,6 +1832,7 @@ function PlayInner() {
async function onBackgroundClick(click: { x: number; y: number }) { async function onBackgroundClick(click: { x: number; y: number }) {
if (phase !== "ready" || !session || !currentScene || !imageUrl) return; if (phase !== "ready" || !session || !currentScene || !imageUrl) return;
if (replayActiveRef.current) detachRecordedReplay();
setPhase("vision-thinking"); setPhase("vision-thinking");
setPendingClick(click); setPendingClick(click);
@@ -1720,6 +1977,15 @@ function PlayInner() {
// ── Render ──────────────────────────────────────────────────────────── // ── 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) { if (error) {
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center px-8"> <div className="min-h-screen flex flex-col items-center justify-center px-8">
@@ -1768,6 +2034,8 @@ function PlayInner() {
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
fullViewport fullViewport
dialogueHistory={dialogueHistory} dialogueHistory={dialogueHistory}
disabledChoiceIds={disabledReplayChoiceIds}
freeformDisabled={replayLocked}
/> />
{orientation === "portrait" && ( {orientation === "portrait" && (
<div <div
@@ -1854,6 +2122,8 @@ function PlayInner() {
visionClickEnabled={visionClickEnabled} visionClickEnabled={visionClickEnabled}
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
dialogueHistory={dialogueHistory} dialogueHistory={dialogueHistory}
disabledChoiceIds={disabledReplayChoiceIds}
freeformDisabled={replayLocked}
aboveCanvas={ aboveCanvas={
<button <button
type="button" type="button"
@@ -1868,16 +2138,28 @@ function PlayInner() {
} }
belowCanvas={ belowCanvas={
session && session.history.length > 0 ? ( session && session.history.length > 0 ? (
<button <>
type="button" <button
onClick={handleExportGallery} type="button"
className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2" onClick={handleExportGallery}
aria-label="导出可交互图集" className="text-[10px] smallcaps text-clay-500 hover:text-ember-500 transition-colors flex items-center gap-2"
title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)" aria-label="导出可交互图集"
> title="导出本局为可交互图集链接(只会保留最近两次的可交互图集链接)"
<i className="fa-solid fa-link text-[10px]" /> >
· · · <i className="fa-solid fa-link text-[10px]" />
</button> · · ·
</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 ) : null
} }
aboveCanvasLeft={ aboveCanvasLeft={
+16 -5
View File
@@ -113,12 +113,14 @@ function ChoiceButton({
index, index,
label, label,
disabled, disabled,
disabledTitle,
vertical, vertical,
onClick, onClick,
}: { }: {
index: number; index: number;
label: string; label: string;
disabled: boolean; disabled: boolean;
disabledTitle?: string;
vertical: boolean; vertical: boolean;
onClick: () => void; onClick: () => void;
}) { }) {
@@ -126,9 +128,10 @@ function ChoiceButton({
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
title={disabledTitle}
onClick={onClick} onClick={onClick}
className={`group relative ${vertical ? "w-full" : "flex-1 min-w-0"} px-4 py-3 text-left transition-all duration-200 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={{ style={{
background: "rgba(20, 14, 8, 0.68)", background: "rgba(20, 14, 8, 0.68)",
border: "1.5px solid rgba(180, 140, 80, 0.65)", border: "1.5px solid rgba(180, 140, 80, 0.65)",
@@ -184,6 +187,8 @@ export function PlayCanvas({
aboveCanvasLeft, aboveCanvasLeft,
belowCanvas, belowCanvas,
dialogueHistory = [], dialogueHistory = [],
disabledChoiceIds = [],
freeformDisabled = false,
}: { }: {
imageUrl: string | null; imageUrl: string | null;
audioSrc: string | null; audioSrc: string | null;
@@ -209,6 +214,8 @@ export function PlayCanvas({
// 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。 // 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。
belowCanvas?: ReactNode; belowCanvas?: ReactNode;
dialogueHistory?: DialogueHistoryItem[]; dialogueHistory?: DialogueHistoryItem[];
disabledChoiceIds?: readonly string[];
freeformDisabled?: boolean;
}) { }) {
const imgRef = useRef<HTMLImageElement>(null); const imgRef = useRef<HTMLImageElement>(null);
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
@@ -226,6 +233,7 @@ export function PlayCanvas({
const choices: BeatChoice[] = isChoiceBeat const choices: BeatChoice[] = isChoiceBeat
? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices ? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices
: []; : [];
const disabledChoices = new Set(disabledChoiceIds);
const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? ""; const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? "";
const { shown: typedBody, done: typingDone, skip: skipTypewriter } = const { shown: typedBody, done: typingDone, skip: skipTypewriter } =
@@ -290,7 +298,7 @@ export function PlayCanvas({
onAdvance(); onAdvance();
return; return;
} }
if (!visionClickEnabled || !imgRef.current) return; if (freeformDisabled || !visionClickEnabled || !imgRef.current) return;
const el = imgRef.current; const el = imgRef.current;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
let x: number; let x: number;
@@ -328,7 +336,9 @@ export function PlayCanvas({
const interactive = phase === "ready" && !!imageUrl; const interactive = phase === "ready" && !!imageUrl;
const imageClickable = const imageClickable =
interactive && interactive &&
(!typingDone || beat?.next.type === "continue" || visionClickEnabled); (!typingDone ||
beat?.next.type === "continue" ||
(visionClickEnabled && !freeformDisabled));
const dimmed = phase === "transitioning"; const dimmed = phase === "transitioning";
const portrait = orientation === "portrait"; const portrait = orientation === "portrait";
@@ -511,12 +521,13 @@ export function PlayCanvas({
key={choice.id} key={choice.id}
index={i} index={i}
label={choice.label} label={choice.label}
disabled={phase !== "ready"} disabled={phase !== "ready" || disabledChoices.has(choice.id)}
disabledTitle={disabledChoices.has(choice.id) ? "分享剧情未包含这条分支" : undefined}
vertical={portrait} vertical={portrait}
onClick={() => onSelectChoice(choice)} onClick={() => onSelectChoice(choice)}
/> />
))} ))}
{onFreeformInput && ( {onFreeformInput && !freeformDisabled && (
<button <button
type="button" type="button"
disabled={phase !== "ready"} 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; scene: Scene;
visitedBeatIds: string[]; visitedBeatIds: string[];
exit?: SceneExit; 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;
}; };
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────