@nodetool-ai/atlascloud-nodes 0.8.1

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.
@@ -0,0 +1,730 @@
1
+ /**
2
+ * Dynamic AtlasCloud node-class factory.
3
+ *
4
+ * Generates node classes from `atlascloud-manifest.json` at runtime, mirroring
5
+ * the topaz-nodes and kie-nodes factories. Each manifest entry declares a
6
+ * model id, modality, output type, and the input fields exposed in the node
7
+ * UI. The generated class extends `BaseNode`, so `BaseNode._injectSecrets`
8
+ * resolves `ATLASCLOUD_API_KEY` from `context.getSecret(...)` and surfaces it
9
+ * as `this._secrets.ATLASCLOUD_API_KEY` inside `process()`.
10
+ */
11
+ import { applyContentCardBody, BaseNode, classifyFields, classNameToTitle, defaultForPropType, propertyOf, registerDeclaredProperty } from "@nodetool-ai/node-sdk";
12
+ import { fetchExternalMedia, loadMediaRefBytes, mapPromptAssetsToInputs } from "@nodetool-ai/runtime";
13
+ import { atlasAwaitResult, atlasDownload, atlasUploadMedia, atlasSubmit, getApiKey, pickOutputUrl } from "./atlascloud-base.js";
14
+ /** True for a single-asset field type. */
15
+ function isAssetFieldType(type) {
16
+ return type === "image" || type === "video" || type === "audio";
17
+ }
18
+ /** The asset kind a `list[...]` field wraps, or null for any other field. */
19
+ function listAssetKind(type) {
20
+ if (type === "list[image]")
21
+ return "image";
22
+ if (type === "list[video]")
23
+ return "video";
24
+ if (type === "list[audio]")
25
+ return "audio";
26
+ return null;
27
+ }
28
+ /**
29
+ * In-flight raw-RGBA images (protocol `RAW_RGBA_MIME`) carry straight-alpha
30
+ * pixels in `data`, not an encoded file. `loadMediaRefBytes` PNG-encodes them,
31
+ * so the outgoing data URI must say `image/png` rather than the ref's mime.
32
+ */
33
+ const RAW_RGBA_MIME = "image/x-raw-rgba";
34
+ /** Narrow a node property value to a string. */
35
+ function isStringValue(value) {
36
+ return typeof value === "string";
37
+ }
38
+ /** Narrow a node property value to an image or video reference. */
39
+ function isVisualReference(value) {
40
+ if (typeof value !== "object" ||
41
+ value === null ||
42
+ Array.isArray(value) ||
43
+ value instanceof Uint8Array) {
44
+ return false;
45
+ }
46
+ return value.type === "image" || value.type === "video";
47
+ }
48
+ /** Narrow a node property value to a number. */
49
+ function isNumberValue(value) {
50
+ return typeof value === "number";
51
+ }
52
+ /** Narrow a node property value to a boolean. */
53
+ function isBooleanValue(value) {
54
+ return typeof value === "boolean";
55
+ }
56
+ /** Narrow a node property value to a media ref object (not a list or bytes). */
57
+ function isAssetRef(value) {
58
+ return (typeof value === "object" &&
59
+ value !== null &&
60
+ !Array.isArray(value) &&
61
+ !(value instanceof Uint8Array));
62
+ }
63
+ /** True when a ref's `data` slot holds a non-empty inline payload string. */
64
+ function isInlineText(data) {
65
+ return typeof data === "string" && data.length > 0;
66
+ }
67
+ function looksLikePublicUrl(s) {
68
+ return s !== undefined && isSafeHttpUrl(s);
69
+ }
70
+ /**
71
+ * Validate an http(s) URL for outbound fetching from the workflow runtime.
72
+ *
73
+ * Returns false for any host that points back at the runtime host or its
74
+ * private network — defense against SSRF via user-controllable asset URIs.
75
+ * Workflow inputs (ImageRef.uri etc.) can be set by anyone who can submit a
76
+ * graph; without this check, the worker would happily proxy requests to
77
+ * - localhost / loopback (any service bound to 127.0.0.1)
78
+ * - RFC1918 private space (10.*, 172.16-31.*, 192.168.*)
79
+ * - 169.254.169.254 (AWS / GCP / Azure instance metadata)
80
+ * - IPv6 loopback / link-local / unique-local equivalents
81
+ *
82
+ * Note: this is hostname-string-based, not a resolved-IP check, so it can be
83
+ * bypassed by a public DNS name that resolves to a private IP (DNS rebinding).
84
+ * That's a known limitation — fixing it properly requires resolving DNS in
85
+ * the same socket dance as the eventual fetch(). The string check is what
86
+ * the canonical fal/replicate/topaz providers use today, hardened here against
87
+ * the inet_aton-style numeric encodings (decimal/hex/octal, short-form) and
88
+ * IPv4-mapped IPv6 that the OS resolver still accepts but a naive dotted-quad
89
+ * regex misses.
90
+ */
91
+ function isSafeHttpUrl(uri) {
92
+ let u;
93
+ try {
94
+ u = new URL(uri);
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ if (u.protocol !== "http:" && u.protocol !== "https:")
100
+ return false;
101
+ return !isPrivateOrLocalHost(u.hostname);
102
+ }
103
+ /** Parse a single inet_aton component: decimal, 0x-hex, or 0-prefixed octal. */
104
+ function parseIpComponent(part) {
105
+ if (/^0x[0-9a-f]+$/i.test(part))
106
+ return parseInt(part.slice(2), 16);
107
+ if (/^0[0-7]+$/.test(part))
108
+ return parseInt(part, 8);
109
+ if (/^[0-9]+$/.test(part))
110
+ return parseInt(part, 10);
111
+ return null;
112
+ }
113
+ /**
114
+ * Parse `host` as IPv4 using inet_aton semantics, which `getaddrinfo` (and thus
115
+ * `fetch`) honors: `a.b.c.d`, the short forms `a.b.c` / `a.b` / `a`, and each
116
+ * component in decimal, hex (`0x7f`), or octal (`0177`). Returns the four
117
+ * octets, or null when the host isn't a numeric IPv4 in any of these forms.
118
+ */
119
+ function ipv4ToOctets(host) {
120
+ const parts = host.split(".");
121
+ if (parts.length === 0 || parts.length > 4)
122
+ return null;
123
+ const nums = [];
124
+ for (const part of parts) {
125
+ const n = parseIpComponent(part);
126
+ if (n === null || n < 0)
127
+ return null;
128
+ nums.push(n);
129
+ }
130
+ // Every component but the last must fit in one octet; the final component
131
+ // absorbs all remaining low-order octets (e.g. "127.1" → 127.0.0.1).
132
+ const n = nums.length;
133
+ for (let i = 0; i < n - 1; i++) {
134
+ if (nums[i] > 0xff)
135
+ return null;
136
+ }
137
+ const tailOctets = 4 - (n - 1);
138
+ const tail = nums[n - 1];
139
+ if (tail < 0 || tail > 0xffffffff || tail >= 2 ** (tailOctets * 8))
140
+ return null;
141
+ let value = tail;
142
+ for (let i = 0; i < n - 1; i++) {
143
+ value += nums[i] * 256 ** (3 - i);
144
+ }
145
+ return [
146
+ (value >>> 24) & 0xff,
147
+ (value >>> 16) & 0xff,
148
+ (value >>> 8) & 0xff,
149
+ value & 0xff
150
+ ];
151
+ }
152
+ /** Extract the embedded IPv4 from an IPv4-mapped / -compatible IPv6 address. */
153
+ function mappedIpv4ToOctets(host) {
154
+ // Dotted tail: ::ffff:127.0.0.1 or the deprecated compat form ::127.0.0.1
155
+ const dotted = /^::(?:ffff:)?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(host);
156
+ if (dotted)
157
+ return ipv4ToOctets(dotted[1]);
158
+ // Hex tail: ::ffff:7f00:1
159
+ const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
160
+ if (hex) {
161
+ const hi = parseInt(hex[1], 16);
162
+ const lo = parseInt(hex[2], 16);
163
+ return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
164
+ }
165
+ return null;
166
+ }
167
+ function isPrivateV4(octets) {
168
+ const [o1, o2] = octets;
169
+ if (o1 === 0)
170
+ return true; // 0.0.0.0/8 — "this network"
171
+ if (o1 === 10)
172
+ return true; // RFC1918
173
+ if (o1 === 127)
174
+ return true; // loopback
175
+ if (o1 === 169 && o2 === 254)
176
+ return true; // link-local incl. cloud metadata
177
+ if (o1 === 172 && o2 >= 16 && o2 <= 31)
178
+ return true; // RFC1918
179
+ if (o1 === 192 && o2 === 168)
180
+ return true; // RFC1918
181
+ if (o1 === 100 && o2 >= 64 && o2 <= 127)
182
+ return true; // CGNAT
183
+ return false;
184
+ }
185
+ function isPrivateOrLocalHost(hostname) {
186
+ // URL.hostname returns IPv6 wrapped in brackets ("[::1]") on Node — strip
187
+ // before pattern matching so the v6 cases below work on the bare form.
188
+ let h = hostname.toLowerCase();
189
+ if (h.startsWith("[") && h.endsWith("]"))
190
+ h = h.slice(1, -1);
191
+ if (h === "" || h === "localhost" || h.endsWith(".localhost"))
192
+ return true;
193
+ // IPv4 in any inet_aton-accepted spelling, or embedded in a mapped IPv6.
194
+ const octets = ipv4ToOctets(h) ?? mappedIpv4ToOctets(h);
195
+ if (octets)
196
+ return isPrivateV4(octets);
197
+ // Pure IPv6 — URL.hostname returns bracket-stripped form ("::1", "fe80::1").
198
+ if (h === "::1" || h === "::")
199
+ return true;
200
+ if (h.startsWith("fe80:") || h.startsWith("fe80::"))
201
+ return true; // link-local
202
+ // ULA range fc00::/7 — first nibble is f, second is c or d
203
+ if (/^f[cd][0-9a-f]{2}:/i.test(h))
204
+ return true;
205
+ return false;
206
+ }
207
+ function bytesToDataUri(bytes, mime) {
208
+ return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`;
209
+ }
210
+ function guessMime(ref, fallback) {
211
+ if (ref?.mime_type)
212
+ return ref.mime_type;
213
+ if (ref?.mimeType)
214
+ return ref.mimeType;
215
+ if (ref?.metadata?.mime_type)
216
+ return ref.metadata.mime_type;
217
+ const uri = ref?.uri ?? "";
218
+ if (/\.jpe?g(?:[?#]|$)/i.test(uri))
219
+ return "image/jpeg";
220
+ if (/\.png(?:[?#]|$)/i.test(uri))
221
+ return "image/png";
222
+ if (/\.webp(?:[?#]|$)/i.test(uri))
223
+ return "image/webp";
224
+ if (/\.mp4(?:[?#]|$)/i.test(uri))
225
+ return "video/mp4";
226
+ if (/\.mov(?:[?#]|$)/i.test(uri))
227
+ return "video/quicktime";
228
+ if (/\.wav(?:[?#]|$)/i.test(uri))
229
+ return "audio/wav";
230
+ if (/\.mp3(?:[?#]|$)/i.test(uri))
231
+ return "audio/mpeg";
232
+ return fallback;
233
+ }
234
+ function defaultMimeFor(fieldType) {
235
+ if (fieldType === "video")
236
+ return "video/mp4";
237
+ if (fieldType === "audio")
238
+ return "audio/wav";
239
+ return "image/png";
240
+ }
241
+ /** Mime for bytes we resolved ourselves, with the raw-RGBA case mapped to PNG. */
242
+ function resolvedMime(ref, fieldType) {
243
+ const mime = guessMime(ref, defaultMimeFor(fieldType));
244
+ return mime === RAW_RGBA_MIME ? "image/png" : mime;
245
+ }
246
+ /** True for a raw-RGBA in-flight image: pixels in `data`, no encoded file. */
247
+ function isRawRgba(ref) {
248
+ return ((ref.mime_type ?? ref.mimeType) === RAW_RGBA_MIME &&
249
+ ref.data instanceof Uint8Array);
250
+ }
251
+ /**
252
+ * True when a ref carries no source at all: every slot blank. The UI sends a
253
+ * fully-shaped ref for an asset input the user left empty
254
+ * (`{ type: "image", uri: "", asset_id: null, data: null }`), which is an
255
+ * absent optional input — not something to fail the node over.
256
+ */
257
+ function isEmptyAssetRef(ref) {
258
+ if ((ref.uri ?? "").trim() !== "")
259
+ return false;
260
+ if (isInlineText(ref.data))
261
+ return false;
262
+ if (ref.data instanceof Uint8Array && ref.data.byteLength > 0)
263
+ return false;
264
+ return ref.asset_id == null || ref.asset_id === "";
265
+ }
266
+ function isHttpUri(uri) {
267
+ return uri !== undefined && /^https?:\/\//i.test(uri);
268
+ }
269
+ /**
270
+ * Resolve a NodeTool asset ref (ImageRef/VideoRef/AudioRef) to something the
271
+ * AtlasCloud API accepts: either a public HTTPS URL or a `data:<mime>;base64,...`
272
+ * URI. Returns null when the ref is empty/undefined.
273
+ */
274
+ export async function resolveAssetForAtlas(ref, context, fieldType) {
275
+ if (!ref)
276
+ return null;
277
+ // Bare string the user pasted in: a public URL, or a data: URI which is
278
+ // already the exact shape the API wants.
279
+ if (isStringValue(ref)) {
280
+ if (ref.startsWith("data:"))
281
+ return ref;
282
+ return looksLikePublicUrl(ref) ? ref : null;
283
+ }
284
+ if (!isAssetRef(ref))
285
+ return null;
286
+ const r = ref;
287
+ // An asset input left empty is an absent optional input, not a broken one.
288
+ if (isEmptyAssetRef(r))
289
+ return null;
290
+ const rawUri = r.uri;
291
+ if (rawUri !== undefined) {
292
+ if (looksLikePublicUrl(rawUri))
293
+ return rawUri;
294
+ if (rawUri.startsWith("data:"))
295
+ return rawUri;
296
+ }
297
+ // Inline bytes, except raw-RGBA pixels — those are not an encoded image and
298
+ // must go through the canonical resolver's PNG encoder below.
299
+ if (!isRawRgba(r)) {
300
+ if (isInlineText(r.data)) {
301
+ return r.data.startsWith("data:")
302
+ ? r.data
303
+ : `data:${resolvedMime(r, fieldType)};base64,${r.data}`;
304
+ }
305
+ if (r.data instanceof Uint8Array && r.data.byteLength > 0) {
306
+ return bytesToDataUri(r.data, resolvedMime(r, fieldType));
307
+ }
308
+ }
309
+ // Internal references the canonical resolver understands but a raw
310
+ // storage.retrieve / guarded fetch can't uniformly turn into bytes:
311
+ // - asset://<id>, package://<pkg>/<path>, memory://<key> reference schemes
312
+ // - the self-hosted `/api/storage/<key>` path, reduced to its bare <key>
313
+ // - a ref that carries only an `asset_id` (its `uri` is empty or an
314
+ // internal path) — the common shape for library-picked and generated
315
+ // media wired into a downstream node
316
+ // The SSRF safety lives HERE, in restricting what we hand resolveAssetBytes:
317
+ // it will happily download an arbitrary http(s) URL if given one, so we only
318
+ // ever pass it these trusted internal refs (or an asset:// derived from
319
+ // asset_id). Arbitrary http(s) URIs stay on the isSafeHttpUrl-guarded fetch
320
+ // path below and are never routed through it.
321
+ //
322
+ const uri = rawUri;
323
+ const trimmedAssetId = r.asset_id?.trim() ?? "";
324
+ const assetId = trimmedAssetId !== "" ? trimmedAssetId : null;
325
+ // Map an internal-ref URI to the argument resolveAssetBytes expects, or null
326
+ // when it isn't one. asset:// / package:// / memory:// are passed verbatim
327
+ // (resolveAssetBytes recognizes each scheme); the `/api/storage/<key>` HTTP
328
+ // path is reduced to its bare `<key>`, since resolveAssetBytes keys off the
329
+ // storage key / asset id, not the route — handing it the full path makes it
330
+ // treat the whole string as an id and build a broken nested URL that 404s.
331
+ const internalRefCandidate = (u) => {
332
+ if (u.startsWith("asset://") ||
333
+ u.startsWith("package://") ||
334
+ u.startsWith("memory://")) {
335
+ return u;
336
+ }
337
+ // Strip any `?query`/`#hash` (thumb URLs carry e.g. `?thumb=1`) so the bare
338
+ // storage key is handed over. resolveAssetBytes strips these too, but doing
339
+ // it here keeps the passed key exact and the intent explicit.
340
+ const apiStorage = /^\/?api\/storage\/(.+)$/.exec(u);
341
+ return apiStorage ? apiStorage[1].split(/[?#]/)[0] || null : null;
342
+ };
343
+ if (context?.resolveAssetBytes) {
344
+ const candidate = (uri !== undefined ? internalRefCandidate(uri) : null) ??
345
+ (assetId ? `asset://${assetId}` : null);
346
+ if (candidate) {
347
+ const { bytes } = await context.resolveAssetBytes(candidate);
348
+ if (bytes && bytes.byteLength > 0) {
349
+ return bytesToDataUri(new Uint8Array(bytes), guessMime(r, defaultMimeFor(fieldType)));
350
+ }
351
+ }
352
+ }
353
+ if (r.uri && context?.storage) {
354
+ try {
355
+ const bytes = await context.storage.retrieve(r.uri);
356
+ if (bytes && bytes.byteLength > 0) {
357
+ return bytesToDataUri(new Uint8Array(bytes), guessMime(r, defaultMimeFor(fieldType)));
358
+ }
359
+ }
360
+ catch {
361
+ /* fall through to direct fetch */
362
+ }
363
+ }
364
+ // Canonical resolver fallback — it understands ref shapes the bespoke
365
+ // branches above miss: `file://` URIs and absolute local paths, raw-RGBA
366
+ // in-flight images (PNG-encoded on the way out), and `asset_id` →
367
+ // `/api/storage/<id>.<ext>` storage candidates.
368
+ //
369
+ // Skipped for http(s) URIs: a safe one already passed through at the top, so
370
+ // anything still here is a private/loopback/metadata host, and
371
+ // loadMediaRefBytes downloads http(s) unguarded. Routing those through it
372
+ // would hand back the SSRF hole isSafeHttpUrl exists to close.
373
+ if (!isHttpUri(r.uri)) {
374
+ const bytes = await loadMediaRefBytes(r, context);
375
+ if (bytes && bytes.byteLength > 0) {
376
+ return bytesToDataUri(bytes, resolvedMime(r, fieldType));
377
+ }
378
+ }
379
+ // Direct fetch fallback. Use the same private-host guard as the
380
+ // pass-through path so a workflow can't trick the worker into proxying
381
+ // requests to internal services / cloud metadata endpoints.
382
+ if (r.uri && isSafeHttpUrl(r.uri)) {
383
+ // The predicate judged the initial URL; the protected fetch judges every
384
+ // redirect hop and applies the media-ref egress policy.
385
+ const res = await fetchExternalMedia(r.uri);
386
+ if (res.ok) {
387
+ const bytes = new Uint8Array(await res.arrayBuffer());
388
+ return bytesToDataUri(bytes, guessMime(r, defaultMimeFor(fieldType)));
389
+ }
390
+ }
391
+ throw new Error(`Cannot resolve ${fieldType} asset for AtlasCloud — no usable uri or inline data`);
392
+ }
393
+ async function uploadVideoInput(apiKey, resolved) {
394
+ if (!resolved.startsWith("data:"))
395
+ return resolved;
396
+ const match = /^data:([^;,]+);base64,(.+)$/s.exec(resolved);
397
+ if (!match) {
398
+ throw new Error("AtlasCloud video input must be a public URL or Base64 data URI");
399
+ }
400
+ const mimeType = match[1];
401
+ const encoded = match[2];
402
+ if (!mimeType || !encoded) {
403
+ throw new Error("AtlasCloud video input data URI is empty");
404
+ }
405
+ return atlasUploadMedia(apiKey, new Uint8Array(Buffer.from(encoded, "base64")), mimeType, "input-video");
406
+ }
407
+ /**
408
+ * Coerce a UI-serialized value back to the type AtlasCloud's worker expects.
409
+ * NodeTool serializes numeric dropdowns as strings; AtlasCloud rejects `"5"`
410
+ * when it wants `5`. Coerce here so the user doesn't see opaque worker errors.
411
+ *
412
+ * Callers must filter out null/undefined before invoking — the factory does
413
+ * this in its field loop, so there's no defensive guard here.
414
+ */
415
+ function coerceScalar(v, type) {
416
+ switch (type) {
417
+ case "int": {
418
+ if (isNumberValue(v))
419
+ return Math.trunc(v);
420
+ const n = parseInt(String(v), 10);
421
+ return Number.isNaN(n) ? null : n;
422
+ }
423
+ case "float": {
424
+ if (isNumberValue(v))
425
+ return v;
426
+ const f = parseFloat(String(v));
427
+ return Number.isNaN(f) ? null : f;
428
+ }
429
+ case "bool": {
430
+ if (isBooleanValue(v))
431
+ return v;
432
+ if (isStringValue(v))
433
+ return v.toLowerCase() === "true";
434
+ return Boolean(v);
435
+ }
436
+ default:
437
+ return v;
438
+ }
439
+ }
440
+ function computeFieldClassification(fields) {
441
+ return classifyFields(fields.map((f) => ({ name: f.name, propType: f.type })));
442
+ }
443
+ /** Whether an asset ref already points at a source (so a mention shouldn't fill it). */
444
+ function refHasSource(value) {
445
+ if (!isAssetRef(value))
446
+ return false;
447
+ const r = value;
448
+ if ((r.uri ?? "").trim() !== "")
449
+ return true;
450
+ if (isInlineText(r.data))
451
+ return true;
452
+ if (r.data instanceof Uint8Array && r.data.byteLength > 0)
453
+ return true;
454
+ return r.asset_id != null && r.asset_id !== "";
455
+ }
456
+ /**
457
+ * Route `asset://` media mentioned inline in a node's text inputs onto its
458
+ * empty image/audio/video inputs (and strip the mentions from the text). Shared
459
+ * with FAL / KIE / Replicate / image-to-image via `mapPromptAssetsToInputs` —
460
+ * lets a Seedance reference-to-video node pull its reference image, audio track
461
+ * and video clip straight from the prompt's @-mentions.
462
+ */
463
+ async function promptAssetOverrides(instance, spec, context) {
464
+ const textFields = [];
465
+ const assetFields = [];
466
+ for (const field of spec.fields) {
467
+ const value = propertyOf(instance, field.name);
468
+ if (isAssetFieldType(field.type)) {
469
+ assetFields.push({
470
+ name: field.name,
471
+ kind: field.type,
472
+ list: false,
473
+ hasSource: refHasSource(value)
474
+ });
475
+ continue;
476
+ }
477
+ const listKind = listAssetKind(field.type);
478
+ if (listKind) {
479
+ assetFields.push({
480
+ name: field.name,
481
+ kind: listKind,
482
+ list: true,
483
+ hasSource: Array.isArray(value) && value.some(refHasSource)
484
+ });
485
+ continue;
486
+ }
487
+ if (field.type === "str") {
488
+ textFields.push({ name: field.name, value: String(value ?? "") });
489
+ }
490
+ }
491
+ const overrides = await mapPromptAssetsToInputs(textFields, assetFields, context);
492
+ // SAFETY: mapPromptAssetsToInputs only writes back what it read off this
493
+ // node's own inputs — rewritten prompt strings and media refs — so every
494
+ // value is a node property value, the shapes `NodeValue` names.
495
+ return overrides;
496
+ }
497
+ /**
498
+ * Append resolved asset URLs to a request array of `{ url, type }` objects,
499
+ * creating it on first use. Order follows manifest field order, so a model
500
+ * that reads its references positionally sees images before videos before
501
+ * audio.
502
+ */
503
+ function appendWrapped(input, key, urls, kind) {
504
+ const existing = input[key];
505
+ const bucket = Array.isArray(existing) ? existing : [];
506
+ for (const url of urls) {
507
+ bucket.push({ url, type: kind });
508
+ }
509
+ input[key] = bucket;
510
+ }
511
+ export function createAtlasNodeClass(spec) {
512
+ const nodeType = `atlascloud.${spec.moduleName}.${spec.className}`;
513
+ const title = spec.title || classNameToTitle(spec.className);
514
+ const specRef = spec;
515
+ const AtlasNodeClass = class extends BaseNode {
516
+ async process(context) {
517
+ const apiKey = getApiKey(this._secrets);
518
+ const input = {};
519
+ const overrides = await promptAssetOverrides(this, specRef, context);
520
+ const readValue = (name) => name in overrides ? overrides[name] : propertyOf(this, name);
521
+ for (const f of specRef.fields) {
522
+ const v = readValue(f.name);
523
+ if (isAssetFieldType(f.type)) {
524
+ const inner = f.type;
525
+ const resolved = v == null ? null : await resolveAssetForAtlas(v, context, inner);
526
+ if (resolved !== null) {
527
+ const inputUrl = inner === "video"
528
+ ? await uploadVideoInput(apiKey, resolved)
529
+ : resolved;
530
+ if (f.wrapInto) {
531
+ appendWrapped(input, f.wrapInto, [inputUrl], inner);
532
+ }
533
+ else {
534
+ input[f.name] = f.array ? [inputUrl] : inputUrl;
535
+ }
536
+ }
537
+ else if (f.required) {
538
+ throw new Error(`${specRef.title}: the ${inner} input "${f.title ?? f.name}" is empty — connect or upload a${inner === "image" ? "n" : ""} ${inner}`);
539
+ }
540
+ continue;
541
+ }
542
+ if (v === undefined || v === null)
543
+ continue;
544
+ const listKind = listAssetKind(f.type);
545
+ if (listKind) {
546
+ const inner = listKind;
547
+ if (!Array.isArray(v))
548
+ continue;
549
+ const resolved = [];
550
+ for (const item of v) {
551
+ const r = await resolveAssetForAtlas(item, context, inner);
552
+ if (r !== null) {
553
+ resolved.push(inner === "video" ? await uploadVideoInput(apiKey, r) : r);
554
+ }
555
+ }
556
+ if (f.maxItems !== undefined && resolved.length > f.maxItems) {
557
+ throw new Error(`${specRef.title}: "${f.title ?? f.name}" accepts at most ${f.maxItems} items`);
558
+ }
559
+ if (resolved.length > 0) {
560
+ if (f.wrapInto) {
561
+ appendWrapped(input, f.wrapInto, resolved, inner);
562
+ }
563
+ else {
564
+ input[f.name] = resolved;
565
+ }
566
+ }
567
+ continue;
568
+ }
569
+ if (v === "")
570
+ continue;
571
+ if (f.type === "enum" &&
572
+ f.values?.length &&
573
+ !f.values.some((allowed) => allowed === v)) {
574
+ // A model switch can leave a stale enum value in a persisted app.
575
+ // Use the declared model default when it is valid instead of sending
576
+ // a parameter that the selected AtlasCloud model cannot accept.
577
+ if (f.default !== undefined &&
578
+ f.default !== null &&
579
+ f.values.some((allowed) => allowed === f.default)) {
580
+ // SAFETY: The preceding membership check proves the manifest default is an allowed NodeValue.
581
+ input[f.name] = f.default;
582
+ }
583
+ continue;
584
+ }
585
+ input[f.name] = coerceScalar(v, f.type);
586
+ }
587
+ const requiredGroups = new Set(specRef.fields
588
+ .filter((field) => field.requiredGroup && field.wrapInto)
589
+ .map((field) => field.wrapInto));
590
+ for (const group of requiredGroups) {
591
+ const values = input[group];
592
+ const hasVisualReference = Array.isArray(values) && values.some(isVisualReference);
593
+ if (!hasVisualReference) {
594
+ throw new Error(`${specRef.title}: connect at least one reference image or video`);
595
+ }
596
+ }
597
+ if (input.background === "transparent" &&
598
+ input.output_format === "jpeg") {
599
+ throw new Error(`${specRef.title}: transparent backgrounds require PNG or WebP output`);
600
+ }
601
+ if (specRef.modelId.startsWith("pixverse/") &&
602
+ Array.isArray(input.images)) {
603
+ input.images = input.images.map((image) => ({ image }));
604
+ }
605
+ else if (specRef.modelId.startsWith("vidu/") &&
606
+ Array.isArray(input.subjects)) {
607
+ const images = input.subjects;
608
+ if (images.length > 7) {
609
+ throw new Error(`${specRef.title}: "subjects" accepts at most 7 images`);
610
+ }
611
+ input.subjects = Array.from({ length: Math.ceil(images.length / 3) }, (_, index) => ({
612
+ id: String(index + 1),
613
+ images: images.slice(index * 3, index * 3 + 3)
614
+ }));
615
+ }
616
+ const predictionId = await atlasSubmit(apiKey, specRef.modality, specRef.modelId, input);
617
+ const result = await atlasAwaitResult(apiKey, predictionId, {
618
+ pollInterval: specRef.pollInterval ?? 3000,
619
+ maxAttempts: specRef.maxAttempts ?? 600
620
+ });
621
+ const url = pickOutputUrl(result);
622
+ // Retries 429/5xx — the job is already generated and billed by now, so a
623
+ // transient CDN blip must not throw the paid-for result away.
624
+ const bytes = await atlasDownload(url);
625
+ // Preferred path: hand bytes to NodeTool storage so the result becomes a
626
+ // proper local asset. Falls back to base64 embed if storage is missing
627
+ // or rejects the write — that mirrors the autoSaveAsset contract used
628
+ // by topaz-nodes.
629
+ const isVideo = specRef.outputType === "video";
630
+ const imageFormat = input.output_format === "jpeg" || input.output_format === "webp"
631
+ ? input.output_format
632
+ : "png";
633
+ const ext = isVideo
634
+ ? "mp4"
635
+ : imageFormat === "jpeg"
636
+ ? "jpg"
637
+ : imageFormat;
638
+ const mime = isVideo ? "video/mp4" : `image/${imageFormat}`;
639
+ const filename = `atlascloud-${specRef.outputType}-${Date.now()}.${ext}`;
640
+ const storage = context?.storage;
641
+ if (storage?.store) {
642
+ try {
643
+ const storageUri = await storage.store(filename, bytes, mime);
644
+ return { output: { type: specRef.outputType, uri: storageUri } };
645
+ }
646
+ catch {
647
+ /* fall through to base64 embed */
648
+ }
649
+ }
650
+ return {
651
+ output: {
652
+ type: specRef.outputType,
653
+ uri: "",
654
+ data: Buffer.from(bytes).toString("base64")
655
+ }
656
+ };
657
+ }
658
+ };
659
+ Object.defineProperty(AtlasNodeClass, "name", {
660
+ value: spec.className,
661
+ configurable: true
662
+ });
663
+ Object.defineProperty(AtlasNodeClass, "nodeType", {
664
+ value: nodeType,
665
+ configurable: true
666
+ });
667
+ Object.defineProperty(AtlasNodeClass, "title", {
668
+ value: title,
669
+ configurable: true
670
+ });
671
+ Object.defineProperty(AtlasNodeClass, "description", {
672
+ value: spec.description,
673
+ configurable: true
674
+ });
675
+ Object.defineProperty(AtlasNodeClass, "requiredSettings", {
676
+ value: ["ATLASCLOUD_API_KEY"],
677
+ configurable: true
678
+ });
679
+ Object.defineProperty(AtlasNodeClass, "autoSaveAsset", {
680
+ value: true,
681
+ configurable: true
682
+ });
683
+ Object.defineProperty(AtlasNodeClass, "metadataOutputTypes", {
684
+ value: { output: spec.outputType },
685
+ configurable: true
686
+ });
687
+ // Every AtlasCloud model generates an image or a video — preview it in the
688
+ // node body.
689
+ applyContentCardBody(AtlasNodeClass);
690
+ const { inlineFields, inputFields } = computeFieldClassification(spec.fields);
691
+ Object.defineProperty(AtlasNodeClass, "inlineFields", {
692
+ value: inlineFields,
693
+ configurable: true
694
+ });
695
+ Object.defineProperty(AtlasNodeClass, "inputFields", {
696
+ value: inputFields,
697
+ configurable: true
698
+ });
699
+ for (const field of spec.fields) {
700
+ // Preserve null defaults verbatim — the AtlasCloud manifests use `null`
701
+ // to mean "field is optional with no preselected value" (e.g. aspect_ratio
702
+ // on Nano Banana models). Coercing those to a type-default empty string
703
+ // can fail enum-values validation, since "" isn't in the values list.
704
+ const propDefault = field.default === null
705
+ ? null
706
+ : (field.default ?? defaultForPropType(field.type));
707
+ const propOptions = {
708
+ type: field.type,
709
+ default: propDefault
710
+ };
711
+ if (field.title)
712
+ propOptions.title = field.title;
713
+ if (field.description)
714
+ propOptions.description = field.description;
715
+ if (field.values?.length)
716
+ propOptions.values = field.values;
717
+ if (field.min !== undefined)
718
+ propOptions.min = field.min;
719
+ if (field.max !== undefined)
720
+ propOptions.max = field.max;
721
+ if (field.required)
722
+ propOptions.required = true;
723
+ registerDeclaredProperty(AtlasNodeClass, field.name, propOptions);
724
+ }
725
+ return AtlasNodeClass;
726
+ }
727
+ export function loadAtlasNodesFromManifest(manifest) {
728
+ return manifest.map(createAtlasNodeClass);
729
+ }
730
+ //# sourceMappingURL=atlascloud-factory.js.map