ima2-gen 3.8.2 → 3.9.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.
Files changed (39) hide show
  1. package/bin/commands/gen.js +10 -0
  2. package/docs/migration/runtime-test-inventory.md +5 -1
  3. package/lib/alphaDecode.js +18 -0
  4. package/lib/atlasCloudImageAdapter.js +6 -1
  5. package/lib/backgroundPresets.js +28 -1
  6. package/lib/generatePipeline.js +74 -5
  7. package/lib/imageBackgroundParam.js +108 -0
  8. package/lib/promptImport/gptImageHints.js +4 -1
  9. package/lib/responsesFallback.js +8 -2
  10. package/lib/responsesImageAdapter.js +10 -1
  11. package/package.json +2 -2
  12. package/routes/video.js +7 -0
  13. package/skills/ima2/SKILL.md +28 -4
  14. package/skills/ima2-front/references/asset-requirements.md +23 -8
  15. package/skills/ima2-uiux/SKILL.md +15 -4
  16. package/ui/dist/.vite/manifest.json +32 -32
  17. package/ui/dist/assets/{AgentWorkspace-bUd5NsQ_.js → AgentWorkspace-fcIXiWV6.js} +1 -1
  18. package/ui/dist/assets/AssetGenWorkspace-CqXyN9at.js +2 -0
  19. package/ui/dist/assets/{AssetsWorkspace-DO_wfFQB.js → AssetsWorkspace-xwHFVOb7.js} +1 -1
  20. package/ui/dist/assets/{CardNewsWorkspace-D3M0w8pg.js → CardNewsWorkspace-ltdjG40R.js} +1 -1
  21. package/ui/dist/assets/{GenerationRequestLogPanel-WvSV0yXP.js → GenerationRequestLogPanel-B68EUfUg.js} +1 -1
  22. package/ui/dist/assets/{HomeWorkspace-BGCBf9iK.js → HomeWorkspace-BBOG_aNm.js} +1 -1
  23. package/ui/dist/assets/KeyingPanel-Bv0n_bhk.js +1 -0
  24. package/ui/dist/assets/{NodeCanvas-DwbR2nol.js → NodeCanvas-VwdctvAW.js} +1 -1
  25. package/ui/dist/assets/{PromptBuilderPanel-KSH3ChjC.js → PromptBuilderPanel-BBMpF_3Q.js} +1 -1
  26. package/ui/dist/assets/{PromptImportDialog-CjHiCEo9.js → PromptImportDialog-D4QYbRfH.js} +2 -2
  27. package/ui/dist/assets/{PromptImportDiscoverySection-CWU-vKyM.js → PromptImportDiscoverySection-Bx7BeTFh.js} +1 -1
  28. package/ui/dist/assets/{PromptImportFolderSection-_zRnJGkJ.js → PromptImportFolderSection-Bwf60URx.js} +1 -1
  29. package/ui/dist/assets/{PromptLibraryPanel-64VN4nml.js → PromptLibraryPanel-mNG2wZkQ.js} +2 -2
  30. package/ui/dist/assets/{SettingsWorkspace-DZMNEpuq.js → SettingsWorkspace-DA5tIa0Q.js} +1 -1
  31. package/ui/dist/assets/{SpriteRecipeWorkspace-Y6GqpVHs.js → SpriteRecipeWorkspace-Da9qTQX0.js} +1 -1
  32. package/ui/dist/assets/{index-CFixfKaC.js → index-Bv1Yrz8W.js} +3 -3
  33. package/ui/dist/assets/{index-waPOg2eb.css → index-C0nfanMr.css} +1 -1
  34. package/ui/dist/assets/{index-XXtoj9ME.js → index-CxEuSZMC.js} +16 -16
  35. package/ui/dist/assets/{pptxgen.es-hCJot2gG.js → pptxgen.es-BE4CSd5F.js} +1 -1
  36. package/ui/dist/assets/{useAgentDialogFocus-PWmMjerz.js → useAgentDialogFocus-R64NV-ZZ.js} +1 -1
  37. package/ui/dist/index.html +2 -2
  38. package/ui/dist/assets/AssetGenWorkspace-8fK-K64E.js +0 -2
  39. package/ui/dist/assets/KeyingPanel-CEBLIhhu.js +0 -1
@@ -14,6 +14,7 @@ import { out, die, dieWithError, color, err, fail, json } from "../lib/output.js
14
14
  import { createCliRequestId, recoverGeneratedOutputs, formatRecoveryHint } from "../lib/recover-output.js";
15
15
  import { deriveProviderIds } from "../../lib/providers/derive.js";
16
16
  import { listProviders } from "../../lib/mcp/providerRegistry.js";
17
+ import { BACKGROUND_PRESETS } from "../../lib/backgroundPresets.js";
17
18
  const VALID_MODES = new Set(["auto", "direct"]);
18
19
  const VALID_MODERATION = new Set(["auto", "low"]);
19
20
  const MAX_GENERATION_COUNT = Math.max(1, Math.trunc(Number(config.limits.maxGeneratedImages) || 24));
@@ -71,6 +72,9 @@ const HELP = `
71
72
  'auto' was removed; choose a lane explicitly
72
73
  --mode <auto|direct> Core lanes only. Default: auto
73
74
  --moderation <auto|low> Core lanes only. Default: low
75
+ --bg <chroma-green|white|black|transparent>
76
+ Core lanes only. 'transparent' asks GPT Image 2
77
+ for a real alpha channel (saved as PNG)
74
78
  --session <id> Core lanes only
75
79
  --reasoning-effort <none|low|medium|high|xhigh|max>
76
80
  Core lanes only
@@ -80,6 +84,7 @@ const HELP = `
80
84
  ima2 defaults set image oauth/gpt-5.6-luna
81
85
  ima2 gen "a shiba in space"
82
86
  ima2 gen "poster" --model oauth/luna --mode direct
87
+ ima2 gen "fox logo mark" --bg transparent -o logo.png
83
88
  ima2 gen "campaign still" --model runway/gen-4 --ref 1780000000000_abcd.png
84
89
  `;
85
90
  function failServer(jsonMode, error) {
@@ -216,6 +221,11 @@ function validateCoreFlags(args) {
216
221
  die(2, "--mode must be one of: auto, direct");
217
222
  if (!VALID_MODERATION.has(String(args.moderation)))
218
223
  die(2, "--moderation must be one of: auto, low");
224
+ // Fail locally on a typo instead of spending a round trip to learn the
225
+ // server rejected it.
226
+ if (args.bg && !BACKGROUND_PRESETS.includes(String(args.bg))) {
227
+ die(2, `--bg must be one of: ${BACKGROUND_PRESETS.join(", ")}`);
228
+ }
219
229
  const validReasoning = new Set(["none", "low", "medium", "high", "xhigh", "max"]);
220
230
  if (args["reasoning-effort"] && !validReasoning.has(String(args["reasoning-effort"])))
221
231
  die(2, "--reasoning-effort must be one of: none, low, medium, high, xhigh, max");
@@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 372 (runtime: 169, contract: 203)
7
+ Total: 376 (runtime: 172, contract: 204)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-mode-auto-planner-contract.test.ts`
@@ -162,6 +162,9 @@ Total: 372 (runtime: 169, contract: 203)
162
162
  - `tests/style-sheet.test.ts`
163
163
  - `tests/terminal-jobs-restart.test.ts`
164
164
  - `tests/thumb-backfill.test.ts`
165
+ - `tests/transparent-alpha-verification.test.ts`
166
+ - `tests/transparent-background-contract.test.ts`
167
+ - `tests/transparent-background-route.test.ts`
165
168
  - `tests/video-chroma-key.test.ts`
166
169
  - `tests/video-concat.test.ts`
167
170
  - `tests/video-history-item.test.ts`
@@ -370,6 +373,7 @@ Total: 372 (runtime: 169, contract: 203)
370
373
  - `tests/subprocess-deadline-contract.test.ts`
371
374
  - `tests/toast-stack-contract.test.js`
372
375
  - `tests/tools-cli-contract.test.ts`
376
+ - `tests/transparent-background-ui-contract.test.js`
373
377
  - `tests/ui-error-code-contract.test.js`
374
378
  - `tests/ui-glyph-policy.test.ts`
375
379
  - `tests/video-continuity-ui-contract.test.js`
@@ -0,0 +1,18 @@
1
+ /**
2
+ * sharp-backed raw decoder for alpha verification.
3
+ *
4
+ * Kept in its own module so lib/imageBackgroundParam.ts stays pure and
5
+ * synchronously testable: the verifier takes the decoder as a parameter, and
6
+ * tests can drive decode failures without stubbing sharp itself.
7
+ */
8
+ import sharp from "sharp";
9
+ export async function decodeRawForAlpha(buffer) {
10
+ const image = sharp(buffer);
11
+ const meta = await image.metadata();
12
+ if (!meta.hasAlpha) {
13
+ // No alpha channel at all: skip the raw decode, the answer is already known.
14
+ return { data: Buffer.alloc(0), channels: 0, hasAlpha: false };
15
+ }
16
+ const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
17
+ return { data, channels: info.channels, hasAlpha: true };
18
+ }
@@ -166,7 +166,10 @@ export async function generateViaAtlasCloud(prompt, ctx, options = {}) {
166
166
  prompt,
167
167
  size: options.size || "1024x1024",
168
168
  quality: options.quality || "medium",
169
- output_format: options.outputFormat || "jpeg",
169
+ // JPEG cannot carry an alpha channel, so a transparent request must never
170
+ // fall back to it. gpt-image-2 accepts png/webp with transparency.
171
+ output_format: options.outputFormat || (options.background === "transparent" ? "png" : "jpeg"),
172
+ ...(options.background ? { background: options.background } : {}),
170
173
  enable_base64_output: false,
171
174
  enable_sync_mode: false,
172
175
  };
@@ -177,6 +180,8 @@ export async function generateViaAtlasCloud(prompt, ctx, options = {}) {
177
180
  model,
178
181
  size: body.size,
179
182
  refs: imageUrls.length,
183
+ background: options.background ?? null,
184
+ outputFormat: body.output_format,
180
185
  });
181
186
  const predictionId = await submitGeneration(apiKey, body, options.signal);
182
187
  const output = await pollOutputUrl(apiKey, predictionId, options.signal);
@@ -4,8 +4,31 @@
4
4
  * The prompt suffix keeps generated backgrounds uniform enough for a
5
5
  * deterministic color key. Prompt assembly is server-owned so the UI, CLI,
6
6
  * and integrations share one contract (devlog/_plan/260715_asset_gen_mode/020).
7
+ *
8
+ * "transparent" is a different KIND of preset: the other three ask the model
9
+ * for a uniform matte that a later color-key pass removes, while transparent
10
+ * asks for a real alpha channel up front. GPT-Image-2 can do this now
11
+ * (devlog/_plan/260821_gpt_image2_transparent_background), so the transparent
12
+ * preset drives BOTH a prompt suffix and the image_generation `background`
13
+ * parameter. It is deliberately NOT color-keyable: there is no matte to key.
14
+ */
15
+ export const BACKGROUND_PRESETS = ["chroma-green", "white", "black", "transparent"];
16
+ /**
17
+ * Presets that produce a solid matte for downstream color keying. Transparent
18
+ * is excluded because it already carries alpha; keying it would be a no-op at
19
+ * best and would eat anti-aliased edges at worst.
20
+ */
21
+ export const COLOR_KEYABLE_PRESETS = ["chroma-green", "white", "black"];
22
+ export function isColorKeyablePreset(preset) {
23
+ return COLOR_KEYABLE_PRESETS.includes(preset);
24
+ }
25
+ /**
26
+ * Alpha-bearing presets cannot be encoded as JPEG. Callers that pick an output
27
+ * format must consult this before defaulting to a lossy opaque format.
7
28
  */
8
- export const BACKGROUND_PRESETS = ["chroma-green", "white", "black"];
29
+ export function presetRequiresAlpha(preset) {
30
+ return preset === "transparent";
31
+ }
9
32
  export function parseBackgroundPreset(raw) {
10
33
  if (raw === undefined || raw === null || raw === "")
11
34
  return { preset: null };
@@ -21,6 +44,7 @@ const SUFFIX_BY_PRESET = {
21
44
  "chroma-green": "The entire background must be a completely uniform solid chroma key green, perfectly flat like a professional green screen, with even studio lighting and no shadows, gradients, or texture on the background. The subject must have absolutely no green color cast, no green rim lighting, no green reflections, and no green spill from the background.",
22
45
  white: "The entire background must be a pure seamless white studio background, perfectly uniform, with even lighting and no shadows, gradients, or texture on the background.",
23
46
  black: "The entire background must be a pure seamless black studio background, perfectly uniform, with even lighting and no gradients or texture on the background.",
47
+ transparent: "The background must be fully transparent with a real alpha channel: an isolated cutout of the subject with no backdrop, no backdrop color, no ground plane, no drop shadow, and no checkerboard pattern drawn into the image. Keep edges cleanly anti-aliased against transparency, and preserve genuine partial transparency in glass, smoke, hair, and other translucent areas.",
24
48
  };
25
49
  export function backgroundPromptSuffix(preset, kind) {
26
50
  const base = SUFFIX_BY_PRESET[preset];
@@ -29,6 +53,9 @@ export function backgroundPromptSuffix(preset, kind) {
29
53
  : base;
30
54
  }
31
55
  export function backgroundPlannerConstraint(preset) {
56
+ if (preset === "transparent") {
57
+ return "Hard constraint: the final prompt MUST explicitly require a fully transparent background with a real alpha channel — an isolated cutout with no backdrop, no ground plane, and no drop shadow. Never drop, weaken, or reinterpret this requirement, and never substitute a solid color background for transparency.";
58
+ }
32
59
  const color = preset === "chroma-green" ? "chroma key green" : preset;
33
60
  return `Hard constraint: the final prompt MUST explicitly require a completely uniform solid ${color} background with even lighting and no shadows, gradients, or texture on the background.${preset === "chroma-green" ? " The subject must have no green color cast, green rim lighting, green reflections, or green spill from the background." : ""} Never drop, weaken, or reinterpret this requirement.`;
34
61
  }
@@ -27,6 +27,8 @@ import { errInfo } from "./errInfo.js";
27
27
  import { requireRuntimeContext } from "./runtimeContext.js";
28
28
  import { STORYBOARD_PREFIX } from "./storyboardPrefix.js";
29
29
  import { parseBackgroundPreset, backgroundPromptSuffix, backgroundPlannerConstraint } from "./backgroundPresets.js";
30
+ import { resolveImageBackgroundParams, validateTransparentFormat, validateTransparentProvider, verifyBufferAlpha, makeTransparentResultError } from "./imageBackgroundParam.js";
31
+ import { decodeRawForAlpha } from "./alphaDecode.js";
30
32
  import { validateModeration, imageFormatFromMime, upstreamErrorFields } from "./routeHelpers.js";
31
33
  import { publish } from "./eventBus.js";
32
34
  import { publishJobEvent } from "./ssePublish.js";
@@ -135,6 +137,15 @@ export async function runGeneratePipeline(req, res, ctx) {
135
137
  return fail(400, { error: backgroundParse.error, code: backgroundParse.code });
136
138
  }
137
139
  const backgroundPreset = backgroundParse.preset;
140
+ // `format` is the canonical request field (default "png"). Validating
141
+ // req.body.outputFormat instead would let format:"jpeg" slip past and
142
+ // then get transcoded to JPEG on save, destroying the alpha channel.
143
+ const formatConflict = validateTransparentFormat(backgroundPreset, format);
144
+ if (formatConflict) {
145
+ return fail(400, { error: formatConflict.error, code: formatConflict.code });
146
+ }
147
+ // Atlas Cloud talks to the gpt-image-2 API directly and accepts the
148
+ // forced value; the OAuth proxy does not (see lib/imageBackgroundParam.ts).
138
149
  const composerPrompt = normalizeComposerPrompt(req.body?.composerPrompt);
139
150
  const composerInsertedPrompts = normalizeComposerInsertedPrompts(req.body?.composerInsertedPrompts);
140
151
  const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
@@ -153,6 +164,23 @@ export async function runGeneratePipeline(req, res, ctx) {
153
164
  const effectiveSize = providerOptions.size;
154
165
  const webSearchEnabled = providerOptions.webSearchEnabled;
155
166
  const activeProvider = providerOptions.provider;
167
+ // Resolved AFTER provider resolution on purpose: the raw request `provider`
168
+ // defaults to "auto", so only `activeProvider` names the lane that will
169
+ // actually run. Atlas Cloud talks to the gpt-image-2 API directly and
170
+ // accepts a forced transparent background; the OAuth proxy rejects it
171
+ // (see lib/imageBackgroundParam.ts).
172
+ const backgroundParams = resolveImageBackgroundParams({
173
+ preset: backgroundPreset,
174
+ supportsForcedTransparent: activeProvider === "atlascloud",
175
+ requestedFormat: typeof format === "string" ? format : undefined,
176
+ });
177
+ // Grok/Gemini/Agy/MiniMax have no background parameter and their branches
178
+ // force JPEG, so a transparent request there would return an opaque image
179
+ // recorded as a cutout. Refuse instead of billing for a wrong result.
180
+ const providerConflict = validateTransparentProvider(backgroundPreset, activeProvider);
181
+ if (providerConflict) {
182
+ return fail(400, { error: providerConflict.error, code: providerConflict.code });
183
+ }
156
184
  // --- Element injection (after provider resolution) ---
157
185
  const rawElementIds = Array.isArray(req.body?.elementIds)
158
186
  ? req.body.elementIds.filter((id) => typeof id === "string" && id)
@@ -330,7 +358,13 @@ export async function runGeneratePipeline(req, res, ctx) {
330
358
  });
331
359
  const startTime = Date.now();
332
360
  const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
333
- const effectiveFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? "jpeg" : String(format);
361
+ const providerForcesJpeg = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax";
362
+ // An alpha-bearing result must never be persisted through a lossy opaque
363
+ // format: embedImageMetadata re-encodes with sharp.toFormat(), so a JPEG
364
+ // here silently flattens the transparency we just asked for.
365
+ const effectiveFormat = backgroundParams
366
+ ? (backgroundParams.outputFormat ?? "png")
367
+ : (providerForcesJpeg ? "jpeg" : String(format));
334
368
  const mime = mimeMap[effectiveFormat] || "image/png";
335
369
  await mkdir(ctx.config.storage.generatedDir, { recursive: true });
336
370
  const grokDirectApiKey = activeProvider === "grok-api" ? ctx.xaiApiKey : undefined;
@@ -376,6 +410,8 @@ export async function runGeneratePipeline(req, res, ctx) {
376
410
  signal: cancelController.signal,
377
411
  requestId,
378
412
  references: refCheck.refDetails,
413
+ ...(backgroundParams ? { background: backgroundParams.background } : {}),
414
+ ...(backgroundParams?.outputFormat ? { outputFormat: backgroundParams.outputFormat } : {}),
379
415
  });
380
416
  throwIfJobCanceled(requestId);
381
417
  return r;
@@ -416,6 +452,8 @@ export async function runGeneratePipeline(req, res, ctx) {
416
452
  webSearchEnabled,
417
453
  signal: cancelController.signal,
418
454
  allowPromptOnlyOAuthFallback: activeProvider !== "api",
455
+ ...(backgroundParams ? { background: backgroundParams.background } : {}),
456
+ ...(backgroundParams?.outputFormat ? { outputFormat: backgroundParams.outputFormat } : {}),
419
457
  });
420
458
  throwIfJobCanceled(requestId);
421
459
  if (r.b64)
@@ -440,6 +478,22 @@ export async function runGeneratePipeline(req, res, ctx) {
440
478
  };
441
479
  const results = await Promise.allSettled(Array.from({ length: count }, generateOne));
442
480
  throwIfJobCanceled(requestId);
481
+ // Alpha is verified for EVERY result before anything is written. Doing it
482
+ // inside the write loop would let an earlier image land on disk before a
483
+ // later opaque one failed the batch, so the error would claim "nothing was
484
+ // saved" while an orphan file existed (adversarial review 260821 round 4).
485
+ if (backgroundParams) {
486
+ for (const r of results) {
487
+ if (r.status !== "fulfilled" || !r.value.b64)
488
+ continue;
489
+ const verdict = await verifyBufferAlpha(Buffer.from(r.value.b64, "base64"), decodeRawForAlpha);
490
+ if (verdict.hasAlpha === false) {
491
+ const { reason } = verdict;
492
+ logEvent("generate", "transparent_result_opaque", { requestId, provider: activeProvider, reason });
493
+ throw makeTransparentResultError(activeProvider, reason);
494
+ }
495
+ }
496
+ }
443
497
  const images = [];
444
498
  let totalUsage = null;
445
499
  let totalWebSearchCalls = 0;
@@ -448,10 +502,25 @@ export async function runGeneratePipeline(req, res, ctx) {
448
502
  if (r.status === "fulfilled" && r.value.b64) {
449
503
  throwIfJobCanceled(requestId);
450
504
  const valueWithMime = r.value;
451
- const resultMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax"
452
- ? (valueWithMime.mime || detectImageMimeFromB64(r.value.b64) || mime)
453
- : mime;
454
- const resultFormat = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? imageFormatFromMime(resultMime) : effectiveFormat;
505
+ // When alpha was requested, trust the BYTES, never a provider-supplied
506
+ // Content-Type. Atlas reads its mime from the download response header
507
+ // (lib/atlasCloudImageAdapter.ts), and a transparent PNG mislabeled
508
+ // "image/jpeg" would otherwise be re-encoded to JPEG by
509
+ // embedImageMetadata's sharp.toFormat() and lose its alpha channel.
510
+ const providerReportsMime = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax";
511
+ // Lazily decoded: only alpha requests always need the byte check, and
512
+ // the provider-mime path keeps its original short-circuit order.
513
+ const detectMime = () => detectImageMimeFromB64(r.value.b64);
514
+ const resultMime = backgroundParams
515
+ ? (detectMime() || mime)
516
+ : providerReportsMime
517
+ ? (valueWithMime.mime || detectMime() || mime)
518
+ : mime;
519
+ const resultFormat = backgroundParams
520
+ ? imageFormatFromMime(resultMime)
521
+ : providerReportsMime
522
+ ? imageFormatFromMime(resultMime)
523
+ : effectiveFormat;
455
524
  const retryValue = r.value;
456
525
  if (!firstRetryMeta && retryValue.retryKind) {
457
526
  firstRetryMeta = {
@@ -0,0 +1,108 @@
1
+ export const VALID_BACKGROUND_VALUES = ["auto", "opaque", "transparent"];
2
+ /** Formats that can carry an alpha channel. JPEG cannot. */
3
+ export const ALPHA_CAPABLE_FORMATS = ["png", "webp"];
4
+ export function isAlphaCapableFormat(value) {
5
+ return typeof value === "string" && ALPHA_CAPABLE_FORMATS.includes(value);
6
+ }
7
+ /**
8
+ * Image lanes that can actually return an alpha channel.
9
+ *
10
+ * Only the GPT image tool (OAuth/API) and the gpt-image-2 API surface expose a
11
+ * background parameter. Grok, Gemini, Agy, and MiniMax have no equivalent and
12
+ * their pipeline branches force JPEG, so a transparent request there would bill
13
+ * the user for an opaque image labeled as a cutout.
14
+ */
15
+ export const ALPHA_CAPABLE_PROVIDERS = ["oauth", "api", "atlascloud"];
16
+ export function providerSupportsTransparent(provider) {
17
+ return typeof provider === "string" && ALPHA_CAPABLE_PROVIDERS.includes(provider);
18
+ }
19
+ /** Refuse a transparent request on a lane that cannot deliver alpha. */
20
+ export function validateTransparentProvider(preset, provider) {
21
+ if (preset !== "transparent")
22
+ return null;
23
+ if (providerSupportsTransparent(provider))
24
+ return null;
25
+ return {
26
+ error: `transparent backgrounds are not supported on the "${String(provider)}" lane (no alpha channel); use ${ALPHA_CAPABLE_PROVIDERS.join(", ")}, or pick a solid background and key it`,
27
+ code: "TRANSPARENT_PROVIDER_UNSUPPORTED",
28
+ };
29
+ }
30
+ /** Fast pre-check: JPEG can never carry alpha, so skip the decode entirely. */
31
+ export function isJpegBuffer(buffer) {
32
+ return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
33
+ }
34
+ /**
35
+ * @param decode injected so tests can drive failure paths without stubbing sharp
36
+ */
37
+ export async function verifyBufferAlpha(buffer, decode) {
38
+ if (isJpegBuffer(buffer))
39
+ return { hasAlpha: false, reason: "jpeg" };
40
+ let decoded;
41
+ try {
42
+ decoded = await decode(buffer);
43
+ }
44
+ catch {
45
+ // Unreadable bytes are not evidence of transparency.
46
+ return { hasAlpha: false, reason: "undetectable" };
47
+ }
48
+ if (!decoded.hasAlpha)
49
+ return { hasAlpha: false, reason: "no-alpha-channel" };
50
+ const { data, channels } = decoded;
51
+ if (channels < 4 || data.length < channels)
52
+ return { hasAlpha: false, reason: "no-alpha-channel" };
53
+ // One non-opaque pixel is enough: partial alpha (glass, hair, anti-aliased
54
+ // edges) counts as transparency just as much as a fully cut-out background.
55
+ for (let i = channels - 1; i < data.length; i += channels) {
56
+ if (data[i] < 255)
57
+ return { hasAlpha: true };
58
+ }
59
+ return { hasAlpha: false, reason: "fully-opaque" };
60
+ }
61
+ /** Operational error for a transparency request that came back opaque. */
62
+ export function makeTransparentResultError(provider, reason) {
63
+ const detail = reason === "jpeg"
64
+ ? "the provider returned JPEG, which cannot carry an alpha channel"
65
+ : reason === "no-alpha-channel"
66
+ ? "the returned image has no alpha channel"
67
+ : reason === "fully-opaque"
68
+ ? "the returned image has an alpha channel but every pixel is fully opaque"
69
+ : "the returned image could not be decoded to verify transparency";
70
+ const err = new Error(`transparent background requested but ${detail} (lane: ${String(provider)}). Nothing was saved; retry, or use a solid background and key it.`);
71
+ err.status = 502;
72
+ err.code = "TRANSPARENT_RESULT_OPAQUE";
73
+ err.isOperational = true;
74
+ return err;
75
+ }
76
+ /**
77
+ * Resolve the tool parameters for a preset. Returns `null` when the preset
78
+ * implies no explicit background handling, so existing callers keep their
79
+ * current payload byte-for-byte.
80
+ */
81
+ export function resolveImageBackgroundParams(input) {
82
+ if (input.preset !== "transparent")
83
+ return null;
84
+ const requested = input.requestedFormat;
85
+ // JPEG cannot hold alpha: silently honoring it would ship an opaque image
86
+ // while the UI claims transparency. Fall back to PNG instead.
87
+ const outputFormat = isAlphaCapableFormat(requested) ? requested : "png";
88
+ return {
89
+ background: input.supportsForcedTransparent ? "transparent" : "auto",
90
+ outputFormat,
91
+ };
92
+ }
93
+ /**
94
+ * Reject an explicit alpha-incapable format paired with a transparent
95
+ * background instead of quietly producing an opaque image.
96
+ */
97
+ export function validateTransparentFormat(preset, requestedFormat) {
98
+ if (preset !== "transparent")
99
+ return null;
100
+ if (requestedFormat === undefined || requestedFormat === null || requestedFormat === "")
101
+ return null;
102
+ if (isAlphaCapableFormat(requestedFormat))
103
+ return null;
104
+ return {
105
+ error: `a transparent background requires an alpha-capable output format (${ALPHA_CAPABLE_FORMATS.join(", ")}); received "${String(requestedFormat)}"`,
106
+ code: "TRANSPARENT_FORMAT_CONFLICT",
107
+ };
108
+ }
@@ -39,7 +39,10 @@ export function extractGptImageHints(text) {
39
39
  const qualityHints = matches(value, QUALITY_HINTS);
40
40
  const warnings = [];
41
41
  if (/\btransparent|alpha channel|no background|cutout\b/i.test(value)) {
42
- warnings.push("transparent-unsupported-gpt-image-2");
42
+ // gpt-image-2 supports real alpha since 2026-08-21; the remaining hazard is
43
+ // asking for it in bare prompt text instead of the background parameter,
44
+ // which can bake a fake checkerboard into an opaque image.
45
+ warnings.push("transparent-needs-background-param");
43
46
  }
44
47
  if (/\bexact text|small text|dense text|legal copy\b/i.test(value)) {
45
48
  warnings.push("text-rendering-sensitive");
@@ -3,7 +3,7 @@ import { imageToolChoice, tools } from "./responsesTools.js";
3
3
  import { emptyResponseError } from "./responsesErrors.js";
4
4
  import { GENERATE_DEVELOPER_PROMPT, GENERATE_NO_SEARCH_DEVELOPER_PROMPT, buildUserTextPrompt, } from "./oauthProxy.js";
5
5
  const MAX_RETRIES = 2;
6
- export async function retryPromptOnlyJsonImage({ postResponses, ctx, provider, prompt, mode, model, quality, size, moderation, requestId, signal, initial, referenceInputs = [], webSearchDroppedOnRetry, reasoningEffort, }) {
6
+ export async function retryPromptOnlyJsonImage({ postResponses, ctx, provider, prompt, mode, model, quality, size, moderation, requestId, signal, initial, referenceInputs = [], webSearchDroppedOnRetry, reasoningEffort, background, outputFormat, }) {
7
7
  if (provider === "api")
8
8
  return null;
9
9
  const developerPrompt = webSearchDroppedOnRetry
@@ -62,7 +62,13 @@ export async function retryPromptOnlyJsonImage({ postResponses, ctx, provider, p
62
62
  ...(plan.developerPromptDroppedOnRetry ? [] : [{ role: "developer", content: developerPrompt }]),
63
63
  { role: "user", content: userContent },
64
64
  ],
65
- tools: tools(false, { quality, size, moderation }),
65
+ tools: tools(false, {
66
+ quality,
67
+ size,
68
+ moderation,
69
+ ...(background ? { background } : {}),
70
+ ...(outputFormat ? { output_format: outputFormat } : {}),
71
+ }),
66
72
  tool_choice: imageToolChoice(true),
67
73
  reasoning: { effort: reasoningEffort || "low" },
68
74
  // OAuth/Codex proxy returns empty output[] for non-stream image requests; SSE required.
@@ -211,7 +211,14 @@ export async function generateViaResponses(provider, prompt, quality, size, mode
211
211
  const ctx = requireRuntimeContext(ctxRaw);
212
212
  const model = options.model || ctx.config?.imageModels?.default || "gpt-5.6-luna";
213
213
  const webSearchEnabled = options.webSearchEnabled !== false && options.searchMode !== "off";
214
- const requestTools = tools(webSearchEnabled, { quality, size, moderation, ...(options.partialImages ? { partial_images: options.partialImages } : {}) });
214
+ const requestTools = tools(webSearchEnabled, {
215
+ quality,
216
+ size,
217
+ moderation,
218
+ ...(options.partialImages ? { partial_images: options.partialImages } : {}),
219
+ ...(options.background ? { background: options.background } : {}),
220
+ ...(options.outputFormat ? { output_format: options.outputFormat } : {}),
221
+ });
215
222
  const toolChoice = imageToolChoice(options.forceImageToolChoice ?? ctx.config?.oauth?.forceImageToolChoice !== false);
216
223
  const toolChoiceKind = imageToolChoiceKind(toolChoice);
217
224
  const referenceInputs = references.map(normalizeRef);
@@ -258,6 +265,8 @@ export async function generateViaResponses(provider, prompt, quality, size, mode
258
265
  referenceInputs,
259
266
  webSearchDroppedOnRetry: webSearchEnabled,
260
267
  ...(options.reasoningEffort !== undefined ? { reasoningEffort: options.reasoningEffort } : {}),
268
+ ...(options.background !== undefined ? { background: options.background } : {}),
269
+ ...(options.outputFormat !== undefined ? { outputFormat: options.outputFormat } : {}),
261
270
  });
262
271
  if (fallback)
263
272
  return fallback;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.8.2",
3
+ "version": "3.9.0",
4
4
  "packageManager": "npm@11.18.0",
5
5
  "description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
6
6
  "type": "module",
@@ -119,5 +119,5 @@
119
119
  "tsx": "^4.23.12",
120
120
  "typescript": "^5.9.3"
121
121
  },
122
- "gitHead": "19b682ffdeaedb38fda2ee4be45726abb5f47390"
122
+ "gitHead": "3883482f57e132376dd7cd078a3f1e11791e94c0"
123
123
  }
package/routes/video.js CHANGED
@@ -143,6 +143,13 @@ export function registerVideoRoutes(app, ctxRaw) {
143
143
  return fail(400, backgroundParse.code, backgroundParse.error);
144
144
  }
145
145
  const backgroundPreset = backgroundParse.preset;
146
+ // "transparent" is an image-only preset: it depends on the GPT image
147
+ // tool's alpha-capable output, and Grok video has no such parameter.
148
+ // Accepting it here would append a cutout suffix that the model cannot
149
+ // honor and hand back an opaque clip that claims transparency.
150
+ if (backgroundPreset === "transparent") {
151
+ return fail(400, "TRANSPARENT_VIDEO_UNSUPPORTED", "transparent backgrounds are image-only; video generation has no alpha channel — use chroma-green and key the clip instead");
152
+ }
146
153
  const clientNodeId = typeof req.body?.clientNodeId === "string" ? req.body.clientNodeId : null;
147
154
  const topic = typeof req.body?.topic === "string" ? req.body.topic.trim() : "";
148
155
  if (provider !== "grok" && provider !== "grok-api")
@@ -263,8 +263,29 @@ discouraged.
263
263
 
264
264
  ### Cutout Assets and Background Strategy
265
265
 
266
- GPT Image 2 does not reliably produce true transparent (alpha) backgrounds.
267
- Use the solid-background-then-remove strategy for cutout assets:
266
+ GPT Image 2 CAN produce true transparent (alpha) backgrounds. Prefer
267
+ `--bg transparent` for cutout assets:
268
+
269
+ ```bash
270
+ ima2 gen "a minimal geometric fox head logo mark, flat vector style" \
271
+ --bg transparent --quality high --mode direct -o logo.png
272
+ ```
273
+
274
+ This asks for a real alpha channel instead of a matte you have to key out
275
+ later. Verified on the live OAuth path 2026-08-21: 5/5 generations returned
276
+ RGBA PNGs with all four corners at alpha 0 and 42-56% fully transparent
277
+ pixels, including genuine PARTIAL alpha on glass and leaf veins. Saved as PNG;
278
+ JPEG is refused because it cannot carry alpha.
279
+
280
+ Mechanics worth knowing: ChatGPT-session models pin the image tool to the
281
+ `gpt-image-2-codex` variant, which rejects a FORCED `background: "transparent"`
282
+ with a 400. ima2 therefore sends `background: "auto"` and puts the cutout
283
+ intent in the prompt, which is what actually produces the alpha. You do not
284
+ need to hand-write that suffix — `--bg transparent` adds it.
285
+
286
+ **Use the solid-background-then-remove strategy only when you need a matte**
287
+ (chroma keying a video, compositing pipelines that expect green screen), or
288
+ when a specific generation refuses to isolate the subject cleanly:
268
289
 
269
290
  **Generate on a pure solid background:**
270
291
  - **Black** (`#000000`) for reflective/metallic/glass subjects
@@ -288,8 +309,11 @@ ima2 gen "3D chrome splash on PURE SOLID BLACK background hex #000000. \
288
309
  - `ima2 edit asset.png --prompt "remove the background, keep only the subject"`
289
310
  - Programmatic: `sharp` / ImageMagick / `rembg`
290
311
 
291
- **Anti-pattern:** requesting "transparent background" or "PNG with alpha" in the
292
- prompt the model often produces a fake checkerboard burned into the image.
312
+ **Anti-pattern:** hand-writing "transparent background" into a prompt WITHOUT
313
+ `--bg transparent`. Bare prompt wording sometimes yields a checkerboard
314
+ pattern painted into an opaque image; the flag sends the real API parameter and
315
+ the tuned suffix together. Always verify alpha rather than trusting the look of
316
+ a preview: `sharp(file).metadata()` should report `channels: 4, hasAlpha: true`.
293
317
 
294
318
  ### Korean Text in Images
295
319
 
@@ -322,10 +322,25 @@ When generating images with Korean text:
322
322
 
323
323
  ### Asset Background Strategy (FE-ASSET-BG-01, DEFAULT)
324
324
 
325
- GPT Image 2 does not reliably produce true transparent (alpha) backgrounds.
326
- Requesting "transparent background" or "PNG with alpha" yields unpredictable
327
- results — sometimes a faint checkerboard pattern, sometimes a solid color
328
- pretending to be transparent. Use the solid-background-then-remove strategy:
325
+ GPT Image 2 CAN produce true transparent (alpha) backgrounds. For cutout
326
+ assets, reach for `--bg transparent` FIRST:
327
+
328
+ ```bash
329
+ ima2 gen "3D render of a liquid chrome splash blob, organic starburst shape, \
330
+ mirror-polished surface with iridescent cyan and gold reflections." \
331
+ --bg transparent --model oauth/luna --quality high --mode direct -o chrome-splash.png
332
+ ```
333
+
334
+ The flag sends the real API background parameter plus a tuned cutout suffix,
335
+ and the asset is saved as PNG (JPEG is refused — it cannot carry alpha).
336
+ Verified 2026-08-21 on the live OAuth path: 5/5 generations returned RGBA with
337
+ corners at alpha 0 and 42-56% fully transparent pixels, preserving PARTIAL
338
+ alpha on glass and foliage. Always confirm with `sharp(file).metadata()`
339
+ (`channels: 4, hasAlpha: true`) rather than trusting a preview.
340
+
341
+ **Still use the solid-background-then-remove strategy when** you specifically
342
+ need a matte (chroma-key video pipelines, compositing tools that expect green
343
+ screen), or when one generation refuses to isolate its subject cleanly:
329
344
 
330
345
  **Generation: pure solid background.**
331
346
 
@@ -393,10 +408,10 @@ ima2 gen "Flat illustration of a coffee cup with steam, centered. \
393
408
  **`ima2 gen` fallback:** same solid-background prompting strategy applies.
394
409
  No Canvas Mode available; use CSS blend modes or programmatic removal only.
395
410
 
396
- **Anti-pattern:** requesting "transparent background" or "PNG with alpha channel"
397
- directly in the prompt. The model will often produce a fake checkerboard pattern
398
- burned into the image, or ignore the request entirely. Always use the
399
- solid-background strategy above.
411
+ **Anti-pattern:** hand-writing "transparent background" or "PNG with alpha
412
+ channel" into a prompt WITHOUT `--bg transparent`. Bare wording can still bake
413
+ a fake checkerboard into an opaque image; the flag pairs the API parameter with
414
+ the tuned suffix. Verify the alpha channel instead of eyeballing the preview.
400
415
 
401
416
  ### Prompt Iteration
402
417
 
@@ -437,8 +437,17 @@ mockups is NOT a skip; it becomes generation input via `--ref`.
437
437
  When concept exploration (UX-CONCEPT-GEN-01) or image-first ism discovery
438
438
  (UX-IMAGE-FIRST-01) generates component/element mockups that need to float
439
439
  over arbitrary backgrounds — icons, 3D objects, product shots, stickers,
440
- UI chrome elements — use the cutout asset pipeline. GPT Image 2 cannot
441
- produce transparent backgrounds reliably; solid-bg-then-remove is mandatory.
440
+ UI chrome elements — use the cutout asset pipeline. GPT Image 2 CAN produce
441
+ real transparent backgrounds: pass `--bg transparent` and the asset comes back
442
+ as an RGBA PNG, no keying pass required.
443
+
444
+ ```bash
445
+ ima2 gen "3D render of [subject], [material], [composition]" \
446
+ --bg transparent --quality high --mode direct -o concept-01.png
447
+ ```
448
+
449
+ Fall back to solid-bg-then-remove when you need a matte for a chroma-key
450
+ pipeline, or when a subject refuses to isolate cleanly.
442
451
 
443
452
  **Cutout asset prompt template (concept pass):**
444
453
 
@@ -467,8 +476,10 @@ ima2 gen "[subject], centered. PURE SOLID background hex #[target]. \
467
476
  - Wrap in `isolation: isolate` container to prevent bleed
468
477
  - Programmatic: `sharp`, ImageMagick, `rembg`. Interactive: ima2 Canvas Mode.
469
478
 
470
- **Anti-pattern:** requesting "transparent background" or "PNG with alpha" in
471
- the prompt. The model produces fake checkerboard burned into the image.
479
+ **Anti-pattern:** hand-writing "transparent background" or "PNG with alpha"
480
+ into the prompt WITHOUT `--bg transparent` bare wording can bake a fake
481
+ checkerboard into an opaque image. Verify alpha (`channels: 4`) rather than
482
+ trusting the preview.
472
483
 
473
484
  Full pipeline reference: `ima2-front/references/asset-requirements.md`
474
485
  § Asset Background Strategy (FE-ASSET-BG-01).