diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 5189b182c..54ea3fda8 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -14915,8 +14915,11 @@ export function AdminSettingsClient() {
})
}
/>
-
+
+
+ 开启后,该模型不会出现在前端,也不会参与第三方生图 API 的选模或返回模型列表。
+
- 前台只显示“实景化”模式,不展示模型名称;这里可选择所有已启用的生图模型。第一阶段失败时第二阶段不会继续执行。
+ 前台只显示“实景化”模式,不展示模型名称;第一阶段只生成中间图,不保存到个人相册;
+ 第二阶段最终图会保存到个人相册。两阶段会严格使用这里指定的模型,第一阶段失败时第二阶段不会继续执行。
diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx
index a783216de..19ed44115 100644
--- a/components/style-pro/StyleProWorkspace.tsx
+++ b/components/style-pro/StyleProWorkspace.tsx
@@ -128,6 +128,8 @@ type StyleProGenerateResponse = {
thumbnailUrl?: string;
model?: string;
modelRef?: string;
+ gallerySavedImageCount?: number;
+ gallerySaveError?: string;
generationMode?: StyleProGenerationMode;
chargedPoints?: number;
selections: StyleProSelectionMap;
@@ -901,7 +903,7 @@ function BeforeAfterSlider({
@@ -1395,7 +1397,13 @@ export function StyleProWorkspace({
toast.warning("结果已生成,历史记录稍后同步");
}
setGenerationTaskState(null);
- toast.success("风格化 PRO 生成完成");
+ if (taskResult.gallerySaveError) {
+ toast.warning(
+ `结果已生成,但个人相册同步失败:${taskResult.gallerySaveError}`
+ );
+ } else {
+ toast.success("风格化 PRO 生成完成,已保存到个人相册");
+ }
} catch (error) {
setGenerationTaskState((current) =>
current
diff --git a/docs/nanobanana-browser-poc.md b/docs/nanobanana-browser-poc.md
new file mode 100644
index 000000000..9a6b3a7e1
--- /dev/null
+++ b/docs/nanobanana-browser-poc.md
@@ -0,0 +1,188 @@
+# Nano Banana Browser POC
+
+This repo includes a small browser-driven POC for inspecting Nano Banana's free-tool flow without trying to bypass Turnstile, login, or rate limits.
+
+## What it does
+
+- Opens the Nano Banana page in a real browser
+- Optionally prefills a public image URL
+- Optionally clicks the primary action button
+- Captures requests and responses for the relevant private endpoints
+- Saves screenshots, console logs, downloads, and browser storage state
+
+## What it does not do
+
+- It does not bypass Cloudflare Turnstile
+- It does not bypass login
+- It does not try to evade rate limits
+- It is not a production API adapter
+
+## Commands
+
+Remove background:
+
+```bash
+pnpm poc:nanobanana --mode remove-background --auto-submit
+```
+
+Upscale:
+
+```bash
+pnpm poc:nanobanana --mode upscale --auto-submit
+```
+
+Use a custom image URL:
+
+```bash
+pnpm poc:nanobanana \
+ --mode upscale \
+ --image-url "https://example.com/demo.jpg" \
+ --auto-submit
+```
+
+## Useful flags
+
+- `--locale en`
+- `--headless`
+- `--no-auto-fill`
+- `--auto-submit`
+- `--close-on-success`
+- `--timeout-ms 300000`
+- `--out-dir /absolute/path/to/output`
+
+## Output
+
+By default, artifacts are written to:
+
+```text
+playwright/nanobanana-poc/-/
+```
+
+The folder includes:
+
+- `summary.json`
+- `session-plan.json`
+- `storage-state-initial.json`
+- `storage-state-final.json`
+- `requests/*.json`
+- `responses/*.json`
+- `logs/*.json`
+- screenshots such as `01-loaded.png` and `99-final.png`
+
+## Current endpoints observed
+
+- `/api/img/rmbg`
+- `/api/img/upscale`
+- `/api/img/result/{taskId}?type=img`
+- `/api/img/image-safety`
+- `/api/presigned-urls`
+
+## Current findings (2026-05-21)
+
+These findings were confirmed from the live frontend bundle and manual browser validation.
+
+### Remove background
+
+Frontend schema:
+
+- `imageSource`: `"upload"` or `"url"`
+- `imageUrl`: optional string
+- `turnstileToken`: optional string
+
+Observed request shape:
+
+```json
+{
+ "imageSource": "url",
+ "imageUrl": "https://...",
+ "turnstileToken": "..."
+}
+```
+
+Observed endpoint behavior:
+
+- `POST /api/img/rmbg`
+- `Content-Type: application/json`
+- success path reads `response.data` as the final output image URL
+- after success the page clears `turnstileToken`
+- `403` with `message === "nsfw"` is handled as an NSFW block
+
+Manual validation in the in-app browser produced a successful result and exposed a final image URL hosted on `replicate.delivery`, which suggests the private backend may fan out to third-party storage or processing before returning the final asset URL.
+
+### Image upscaler
+
+Frontend schema:
+
+- `imageSource`: `"upload"` or `"url"`
+- `imageUrl`: optional string
+- `scale`: number, min `1`, max `8`, default `4`
+- `turnstileToken`: optional string
+
+Observed submit request shape:
+
+```json
+{
+ "imageUrl": "https://...",
+ "scale": "4",
+ "turnstileToken": "..."
+}
+```
+
+Upload mode can also send an `image` field populated from the uploaded asset URL.
+
+Observed endpoint behavior:
+
+- `POST /api/img/upscale`
+- `Content-Type: application/json`
+- success path reads `taskId` from `response.data.taskId` or `response.taskId`
+- the frontend then polls `GET /api/img/result/{taskId}?type=img`
+- polling treats `code === 1` or `status === 1` as success
+- final output is read from `data.imgAfterSrc` or `imgAfterSrc`
+- polling treats `code === 2` or `status === 2` as failure
+- the polling loop waits 2 seconds and retries up to 60 times
+- `402` is handled as an upgrade/paywall condition
+- `403` with `message === "nsfw"` is handled as an NSFW block
+
+### Turnstile
+
+Observed Cloudflare site key:
+
+- `0x4AAAAAAAh8CLI0IWTYOWMY`
+
+Observed frontend behavior:
+
+- when `turnstileToken` is missing, the frontend renders or executes Turnstile before submitting
+- after a successful request, the frontend clears `turnstileToken`
+- this means the free-tool flow is not designed as a stable unattended API
+
+### Upload helper
+
+Observed upload helper behavior:
+
+- `POST /api/presigned-urls` with JSON like `{ "count": 1, "fileType": "" }`
+- response returns `urls`
+- the browser `PUT`s the file to the returned presigned URL
+- the frontend then derives a public asset URL in the form:
+
+```text
+https://temp.videostudioai.com/
+```
+
+### Practical takeaway
+
+This is enough to build an internal unofficial wrapper, but not enough to remove the core operational risk:
+
+- the flow still depends on Turnstile
+- Turnstile tokens are intentionally short-lived and cleared after use
+- the frontend contract may change at any time
+- upscaling still appears more likely to require session state than remove-background
+
+## Suggested use
+
+Use this only as an internal validation tool to answer questions like:
+
+- Does the browser session create anonymous or guest state?
+- When does Turnstile appear?
+- Which cookies or local storage values appear before the request?
+- Does upscaling require an authenticated session?
+- What request and polling shape does the frontend use today?
diff --git a/docs/第三方生图API调用说明.md b/docs/第三方生图API调用说明.md
index e0989c541..03eec85da 100644
--- a/docs/第三方生图API调用说明.md
+++ b/docs/第三方生图API调用说明.md
@@ -79,6 +79,7 @@ curl -X GET "https://ai.yydd.ai/api/external/image/models" \
说明:
- 这个接口主要用于联调排查或查看当前可用能力
+- 被后台设置为“前台隐藏”的模型不会出现在这个列表里
- 正式调用生图接口时,第三方 **无需传模型名**
## 4. 发起生图
@@ -227,4 +228,5 @@ Authorization: Bearer YOUR_API_KEY
## 9. 对接备注
- 这套接口使用的是我们系统当前启用的生图模型,模型列表会随后台配置变化而变化
+- 被后台设置为“前台隐藏”的模型,默认也禁止提供给第三方 API 使用
- 如果需要增加调用频控、签名校验、白名单 IP、异步回调等能力,可以继续在这套 API 上扩展
diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts
index 89a46b9c5..c3277d9c7 100644
--- a/lib/canvas/generate-image.ts
+++ b/lib/canvas/generate-image.ts
@@ -39,6 +39,7 @@ export interface CanvasGenerateImageRequest {
ownerKey?: string;
debugLabel?: string;
debugMeta?: Record;
+ allowFailover?: boolean;
internalFailoverTriedModelRefs?: string[];
onQueueStatusChange?: (
status: CanvasImageModelQueueStatus & {
@@ -678,7 +679,8 @@ export async function generateCanvasImage(
selectedModel.model.modelId,
].filter(Boolean))
);
- const fallbackModel = shouldFailoverAiModelError(caughtError)
+ const allowFailover = request.allowFailover !== false;
+ const fallbackModel = allowFailover && shouldFailoverAiModelError(caughtError)
? findCanvasFailoverModel({
selectedModel,
imageModels,
diff --git a/lib/external-image-api/service.ts b/lib/external-image-api/service.ts
index 92e4c9799..44c7c7bb3 100644
--- a/lib/external-image-api/service.ts
+++ b/lib/external-image-api/service.ts
@@ -454,7 +454,7 @@ export async function authenticateExternalImageApiRequest(request: Request) {
}
export async function getExternalImageApiModels() {
- const models = await getEnabledAiModelsWithProvider(false, true);
+ const models = await getEnabledAiModelsWithProvider(false, false);
return sortExternalImageApiModels(
models.filter(
@@ -500,7 +500,7 @@ export async function resolveExternalImageApiModel(
const referenceImageCount = Math.max(0, options?.referenceImageCount ?? 1);
const models = sortExternalImageApiModels(
(
- await getEnabledAiModelsWithProvider(false, true)
+ await getEnabledAiModelsWithProvider(false, false)
).filter((entry) =>
isImageGenerationModelDescriptor({
providerType: entry.provider.type,
@@ -554,7 +554,7 @@ export async function buildExternalImageApiModelCandidates(params?: {
const referenceImageCount = Math.max(0, params?.referenceImageCount ?? 1);
const allModels = sortExternalImageApiModels(
(
- await getEnabledAiModelsWithProvider(false, true)
+ await getEnabledAiModelsWithProvider(false, false)
).filter((entry) =>
isImageGenerationModelDescriptor({
providerType: entry.provider.type,
diff --git a/lib/style-pro/task.ts b/lib/style-pro/task.ts
index 1578a8c73..0282484c6 100644
--- a/lib/style-pro/task.ts
+++ b/lib/style-pro/task.ts
@@ -5,6 +5,7 @@ import {
generateCanvasImage,
type CanvasGenerateImageResult,
} from "@/lib/canvas/generate-image";
+import { saveGeneratedImages } from "@/lib/ai/runtime/generated-image-persistence";
import type {
StyleProGenerationMode,
StyleProCustomInputMap,
@@ -72,6 +73,8 @@ export type StyleProTaskResult = {
thumbnailUrl?: string;
model?: string;
modelRef?: string;
+ gallerySavedImageCount?: number;
+ gallerySaveError?: string;
generationMode: StyleProGenerationMode;
chargedPoints: number;
selections: StyleProSelectionMap;
@@ -90,6 +93,10 @@ function composeStyleProPrompt({
selectedCustomInputs?: StyleProTaskCustomInput[];
mode?: string;
}) {
+ const uniqueStrings = (values: string[]) =>
+ Array.from(
+ new Set(values.map((value) => String(value || "").trim()).filter(Boolean))
+ );
const groupedPrompt = selectedOptions.reduce>(
(acc, item) => {
const key = item.jsonKey || item.categoryId;
@@ -111,6 +118,34 @@ function composeStyleProPrompt({
},
{}
);
+ const promptByKey = Object.fromEntries(
+ Object.entries(groupedPrompt).map(([key, prompts]) => [
+ key,
+ uniqueStrings(prompts),
+ ])
+ );
+ const customPromptByScope = Object.fromEntries(
+ Object.entries(customPrompt).map(([key, prompts]) => [
+ key,
+ uniqueStrings(prompts),
+ ])
+ );
+ const selectionSummary = selectedOptions.map((item) => ({
+ categoryId: item.categoryId,
+ categoryTitle: item.categoryTitle,
+ jsonKey: item.jsonKey,
+ optionId: item.optionId,
+ optionLabel: item.optionLabel,
+ }));
+ const customInputSummary = selectedCustomInputs.map((item) => ({
+ scope: item.scope,
+ categoryId: item.categoryId,
+ categoryTitle: item.categoryTitle,
+ fieldId: item.fieldId,
+ fieldLabel: item.fieldLabel,
+ fieldType: item.fieldType,
+ value: item.value,
+ }));
return [
defaultPrompt,
@@ -118,10 +153,10 @@ function composeStyleProPrompt({
JSON.stringify(
{
mode,
- selections: selectedOptions,
- promptByKey: groupedPrompt,
- customInputs: selectedCustomInputs,
- customPromptByScope: customPrompt,
+ selections: selectionSummary,
+ promptByKey,
+ customInputs: customInputSummary,
+ customPromptByScope,
},
null,
2
@@ -190,6 +225,42 @@ function normalizeCanvasImageResultImage(
};
}
+async function saveStyleProFinalImageToGallery(params: {
+ userId: string;
+ request: StyleProTaskRequest;
+ imageUrl: string;
+ thumbnailUrl: string;
+ modelRef?: string;
+ prompt: string;
+}) {
+ const imageSize = params.request.prompts.imageSize || "2K";
+ const imageMarkdown = ``;
+ const persistResult = await saveGeneratedImages(
+ imageMarkdown,
+ params.userId,
+ null,
+ params.request.billing.messageId || null,
+ params.prompt,
+ params.modelRef || params.request.billing.modelId,
+ {
+ sourceImageUrls: [params.request.sourceImageUrl],
+ relationType: "edited_from",
+ sourceType:
+ params.request.generationMode === "realistic"
+ ? "style_pro_realistic"
+ : "style_pro",
+ projectId: params.request.canvasId || null,
+ userPrompt: params.request.optionLabels.join(" / "),
+ optimizedPrompt: params.prompt,
+ requestedImageCount: 1,
+ aspectRatio: params.request.prompts.aspectRatio || "auto",
+ imageSize,
+ skipBilling: true,
+ }
+ );
+ return persistResult.savedImageCount;
+}
+
export async function executeStyleProTask(params: {
request: StyleProTaskRequest;
userId: string;
@@ -199,6 +270,7 @@ export async function executeStyleProTask(params: {
const imageSize = request.prompts.imageSize || "2K";
const negativePrompt = request.prompts.negativePrompt || undefined;
+ let finalPrompt = "";
const result =
request.generationMode === "realistic"
? await (async () => {
@@ -217,10 +289,12 @@ export async function executeStyleProTask(params: {
imageCount: 1,
referenceImage: request.sourceImageUrl,
ownerKey: userId,
+ allowFailover: false,
debugLabel: "style-pro-realistic-stage1",
debugMeta: {
canvasId: request.canvasId || null,
selectedOptionCount: request.selectedOptions.length,
+ configuredModelRef: firstModelId,
},
});
const firstPassImage = normalizeCanvasImageResultImage(
@@ -228,13 +302,14 @@ export async function executeStyleProTask(params: {
"实景化第一阶段生成失败"
);
+ finalPrompt = composeStyleProPrompt({
+ defaultPrompt: request.prompts.realisticSecondPrompt || "",
+ selectedOptions: request.selectedOptions,
+ selectedCustomInputs: request.selectedCustomInputs || [],
+ mode: "style-pro-realistic-final",
+ });
return generateCanvasImage({
- prompt: composeStyleProPrompt({
- defaultPrompt: request.prompts.realisticSecondPrompt || "",
- selectedOptions: request.selectedOptions,
- selectedCustomInputs: request.selectedCustomInputs || [],
- mode: "style-pro-realistic-final",
- }),
+ prompt: finalPrompt,
negativePrompt,
model: secondModelId,
aspectRatio,
@@ -242,36 +317,67 @@ export async function executeStyleProTask(params: {
imageCount: 1,
referenceImage: firstPassImage.imageUrl,
ownerKey: userId,
+ allowFailover: false,
debugLabel: "style-pro-realistic-stage2",
debugMeta: {
canvasId: request.canvasId || null,
selectedOptionCount: request.selectedOptions.length,
firstStageModelRef: firstModelId,
+ configuredModelRef: secondModelId,
},
});
})()
- : await generateCanvasImage({
- prompt: composeStyleProPrompt({
+ : await (async () => {
+ finalPrompt = composeStyleProPrompt({
defaultPrompt: request.prompts.defaultPrompt,
selectedOptions: request.selectedOptions,
selectedCustomInputs: request.selectedCustomInputs || [],
- }),
- negativePrompt,
- model: getPrimaryModelId(request),
- aspectRatio,
- imageSize,
- imageCount: 1,
- referenceImage: request.sourceImageUrl,
- ownerKey: userId,
- debugLabel: "style-pro",
- debugMeta: {
- canvasId: request.canvasId || null,
- requestedStyleProModelId: request.requestedStyleProModelId || null,
- selectedOptionCount: request.selectedOptions.length,
- },
- });
+ });
+ return await generateCanvasImage({
+ prompt: finalPrompt,
+ negativePrompt,
+ model: getPrimaryModelId(request),
+ aspectRatio,
+ imageSize,
+ imageCount: 1,
+ referenceImage: request.sourceImageUrl,
+ ownerKey: userId,
+ allowFailover: false,
+ debugLabel: "style-pro",
+ debugMeta: {
+ canvasId: request.canvasId || null,
+ requestedStyleProModelId: request.requestedStyleProModelId || null,
+ configuredModelRef: getPrimaryModelId(request),
+ selectedOptionCount: request.selectedOptions.length,
+ },
+ });
+ })();
const finalImage = normalizeCanvasImageResultImage(result, "生成结果为空");
+ let gallerySavedImageCount = 0;
+ let gallerySaveError: string | undefined;
+ try {
+ gallerySavedImageCount = await saveStyleProFinalImageToGallery({
+ userId,
+ request,
+ imageUrl: finalImage.imageUrl,
+ thumbnailUrl: finalImage.thumbnailUrl,
+ modelRef: finalImage.modelRef,
+ prompt: finalPrompt || result.prompt,
+ });
+ if (gallerySavedImageCount <= 0) {
+ gallerySaveError = "最终图未写入个人相册";
+ }
+ } catch (error) {
+ gallerySaveError =
+ error instanceof Error ? error.message : "保存到个人相册失败";
+ console.error("[Style Pro Task] Failed to save final image to gallery:", {
+ error: gallerySaveError,
+ generationMode: request.generationMode,
+ modelRef: finalImage.modelRef,
+ canvasId: request.canvasId || null,
+ });
+ }
const chargedPoints = await consumeStyleProTaskPoints(userId, request.billing);
return {
@@ -279,6 +385,8 @@ export async function executeStyleProTask(params: {
thumbnailUrl: finalImage.thumbnailUrl,
model: finalImage.model,
modelRef: finalImage.modelRef,
+ gallerySavedImageCount,
+ gallerySaveError,
generationMode: request.generationMode,
chargedPoints,
selections: request.normalizedSelections,
diff --git a/package.json b/package.json
index 1c6f9610e..1e5d91dba 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"db:pull": "drizzle-kit pull",
"db:check": "drizzle-kit check",
"db:up": "drizzle-kit up",
+ "poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts",
"test": "export PLAYWRIGHT=True && pnpm exec playwright test"
},
"dependencies": {
diff --git a/scripts/nanobanana-browser-poc.ts b/scripts/nanobanana-browser-poc.ts
new file mode 100644
index 000000000..416e90417
--- /dev/null
+++ b/scripts/nanobanana-browser-poc.ts
@@ -0,0 +1,530 @@
+import { chromium, type ConsoleMessage, type Page, type Request, type Response } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import process from "node:process";
+
+type Mode = "remove-background" | "upscale";
+
+type Options = {
+ autoFill: boolean;
+ autoSubmit: boolean;
+ closeOnSuccess: boolean;
+ headless: boolean;
+ imageUrl?: string;
+ locale: string;
+ mode: Mode;
+ outDir: string;
+ timeoutMs: number;
+};
+
+type EventKind =
+ | "console"
+ | "download"
+ | "image-safety"
+ | "pageerror"
+ | "presigned-urls"
+ | "remove-background"
+ | "result"
+ | "upscale";
+
+type MatchedEvent = {
+ kind: EventKind;
+ note?: string;
+ url: string;
+};
+
+type CaptureSummary = {
+ artifactDir: string;
+ consoleCount: number;
+ downloads: string[];
+ errorCount: number;
+ eventCount: number;
+ finalImageUrl?: string;
+ mode: Mode;
+ pageUrl: string;
+ requestCount: number;
+ responseCount: number;
+ successDetected: boolean;
+};
+
+const DEFAULT_IMAGE_URL =
+ "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Example.jpg/240px-Example.jpg";
+
+const MODE_CONFIG: Record<
+ Mode,
+ {
+ actionLabels: string[];
+ pagePath: string;
+ }
+> = {
+ "remove-background": {
+ actionLabels: ["移除背景", "Remove Background"],
+ pagePath: "remove-background",
+ },
+ upscale: {
+ actionLabels: ["放大图片", "Upscale Image"],
+ pagePath: "ai-image-upscaler",
+ },
+};
+
+function printHelp() {
+ console.log(`Nano Banana browser POC
+
+Usage:
+ pnpm poc:nanobanana --mode [options]
+
+Options:
+ --mode Required. One of: remove-background, upscale
+ --image-url Public image URL to prefill. Defaults to a sample Wikimedia image.
+ --locale Site locale. Defaults to zh.
+ --out-dir Output directory. Defaults to playwright/nanobanana-poc/.
+ --headless Run headless. Default is visible browser.
+ --no-auto-fill Do not try to prefill the image URL field.
+ --auto-submit Try to click the primary action button after prefilling.
+ --close-on-success Close the browser once a successful result is detected.
+ --timeout-ms Session timeout in milliseconds. Defaults to 600000.
+ --help Show this message.
+
+Notes:
+ - This POC does not bypass Turnstile, login, or rate limits.
+ - Complete any CAPTCHA or login step manually in the opened browser.
+ - Close the browser window when you are done if --close-on-success is not used.
+`);
+}
+
+function timestampLabel(date = new Date()) {
+ const parts = [
+ date.getFullYear(),
+ `${date.getMonth() + 1}`.padStart(2, "0"),
+ `${date.getDate()}`.padStart(2, "0"),
+ `${date.getHours()}`.padStart(2, "0"),
+ `${date.getMinutes()}`.padStart(2, "0"),
+ `${date.getSeconds()}`.padStart(2, "0"),
+ ];
+ return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
+}
+
+function sanitizeFilePart(value: string) {
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 80) || "item";
+}
+
+function getFlagValue(args: string[], flag: string) {
+ const index = args.indexOf(flag);
+ if (index === -1) {
+ return undefined;
+ }
+
+ const value = args[index + 1];
+ if (!value || value.startsWith("--")) {
+ throw new Error(`Missing value for ${flag}`);
+ }
+
+ return value;
+}
+
+function hasFlag(args: string[], flag: string) {
+ return args.includes(flag);
+}
+
+function parseArgs(argv: string[]): Options {
+ if (hasFlag(argv, "--help")) {
+ printHelp();
+ process.exit(0);
+ }
+
+ const mode = getFlagValue(argv, "--mode");
+ if (mode !== "remove-background" && mode !== "upscale") {
+ throw new Error("You must pass --mode remove-background or --mode upscale");
+ }
+
+ const locale = getFlagValue(argv, "--locale") ?? "zh";
+ const imageUrl = getFlagValue(argv, "--image-url") ?? DEFAULT_IMAGE_URL;
+ const timeoutMsRaw = getFlagValue(argv, "--timeout-ms");
+ const timeoutMs = timeoutMsRaw ? Number.parseInt(timeoutMsRaw, 10) : 10 * 60 * 1000;
+
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
+ throw new Error("--timeout-ms must be a positive integer");
+ }
+
+ const outDir =
+ getFlagValue(argv, "--out-dir") ??
+ path.join(process.cwd(), "playwright", "nanobanana-poc", `${mode}-${timestampLabel()}`);
+
+ return {
+ autoFill: !hasFlag(argv, "--no-auto-fill"),
+ autoSubmit: hasFlag(argv, "--auto-submit"),
+ closeOnSuccess: hasFlag(argv, "--close-on-success"),
+ headless: hasFlag(argv, "--headless"),
+ imageUrl,
+ locale,
+ mode,
+ outDir,
+ timeoutMs,
+ };
+}
+
+function matchInterestingEvent(url: string, mode: Mode): MatchedEvent | null {
+ if (url.includes("/api/img/image-safety")) {
+ return { kind: "image-safety", url };
+ }
+
+ if (url.includes("/api/presigned-urls")) {
+ return { kind: "presigned-urls", url };
+ }
+
+ if (url.includes("/api/img/result/")) {
+ return { kind: "result", url };
+ }
+
+ if (mode === "remove-background" && url.includes("/api/img/rmbg")) {
+ return { kind: "remove-background", url };
+ }
+
+ if (mode === "upscale" && url.includes("/api/img/upscale")) {
+ return { kind: "upscale", url };
+ }
+
+ return null;
+}
+
+function safeJsonParse(value: string) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+}
+
+function getTargetUrl(mode: Mode, locale: string) {
+ return `https://nanobanana.im/${locale}/${MODE_CONFIG[mode].pagePath}`;
+}
+
+async function ensureDir(dirPath: string) {
+ await mkdir(dirPath, { recursive: true });
+}
+
+async function writeJson(filePath: string, value: unknown) {
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
+}
+
+async function saveScreenshot(page: Page, outDir: string, name: string) {
+ const screenshotPath = path.join(outDir, `${name}.png`);
+ await page.screenshot({ fullPage: true, path: screenshotPath });
+ return screenshotPath;
+}
+
+async function tryClickByLabels(page: Page, role: "button" | "tab", labels: string[]) {
+ for (const label of labels) {
+ const locator = page.getByRole(role, { name: label, exact: false });
+ if ((await locator.count()) >= 1) {
+ await locator.first().click();
+ return label;
+ }
+ }
+
+ return undefined;
+}
+
+async function tryFillImageUrl(page: Page, imageUrl: string) {
+ const candidates = [
+ page.getByPlaceholder("请输入图片链接...", { exact: false }),
+ page.getByPlaceholder("Enter image URL...", { exact: false }),
+ page.locator('input[id="image-url"]'),
+ page.locator('input[type="url"]'),
+ page.locator('input[placeholder*="URL" i]'),
+ page.locator('input[placeholder*="url" i]'),
+ page.getByRole("textbox", { name: "请输入图片链接...", exact: false }),
+ page.getByRole("textbox", { name: "Enter image URL...", exact: false }),
+ ];
+
+ for (const locator of candidates) {
+ if ((await locator.count()) >= 1) {
+ await locator.first().fill(imageUrl);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+async function tryPrepareForm(page: Page, options: Options, outDir: string) {
+ if (!options.autoFill || !options.imageUrl) {
+ return {
+ imageUrlFilled: false,
+ submitClicked: false,
+ urlTabClicked: false,
+ };
+ }
+
+ const urlTabClicked = Boolean(
+ await tryClickByLabels(page, "tab", [
+ "图片链接",
+ "Image URL",
+ "Image Url",
+ "URL",
+ "Link",
+ ])
+ );
+
+ const imageUrlFilled = await tryFillImageUrl(page, options.imageUrl);
+
+ if (imageUrlFilled) {
+ await saveScreenshot(page, outDir, "02-prefilled");
+ }
+
+ let submitClicked = false;
+ if (options.autoSubmit) {
+ const clickedLabel = await tryClickByLabels(page, "button", MODE_CONFIG[options.mode].actionLabels);
+ submitClicked = Boolean(clickedLabel);
+ if (submitClicked) {
+ await saveScreenshot(page, outDir, "03-after-submit-click");
+ }
+ }
+
+ return {
+ imageUrlFilled,
+ submitClicked,
+ urlTabClicked,
+ };
+}
+
+function describeConsoleMessage(message: ConsoleMessage) {
+ return {
+ location: message.location(),
+ text: message.text(),
+ type: message.type(),
+ };
+}
+
+async function run() {
+ const options = parseArgs(process.argv.slice(2));
+ const requestsDir = path.join(options.outDir, "requests");
+ const responsesDir = path.join(options.outDir, "responses");
+ const logsDir = path.join(options.outDir, "logs");
+ const downloadsDir = path.join(options.outDir, "downloads");
+
+ await Promise.all([ensureDir(options.outDir), ensureDir(requestsDir), ensureDir(responsesDir), ensureDir(logsDir), ensureDir(downloadsDir)]);
+
+ const browser = await chromium.launch({
+ headless: options.headless,
+ });
+ const context = await browser.newContext({
+ acceptDownloads: true,
+ viewport: {
+ height: 1000,
+ width: 1440,
+ },
+ });
+ const page = await context.newPage();
+
+ const pendingWrites: Promise[] = [];
+ const downloads: string[] = [];
+ const summary: CaptureSummary = {
+ artifactDir: options.outDir,
+ consoleCount: 0,
+ downloads,
+ errorCount: 0,
+ eventCount: 0,
+ mode: options.mode,
+ pageUrl: getTargetUrl(options.mode, options.locale),
+ requestCount: 0,
+ responseCount: 0,
+ successDetected: false,
+ };
+
+ let sequence = 0;
+ let finishRequested = false;
+ let resolveFinished: (() => void) | undefined;
+ const finished = new Promise((resolve) => {
+ resolveFinished = resolve;
+ });
+
+ const requestFinish = () => {
+ if (finishRequested) {
+ return;
+ }
+ finishRequested = true;
+ resolveFinished?.();
+ };
+
+ page.on("console", (message) => {
+ summary.consoleCount += 1;
+ const record = {
+ ...describeConsoleMessage(message),
+ timestamp: new Date().toISOString(),
+ };
+ const fileName = `${String(++sequence).padStart(3, "0")}-console.json`;
+ pendingWrites.push(writeJson(path.join(logsDir, fileName), record));
+ });
+
+ page.on("pageerror", (error) => {
+ summary.errorCount += 1;
+ const fileName = `${String(++sequence).padStart(3, "0")}-pageerror.json`;
+ pendingWrites.push(
+ writeJson(path.join(logsDir, fileName), {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ timestamp: new Date().toISOString(),
+ })
+ );
+ });
+
+ page.on("download", async (download) => {
+ const suggestedName = sanitizeFilePart(download.suggestedFilename());
+ const downloadPath = path.join(downloadsDir, suggestedName);
+ downloads.push(downloadPath);
+ await download.saveAs(downloadPath);
+ const fileName = `${String(++sequence).padStart(3, "0")}-download.json`;
+ pendingWrites.push(
+ writeJson(path.join(logsDir, fileName), {
+ path: downloadPath,
+ suggestedFilename: download.suggestedFilename(),
+ timestamp: new Date().toISOString(),
+ })
+ );
+ });
+
+ page.on("request", (request: Request) => {
+ const matched = matchInterestingEvent(request.url(), options.mode);
+ if (!matched) {
+ return;
+ }
+
+ summary.eventCount += 1;
+ summary.requestCount += 1;
+ const fileName = `${String(++sequence).padStart(3, "0")}-${matched.kind}-request.json`;
+ pendingWrites.push(
+ writeJson(path.join(requestsDir, fileName), {
+ headers: request.headers(),
+ kind: matched.kind,
+ method: request.method(),
+ postData: request.postData(),
+ resourceType: request.resourceType(),
+ timestamp: new Date().toISOString(),
+ url: request.url(),
+ })
+ );
+ });
+
+ page.on("response", async (response: Response) => {
+ const matched = matchInterestingEvent(response.url(), options.mode);
+ if (!matched) {
+ return;
+ }
+
+ summary.eventCount += 1;
+ summary.responseCount += 1;
+
+ const bodyText = await response.text().catch(() => undefined);
+ const bodyJson = bodyText ? safeJsonParse(bodyText) : undefined;
+
+ if (matched.kind === "remove-background" && bodyJson?.data && typeof bodyJson.data === "string") {
+ summary.finalImageUrl = bodyJson.data;
+ summary.successDetected = true;
+ if (options.closeOnSuccess) {
+ requestFinish();
+ }
+ }
+
+ if (matched.kind === "result") {
+ const candidateUrl =
+ bodyJson?.data?.imgAfterSrc ?? bodyJson?.imgAfterSrc;
+ if (typeof candidateUrl === "string" && candidateUrl.length > 0) {
+ summary.finalImageUrl = candidateUrl;
+ summary.successDetected = true;
+ if (options.closeOnSuccess) {
+ requestFinish();
+ }
+ }
+ }
+
+ const fileName = `${String(++sequence).padStart(3, "0")}-${matched.kind}-response.json`;
+ pendingWrites.push(
+ writeJson(path.join(responsesDir, fileName), {
+ bodyJson,
+ bodyText,
+ headers: await response.allHeaders(),
+ kind: matched.kind,
+ ok: response.ok(),
+ status: response.status(),
+ statusText: response.statusText(),
+ timestamp: new Date().toISOString(),
+ url: response.url(),
+ })
+ );
+ });
+
+ const timeout = setTimeout(() => {
+ console.log(`Session timed out after ${options.timeoutMs}ms`);
+ requestFinish();
+ }, options.timeoutMs);
+
+ page.on("close", () => {
+ requestFinish();
+ });
+
+ const pageUrl = getTargetUrl(options.mode, options.locale);
+ console.log(`Opening ${pageUrl}`);
+ console.log(`Artifacts will be written to ${options.outDir}`);
+ console.log("Manual steps are still expected for Turnstile, login, and any final confirmation.");
+
+ await page.goto(pageUrl, { waitUntil: "domcontentloaded" });
+ await saveScreenshot(page, options.outDir, "01-loaded");
+ await writeJson(path.join(options.outDir, "storage-state-initial.json"), await context.storageState());
+
+ const formPrep = await tryPrepareForm(page, options, options.outDir);
+ await writeJson(path.join(options.outDir, "session-plan.json"), {
+ ...formPrep,
+ mode: options.mode,
+ options: {
+ autoFill: options.autoFill,
+ autoSubmit: options.autoSubmit,
+ closeOnSuccess: options.closeOnSuccess,
+ headless: options.headless,
+ imageUrl: options.imageUrl,
+ locale: options.locale,
+ timeoutMs: options.timeoutMs,
+ },
+ pageUrl,
+ startedAt: new Date().toISOString(),
+ });
+
+ console.log("Browser is ready.");
+ if (!formPrep.imageUrlFilled) {
+ console.log("The script did not prefill the image URL. Fill the form manually if needed.");
+ }
+ if (options.autoSubmit && !formPrep.submitClicked) {
+ console.log("The script did not find the primary action button. Click it manually.");
+ }
+ console.log("Close the browser window when you are done, or wait for timeout.");
+
+ process.on("SIGINT", () => {
+ console.log("Received SIGINT, finishing capture...");
+ requestFinish();
+ });
+
+ await finished;
+ clearTimeout(timeout);
+
+ await saveScreenshot(page, options.outDir, "99-final").catch(() => undefined);
+ await writeJson(path.join(options.outDir, "storage-state-final.json"), await context.storageState());
+ await Promise.allSettled(pendingWrites);
+
+ await writeJson(path.join(options.outDir, "summary.json"), {
+ ...summary,
+ finishedAt: new Date().toISOString(),
+ });
+
+ await context.close();
+ await browser.close();
+
+ console.log("");
+ console.log("POC finished.");
+ console.log(JSON.stringify(summary, null, 2));
+}
+
+run().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});