ima2-gen 3.24.1 → 3.25.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/.env.example CHANGED
@@ -17,6 +17,10 @@
17
17
  # IMA2_OAUTH_PROXY_PORT=10531 # that endpoint's port on 127.0.0.1; also accepted: OAUTH_PORT
18
18
  # IMA2_OAUTH_STATUS_TIMEOUT_MS=3000
19
19
  # IMA2_OAUTH_RESTART_DELAY_MS=5000
20
+ # IMA2_OAUTH_RATE_RETRY_MAX=5 # retries per job (plan + renders) after a per-minute GPT OAuth rate limit (0 disables)
21
+ # IMA2_OAUTH_RATE_RETRY_BASE_MS=8000 # wait = base x attempt (+-20% jitter) unless the reply says "try again in Ns"
22
+ # IMA2_OAUTH_RATE_RETRY_MAX_MS=45000 # cap for any single wait; a wordless 429 whose Retry-After exceeds it is not retried
23
+ # IMA2_OAUTH_RATE_RETRY_TOTAL_MS=120000 # cap for all waits of one job; no wait runs past the generation timeout
20
24
  # IMA2_RESEARCH_SUFFIX="..." # appended to every image prompt
21
25
 
22
26
  # ── Paths ──────────────────────────────────────────────────────────────
package/CHANGELOG.md CHANGED
@@ -7,11 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.25.0] - 2026-09-30
11
+
12
+ ### Added
13
+
14
+ - **Masked edits keep everything outside the mask** — after a masked edit the server restores the source wherever the mask is opaque and blends soft mask edges, so the model can no longer redraw the area you kept. Transparent source pixels stay transparent, and a result that comes back at another size is fitted to the source first. On by default; turn it off with `IMA2_PRESERVE_OUTSIDE_MASK=0` or `ima2 config set features.preserveOutsideMask false`. Composites run one at a time and sources above 4096 × 4096 keep the provider result; the sidecar records `maskOutsidePreserved` when the lock ran. (#340, thanks @ree9622)
15
+ - **GPT OAuth waits out per-minute rate limits** — a transient 429 ("rate limit reached … try again in Ns") on the planner or a render now waits and retries the same request with jitter, while usage, quota and billing limits still fail at once. Each job has one budget shared by the plan and every render: `IMA2_OAUTH_RATE_RETRY_MAX` (5 retries), `IMA2_OAUTH_RATE_RETRY_BASE_MS` (8000), `IMA2_OAUTH_RATE_RETRY_MAX_MS` (45000 per wait), `IMA2_OAUTH_RATE_RETRY_TOTAL_MS` (120000 in total), and no wait runs past the job's generation timeout. A job canceled during a rate limit ends as `499 GENERATION_CANCELED`. (#339, thanks @ree9622)
16
+
17
+ ### Changed
18
+
19
+ - Tests retry temp-directory cleanup on Windows file locks and give media events 30 seconds (#350).
20
+
21
+ ## [3.24.1] - 2026-09-29
22
+
10
23
  ### Changed
11
24
 
12
25
  - **Releases finish on their own** — a PR merged into dev during a release no longer fails it: the stable publish requires the tag to match and main, dev and preview to contain the release, and the tag job lands the release on dev by fast-forward or merge. `npm run release` pushes the admin-only `desktop-v` tag as soon as the release tag lands, which starts the desktop build.
13
26
  - **`npm run release -- resume X.Y.Z`** finishes an already-tagged release (dev landing, desktop tag and build, stable publish, Pages) and skips what already landed. `--yes` now answers every prompt.
14
27
  - Release waits only follow the publish run for their own ref and give a dispatch ten minutes to appear.
28
+ - The release dispatches publish.yml on the ref it publishes (`--ref vX.Y.Z`, `--ref preview`), so the package smoke compares the tarball with the release commit even when main has moved on; the smoke reads `PUBLISH_SHA`.
29
+ - **Resume finishes an untagged cut** — when a release stops after main and the npm preview proof but before the tag, `npm run release -- resume X.Y.Z` finds the version commit on main, re-checks the preview proof and mints the tag. The npm registry proof window grows from 15 to 35 minutes (publish verify steps 51, release waits 130), since a preview took longer than 15 minutes to appear.
15
30
  - **Site: the desktop app comes right after the hero** — a new "Desktop app" section under the unchanged hero offers a one-click download for the visitor's OS (resolved from the latest desktop release, with a releases-page fallback), explains what the app adds (bundled server, tray, CLI attach/takeover, macOS updates) with a tray menu built from the real labels, and the header gains a Download link. The download blocks share one mounter.
16
31
 
17
32
  ## [3.24.0] - 2026-09-29
package/README.md CHANGED
@@ -362,6 +362,7 @@ Config priority is `environment variables > ~/.ima2/config.json > built-in defau
362
362
  | `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
363
363
  | `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
364
364
  | `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
365
+ | `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
365
366
  | `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
366
367
  | `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
367
368
  | `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
package/config.js CHANGED
@@ -179,6 +179,15 @@ export const config = {
179
179
  statusTimeoutMs: pickInt(env.IMA2_OAUTH_STATUS_TIMEOUT_MS, fileCfg.oauth?.statusTimeoutMs, 3000),
180
180
  generationTimeoutMs: pickInt(env.IMA2_OAUTH_GENERATION_TIMEOUT_MS, fileCfg.oauth?.generationTimeoutMs, 400 * 1000),
181
181
  restartDelayMs: pickInt(env.IMA2_OAUTH_RESTART_DELAY_MS, fileCfg.oauth?.restartDelayMs, 5000),
182
+ // Backoff for per-minute rate limits (HTTP 429 "... per min", "try again in Ns"); usage caps
183
+ // and quotas are never retried. See lib/oauthRateLimit.ts.
184
+ rateLimitRetry: {
185
+ maxRetries: pickInt(env.IMA2_OAUTH_RATE_RETRY_MAX, fileCfg.oauth?.rateLimitRetry?.maxRetries, 5),
186
+ baseDelayMs: pickInt(env.IMA2_OAUTH_RATE_RETRY_BASE_MS, fileCfg.oauth?.rateLimitRetry?.baseDelayMs, 8000),
187
+ maxDelayMs: pickInt(env.IMA2_OAUTH_RATE_RETRY_MAX_MS, fileCfg.oauth?.rateLimitRetry?.maxDelayMs, 45_000),
188
+ // Retries and this total wait are one budget per job (plan + renders), inside generationTimeoutMs.
189
+ maxTotalWaitMs: pickInt(env.IMA2_OAUTH_RATE_RETRY_TOTAL_MS, fileCfg.oauth?.rateLimitRetry?.maxTotalWaitMs, 120_000),
190
+ },
182
191
  // Provider-backed masked edit is off until upstream STEP-0 verification is recorded.
183
192
  maskedEditEnabled: pickBool(env.IMA2_OAUTH_MASKED_EDIT_ENABLED, fileCfg.oauth?.maskedEditEnabled, false),
184
193
  forceImageToolChoice: pickBool(env.IMA2_OAUTH_FORCE_IMAGE_TOOL_CHOICE, fileCfg.oauth?.forceImageToolChoice, true),
@@ -321,6 +330,10 @@ export const config = {
321
330
  },
322
331
  features: {
323
332
  cardNews: pickBool(env.IMA2_CARD_NEWS, fileCfg.features?.cardNews, env.IMA2_DEV === "1"),
333
+ // Masked edits restore the source wherever the mask is opaque, because GPT Image
334
+ // treats the mask as guidance and may redraw the kept area. Opt out with
335
+ // IMA2_PRESERVE_OUTSIDE_MASK=0 to keep the raw provider result.
336
+ preserveOutsideMask: pickBool(env.IMA2_PRESERVE_OUTSIDE_MASK, fileCfg.features?.preserveOutsideMask, true),
324
337
  },
325
338
  cardNewsPlanner: {
326
339
  enabled: pickBool(env.IMA2_CARD_NEWS_PLANNER, fileCfg.cardNewsPlanner?.enabled, true),
package/docs/API.md CHANGED
@@ -70,7 +70,7 @@ Image generation supports OAuth, API-key, Grok, and Gemini (`agy` and `gemini-ap
70
70
  - API-key generation covers classic generate, edit, mask-guided edit, multimode, and node generation.
71
71
  - If `provider: "api"` is requested without an API key, routes fail before upstream with `401` and `API_KEY_REQUIRED`.
72
72
  - Grok generation maps `size` to xAI `aspect_ratio` and `resolution`; it does not send an OpenAI-style `size` field upstream. Grok edit uses xAI `/v1/images/edits`; Grok mask edit remains unsupported and returns `GROK_MASK_UNSUPPORTED`.
73
- - Mask edits are mask/selection guided edits, not pixel-perfect inpaint guarantees.
73
+ - Mask edits are mask/selection guided edits. The model may redraw outside the mask, so the edit route restores source pixels wherever the mask is opaque before saving (`features.preserveOutsideMask`, env `IMA2_PRESERVE_OUTSIDE_MASK`, default on; the sidecar records `maskOutsidePreserved: true`). The output keeps the source dimensions. When the provider returns a different aspect ratio, the result is stretched to the source size before compositing, so the edited area may look stretched or squeezed. Transparent source pixels in the kept area stay transparent. Composites run one at a time and refuse sources above 4096 × 4096 pixels. If compositing fails or the source is above that bound, the provider result is saved unchanged and `maskOutsidePreserved` is omitted.
74
74
 
75
75
  Grok video generation uses `POST /api/video/generate` (SSE). See the Video
76
76
  Generation section below for the full endpoint specification.
@@ -406,7 +406,7 @@ Custom sizes are reduced to the closest xAI-supported aspect ratio and use
406
406
 
407
407
  Image edit / image-to-image generation.
408
408
 
409
- The request includes a prompt and image payload. `provider: "api"` sends the prompt and image through the shared Responses image adapter. Optional masks are forwarded as mask guidance, not a pixel-perfect edit guarantee.
409
+ The request includes a prompt and image payload. `provider: "api"` sends the prompt and image through the shared Responses image adapter. Optional masks are forwarded as mask guidance; after the provider returns, pixels where the mask is opaque are restored from the source image (see `IMA2_PRESERVE_OUTSIDE_MASK`).
410
410
 
411
411
  With `provider: "grok"`, edit requests are sent to xAI `/v1/images/edits`
412
412
  with the stored OAuth session. Masked Grok edits are rejected before
package/docs/README.ja.md CHANGED
@@ -361,6 +361,7 @@ Full reference: [CLI.md](CLI.md).
361
361
  | `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
362
362
  | `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
363
363
  | `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
364
+ | `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
364
365
  | `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
365
366
  | `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
366
367
  | `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
package/docs/README.ko.md CHANGED
@@ -361,6 +361,7 @@ Full reference: [CLI.md](CLI.md).
361
361
  | `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
362
362
  | `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
363
363
  | `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
364
+ | `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
364
365
  | `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
365
366
  | `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
366
367
  | `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
@@ -361,6 +361,7 @@ Full reference: [docs/CLI.md](CLI.md).
361
361
  | `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
362
362
  | `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
363
363
  | `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
364
+ | `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
364
365
  | `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
365
366
  | `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
366
367
  | `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
@@ -361,6 +361,7 @@ Full reference: [docs/CLI.md](CLI.md).
361
361
  | `IMA2_GROK_VIDEO_MODEL_DEFAULT` | `grok-imagine-video-1.5` | Default Grok video model |
362
362
  | `IMA2_GROK_GENERATION_TIMEOUT_MS` | `300000` | Timeout for the final Grok Images API call |
363
363
  | `IMA2_OAUTH_MASKED_EDIT_ENABLED` | `false` | Opt-in feature flag for masked-edit requests on the OAuth path (#31, groundwork only) |
364
+ | `IMA2_PRESERVE_OUTSIDE_MASK` | `true` | Restore source pixels wherever an edit mask is opaque, so masked edits cannot redraw the kept area (`0` keeps the raw provider result) |
364
365
  | `GEMINI_API_KEY` | — | API key for `provider: "gemini-api"` direct Generative Language API path |
365
366
  | `VERTEX_SERVICE_ACCOUNT_JSON` | — | Google service account JSON for Vertex AI auth with `provider: "gemini-api"`; takes priority over `GEMINI_API_KEY` when both are set |
366
367
  | `IMA2_AGY_BIN` | `agy` on PATH | Explicit path to the Antigravity CLI binary for `provider: "agy"` |
@@ -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: 532 (runtime: 239, contract: 293)
7
+ Total: 536 (runtime: 243, contract: 293)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/advertise-payload-contract.test.ts`
@@ -49,6 +49,7 @@ Total: 532 (runtime: 239, contract: 293)
49
49
  - `tests/card-news-template.test.ts`
50
50
  - `tests/chatgpt-auth-contract.test.ts`
51
51
  - `tests/classic-generate-async.test.ts`
52
+ - `tests/cli-config-keys-contract.test.ts`
52
53
  - `tests/cli-error-hints.test.ts`
53
54
  - `tests/cli-lan-auth.test.ts`
54
55
  - `tests/cli-lib.test.ts`
@@ -116,6 +117,7 @@ Total: 532 (runtime: 239, contract: 293)
116
117
  - `tests/local-import-contract.test.ts`
117
118
  - `tests/local-lan-policy.test.ts`
118
119
  - `tests/logging.test.ts`
120
+ - `tests/masked-edit-composite.test.ts`
119
121
  - `tests/mcp-character-route.test.ts`
120
122
  - `tests/mcp-connection-manager.test.ts`
121
123
  - `tests/mcp-connection-routes.test.ts`
@@ -162,6 +164,8 @@ Total: 532 (runtime: 239, contract: 293)
162
164
  - `tests/oauth-cli-dependencies.test.ts`
163
165
  - `tests/oauth-normalize.test.ts`
164
166
  - `tests/oauth-proxy-error-safety.test.ts`
167
+ - `tests/oauth-rate-limit-lane.test.ts`
168
+ - `tests/oauth-rate-limit-retry.test.ts`
165
169
  - `tests/open-directory.test.ts`
166
170
  - `tests/openai-execution-parity.test.ts`
167
171
  - `tests/openai-transport-parity.test.ts`
@@ -314,7 +318,6 @@ Total: 532 (runtime: 239, contract: 293)
314
318
  - `tests/cli-capabilities-contract.test.ts`
315
319
  - `tests/cli-character-contract.test.ts`
316
320
  - `tests/cli-commands.test.ts`
317
- - `tests/cli-config-keys-contract.test.ts`
318
321
  - `tests/cli-default-output-dir-contract.test.ts`
319
322
  - `tests/cli-defaults-command-contract.test.ts`
320
323
  - `tests/cli-destructive-safety-contract.test.ts`
@@ -515,6 +518,7 @@ Total: 532 (runtime: 239, contract: 293)
515
518
  - `tests/style-feature-removal-contract.test.ts`
516
519
  - `tests/subprocess-deadline-contract.test.ts`
517
520
  - `tests/test-runner-invocation.test.ts`
521
+ - `tests/test-temp-cleanup-contract.test.ts`
518
522
  - `tests/toast-stack-contract.test.ts`
519
523
  - `tests/tools-cli-contract.test.ts`
520
524
  - `tests/transparent-background-ui-contract.test.ts`
package/lib/configKeys.js CHANGED
@@ -8,6 +8,7 @@ export const WRITABLE_CONFIG_KEYS = new Set([
8
8
  "apiProvider.defaultReasoningEffort",
9
9
  "log.level",
10
10
  "features.cardNews",
11
+ "features.preserveOutsideMask",
11
12
  "cardNewsPlanner.enabled",
12
13
  "cardNewsPlanner.model",
13
14
  "cardNewsPlanner.timeoutMs",
@@ -48,6 +49,7 @@ export const KEY_TO_ENV = {
48
49
  "apiProvider.defaultReasoningEffort": "IMA2_API_REASONING_EFFORT",
49
50
  "log.level": "IMA2_LOG_LEVEL",
50
51
  "features.cardNews": "IMA2_CARD_NEWS",
52
+ "features.preserveOutsideMask": "IMA2_PRESERVE_OUTSIDE_MASK",
51
53
  "server.port": "IMA2_PORT",
52
54
  "server.host": "IMA2_HOST",
53
55
  "server.publicOrigins": "IMA2_PUBLIC_ORIGINS",
@@ -45,7 +45,8 @@ export function isConnectionResetError(err) {
45
45
  || msg.includes("connection reset by peer")
46
46
  || msg.includes("socket hang up");
47
47
  }
48
- function retryAfterDelayMs(headers) {
48
+ /** Retry-After (seconds or HTTP date) in ms; also used by lib/oauthRateLimit.ts. */
49
+ export function retryAfterDelayMs(headers) {
49
50
  const raw = headers.get("retry-after")?.trim();
50
51
  if (!raw)
51
52
  return undefined;
@@ -62,7 +63,11 @@ export function retryBackoffDelayMs(attempt, opts) {
62
63
  if (retryAfter !== undefined)
63
64
  return Math.min(retryAfter, opts.maxDelayMs);
64
65
  const exp = Math.min(opts.baseDelayMs * (2 ** attempt), opts.maxDelayMs);
65
- return Math.floor(exp * (0.8 + Math.random() * 0.4));
66
+ return jitterDelayMs(exp);
67
+ }
68
+ /** Spread a wait by ±20% so callers blocked in the same instant do not retry in lockstep. */
69
+ export function jitterDelayMs(ms, random = Math.random) {
70
+ return Math.floor(ms * (0.8 + random() * 0.4));
66
71
  }
67
72
  function abortError(signal) {
68
73
  return signal?.reason ?? new DOMException("The operation was aborted", "AbortError");
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Deterministic pixel lock for masked edits.
3
+ *
4
+ * GPT Image models receive the edit mask as guidance, not as a pixel lock, so
5
+ * they often redraw the area the user meant to keep. After the provider
6
+ * returns, restore the source wherever the submitted mask is opaque (the
7
+ * OpenAI mask contract: alpha 0 = editable, alpha 255 = keep). Partial alpha
8
+ * blends source and result proportionally, so a soft mask edge stays soft.
9
+ *
10
+ * The blend covers all four RGBA channels, so a transparent source pixel in
11
+ * the kept area stays transparent instead of letting the result show through.
12
+ *
13
+ * The output keeps the source dimensions. When the backend returns a
14
+ * different size, the result is resized to the source first so the kept area
15
+ * stays byte-identical to the source instead of being resampled.
16
+ *
17
+ * Memory is bounded twice: every decode refuses inputs above
18
+ * MASKED_EDIT_MAX_PIXELS, and composites run one at a time, so at most one set
19
+ * of full-size raw buffers exists however many edits run in parallel. A
20
+ * refused input throws; the edit route then keeps the provider bytes.
21
+ */
22
+ import sharp from "sharp";
23
+ /** 4096 x 4096: one composite then holds about 4 raw buffers of 64 MiB. */
24
+ export const MASKED_EDIT_MAX_PIXELS = 4096 * 4096;
25
+ function decode(input) {
26
+ return sharp(input, { limitInputPixels: MASKED_EDIT_MAX_PIXELS });
27
+ }
28
+ function encode(pipeline, format, quality) {
29
+ const q = Math.max(1, Math.min(100, Math.round(quality) || 100));
30
+ if (format === "jpeg")
31
+ return pipeline.jpeg({ quality: q });
32
+ if (format === "webp")
33
+ return pipeline.webp({ quality: q });
34
+ return pipeline.png();
35
+ }
36
+ function rgbaAt(input, width, height) {
37
+ return decode(input).resize(width, height, { fit: "fill" }).ensureAlpha().raw().toBuffer();
38
+ }
39
+ function maskAlphaAt(mask, width, height) {
40
+ return decode(mask).resize(width, height, { fit: "fill" }).ensureAlpha().extractChannel("alpha").raw().toBuffer();
41
+ }
42
+ /**
43
+ * result = source * keep + result * (1 - keep) per channel, keep = mask alpha / 255.
44
+ * Returns whether any output pixel is not fully opaque.
45
+ */
46
+ function blendInto(result, source, keep) {
47
+ let translucent = false;
48
+ for (let pixel = 0, base = 0; pixel < keep.length; pixel += 1, base += 4) {
49
+ const k = keep[pixel];
50
+ if (k === 255)
51
+ source.copy(result, base, base, base + 4);
52
+ else if (k !== 0) {
53
+ for (let c = 0; c < 4; c += 1) {
54
+ result[base + c] = Math.round((source[base + c] * k + result[base + c] * (255 - k)) / 255);
55
+ }
56
+ }
57
+ if (result[base + 3] < 255)
58
+ translucent = true;
59
+ }
60
+ return translucent;
61
+ }
62
+ async function composite(input) {
63
+ const { width, height } = await decode(input.source).metadata();
64
+ if (!width || !height)
65
+ throw new Error("masked edit source dimensions are unavailable");
66
+ if (width * height > MASKED_EDIT_MAX_PIXELS)
67
+ throw new Error("masked edit source exceeds the composite pixel limit");
68
+ const source = await rgbaAt(input.source, width, height);
69
+ const result = await rgbaAt(input.result, width, height);
70
+ const keep = await maskAlphaAt(input.mask, width, height);
71
+ const translucent = blendInto(result, source, keep);
72
+ // An all-opaque output is saved without an alpha channel, so alphaVerified
73
+ // keeps meaning "some pixel is actually transparent".
74
+ const pipeline = sharp(result, { raw: { width, height, channels: 4 } });
75
+ const output = translucent ? pipeline : pipeline.removeAlpha();
76
+ return encode(output, input.format ?? "png", input.quality ?? 100).toBuffer();
77
+ }
78
+ let queueTail = Promise.resolve();
79
+ /** Composites run one at a time; see the memory note above. */
80
+ export function preserveOutsideMask(input) {
81
+ const run = queueTail.then(() => composite(input));
82
+ queueTail = run.catch(() => undefined);
83
+ return run;
84
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { logEvent } from "./logger.js";
15
15
  import { postOAuthImages, postResponses } from "./responsesTransport.js";
16
+ import { createOAuthRateLimitBudget, oauthRateLimitRetryConfig, withOAuthRateLimitRetry, } from "./oauthRateLimit.js";
16
17
  export const OAUTH_IMAGE_TOOL = "image_gen";
17
18
  export const OAUTH_RENDER_MODEL = "gpt-image-2";
18
19
  /** The ChatGPT images edit endpoint accepts at most five input images. */
@@ -128,7 +129,27 @@ export function renderFields(job) {
128
129
  function extensionFor(mime) {
129
130
  return mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png";
130
131
  }
131
- async function renderOne(job, prompt) {
132
+ /**
133
+ * One retry budget per job: the plan and every render draw from the same retry count and total
134
+ * wait, and no wait may run past the job's generation timeout.
135
+ */
136
+ function rateLimitBudgetFor(job) {
137
+ return createOAuthRateLimitBudget(job.ctx?.config?.oauth?.generationTimeoutMs ?? 400 * 1000);
138
+ }
139
+ /** Replay a call the backend rejected with a per-minute rate limit; log every wait. */
140
+ function withRateLimitBackoff(job, budget, stage, request) {
141
+ return withOAuthRateLimitRetry(request, {
142
+ config: oauthRateLimitRetryConfig(job.ctx?.config?.oauth?.rateLimitRetry),
143
+ budget,
144
+ signal: job.signal,
145
+ onRetry: (info) => logEvent(job.scope, "rate_limit_retry", { requestId: job.requestId, stage, ...info }),
146
+ onExhausted: (info) => logEvent(job.scope, "rate_limit_exhausted", { requestId: job.requestId, stage, ...info }),
147
+ });
148
+ }
149
+ async function renderOne(job, budget, prompt) {
150
+ return withRateLimitBackoff(job, budget, "render", () => renderOnce(job, prompt));
151
+ }
152
+ async function renderOnce(job, prompt) {
132
153
  const fields = renderFields(job);
133
154
  if (!job.images.length) {
134
155
  return postOAuthImages({
@@ -174,18 +195,18 @@ function emptyDiagnostics() {
174
195
  outputTextChars: 0,
175
196
  };
176
197
  }
177
- async function plan(job) {
178
- let result = await postResponses({
198
+ async function plan(job, budget) {
199
+ let result = await withRateLimitBackoff(job, budget, "plan", () => postResponses({
179
200
  ctx: job.ctx, provider: "oauth", scope: `${job.scope}-plan`, requestId: job.requestId,
180
201
  signal: job.signal, maxImages: 0, payload: buildPlannerPayload(job),
181
- });
202
+ }));
182
203
  let prompts = promptsFromCalls(result.functionCalls, job.maxImages);
183
204
  if (!prompts.length) {
184
205
  logEvent(job.scope, "plan_retry", { requestId: job.requestId, events: result.eventCount });
185
- result = await postResponses({
206
+ result = await withRateLimitBackoff(job, budget, "plan", () => postResponses({
186
207
  ctx: job.ctx, provider: "oauth", scope: `${job.scope}-plan`, requestId: job.requestId,
187
208
  signal: job.signal, maxImages: 0, payload: buildPlannerPayload(job, true),
188
- });
209
+ }));
189
210
  prompts = promptsFromCalls(result.functionCalls, job.maxImages);
190
211
  }
191
212
  return { result, prompts };
@@ -195,9 +216,10 @@ export async function runOAuthImageJob(job) {
195
216
  // prompt per stage, so it goes through the planner (its user text already carries the
196
217
  // sequence instructions and the Direct fidelity rule).
197
218
  const direct = job.mode === "direct" && job.maxImages <= 1;
219
+ const budget = rateLimitBudgetFor(job);
198
220
  const planned = direct
199
221
  ? { result: null, prompts: Array.from({ length: Math.max(1, job.maxImages) }, () => job.directPrompt) }
200
- : await plan(job);
222
+ : await plan(job, budget);
201
223
  const base = planned.result;
202
224
  const usage = {};
203
225
  addUsage(usage, base?.usage);
@@ -215,7 +237,7 @@ export async function runOAuthImageJob(job) {
215
237
  while (cursor < planned.prompts.length) {
216
238
  const index = cursor++;
217
239
  try {
218
- slots[index]?.resolve({ status: "done", value: await renderOne(job, planned.prompts[index]) });
240
+ slots[index]?.resolve({ status: "done", value: await renderOne(job, budget, planned.prompts[index]) });
219
241
  }
220
242
  catch (error) {
221
243
  slots[index]?.resolve({ status: "failed", error });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Backoff for GPT OAuth per-minute rate limits.
3
+ *
4
+ * The ChatGPT backend rejects a burst with HTTP 429 and a message such as
5
+ * "Rate limit reached for gpt-image-2-codex ... on input-images per min: Limit 4000,
6
+ * Used 4000. Please try again in 7.5s". That bucket refills within the minute, so waiting
7
+ * and sending the same request again succeeds. A usage cap (the 5-hour or weekly plan
8
+ * limit, `usage_limit_reached`, an exhausted quota or billing limit) does not refill in
9
+ * seconds and must fail fast, so it is excluded before any per-minute wording is matched.
10
+ *
11
+ * A 429 is a rejection before any work starts, so replaying the request cannot bill the
12
+ * user twice (unlike the pre-response retries in lib/grokUpstreamRetry.ts).
13
+ *
14
+ * One job (a plan plus its renders) spends one retry budget: a retry count, a total wait and
15
+ * the job's generation deadline, so a burst never stretches a job past its timeout.
16
+ *
17
+ * MUST stay a leaf module: it only borrows the abortable sleep, jitter and Retry-After parsing.
18
+ */
19
+ import { jitterDelayMs, retryAfterDelayMs, sleepWithAbort } from "./grokUpstreamRetry.js";
20
+ /**
21
+ * Caps that no short wait can clear. Checked first so mixed wording never retries. A bare
22
+ * "billing" or "credit" is not matched: per-minute messages can link to the billing page or
23
+ * mention credits, so only an exhausted credit balance counts.
24
+ */
25
+ const PERMANENT_LIMIT_RE = /usage[ _]?limit|usage cap|quota|billing[ _](?:hard[ _])?limit|hard limit|spending|insufficient[_ ]credits?|credits? (?:exhausted|depleted|balance)|out of credits|plan limit|monthly|weekly|daily|per day|\b5[- ]?hours?\b/i;
26
+ const TRANSIENT_LIMIT_RE = /rate[ _]?limit(?:ed| reached|_exceeded)|(?:requests|tokens|images|input-images) per min|per minute|try again in/i;
27
+ const PER_WINDOW_RE = /per min|per minute|try again in/i;
28
+ export const DEFAULT_OAUTH_RATE_LIMIT_RETRY = Object.freeze({
29
+ maxRetries: 5,
30
+ baseDelayMs: 8_000,
31
+ maxDelayMs: 45_000,
32
+ maxTotalWaitMs: 120_000,
33
+ });
34
+ /** `null` means "not a rate limit this module recognizes"; only "transient" is retried. */
35
+ export function classifyOAuthRateLimit({ status, text, retryAfterMs, maxDelayMs }) {
36
+ if (status === 401 || status === 403)
37
+ return null;
38
+ const s = String(text ?? "");
39
+ if (PERMANENT_LIMIT_RE.test(s))
40
+ return status === 429 || TRANSIENT_LIMIT_RE.test(s) ? "permanent" : null;
41
+ if (TRANSIENT_LIMIT_RE.test(s) && (status === 429 || PER_WINDOW_RE.test(s)))
42
+ return "transient";
43
+ // A wordless 429 with only Retry-After is a per-window bucket only when the wait fits one
44
+ // retry; a longer wait points at a cap the text did not name, so it surfaces unclassified.
45
+ if (status === 429 && retryAfterMs !== undefined) {
46
+ return retryAfterMs <= (maxDelayMs ?? DEFAULT_OAUTH_RATE_LIMIT_RETRY.maxDelayMs) ? "transient" : null;
47
+ }
48
+ return null;
49
+ }
50
+ /** "Please try again in 7.5s" / "in 820ms" / "in 2 seconds" → milliseconds. */
51
+ export function parseRetryHintMs(text) {
52
+ const match = String(text ?? "").match(/try again in\s*([\d.]+)\s*(ms|milliseconds?|s|secs?|seconds?)\b/i);
53
+ if (!match)
54
+ return undefined;
55
+ const value = Number.parseFloat(match[1]);
56
+ if (!Number.isFinite(value) || value < 0)
57
+ return undefined;
58
+ return /^m/i.test(match[2]) ? value : value * 1000;
59
+ }
60
+ /** Fields the transport attaches to a rejected OAuth request. */
61
+ export function oauthRateLimitFields(status, text, headers, maxDelayMs) {
62
+ const headerMs = headers ? retryAfterDelayMs(headers) : undefined;
63
+ const kind = classifyOAuthRateLimit({ status, text, retryAfterMs: headerMs, maxDelayMs });
64
+ if (!kind)
65
+ return {};
66
+ const retryAfterMs = parseRetryHintMs(text) ?? headerMs;
67
+ return { rateLimit: kind, ...(kind === "transient" && retryAfterMs !== undefined ? { retryAfterMs } : {}) };
68
+ }
69
+ function nonNegativeInt(value, fallback) {
70
+ const n = Number(value);
71
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
72
+ }
73
+ export function oauthRateLimitRetryConfig(raw) {
74
+ const d = DEFAULT_OAUTH_RATE_LIMIT_RETRY;
75
+ const maxRetries = Math.min(nonNegativeInt(raw?.maxRetries, d.maxRetries), 20);
76
+ const baseDelayMs = nonNegativeInt(raw?.baseDelayMs, d.baseDelayMs);
77
+ const maxDelayMs = Math.max(baseDelayMs, nonNegativeInt(raw?.maxDelayMs, d.maxDelayMs));
78
+ const maxTotalWaitMs = nonNegativeInt(raw?.maxTotalWaitMs, d.maxTotalWaitMs);
79
+ return { maxRetries, baseDelayMs, maxDelayMs, maxTotalWaitMs };
80
+ }
81
+ /**
82
+ * The upstream hint wins when it is a real wait (≥ 1s); sub-second hints are unrealistic for a
83
+ * per-minute bucket, so the linear backoff (base × attempt) applies. The backoff is spread ±20%
84
+ * like lib/grokUpstreamRetry.ts; a hint only moves later (0 to +20%) so no retry lands before
85
+ * the bucket refills. Both are capped at `maxDelayMs`.
86
+ */
87
+ export function oauthRateLimitDelayMs(attempt, hintMs, config, random = Math.random) {
88
+ if (hintMs !== undefined && hintMs >= 1000)
89
+ return Math.min(Math.ceil(hintMs * (1 + random() * 0.2)), config.maxDelayMs);
90
+ return Math.min(jitterDelayMs(config.baseDelayMs * Math.max(1, attempt), random), config.maxDelayMs);
91
+ }
92
+ export function isTransientOAuthRateLimit(error) {
93
+ return Boolean(error && typeof error === "object" && error.rateLimit === "transient");
94
+ }
95
+ function retryAfterOf(error) {
96
+ const value = error?.retryAfterMs;
97
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
98
+ }
99
+ function canceledError(cause) {
100
+ return Object.assign(new Error("Generation canceled"), { status: 499, code: "GENERATION_CANCELED", cause });
101
+ }
102
+ export function createOAuthRateLimitBudget(timeoutMs, now = Date.now) {
103
+ const bounded = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0;
104
+ return { retries: 0, totalWaitMs: 0, deadlineAt: bounded ? now() + timeoutMs : undefined };
105
+ }
106
+ /** Why the budget cannot pay for `waitMs`, or null when it can. */
107
+ function budgetShortfall(budget, config, waitMs, now) {
108
+ if (budget.retries >= config.maxRetries)
109
+ return "retries";
110
+ if (budget.totalWaitMs + waitMs > config.maxTotalWaitMs)
111
+ return "total_wait";
112
+ if (budget.deadlineAt !== undefined && now + waitMs >= budget.deadlineAt)
113
+ return "deadline";
114
+ return null;
115
+ }
116
+ /** Run `request`, replaying it after a transient rate limit. Any other error surfaces as is. */
117
+ export async function withOAuthRateLimitRetry(request, options = {}) {
118
+ const config = options.config ?? DEFAULT_OAUTH_RATE_LIMIT_RETRY;
119
+ const budget = options.budget ?? createOAuthRateLimitBudget();
120
+ const sleep = options.sleep ?? sleepWithAbort;
121
+ const now = options.now ?? Date.now;
122
+ const signal = options.signal ?? undefined;
123
+ for (let attempt = 1;; attempt++) {
124
+ try {
125
+ return await request();
126
+ }
127
+ catch (error) {
128
+ if (!isTransientOAuthRateLimit(error))
129
+ throw error;
130
+ // A job canceled while its request was being rate limited ends as a cancel, not a 429.
131
+ if (signal?.aborted)
132
+ throw canceledError(signal.reason ?? error);
133
+ const waitMs = oauthRateLimitDelayMs(attempt, retryAfterOf(error), config, options.random);
134
+ const reason = budgetShortfall(budget, config, waitMs, now());
135
+ if (reason) {
136
+ options.onExhausted?.({ retries: budget.retries, totalWaitMs: budget.totalWaitMs, reason });
137
+ throw error;
138
+ }
139
+ budget.retries++;
140
+ budget.totalWaitMs += waitMs;
141
+ options.onRetry?.({ attempt: budget.retries, maxRetries: config.maxRetries, waitMs, totalWaitMs: budget.totalWaitMs });
142
+ try {
143
+ await sleep(waitMs, signal);
144
+ }
145
+ catch (sleepError) {
146
+ if (signal?.aborted)
147
+ throw canceledError(sleepError);
148
+ throw sleepError;
149
+ }
150
+ if (signal?.aborted)
151
+ throw canceledError(signal.reason);
152
+ }
153
+ }
154
+ }
@@ -5,6 +5,7 @@ import { setJobPhase } from "./inflight.js";
5
5
  import { parseJson, parseStream, safeDiagnosticLabel, } from "./responsesParse.js";
6
6
  import { waitForOAuthReady } from "./oauthProxy.js";
7
7
  import { oauthFetch } from "./codexBackend/index.js";
8
+ import { oauthRateLimitFields, oauthRateLimitRetryConfig } from "./oauthRateLimit.js";
8
9
  const RESPONSES_ERROR_MARKER = "ima2ResponsesError";
9
10
  function makeError(message, { status = 500, code = "RESPONSES_IMAGE_ERROR", cause, ...rest } = {}) {
10
11
  const err = new Error(message);
@@ -63,6 +64,15 @@ function safeUpstreamClientMessage(upstream, status) {
63
64
  return "OpenAI rate limited the image request.";
64
65
  return "OpenAI rejected the image request.";
65
66
  }
67
+ /**
68
+ * Rate-limit kind and wait hint for a rejected GPT OAuth call, read from the raw upstream text
69
+ * before the client message is redacted. Only the kind and a number leave this function.
70
+ */
71
+ function rateLimitFieldsOf(ctx, res, upstream, text) {
72
+ const raw = upstream ? [upstream.message, upstream.code, upstream.type].filter(Boolean).join(" ") : text.slice(0, 2000);
73
+ const { maxDelayMs } = oauthRateLimitRetryConfig(ctx?.config?.oauth?.rateLimitRetry);
74
+ return oauthRateLimitFields(res.status, raw, res.headers, maxDelayMs);
75
+ }
66
76
  function apiAuthorizationHeader(apiKey) {
67
77
  const key = typeof apiKey === "string" ? apiKey.trim() : "";
68
78
  if (!key) {
@@ -138,6 +148,7 @@ export async function postResponses({ ctx, provider, scope, payload, requestId,
138
148
  if (!res.ok) {
139
149
  const text = await res.text();
140
150
  const upstream = parseOpenAIErrorBody(text);
151
+ const rateLimit = provider === "api" ? {} : rateLimitFieldsOf(ctx, res, upstream, text);
141
152
  if (res.status >= 400 && res.status < 500 && upstream?.message) {
142
153
  throw makeError(safeUpstreamClientMessage(upstream, res.status), {
143
154
  status: res.status,
@@ -147,11 +158,13 @@ export async function postResponses({ ctx, provider, scope, payload, requestId,
147
158
  upstreamType: upstream.type,
148
159
  upstreamParam: upstream.param,
149
160
  upstreamMessageRedacted: true,
161
+ ...rateLimit,
150
162
  });
151
163
  }
152
164
  throw makeError(`${provider === "api" ? "OpenAI API" : "OAuth proxy"} returned ${res.status}`, {
153
165
  status: res.status,
154
166
  upstreamBodyChars: text.length,
167
+ ...rateLimit,
155
168
  });
156
169
  }
157
170
  if (requestId)
@@ -207,6 +220,7 @@ export async function postOAuthImages({ ctx, scope, kind, json, form, requestId,
207
220
  const text = await res.text();
208
221
  if (!res.ok) {
209
222
  const upstream = parseOpenAIErrorBody(text);
223
+ const rateLimit = rateLimitFieldsOf(ctx, res, upstream, text);
210
224
  if (res.status >= 400 && res.status < 500 && upstream?.message) {
211
225
  throw makeError(safeUpstreamClientMessage(upstream, res.status), {
212
226
  status: res.status,
@@ -216,9 +230,10 @@ export async function postOAuthImages({ ctx, scope, kind, json, form, requestId,
216
230
  upstreamType: upstream.type,
217
231
  upstreamParam: upstream.param,
218
232
  upstreamMessageRedacted: true,
233
+ ...rateLimit,
219
234
  });
220
235
  }
221
- throw makeError(`GPT OAuth returned ${res.status}`, { status: res.status, upstreamBodyChars: text.length });
236
+ throw makeError(`GPT OAuth returned ${res.status}`, { status: res.status, upstreamBodyChars: text.length, ...rateLimit });
222
237
  }
223
238
  let parsed;
224
239
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.24.1",
3
+ "version": "3.25.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",
@@ -131,5 +131,5 @@
131
131
  "typescript-eslint": "8.70.0",
132
132
  "yaml": "2.9.1"
133
133
  },
134
- "gitHead": "d71f73cbb68fff662a1802154a5c9e6fb0b9c82f"
134
+ "gitHead": "057de2c0639cd451c65bff6f484ae397e86d7447"
135
135
  }
package/routes/edit.js CHANGED
@@ -12,7 +12,7 @@ import { checkImageExecutionAdmission } from "../lib/providers/execution/admissi
12
12
  import { resolveGrokQualityModel } from "../lib/imageModels.js";
13
13
  import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStartJobFailure, INFLIGHT_RETRY_AFTER_SECONDS } from "../lib/inflight.js";
14
14
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
15
- import { logEvent, logError } from "../lib/logger.js";
15
+ import { logEvent, logError, logWarn } from "../lib/logger.js";
16
16
  import { hasPngAlphaChannel, parsePngInfo } from "../lib/pngInfo.js";
17
17
  import { verifyBufferAlpha } from "../lib/imageBackgroundParam.js";
18
18
  import { decodeRawForAlpha } from "../lib/alphaDecode.js";
@@ -22,6 +22,7 @@ import { requireRuntimeContext } from "../lib/runtimeContext.js";
22
22
  import { errorEnvelopeFields } from "../lib/errors/envelope.js";
23
23
  import { upstreamLabelFields } from "../lib/diagnosticLabel.js";
24
24
  import { getProviderSurfaceSupport } from "../lib/providers/derive.js";
25
+ import { preserveOutsideMask } from "../lib/maskedEditComposite.js";
25
26
  function validateModeration(ctx, moderation) {
26
27
  if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
27
28
  return { error: "moderation must be one of: auto, low" };
@@ -72,12 +73,12 @@ function validateEditMask(imageB64, mask) {
72
73
  return { error: "mask PNG must include an alpha channel", code: "EDIT_MASK_NO_ALPHA" };
73
74
  }
74
75
  const imageCheck = decodePngDataUrl(imageB64, "INVALID_EDIT_IMAGE_BASE64", "INVALID_EDIT_IMAGE_PNG");
75
- if (imageCheck.error || !imageCheck.info)
76
+ if (imageCheck.error || !imageCheck.buffer || !imageCheck.info)
76
77
  return imageCheck;
77
78
  if (imageCheck.info.width !== maskCheck.info.width || imageCheck.info.height !== maskCheck.info.height) {
78
79
  return { error: "mask dimensions must match image dimensions", code: "EDIT_MASK_DIMENSION_MISMATCH" };
79
80
  }
80
- return { mask: maskCheck.b64, maskBytes: maskCheck.buffer.length };
81
+ return { mask: maskCheck.b64, maskBytes: maskCheck.buffer.length, maskBuffer: maskCheck.buffer, sourceBuffer: imageCheck.buffer };
81
82
  }
82
83
  export function registerEditRoutes(app, ctxRaw) {
83
84
  const ctx = requireRuntimeContext(ctxRaw);
@@ -240,7 +241,29 @@ export function registerEditRoutes(app, ctxRaw) {
240
241
  ? (resultMimeFromProvider || detectImageMimeFromB64(resultB64) || "image/png")
241
242
  : "image/png";
242
243
  const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" || activeProvider === "nai" ? imageFormatFromMime(editMime) : "png";
243
- const editBuffer = Buffer.from(resultB64, "base64");
244
+ const providerBuffer = Buffer.from(resultB64, "base64");
245
+ // Masks are guidance for GPT Image models; restore the source wherever the
246
+ // mask is opaque so a masked edit cannot redraw the area the user kept.
247
+ const { maskBuffer, sourceBuffer } = maskCheck;
248
+ const kept = ctx.config.features?.preserveOutsideMask !== false && maskBuffer && sourceBuffer
249
+ ? { mask: maskBuffer, source: sourceBuffer }
250
+ : null;
251
+ // The provider call is already paid for, so a failed composite falls back
252
+ // to the provider bytes instead of discarding the result with a 500.
253
+ let editBuffer = providerBuffer;
254
+ let maskOutsidePreserved = false;
255
+ if (kept) {
256
+ try {
257
+ editBuffer = await preserveOutsideMask({ ...kept, result: providerBuffer, format: editExt });
258
+ maskOutsidePreserved = true;
259
+ }
260
+ catch (compositeErr) {
261
+ const message = compositeErr instanceof Error ? compositeErr.message : String(compositeErr);
262
+ logWarn("edit", "mask_preserve_failed", { requestId, errorMessage: message.slice(0, 200) });
263
+ }
264
+ }
265
+ const editB64 = maskOutsidePreserved ? editBuffer.toString("base64") : resultB64;
266
+ throwIfJobCanceled(requestId);
244
267
  const createdAt = Date.now();
245
268
  // Semantic alpha verification: at least one pixel with alpha < 255.
246
269
  // Never trust provider mime for transparency claims (same doctrine as
@@ -289,19 +312,20 @@ export function registerEditRoutes(app, ctxRaw) {
289
312
  webSearchCalls,
290
313
  webSearchEnabled,
291
314
  ...(providerUrl ? { providerUrl } : {}),
315
+ ...(maskOutsidePreserved ? { maskOutsidePreserved: true } : {}),
292
316
  };
293
317
  await safeWriteSidecar(join(ctx.config.storage.generatedDir, filename + ".json"), meta);
294
318
  invalidateHistoryIndex();
295
319
  finishHttpStatus = 200;
296
- finishMeta = { filename, imageChars: resultB64.length };
320
+ finishMeta = { filename, imageChars: editB64.length };
297
321
  logEvent("edit", "saved", {
298
322
  requestId,
299
323
  filename,
300
- imageChars: resultB64.length,
324
+ imageChars: editB64.length,
301
325
  elapsedMs: Date.now() - startTime,
302
326
  });
303
327
  res.json({
304
- image: `data:${editMime};base64,${resultB64}`,
328
+ image: `data:${editMime};base64,${editB64}`,
305
329
  elapsed,
306
330
  reasoningEffort,
307
331
  filename,