@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.
- package/README.md +210 -0
- package/dist/.nodetool-build-stamp +1 -0
- package/dist/atlascloud-base.d.ts +13 -0
- package/dist/atlascloud-base.d.ts.map +1 -0
- package/dist/atlascloud-base.js +20 -0
- package/dist/atlascloud-base.js.map +1 -0
- package/dist/atlascloud-factory.d.ts +78 -0
- package/dist/atlascloud-factory.d.ts.map +1 -0
- package/dist/atlascloud-factory.js +730 -0
- package/dist/atlascloud-factory.js.map +1 -0
- package/dist/atlascloud-manifest.json +14364 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
|
@@ -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
|