代码提交
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
release/
|
||||
out/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Electron
|
||||
*.asar
|
||||
EOF
|
||||
node_modules
|
||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
# 打包前让 pnpm 扁平化 node_modules,避免 asar 里缺嵌套依赖
|
||||
shamefully-hoist=true
|
||||
BIN
01/004.jpg
Normal file
BIN
01/004.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 644 KiB |
14
config_nano_banana.json
Normal file
14
config_nano_banana.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"api_key": "i0jpgqNGp1h92yKDAkBo3G6N79",
|
||||
"poll_interval_seconds": 10,
|
||||
"prompt": "扩展参考图片内容,使其尺寸为4K,比例为16:9,清晰度为高清,细节丰富,清晰对焦,高对比度,专业摄影",
|
||||
"size": "4K",
|
||||
"aspect-ratio": "16:9",
|
||||
"reference_urls": [],
|
||||
"r2_account_id": "11d255a38a61526815d6c95a713bff12",
|
||||
"r2_access_key_id": "f5d1db252f937b93fe84ba92329f5a7b",
|
||||
"r2_secret_access_key": "b57b47be255263885ca37d0ec768d78ed1d07f3bf16d269e464e1d19166df63c",
|
||||
"r2_bucket": "703",
|
||||
"r2_public_url": "https://pub-cbc7501389e744aabf4dd3814483a61c.r2.dev"
|
||||
}
|
||||
|
||||
241
core/generator.js
Normal file
241
core/generator.js
Normal file
@@ -0,0 +1,241 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const axios = require("axios");
|
||||
const { S3Client, PutObjectCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3");
|
||||
|
||||
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
|
||||
const CONTENT_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".gif": "image/gif",
|
||||
};
|
||||
|
||||
function isR2Configured(config) {
|
||||
return (
|
||||
config &&
|
||||
config.r2_account_id &&
|
||||
config.r2_access_key_id &&
|
||||
config.r2_secret_access_key &&
|
||||
config.r2_bucket &&
|
||||
config.r2_public_url
|
||||
);
|
||||
}
|
||||
|
||||
function createR2Client(config) {
|
||||
const endpoint = `https://${config.r2_account_id}.r2.cloudflarestorage.com`;
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.r2_access_key_id,
|
||||
secretAccessKey: config.r2_secret_access_key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 将指定本地文件路径上传到 R2,返回 { urls, keys } */
|
||||
async function uploadRefFilesToR2({ client, bucket, publicBaseUrl, filePaths }) {
|
||||
const runId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
||||
const prefix = `ref/${runId}`;
|
||||
const urls = [];
|
||||
const keys = [];
|
||||
const base = (publicBaseUrl || "").replace(/\/$/, "");
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!IMAGE_EXTS.has(ext)) continue;
|
||||
const name = path.basename(filePath);
|
||||
const key = `${prefix}/${name}`;
|
||||
const body = fs.readFileSync(filePath);
|
||||
const contentType = CONTENT_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
urls.push(`${base}/${key}`);
|
||||
keys.push(key);
|
||||
}
|
||||
return { urls, keys };
|
||||
}
|
||||
|
||||
async function deleteR2Objects({ client, bucket, keys }) {
|
||||
if (!keys || keys.length === 0) return;
|
||||
await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: keys.map((Key) => ({ Key })) },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function extractImageUrls(dataObj) {
|
||||
if (!dataObj) return [];
|
||||
const candidates = [];
|
||||
if (Array.isArray(dataObj)) {
|
||||
dataObj.forEach((v) => v && candidates.push(String(v)));
|
||||
} else if (typeof dataObj === "object") {
|
||||
for (const key of ["img_url", "img_urls", "image_urls", "urls", "images", "result"]) {
|
||||
const val = dataObj[key];
|
||||
if (!val) continue;
|
||||
if (typeof val === "string") candidates.push(val);
|
||||
else if (Array.isArray(val)) val.forEach((v) => v && candidates.push(String(v)));
|
||||
}
|
||||
}
|
||||
const seen = new Set();
|
||||
return candidates.filter((u) => !seen.has(u) && seen.add(u));
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次生图流程,支持进度回调
|
||||
* @param {object} config - 完整配置(api_key, r2_*, api_create_url, api_result_url, poll_interval_seconds, max_wait_seconds 等)
|
||||
* @param {object} options - { prompt, size, aspectRatio, refFilePaths [], saveDir, extraParams {} }
|
||||
* @param {function} onProgress - (step, message) => void
|
||||
*/
|
||||
async function runGeneration(config, options, onProgress = () => {}) {
|
||||
const apiKey = config.api_key || process.env.WUYIN_API_KEY;
|
||||
if (!apiKey) throw new Error("未配置 API 密钥");
|
||||
|
||||
const prompt = (options.prompt || "").trim();
|
||||
if (!prompt) throw new Error("请输入提示词");
|
||||
|
||||
const createUrl = config.api_create_url || "https://api.wuyinkeji.com/api/async/image_nanoBanana2";
|
||||
const resultUrl = config.api_result_url || "https://api.wuyinkeji.com/api/async/detail";
|
||||
const pollIntervalMs = (config.poll_interval_seconds || 5) * 1000;
|
||||
const maxWaitMs = (config.max_wait_seconds || 300) * 1000;
|
||||
const saveDir = options.saveDir || config.default_save_dir || path.join(process.cwd(), "save");
|
||||
const extraParams = options.extraParams || config.extra_params || {};
|
||||
|
||||
let r2KeysToDelete = [];
|
||||
let combinedUrls = [];
|
||||
|
||||
if (isR2Configured(config) && options.refFilePaths && options.refFilePaths.length > 0) {
|
||||
onProgress("upload", "正在上传参考图到 R2…");
|
||||
const client = createR2Client(config);
|
||||
const { urls, keys } = await uploadRefFilesToR2({
|
||||
client,
|
||||
bucket: config.r2_bucket,
|
||||
publicBaseUrl: config.r2_public_url,
|
||||
filePaths: options.refFilePaths,
|
||||
});
|
||||
r2KeysToDelete = keys;
|
||||
combinedUrls = urls;
|
||||
onProgress("upload_done", `已上传 ${urls.length} 张参考图`);
|
||||
}
|
||||
|
||||
try {
|
||||
onProgress("create", "正在创建生图任务…");
|
||||
const body = {
|
||||
prompt,
|
||||
size: options.size || "1K",
|
||||
aspectRatio: options.aspectRatio || "auto",
|
||||
key: apiKey,
|
||||
...extraParams,
|
||||
};
|
||||
if (combinedUrls.length > 0) body.urls = combinedUrls;
|
||||
|
||||
const createResp = await axios.post(createUrl, body, {
|
||||
headers: {
|
||||
Authorization: apiKey,
|
||||
"Content-Type": "application/json;charset=utf-8;",
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
const payload = createResp.data;
|
||||
if (!payload || payload.code !== 200) {
|
||||
throw new Error("创建任务失败: " + (payload?.msg || JSON.stringify(payload)));
|
||||
}
|
||||
const taskId = (payload.data || {}).id;
|
||||
if (!taskId) throw new Error("返回数据中缺少任务 id");
|
||||
|
||||
onProgress("poll", "正在等待生成结果…");
|
||||
const imageUrls = await pollResult({
|
||||
apiKey,
|
||||
taskId,
|
||||
resultUrl,
|
||||
pollIntervalMs,
|
||||
maxWaitMs,
|
||||
});
|
||||
|
||||
if (!imageUrls || imageUrls.length === 0) {
|
||||
throw new Error("任务完成但未获取到图片 URL");
|
||||
}
|
||||
|
||||
onProgress("download", `正在保存 ${imageUrls.length} 张图片…`);
|
||||
if (!fs.existsSync(saveDir)) fs.mkdirSync(saveDir, { recursive: true });
|
||||
const savedPaths = [];
|
||||
for (let i = 0; i < imageUrls.length; i++) {
|
||||
const url = imageUrls[i];
|
||||
const resp = await axios.get(url, { responseType: "arraybuffer", timeout: 60000 });
|
||||
let ext = ".jpg";
|
||||
for (const e of [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]) {
|
||||
if (url.toLowerCase().includes(e)) {
|
||||
ext = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const filePath = path.join(saveDir, `${taskId}_${i + 1}${ext}`);
|
||||
fs.writeFileSync(filePath, resp.data);
|
||||
savedPaths.push(filePath);
|
||||
}
|
||||
onProgress("done", `已保存到 ${saveDir}`);
|
||||
return { taskId, saveDir, count: imageUrls.length, savedPaths };
|
||||
} finally {
|
||||
if (r2KeysToDelete.length > 0 && isR2Configured(config)) {
|
||||
try {
|
||||
const client = createR2Client(config);
|
||||
await deleteR2Objects({ client, bucket: config.r2_bucket, keys: r2KeysToDelete });
|
||||
onProgress("cleanup", "已删除 R2 临时参考图");
|
||||
} catch (e) {
|
||||
onProgress("cleanup_error", "删除 R2 参考图失败: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pollResult({ apiKey, taskId, resultUrl, pollIntervalMs, maxWaitMs }) {
|
||||
const start = Date.now();
|
||||
const headers = {
|
||||
Authorization: apiKey,
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8;",
|
||||
};
|
||||
while (true) {
|
||||
const resp = await axios.get(resultUrl, {
|
||||
params: { key: apiKey, id: taskId },
|
||||
headers,
|
||||
timeout: 30000,
|
||||
});
|
||||
const payload = resp.data;
|
||||
if (!payload || payload.code !== 200) {
|
||||
throw new Error(`查询失败: ${payload?.msg || "未知错误"}`);
|
||||
}
|
||||
const data = payload.data || {};
|
||||
const status = data.status;
|
||||
if (status === 2) return extractImageUrls(data);
|
||||
if (status === 3) throw new Error("任务生成失败: " + (data.message || payload.msg || "未知原因"));
|
||||
const urls = extractImageUrls(data);
|
||||
if (urls.length > 0) return urls;
|
||||
if (Date.now() - start > maxWaitMs) {
|
||||
throw new Error("等待结果超时");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runGeneration,
|
||||
isR2Configured,
|
||||
uploadRefFilesToR2,
|
||||
deleteR2Objects,
|
||||
createR2Client,
|
||||
};
|
||||
166
electron/main.js
Normal file
166
electron/main.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require("electron");
|
||||
const { runGeneration } = require("../core/generator");
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
||||
const configPath = path.join(app.getPath("userData"), "config_nano_banana.json");
|
||||
|
||||
const defaultConfig = {
|
||||
api_key: "",
|
||||
api_create_url: "https://api.wuyinkeji.com/api/async/image_nanoBanana2",
|
||||
api_result_url: "https://api.wuyinkeji.com/api/async/detail",
|
||||
poll_interval_seconds: 10,
|
||||
max_wait_seconds: 300,
|
||||
r2_account_id: "",
|
||||
r2_access_key_id: "",
|
||||
r2_secret_access_key: "",
|
||||
r2_bucket: "",
|
||||
r2_public_url: "",
|
||||
default_save_dir: "",
|
||||
extra_params: [],
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
return { ...defaultConfig, ...data };
|
||||
} catch {
|
||||
const fallback = path.join(app.getAppPath(), "config_nano_banana.json");
|
||||
try {
|
||||
if (fs.existsSync(fallback)) {
|
||||
const raw = fs.readFileSync(fallback, "utf8");
|
||||
const data = JSON.parse(raw);
|
||||
return { ...defaultConfig, ...data };
|
||||
}
|
||||
} catch (_) {}
|
||||
return { ...defaultConfig };
|
||||
}
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
minWidth: 640,
|
||||
minHeight: 480,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
title: "NanoBanana 生图",
|
||||
show: false,
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL("http://localhost:5173");
|
||||
win.webContents.openDevTools();
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, "../dist/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle("getConfig", () => loadConfig());
|
||||
|
||||
ipcMain.handle("saveConfig", (_e, config) => {
|
||||
const current = loadConfig();
|
||||
const merged = { ...current, ...config };
|
||||
saveConfig(merged);
|
||||
return loadConfig();
|
||||
});
|
||||
|
||||
ipcMain.handle("selectDirectory", async (_e, defaultPath) => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
defaultPath: defaultPath || undefined,
|
||||
});
|
||||
if (canceled || !filePaths.length) return null;
|
||||
return filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle("selectReferenceFiles", async () => {
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ["openFile", "multiSelections"],
|
||||
filters: [
|
||||
{ name: "图片", extensions: ["jpg", "jpeg", "png", "webp", "bmp", "gif"] },
|
||||
],
|
||||
});
|
||||
if (canceled || !filePaths.length) return [];
|
||||
return filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle("getImageDataUrl", async (_e, filePath) => {
|
||||
if (!filePath || typeof filePath !== "string") return null;
|
||||
try {
|
||||
const ext = filePath.replace(/^.*\./, "").toLowerCase();
|
||||
const mime = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", bmp: "image/bmp", gif: "image/gif" }[ext] || "image/jpeg";
|
||||
const buf = fs.readFileSync(filePath);
|
||||
return `data:${mime};base64,${buf.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"startGeneration",
|
||||
async (
|
||||
_e,
|
||||
{
|
||||
prompt,
|
||||
size,
|
||||
aspectRatio,
|
||||
refFilePaths,
|
||||
saveDir,
|
||||
extraParams,
|
||||
}
|
||||
) => {
|
||||
const config = loadConfig();
|
||||
const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
|
||||
const send = (step, message) => {
|
||||
if (win && !win.isDestroyed()) win.webContents.send("generationProgress", { step, message });
|
||||
};
|
||||
const extra = Array.isArray(config.extra_params)
|
||||
? config.extra_params.reduce((acc, { key, value }) => {
|
||||
if (key != null && key !== "") acc[key] = value;
|
||||
return acc;
|
||||
}, {})
|
||||
: {};
|
||||
const mergedExtra = { ...extra, ...(extraParams || {}) };
|
||||
try {
|
||||
const result = await runGeneration(
|
||||
config,
|
||||
{
|
||||
prompt,
|
||||
size: size || config.size || "1K",
|
||||
aspectRatio: aspectRatio || config.aspectRatio || "auto",
|
||||
refFilePaths: refFilePaths || [],
|
||||
saveDir: saveDir || config.default_save_dir,
|
||||
extraParams: Object.keys(mergedExtra).length ? mergedExtra : undefined,
|
||||
},
|
||||
send
|
||||
);
|
||||
return { success: true, ...result };
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
);
|
||||
15
electron/preload.js
Normal file
15
electron/preload.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getConfig: () => ipcRenderer.invoke("getConfig"),
|
||||
saveConfig: (config) => ipcRenderer.invoke("saveConfig", config),
|
||||
selectDirectory: (defaultPath) => ipcRenderer.invoke("selectDirectory", defaultPath),
|
||||
selectReferenceFiles: () => ipcRenderer.invoke("selectReferenceFiles"),
|
||||
getImageDataUrl: (filePath) => ipcRenderer.invoke("getImageDataUrl", filePath),
|
||||
startGeneration: (options) => ipcRenderer.invoke("startGeneration", options),
|
||||
onGenerationProgress: (cb) => {
|
||||
const handler = (_e, data) => cb(data);
|
||||
ipcRenderer.on("generationProgress", handler);
|
||||
return () => ipcRenderer.removeListener("generationProgress", handler);
|
||||
},
|
||||
});
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NanoBanana 生图</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
389
nano_banana_client.js
Normal file
389
nano_banana_client.js
Normal file
@@ -0,0 +1,389 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const axios = require("axios");
|
||||
const { S3Client, PutObjectCommand, DeleteObjectsCommand } = require("@aws-sdk/client-s3");
|
||||
|
||||
// NanoBanana2 生图接口,文档 https://api.wuyinkeji.com/doc/65
|
||||
const NANOBANANA_URL = "https://api.wuyinkeji.com/api/async/image_nanoBanana2";
|
||||
// 全模型通用结果详情接口,文档见 https://api.wuyinkeji.com/doc/47
|
||||
const RESULT_DETAIL_URL = "https://api.wuyinkeji.com/api/async/detail";
|
||||
const CONFIG_FILE_NAME = "config_nano_banana.json";
|
||||
|
||||
function loadConfig(baseDir) {
|
||||
const configPath = path.join(baseDir, CONFIG_FILE_NAME);
|
||||
if (!fs.existsSync(configPath)) return {};
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, "utf8");
|
||||
return JSON.parse(raw) || {};
|
||||
} catch (e) {
|
||||
console.log("读取配置文件失败,将忽略配置文件。错误:", e.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function ensureApiKey(config) {
|
||||
const key = config.api_key || process.env.WUYIN_API_KEY;
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"未找到 API 密钥,请在 config_nano_banana.json 中填写 api_key," +
|
||||
"或设置环境变量 WUYIN_API_KEY。"
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function ensurePrompt(config) {
|
||||
const prompt = config.prompt;
|
||||
if (!prompt || !prompt.trim()) {
|
||||
throw new Error(
|
||||
"未在 config_nano_banana.json 中找到有效的 prompt,请先在文件中填写默认提示词。"
|
||||
);
|
||||
}
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/** 检查是否配置了 R2 图床(用于 01 参考图上传) */
|
||||
function isR2Configured(config) {
|
||||
return (
|
||||
config &&
|
||||
config.r2_account_id &&
|
||||
config.r2_access_key_id &&
|
||||
config.r2_secret_access_key &&
|
||||
config.r2_bucket &&
|
||||
config.r2_public_url
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建 R2(S3 兼容)客户端 */
|
||||
function createR2Client(config) {
|
||||
const accountId = config.r2_account_id;
|
||||
const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
|
||||
return new S3Client({
|
||||
region: "auto",
|
||||
endpoint,
|
||||
// 对不少 S3 兼容服务更稳:强制使用 path-style
|
||||
// 形如 https://<accountId>.r2.cloudflarestorage.com/<bucket>/<key>
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.r2_access_key_id,
|
||||
secretAccessKey: config.r2_secret_access_key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 01 目录中的参考图上传到 R2,返回可公网访问的 URL 列表及本次上传的 key 列表(用于事后删除)
|
||||
*/
|
||||
async function uploadRefImagesToR2({ client, bucket, publicBaseUrl, inputDir, maxFiles = 14 }) {
|
||||
if (!fs.existsSync(inputDir)) return { urls: [], keys: [] };
|
||||
|
||||
const exts = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
|
||||
const files = fs
|
||||
.readdirSync(inputDir)
|
||||
.map((name) => path.join(inputDir, name))
|
||||
.filter((p) => exts.has(path.extname(p).toLowerCase()))
|
||||
.slice(0, maxFiles);
|
||||
|
||||
const runId = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
||||
const prefix = `ref/${runId}`;
|
||||
const urls = [];
|
||||
const keys = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const name = path.basename(filePath);
|
||||
const key = `${prefix}/${name}`;
|
||||
const body = fs.readFileSync(filePath);
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const contentType = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".bmp": "image/bmp", ".gif": "image/gif" }[ext] || "application/octet-stream";
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
const base = publicBaseUrl.replace(/\/$/, "");
|
||||
urls.push(`${base}/${key}`);
|
||||
keys.push(key);
|
||||
}
|
||||
return { urls, keys };
|
||||
}
|
||||
|
||||
/** 从 R2 删除指定 key 的对象 */
|
||||
async function deleteR2Objects({ client, bucket, keys }) {
|
||||
if (!keys || keys.length === 0) return;
|
||||
await client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: bucket,
|
||||
Delete: { Objects: keys.map((Key) => ({ Key })) },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function collectReferenceImages(inputDir, maxFiles = 14) {
|
||||
if (!fs.existsSync(inputDir)) return [];
|
||||
|
||||
const exts = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
|
||||
const files = fs
|
||||
.readdirSync(inputDir)
|
||||
.map((name) => path.join(inputDir, name))
|
||||
.filter((p) => exts.has(path.extname(p).toLowerCase()))
|
||||
.slice(0, maxFiles);
|
||||
|
||||
const result = [];
|
||||
for (const file of files) {
|
||||
const buf = fs.readFileSync(file);
|
||||
result.push(buf.toString("base64"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createTask({ apiKey, prompt, size, aspectRatio, refB64List }) {
|
||||
const headers = {
|
||||
Authorization: apiKey,
|
||||
"Content-Type": "application/json;charset=utf-8;",
|
||||
};
|
||||
|
||||
const body = {
|
||||
prompt,
|
||||
size,
|
||||
aspectRatio,
|
||||
key: apiKey,
|
||||
};
|
||||
if (refB64List && refB64List.length > 0) {
|
||||
body.urls = refB64List;
|
||||
}
|
||||
|
||||
const resp = await axios.post(NANOBANANA_URL, body, {
|
||||
headers,
|
||||
timeout: 30000,
|
||||
});
|
||||
const payload = resp.data;
|
||||
|
||||
if (!payload || payload.code !== 200) {
|
||||
throw new Error("创建任务失败: " + JSON.stringify(payload));
|
||||
}
|
||||
|
||||
const data = payload.data || {};
|
||||
const taskId = data.id;
|
||||
if (!taskId) {
|
||||
throw new Error("返回数据中缺少任务 id: " + JSON.stringify(payload));
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
function extractImageUrls(dataObj) {
|
||||
if (!dataObj) return [];
|
||||
|
||||
const candidates = [];
|
||||
if (Array.isArray(dataObj)) {
|
||||
for (const v of dataObj) {
|
||||
if (v) candidates.push(String(v));
|
||||
}
|
||||
} else if (typeof dataObj === "object") {
|
||||
// 兼容多种字段命名,包括结果详情返回的 result 数组
|
||||
for (const key of ["img_url", "img_urls", "image_urls", "urls", "images", "result"]) {
|
||||
if (dataObj[key]) {
|
||||
const val = dataObj[key];
|
||||
if (typeof val === "string") {
|
||||
candidates.push(val);
|
||||
} else if (Array.isArray(val)) {
|
||||
for (const v of val) {
|
||||
if (v) candidates.push(String(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const url of candidates) {
|
||||
if (!seen.has(url)) {
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function queryResult({ apiKey, taskId, pollIntervalMs, maxWaitMs }) {
|
||||
const headers = {
|
||||
Authorization: apiKey,
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8;",
|
||||
};
|
||||
const params = { key: apiKey, id: taskId };
|
||||
const start = Date.now();
|
||||
|
||||
while (true) {
|
||||
const resp = await axios.get(RESULT_DETAIL_URL, {
|
||||
params,
|
||||
headers,
|
||||
timeout: 30000,
|
||||
});
|
||||
const payload = resp.data;
|
||||
|
||||
if (!payload || payload.code !== 200) {
|
||||
const msg = (payload && payload.msg) || "未知错误";
|
||||
throw new Error(`查询任务失败: code=${payload && payload.code}, msg=${msg}`);
|
||||
}
|
||||
|
||||
const data = payload.data || {};
|
||||
const status = data.status;
|
||||
|
||||
if (status === 2) {
|
||||
const urls = extractImageUrls(data);
|
||||
return urls;
|
||||
}
|
||||
if (status === 3) {
|
||||
const reason = data.message || payload.msg || "未知原因";
|
||||
throw new Error("任务生成失败: " + reason);
|
||||
}
|
||||
|
||||
const urls = extractImageUrls(data);
|
||||
if (urls.length > 0) return urls;
|
||||
|
||||
if (Date.now() - start > maxWaitMs) {
|
||||
throw new Error("等待任务结果超时,请稍后在控制台或结果查询接口自行确认。");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImages({ urls, outputDir, taskId }) {
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const url = urls[i];
|
||||
try {
|
||||
const resp = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
let ext = ".jpg";
|
||||
for (const cand of [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"]) {
|
||||
if (url.toLowerCase().includes(cand)) {
|
||||
ext = cand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = path.join(outputDir, `${taskId}_${i + 1}${ext}`);
|
||||
fs.writeFileSync(filename, resp.data);
|
||||
console.log("已保存:", filename);
|
||||
} catch (e) {
|
||||
console.log("下载失败:", url, "| 错误:", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = __dirname;
|
||||
const config = loadConfig(baseDir);
|
||||
|
||||
const apiKey = ensureApiKey(config);
|
||||
const prompt = ensurePrompt(config);
|
||||
|
||||
const size = config.size || "1K";
|
||||
const aspectRatio = config.aspectRatio || "auto";
|
||||
const pollIntervalMs = (config.poll_interval_seconds || 5) * 1000;
|
||||
const maxWaitMs = (config.max_wait_seconds || 300) * 1000;
|
||||
|
||||
const inputDir = path.join(baseDir, config.input_dir || "01");
|
||||
const outputDir = path.join(baseDir, config.output_dir || "save");
|
||||
|
||||
let combinedUrls = [];
|
||||
let r2KeysToDelete = [];
|
||||
const useR2 = isR2Configured(config);
|
||||
|
||||
if (useR2) {
|
||||
// 使用 R2 图床:将 01 目录的图片上传到 R2,用返回的 URL 作为参考图
|
||||
const client = createR2Client(config);
|
||||
const { urls: r2Urls, keys } = await uploadRefImagesToR2({
|
||||
client,
|
||||
bucket: config.r2_bucket,
|
||||
publicBaseUrl: config.r2_public_url,
|
||||
inputDir,
|
||||
maxFiles: 14,
|
||||
});
|
||||
r2KeysToDelete = keys;
|
||||
if (r2Urls.length > 0) {
|
||||
console.log(`已上传 ${r2Urls.length} 张参考图到 R2 图床。`);
|
||||
}
|
||||
combinedUrls = [...r2Urls];
|
||||
} else {
|
||||
// 未配置 R2:本地 01 转为 base64
|
||||
const refB64List = await collectReferenceImages(inputDir);
|
||||
if (refB64List.length > 0) {
|
||||
console.log(`已从目录 ${inputDir} 读取 ${refB64List.length} 张本地参考图(base64)。`);
|
||||
}
|
||||
combinedUrls = [...refB64List];
|
||||
}
|
||||
|
||||
// 配置中的参考图 URL(直接透传给接口)
|
||||
let extraUrls = [];
|
||||
if (typeof config.reference_urls === "string") {
|
||||
extraUrls = [config.reference_urls];
|
||||
} else if (Array.isArray(config.reference_urls)) {
|
||||
extraUrls = config.reference_urls.filter((u) => !!u).map(String);
|
||||
}
|
||||
combinedUrls = [...combinedUrls, ...extraUrls];
|
||||
|
||||
if (extraUrls.length > 0) {
|
||||
console.log(`已从配置文件读取 ${extraUrls.length} 个参考图 URL。`);
|
||||
}
|
||||
if (combinedUrls.length === 0) {
|
||||
console.log(`未找到任何参考图,将仅根据提示词生成。`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("正在创建 NanoBanana2 任务...");
|
||||
const taskId = await createTask({
|
||||
apiKey,
|
||||
prompt,
|
||||
size,
|
||||
aspectRatio,
|
||||
refB64List: combinedUrls,
|
||||
});
|
||||
console.log("任务已创建,任务 id:", taskId);
|
||||
|
||||
console.log("开始轮询任务结果...");
|
||||
const urls = await queryResult({
|
||||
apiKey,
|
||||
taskId,
|
||||
pollIntervalMs,
|
||||
maxWaitMs,
|
||||
});
|
||||
|
||||
if (!urls || urls.length === 0) {
|
||||
console.log("任务完成,但未在结果中找到图片 URL,请登录速创API控制台或检查结果详情接口。");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`任务完成,共获取到 ${urls.length} 个图片 URL,开始下载...`);
|
||||
await downloadImages({ urls, outputDir, taskId });
|
||||
console.log("全部处理完成。");
|
||||
} finally {
|
||||
if (useR2 && r2KeysToDelete.length > 0) {
|
||||
try {
|
||||
const client = createR2Client(config);
|
||||
await deleteR2Objects({ client, bucket: config.r2_bucket, keys: r2KeysToDelete });
|
||||
console.log("已删除本次上传的 R2 参考图。");
|
||||
} catch (e) {
|
||||
console.warn("删除 R2 参考图失败:", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("执行出错:", err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
343
nano_banana_client.py
Normal file
343
nano_banana_client.py
Normal file
@@ -0,0 +1,343 @@
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# NanoBanana2 生图接口,文档 https://api.wuyinkeji.com/doc/65
|
||||
NANOBANANA_URL = "https://api.wuyinkeji.com/api/async/image_nanoBanana2"
|
||||
# 全模型通用结果详情接口,文档见 https://api.wuyinkeji.com/doc/47
|
||||
RESULT_DETAIL_URL = "https://api.wuyinkeji.com/api/async/detail"
|
||||
CONFIG_FILE_NAME = "config_nano_banana.json"
|
||||
|
||||
|
||||
def load_config(base_dir: Path) -> dict:
|
||||
"""
|
||||
从配置文件中读取默认参数(如 api_key、prompt 等)。
|
||||
"""
|
||||
config_path = base_dir / CONFIG_FILE_NAME
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f) or {}
|
||||
except Exception as e:
|
||||
print(f"读取配置文件失败,将忽略配置文件。错误: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def load_api_key(cli_key: Optional[str], config: dict) -> str:
|
||||
"""
|
||||
优先级:命令行参数 > 配置文件 > 环境变量 WUYIN_API_KEY。
|
||||
"""
|
||||
key_from_config = config.get("api_key") if isinstance(config, dict) else None
|
||||
key = cli_key or key_from_config or os.environ.get("WUYIN_API_KEY")
|
||||
if not key:
|
||||
raise SystemExit(
|
||||
"未找到 API 密钥,请在 config_nano_banana.json 中填写 api_key,"
|
||||
"或通过参数 --api-key 传入,或在环境变量 WUYIN_API_KEY 中设置。"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def collect_reference_images(input_dir: Path, max_files: int = 14) -> List[str]:
|
||||
"""
|
||||
从指定目录读取参考图,返回 base64 字符串数组。
|
||||
"""
|
||||
if not input_dir.exists():
|
||||
return []
|
||||
|
||||
exts = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
|
||||
files = [p for p in sorted(input_dir.iterdir()) if p.suffix.lower() in exts]
|
||||
files = files[:max_files]
|
||||
|
||||
encoded_list: List[str] = []
|
||||
for p in files:
|
||||
with p.open("rb") as f:
|
||||
b64 = base64.b64encode(f.read()).decode("ascii")
|
||||
encoded_list.append(b64)
|
||||
return encoded_list
|
||||
|
||||
|
||||
def create_task(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
size: str,
|
||||
aspect_ratio: str,
|
||||
ref_b64_list: List[str],
|
||||
) -> str:
|
||||
"""
|
||||
调用 NanoBanana2 异步图片生成接口,返回任务 id。
|
||||
使用 JSON 请求体,urls 字段为数组,避免类型错误。
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": api_key,
|
||||
"Content-Type": "application/json;charset=utf-8;",
|
||||
}
|
||||
|
||||
data: dict = {
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"key": api_key,
|
||||
}
|
||||
|
||||
if ref_b64_list:
|
||||
# urls 为字符串数组,元素可以是 URL 或 Base64
|
||||
data["urls"] = ref_b64_list
|
||||
|
||||
resp = requests.post(NANOBANANA_URL, json=data, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
|
||||
if payload.get("code") != 200:
|
||||
raise RuntimeError(f"创建任务失败: {payload}")
|
||||
|
||||
data_obj = payload.get("data") or {}
|
||||
task_id = data_obj.get("id")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"返回数据中缺少任务 id: {payload}")
|
||||
|
||||
return task_id
|
||||
|
||||
|
||||
def extract_image_urls(data_obj) -> List[str]:
|
||||
"""
|
||||
尝试从结果详情 data 字段中提取图片 URL,兼容多种字段命名。
|
||||
"""
|
||||
if not data_obj:
|
||||
return []
|
||||
|
||||
# 常见字段名兼容
|
||||
candidates = []
|
||||
if isinstance(data_obj, dict):
|
||||
# 兼容多种字段命名,包括结果详情返回的 result 数组
|
||||
for key in ("img_url", "img_urls", "image_urls", "urls", "images", "result"):
|
||||
if key in data_obj and data_obj[key]:
|
||||
val = data_obj[key]
|
||||
if isinstance(val, str):
|
||||
candidates.append(val)
|
||||
elif isinstance(val, list):
|
||||
candidates.extend(str(v) for v in val if v)
|
||||
elif isinstance(data_obj, list):
|
||||
candidates.extend(str(v) for v in data_obj if v)
|
||||
|
||||
# 去重
|
||||
seen = set()
|
||||
result: List[str] = []
|
||||
for u in candidates:
|
||||
if u not in seen:
|
||||
seen.add(u)
|
||||
result.append(u)
|
||||
return result
|
||||
|
||||
|
||||
def query_result(
|
||||
api_key: str,
|
||||
task_id: str,
|
||||
poll_interval: float = 5.0,
|
||||
max_wait: float = 300.0,
|
||||
) -> List[str]:
|
||||
"""
|
||||
轮询结果详情接口,直到任务完成或超时,返回图片 URL 列表。
|
||||
"""
|
||||
start = time.time()
|
||||
params = {"key": api_key, "id": task_id}
|
||||
headers = {
|
||||
"Authorization": api_key,
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8;",
|
||||
}
|
||||
|
||||
while True:
|
||||
resp = requests.get(RESULT_DETAIL_URL, params=params, headers=headers, timeout=30)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
|
||||
if payload.get("code") != 200:
|
||||
# 有些平台在排队/处理中阶段也返回 code=200,这里仅在明确失败时直接抛出
|
||||
msg = payload.get("msg") or "未知错误"
|
||||
raise RuntimeError(f"查询任务失败: code={payload.get('code')}, msg={msg}")
|
||||
|
||||
data_obj = payload.get("data") or {}
|
||||
status = data_obj.get("status")
|
||||
|
||||
# 约定:0 排队中,1 生成中,2 成功,3 失败
|
||||
if status == 2:
|
||||
urls = extract_image_urls(data_obj)
|
||||
if urls:
|
||||
return urls
|
||||
# 没有找到 URL 但状态为成功,直接返回空列表交由上层处理
|
||||
return []
|
||||
elif status == 3:
|
||||
reason = data_obj.get("fail_reason") or payload.get("msg") or "未知原因"
|
||||
raise RuntimeError(f"任务生成失败: {reason}")
|
||||
|
||||
# 如果未提供 status,但已经带有图片 URL,也视为成功
|
||||
urls = extract_image_urls(data_obj)
|
||||
if urls:
|
||||
return urls
|
||||
|
||||
if time.time() - start > max_wait:
|
||||
raise TimeoutError("等待任务结果超时,请稍后在控制台或结果查询接口自行确认。")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
def download_images(urls: List[str], output_dir: Path, task_id: str) -> None:
|
||||
"""
|
||||
将图片 URL 下载到指定目录。
|
||||
"""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for idx, url in enumerate(urls, start=1):
|
||||
try:
|
||||
resp = requests.get(url, stream=True, timeout=60)
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
print(f"下载失败: {url} | 错误: {e}")
|
||||
continue
|
||||
|
||||
# 根据 URL 粗略推断扩展名
|
||||
ext = ".jpg"
|
||||
for cand in (".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"):
|
||||
if cand in url.lower():
|
||||
ext = cand
|
||||
break
|
||||
|
||||
filename = output_dir / f"{task_id}_{idx}{ext}"
|
||||
with filename.open("wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
|
||||
print(f"已保存: {filename}")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"NanoBanana2 调用脚本:"
|
||||
"读取 ./01 目录中的参考图,生成图片并保存到 ./save 目录。"
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--prompt",
|
||||
required=False,
|
||||
help="提示词,可在此修改,也可留空在运行时输入。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--size",
|
||||
default="1K",
|
||||
choices=["1K", "2K", "4K"],
|
||||
help="输出图像大小,支持 1K/2K/4K,默认 1K。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--aspect-ratio",
|
||||
default="auto",
|
||||
help="输出图像比例,如 auto、1:1、16:9 等,默认 auto。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-dir",
|
||||
default="01",
|
||||
help="参考图目录,相对于当前脚本所在目录,默认 01。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="save",
|
||||
help="生成图片保存目录,相对于当前脚本所在目录,默认 save。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key",
|
||||
help="速创API接口密钥;如未提供,将尝试从环境变量 WUYIN_API_KEY 读取。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--poll-interval",
|
||||
type=float,
|
||||
default=5.0,
|
||||
help="轮询结果详情接口的间隔秒数,默认 5 秒。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-wait",
|
||||
type=float,
|
||||
default=300.0,
|
||||
help="等待结果的最长时间(秒),默认 300 秒。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
config = load_config(base_dir)
|
||||
|
||||
api_key = load_api_key(args.api_key, config)
|
||||
|
||||
# 提示词优先级:命令行参数 > 配置文件 > 运行时输入
|
||||
prompt = args.prompt or (config.get("prompt") if isinstance(config, dict) else None)
|
||||
if not prompt:
|
||||
prompt = input("请输入提示词(prompt):").strip()
|
||||
if not prompt:
|
||||
raise SystemExit("提示词不能为空。")
|
||||
|
||||
input_dir = base_dir / args.input_dir
|
||||
output_dir = base_dir / args.output_dir
|
||||
|
||||
# 本地参考图(转为 base64)
|
||||
ref_b64_list = collect_reference_images(input_dir)
|
||||
|
||||
# 配置中的参考图 URL(直接透传给接口)
|
||||
extra_urls_cfg = config.get("reference_urls") if isinstance(config, dict) else None
|
||||
extra_urls: List[str] = []
|
||||
if isinstance(extra_urls_cfg, str):
|
||||
extra_urls = [extra_urls_cfg]
|
||||
elif isinstance(extra_urls_cfg, list):
|
||||
extra_urls = [str(u) for u in extra_urls_cfg if u]
|
||||
|
||||
combined_urls: List[str] = []
|
||||
combined_urls.extend(ref_b64_list)
|
||||
combined_urls.extend(extra_urls)
|
||||
|
||||
if ref_b64_list:
|
||||
print(f"已从目录 {input_dir} 读取 {len(ref_b64_list)} 张本地参考图。")
|
||||
if extra_urls:
|
||||
print(f"已从配置文件读取 {len(extra_urls)} 个参考图 URL。")
|
||||
if not combined_urls:
|
||||
print(f"未找到任何参考图,将仅根据提示词生成。")
|
||||
|
||||
print("正在创建 NanoBanana2 任务...")
|
||||
task_id = create_task(
|
||||
api_key=api_key,
|
||||
prompt=prompt,
|
||||
size=args.size,
|
||||
aspect_ratio=args.aspect_ratio,
|
||||
ref_b64_list=combined_urls,
|
||||
)
|
||||
print(f"任务已创建,任务 id: {task_id}")
|
||||
|
||||
print("开始轮询任务结果(结果详情接口地址可根据官方文档调整)...")
|
||||
urls = query_result(
|
||||
api_key=api_key,
|
||||
task_id=task_id,
|
||||
poll_interval=args.poll_interval,
|
||||
max_wait=args.max_wait,
|
||||
)
|
||||
|
||||
if not urls:
|
||||
print("任务完成,但未在结果中找到图片 URL,请登录速创API控制台或检查结果详情接口。")
|
||||
return
|
||||
|
||||
print(f"任务完成,共获取到 {len(urls)} 个图片 URL,开始下载...")
|
||||
download_images(urls, output_dir=output_dir, task_id=task_id)
|
||||
print("全部处理完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
66
package.json
Normal file
66
package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "nano-banana-img",
|
||||
"version": "1.0.0",
|
||||
"description": "NanoBanana2 本地图片生成脚本与桌面应用",
|
||||
"main": "electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "node nano_banana_client.js",
|
||||
"ui": "vite --open",
|
||||
"electron": "electron .",
|
||||
"app": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"build": "vite build",
|
||||
"start": "electron .",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ electron-builder --win",
|
||||
"dist:clean": "rimraf release && npm run dist",
|
||||
"deps:check": "node -e \"require('./core/generator.js'); console.log('主进程依赖完整');\""
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.nanobanana.img",
|
||||
"productName": "NanoBanana生图",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"core/**/*",
|
||||
"node_modules/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{ "target": "dir", "arch": ["x64"] }
|
||||
],
|
||||
"icon": null,
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"installerLanguages": ["zh_CN"],
|
||||
"shortcutName": "NanoBanana生图"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"form-data": "^4.0.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"delayed-stream": "^1.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"mime-db": "^1.52.0",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"concurrently": "^8.2.0",
|
||||
"wait-on": "^7.2.0",
|
||||
"rimraf": "^5.0.5"
|
||||
}
|
||||
}
|
||||
4627
pnpm-lock.yaml
generated
Normal file
4627
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests>=2.31.0
|
||||
384
src/App.css
Normal file
384
src/App.css
Normal file
@@ -0,0 +1,384 @@
|
||||
.app {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-nav button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-nav button:hover {
|
||||
background: #222228;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.app-nav button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #0f0f12;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.tab-pane-active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-row input,
|
||||
.form-row select,
|
||||
.form-row textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-row input:focus,
|
||||
.form-row select:focus,
|
||||
.form-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-row textarea {
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row .hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #0f0f12;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #3a3a42;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.log-entry.done {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-entry.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.input-with-btn {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-btn input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.extra-param-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.extra-key {
|
||||
width: 140px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.extra-value {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-message {
|
||||
font-size: 0.85rem;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.form-warn {
|
||||
font-size: 0.8rem;
|
||||
color: var(--error);
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.form-row-inline {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row-inline > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ref-files {
|
||||
margin-bottom: 10px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ref-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ref-file-name {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-success-card h2 {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.result-success-msg {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.result-preview-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.result-preview-thumb {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.result-preview-thumb:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.result-preview-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.preview-close:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ref-drop-zone {
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.ref-drop-zone-active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(167, 139, 250, 0.08);
|
||||
}
|
||||
|
||||
.ref-drop-placeholder {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.ref-drop-placeholder-inline {
|
||||
padding: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
110
src/App.jsx
Normal file
110
src/App.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Settings from "./Settings";
|
||||
import Generate from "./Generate";
|
||||
import "./App.css";
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState("generate");
|
||||
const [config, setConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runningInGenerate, setRunningInGenerate] = useState(false);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
if (typeof window.electronAPI?.getConfig !== "function") {
|
||||
setConfig(getFallbackConfig());
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const c = await window.electronAPI.getConfig();
|
||||
setConfig(normalizeConfig(c));
|
||||
} catch {
|
||||
setConfig(getFallbackConfig());
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const saveConfig = async (next) => {
|
||||
if (typeof window.electronAPI?.saveConfig !== "function") return;
|
||||
const c = await window.electronAPI.saveConfig(next);
|
||||
setConfig(normalizeConfig(c));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app-loading">
|
||||
<span>加载中…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header app-title">
|
||||
<h1>NanoBanana 生图</h1>
|
||||
<nav className="app-nav">
|
||||
<button
|
||||
className={tab === "generate" ? "active" : ""}
|
||||
onClick={() => setTab("generate")}
|
||||
>
|
||||
生图{runningInGenerate ? " (进行中)" : ""}
|
||||
</button>
|
||||
<button
|
||||
className={tab === "settings" ? "active" : ""}
|
||||
onClick={() => setTab("settings")}
|
||||
>
|
||||
设置
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="app-main">
|
||||
<div className={`tab-pane ${tab === "settings" ? "tab-pane-active" : ""}`}>
|
||||
<Settings config={config ?? getFallbackConfig()} onSave={saveConfig} />
|
||||
</div>
|
||||
<div className={`tab-pane ${tab === "generate" ? "tab-pane-active" : ""}`}>
|
||||
<Generate
|
||||
config={config ?? getFallbackConfig()}
|
||||
onRunningChange={setRunningInGenerate}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getFallbackConfig() {
|
||||
return {
|
||||
api_key: "",
|
||||
api_create_url: "https://api.wuyinkeji.com/api/async/image_nanoBanana2",
|
||||
api_result_url: "https://api.wuyinkeji.com/api/async/detail",
|
||||
poll_interval_seconds: 10,
|
||||
max_wait_seconds: 300,
|
||||
r2_account_id: "",
|
||||
r2_access_key_id: "",
|
||||
r2_secret_access_key: "",
|
||||
r2_bucket: "",
|
||||
r2_public_url: "",
|
||||
default_save_dir: "",
|
||||
extra_params: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(c) {
|
||||
if (!c) return getFallbackConfig();
|
||||
const extra = Array.isArray(c.extra_params)
|
||||
? c.extra_params
|
||||
: [];
|
||||
return {
|
||||
...getFallbackConfig(),
|
||||
...c,
|
||||
extra_params: extra.map((e) =>
|
||||
typeof e === "object" && e !== null
|
||||
? { key: e.key ?? "", value: e.value ?? "" }
|
||||
: { key: "", value: "" }
|
||||
),
|
||||
};
|
||||
}
|
||||
51
src/ErrorBoundary.jsx
Normal file
51
src/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
|
||||
export class ErrorBoundary extends React.Component {
|
||||
state = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error("ErrorBoundary:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 24,
|
||||
maxWidth: 480,
|
||||
margin: "40px auto",
|
||||
background: "var(--bg-card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
color: "var(--text)",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}>
|
||||
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem" }}>界面加载出错</h2>
|
||||
<p style={{ margin: 0, fontSize: 0.9, color: "var(--text-muted)" }}>
|
||||
{this.state.error?.message || String(this.state.error)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => this.setState({ error: null })}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 16px",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
color: "#0f0f12",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
332
src/Generate.jsx
Normal file
332
src/Generate.jsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
const SIZES = ["1K", "2K", "4K"];
|
||||
const RATIOS = ["auto", "1:1", "16:9", "4:3", "3:4", "9:16"];
|
||||
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
|
||||
|
||||
function getPathFromFile(file) {
|
||||
if (file.path) return file.path;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isImagePath(pathStr) {
|
||||
if (!pathStr) return false;
|
||||
const ext = pathStr.replace(/^.*\./, ".").toLowerCase();
|
||||
return IMAGE_EXTS.has(ext);
|
||||
}
|
||||
|
||||
export default function Generate({ config, onRunningChange }) {
|
||||
const [prompt, setPrompt] = useState(config?.prompt || "");
|
||||
const [size, setSize] = useState(config?.size || "1K");
|
||||
const [aspectRatio, setAspectRatio] = useState(config?.aspectRatio || config?.["aspect-ratio"] || "auto");
|
||||
const [refFiles, setRefFiles] = useState([]);
|
||||
const [saveDir, setSaveDir] = useState(config?.default_save_dir || "");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [log, setLog] = useState([]);
|
||||
const [result, setResult] = useState(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [previewUrls, setPreviewUrls] = useState([]);
|
||||
const [previewIndex, setPreviewIndex] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onRunningChange === "function") onRunningChange(running);
|
||||
}, [running, onRunningChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.prompt) setPrompt(config.prompt);
|
||||
if (config?.size) setSize(config.size);
|
||||
if (config?.aspectRatio) setAspectRatio(config.aspectRatio);
|
||||
if (config?.["aspect-ratio"]) setAspectRatio(config["aspect-ratio"]);
|
||||
if (config?.default_save_dir) setSaveDir(config.default_save_dir);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!result?.success || !result?.savedPaths?.length || typeof window.electronAPI?.getImageDataUrl !== "function") {
|
||||
setPreviewUrls([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const urls = [];
|
||||
for (const p of result.savedPaths) {
|
||||
if (cancelled) return;
|
||||
const dataUrl = await window.electronAPI.getImageDataUrl(p);
|
||||
if (cancelled) return;
|
||||
urls.push(dataUrl || "");
|
||||
}
|
||||
if (!cancelled) setPreviewUrls(urls);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [result?.taskId, result?.savedPaths?.length]);
|
||||
|
||||
const addPaths = useCallback((paths) => {
|
||||
if (!paths || !paths.length) return;
|
||||
const valid = paths.filter(isImagePath);
|
||||
const unique = [...new Set(valid)];
|
||||
setRefFiles((prev) => {
|
||||
const set = new Set(prev);
|
||||
unique.forEach((p) => set.add(p));
|
||||
return [...set];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addRefFiles = useCallback(async () => {
|
||||
if (typeof window.electronAPI?.selectReferenceFiles !== "function") return;
|
||||
const paths = await window.electronAPI.selectReferenceFiles();
|
||||
addPaths(paths);
|
||||
}, [addPaths]);
|
||||
|
||||
const clearRefFiles = useCallback(() => {
|
||||
setRefFiles([]);
|
||||
}, []);
|
||||
|
||||
const pickSaveDir = useCallback(async () => {
|
||||
if (typeof window.electronAPI?.selectDirectory !== "function") return;
|
||||
const dir = await window.electronAPI.selectDirectory(saveDir || undefined);
|
||||
if (dir) setSaveDir(dir);
|
||||
}, [saveDir]);
|
||||
|
||||
const removeRefFile = useCallback((index) => {
|
||||
setRefFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (running) return;
|
||||
const items = e.dataTransfer?.files;
|
||||
if (!items?.length) return;
|
||||
const paths = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const p = getPathFromFile(items[i]);
|
||||
if (p) paths.push(p);
|
||||
}
|
||||
addPaths(paths);
|
||||
setDragOver(false);
|
||||
},
|
||||
[running, addPaths]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (running) return;
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragOver(true);
|
||||
}, [running]);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (running) return;
|
||||
setRunning(true);
|
||||
setLog([]);
|
||||
setResult(null);
|
||||
|
||||
const unsub =
|
||||
typeof window.electronAPI?.onGenerationProgress === "function"
|
||||
? window.electronAPI.onGenerationProgress(({ step, message }) => {
|
||||
setLog((prev) => [...prev, `[${step}] ${message}`]);
|
||||
})
|
||||
: () => {};
|
||||
|
||||
try {
|
||||
const res = await window.electronAPI?.startGeneration?.({
|
||||
prompt: prompt.trim(),
|
||||
size,
|
||||
aspectRatio,
|
||||
refFilePaths: refFiles,
|
||||
saveDir: saveDir || undefined,
|
||||
});
|
||||
if (res?.success) {
|
||||
setResult(res);
|
||||
setLog((prev) => [...prev, `完成:已保存 ${res.count} 张到 ${res.saveDir}`]);
|
||||
} else {
|
||||
setLog((prev) => [...prev, `错误: ${res?.error || "未知"}`]);
|
||||
}
|
||||
} catch (e) {
|
||||
setLog((prev) => [...prev, "错误: " + (e.message || String(e))]);
|
||||
} finally {
|
||||
unsub?.();
|
||||
setRunning(false);
|
||||
}
|
||||
}, [running, prompt, size, aspectRatio, refFiles, saveDir]);
|
||||
|
||||
const hasR2 = config?.r2_account_id && config?.r2_bucket && config?.r2_public_url;
|
||||
const needR2ForRef = refFiles.length > 0;
|
||||
|
||||
return (
|
||||
<div className="generate">
|
||||
<div className="card">
|
||||
<h2>生图参数</h2>
|
||||
<div className="form-row">
|
||||
<label>提示词</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="描述你想要的画面…"
|
||||
disabled={running}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row form-row-inline">
|
||||
<div>
|
||||
<label>尺寸</label>
|
||||
<select value={size} onChange={(e) => setSize(e.target.value)} disabled={running}>
|
||||
{SIZES.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>比例</label>
|
||||
<select value={aspectRatio} onChange={(e) => setAspectRatio(e.target.value)} disabled={running}>
|
||||
{RATIOS.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>参考图</h2>
|
||||
<p className="form-hint">拖拽或选择图片,将上传到 R2 作为参考;需在设置中配置 R2</p>
|
||||
<div
|
||||
className={`ref-drop-zone ${dragOver ? "ref-drop-zone-active" : ""}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div className="ref-files">
|
||||
{refFiles.map((path, i) => (
|
||||
<div key={i} className="ref-file-item">
|
||||
<span className="ref-file-name" title={path || ""}>
|
||||
{typeof path === "string" ? path.replace(/^.*[/\\]/, "") : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => removeRefFile(i)}
|
||||
disabled={running}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{refFiles.length === 0 && (
|
||||
<div className="ref-drop-placeholder">
|
||||
{dragOver ? "松开即可添加" : "拖拽图片到此处"}
|
||||
</div>
|
||||
)}
|
||||
{refFiles.length > 0 && dragOver && (
|
||||
<div className="ref-drop-placeholder ref-drop-placeholder-inline">
|
||||
松开添加更多
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={addRefFiles} disabled={running}>
|
||||
添加参考图
|
||||
</button>
|
||||
{refFiles.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary" onClick={clearRefFiles} disabled={running}>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{needR2ForRef && !hasR2 && (
|
||||
<p className="form-warn">已选参考图但未配置 R2,请先在设置中填写 R2 信息</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>保存位置</h2>
|
||||
<div className="input-with-btn">
|
||||
<input type="text" readOnly value={saveDir || ""} placeholder="未选择(将使用设置中的默认目录)" />
|
||||
<button type="button" className="btn btn-secondary" onClick={pickSaveDir} disabled={running}>
|
||||
选择文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={start}
|
||||
disabled={running || !prompt.trim() || (needR2ForRef && !hasR2)}
|
||||
>
|
||||
{running ? "生成中…" : "开始生图"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(log.length > 0 || result) && (
|
||||
<div className="card">
|
||||
<h2>运行日志</h2>
|
||||
<div className="log">
|
||||
{log.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`log-entry ${
|
||||
line.startsWith("错误") ? "error" : line.startsWith("完成") || line.includes("已保存") ? "done" : ""
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.success && result?.savedPaths?.length > 0 && (
|
||||
<div className="card result-success-card">
|
||||
<h2>生成成功</h2>
|
||||
<p className="result-success-msg">
|
||||
共保存 {result.count} 张图片到 {result.saveDir}
|
||||
</p>
|
||||
<div className="result-preview-grid">
|
||||
{previewUrls.map((dataUrl, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="result-preview-thumb"
|
||||
onClick={() => setPreviewIndex(i)}
|
||||
>
|
||||
{dataUrl ? (
|
||||
<img src={dataUrl} alt={`预览 ${i + 1}`} />
|
||||
) : (
|
||||
<span>加载中…</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewIndex !== null && previewUrls[previewIndex] && (
|
||||
<div
|
||||
className="preview-overlay"
|
||||
onClick={() => setPreviewIndex(null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<button type="button" className="preview-close" onClick={() => setPreviewIndex(null)} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={previewUrls[previewIndex]}
|
||||
alt="预览"
|
||||
className="preview-img"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/Settings.jsx
Normal file
189
src/Settings.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
const R2_FIELDS = [
|
||||
{ key: "r2_account_id", label: "R2 Account ID" },
|
||||
{ key: "r2_access_key_id", label: "R2 Access Key ID" },
|
||||
{ key: "r2_secret_access_key", label: "R2 Secret Access Key", type: "password" },
|
||||
{ key: "r2_bucket", label: "R2 Bucket 名称" },
|
||||
{ key: "r2_public_url", label: "R2 公网访问地址" },
|
||||
];
|
||||
|
||||
function getDefaultForm() {
|
||||
return {
|
||||
api_key: "",
|
||||
api_create_url: "https://api.wuyinkeji.com/api/async/image_nanoBanana2",
|
||||
api_result_url: "https://api.wuyinkeji.com/api/async/detail",
|
||||
r2_account_id: "",
|
||||
r2_access_key_id: "",
|
||||
r2_secret_access_key: "",
|
||||
r2_bucket: "",
|
||||
r2_public_url: "",
|
||||
default_save_dir: "",
|
||||
extra_params: [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function Settings({ config, onSave }) {
|
||||
const safeConfig = config && typeof config === "object" ? config : {};
|
||||
const [form, setForm] = useState(() => ({ ...getDefaultForm(), ...safeConfig }));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const update = useCallback((key, value) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const updateExtra = useCallback((index, field, value) => {
|
||||
setForm((prev) => {
|
||||
const next = [...(prev.extra_params || [])];
|
||||
if (!next[index]) next[index] = { key: "", value: "" };
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
return { ...prev, extra_params: next };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addExtra = useCallback(() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
extra_params: [...(prev.extra_params || []), { key: "", value: "" }],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeExtra = useCallback((index) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
extra_params: (prev.extra_params || []).filter((_, i) => i !== index),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const pickDir = useCallback(async () => {
|
||||
if (typeof window.electronAPI?.selectDirectory !== "function") return;
|
||||
const dir = await window.electronAPI.selectDirectory(form.default_save_dir);
|
||||
if (dir) update("default_save_dir", dir);
|
||||
}, [form.default_save_dir, update]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setMessage("");
|
||||
try {
|
||||
await onSave(form);
|
||||
setMessage("已保存");
|
||||
setTimeout(() => setMessage(""), 2000);
|
||||
} catch (e) {
|
||||
setMessage("保存失败: " + (e.message || String(e)));
|
||||
}
|
||||
setSaving(false);
|
||||
}, [form, onSave]);
|
||||
|
||||
return (
|
||||
<div className="settings">
|
||||
<div className="card">
|
||||
<h2>API 与接口</h2>
|
||||
<div className="form-row">
|
||||
<label>API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.api_key || ""}
|
||||
onChange={(e) => update("api_key", e.target.value)}
|
||||
placeholder="速创 API 密钥"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>创建任务接口 URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.api_create_url || ""}
|
||||
onChange={(e) => update("api_create_url", e.target.value)}
|
||||
placeholder="https://api.wuyinkeji.com/api/async/image_nanoBanana2"
|
||||
/>
|
||||
<div className="hint">接口文档变更时可修改此处</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>结果查询接口 URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.api_result_url || ""}
|
||||
onChange={(e) => update("api_result_url", e.target.value)}
|
||||
placeholder="https://api.wuyinkeji.com/api/async/detail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>R2 图床(上传参考图用)</h2>
|
||||
{R2_FIELDS.map(({ key, label, type }) => (
|
||||
<div key={key} className="form-row">
|
||||
<label>{label}</label>
|
||||
<input
|
||||
type={type || "text"}
|
||||
value={form[key] || ""}
|
||||
onChange={(e) => update(key, e.target.value)}
|
||||
placeholder={label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>默认保存目录</h2>
|
||||
<div className="form-row">
|
||||
<label>生图结果默认保存到此目录</label>
|
||||
<div className="input-with-btn">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={form.default_save_dir || ""}
|
||||
placeholder="未选择"
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={pickDir}>
|
||||
选择文件夹
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>自定义请求参数</h2>
|
||||
<p className="form-hint">接口文档变更时,可在此增加或修改请求体参数(键值对)</p>
|
||||
{(form.extra_params || []).map((item, index) => (
|
||||
<div key={index} className="extra-param-row">
|
||||
<input
|
||||
className="extra-key"
|
||||
value={item?.key ?? ""}
|
||||
onChange={(e) => updateExtra(index, "key", e.target.value)}
|
||||
placeholder="参数名"
|
||||
/>
|
||||
<input
|
||||
className="extra-value"
|
||||
value={item?.value ?? ""}
|
||||
onChange={(e) => updateExtra(index, "value", e.target.value)}
|
||||
placeholder="参数值"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => removeExtra(index)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={addExtra}>
|
||||
+ 添加参数
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "保存中…" : "保存配置"}
|
||||
</button>
|
||||
{message && <span className="form-message">{message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/index.css
Normal file
44
src/index.css
Normal file
@@ -0,0 +1,44 @@
|
||||
:root {
|
||||
--bg: #0f0f12;
|
||||
--bg-card: #18181c;
|
||||
--border: #2a2a30;
|
||||
--text: #e4e4e7;
|
||||
--text-muted: #a1a1aa;
|
||||
--accent: #a78bfa;
|
||||
--accent-hover: #c4b5fd;
|
||||
--success: #34d399;
|
||||
--error: #f87171;
|
||||
--radius: 10px;
|
||||
--font: "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
input, textarea, select, button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
}
|
||||
13
src/main.jsx
Normal file
13
src/main.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
);
|
||||
9
vite.config.js
Normal file
9
vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
build: { outDir: "dist" },
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
});
|
||||
Reference in New Issue
Block a user