@aibridge/cli 0.6.0 → 0.8.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 +1 -1
- package/dist/{context-F8WLXzPv.mjs → context-DJtEcg6f.mjs} +127 -12
- package/dist/index.mjs +1 -1
- package/package.json +7 -6
- package/src/commands/image-gen/command.ts +7 -1
- package/src/commands/image-gen/impl.test.ts +61 -0
- package/src/commands/image-gen/impl.ts +75 -11
- package/src/commands/models/impl.test.ts +6 -3
- package/src/commands/models/impl.ts +5 -2
- package/src/models.test.ts +16 -1
- package/src/models.ts +21 -0
- package/src/transparency.test.ts +320 -0
- package/src/transparency.ts +107 -0
package/dist/cli.mjs
CHANGED
|
@@ -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
|
|
318
|
-
if (
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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.
|
|
3
|
+
"version": "0.8.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
|
-
"
|
|
39
|
-
"@aibridge/
|
|
40
|
-
"@aibridge/driver-agy": "0.
|
|
41
|
-
"@aibridge/driver-
|
|
42
|
-
"@aibridge/driver-claude": "0.
|
|
38
|
+
"sharp": "^0.35.3",
|
|
39
|
+
"@aibridge/proc": "0.8.0",
|
|
40
|
+
"@aibridge/driver-agy": "0.8.0",
|
|
41
|
+
"@aibridge/driver-codex": "0.8.0",
|
|
42
|
+
"@aibridge/driver-claude": "0.8.0",
|
|
43
|
+
"@aibridge/driver-grok": "0.8.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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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`);
|
package/src/models.test.ts
CHANGED
|
@@ -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
|
+
}
|