ima2-gen 3.13.1 → 3.14.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 (157) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +15 -2
  4. package/bin/commands/capabilities.js +5 -3
  5. package/bin/commands/config.js +15 -4
  6. package/bin/commands/defaults.js +7 -5
  7. package/bin/commands/doctor.js +88 -39
  8. package/bin/commands/gen.js +21 -4
  9. package/bin/commands/models.js +2 -2
  10. package/bin/commands/ping.js +1 -1
  11. package/bin/commands/prompt.js +5 -3
  12. package/bin/commands/service.js +32 -4
  13. package/bin/commands/tools.js +19 -3
  14. package/bin/commands/upscale.js +18 -4
  15. package/bin/commands/video.js +37 -50
  16. package/bin/ima2.js +28 -9
  17. package/bin/lib/characterResolve.js +2 -1
  18. package/bin/lib/client.js +155 -20
  19. package/bin/lib/config-store.js +40 -7
  20. package/bin/lib/doctor-bundle.js +6 -2
  21. package/bin/lib/doctor-checks.js +13 -29
  22. package/bin/lib/doctor-media.js +3 -3
  23. package/bin/lib/doctor-providers.js +48 -35
  24. package/bin/lib/doctor-report.js +78 -0
  25. package/bin/lib/doctor-runtime.js +173 -0
  26. package/bin/lib/mcpJob.js +19 -7
  27. package/bin/lib/output.js +3 -0
  28. package/bin/lib/platform.js +12 -9
  29. package/bin/lib/recover-output.js +9 -2
  30. package/bin/lib/sse.js +9 -1
  31. package/bin/lib/videoMcp.js +16 -8
  32. package/config.js +41 -5
  33. package/docs/API.md +154 -16
  34. package/docs/CLI.md +65 -1
  35. package/docs/DOCKER.md +19 -4
  36. package/docs/DOCKER.zh-CN.md +11 -4
  37. package/docs/DOCKER.zh-TW.md +11 -4
  38. package/docs/FAQ.md +13 -0
  39. package/docs/NPX_QUICKSTART.md +6 -0
  40. package/docs/README.ja.md +15 -2
  41. package/docs/README.ko.md +15 -2
  42. package/docs/README.zh-CN.md +15 -2
  43. package/docs/README.zh-TW.md +15 -2
  44. package/docs/migration/runtime-test-inventory.md +79 -8
  45. package/lib/agyArtifact.js +82 -0
  46. package/lib/agyArtifactRead.js +249 -0
  47. package/lib/agyImageAdapter.js +2 -329
  48. package/lib/agyProcess.js +122 -0
  49. package/lib/apiRequestBudget.js +48 -0
  50. package/lib/assetLifecycle.js +42 -9
  51. package/lib/canvasVersionStore.js +46 -14
  52. package/lib/capabilities.js +7 -1
  53. package/lib/configKeys.js +2 -0
  54. package/lib/errors/providerMap.js +26 -0
  55. package/lib/eventBus.js +10 -6
  56. package/lib/eventsPolicy.js +9 -0
  57. package/lib/geminiApiImageAdapter.js +1 -214
  58. package/lib/generatePipeline.js +39 -165
  59. package/lib/generatedMediaAccess.js +74 -0
  60. package/lib/generationErrors.js +3 -22
  61. package/lib/grokImageAdapter.js +2 -353
  62. package/lib/grokImageCore.js +1 -57
  63. package/lib/grokImageDownload.js +159 -0
  64. package/lib/grokImageDownloadPolicy.js +124 -0
  65. package/lib/grokImagePlanner.js +301 -0
  66. package/lib/grokMultimodeAdapter.js +1 -75
  67. package/lib/grokVideoDownload.js +137 -34
  68. package/lib/inflight.js +112 -112
  69. package/lib/jobs/terminalStore.js +45 -0
  70. package/lib/lanSessionStore.js +131 -0
  71. package/lib/localAccessPolicy.js +180 -0
  72. package/lib/localLanAccess.js +281 -0
  73. package/lib/logger.js +22 -13
  74. package/lib/mcp/downloadMediaResult.js +72 -78
  75. package/lib/multimodePipeline.js +39 -126
  76. package/lib/nodeGeneration.js +44 -101
  77. package/lib/nodeStore.js +25 -11
  78. package/lib/nodeValidation.js +5 -1
  79. package/lib/pinnedHttpGet.js +174 -0
  80. package/lib/promptImport/githubFolder.js +32 -18
  81. package/lib/promptImport/githubSource.js +14 -10
  82. package/lib/promptImport/parsePromptCandidates.js +32 -2
  83. package/lib/providers/adapters/agyOperations.js +179 -0
  84. package/lib/providers/adapters/comfy.js +1 -0
  85. package/lib/providers/adapters/geminiOperations.js +214 -0
  86. package/lib/providers/adapters/googleExecution.js +97 -0
  87. package/lib/providers/adapters/grokExecution.js +137 -0
  88. package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
  89. package/lib/providers/adapters/grokOperations.js +63 -0
  90. package/lib/providers/adapters/openaiExecution.js +118 -0
  91. package/lib/providers/adapters/openaiOperations.js +201 -0
  92. package/lib/providers/adapters/openaiTypes.js +1 -0
  93. package/lib/providers/derive.js +4 -0
  94. package/lib/providers/deriveCore.js +2 -7
  95. package/lib/providers/execution/admission.js +25 -0
  96. package/lib/providers/execution/index.js +34 -0
  97. package/lib/providers/execution/legacy.js +17 -0
  98. package/lib/providers/execution/legacyClassic.js +66 -0
  99. package/lib/providers/execution/legacyEdit.js +30 -0
  100. package/lib/providers/execution/legacyMultimode.js +34 -0
  101. package/lib/providers/execution/legacyNode.js +41 -0
  102. package/lib/providers/execution/types.js +1 -0
  103. package/lib/providers/registry.js +19 -14
  104. package/lib/providers/surfaceSupport.js +22 -0
  105. package/lib/responsesImageAdapter.js +1 -389
  106. package/lib/responsesTransport.js +190 -0
  107. package/lib/spriteJobEvents.js +20 -2
  108. package/lib/ssePublish.js +5 -4
  109. package/lib/videoExtendI2vOperation.js +76 -0
  110. package/package.json +5 -2
  111. package/routes/edit.js +31 -123
  112. package/routes/events.js +140 -63
  113. package/routes/mcpMedia.js +10 -2
  114. package/routes/models.js +28 -25
  115. package/routes/videoExtended.js +9 -68
  116. package/server.js +24 -46
  117. package/ui/dist/.vite/manifest.json +90 -63
  118. package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
  119. package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
  120. package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
  121. package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
  122. package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
  123. package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
  124. package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
  125. package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
  126. package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
  127. package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
  128. package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
  129. package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
  130. package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
  131. package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
  132. package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
  133. package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
  134. package/ui/dist/assets/index-7ngEOVxt.js +5 -0
  135. package/ui/dist/assets/index-B5jy7ePg.css +1 -0
  136. package/ui/dist/assets/index-gxh_02DQ.js +22 -0
  137. package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
  138. package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
  139. package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
  140. package/ui/dist/index.html +3 -2
  141. package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
  142. package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
  143. package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
  144. package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
  145. package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
  146. package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
  147. package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
  148. package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
  149. package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
  150. package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
  151. package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
  152. package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
  153. package/ui/dist/assets/index-0fpfY8vu.css +0 -1
  154. package/ui/dist/assets/index-BFciPGTn.js +0 -5
  155. package/ui/dist/assets/index-BbykeXR9.js +0 -30
  156. package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
  157. package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
package/routes/events.js CHANGED
@@ -1,16 +1,7 @@
1
- import { subscribe, replaySince, hasReplayGap, replayOldestId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
1
+ import { subscribe, replaySince, hasReplayGap, replayOldestId, latestEventId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
2
+ import { SSE_STREAM_POLICY } from "../lib/eventsPolicy.js";
2
3
  let activeConnections = 0;
3
- function safeWrite(res, chunk) {
4
- if (res.writableEnded || res.destroyed)
5
- return false;
6
- try {
7
- res.write(chunk);
8
- return true;
9
- }
10
- catch {
11
- return false;
12
- }
13
- }
4
+ const HEARTBEAT_MS = 15_000;
14
5
  function formatSse(ev) {
15
6
  // jobSeq and envelope are additive: the publisher already built them, so this
16
7
  // only serializes. Consumers that ignore the new fields see the old payload.
@@ -25,6 +16,140 @@ function parseLastEventIdHeader(value) {
25
16
  const raw = Array.isArray(value) ? value[0] : value;
26
17
  return parseInt(raw?.split(",", 1)[0]?.trim() ?? "", 10);
27
18
  }
19
+ class EventConnection {
20
+ req;
21
+ res;
22
+ cursor;
23
+ closed = false;
24
+ pumping = false;
25
+ blocked = false;
26
+ unsubscribe = () => { };
27
+ heartbeat;
28
+ deadline;
29
+ constructor(req, res, cursor) {
30
+ this.req = req;
31
+ this.res = res;
32
+ this.cursor = cursor;
33
+ }
34
+ start() {
35
+ activeConnections++;
36
+ this.req.on("close", this.close);
37
+ this.res.on("close", this.close);
38
+ this.res.on("error", this.close);
39
+ this.res.on("drain", this.drain);
40
+ try {
41
+ this.res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
42
+ this.res.setHeader("Cache-Control", "no-cache, no-transform");
43
+ this.res.setHeader("Connection", "keep-alive");
44
+ this.res.setHeader("X-Accel-Buffering", "no");
45
+ this.res.setHeader("x-ima2-event-cursor", String(this.cursor));
46
+ this.res.flushHeaders?.();
47
+ if (this.closed)
48
+ return;
49
+ this.unsubscribe = subscribe(this.live);
50
+ this.pump();
51
+ if (!this.closed)
52
+ this.heartbeat = setInterval(this.ping, HEARTBEAT_MS);
53
+ }
54
+ catch {
55
+ this.close();
56
+ }
57
+ }
58
+ write(chunk) {
59
+ if (this.closed || this.blocked)
60
+ return false;
61
+ if (this.res.writableEnded || this.res.destroyed) {
62
+ this.close();
63
+ return false;
64
+ }
65
+ try {
66
+ const ready = this.res.write(chunk);
67
+ if (this.closed)
68
+ return false;
69
+ if (!ready) {
70
+ this.blocked = true;
71
+ this.deadline = setTimeout(this.close, SSE_STREAM_POLICY.drainTimeoutMs);
72
+ }
73
+ return ready;
74
+ }
75
+ catch {
76
+ this.close();
77
+ return false;
78
+ }
79
+ }
80
+ gap() {
81
+ if (!hasReplayGap(this.cursor))
82
+ return true;
83
+ const oldestAvailableId = replayOldestId();
84
+ const data = JSON.stringify({ lastEventId: this.cursor, oldestAvailableId });
85
+ this.cursor = oldestAvailableId === null ? latestEventId() : oldestAvailableId - 1;
86
+ return this.write(`event: replay-gap\ndata: ${data}\n\n`);
87
+ }
88
+ pump() {
89
+ if (this.closed || this.blocked || this.pumping)
90
+ return;
91
+ this.pumping = true;
92
+ try {
93
+ do {
94
+ if (!this.gap())
95
+ return;
96
+ for (const ev of replaySince(this.cursor)) {
97
+ // write(false) accepts the chunk; drain must start AFTER this ID.
98
+ this.cursor = ev.id;
99
+ if (!this.write(formatSse(ev)))
100
+ return;
101
+ }
102
+ } while (!this.closed && this.cursor < latestEventId());
103
+ }
104
+ finally {
105
+ this.pumping = false;
106
+ }
107
+ }
108
+ live = (ev) => {
109
+ if (this.closed || this.blocked || this.pumping || ev.id <= this.cursor)
110
+ return;
111
+ if (ev.id !== this.cursor + 1) {
112
+ this.pump();
113
+ return;
114
+ }
115
+ this.pumping = true;
116
+ try {
117
+ this.cursor = ev.id;
118
+ this.write(formatSse(ev));
119
+ }
120
+ finally {
121
+ this.pumping = false;
122
+ }
123
+ this.pump();
124
+ };
125
+ ping = () => {
126
+ if (!this.blocked && !this.pumping)
127
+ this.write(": ping\n\n");
128
+ };
129
+ drain = () => {
130
+ if (this.closed || !this.blocked)
131
+ return;
132
+ clearTimeout(this.deadline);
133
+ this.deadline = undefined;
134
+ this.blocked = false;
135
+ this.pump();
136
+ };
137
+ close = () => {
138
+ if (this.closed)
139
+ return;
140
+ this.closed = true;
141
+ this.unsubscribe();
142
+ clearInterval(this.heartbeat);
143
+ clearTimeout(this.deadline);
144
+ this.req.off("close", this.close);
145
+ this.res.off("close", this.close);
146
+ this.res.off("error", this.close);
147
+ this.res.off("drain", this.drain);
148
+ activeConnections--;
149
+ if (!this.res.destroyed)
150
+ this.res.destroy();
151
+ };
152
+ }
28
153
  export function registerEventsRoute(app, _ctx) {
29
154
  app.get("/api/events", (req, res) => {
30
155
  if (activeConnections >= MAX_SSE_LISTENERS) {
@@ -32,58 +157,10 @@ export function registerEventsRoute(app, _ctx) {
32
157
  error: { code: "SSE_CAPACITY", message: "Too many event stream connections" },
33
158
  });
34
159
  }
35
- res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
36
- res.setHeader("Cache-Control", "no-cache, no-transform");
37
- res.setHeader("Connection", "keep-alive");
38
- res.setHeader("X-Accel-Buffering", "no");
39
- res.flushHeaders?.();
40
- activeConnections++;
41
160
  const headerLastId = parseLastEventIdHeader(req.headers["last-event-id"]);
42
161
  const queryLastId = parseInt(String(req.query.lastEventId ?? ""), 10);
43
- const lastId = !Number.isNaN(headerLastId) ? headerLastId : queryLastId;
44
- if (!Number.isNaN(lastId)) {
45
- if (hasReplayGap(lastId)) {
46
- const gapPayload = JSON.stringify({
47
- lastEventId: lastId,
48
- oldestAvailableId: replayOldestId(),
49
- });
50
- if (!safeWrite(res, `event: replay-gap\ndata: ${gapPayload}\n\n`)) {
51
- activeConnections = Math.max(0, activeConnections - 1);
52
- return;
53
- }
54
- }
55
- for (const ev of replaySince(lastId)) {
56
- if (!safeWrite(res, formatSse(ev)))
57
- break;
58
- }
59
- }
60
- let cleaned = false;
61
- const unsub = subscribe((ev) => {
62
- if (!safeWrite(res, formatSse(ev)))
63
- cleanup();
64
- });
65
- const heartbeat = setInterval(() => {
66
- if (!safeWrite(res, ": ping\n\n"))
67
- cleanup();
68
- }, 15_000);
69
- function cleanup() {
70
- if (cleaned)
71
- return;
72
- cleaned = true;
73
- unsub();
74
- clearInterval(heartbeat);
75
- activeConnections = Math.max(0, activeConnections - 1);
76
- if (!res.writableEnded && !res.destroyed) {
77
- try {
78
- res.end();
79
- }
80
- catch {
81
- /* socket already torn down */
82
- }
83
- }
84
- }
85
- req.on("close", cleanup);
86
- res.on("close", cleanup);
87
- res.on("error", cleanup);
162
+ const lastId = Number.isSafeInteger(headerLastId) ? headerLastId : queryLastId;
163
+ const cursor = Number.isSafeInteger(lastId) ? Math.max(0, lastId) : latestEventId();
164
+ new EventConnection(req, res, cursor).start();
88
165
  });
89
166
  }
@@ -160,8 +160,16 @@ async function runMediaAction(input) {
160
160
  if (input.mode === "fallback" && input.decision === "local-ffmpeg-concat") {
161
161
  setJobPhase(requestId, "media-processing");
162
162
  publishJobEvent(requestId, "progress", { phase: "media-processing" });
163
- const tempOut = join(ctx.config.storage.generatedDir, `.tmp-concat-${requestId}.mp4`);
164
- await deps.concat(input.resolvedFiles, tempOut, { signal: input.signal });
163
+ const tempOut = join(ctx.config.storage.generatedDir, `.tmp-concat-${randomBytes(16).toString("hex")}.mp4`);
164
+ try {
165
+ await deps.concat(input.resolvedFiles, tempOut, { signal: input.signal });
166
+ }
167
+ catch (concatError) {
168
+ // Concat can leave a partial file; retain its error if cleanup also fails.
169
+ await rm(tempOut, { force: true }).catch(() => undefined);
170
+ throw concatError;
171
+ }
172
+ // Successful concat hands cleanup to commitMediaResult, before its done event.
165
173
  await commitMediaResult({
166
174
  ctx, deps, requestId, kind: "video",
167
175
  tempPath: tempOut, cleanup: () => rm(tempOut, { force: true }),
package/routes/models.js CHANGED
@@ -7,7 +7,8 @@ import { getProviderAdapter } from "../lib/providers/adapters/index.js";
7
7
  import { MAX_VIDEO_DURATION, MIN_VIDEO_DURATION, VALID_VIDEO_ASPECT_RATIOS, VALID_VIDEO_RESOLUTIONS, } from "../lib/imageModels.js";
8
8
  import { getProviderModels, } from "../lib/mcp/modelsCatalog.js";
9
9
  import { listProviders, } from "../lib/mcp/providerRegistry.js";
10
- import { deriveModels } from "../lib/providers/derive.js";
10
+ import { deriveModels, deriveProviderIds, getProviderSurfaceSupport } from "../lib/providers/derive.js";
11
+ import { PROVIDER_SURFACES } from "../lib/providers/surfaceSupport.js";
11
12
  import { listWorkflows } from "../lib/comfyWorkflowStore.js";
12
13
  import { probeComfyOrigins } from "../lib/comfyImageAdapter.js";
13
14
  import { requireRuntimeContext, } from "../lib/runtimeContext.js";
@@ -21,20 +22,15 @@ function emptyModels() {
21
22
  function capabilities(inputRoles = [], parameters = [], aspectRatios = []) {
22
23
  return { source: "verified-contract", aspectRatios, parameters, inputRoles };
23
24
  }
24
- /**
25
- * Text-only lanes. The default in `entries` advertises image_references, which
26
- * would be a lie for a lane whose routes answer NAI_REF_UNSUPPORTED.
27
- */
28
- function textOnlyCapabilities() {
29
- return capabilities(["text"]);
30
- }
31
- function entries(ids, caps) {
25
+ function entries(provider, ids, caps) {
32
26
  return [...ids].map((id) => ({
33
27
  id,
34
28
  label: id,
35
29
  capabilities: caps
36
30
  ? { ...caps, aspectRatios: [...caps.aspectRatios], parameters: [...caps.parameters], inputRoles: [...caps.inputRoles] }
37
- : capabilities(["text", "image_references"]),
31
+ // CoreProviderId comes from the same registry as the getter: never null.
32
+ : capabilities(getProviderSurfaceSupport(provider, "generate").references
33
+ ? ["text", "image_references"] : ["text"]),
38
34
  }));
39
35
  }
40
36
  function videoCapabilities() {
@@ -91,21 +87,21 @@ function grokLaneState(ctx) {
91
87
  return { status: "ready", reason: UNPROBED_GROK_REASON };
92
88
  }
93
89
  }
94
- function grokLane(ctx) {
90
+ function grokLane(ctx, provider = "grok") {
95
91
  const state = grokLaneState(ctx);
96
92
  return lane(state, {
97
93
  image: ctx.config.grokProvider.defaultImageModel,
98
94
  video: ctx.config.grokProvider.defaultVideoModel,
99
95
  }, {
100
- image: entries(deriveModels("grok", "image")),
101
- video: entries([...deriveModels("grok", "video")].filter((model) => model !== "grok-imagine-video-1.5-preview"), videoCapabilities()),
96
+ image: entries(provider, deriveModels(provider, "image")),
97
+ video: entries(provider, [...deriveModels("grok", "video")].filter((model) => model !== "grok-imagine-video-1.5-preview"), videoCapabilities()),
102
98
  });
103
99
  }
104
100
  function grokApiLane(ctx) {
105
101
  const state = ctx.xaiApiKey
106
102
  ? { status: "ready" }
107
103
  : { status: "key-missing", reason: "xAI API key missing" };
108
- const grok = grokLane(ctx);
104
+ const grok = grokLane(ctx, "grok-api");
109
105
  return lane(state, { ...grok.defaults }, grok.models);
110
106
  }
111
107
  function agyLane(installed) {
@@ -113,7 +109,7 @@ function agyLane(installed) {
113
109
  ? { status: "ready", reason: "binary installed; login cannot be probed" }
114
110
  : { status: "disconnected", reason: "binary not installed" };
115
111
  return lane(state, { image: "nano-banana-2" }, {
116
- image: entries(deriveModels("agy", "image")), video: [],
112
+ image: entries("agy", deriveModels("agy", "image")), video: [],
117
113
  });
118
114
  }
119
115
  function geminiLane(ctx) {
@@ -122,7 +118,7 @@ function geminiLane(ctx) {
122
118
  ? { status: "ready" }
123
119
  : { status: "key-missing", reason: "Gemini API or Vertex credentials missing" };
124
120
  return lane(state, { image: "nano-banana-2" }, {
125
- image: entries(deriveModels("gemini-api", "image")), video: [],
121
+ image: entries("gemini-api", deriveModels("gemini-api", "image")), video: [],
126
122
  });
127
123
  }
128
124
  function atlasCloudLane(ctx) {
@@ -135,7 +131,7 @@ function atlasCloudLane(ctx) {
135
131
  ? { status: "ready" }
136
132
  : { status: "key-missing", reason: "Atlas Cloud API key missing" };
137
133
  return lane(fallback, { image: ATLASCLOUD_TEXT_TO_IMAGE_MODEL }, {
138
- image: entries(deriveModels("atlascloud", "image")), video: [],
134
+ image: entries("atlascloud", deriveModels("atlascloud", "image")), video: [],
139
135
  });
140
136
  }
141
137
  const auth = adapter.validateAuth();
@@ -143,7 +139,7 @@ function atlasCloudLane(ctx) {
143
139
  ? { status: "ready" }
144
140
  : { status: "key-missing", reason: auth.reason ?? "Atlas Cloud API key missing" };
145
141
  return lane(state, { image: ATLASCLOUD_TEXT_TO_IMAGE_MODEL }, {
146
- image: entries(adapter.listModels().map((model) => model.id)), video: [],
142
+ image: entries("atlascloud", adapter.listModels().map((model) => model.id)), video: [],
147
143
  });
148
144
  }
149
145
  function minimaxLane(ctx) {
@@ -156,7 +152,7 @@ function minimaxLane(ctx) {
156
152
  ? { status: "ready" }
157
153
  : { status: "key-missing", reason: "MiniMax API key missing" };
158
154
  return lane(fallback, { image: MINIMAX_TEXT_TO_IMAGE_MODEL }, {
159
- image: entries(deriveModels("minimax", "image")), video: [],
155
+ image: entries("minimax", deriveModels("minimax", "image")), video: [],
160
156
  });
161
157
  }
162
158
  const auth = adapter.validateAuth();
@@ -164,7 +160,7 @@ function minimaxLane(ctx) {
164
160
  ? { status: "ready" }
165
161
  : { status: "key-missing", reason: auth.reason ?? "MiniMax API key missing" };
166
162
  return lane(state, { image: MINIMAX_TEXT_TO_IMAGE_MODEL }, {
167
- image: entries(adapter.listModels().map((model) => model.id)), video: [],
163
+ image: entries("minimax", adapter.listModels().map((model) => model.id)), video: [],
168
164
  });
169
165
  }
170
166
  function naiLane(ctx) {
@@ -174,7 +170,7 @@ function naiLane(ctx) {
174
170
  ? { status: "ready" }
175
171
  : { status: "key-missing", reason: "NovelAI API token missing" };
176
172
  return lane(fallback, { image: NAI_DEFAULT_IMAGE_MODEL }, {
177
- image: entries(deriveModels("nai", "image"), textOnlyCapabilities()), video: [],
173
+ image: entries("nai", deriveModels("nai", "image")), video: [],
178
174
  });
179
175
  }
180
176
  const auth = adapter.validateAuth();
@@ -182,7 +178,7 @@ function naiLane(ctx) {
182
178
  ? { status: "ready" }
183
179
  : { status: "key-missing", reason: auth.reason ?? "NovelAI API token missing" };
184
180
  return lane(state, { image: NAI_DEFAULT_IMAGE_MODEL }, {
185
- image: entries(adapter.listModels().map((model) => model.id), textOnlyCapabilities()), video: [],
181
+ image: entries("nai", adapter.listModels().map((model) => model.id)), video: [],
186
182
  });
187
183
  }
188
184
  /**
@@ -232,10 +228,10 @@ async function comfyLane(ctx, deps = {}) {
232
228
  });
233
229
  }
234
230
  async function buildCoreLanes(ctx, agyInstalled, deps = {}) {
235
- const gptModels = entries(ctx.config.imageModels.valid);
236
- return {
231
+ const gptModels = entries("oauth", ctx.config.imageModels.valid);
232
+ const lanes = {
237
233
  oauth: oauthLane(ctx, gptModels),
238
- api: apiLane(ctx, entries(ctx.config.imageModels.valid)),
234
+ api: apiLane(ctx, entries("api", ctx.config.imageModels.valid)),
239
235
  grok: grokLane(ctx),
240
236
  "grok-api": grokApiLane(ctx),
241
237
  agy: agyLane(agyInstalled),
@@ -245,6 +241,13 @@ async function buildCoreLanes(ctx, agyInstalled, deps = {}) {
245
241
  nai: naiLane(ctx),
246
242
  comfy: await comfyLane(ctx, deps),
247
243
  };
244
+ for (const id of deriveProviderIds()) {
245
+ // Both ids and surfaces exhaust the canonical registry; MCP is never decorated.
246
+ lanes[id].surfaces = Object.fromEntries(PROVIDER_SURFACES.map((surface) => [
247
+ surface, getProviderSurfaceSupport(id, surface),
248
+ ]));
249
+ }
250
+ return lanes;
248
251
  }
249
252
  function detectAgyInstalled() {
250
253
  return new Promise((resolve) => {
@@ -8,19 +8,19 @@ import { downloadVideo, generateVideoViaGrok, pollVideoUntilDone } from "../lib/
8
8
  import { invalidateHistoryIndex } from "../lib/historyIndex.js";
9
9
  import { ACTIVE_VIDEO_PROMPT_GUIDANCE, appendVideoContinuityEntry, lineageFromVideoMetadata, readVideoSidecar } from "../lib/videoContinuity.js";
10
10
  import { assertLocalMp4, extractGeneratedVideoFrameB64, extractVideoFrame, safeGeneratedFilePath } from "../lib/videoFrameExtract.js";
11
- import { finishJob, INFLIGHT_RETRY_AFTER_SECONDS, isJobCanceled, isStartJobFailure, registerJobAbortController, setJobPhase, startJob, updateJobAdmission } from "../lib/inflight.js";
11
+ import { finishJob, INFLIGHT_RETRY_AFTER_SECONDS, isJobCanceled, isStartJobFailure, registerJobAbortController, startJob, updateJobAdmission } from "../lib/inflight.js";
12
12
  import { makeGenerationCanceledError } from "../lib/generationCancel.js";
13
- import { publish } from "../lib/eventBus.js";
14
13
  import { publishJobEvent } from "../lib/ssePublish.js";
15
14
  import { normalizeBodyRequestId } from "../lib/generationInputValidation.js";
16
15
  import { normalizeGrokVideoModel, normalizeVideoAspectRatio, normalizeVideoDuration, normalizeVideoResolution, validateVideoResolutionForRequest } from "../lib/imageModels.js";
17
16
  import { persistVideoArtifact } from "../lib/videoArtifactPersistence.js";
18
- import { deriveChildVideoLineage, normalizeVideoLineage } from "../lib/videoLineage.js";
17
+ import { normalizeVideoLineage } from "../lib/videoLineage.js";
19
18
  import { getMotionFragment, MOTION_PRESETS } from "../lib/videoMotionPresets.js";
20
19
  import { errInfo } from "../lib/errInfo.js";
21
- import { codedVideoError as codedError, emitPhase, envDeadline, extractError, requestSignal, requirePrompt, retryableData } from "../lib/videoExtendedHelpers.js";
20
+ import { codedVideoError as codedError, emitPhase, envDeadline, requestSignal, requirePrompt, retryableData } from "../lib/videoExtendedHelpers.js";
22
21
  import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
23
22
  import { errorEnvelopeFields } from "../lib/errors/envelope.js";
23
+ import { runLastFrameI2v } from "../lib/videoExtendI2vOperation.js";
24
24
  function validationError(value) {
25
25
  if (!value || typeof value !== "object" || !("error" in value))
26
26
  return null;
@@ -312,70 +312,11 @@ export function registerVideoExtendedRoutes(app, ctxRaw, dependencies = {}) {
312
312
  updateJobAdmission(requestId, { prompt, meta: { workflow: "last-frame-i2v", sourceVideoId, provider, model } });
313
313
  emitPhase(requestId, "queued");
314
314
  res.status(202).json({ ok: true, requestId, sourceVideoId, workflow: "last-frame-i2v" });
315
- void (async () => {
316
- const startedAt = Date.now();
317
- let stage = "extracting-frame";
318
- try {
319
- emitPhase(requestId, stage);
320
- let sourceImage;
321
- try {
322
- sourceImage = await extractFrame(ctx.config.storage.generatedDir, sourceVideoId, "last", { signal: cancelController.signal });
323
- }
324
- catch (error) {
325
- throw extractError(error, cancelController.signal);
326
- }
327
- if (cancelController.signal.aborted)
328
- throw makeGenerationCanceledError();
329
- const parentContinuity = lineageFromVideoMetadata(sourceVideoId, parent);
330
- const onEvent = (event) => {
331
- setJobPhase(requestId, event.phase === "submitted" ? "streaming" : event.phase);
332
- publish(requestId, event.phase, {
333
- requestId,
334
- ...(event.phase === "submitted" ? { xaiVideoRequestId: event.xaiVideoRequestId, requestedModel: event.requestedModel, effectiveModel: event.effectiveModel, modelFallback: event.modelFallback ?? null } : {}),
335
- ...(event.phase === "progress" ? { progress: typeof event.progress === "number" ? event.progress / 100 : null, stalled: Boolean(event.stalled) } : {}),
336
- });
337
- };
338
- const compiledPrompt = motion.fragment ? `${prompt}\n\nCamera motion: ${motion.fragment}.` : prompt;
339
- const result = await generateVideo(compiledPrompt, ctx, {
340
- model, mode: "image-to-video", duration, resolution, aspectRatio,
341
- sourceImage, sourceMime: "image/png", signal: cancelController.signal,
342
- requestId, continuityLineage: parentContinuity,
343
- directApiKey: provider === "grok-api" ? ctx.xaiApiKey ?? undefined : undefined,
344
- onEvent,
345
- });
346
- if (cancelController.signal.aborted)
347
- throw makeGenerationCanceledError();
348
- stage = "persisting";
349
- emitPhase(requestId, stage);
350
- const filename = createFilename(ctx);
351
- const createdAt = Date.now();
352
- const videoLineage = deriveChildVideoLineage(filename, sourceVideoId, parent);
353
- const videoContinuity = appendVideoContinuityEntry(parentContinuity, { filename, userPrompt: prompt, revisedPrompt: result.revisedPrompt, createdAt });
354
- const elapsed = +((createdAt - startedAt) / 1000).toFixed(1);
355
- const video = { operation: "extend", mode: "image-to-video", sourceVideoId, sourceFrame: "last", duration: result.duration, resolution: result.resolution, aspectRatio: result.aspectRatio, xaiVideoRequestId: result.xaiVideoRequestId };
356
- const metadata = { kind: "video", mediaType: "video", providerUrl: result.url, requestId, prompt, userPrompt: prompt, revisedPrompt: result.revisedPrompt, motionPresetIds: motion.ids, provider, model: result.effectiveModel, createdAt, elapsed, usage: result.usage, webSearchCalls: result.webSearchCalls, video, videoLineage, videoContinuity };
357
- try {
358
- await persistArtifact(ctx.config.storage.generatedDir, filename, result.videoBuffer, metadata);
359
- }
360
- catch (error) {
361
- throw codedError(errInfo(error).message, 500, "VIDEO_PERSIST_FAILED");
362
- }
363
- invalidateHistoryIndex();
364
- const done = { requestId, filename, url: `/generated/${encodeURIComponent(filename)}`, providerUrl: result.url, mediaType: "video", provider, model: result.effectiveModel, prompt, userPrompt: prompt, revisedPrompt: result.revisedPrompt, createdAt, elapsed, usage: result.usage, webSearchCalls: result.webSearchCalls, video, videoLineage, videoContinuity };
365
- // finishJob BEFORE done (audit blocker B4): a done event must never be
366
- // followed by an error from a failing inflight-completion write.
367
- finishJob(requestId, { status: "completed", meta: { filename, xaiVideoRequestId: result.xaiVideoRequestId } });
368
- publishJobEvent(requestId, "done", done);
369
- }
370
- catch (error) {
371
- if (!isJobCanceled(requestId)) {
372
- const info = errInfo(error);
373
- // #151 stage 2: terminal failure carries the canonical envelope.
374
- publishJobEvent(requestId, "error", { requestId, error: info.message, code: info.code ?? "VIDEO_EXTEND_FAILED", status: info.status ?? 500, ...retryableData(error), ...errorEnvelopeFields(error) });
375
- finishJob(requestId, { status: "error", httpStatus: info.status ?? 500, errorCode: info.code ?? "VIDEO_EXTEND_FAILED", meta: { stage } });
376
- }
377
- }
378
- })();
315
+ void runLastFrameI2v({
316
+ ctx, requestId, sourceVideoId, prompt, model, provider, duration, resolution,
317
+ aspectRatio, parent, motion, cancelController, extractFrame, generateVideo,
318
+ persistArtifact, createFilename,
319
+ });
379
320
  });
380
321
  // --- Provider-native legacy extension ---
381
322
  app.post("/api/video/extend/native", async (req, res) => {
package/server.js CHANGED
@@ -15,7 +15,10 @@ import { configureLogger, logError } from "./lib/logger.js";
15
15
  import { createRequestLogger } from "./lib/requestLogger.js";
16
16
  import { configureApiCachePolicy } from "./lib/apiCachePolicy.js";
17
17
  import { configureRoutes } from "./routes/index.js";
18
- import { config } from "./config.js";
18
+ import { API_REQUEST_POLICY, config } from "./config.js";
19
+ import { createApiRequestBudget } from "./lib/apiRequestBudget.js";
20
+ import { createLocalLanAccess, createLanApiGuard as tokenOnlyLanApiGuard } from "./lib/localLanAccess.js";
21
+ import { createGeneratedMediaAccess } from "./lib/generatedMediaAccess.js";
19
22
  import { getServerPort, listenWithPortFallback } from "./lib/runtimePorts.js";
20
23
  import { shutdownServerAndMcp, startMcpRestoreAfterListen } from "./lib/mcp/shutdown.js";
21
24
  import { closeDb } from "./lib/db.js";
@@ -23,7 +26,6 @@ import { stopAgentQueueWorker } from "./lib/agentQueueWorker.js";
23
26
  import { reapCardNewsJobs } from "./lib/cardNewsJobStore.js";
24
27
  import { reapTerminalJobs } from "./lib/inflight.js";
25
28
  import { errInfo } from "./lib/errInfo.js";
26
- import { timingSafeEqual } from "node:crypto";
27
29
  import { cleanupExpiredMcpTempReferences, MCP_TEMP_REFERENCE_JSON_BODY_LIMIT_BYTES, MCP_TEMP_REFERENCE_SWEEP_INTERVAL_MS, } from "./lib/mcpTempReferenceStore.js";
28
30
  const rootDir = dirname(fileURLToPath(import.meta.url));
29
31
  async function loadApiKey() {
@@ -210,6 +212,8 @@ function setUiStaticHeaders(res, filePath) {
210
212
  const normalized = filePath.replace(/\\/g, "/");
211
213
  if (normalized.endsWith("/index.html")) {
212
214
  res.setHeader("Cache-Control", "no-store, max-age=0");
215
+ res.setHeader("Referrer-Policy", "no-referrer");
216
+ res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
213
217
  return;
214
218
  }
215
219
  if (normalized.includes("/assets/")) {
@@ -221,44 +225,29 @@ export function isLoopbackHost(host) {
221
225
  return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
222
226
  }
223
227
  export function assertLanAccessConfiguration(host, token) {
224
- if (isLoopbackHost(host) || token)
228
+ if (isLoopbackHost(host))
225
229
  return;
226
- const message = `[server.security] Refusing non-loopback host ${host || "<empty>"}: set IMA2_LAN_TOKEN to enable LAN access.`;
230
+ if (token && Buffer.byteLength(token) <= config.security.lanTokenMaxBytes)
231
+ return;
232
+ const message = "[server.security] Set a nonempty IMA2_LAN_TOKEN of at most 4096 UTF-8 bytes to enable LAN access.";
227
233
  console.error(message);
228
234
  throw new Error(message);
229
235
  }
230
- function tokenMatches(actual, expected) {
231
- if (typeof actual !== "string")
232
- return false;
233
- const actualBuffer = Buffer.from(actual);
234
- const expectedBuffer = Buffer.from(expected);
235
- return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
236
- }
237
236
  export function createLanApiGuard(host, token) {
238
- const requiredToken = isLoopbackHost(host) ? "" : String(token || "");
239
- return function lanApiGuard(req, res, next) {
240
- if (!requiredToken || !req.path.startsWith("/api"))
241
- return next();
242
- // OAuth redirect endpoints are conventionally unauthenticated: the provider's
243
- // browser redirect cannot carry x-ima2-token. Security boundary for this single
244
- // path is the single-use unguessable OAuth state + PKCE (030 WP3 audit round 2);
245
- // an invalid state is rejected with 400 before any token exchange.
246
- if (req.path === "/api/mcp/oauth/callback")
247
- return next();
248
- const supplied = req.get("x-ima2-token") ?? req.query.token;
249
- if (tokenMatches(supplied, requiredToken))
250
- return next();
251
- return res.status(401).json({
252
- error: { code: "LAN_TOKEN_REQUIRED", message: "A valid IMA2 LAN token is required" },
253
- });
254
- };
237
+ return tokenOnlyLanApiGuard(host, token);
255
238
  }
256
239
  export function buildApp(ctx) {
257
240
  const app = express();
241
+ const access = createLocalLanAccess(ctx);
242
+ const budget = createApiRequestBudget(API_REQUEST_POLICY);
243
+ const locals = Object.assign(app.locals, { disposeLocalLanAccess: access.dispose });
258
244
  configureApiCachePolicy(app);
259
245
  configureLogger({ level: ctx.config.log.level });
260
246
  app.use(createRequestLogger());
261
- app.use(createLanApiGuard(ctx.config.server.host, ctx.config.server.lanToken));
247
+ app.use(access.mediaHeaders);
248
+ access.registerSessionRoutes(app, budget);
249
+ app.use(access.guard);
250
+ app.use(budget);
262
251
  app.use("/api/mcp/temp-references", express.json({ limit: MCP_TEMP_REFERENCE_JSON_BODY_LIMIT_BYTES }));
263
252
  app.use(express.json({ limit: ctx.config.server.bodyLimit }));
264
253
  app.use(express.static(join(ctx.rootDir, "ui", "dist"), {
@@ -267,22 +256,7 @@ export function buildApp(ctx) {
267
256
  app.use("/assets", (_req, res) => {
268
257
  res.status(404).type("text/plain").send("Asset not found");
269
258
  });
270
- app.use("/generated", (req, res, next) => {
271
- if (req.path.endsWith(".json"))
272
- return res.status(404).type("text/plain").send("Generated metadata is not public");
273
- if (req.path.toLowerCase().endsWith(".svg")) {
274
- // SVG is an active document under top-level navigation, and /generated sits
275
- // outside the LAN token guard (which only covers /api). Traced output is
276
- // machine-generated, but the guarantee should not rest on writer discipline:
277
- // this neuters script execution for any .svg served from here, now or later.
278
- res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'");
279
- res.setHeader("X-Content-Type-Options", "nosniff");
280
- }
281
- return next();
282
- }, express.static(ctx.config.storage.generatedDir, {
283
- maxAge: ctx.config.storage.staticMaxAge,
284
- immutable: true,
285
- }));
259
+ app.use("/generated", ...createGeneratedMediaAccess(ctx, access));
286
260
  configureRoutes(app, ctx);
287
261
  app.use((_req, res) => {
288
262
  res.status(404).json({ error: { code: "NOT_FOUND", message: "Route not found" } });
@@ -302,7 +276,7 @@ export function buildApp(ctx) {
302
276
  },
303
277
  });
304
278
  });
305
- return app;
279
+ return Object.assign(app, { locals });
306
280
  }
307
281
  function runtimeHostUrl(host) {
308
282
  if (!host || host === "0.0.0.0" || host === "::")
@@ -472,6 +446,8 @@ export async function createRuntimeContext(overrides = {}) {
472
446
  export async function startServer(overrides = {}) {
473
447
  const ctx = await createRuntimeContext(overrides);
474
448
  assertLanAccessConfiguration(ctx.config.server.host, ctx.config.server.lanToken);
449
+ // Validate the lazy origin configuration before any storage migration.
450
+ void ctx.config.server.publicOrigins;
475
451
  await migrateGeneratedStorage(ctx);
476
452
  try {
477
453
  await cleanupExpiredMcpTempReferences(ctx.config.storage.generatedDir);
@@ -547,6 +523,7 @@ export async function startServer(overrides = {}) {
547
523
  clearInterval(reapTimer);
548
524
  if (tempReferenceReapTimer)
549
525
  clearInterval(tempReferenceReapTimer);
526
+ app.locals.disposeLocalLanAccess();
550
527
  await shutdownServerAndMcp({
551
528
  closeServer: () => new Promise((resolve) => {
552
529
  if (server)
@@ -567,6 +544,7 @@ export async function startServer(overrides = {}) {
567
544
  },
568
545
  });
569
546
  ctx.serverActualPort = getServerPort(server) || ctx.config.server.port;
547
+ server.once("close", app.locals.disposeLocalLanAccess);
570
548
  ctx.serverUrl = `http://${runtimeHostUrl(ctx.config.server.host)}:${ctx.serverActualPort}`;
571
549
  void startMcpRestoreAfterListen(ctx).catch((error) => {
572
550
  console.warn(`[mcp.restore] code=${String(error?.message ?? error).split(":")[0]}`);