Compare commits

..

10 Commits

10 changed files with 423 additions and 21 deletions

3
.env.example Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View 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
View 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
}
}