Merge pull request #55 from zonghaoyuan/staging

chore: sync staging to main
This commit is contained in:
Zonghao Yuan
2026-06-08 15:47:59 +08:00
committed by GitHub
17 changed files with 1019 additions and 110 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
config.reasoning_effort: "high" config.reasoning_effort: "high"
openai.api_base: ${{ secrets.PR_REVIEW_BASE_URL }} openai.api_base: ${{ secrets.PR_REVIEW_BASE_URL }}
github_action_config.auto_review: "true" github_action_config.auto_review: "true"
github_action_config.auto_describe: "true" github_action_config.auto_describe: "false"
github_action_config.auto_improve: "true" github_action_config.auto_improve: "true"
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "synchronize"]' github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "synchronize"]'
pr_reviewer.extra_instructions: | pr_reviewer.extra_instructions: |
+1 -1
View File
@@ -19,7 +19,7 @@ require_todo_scan = true
persistent_comment = false # two model jobs would otherwise overwrite each other persistent_comment = false # two model jobs would otherwise overwrite each other
[pr_description] [pr_description]
generate_ai_title = true generate_ai_title = false
publish_labels = true publish_labels = true
use_bullet_points = true use_bullet_points = true
enable_pr_diagram = true enable_pr_diagram = true
+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
+9 -3
View File
@@ -51,10 +51,16 @@ After deploy, fill in the environment variables — see the [Configuration guide
### Docker (self-hosted) ### Docker (self-hosted)
For VPS, home servers, or local machines. Supports x86 and ARM (including Apple Silicon Macs). For VPS, home servers, or local machines. Supports x86 and ARM (including Apple Silicon Macs). No need to clone the repo — just download two files:
1. Copy `.env.example` to `.env.local` and fill in your API keys (see [Configuration guide](#configuration-guide)) ```bash
2. Start: mkdir -p infiplot && cd infiplot
curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/docker-compose.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/.env.example -o .env.example
[ -f .env.local ] || cp .env.example .env.local
```
Edit `.env.local` with your API keys (see [Configuration guide](#configuration-guide)), then start:
```bash ```bash
docker compose up -d docker compose up -d
+9 -3
View File
@@ -51,10 +51,16 @@ Cloudflare へのデプロイはシーンパイプラインがより長い CPU
### Docker デプロイ(セルフホスト) ### Docker デプロイ(セルフホスト)
VPS、ホームサーバー、ローカルマシンに対応。x86 と ARMApple Silicon Mac を含む)をサポート。 VPS、ホームサーバー、ローカルマシンに対応。x86 と ARMApple Silicon Mac を含む)をサポート。リポジトリのクローンは不要です。2 つのファイルをダウンロードするだけで始められます:
1. `.env.example``.env.local` にコピーし、API キーを設定([設定ガイド](#設定ガイド)を参照) ```bash
2. 起動: mkdir -p infiplot && cd infiplot
curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/docker-compose.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/.env.example -o .env.example
[ -f .env.local ] || cp .env.example .env.local
```
`.env.local` を編集して API キーを設定し([設定ガイド](#設定ガイド)を参照)、起動します:
```bash ```bash
docker compose up -d docker compose up -d
+9 -3
View File
@@ -51,10 +51,16 @@ Cloudflare 部署因场景流水线需要更长 CPU 时间,需要 Workers Paid
### Docker 部署(自托管) ### Docker 部署(自托管)
适用于 VPS、家庭服务器或本地电脑。支持 x86 和 ARM(含 Apple Silicon Mac)。 适用于 VPS、家庭服务器或本地电脑。支持 x86 和 ARM(含 Apple Silicon Mac)。无需克隆仓库,只需下载两个文件:
1. 复制 `.env.example``.env.local`,填入你的 API Key(详见[配置教程](#配置教程) ```bash
2. 启动: mkdir -p infiplot && cd infiplot
curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/docker-compose.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/zonghaoyuan/infiplot/main/.env.example -o .env.example
[ -f .env.local ] || cp .env.example .env.local
```
编辑 `.env.local` 填入你的 API Key(详见[配置教程](#配置教程)),然后启动:
```bash ```bash
docker compose up -d docker compose up -d
+52
View File
@@ -0,0 +1,52 @@
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 },
);
}
const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_DOC_BYTES + 1024) {
return Response.json(
{ error: "剧情数据太大,无法打包分享" },
{ status: 413 },
);
}
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",
},
});
}
+43
View File
@@ -0,0 +1,43 @@
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 },
);
}
const contentLength = req.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_FILE_BYTES) {
return Response.json({ error: "文件太大" }, { status: 413 });
}
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 {
return Response.json(
{ error: "剧情文件解包失败" },
{ status: 400 },
);
}
}
+24 -84
View File
@@ -15,6 +15,11 @@ import type {
Orientation, Orientation,
SceneExit, SceneExit,
} from "@infiplot/types"; } from "@infiplot/types";
import {
downloadImagesIndividually,
downloadImagesAsZip,
inferImageExtension,
} from "@/lib/imageZipDownload";
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Gallery — an offline-only replay of a played session. Entered from // Gallery — an offline-only replay of a played session. Entered from
@@ -123,72 +128,6 @@ function pickedChoiceIdAt(
return null; return null;
} }
// ── Download a batch of image URLs as separate browser downloads.
// Runware CDN sends Access-Control-Allow-Origin (the annotate flow already
// relies on this) so fetch().blob() works cross-origin without a proxy.
//
// Each fetch has its own AbortController + per-file timeout — without that
// a single slow/hung CDN response strands the whole loop, the caller's busy
// flag never clears, and the button looks "stuck" (the original "下载完按钮就没了"
// report). Fetches run in a small concurrency pool to keep total time
// reasonable for ~10-30 portraits; the actual <a download> clicks remain
// serial with a small gap so Chrome's "allow multiple downloads" prompt
// fires once instead of being coalesced or dropped.
async function downloadImages(
files: { url: string; name: string }[],
): Promise<void> {
const CONCURRENT_FETCH = 4;
const FETCH_TIMEOUT_MS = 20_000;
async function fetchOne(
file: { url: string; name: string },
): Promise<{ blobUrl: string; name: string } | null> {
const { url, name } = file;
if (!url) return null;
if (url.startsWith("data:")) return { blobUrl: url, name };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const r = await fetch(url, { mode: "cors", signal: ctrl.signal });
if (!r.ok) return null;
const blob = await r.blob();
return { blobUrl: URL.createObjectURL(blob), name };
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
const queue = [...files];
const ready: ({ blobUrl: string; name: string } | null)[] = [];
await Promise.all(
Array.from({ length: CONCURRENT_FETCH }, async () => {
while (queue.length > 0) {
const f = queue.shift();
if (!f) break;
ready.push(await fetchOne(f));
}
}),
);
for (const item of ready) {
if (!item) continue;
const { blobUrl, name } = item;
const a = document.createElement("a");
a.href = blobUrl;
a.download = name;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
if (blobUrl.startsWith("blob:")) {
setTimeout(() => URL.revokeObjectURL(blobUrl), 1500);
}
await new Promise((r) => setTimeout(r, 250));
}
}
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Dialogue panel — full beat trail of the current scene // Dialogue panel — full beat trail of the current scene
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
@@ -892,13 +831,6 @@ function GalleryInner() {
if (!doc || downloadingScenes) return; if (!doc || downloadingScenes) return;
setDownloadingScenes(true); setDownloadingScenes(true);
try { try {
function extOf(url: string): string {
if (url.startsWith("data:image/svg")) return "svg";
if (url.startsWith("data:image/")) {
return url.slice(11, url.indexOf(";")) || "png";
}
return "jpg";
}
// Main path + every unique alternate (AI-prefetched branches the player // Main path + every unique alternate (AI-prefetched branches the player
// didn't take). Dedupe by URL — the picked choice's alternate IS the // didn't take). Dedupe by URL — the picked choice's alternate IS the
// next main scene, so they overlap, and we never want the same image // next main scene, so they overlap, and we never want the same image
@@ -913,7 +845,7 @@ function GalleryInner() {
sceneN++; sceneN++;
files.push({ files.push({
url: sc.imageUrl, url: sc.imageUrl,
name: `infiplot-scene-${String(sceneN).padStart(3, "0")}.${extOf(sc.imageUrl)}`, name: `infiplot-scene-${String(sceneN).padStart(3, "0")}.${inferImageExtension(sc.imageUrl)}`,
}); });
} }
let branchN = 0; let branchN = 0;
@@ -923,10 +855,17 @@ function GalleryInner() {
branchN++; branchN++;
files.push({ files.push({
url: alt.imageUrl, url: alt.imageUrl,
name: `infiplot-branch-${String(branchN).padStart(3, "0")}.${extOf(alt.imageUrl)}`, name: `infiplot-branch-${String(branchN).padStart(3, "0")}.${inferImageExtension(alt.imageUrl)}`,
}); });
} }
await downloadImages(files); const result = await downloadImagesAsZip(files, `infiplot-gallery-${doc.id}.zip`);
if (result.downloaded === 0) {
alert("所有图片抓取失败,请检查网络后重试");
} else if (result.failed.length > 0) {
alert(`已打包 ${result.downloaded} 张,${result.failed.length} 张抓取失败`);
}
} catch {
alert("打包下载失败,请重试");
} finally { } finally {
setDownloadingScenes(false); setDownloadingScenes(false);
} }
@@ -1000,7 +939,7 @@ function GalleryInner() {
if (files.length === 0) return; if (files.length === 0) return;
setDownloadingPortraits(true); setDownloadingPortraits(true);
try { try {
await downloadImages(files); await downloadImagesIndividually(files);
} finally { } finally {
setDownloadingPortraits(false); setDownloadingPortraits(false);
} }
@@ -1173,27 +1112,28 @@ function GalleryInner() {
disabled={downloadingScenes} disabled={downloadingScenes}
className="flex h-9 items-center gap-2 rounded-full bg-black/40 px-3 text-[11px] smallcaps text-white/80 backdrop-blur-sm transition-colors hover:text-white disabled:opacity-50" className="flex h-9 items-center gap-2 rounded-full bg-black/40 px-3 text-[11px] smallcaps text-white/80 backdrop-blur-sm transition-colors hover:text-white disabled:opacity-50"
aria-label="批量下载图集到本地" aria-label="批量下载图集到本地"
title="把本局所有场景图(含未选中的分支预生成图)下载到本机(浏览器若弹「允许多个下载」请点允许)" title="把本局所有场景图(含未选中的分支预生成图)打包成 zip 下载到本机"
> >
<i <i
className={`fa-solid ${downloadingScenes ? "fa-spinner animate-spin" : "fa-download"} text-[11px]`} className={`fa-solid ${downloadingScenes ? "fa-spinner animate-spin" : "fa-download"} text-[11px]`}
/> />
{downloadingScenes ? "下载中" : "下载图集"} {downloadingScenes ? "打包中" : "下载图集"}
</button> </button>
</div> </div>
</div> </div>
{/* Download-in-progress hint — Chrome/Edge/Firefox throw a "允许此网站
下载多个文件" prompt after the first <a download>.click(); without
this banner most users miss it and only the first file lands. */}
{(downloadingScenes || downloadingPortraits) && ( {(downloadingScenes || downloadingPortraits) && (
<div <div
className="absolute inset-x-0 z-30 flex justify-center pointer-events-none px-4" className="absolute inset-x-0 z-30 flex justify-center pointer-events-none px-4"
style={{ top: "calc(max(0.75rem, env(safe-area-inset-top)) + 60px)" }} style={{ top: "calc(max(0.75rem, env(safe-area-inset-top)) + 60px)" }}
> >
<span className="flex items-center gap-2 rounded-full bg-black/70 px-4 py-2 text-[11px] text-white/95 backdrop-blur-sm shadow-lg max-w-[92vw]"> <span className="flex items-center gap-2 rounded-full bg-black/70 px-4 py-2 text-[11px] text-white/95 backdrop-blur-sm shadow-lg max-w-[92vw]">
<i className="fa-solid fa-circle-exclamation text-[11px] text-amber-300" /> <i
,, className={`fa-solid ${downloadingScenes ? "fa-file-zipper" : "fa-circle-exclamation"} text-[11px] text-amber-300`}
/>
{downloadingScenes
? "正在抓取图片并打包 zip,完成后会自动开始下载"
: "浏览器顶部如弹出「允许此网站下载多个文件」,请点「允许」,否则只能下到第一张"}
</span> </span>
</div> </div>
)} )}
+65
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,46 @@ 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;
}
const isJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json";
const maxImportBytes = isJson ? 12_000_000 : 13_000_000;
if (file.size > maxImportBytes) {
setStoryImportError("剧情文件太大,无法载入。");
return;
}
try {
let text: string;
if (isJson) {
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(
@@ -1510,7 +1553,29 @@ export default function HomePage() {
<i className="fa-solid fa-arrow-right text-xs" /> <i className="fa-solid fa-arrow-right text-xs" />
</button> </button>
<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="group absolute right-[-2.25rem] bottom-2 md:bottom-3 inline-flex items-center justify-center rounded-sm border border-clay-900/20 px-2 py-2 md:py-2.5 text-clay-400 transition-colors hover:border-ember-500 hover:text-ember-500"
>
<i className="fa-solid fa-file-import text-sm" />
<span className="pointer-events-none absolute -bottom-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-clay-900 px-2 py-1 font-sans text-[11px] text-cream-50 opacity-0 transition-opacity group-hover:opacity-100">
</span>
</button>
</div> </div>
{storyImportError && (
<p className="mt-2 text-right text-xs leading-relaxed text-ember-500">
{storyImportError}
</p>
)}
{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
+298 -10
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,10 @@ 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);
const exportingStoryRef = 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 +886,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 +1051,45 @@ function PlayInner() {
})(); })();
}, [trimGalleryExports]); }, [trimGalleryExports]);
const handleExportStory = useCallback(() => {
const s = sessionRef.current;
if (!s || s.history.length === 0 || exportingStoryRef.current) return;
exportingStoryRef.current = true;
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("剧情分享打包失败");
} finally {
exportingStoryRef.current = false;
}
})();
}, []);
// ── Presentation mode toggle ───────────────────────────────────────── // ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => { const togglePresentation = useCallback(async () => {
const entering = !presentation; const entering = !presentation;
@@ -1098,9 +1154,60 @@ 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 initialStoryState = first.storyStateAfter ?? imported.storyState;
if (!initialStoryState) throw new Error("剧情分享文件缺少初始剧情记忆,无法载入。");
const initial: Session = {
...imported,
history: [
{
...first,
visitedBeatIds: [first.scene.entryBeatId],
exit: undefined,
},
],
storyState: initialStoryState,
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 +1331,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 +1358,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) {
@@ -1346,6 +1455,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 +1483,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 +1632,22 @@ 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();
}
} else 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 +1661,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 +1706,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 +1838,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 +1983,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 +2040,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 +2128,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 +2144,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"}
+189
View File
@@ -0,0 +1,189 @@
import JSZip from "jszip";
export type ImageZipFile = {
url: string;
name: string;
};
export type ImageZipDownloadResult = {
downloaded: number;
failed: ImageZipFile[];
};
type DownloadOptions = {
concurrency?: number;
timeoutMs?: number;
};
const DEFAULT_CONCURRENCY = 4;
const DEFAULT_TIMEOUT_MS = 20_000;
export function inferImageExtension(url: string): string {
const dataMatch = /^data:image\/([^;,]+)/i.exec(url);
if (dataMatch?.[1]) {
const sub = dataMatch[1].toLowerCase();
if (sub === "svg+xml") return "svg";
return sub === "jpeg" ? "jpg" : sub;
}
try {
const base =
typeof window !== "undefined" ? window.location.href : "http://localhost";
const ext = new URL(url, base).pathname.split(".").pop()?.toLowerCase();
if (ext && ["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) {
return ext === "jpeg" ? "jpg" : ext;
}
} catch {
// Fall through to the historical default used by gallery downloads.
}
return "jpg";
}
export async function downloadImagesAsZip(
files: ImageZipFile[],
zipName: string,
options: DownloadOptions = {},
): Promise<ImageZipDownloadResult> {
const filtered = files.filter((file) => file.url && file.name);
if (filtered.length === 0) return { downloaded: 0, failed: [] };
const blobs = await fetchImageBlobs(filtered, options);
const zip = new JSZip();
const usedPaths = new Set<string>();
const failed: ImageZipFile[] = [];
let downloaded = 0;
for (let i = 0; i < filtered.length; i++) {
const file = filtered[i]!;
const blob = blobs[i];
if (!blob) {
failed.push(file);
continue;
}
zip.file(uniqueZipPath(file.name, usedPaths), blob, { date: new Date() });
downloaded++;
}
if (downloaded === 0) return { downloaded, failed };
const blob = await zip.generateAsync({ type: "blob", compression: "STORE" });
triggerBrowserDownload(blob, normalizeZipName(zipName));
return { downloaded, failed };
}
export async function downloadImagesIndividually(
files: ImageZipFile[],
options: DownloadOptions = {},
): Promise<ImageZipDownloadResult> {
const filtered = files.filter((file) => file.url && file.name);
if (filtered.length === 0) return { downloaded: 0, failed: [] };
const blobs = await fetchImageBlobs(filtered, options);
const failed: ImageZipFile[] = [];
let downloaded = 0;
for (let i = 0; i < filtered.length; i++) {
const file = filtered[i]!;
const blob = blobs[i];
if (!blob) {
failed.push(file);
continue;
}
triggerBrowserDownload(blob, file.name);
downloaded++;
await new Promise((resolve) => setTimeout(resolve, 250));
}
return { downloaded, failed };
}
async function fetchImageBlobs(
files: ImageZipFile[],
options: DownloadOptions,
): Promise<(Blob | null)[]> {
const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY);
const timeoutMs = Math.max(1000, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const queue = files.map((file, index) => ({ file, index }));
const blobs = new Array<Blob | null>(files.length).fill(null);
await Promise.all(
Array.from({ length: Math.min(concurrency, files.length) }, async () => {
while (queue.length > 0) {
const next = queue.shift();
if (!next) break;
blobs[next.index] = await fetchImageBlob(next.file.url, timeoutMs);
}
}),
);
return blobs;
}
async function fetchImageBlob(url: string, timeoutMs: number): Promise<Blob | null> {
if (!url) return null;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const init: RequestInit = { signal: ctrl.signal };
if (!url.startsWith("data:")) init.mode = "cors";
const response = await fetch(url, init);
if (!response.ok) return null;
const blob = await response.blob();
return blob.size > 0 ? blob : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function triggerBrowserDownload(blob: Blob, fileName: string): void {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = fileName;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
const delayMs = blob.size > 5_000_000 ? 60_000 : 1_500;
setTimeout(() => URL.revokeObjectURL(blobUrl), delayMs);
}
function normalizeZipName(name: string): string {
const trimmed = name.trim() || "images.zip";
return trimmed.toLowerCase().endsWith(".zip") ? trimmed : `${trimmed}.zip`;
}
function uniqueZipPath(name: string, usedPaths: Set<string>): string {
const clean = sanitizeZipPath(name);
if (!usedPaths.has(clean)) {
usedPaths.add(clean);
return clean;
}
const dot = clean.lastIndexOf(".");
const base = dot > 0 ? clean.slice(0, dot) : clean;
const ext = dot > 0 ? clean.slice(dot) : "";
for (let n = 2; n < 10_000; n++) {
const candidate = `${base}-${n}${ext}`;
if (!usedPaths.has(candidate)) {
usedPaths.add(candidate);
return candidate;
}
}
const fallback = `${base}-${Date.now()}${ext}`;
usedPaths.add(fallback);
return fallback;
}
function sanitizeZipPath(name: string): string {
const parts = name
.replace(/\\/g, "/")
.split("/")
.map((part) => part.replace(/[^\w.\-\u4e00-\u9fff]/g, "_"))
.filter((part) => part && part !== "." && part !== "..");
return parts.join("/") || "image.jpg";
}
+219
View File
@@ -0,0 +1,219 @@
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) ||
!Number.isInteger(value.current.sceneIndex) ||
(value.current.sceneIndex as number) < 0
) {
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;
}; };
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
+1
View File
@@ -25,6 +25,7 @@
"@ai-sdk/openai": "^3.0.67", "@ai-sdk/openai": "^3.0.67",
"ai": "^6.0.196", "ai": "^6.0.196",
"jsonrepair": "^3.14.0", "jsonrepair": "^3.14.0",
"jszip": "^3.10.1",
"next": "^16.0.0", "next": "^16.0.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
+75
View File
@@ -23,6 +23,9 @@ importers:
jsonrepair: jsonrepair:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.14.0 version: 3.14.0
jszip:
specifier: ^3.10.1
version: 3.10.1
next: next:
specifier: ^16.0.0 specifier: ^16.0.0
version: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 16.2.7(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1492,6 +1495,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1769,6 +1775,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -1806,6 +1815,9 @@ packages:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'} engines: {node: '>=8'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isexe@2.0.0: isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1828,10 +1840,16 @@ packages:
resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==} resolution: {integrity: sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==}
hasBin: true hasBin: true
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
kleur@4.1.5: kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lilconfig@3.1.3: lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -2013,6 +2031,9 @@ packages:
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parseurl@1.3.3: parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2115,6 +2136,9 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
proxy-addr@2.0.7: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -2146,6 +2170,9 @@ packages:
read-cache@1.0.0: read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readdirp@3.6.0: readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
@@ -2166,6 +2193,9 @@ packages:
run-parallel@1.2.0: run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -2185,6 +2215,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -2249,6 +2282,9 @@ packages:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-ansi@6.0.1: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -4037,6 +4073,8 @@ snapshots:
cookie@1.1.1: {} cookie@1.1.1: {}
core-util-is@1.0.3: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -4382,6 +4420,8 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
immediate@3.0.6: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
@@ -4408,6 +4448,8 @@ snapshots:
is-stream@2.0.1: {} is-stream@2.0.1: {}
isarray@1.0.0: {}
isexe@2.0.0: {} isexe@2.0.0: {}
isexe@3.1.5: {} isexe@3.1.5: {}
@@ -4422,8 +4464,19 @@ snapshots:
jsonrepair@3.14.0: {} jsonrepair@3.14.0: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
kleur@4.1.5: {} kleur@4.1.5: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lilconfig@3.1.3: {} lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
@@ -4566,6 +4619,8 @@ snapshots:
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
parseurl@1.3.3: {} parseurl@1.3.3: {}
path-expression-matcher@1.5.0: {} path-expression-matcher@1.5.0: {}
@@ -4644,6 +4699,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
process-nextick-args@2.0.1: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
@@ -4675,6 +4732,16 @@ snapshots:
dependencies: dependencies:
pify: 2.3.0 pify: 2.3.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readdirp@3.6.0: readdirp@3.6.0:
dependencies: dependencies:
picomatch: 2.3.2 picomatch: 2.3.2
@@ -4702,6 +4769,8 @@ snapshots:
dependencies: dependencies:
queue-microtask: 1.2.3 queue-microtask: 1.2.3
safe-buffer@5.1.2: {}
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}
@@ -4733,6 +4802,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
setimmediate@1.0.5: {}
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sharp@0.33.5: sharp@0.33.5:
@@ -4851,6 +4922,10 @@ snapshots:
get-east-asian-width: 1.6.0 get-east-asian-width: 1.6.0
strip-ansi: 7.2.0 strip-ansi: 7.2.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
strip-ansi@6.0.1: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1