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 { streamText } from "ai";
|
||||
|
||||
import type { Config } from "@/config";
|
||||
import type { ImageJob } from "@/jobs";
|
||||
|
||||
type GeneratedImage = {
|
||||
bytes: Uint8Array;
|
||||
mediaType: string;
|
||||
};
|
||||
|
||||
type InputImagePart = {
|
||||
type: "image";
|
||||
image: Uint8Array;
|
||||
mediaType: string;
|
||||
};
|
||||
|
||||
const MARKDOWN_IMAGE_REGEX = /!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/;
|
||||
const INPUT_IMAGES_DIR = "images";
|
||||
|
||||
function createProvider(config: Config) {
|
||||
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 {
|
||||
if (alreadyPrinted) {
|
||||
return;
|
||||
@@ -51,12 +96,18 @@ async function downloadImage(url: string): Promise<GeneratedImage> {
|
||||
|
||||
export async function streamPrompt(
|
||||
config: Config,
|
||||
prompt: string,
|
||||
job: Pick<ImageJob, "prompt" | "images">,
|
||||
): Promise<GeneratedImage> {
|
||||
const provider = createProvider(config);
|
||||
const imageParts = await buildImageParts(job.images ?? []);
|
||||
const result = streamText({
|
||||
model: provider(config.model),
|
||||
prompt,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: job.prompt }, ...imageParts],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let reasoningStarted = false;
|
||||
|
||||
21
src/image.ts
21
src/image.ts
@@ -1,7 +1,16 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
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 {
|
||||
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 timestamp = new Date().toISOString().replace(/[.:]/g, "-");
|
||||
return `${timestamp}.${extension}`;
|
||||
const prefix = name ? `${sanitizeFileNamePart(name)}-` : "";
|
||||
return `${prefix}${timestamp}.${extension}`;
|
||||
}
|
||||
|
||||
export async function saveImage(
|
||||
bytes: Uint8Array,
|
||||
mediaType: string,
|
||||
name?: 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);
|
||||
|
||||
return filePath;
|
||||
|
||||
29
src/index.ts
29
src/index.ts
@@ -1,27 +1,32 @@
|
||||
import { streamPrompt } from "@/ai";
|
||||
import { loadConfig } from "@/config";
|
||||
import { saveImage } from "@/image";
|
||||
|
||||
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;
|
||||
}
|
||||
import { jobs } from "@/jobs";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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(`Image media type: ${image.mediaType}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
|
||||
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