Merge pull request #55 from zonghaoyuan/staging
chore: sync staging to main
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -51,10 +51,16 @@ Cloudflare へのデプロイはシーンパイプラインがより長い CPU
|
|||||||
|
|
||||||
### Docker デプロイ(セルフホスト)
|
### Docker デプロイ(セルフホスト)
|
||||||
|
|
||||||
VPS、ホームサーバー、ローカルマシンに対応。x86 と ARM(Apple Silicon Mac を含む)をサポート。
|
VPS、ホームサーバー、ローカルマシンに対応。x86 と ARM(Apple 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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 → 用户自定义 prompt,sessionStorage 取 ws/sg
|
// ?custom=1 → 用户自定义 prompt,sessionStorage 取 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={
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+75
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user