Compare commits
7 Commits
4783cc95c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d9baedfa9 | |||
| b2b4a64eab | |||
| 7534c6bdf3 | |||
| 6ceeeff907 | |||
| aa33dea638 | |||
| 70e28fbfa4 | |||
| ec8830f372 |
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FLOW2API_BASE_URL=https://flow2api.imbytecat.com/v1
|
||||||
|
FLOW2API_API_KEY=
|
||||||
|
FLOW2API_MODEL=gemini-3.0-pro-image-landscape
|
||||||
215
.gitignore
vendored
Normal file
215
.gitignore
vendored
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
output/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
__MACOSX/
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
Icon[
|
||||||
|
]
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# Metadata left by Dolphin file manager, which comes with KDE Plasma
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
# Log files created by default by the nohup command
|
||||||
|
nohup.out
|
||||||
32
README.md
Normal file
32
README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# nano-banano
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up your environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
To run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run src/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
- Reads `FLOW2API_BASE_URL`, `FLOW2API_API_KEY`, and `FLOW2API_MODEL` from `.env`
|
||||||
|
- Accepts either a root endpoint or a `/v1` endpoint and normalizes root URLs to the OpenAI-compatible `/v1` base URL automatically
|
||||||
|
- Also accepts older `GEMINI_MODEL` and `OPENAI_MODEL` env names for compatibility
|
||||||
|
- Reads batch jobs from `src/jobs.ts`, where each item can define `name`, `prompt`, `images`, and `enabled`
|
||||||
|
- Treats relative `images` entries in each job as files under the local `images/` directory
|
||||||
|
- Sends `prompt` plus any local input images through AI SDK's `streamText`, then downloads the generated image from the streamed Markdown image link
|
||||||
|
- Saves each generated image into `output/` with a job-based timestamp filename
|
||||||
|
- Keeps runtime code under `src/` and uses the `@/*` TypeScript alias for internal imports
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||||
34
biome.json
Normal file
34
biome.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
images/.gitignore
vendored
Normal file
2
images/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
55
src/ai.ts
55
src/ai.ts
@@ -1,14 +1,25 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { extname, isAbsolute, join, resolve } from "node:path";
|
||||||
|
|
||||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
|
|
||||||
import type { Config } from "@/config";
|
import type { Config } from "@/config";
|
||||||
|
import type { ImageJob } from "@/jobs";
|
||||||
|
|
||||||
type GeneratedImage = {
|
type GeneratedImage = {
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type InputImagePart = {
|
||||||
|
type: "image";
|
||||||
|
image: Uint8Array;
|
||||||
|
mediaType: string;
|
||||||
|
};
|
||||||
|
|
||||||
const MARKDOWN_IMAGE_REGEX = /!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/;
|
const MARKDOWN_IMAGE_REGEX = /!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/;
|
||||||
|
const INPUT_IMAGES_DIR = "images";
|
||||||
|
|
||||||
function createProvider(config: Config) {
|
function createProvider(config: Config) {
|
||||||
return createOpenAICompatible({
|
return createOpenAICompatible({
|
||||||
@@ -18,6 +29,40 @@ function createProvider(config: Config) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mediaTypeFromPath(filePath: string): string {
|
||||||
|
switch (extname(filePath).toLowerCase()) {
|
||||||
|
case ".png":
|
||||||
|
return "image/png";
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
return "image/jpeg";
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp";
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif";
|
||||||
|
default:
|
||||||
|
return "image/png";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildImageParts(
|
||||||
|
imagePaths: string[],
|
||||||
|
): Promise<InputImagePart[]> {
|
||||||
|
return Promise.all(
|
||||||
|
imagePaths.map(async (imagePath) => ({
|
||||||
|
type: "image" as const,
|
||||||
|
image: new Uint8Array(
|
||||||
|
await readFile(
|
||||||
|
isAbsolute(imagePath)
|
||||||
|
? imagePath
|
||||||
|
: resolve(join(INPUT_IMAGES_DIR, imagePath)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
mediaType: mediaTypeFromPath(imagePath),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function writeSectionTitle(title: string, alreadyPrinted: boolean): void {
|
function writeSectionTitle(title: string, alreadyPrinted: boolean): void {
|
||||||
if (alreadyPrinted) {
|
if (alreadyPrinted) {
|
||||||
return;
|
return;
|
||||||
@@ -51,12 +96,18 @@ async function downloadImage(url: string): Promise<GeneratedImage> {
|
|||||||
|
|
||||||
export async function streamPrompt(
|
export async function streamPrompt(
|
||||||
config: Config,
|
config: Config,
|
||||||
prompt: string,
|
job: Pick<ImageJob, "prompt" | "images">,
|
||||||
): Promise<GeneratedImage> {
|
): Promise<GeneratedImage> {
|
||||||
const provider = createProvider(config);
|
const provider = createProvider(config);
|
||||||
|
const imageParts = await buildImageParts(job.images ?? []);
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: provider(config.model),
|
model: provider(config.model),
|
||||||
prompt,
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: job.prompt }, ...imageParts],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
let reasoningStarted = false;
|
let reasoningStarted = false;
|
||||||
|
|||||||
21
src/image.ts
21
src/image.ts
@@ -1,7 +1,16 @@
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const IMAGES_DIR = "images";
|
const OUTPUT_DIR = "output";
|
||||||
|
|
||||||
|
function sanitizeFileNamePart(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
function mediaTypeToExtension(mediaType: string): string {
|
function mediaTypeToExtension(mediaType: string): string {
|
||||||
switch (mediaType) {
|
switch (mediaType) {
|
||||||
@@ -18,19 +27,21 @@ function mediaTypeToExtension(mediaType: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTimestampFileName(mediaType: string): string {
|
function createTimestampFileName(mediaType: string, name?: string): string {
|
||||||
const extension = mediaTypeToExtension(mediaType);
|
const extension = mediaTypeToExtension(mediaType);
|
||||||
const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
|
||||||
return `${timestamp}.${extension}`;
|
const prefix = name ? `${sanitizeFileNamePart(name)}-` : "";
|
||||||
|
return `${prefix}${timestamp}.${extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImage(
|
export async function saveImage(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
mediaType: string,
|
mediaType: string,
|
||||||
|
name?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
await mkdir(IMAGES_DIR, { recursive: true });
|
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
|
||||||
const filePath = join(IMAGES_DIR, createTimestampFileName(mediaType));
|
const filePath = join(OUTPUT_DIR, createTimestampFileName(mediaType, name));
|
||||||
await writeFile(filePath, bytes);
|
await writeFile(filePath, bytes);
|
||||||
|
|
||||||
return filePath;
|
return filePath;
|
||||||
|
|||||||
29
src/index.ts
29
src/index.ts
@@ -1,28 +1,33 @@
|
|||||||
import { streamPrompt } from "@/ai";
|
import { streamPrompt } from "@/ai";
|
||||||
import { loadConfig } from "@/config";
|
import { loadConfig } from "@/config";
|
||||||
import { saveImage } from "@/image";
|
import { saveImage } from "@/image";
|
||||||
|
import { jobs } from "@/jobs";
|
||||||
const DEFAULT_PROMPT =
|
|
||||||
"A cute nano banano mascot in a clean sci-fi lab, bright colors, polished illustration";
|
|
||||||
|
|
||||||
function buildPrompt(): string {
|
|
||||||
const cliPrompt = Bun.argv.slice(2).join(" ").trim();
|
|
||||||
return cliPrompt || DEFAULT_PROMPT;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const prompt = buildPrompt();
|
const enabledJobs = jobs.filter((job) => job.enabled !== false);
|
||||||
|
|
||||||
console.log(`Prompt: ${prompt}`);
|
if (enabledJobs.length === 0) {
|
||||||
|
throw new Error("No enabled jobs found.");
|
||||||
|
}
|
||||||
|
|
||||||
const image = await streamPrompt(config, prompt);
|
for (const job of enabledJobs) {
|
||||||
|
console.log(`\n=== ${job.name} ===`);
|
||||||
|
console.log(`Prompt: ${job.prompt}`);
|
||||||
|
console.log(`Input images: ${job.images?.length ?? 0}`);
|
||||||
|
|
||||||
const savedImagePath = await saveImage(image.bytes, image.mediaType);
|
const image = await streamPrompt(config, job);
|
||||||
|
|
||||||
|
const savedImagePath = await saveImage(
|
||||||
|
image.bytes,
|
||||||
|
image.mediaType,
|
||||||
|
job.name,
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`\nSaved image: ${savedImagePath}`);
|
console.log(`\nSaved image: ${savedImagePath}`);
|
||||||
console.log(`Image media type: ${image.mediaType}`);
|
console.log(`Image media type: ${image.mediaType}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
|||||||
16
src/jobs.ts
Normal file
16
src/jobs.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type ImageJob = {
|
||||||
|
name: string;
|
||||||
|
prompt: string;
|
||||||
|
images?: string[];
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jobs: ImageJob[] = [
|
||||||
|
{
|
||||||
|
name: "nano-banano-test",
|
||||||
|
enabled: true,
|
||||||
|
prompt:
|
||||||
|
"根据角色参考图,设计并绘制一个细化过的插画。画面需包括这四位角色。画面描绘四位角色在一起睡觉的场景,删除帽子、鞋子和披肩,保留袜子。衣服凌乱,闭眼,互相紧靠在一起。注意保留角色的头身比、脸型、服饰细节和原图画风。注意表现角色身高关系:粉发170 两位白发162 棕发151",
|
||||||
|
images: ["Aishia1.png", "Celia1.png", "Latifa1.png", "Sara1.png"],
|
||||||
|
},
|
||||||
|
];
|
||||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user