@aibridge/cli 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as runCli, t as buildContext } from "./context-F8WLXzPv.mjs";
2
+ import { r as runCli, t as buildContext } from "./context-DJtEcg6f.mjs";
3
3
  //#region src/cli.ts
4
4
  await runCli(buildContext(process), process.argv.slice(2));
5
5
  //#endregion
@@ -2,7 +2,8 @@ import { createRequire } from "node:module";
2
2
  import { ExitCode, buildApplication, buildCommand, buildRouteMap, run } from "@stricli/core";
3
3
  import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
- import { isAbsolute, join, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import sharp from "sharp";
6
7
  import * as agy from "@aibridge/driver-agy";
7
8
  import { fetchAgyQuota, findModelQuota } from "@aibridge/driver-agy";
8
9
  import * as claude from "@aibridge/driver-claude";
@@ -186,12 +187,20 @@ const IMAGE_GEN_FORMATS = /* @__PURE__ */ new Map([
186
187
  ["codex", "png"],
187
188
  ["grok", "jpg"]
188
189
  ]);
190
+ const IMAGE_ALPHA = /* @__PURE__ */ new Map([
191
+ ["agy", "chroma"],
192
+ ["codex", "native"],
193
+ ["grok", "chroma"]
194
+ ]);
189
195
  function supportsImageGen(resolved) {
190
196
  return IMAGE_GEN_FORMATS.has(resolved.spec.backend);
191
197
  }
192
198
  function imageFormatFor(resolved) {
193
199
  return IMAGE_GEN_FORMATS.get(resolved.spec.backend);
194
200
  }
201
+ function imageAlphaFor(resolved) {
202
+ return IMAGE_ALPHA.get(resolved.spec.backend);
203
+ }
195
204
  const EFFORTS_SET = /* @__PURE__ */ new Set([
196
205
  "low",
197
206
  "medium",
@@ -231,6 +240,10 @@ function listModelHelpLines(opts = {}) {
231
240
  if (opts.imageOnly && !IMAGE_GEN_FORMATS.has(spec.backend)) continue;
232
241
  lines.push(` ${slug}`);
233
242
  lines.push(` ${spec.brief}`);
243
+ if (opts.imageOnly) {
244
+ const alpha = IMAGE_ALPHA.get(spec.backend);
245
+ if (alpha !== void 0) lines.push(` --transparent: ${alpha === "native" ? "native alpha (soft edges)" : "chroma-keyed (binary edges)"}`);
246
+ }
234
247
  }
235
248
  return lines;
236
249
  }
@@ -299,6 +312,73 @@ function getDriver(backend) {
299
312
  return driver;
300
313
  }
301
314
  //#endregion
315
+ //#region src/transparency.ts
316
+ const CHROMA_CLAUSE = "The entire background must be a perfectly flat solid #00ff00 chroma-key green. The background must be one uniform colour with no shadows, gradients, texture, reflections, or lighting variation. Keep the subject fully separated from the background with crisp edges. Do not use #00ff00 or any similar green anywhere on the subject. No cast shadow, no contact shadow, no reflection.";
317
+ const NATIVE_ALPHA_CLAUSE = "Render the subject on a fully transparent background — PNG with a real alpha channel, no backdrop, no canvas colour, no cast shadow.";
318
+ /**
319
+ * Background-scoped only. `transparent` on its own describes subjects far more
320
+ * often than backdrops ("transparent glass bottle", "goldfish in a transparent
321
+ * bowl"), and refusing those would force `--transparent` onto a brief the chroma
322
+ * clause actively fights.
323
+ */
324
+ const TRANSPARENT_BACKGROUND = /\b(transparent (background|backdrop)|no background|without a background|alpha channel|chroma[- ]?key)/i;
325
+ /** Does the prompt ask for a see-through *background* (as opposed to a see-through subject)? */
326
+ function mentionsTransparentBackground(prompt) {
327
+ return TRANSPARENT_BACKGROUND.test(prompt);
328
+ }
329
+ const GREEN_MIN = 90;
330
+ /**
331
+ * How far green must lead both red and blue for a pixel to count as backdrop.
332
+ * Position, not geometry, decides: a saturated green *subject* is keyed away too.
333
+ * That is inherent to chroma keying — CHROMA_CLAUSE tells the model not to put
334
+ * green on the subject, and a native-alpha seat is the answer when it must be.
335
+ */
336
+ const DOMINANCE = 40;
337
+ async function chromaKeyToPng(src, dest) {
338
+ const { data, info } = await sharp(src).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
339
+ const { width, height, channels } = info;
340
+ if (channels !== 4) throw new Error(`chroma key expected 4-channel RGBA, got ${channels} channels from ${src}`);
341
+ const totalPixels = width * height;
342
+ if (totalPixels === 0) {
343
+ await sharp(data, { raw: {
344
+ width,
345
+ height,
346
+ channels
347
+ } }).png({ compressionLevel: 9 }).toFile(dest);
348
+ return { transparentRatio: 0 };
349
+ }
350
+ const mask = new Uint8Array(totalPixels);
351
+ let keyedCount = 0;
352
+ for (let i = 0; i < totalPixels; i++) {
353
+ const offset = i * channels;
354
+ const r = data[offset] ?? 0;
355
+ const g = data[offset + 1] ?? 0;
356
+ const b = data[offset + 2] ?? 0;
357
+ if (g >= GREEN_MIN && g - r >= DOMINANCE && g - b >= DOMINANCE) {
358
+ data[offset + 3] = 0;
359
+ mask[i] = 1;
360
+ keyedCount++;
361
+ }
362
+ }
363
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
364
+ const idx = y * width + x;
365
+ if (mask[idx] === 1) continue;
366
+ const offset = idx * channels;
367
+ const r = data[offset] ?? 0;
368
+ const g = data[offset + 1] ?? 0;
369
+ const b = data[offset + 2] ?? 0;
370
+ if (g - r >= 20 && g - b >= 20) {
371
+ if (x > 0 && mask[idx - 1] === 1 || x < width - 1 && mask[idx + 1] === 1 || y > 0 && mask[idx - width] === 1 || y < height - 1 && mask[idx + width] === 1) data[offset + 1] = Math.max(r, b);
372
+ }
373
+ }
374
+ await sharp(data, { raw: {
375
+ width,
376
+ height,
377
+ channels
378
+ } }).png({ compressionLevel: 9 }).toFile(dest);
379
+ return { transparentRatio: keyedCount / totalPixels };
380
+ }
381
+ //#endregion
302
382
  //#region src/commands/image-gen/impl.ts
303
383
  const MIN_REAL_BYTES_CODEX = 1e5;
304
384
  const MIN_REAL_BYTES_TOOL = 1e4;
@@ -314,8 +394,15 @@ async function imageGen$1(flags, prompt) {
314
394
  if (model.spec.backend === "codex" && model.effort) return fail(`effort "-${model.effort}" has no effect on image-gen (the image tool renders, not the seat model); pass the un-suffixed slug "${model.spec.slug}" instead.`);
315
395
  const expected = imageFormatFor(model);
316
396
  if (expected === void 0) return fail(formatImageGenModelError(inputSlug, model));
317
- const label = expected === "png" ? "PNG" : "JPEG";
318
- if (!(expected === "png" ? /\.png$/i.test(flags.out) : /\.jpe?g$/i.test(flags.out))) return fail(`--out "${flags.out}" must end in ${expected === "png" ? ".png" : ".jpg or .jpeg"} — the ${model.spec.slug} seat renders ${label} and aibridge does not convert.`);
397
+ const alpha = imageAlphaFor(model);
398
+ if (alpha === void 0) return fail(formatImageGenModelError(inputSlug, model));
399
+ const outFormat = flags.transparent ? "png" : expected;
400
+ const label = outFormat === "png" ? "PNG" : "JPEG";
401
+ if (!(outFormat === "png" ? /\.png$/i.test(flags.out) : /\.jpe?g$/i.test(flags.out))) {
402
+ const reason = flags.transparent && expected === "jpg" ? "--transparent always writes PNG" : `the ${model.spec.slug} seat renders ${label} and aibridge does not convert`;
403
+ return fail(`--out "${flags.out}" must end in ${outFormat === "png" ? ".png" : ".jpg or .jpeg"} — ${reason}.`);
404
+ }
405
+ if (!flags.transparent && alpha === "chroma" && mentionsTransparentBackground(prompt)) return fail(`the prompt asks for a transparent background but the ${model.spec.slug} seat cannot render alpha — pass --transparent (aibridge chroma-keys it locally, binary edges) or use a native-alpha seat (openai-codex/*). Re-run with --transparent to proceed.`);
319
406
  let aspectRatio;
320
407
  if (flags.aspectRatio !== void 0) {
321
408
  const m = flags.aspectRatio.match(/^(\d+)\s*:\s*(\d+)$/);
@@ -334,9 +421,10 @@ async function imageGen$1(flags, prompt) {
334
421
  if (!driver.generateImage) return fail(formatImageGenModelError(inputSlug, model));
335
422
  const minBytes = model.spec.backend === "codex" ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_TOOL;
336
423
  const work = mkdtempSync(join(tmpdir(), "aibridge-imagegen-"));
424
+ const effectivePrompt = flags.transparent ? `${prompt} ${alpha === "chroma" ? CHROMA_CLAUSE : NATIVE_ALPHA_CLAUSE}` : prompt;
337
425
  try {
338
426
  let outcome = await driver.generateImage({
339
- prompt,
427
+ prompt: effectivePrompt,
340
428
  workDir: work,
341
429
  backendModel: backendModelId(model),
342
430
  effort: model.effort,
@@ -347,7 +435,7 @@ async function imageGen$1(flags, prompt) {
347
435
  minBytes
348
436
  });
349
437
  if (model.spec.backend === "codex" && outcome.kind === "suspect") outcome = await driver.generateImage({
350
- prompt,
438
+ prompt: effectivePrompt,
351
439
  workDir: work,
352
440
  backendModel: backendModelId(model),
353
441
  effort: model.effort,
@@ -362,10 +450,21 @@ async function imageGen$1(flags, prompt) {
362
450
  if (outcome.kind === "suspect") return fail(model.spec.backend === "agy" ? "agy produced no usable image. Re-run with a simpler prompt, or check Antigravity image access." : model.spec.backend === "grok" ? "grok produced no usable image. Check SuperGrok image access and re-run with a simpler prompt." : "codex produced only a tiny/code-drawn image, not a real render. Try a clearer, simpler prompt.");
363
451
  const local = join(work, "result.bin");
364
452
  copyFileSync(outcome.path, local);
365
- const dims = imageSize(local);
366
- const actual = pngSize(local) ? "png" : jpegSize(local) ? "jpg" : null;
367
- if (actual !== null && actual !== expected) this.process.stderr.write(`aibridge image-gen: expected a ${label} render from this seat but got ${actual === "png" ? "PNG" : "JPEG"}; wrote the raw bytes to ${outPath} anyway — the extension does not match the contents.\n`);
368
- copyFileSync(local, outPath);
453
+ let artefact = local;
454
+ let transparency = null;
455
+ if (flags.transparent) if ((await sharp(local).metadata()).hasAlpha === true) transparency = "native";
456
+ else {
457
+ const keyed = join(work, "keyed.png");
458
+ const { transparentRatio } = await chromaKeyToPng(local, keyed);
459
+ transparency = "chroma";
460
+ if (transparentRatio < .02) this.process.stderr.write(`aibridge image-gen: --transparent keyed only ${(transparentRatio * 100).toFixed(1)}% of the image — the model likely ignored the chroma-key instruction; wrote it anyway. Re-run, or use a native-alpha seat.\n`);
461
+ artefact = keyed;
462
+ }
463
+ const dims = imageSize(artefact);
464
+ const actual = pngSize(artefact) ? "png" : jpegSize(artefact) ? "jpg" : null;
465
+ if (actual !== null && actual !== outFormat) this.process.stderr.write(`aibridge image-gen: expected a ${label} render from this seat but got ${actual === "png" ? "PNG" : "JPEG"}; wrote the raw bytes to ${outPath} anyway — the extension does not match the contents.\n`);
466
+ mkdirSync(dirname(outPath), { recursive: true });
467
+ copyFileSync(artefact, outPath);
369
468
  const bytes = statSync(outPath).size;
370
469
  if (flags.json) this.process.stdout.write(`${JSON.stringify({
371
470
  out: outPath,
@@ -375,12 +474,14 @@ async function imageGen$1(flags, prompt) {
375
474
  aspectRatio: flags.aspectRatio ?? null,
376
475
  model: model.spec.slug,
377
476
  backend: model.spec.backend,
477
+ transparency,
378
478
  real: true
379
479
  })}\n`);
380
480
  else {
381
481
  const kb = Math.round(bytes / 1024);
382
482
  const dimStr = dims ? `${dims.width}x${dims.height}, ` : "";
383
- this.process.stdout.write(`✓ Wrote ${outPath} (${dimStr}${kb} KB, ${model.spec.slug})\n`);
483
+ const transparencyStr = flags.transparent ? `, transparency: ${transparency === "native" ? "native alpha" : "chroma-keyed"}` : "";
484
+ this.process.stdout.write(`✓ Wrote ${outPath} (${dimStr}${kb} KB, ${model.spec.slug}${transparencyStr})\n`);
384
485
  }
385
486
  } finally {
386
487
  rmSync(work, {
@@ -459,7 +560,12 @@ const imageGen = buildCommand({
459
560
  out: {
460
561
  kind: "parsed",
461
562
  parse: String,
462
- brief: "Path to write the image — extension must match the seat format (.png for codex, .jpg for agy/grok)"
563
+ brief: "Path to write the image — extension must match the seat format (.png for codex or any --transparent run, .jpg for agy/grok otherwise)"
564
+ },
565
+ transparent: {
566
+ kind: "boolean",
567
+ withNegated: false,
568
+ brief: "Transparent background: native alpha where the seat has it, chroma-keyed otherwise — always writes PNG"
463
569
  },
464
570
  aspectRatio: {
465
571
  kind: "parsed",
@@ -876,6 +982,10 @@ function modelsImpl(flags) {
876
982
  spec,
877
983
  effort: void 0
878
984
  }) ?? null,
985
+ imageAlpha: imageAlphaFor({
986
+ spec,
987
+ effort: void 0
988
+ }) ?? null,
879
989
  brief: spec.brief
880
990
  }));
881
991
  this.process.stdout.write(`${JSON.stringify(jsonOutput)}\n`);
@@ -900,7 +1010,12 @@ function modelsImpl(flags) {
900
1010
  spec,
901
1011
  effort: void 0
902
1012
  });
903
- segments.push(`image: ${img ?? "—"}`);
1013
+ const alpha = imageAlphaFor({
1014
+ spec,
1015
+ effort: void 0
1016
+ });
1017
+ const imageStr = img ? `${img} (alpha: ${alpha ?? "—"})` : "—";
1018
+ segments.push(`image: ${imageStr}`);
904
1019
  segments.push(`id: ${spec.backendModel}`);
905
1020
  this.process.stdout.write(` ${segments.join(" · ")}\n`);
906
1021
  this.process.stdout.write(` ${spec.brief}\n`);
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { S as supportsImageGen, _ as backendModelId, a as readRunLogs, b as listModelHelpLines, c as evaluateCodexPreflight, d as renderPreflightRefusal, f as delegate, g as MODELS, h as positiveIntSeconds, i as listRuns, l as preflightCodex, m as nonEmptyPrompt, n as app, o as startRun, p as getDriver, r as runCli, s as evaluateAgyPreflight, t as buildContext, u as preflightModel, v as formatImageGenModelError, x as resolveModel, y as formatUnknownModelError } from "./context-F8WLXzPv.mjs";
1
+ import { S as supportsImageGen, _ as backendModelId, a as readRunLogs, b as listModelHelpLines, c as evaluateCodexPreflight, d as renderPreflightRefusal, f as delegate, g as MODELS, h as positiveIntSeconds, i as listRuns, l as preflightCodex, m as nonEmptyPrompt, n as app, o as startRun, p as getDriver, r as runCli, s as evaluateAgyPreflight, t as buildContext, u as preflightModel, v as formatImageGenModelError, x as resolveModel, y as formatUnknownModelError } from "./context-DJtEcg6f.mjs";
2
2
  export { MODELS, app, backendModelId, buildContext, delegate, evaluateAgyPreflight, evaluateCodexPreflight, formatImageGenModelError, formatUnknownModelError, getDriver, listModelHelpLines, listRuns, nonEmptyPrompt, positiveIntSeconds, preflightCodex, preflightModel, readRunLogs, renderPreflightRefusal, resolveModel, runCli, startRun, supportsImageGen };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibridge/cli",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "CLI that bridges tasks to AI CLIs on your machine (plan / implement / review / subagent / image-gen)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,11 +35,12 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@stricli/core": "1.3.0",
38
- "@aibridge/proc": "0.5.1",
39
- "@aibridge/driver-grok": "0.5.1",
40
- "@aibridge/driver-codex": "0.5.1",
41
- "@aibridge/driver-agy": "0.5.1",
42
- "@aibridge/driver-claude": "0.5.1"
38
+ "sharp": "^0.35.3",
39
+ "@aibridge/driver-agy": "0.7.0",
40
+ "@aibridge/proc": "0.7.0",
41
+ "@aibridge/driver-grok": "0.7.0",
42
+ "@aibridge/driver-codex": "0.7.0",
43
+ "@aibridge/driver-claude": "0.7.0"
43
44
  },
44
45
  "devDependencies": {
45
46
  "tsdown": "0.22.14"
@@ -25,7 +25,13 @@ export const imageGen = buildCommand({
25
25
  kind: 'parsed',
26
26
  parse: String,
27
27
  brief:
28
- 'Path to write the image — extension must match the seat format (.png for codex, .jpg for agy/grok)',
28
+ 'Path to write the image — extension must match the seat format (.png for codex or any --transparent run, .jpg for agy/grok otherwise)',
29
+ },
30
+ transparent: {
31
+ kind: 'boolean',
32
+ withNegated: false,
33
+ brief:
34
+ 'Transparent background: native alpha where the seat has it, chroma-keyed otherwise — always writes PNG',
29
35
  },
30
36
  aspectRatio: {
31
37
  kind: 'parsed',
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { LocalContext } from '../../context.ts';
3
+ import imageGen, { type ImageGenFlags } from './impl.ts';
4
+
5
+ /**
6
+ * Both refusals below must land before any backend spawns — that is the whole
7
+ * point of them, so these tests deliberately use a context with no driver stub:
8
+ * if validation ever moves after `getDriver`, they fail by trying to run a real CLI.
9
+ */
10
+ const ctx = (): { ctx: LocalContext; stderr: () => string } => {
11
+ let stderr = '';
12
+ const fake = {
13
+ process: {
14
+ stderr: {
15
+ write: (s: string) => {
16
+ stderr += s;
17
+ return true;
18
+ },
19
+ },
20
+ stdout: { write: () => true },
21
+ cwd: () => '/tmp',
22
+ exitCode: 0,
23
+ },
24
+ };
25
+ return { ctx: fake as unknown as LocalContext, stderr: () => stderr };
26
+ };
27
+
28
+ const flags = (over: Partial<ImageGenFlags>): ImageGenFlags => ({
29
+ model: 'xai-grok/grok-4.6',
30
+ out: '/tmp/out.jpg',
31
+ json: false,
32
+ transparent: false,
33
+ ...over,
34
+ });
35
+
36
+ describe('image-gen validation', () => {
37
+ it('refuses --transparent with a .jpg --out', async () => {
38
+ const { ctx: c, stderr } = ctx();
39
+ await imageGen.call(c, flags({ transparent: true }), 'a fox');
40
+ expect(stderr()).toContain('must end in .png');
41
+ expect(stderr()).toContain('--transparent always writes PNG');
42
+ expect(c.process.exitCode).toBe(1);
43
+ });
44
+
45
+ it('refuses a transparent-background prompt on a chroma seat without the flag', async () => {
46
+ const { ctx: c, stderr } = ctx();
47
+ await imageGen.call(c, flags({}), 'a fox on a transparent background');
48
+ expect(stderr()).toContain('cannot render alpha');
49
+ expect(stderr()).toContain('--transparent');
50
+ expect(c.process.exitCode).toBe(1);
51
+ });
52
+
53
+ it('does not refuse a see-through subject', async () => {
54
+ const { ctx: c, stderr } = ctx();
55
+ // A transparent *subject* is a normal brief; only the background phrasing is
56
+ // a capability mismatch. This one must fall through to the .jpg/.png check.
57
+ await imageGen.call(c, flags({ out: '/tmp/out.png' }), 'a transparent glass bottle');
58
+ expect(stderr()).not.toContain('cannot render alpha');
59
+ expect(stderr()).toContain('must end in .jpg or .jpeg');
60
+ });
61
+ });
@@ -2,6 +2,7 @@ import {
2
2
  closeSync,
3
3
  copyFileSync,
4
4
  existsSync,
5
+ mkdirSync,
5
6
  mkdtempSync,
6
7
  openSync,
7
8
  readSync,
@@ -9,7 +10,8 @@ import {
9
10
  statSync,
10
11
  } from 'node:fs';
11
12
  import { tmpdir } from 'node:os';
12
- import { join, resolve } from 'node:path';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import sharp from 'sharp';
13
15
  import type { LocalContext } from '../../context.ts';
14
16
  import type { ImageResult } from '../../driver.ts';
15
17
  import { getDriver } from '../../drivers.ts';
@@ -17,10 +19,18 @@ import {
17
19
  backendModelId,
18
20
  formatImageGenModelError,
19
21
  formatUnknownModelError,
22
+ type ImageFormat,
23
+ imageAlphaFor,
20
24
  imageFormatFor,
21
25
  resolveModel,
22
26
  supportsImageGen,
23
27
  } from '../../models.ts';
28
+ import {
29
+ CHROMA_CLAUSE,
30
+ chromaKeyToPng,
31
+ mentionsTransparentBackground,
32
+ NATIVE_ALPHA_CLAUSE,
33
+ } from '../../transparency.ts';
24
34
 
25
35
  export interface ImageGenFlags {
26
36
  readonly model: string;
@@ -29,6 +39,7 @@ export interface ImageGenFlags {
29
39
  readonly image?: string;
30
40
  readonly timeout?: number;
31
41
  readonly json: boolean;
42
+ readonly transparent: boolean;
32
43
  }
33
44
 
34
45
  const MIN_REAL_BYTES_CODEX = 100_000;
@@ -58,11 +69,27 @@ export default async function imageGen(
58
69
  const expected = imageFormatFor(model);
59
70
  if (expected === undefined) return fail(formatImageGenModelError(inputSlug, model));
60
71
 
61
- const label = expected === 'png' ? 'PNG' : 'JPEG';
62
- const extValid = expected === 'png' ? /\.png$/i.test(flags.out) : /\.jpe?g$/i.test(flags.out);
72
+ const alpha = imageAlphaFor(model);
73
+ if (alpha === undefined) return fail(formatImageGenModelError(inputSlug, model));
74
+
75
+ const outFormat: ImageFormat = flags.transparent ? 'png' : expected;
76
+ const label = outFormat === 'png' ? 'PNG' : 'JPEG';
77
+ const extValid = outFormat === 'png' ? /\.png$/i.test(flags.out) : /\.jpe?g$/i.test(flags.out);
63
78
  if (!extValid) {
79
+ const reason =
80
+ flags.transparent && expected === 'jpg'
81
+ ? '--transparent always writes PNG'
82
+ : `the ${model.spec.slug} seat renders ${label} and aibridge does not convert`;
83
+ return fail(
84
+ `--out "${flags.out}" must end in ${outFormat === 'png' ? '.png' : '.jpg or .jpeg'} — ${reason}.`,
85
+ );
86
+ }
87
+
88
+ if (!flags.transparent && alpha === 'chroma' && mentionsTransparentBackground(prompt)) {
64
89
  return fail(
65
- `--out "${flags.out}" must end in ${expected === 'png' ? '.png' : '.jpg or .jpeg'} — the ${model.spec.slug} seat renders ${label} and aibridge does not convert.`,
90
+ `the prompt asks for a transparent background but the ${model.spec.slug} seat cannot render alpha ` +
91
+ `pass --transparent (aibridge chroma-keys it locally, binary edges) or use a native-alpha seat ` +
92
+ `(openai-codex/*). Re-run with --transparent to proceed.`,
66
93
  );
67
94
  }
68
95
 
@@ -98,9 +125,17 @@ export default async function imageGen(
98
125
  const minBytes = model.spec.backend === 'codex' ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_TOOL;
99
126
  const work = mkdtempSync(join(tmpdir(), 'aibridge-imagegen-'));
100
127
 
128
+ // Single space, never a newline: the codex driver wraps the prompt in a one-line
129
+ // `$imagegen …` invocation, and a blank line there makes it code-draw a substitute
130
+ // instead of calling the image tool (observed: --transparent failed while the same
131
+ // prompt without the clause rendered fine).
132
+ const effectivePrompt = flags.transparent
133
+ ? `${prompt} ${alpha === 'chroma' ? CHROMA_CLAUSE : NATIVE_ALPHA_CLAUSE}`
134
+ : prompt;
135
+
101
136
  try {
102
137
  let outcome: ImageResult = await driver.generateImage({
103
- prompt,
138
+ prompt: effectivePrompt,
104
139
  workDir: work,
105
140
  backendModel: backendModelId(model),
106
141
  effort: model.effort,
@@ -113,7 +148,7 @@ export default async function imageGen(
113
148
 
114
149
  if (model.spec.backend === 'codex' && outcome.kind === 'suspect') {
115
150
  outcome = await driver.generateImage({
116
- prompt,
151
+ prompt: effectivePrompt,
117
152
  workDir: work,
118
153
  backendModel: backendModelId(model),
119
154
  effort: model.effort,
@@ -144,17 +179,40 @@ export default async function imageGen(
144
179
  const local = join(work, 'result.bin');
145
180
  copyFileSync(outcome.path, local);
146
181
 
147
- const dims = imageSize(local);
148
- const actual = pngSize(local) ? 'png' : jpegSize(local) ? 'jpg' : null;
182
+ let artefact = local;
183
+ let transparency: 'native' | 'chroma' | null = null;
184
+ if (flags.transparent) {
185
+ const meta = await sharp(local).metadata();
186
+ const alreadyAlpha = meta.hasAlpha === true;
187
+ if (alreadyAlpha) {
188
+ transparency = 'native';
189
+ } else {
190
+ const keyed = join(work, 'keyed.png');
191
+ const { transparentRatio } = await chromaKeyToPng(local, keyed);
192
+ transparency = 'chroma';
193
+ if (transparentRatio < 0.02) {
194
+ this.process.stderr.write(
195
+ `aibridge image-gen: --transparent keyed only ${(transparentRatio * 100).toFixed(1)}% of the image — ` +
196
+ `the model likely ignored the chroma-key instruction; wrote it anyway. Re-run, or use a native-alpha seat.\n`,
197
+ );
198
+ }
199
+ artefact = keyed;
200
+ }
201
+ }
202
+
203
+ const dims = imageSize(artefact);
204
+ const actual = pngSize(artefact) ? 'png' : jpegSize(artefact) ? 'jpg' : null;
149
205
 
150
206
  // ponytail: guard for a backend changing formats in the future without throwing away a paid render
151
- if (actual !== null && actual !== expected) {
207
+ if (actual !== null && actual !== outFormat) {
152
208
  this.process.stderr.write(
153
209
  `aibridge image-gen: expected a ${label} render from this seat but got ${actual === 'png' ? 'PNG' : 'JPEG'}; wrote the raw bytes to ${outPath} anyway — the extension does not match the contents.\n`,
154
210
  );
155
211
  }
156
212
 
157
- copyFileSync(local, outPath);
213
+ // The render is already paid for — don't lose it to a missing --out directory.
214
+ mkdirSync(dirname(outPath), { recursive: true });
215
+ copyFileSync(artefact, outPath);
158
216
  const bytes = statSync(outPath).size;
159
217
 
160
218
  if (flags.json) {
@@ -167,13 +225,19 @@ export default async function imageGen(
167
225
  aspectRatio: flags.aspectRatio ?? null,
168
226
  model: model.spec.slug,
169
227
  backend: model.spec.backend,
228
+ transparency,
170
229
  real: true,
171
230
  })}\n`,
172
231
  );
173
232
  } else {
174
233
  const kb = Math.round(bytes / 1024);
175
234
  const dimStr = dims ? `${dims.width}x${dims.height}, ` : '';
176
- this.process.stdout.write(`✓ Wrote ${outPath} (${dimStr}${kb} KB, ${model.spec.slug})\n`);
235
+ const transparencyStr = flags.transparent
236
+ ? `, transparency: ${transparency === 'native' ? 'native alpha' : 'chroma-keyed'}`
237
+ : '';
238
+ this.process.stdout.write(
239
+ `✓ Wrote ${outPath} (${dimStr}${kb} KB, ${model.spec.slug}${transparencyStr})\n`,
240
+ );
177
241
  }
178
242
  } finally {
179
243
  rmSync(work, { recursive: true, force: true });
@@ -27,7 +27,7 @@ function createTestContext() {
27
27
  }
28
28
 
29
29
  describe('modelsImpl', () => {
30
- it('--json emits parseable JSON with one entry per key of MODELS, with seven documented fields', () => {
30
+ it('--json emits parseable JSON with one entry per key of MODELS, with eight documented fields', () => {
31
31
  const { ctx, getStdout } = createTestContext();
32
32
  modelsImpl.call(ctx, { json: true });
33
33
 
@@ -44,8 +44,9 @@ describe('modelsImpl', () => {
44
44
  expect(item).toHaveProperty('efforts');
45
45
  expect(item).toHaveProperty('defaultEffort');
46
46
  expect(item).toHaveProperty('image');
47
+ expect(item).toHaveProperty('imageAlpha');
47
48
  expect(item).toHaveProperty('brief');
48
- expect(Object.keys(item)).toHaveLength(7);
49
+ expect(Object.keys(item)).toHaveLength(8);
49
50
  }
50
51
  });
51
52
 
@@ -62,7 +63,7 @@ describe('modelsImpl', () => {
62
63
  expect(geminiPro.defaultEffort).toBe('high');
63
64
  });
64
65
 
65
- it('reports backendModel and image correctly for opus-5 and gpt-5.6-sol in JSON', () => {
66
+ it('reports backendModel, image, and imageAlpha correctly for opus-5 and gpt-5.6-sol in JSON', () => {
66
67
  const { ctx, getStdout } = createTestContext();
67
68
  modelsImpl.call(ctx, { json: true });
68
69
 
@@ -71,10 +72,12 @@ describe('modelsImpl', () => {
71
72
  expect(opus).toBeDefined();
72
73
  expect(opus.backendModel).toBe('claude-opus-5[1m]');
73
74
  expect(opus.image).toBeNull();
75
+ expect(opus.imageAlpha).toBeNull();
74
76
 
75
77
  const sol = data.find((item: { slug: string }) => item.slug === 'openai-codex/gpt-5.6-sol');
76
78
  expect(sol).toBeDefined();
77
79
  expect(sol.image).toBe('png');
80
+ expect(sol.imageAlpha).toBe('native');
78
81
  });
79
82
 
80
83
  it('human output (no --json) contains every slug in MODELS', () => {
@@ -1,5 +1,5 @@
1
1
  import type { LocalContext } from '../../context.ts';
2
- import { type Backend, imageFormatFor, MODELS } from '../../models.ts';
2
+ import { type Backend, imageAlphaFor, imageFormatFor, MODELS } from '../../models.ts';
3
3
 
4
4
  export interface ModelsFlags {
5
5
  readonly json: boolean;
@@ -23,6 +23,7 @@ export default function modelsImpl(this: LocalContext, flags: ModelsFlags): void
23
23
  efforts: spec.efforts ? [...spec.efforts] : [],
24
24
  defaultEffort: spec.defaultEffort ?? null,
25
25
  image: imageFormatFor({ spec, effort: undefined }) ?? null,
26
+ imageAlpha: imageAlphaFor({ spec, effort: undefined }) ?? null,
26
27
  brief: spec.brief,
27
28
  }));
28
29
  this.process.stdout.write(`${JSON.stringify(jsonOutput)}\n`);
@@ -56,7 +57,9 @@ export default function modelsImpl(this: LocalContext, flags: ModelsFlags): void
56
57
  segments.push(`efforts: ${formattedEfforts}`);
57
58
  }
58
59
  const img = imageFormatFor({ spec, effort: undefined });
59
- segments.push(`image: ${img ?? '—'}`);
60
+ const alpha = imageAlphaFor({ spec, effort: undefined });
61
+ const imageStr = img ? `${img} (alpha: ${alpha ?? '—'})` : '—';
62
+ segments.push(`image: ${imageStr}`);
60
63
  segments.push(`id: ${spec.backendModel}`);
61
64
 
62
65
  this.process.stdout.write(` ${segments.join(' · ')}\n`);
@@ -3,6 +3,7 @@ import {
3
3
  backendModelId,
4
4
  formatImageGenModelError,
5
5
  formatUnknownModelError,
6
+ imageAlphaFor,
6
7
  listModelHelpLines,
7
8
  resolveModel,
8
9
  supportsImageGen,
@@ -98,12 +99,26 @@ describe('models registry', () => {
98
99
  expect(supportsImageGen(claudeSonnet)).toBe(false);
99
100
  });
100
101
 
101
- it('lists only image-capable seats when imageOnly', () => {
102
+ it('lists only image-capable seats when imageOnly and shows --transparent capability', () => {
102
103
  const lines = listModelHelpLines({ imageOnly: true }).join('\n');
103
104
  expect(lines).toContain('xai-grok/grok-4.6');
104
105
  expect(lines).toContain('openai-codex/gpt-5.6-sol');
105
106
  expect(lines).toContain('google-antigravity/gemini-3.7-flash');
106
107
  expect(lines).not.toContain('anthropic-claude/sonnet-5');
108
+ expect(lines).toContain('--transparent: native alpha (soft edges)');
109
+ expect(lines).toContain('--transparent: chroma-keyed (binary edges)');
110
+ });
111
+
112
+ it('reports correct imageAlpha for seats', () => {
113
+ const codex = resolveModel('openai-codex/gpt-5.6-sol');
114
+ const grok = resolveModel('xai-grok/grok-4.6');
115
+ const gemini = resolveModel('google-antigravity/gemini-3.7-flash');
116
+ const claudeSonnet = resolveModel('anthropic-claude/sonnet-5');
117
+ if (!codex || !grok || !gemini || !claudeSonnet) throw new Error('resolution failed');
118
+ expect(imageAlphaFor(codex)).toBe('native');
119
+ expect(imageAlphaFor(grok)).toBe('chroma');
120
+ expect(imageAlphaFor(gemini)).toBe('chroma');
121
+ expect(imageAlphaFor(claudeSonnet)).toBeUndefined();
107
122
  });
108
123
 
109
124
  it('formats image-gen model errors with capable seats only', () => {
package/src/models.ts CHANGED
@@ -150,12 +150,21 @@ export const MODELS: Record<string, ModelSpec> = {
150
150
 
151
151
  export type ImageFormat = 'jpg' | 'png';
152
152
 
153
+ /** Whether a seat's image tool can emit a real alpha channel, or needs local chroma keying. */
154
+ export type ImageAlpha = 'native' | 'chroma';
155
+
153
156
  const IMAGE_GEN_FORMATS: ReadonlyMap<Backend, ImageFormat> = new Map([
154
157
  ['agy', 'jpg'],
155
158
  ['codex', 'png'],
156
159
  ['grok', 'jpg'],
157
160
  ]);
158
161
 
162
+ const IMAGE_ALPHA: ReadonlyMap<Backend, ImageAlpha> = new Map([
163
+ ['agy', 'chroma'],
164
+ ['codex', 'native'],
165
+ ['grok', 'chroma'],
166
+ ]);
167
+
159
168
  export function supportsImageGen(resolved: ResolvedModel): boolean {
160
169
  return IMAGE_GEN_FORMATS.has(resolved.spec.backend);
161
170
  }
@@ -164,6 +173,10 @@ export function imageFormatFor(resolved: ResolvedModel): ImageFormat | undefined
164
173
  return IMAGE_GEN_FORMATS.get(resolved.spec.backend);
165
174
  }
166
175
 
176
+ export function imageAlphaFor(resolved: ResolvedModel): ImageAlpha | undefined {
177
+ return IMAGE_ALPHA.get(resolved.spec.backend);
178
+ }
179
+
167
180
  const EFFORTS_SET: ReadonlySet<string> = new Set<Effort>(['low', 'medium', 'high', 'xhigh', 'max']);
168
181
 
169
182
  export function resolveModel(input: string): ResolvedModel | undefined {
@@ -208,6 +221,14 @@ export function listModelHelpLines(opts: { readonly imageOnly?: boolean } = {}):
208
221
  if (opts.imageOnly && !IMAGE_GEN_FORMATS.has(spec.backend)) continue;
209
222
  lines.push(` ${slug}`);
210
223
  lines.push(` ${spec.brief}`);
224
+ if (opts.imageOnly) {
225
+ const alpha = IMAGE_ALPHA.get(spec.backend);
226
+ if (alpha !== undefined) {
227
+ lines.push(
228
+ ` --transparent: ${alpha === 'native' ? 'native alpha (soft edges)' : 'chroma-keyed (binary edges)'}`,
229
+ );
230
+ }
231
+ }
211
232
  }
212
233
  return lines;
213
234
  }
@@ -0,0 +1,320 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import sharp from 'sharp';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { chromaKeyToPng, mentionsTransparentBackground } from './transparency.ts';
7
+
8
+ describe('chromaKeyToPng', () => {
9
+ let tempDir: string;
10
+
11
+ beforeEach(() => {
12
+ tempDir = mkdtempSync(join(tmpdir(), 'aibridge-transparency-test-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(tempDir, { recursive: true, force: true });
17
+ });
18
+
19
+ it('keys the background, keeps the subject', async () => {
20
+ const width = 512;
21
+ const height = 512;
22
+ const buf = Buffer.alloc(width * height * 3);
23
+
24
+ // Fill with green background (17, 249, 19)
25
+ for (let i = 0; i < width * height; i++) {
26
+ const offset = i * 3;
27
+ buf[offset] = 17;
28
+ buf[offset + 1] = 249;
29
+ buf[offset + 2] = 19;
30
+ }
31
+
32
+ // Solid red (230, 80, 30) square in the center (from 200 to 312)
33
+ for (let y = 200; y < 312; y++) {
34
+ for (let x = 200; x < 312; x++) {
35
+ const offset = (y * width + x) * 3;
36
+ buf[offset] = 230;
37
+ buf[offset + 1] = 80;
38
+ buf[offset + 2] = 30;
39
+ }
40
+ }
41
+
42
+ const src = join(tempDir, 'input.png');
43
+ const dest = join(tempDir, 'output.png');
44
+
45
+ await sharp(buf, { raw: { width, height, channels: 3 } }).toFile(src);
46
+
47
+ const { transparentRatio } = await chromaKeyToPng(src, dest);
48
+
49
+ expect(transparentRatio).toBeGreaterThanOrEqual(0.7);
50
+ expect(transparentRatio).toBeLessThanOrEqual(0.99);
51
+
52
+ const { data } = await sharp(dest).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53
+
54
+ // Corner pixel (0, 0) should have alpha 0
55
+ const cornerOffset = (0 * width + 0) * 4;
56
+ expect(data[cornerOffset + 3]).toBe(0);
57
+
58
+ // Centre pixel (256, 256) should have alpha 255 and RGB (230, 80, 30)
59
+ const centreOffset = (256 * width + 256) * 4;
60
+ expect(data[centreOffset]).toBe(230);
61
+ expect(data[centreOffset + 1]).toBe(80);
62
+ expect(data[centreOffset + 2]).toBe(30);
63
+ expect(data[centreOffset + 3]).toBe(255);
64
+ });
65
+
66
+ it('interior light pixels survive', async () => {
67
+ const width = 512;
68
+ const height = 512;
69
+ const buf = Buffer.alloc(width * height * 3);
70
+
71
+ // Fill background with green (17, 249, 19)
72
+ for (let i = 0; i < width * height; i++) {
73
+ const offset = i * 3;
74
+ buf[offset] = 17;
75
+ buf[offset + 1] = 249;
76
+ buf[offset + 2] = 19;
77
+ }
78
+
79
+ // Red square (200..312)
80
+ for (let y = 200; y < 312; y++) {
81
+ for (let x = 200; x < 312; x++) {
82
+ const offset = (y * width + x) * 3;
83
+ buf[offset] = 230;
84
+ buf[offset + 1] = 80;
85
+ buf[offset + 2] = 30;
86
+ }
87
+ }
88
+
89
+ // White (255, 255, 255) block inside red square (240..270)
90
+ for (let y = 240; y < 270; y++) {
91
+ for (let x = 240; x < 270; x++) {
92
+ const offset = (y * width + x) * 3;
93
+ buf[offset] = 255;
94
+ buf[offset + 1] = 255;
95
+ buf[offset + 2] = 255;
96
+ }
97
+ }
98
+
99
+ const src = join(tempDir, 'input.png');
100
+ const dest = join(tempDir, 'output.png');
101
+
102
+ await sharp(buf, { raw: { width, height, channels: 3 } }).toFile(src);
103
+ await chromaKeyToPng(src, dest);
104
+
105
+ const { data } = await sharp(dest).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
106
+
107
+ const whiteOffset = (255 * width + 255) * 4;
108
+ expect(data[whiteOffset]).toBe(255);
109
+ expect(data[whiteOffset + 1]).toBe(255);
110
+ expect(data[whiteOffset + 2]).toBe(255);
111
+ expect(data[whiteOffset + 3]).toBe(255);
112
+ });
113
+
114
+ it('keys a saturated green subject too — the standing cost of chroma keying', async () => {
115
+ const width = 64;
116
+ const height = 64;
117
+ const buf = Buffer.alloc(width * height * 3);
118
+
119
+ // Green background (17, 249, 19) with a red square, and a saturated green
120
+ // (40, 200, 60) block deep inside the subject. Dominance is 160/140, far past
121
+ // DOMINANCE — so it keys, wherever it sits. A chroma key cannot tell a green
122
+ // subject from a green backdrop; CHROMA_CLAUSE tells the model not to make one,
123
+ // and codex is the seat for subjects that must be green.
124
+ for (let i = 0; i < width * height; i++) {
125
+ const offset = i * 3;
126
+ buf[offset] = 17;
127
+ buf[offset + 1] = 249;
128
+ buf[offset + 2] = 19;
129
+ }
130
+ for (let y = 16; y < 48; y++) {
131
+ for (let x = 16; x < 48; x++) {
132
+ const offset = (y * width + x) * 3;
133
+ buf[offset] = 230;
134
+ buf[offset + 1] = 80;
135
+ buf[offset + 2] = 30;
136
+ }
137
+ }
138
+ for (let y = 28; y < 36; y++) {
139
+ for (let x = 28; x < 36; x++) {
140
+ const offset = (y * width + x) * 3;
141
+ buf[offset] = 40;
142
+ buf[offset + 1] = 200;
143
+ buf[offset + 2] = 60;
144
+ }
145
+ }
146
+
147
+ const src = join(tempDir, 'input.png');
148
+ const dest = join(tempDir, 'output.png');
149
+ await sharp(buf, { raw: { width, height, channels: 3 } }).toFile(src);
150
+ await chromaKeyToPng(src, dest);
151
+
152
+ const { data } = await sharp(dest).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
153
+ expect(data[(32 * width + 32) * 4 + 3]).toBe(0); // green block: keyed
154
+ expect(data[(20 * width + 20) * 4 + 3]).toBe(255); // red subject: kept
155
+ });
156
+
157
+ it('keeps a mildly green subject (below the dominance threshold) and does not despill it', async () => {
158
+ const width = 512;
159
+ const height = 512;
160
+ const buf = Buffer.alloc(width * height * 3);
161
+
162
+ // Fill background with green (17, 249, 19)
163
+ for (let i = 0; i < width * height; i++) {
164
+ const offset = i * 3;
165
+ buf[offset] = 17;
166
+ buf[offset + 1] = 249;
167
+ buf[offset + 2] = 19;
168
+ }
169
+
170
+ // Red square (200..312)
171
+ for (let y = 200; y < 312; y++) {
172
+ for (let x = 200; x < 312; x++) {
173
+ const offset = (y * width + x) * 3;
174
+ buf[offset] = 230;
175
+ buf[offset + 1] = 80;
176
+ buf[offset + 2] = 30;
177
+ }
178
+ }
179
+
180
+ // Green subject (130, 160, 130) fully inside red square (230..250, >2px from background edge at 200)
181
+ // g - r = 30 (< DOMINANCE 40 so not keyed in pass 1, >= 20 so would be despilled if ungated)
182
+ for (let y = 230; y < 250; y++) {
183
+ for (let x = 230; x < 250; x++) {
184
+ const offset = (y * width + x) * 3;
185
+ buf[offset] = 130;
186
+ buf[offset + 1] = 160;
187
+ buf[offset + 2] = 130;
188
+ }
189
+ }
190
+
191
+ const src = join(tempDir, 'input.png');
192
+ const dest = join(tempDir, 'output.png');
193
+
194
+ await sharp(buf, { raw: { width, height, channels: 3 } }).toFile(src);
195
+ await chromaKeyToPng(src, dest);
196
+
197
+ const { data } = await sharp(dest).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
198
+
199
+ const greenOffset = (240 * width + 240) * 4;
200
+ expect(data[greenOffset]).toBe(130);
201
+ expect(data[greenOffset + 1]).toBe(160);
202
+ expect(data[greenOffset + 2]).toBe(130);
203
+ expect(data[greenOffset + 3]).toBe(255);
204
+ });
205
+
206
+ it('fringe is despilled', async () => {
207
+ const width = 512;
208
+ const height = 512;
209
+ const buf = Buffer.alloc(width * height * 3);
210
+
211
+ // Fill background with green (17, 249, 19)
212
+ for (let i = 0; i < width * height; i++) {
213
+ const offset = i * 3;
214
+ buf[offset] = 17;
215
+ buf[offset + 1] = 249;
216
+ buf[offset + 2] = 19;
217
+ }
218
+
219
+ // Red square interior (201..311)
220
+ for (let y = 201; y < 311; y++) {
221
+ for (let x = 201; x < 311; x++) {
222
+ const offset = (y * width + x) * 3;
223
+ buf[offset] = 230;
224
+ buf[offset + 1] = 80;
225
+ buf[offset + 2] = 30;
226
+ }
227
+ }
228
+
229
+ // 1-pixel fringe ring at y=200, y=311, x=200, x=311 with green-tinted (160, 190, 160)
230
+ // where g - r = 30 (>= 20 for despill, < 40 so not keyed)
231
+ for (let x = 200; x <= 311; x++) {
232
+ for (const y of [200, 311]) {
233
+ const offset = (y * width + x) * 3;
234
+ buf[offset] = 160;
235
+ buf[offset + 1] = 190;
236
+ buf[offset + 2] = 160;
237
+ }
238
+ }
239
+ for (let y = 200; y <= 311; y++) {
240
+ for (const x of [200, 311]) {
241
+ const offset = (y * width + x) * 3;
242
+ buf[offset] = 160;
243
+ buf[offset + 1] = 190;
244
+ buf[offset + 2] = 160;
245
+ }
246
+ }
247
+
248
+ const src = join(tempDir, 'input.png');
249
+ const dest = join(tempDir, 'output.png');
250
+
251
+ await sharp(buf, { raw: { width, height, channels: 3 } }).toFile(src);
252
+ await chromaKeyToPng(src, dest);
253
+
254
+ const { data } = await sharp(dest).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
255
+
256
+ // Fringe pixel at (200, 200) was adjacent to keyed background
257
+ const fringeOffset = (200 * width + 200) * 4;
258
+ expect(data[fringeOffset + 3]).toBe(255); // stays opaque
259
+ expect(data[fringeOffset + 1]).toBeLessThanOrEqual(
260
+ Math.max(data[fringeOffset] ?? 0, data[fringeOffset + 2] ?? 0),
261
+ );
262
+ expect(data[fringeOffset + 1]).toBe(160);
263
+ });
264
+
265
+ it('handles an image with no green at all', async () => {
266
+ const width = 512;
267
+ const height = 512;
268
+ const buf = Buffer.alloc(width * height * 3);
269
+
270
+ // All red (255, 0, 0)
271
+ for (let i = 0; i < width * height; i++) {
272
+ const offset = i * 3;
273
+ buf[offset] = 255;
274
+ buf[offset + 1] = 0;
275
+ buf[offset + 2] = 0;
276
+ }
277
+
278
+ const src = join(tempDir, 'input.png');
279
+ const dest = join(tempDir, 'output.png');
280
+
281
+ await sharp(buf, { raw: { width, height, channels: 3 } }).toFile(src);
282
+ const { transparentRatio } = await chromaKeyToPng(src, dest);
283
+
284
+ expect(transparentRatio).toBe(0);
285
+
286
+ const { data } = await sharp(dest).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
287
+
288
+ for (let i = 0; i < width * height; i++) {
289
+ expect(data[i * 4 + 3]).toBe(255);
290
+ }
291
+ });
292
+ });
293
+
294
+ describe('mentionsTransparentBackground', () => {
295
+ it('matches background phrasing', () => {
296
+ for (const p of [
297
+ 'a fox on a transparent background',
298
+ 'a fox, transparent backdrop',
299
+ 'a fox with no background',
300
+ 'a fox without a background',
301
+ 'PNG with an alpha channel',
302
+ 'shot on a chroma-key stage',
303
+ 'chroma key green screen',
304
+ ]) {
305
+ expect(mentionsTransparentBackground(p)).toBe(true);
306
+ }
307
+ });
308
+
309
+ it('ignores see-through subjects and unrelated wording', () => {
310
+ for (const p of [
311
+ 'a transparent glass bottle',
312
+ 'a goldfish in a transparent bowl',
313
+ 'frosted transparent plastic packaging',
314
+ 'a paper cutout of a fox',
315
+ 'transparency and trust, abstract illustration',
316
+ ]) {
317
+ expect(mentionsTransparentBackground(p)).toBe(false);
318
+ }
319
+ });
320
+ });
@@ -0,0 +1,107 @@
1
+ import sharp from 'sharp';
2
+
3
+ export const CHROMA_CLAUSE =
4
+ 'The entire background must be a perfectly flat solid #00ff00 chroma-key green. The background must be one uniform colour with no shadows, gradients, texture, reflections, or lighting variation. Keep the subject fully separated from the background with crisp edges. Do not use #00ff00 or any similar green anywhere on the subject. No cast shadow, no contact shadow, no reflection.';
5
+
6
+ export const NATIVE_ALPHA_CLAUSE =
7
+ 'Render the subject on a fully transparent background — PNG with a real alpha channel, no backdrop, no canvas colour, no cast shadow.';
8
+
9
+ export interface KeyResult {
10
+ readonly transparentRatio: number;
11
+ }
12
+
13
+ /**
14
+ * Background-scoped only. `transparent` on its own describes subjects far more
15
+ * often than backdrops ("transparent glass bottle", "goldfish in a transparent
16
+ * bowl"), and refusing those would force `--transparent` onto a brief the chroma
17
+ * clause actively fights.
18
+ */
19
+ const TRANSPARENT_BACKGROUND =
20
+ /\b(transparent (background|backdrop)|no background|without a background|alpha channel|chroma[- ]?key)/i;
21
+
22
+ /** Does the prompt ask for a see-through *background* (as opposed to a see-through subject)? */
23
+ export function mentionsTransparentBackground(prompt: string): boolean {
24
+ return TRANSPARENT_BACKGROUND.test(prompt);
25
+ }
26
+
27
+ const GREEN_MIN = 90;
28
+ /**
29
+ * How far green must lead both red and blue for a pixel to count as backdrop.
30
+ * Position, not geometry, decides: a saturated green *subject* is keyed away too.
31
+ * That is inherent to chroma keying — CHROMA_CLAUSE tells the model not to put
32
+ * green on the subject, and a native-alpha seat is the answer when it must be.
33
+ */
34
+ const DOMINANCE = 40;
35
+
36
+ export async function chromaKeyToPng(src: string, dest: string): Promise<KeyResult> {
37
+ // toColourspace before ensureAlpha: a greyscale source would otherwise come back
38
+ // 2-channel (Y+A) and every RGBA offset below would read the wrong byte.
39
+ const { data, info } = await sharp(src)
40
+ .toColourspace('srgb')
41
+ .ensureAlpha()
42
+ .raw()
43
+ .toBuffer({ resolveWithObject: true });
44
+
45
+ const { width, height, channels } = info;
46
+ if (channels !== 4) {
47
+ throw new Error(`chroma key expected 4-channel RGBA, got ${channels} channels from ${src}`);
48
+ }
49
+ const totalPixels = width * height;
50
+
51
+ if (totalPixels === 0) {
52
+ await sharp(data, { raw: { width, height, channels } })
53
+ .png({ compressionLevel: 9 })
54
+ .toFile(dest);
55
+ return { transparentRatio: 0 };
56
+ }
57
+
58
+ // Pass 1 — key: detect background green pixels and set alpha to 0.
59
+ const mask = new Uint8Array(totalPixels);
60
+ let keyedCount = 0;
61
+
62
+ for (let i = 0; i < totalPixels; i++) {
63
+ const offset = i * channels;
64
+ const r = data[offset] ?? 0;
65
+ const g = data[offset + 1] ?? 0;
66
+ const b = data[offset + 2] ?? 0;
67
+
68
+ if (g >= GREEN_MIN && g - r >= DOMINANCE && g - b >= DOMINANCE) {
69
+ data[offset + 3] = 0;
70
+ mask[i] = 1;
71
+ keyedCount++;
72
+ }
73
+ }
74
+
75
+ // ponytail: neighbour-gated despill — an ungated despill would grey out any green subject.
76
+ // Ceiling: binary alpha, 1px fringe ring; upgrade path is a real matte (feathered alpha) if soft edges are ever needed.
77
+ // Pass 2 — despill: clamp green for opaque fringe pixels adjacent to keyed pixels.
78
+ for (let y = 0; y < height; y++) {
79
+ for (let x = 0; x < width; x++) {
80
+ const idx = y * width + x;
81
+ if (mask[idx] === 1) continue;
82
+
83
+ const offset = idx * channels;
84
+ const r = data[offset] ?? 0;
85
+ const g = data[offset + 1] ?? 0;
86
+ const b = data[offset + 2] ?? 0;
87
+
88
+ if (g - r >= 20 && g - b >= 20) {
89
+ const hasKeyedNeighbor =
90
+ (x > 0 && mask[idx - 1] === 1) ||
91
+ (x < width - 1 && mask[idx + 1] === 1) ||
92
+ (y > 0 && mask[idx - width] === 1) ||
93
+ (y < height - 1 && mask[idx + width] === 1);
94
+
95
+ if (hasKeyedNeighbor) {
96
+ data[offset + 1] = Math.max(r, b);
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ await sharp(data, { raw: { width, height, channels } }).png({ compressionLevel: 9 }).toFile(dest);
103
+
104
+ return {
105
+ transparentRatio: keyedCount / totalPixels,
106
+ };
107
+ }