feat(gemini-web): auto-remove watermark from generated images

Uses reverse alpha blending to restore original pixels from Gemini's
visible watermark. Enabled by default; opt out with --keep-watermark.
Algorithm ported from gemini-watermark-remover (MIT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alec Jiang 2026-03-16 20:04:36 +08:00
parent 462d080a0e
commit 98848ea7bf
8 changed files with 142 additions and 3 deletions

View File

@ -122,10 +122,19 @@ ${BUN_X} {baseDir}/scripts/main.ts "Hello" --json
| `--sessionId` | Session ID for multi-turn conversation |
| `--list-sessions` | List saved sessions |
| `--json` | Output as JSON |
| `--keep-watermark` | Skip watermark removal (removed by default) |
| `--login` | Refresh cookies, then exit |
| `--cookie-path` | Custom cookie file path |
| `--profile-dir` | Chrome profile directory |
## Watermark Removal
Generated images have Gemini's visible watermark removed by default using reverse alpha blending. This is a lossless mathematical operation that precisely restores the original pixels — not AI inpainting.
To keep the watermark, pass `--keep-watermark`.
Algorithm credit: [gemini-watermark-remover](https://github.com/journey-ad/gemini-watermark-remover) by journey-ad, based on [GeminiWatermarkTool](https://github.com/allenk/GeminiWatermarkTool) by allenk.
## Models
| Model | Description |

View File

@ -5,10 +5,22 @@
"name": "baoyu-danger-gemini-web-scripts",
"dependencies": {
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
"pngjs": "^7.0.0",
},
"devDependencies": {
"@types/pngjs": "^6.0.5",
},
},
},
"packages": {
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/pngjs": ["@types/pngjs@6.0.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ=="],
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
import { logger } from '../utils/logger.js';
import { cookie_header, fetch_with_timeout } from '../utils/http.js';
import { removeWatermarkFromFile } from '../watermark.js';
export class Image {
constructor(
@ -105,10 +106,20 @@ export class GeneratedImage extends Image {
verbose: boolean = false,
skip_invalid_filename: boolean = false,
full_size: boolean = true,
stripWatermark: boolean = true,
): Promise<string | null> {
const u = full_size ? `${this.url}=s2048` : this.url;
const f = filename ?? `${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}_${u.slice(-10)}.png`;
const img = new Image(u, this.title, this.alt, this.proxy);
return await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename);
const dest = await img.save(p, f, cookies ?? this.cookies, verbose, skip_invalid_filename);
if (dest && stripWatermark) {
try {
const removed = await removeWatermarkFromFile(dest);
if (verbose) logger.info(removed ? `Watermark removed from ${dest}` : `Watermark removal skipped (non-PNG?) for ${dest}`);
} catch (e) {
if (verbose) logger.warning(`Watermark removal failed: ${e}`);
}
}
return dest;
}
}

View File

@ -0,0 +1,95 @@
import path from 'node:path';
import { readFile, writeFile } from 'node:fs/promises';
import { PNG } from 'pngjs';
const ALPHA_THRESHOLD = 0.002;
const MAX_ALPHA = 0.99;
const LOGO_VALUE = 255;
type WmConfig = { logoSize: number; marginRight: number; marginBottom: number };
type WmPosition = { x: number; y: number; width: number; height: number };
function detectConfig(w: number, h: number): WmConfig {
if (w > 1024 && h > 1024) return { logoSize: 96, marginRight: 64, marginBottom: 64 };
return { logoSize: 48, marginRight: 32, marginBottom: 32 };
}
function calcPosition(w: number, h: number, cfg: WmConfig): WmPosition {
return {
x: w - cfg.marginRight - cfg.logoSize,
y: h - cfg.marginBottom - cfg.logoSize,
width: cfg.logoSize,
height: cfg.logoSize,
};
}
function buildAlphaMap(bgData: Buffer, w: number, h: number): Float32Array {
const map = new Float32Array(w * h);
for (let i = 0; i < map.length; i++) {
const idx = i * 4;
map[i] = Math.max(bgData[idx]!, bgData[idx + 1]!, bgData[idx + 2]!) / 255.0;
}
return map;
}
function applyReverseBlend(
data: Buffer,
imgW: number,
alphaMap: Float32Array,
pos: WmPosition,
): void {
for (let row = 0; row < pos.height; row++) {
for (let col = 0; col < pos.width; col++) {
const imgIdx = ((pos.y + row) * imgW + (pos.x + col)) * 4;
const alphaIdx = row * pos.width + col;
let alpha = alphaMap[alphaIdx]!;
if (alpha < ALPHA_THRESHOLD) continue;
alpha = Math.min(alpha, MAX_ALPHA);
const inv = 1.0 - alpha;
for (let c = 0; c < 3; c++) {
const val = (data[imgIdx + c]! - alpha * LOGO_VALUE) / inv;
data[imgIdx + c] = Math.max(0, Math.min(255, Math.round(val)));
}
}
}
}
const alphaMaps: Record<number, Float32Array> = {};
async function getAlphaMap(size: number): Promise<Float32Array> {
if (alphaMaps[size]) return alphaMaps[size]!;
const file = size === 48 ? 'bg_48.png' : 'bg_96.png';
const dir = (import.meta as any).dir ?? path.dirname(new URL(import.meta.url).pathname);
const buf = await readFile(path.join(dir, 'assets', file));
const png = PNG.sync.read(buf);
const map = buildAlphaMap(png.data as unknown as Buffer, png.width, png.height);
alphaMaps[size] = map;
return map;
}
export async function removeWatermarkFromFile(filePath: string): Promise<boolean> {
const buf = await readFile(filePath);
let png: PNG;
try {
png = PNG.sync.read(buf);
} catch {
return false;
}
const { width, height } = png;
const cfg = detectConfig(width, height);
const pos = calcPosition(width, height, cfg);
if (pos.x < 0 || pos.y < 0) return false;
const alphaMap = await getAlphaMap(cfg.logoSize);
applyReverseBlend(png.data as unknown as Buffer, width, alphaMap, pos);
const out = PNG.sync.write(png);
await writeFile(filePath, out);
return true;
}

View File

@ -18,6 +18,7 @@ type CliArgs = {
login: boolean;
cookiePath: string | null;
profileDir: string | null;
keepWatermark: boolean;
help: boolean;
};
@ -95,6 +96,7 @@ Options:
--ref <files...> Alias for --reference
--sessionId <id> Session ID for multi-turn conversation (agent should generate unique ID)
--list-sessions List saved sessions (max 100, sorted by update time)
--keep-watermark Skip watermark removal (watermark is removed by default)
--login Only refresh cookies, then exit
--cookie-path <path> Cookie file path (default: ${cookiePath})
--profile-dir <path> Chrome profile dir (default: ${profileDir})
@ -117,6 +119,7 @@ function parseArgs(argv: string[]): CliArgs {
login: false,
cookiePath: null,
profileDir: null,
keepWatermark: false,
help: false,
};
@ -157,6 +160,11 @@ function parseArgs(argv: string[]): CliArgs {
continue;
}
if (a === '--keep-watermark') {
out.keepWatermark = true;
continue;
}
if (a === '--prompt' || a === '-p') {
const v = argv[++i];
if (!v) throw new Error(`Missing value for ${a}`);
@ -474,7 +482,7 @@ async function main(): Promise<void> {
const dp = dir;
if (img instanceof GeneratedImage) {
savedImage = await img.save(dp, fn, undefined, false, false, true);
savedImage = await img.save(dp, fn, undefined, false, false, true, !args.keepWatermark);
} else {
savedImage = await img.save(dp, fn, c.cookies, false, false);
}

View File

@ -3,6 +3,10 @@
"private": true,
"type": "module",
"dependencies": {
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
"pngjs": "^7.0.0"
},
"devDependencies": {
"@types/pngjs": "^6.0.5"
}
}