dreamlayer 0.2.0 → 0.4.0-beta.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 +32 -4
- package/dist/cli.js +101 -13
- package/dist/client.d.ts +33 -4
- package/dist/client.js +282 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ dreamlayer cutout <image> [--out file.png] # background removal
|
|
|
21
21
|
dreamlayer upscale <image> [--out file.png] # 2x
|
|
22
22
|
dreamlayer answer <conversation-id> <text> [--image file.png]
|
|
23
23
|
dreamlayer status <execution-id>
|
|
24
|
+
dreamlayer balance # spends nothing
|
|
24
25
|
dreamlayer capabilities # spends nothing
|
|
25
26
|
```
|
|
26
27
|
|
|
@@ -52,7 +53,7 @@ open "$(dreamlayer generate 'a fox logo')"
|
|
|
52
53
|
| 1 | Usage error |
|
|
53
54
|
| 2 | Auth or account problem |
|
|
54
55
|
| 3 | Out of credits |
|
|
55
|
-
| 4 |
|
|
56
|
+
| 4 | Non-retryable request or execution failure |
|
|
56
57
|
| 5 | Temporary, worth retrying |
|
|
57
58
|
| 6 | It asked a question instead of producing an image |
|
|
58
59
|
|
|
@@ -60,9 +61,19 @@ open "$(dreamlayer generate 'a fox logo')"
|
|
|
60
61
|
for f in shots/*.png; do dreamlayer cutout "$f" --out "cut/$(basename "$f")" || break; done
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
**`--json`** gives machine-readable output on stdout, carrying job state,
|
|
64
|
-
and the written path.
|
|
65
|
-
|
|
64
|
+
**`--json`** gives machine-readable success output on stdout, carrying job state,
|
|
65
|
+
execution ids, and the written path. Errors use the same stable `code`, `reason`,
|
|
66
|
+
`message`, `retryable`, and `request_id` fields as REST and are written to stderr, so
|
|
67
|
+
stdout stays result-only. Error envelopes deliberately exclude your prompt, images,
|
|
68
|
+
local filenames, and key. Successful image JSON includes the destination path you chose;
|
|
69
|
+
remove it before sharing if the local name is private.
|
|
70
|
+
|
|
71
|
+
Check the authenticated key's own balance without starting image work:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
dreamlayer balance
|
|
75
|
+
dreamlayer balance --json
|
|
76
|
+
```
|
|
66
77
|
|
|
67
78
|
## Retries are safe if you reuse the key
|
|
68
79
|
|
|
@@ -96,3 +107,20 @@ Node.js 22.12 or later.
|
|
|
96
107
|
## License
|
|
97
108
|
|
|
98
109
|
MIT. See LICENSE and NOTICE.
|
|
110
|
+
|
|
111
|
+
## Sprite-sheet beta
|
|
112
|
+
|
|
113
|
+
Sprite requests accept exactly one of `options.animation_prompt` (1–4000 characters) or an `options.action` preset (`walk`, `run`, `idle`). Custom prompts can describe characters, creatures, objects, effects or 360° turntables. `animation_mode` is `loop` or `once`; presets default to loop, custom prompts to once. For a turntable, request a stationary camera and rotating subject. Broad requests do not guarantee correct motion, unseen details or successful effect transparency.
|
|
114
|
+
|
|
115
|
+
Request integer `frame_count` 7–100 (default 12) and `frame_size` 32, 64, 128, 256, 512, 720 or 1080 (default 512). These are square export canvases; a larger export does not guarantee additional detail. Aspect ratio and shared alignment are preserved with transparent padding. A bundle contains transparent PNG frames, sheet, atlas, preview and import instructions, including each frame's playback duration. Choose a repeating loop or a one-time action with a beginning and ending. If the requested number of distinct frames cannot be delivered, the job fails and held credits are returned. Translucent effects can lose detail or fail; small exports are not automatically pixel art.
|
|
116
|
+
|
|
117
|
+
Pricing is unchanged across sizes: frames 1–14 cost $0.14 each; additional frames $0.07 each. One credit is $0.17. Round the complete order upward once to a tenth of a credit. Check `sprite_pricing` in capabilities and approve the quote with `max_credits`. Credits are held during processing, settled after complete delivery and restored on failure/timeout. There is no customer cancellation. Keep the execution ID to resume status. Custom requests need the matching broad-animation server release; older servers reject them. New live generation quality, 100-frame duration and actual cost remain unverified.
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip
|
|
121
|
+
dreamlayer status EXECUTION_ID
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes.
|
|
125
|
+
|
|
126
|
+
For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier.
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spriteCreditPrice } from "./client.js";
|
|
2
3
|
/**
|
|
3
4
|
* DreamLayer CLI.
|
|
4
5
|
*
|
|
@@ -18,7 +19,7 @@ import { randomUUID } from "node:crypto";
|
|
|
18
19
|
import { openAsBlob, readFileSync } from "node:fs";
|
|
19
20
|
import { stat, writeFile } from "node:fs/promises";
|
|
20
21
|
import path from "node:path";
|
|
21
|
-
import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, } from "./client.js";
|
|
22
|
+
import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
|
|
22
23
|
import { Progress, consume } from "./render.js";
|
|
23
24
|
const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
24
25
|
const USAGE = `dreamlayer - generate and edit images from your terminal
|
|
@@ -28,14 +29,22 @@ USAGE
|
|
|
28
29
|
dreamlayer edit <image> <prompt> [--out <file>]
|
|
29
30
|
dreamlayer cutout <image> [--out <file>]
|
|
30
31
|
dreamlayer upscale <image> [--out <file>]
|
|
32
|
+
dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
|
|
31
33
|
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
|
|
32
34
|
dreamlayer status <execution-id>
|
|
35
|
+
dreamlayer balance
|
|
33
36
|
dreamlayer capabilities
|
|
34
37
|
|
|
35
38
|
OPTIONS
|
|
36
39
|
--out <file> Where to write the image. Default: dreamlayer-<n>.png
|
|
37
40
|
--image <file> Attach an image when answering a question that asks for one
|
|
38
41
|
--aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
|
|
42
|
+
--action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
|
|
43
|
+
--animation-prompt <text> Custom animation; cannot combine with --action
|
|
44
|
+
--animation-mode <loop|once> Default: loop for presets, once for custom
|
|
45
|
+
--frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
|
|
46
|
+
--frames <n> Frame count: integer 7–100, default 12
|
|
47
|
+
--max-credits <n> Maximum approved charge for the sprite job
|
|
39
48
|
--json Machine-readable output on stdout
|
|
40
49
|
--quiet No progress on stderr
|
|
41
50
|
--idempotency-key <key> Reuse to retry safely after an uncertain response
|
|
@@ -44,13 +53,15 @@ ENVIRONMENT
|
|
|
44
53
|
DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
|
|
45
54
|
DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
|
|
46
55
|
|
|
47
|
-
|
|
56
|
+
Image operations cost one credit. Sprite pricing is listed in capabilities. A new account starts at zero.
|
|
48
57
|
`;
|
|
49
58
|
class UsageError extends Error {
|
|
50
59
|
}
|
|
51
60
|
function parseOptions(argv) {
|
|
52
61
|
const positional = [];
|
|
53
62
|
const options = {
|
|
63
|
+
maxCredits: 1,
|
|
64
|
+
frameCount: 12,
|
|
54
65
|
out: null,
|
|
55
66
|
image: null,
|
|
56
67
|
aspect: "1:1",
|
|
@@ -70,6 +81,42 @@ function parseOptions(argv) {
|
|
|
70
81
|
throw new UsageError("--out needs a file path");
|
|
71
82
|
options.out = value;
|
|
72
83
|
}
|
|
84
|
+
else if (token === "--action") {
|
|
85
|
+
const value = argv[++i];
|
|
86
|
+
if (value !== "walk" && value !== "run" && value !== "idle")
|
|
87
|
+
throw new UsageError("--action must be walk, run, or idle");
|
|
88
|
+
options.action = value;
|
|
89
|
+
}
|
|
90
|
+
else if (token === "--animation-prompt") {
|
|
91
|
+
const value = argv[++i];
|
|
92
|
+
if (!value?.trim() || [...value].length > 4000)
|
|
93
|
+
throw new UsageError("--animation-prompt needs 1–4000 characters");
|
|
94
|
+
options.animationPrompt = value;
|
|
95
|
+
}
|
|
96
|
+
else if (token === "--animation-mode") {
|
|
97
|
+
const value = argv[++i];
|
|
98
|
+
if (value !== "loop" && value !== "once")
|
|
99
|
+
throw new UsageError("--animation-mode must be loop or once");
|
|
100
|
+
options.animationMode = value;
|
|
101
|
+
}
|
|
102
|
+
else if (token === "--frame-size") {
|
|
103
|
+
const value = Number(argv[++i]);
|
|
104
|
+
if (![32, 64, 128, 256, 512, 720, 1080].includes(value))
|
|
105
|
+
throw new UsageError("--frame-size must be 32, 64, 128, 256, 512, 720 or 1080");
|
|
106
|
+
options.frameSize = value;
|
|
107
|
+
}
|
|
108
|
+
else if (token === "--frames") {
|
|
109
|
+
const value = Number(argv[++i]);
|
|
110
|
+
if (!Number.isInteger(value) || value < 7 || value > 100)
|
|
111
|
+
throw new UsageError("--frames must be an integer from 7 to 100");
|
|
112
|
+
options.frameCount = value;
|
|
113
|
+
}
|
|
114
|
+
else if (token === "--max-credits") {
|
|
115
|
+
const value = Number(argv[++i]);
|
|
116
|
+
if (!Number.isFinite(value) || value < 0.1 || value > 100)
|
|
117
|
+
throw new UsageError("--max-credits must be 0.1 to 100");
|
|
118
|
+
options.maxCredits = value;
|
|
119
|
+
}
|
|
73
120
|
else if (token === "--image") {
|
|
74
121
|
const value = argv[++i];
|
|
75
122
|
if (!value)
|
|
@@ -129,7 +176,7 @@ function defaultOut() {
|
|
|
129
176
|
async function run(api, input, options) {
|
|
130
177
|
const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
|
|
131
178
|
const idempotencyKey = options.idempotencyKey ?? randomUUID();
|
|
132
|
-
const outcome = await consume(api.
|
|
179
|
+
const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
|
|
133
180
|
if (outcome.question) {
|
|
134
181
|
progress.stop();
|
|
135
182
|
if (options.json) {
|
|
@@ -146,7 +193,12 @@ async function run(api, input, options) {
|
|
|
146
193
|
return 6;
|
|
147
194
|
}
|
|
148
195
|
if (outcome.status !== "completed" || !outcome.asset) {
|
|
149
|
-
progress.stop("Failed");
|
|
196
|
+
progress.stop(options.json ? undefined : "Failed");
|
|
197
|
+
if (outcome.status === "failed" && outcome.execution_id) {
|
|
198
|
+
const terminal = terminalExecutionError(await api.getExecution(outcome.execution_id));
|
|
199
|
+
if (terminal)
|
|
200
|
+
throw terminal;
|
|
201
|
+
}
|
|
150
202
|
if (options.json)
|
|
151
203
|
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
|
|
152
204
|
else
|
|
@@ -155,7 +207,7 @@ async function run(api, input, options) {
|
|
|
155
207
|
}
|
|
156
208
|
progress.set("Downloading");
|
|
157
209
|
const bytes = await api.download(outcome.asset.download_url);
|
|
158
|
-
const target = options.out ?? defaultOut();
|
|
210
|
+
const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
|
|
159
211
|
await writeFile(target, bytes);
|
|
160
212
|
progress.stop();
|
|
161
213
|
if (options.json) {
|
|
@@ -212,7 +264,7 @@ function warnIfOperationsDrifted(capabilities) {
|
|
|
212
264
|
const server = new Set(listed);
|
|
213
265
|
const mine = new Set(KNOWN_OPERATIONS);
|
|
214
266
|
const serverOnly = [...server].filter((o) => !mine.has(o));
|
|
215
|
-
const clientOnly = [...mine].filter((o) => !server.has(o));
|
|
267
|
+
const clientOnly = [...mine].filter((o) => !server.has(o) && o !== "sprite_sheet");
|
|
216
268
|
if (serverOnly.length === 0 && clientOnly.length === 0)
|
|
217
269
|
return;
|
|
218
270
|
process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
|
|
@@ -226,13 +278,11 @@ function warnIfOperationsDrifted(capabilities) {
|
|
|
226
278
|
}
|
|
227
279
|
}
|
|
228
280
|
function exitCodeFor(error) {
|
|
229
|
-
if (error.
|
|
281
|
+
if (error.reason === "authentication_failed" || error.reason === "access_denied")
|
|
230
282
|
return 2;
|
|
231
|
-
if (error.
|
|
283
|
+
if (error.reason === "insufficient_credits" || error.reason === "quota_exceeded")
|
|
232
284
|
return 3;
|
|
233
|
-
|
|
234
|
-
return 4;
|
|
235
|
-
return 5;
|
|
285
|
+
return error.retryable ? 5 : 4;
|
|
236
286
|
}
|
|
237
287
|
async function main(argv) {
|
|
238
288
|
const [command, ...rest] = argv;
|
|
@@ -246,6 +296,23 @@ async function main(argv) {
|
|
|
246
296
|
}
|
|
247
297
|
const { positional, options } = parseOptions(rest);
|
|
248
298
|
switch (command) {
|
|
299
|
+
case "sprite": {
|
|
300
|
+
if (options.action && options.animationPrompt)
|
|
301
|
+
throw new UsageError("Use either --action or --animation-prompt, not both");
|
|
302
|
+
const file = positional[0];
|
|
303
|
+
if (!file)
|
|
304
|
+
throw new UsageError("sprite needs a reference image");
|
|
305
|
+
const api = client();
|
|
306
|
+
const caps = await api.getCapabilities();
|
|
307
|
+
if (!Array.isArray(caps.operations) || !caps.operations.includes("sprite_sheet"))
|
|
308
|
+
throw new UsageError("sprite beta access is not enabled for this account");
|
|
309
|
+
if (!caps.sprite_pricing)
|
|
310
|
+
throw new UsageError("The server does not support configurable sprite pricing yet");
|
|
311
|
+
const price = spriteCreditPrice(options.frameCount);
|
|
312
|
+
if (options.maxCredits < price)
|
|
313
|
+
throw new UsageError(`Sprite jobs require ${price} credits. Set --max-credits to approve that amount.`);
|
|
314
|
+
return run(api, { operation: "sprite_sheet", input_asset_id: await upload(api, file), options: { ...(options.animationPrompt ? { animation_prompt: options.animationPrompt } : { action: options.action ?? "walk" }), ...(options.animationMode ? { animation_mode: options.animationMode } : {}), ...(options.frameSize ? { frame_size: options.frameSize } : {}), frame_count: options.frameCount }, max_credits: options.maxCredits }, options);
|
|
315
|
+
}
|
|
249
316
|
case "generate": {
|
|
250
317
|
const prompt = positional[0];
|
|
251
318
|
if (!prompt)
|
|
@@ -294,6 +361,22 @@ async function main(argv) {
|
|
|
294
361
|
process.stdout.write(`${JSON.stringify(execution, null, 2)}\n`);
|
|
295
362
|
return 0;
|
|
296
363
|
}
|
|
364
|
+
case "balance": {
|
|
365
|
+
if (positional.length > 0)
|
|
366
|
+
throw new UsageError("balance takes no arguments");
|
|
367
|
+
const balance = await client().getBalance();
|
|
368
|
+
if (options.json) {
|
|
369
|
+
process.stdout.write(`${JSON.stringify(balance, null, 2)}\n`);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
process.stdout.write(`${balance.available} credits available ` +
|
|
373
|
+
`(${balance.promotional} promotional, ${balance.purchased} purchased)\n`);
|
|
374
|
+
}
|
|
375
|
+
if (!options.json && Math.round(balance.available * 10) > Math.round(balance.promotional * 10) + Math.round(balance.purchased * 10)) {
|
|
376
|
+
process.stdout.write("Use the available total for affordability. Funding balances are rounded down separately; stored fractions are preserved.\n");
|
|
377
|
+
}
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
297
380
|
case "capabilities": {
|
|
298
381
|
const capabilities = await client().getCapabilities();
|
|
299
382
|
process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
|
|
@@ -315,12 +398,17 @@ main(process.argv.slice(2))
|
|
|
315
398
|
return;
|
|
316
399
|
}
|
|
317
400
|
if (error instanceof ApiError) {
|
|
318
|
-
const hint = error.
|
|
401
|
+
const hint = error.reason === "insufficient_credits"
|
|
319
402
|
? "Buy credits at https://platform.dreamlayer.io/console/billing"
|
|
320
403
|
: error.retryable
|
|
321
404
|
? "Temporary. Retry with --idempotency-key to avoid paying twice."
|
|
322
405
|
: "";
|
|
323
|
-
process.
|
|
406
|
+
if (process.argv.slice(2).includes("--json")) {
|
|
407
|
+
process.stderr.write(`${JSON.stringify(error.toPublicEnvelope())}\n`);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
process.stderr.write(`${error.message}\nReason: ${error.reason}${hint ? `\n${hint}` : ""}\n`);
|
|
411
|
+
}
|
|
324
412
|
process.exitCode = exitCodeFor(error);
|
|
325
413
|
return;
|
|
326
414
|
}
|
package/dist/client.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export type ManagedEvent = {
|
|
|
34
34
|
* anyway. Confirmed against the deployment, not the source: the live /openapi.json
|
|
35
35
|
* advertises exactly these four in both ExecuteRequest and ImageJobCreate.
|
|
36
36
|
*/
|
|
37
|
-
export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale"];
|
|
37
|
+
export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale", "sprite_sheet"];
|
|
38
38
|
/**
|
|
39
39
|
* Derived from the array above, not written twice.
|
|
40
40
|
*
|
|
@@ -53,7 +53,17 @@ export type ManagedExecuteInput = {
|
|
|
53
53
|
aspect_ratio?: string;
|
|
54
54
|
/** Requires the gateway build that added it. See ManagedOperation. */
|
|
55
55
|
operation?: ManagedOperation;
|
|
56
|
+
options?: {
|
|
57
|
+
action?: "walk" | "run" | "idle";
|
|
58
|
+
animation_prompt?: string;
|
|
59
|
+
animation_mode?: "loop" | "once";
|
|
60
|
+
frame_count?: number;
|
|
61
|
+
frame_size?: 32 | 64 | 128 | 256 | 512 | 720 | 1080;
|
|
62
|
+
};
|
|
63
|
+
max_credits?: number;
|
|
56
64
|
};
|
|
65
|
+
export declare function spriteCreditPrice(frameCount: number): number;
|
|
66
|
+
export declare function validateSpriteInput(input: ManagedExecuteInput): void;
|
|
57
67
|
export type ManagedInputAsset = {
|
|
58
68
|
input_asset_id: string;
|
|
59
69
|
width: number;
|
|
@@ -66,17 +76,28 @@ export type ManagedExecution = {
|
|
|
66
76
|
status: string;
|
|
67
77
|
image_job: Record<string, unknown> | null;
|
|
68
78
|
};
|
|
79
|
+
export declare const PUBLIC_ERROR_REASONS: readonly ["invalid_request", "authentication_failed", "access_denied", "resource_not_found", "insufficient_credits", "conflict", "too_many_active_jobs", "rate_limited", "quota_exceeded", "content_refused", "temporarily_unavailable", "generation_failed", "insufficient_frames"];
|
|
80
|
+
export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number];
|
|
69
81
|
export declare class ApiError extends Error {
|
|
70
82
|
readonly status: number;
|
|
71
|
-
readonly detail: string
|
|
72
|
-
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
83
|
+
readonly detail: string;
|
|
73
84
|
readonly requestId: string | null;
|
|
85
|
+
readonly code: string;
|
|
86
|
+
readonly reason: PublicErrorReason;
|
|
74
87
|
constructor(status: number, surface?: string, detail?: string | null,
|
|
75
88
|
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
76
|
-
requestId?: string | null);
|
|
89
|
+
requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
|
|
77
90
|
/** Whether retrying with the same idempotency key is worth doing. */
|
|
78
91
|
get retryable(): boolean;
|
|
92
|
+
/** The same stable fields exposed by REST and MCP, with no private response text. */
|
|
93
|
+
toPublicEnvelope(): Record<string, unknown>;
|
|
79
94
|
}
|
|
95
|
+
export type ManagedBalance = {
|
|
96
|
+
promotional: number;
|
|
97
|
+
purchased: number;
|
|
98
|
+
available: number;
|
|
99
|
+
credit_usd: "0.17";
|
|
100
|
+
};
|
|
80
101
|
/**
|
|
81
102
|
* A stream that went silent, as distinct from a slow one.
|
|
82
103
|
*
|
|
@@ -93,6 +114,9 @@ export declare class UploadTimeoutError extends Error {
|
|
|
93
114
|
constructor();
|
|
94
115
|
}
|
|
95
116
|
export declare function uploadTimeoutMs(bytes: number): number;
|
|
117
|
+
export declare function managedBalance(value: unknown): ManagedBalance;
|
|
118
|
+
/** Convert a terminal job failure into the same safe contract used by HTTP errors. */
|
|
119
|
+
export declare function terminalExecutionError(execution: ManagedExecution): ApiError | null;
|
|
96
120
|
/**
|
|
97
121
|
* Validate one sanitized event against the published contract.
|
|
98
122
|
*
|
|
@@ -116,10 +140,15 @@ export declare class ManagedClient {
|
|
|
116
140
|
execute(input: ManagedExecuteInput, options: {
|
|
117
141
|
idempotencyKey: string;
|
|
118
142
|
}): AsyncGenerator<ManagedEvent>;
|
|
143
|
+
/** Follow a durable job across finite streams without submitting it twice. */
|
|
144
|
+
follow(input: ManagedExecuteInput, options: {
|
|
145
|
+
idempotencyKey: string;
|
|
146
|
+
}): AsyncGenerator<ManagedEvent>;
|
|
119
147
|
/** Resume a stream after a drop. Pass the last event id you actually processed. */
|
|
120
148
|
events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
|
|
121
149
|
getCapabilities(): Promise<Record<string, unknown>>;
|
|
122
150
|
getExecution(executionId: string): Promise<ManagedExecution>;
|
|
151
|
+
getBalance(): Promise<ManagedBalance>;
|
|
123
152
|
cancel(executionId: string): Promise<ManagedExecution>;
|
|
124
153
|
listConversations(): Promise<Array<Record<string, unknown>>>;
|
|
125
154
|
deleteConversation(conversationId: string): Promise<void>;
|
package/dist/client.js
CHANGED
|
@@ -33,7 +33,32 @@ export const KNOWN_OPERATIONS = [
|
|
|
33
33
|
"image_to_image",
|
|
34
34
|
"background_remove",
|
|
35
35
|
"upscale",
|
|
36
|
+
"sprite_sheet",
|
|
36
37
|
];
|
|
38
|
+
export function spriteCreditPrice(frameCount) {
|
|
39
|
+
if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100)
|
|
40
|
+
throw new Error("frame_count must be an integer from 7 to 100");
|
|
41
|
+
const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
|
|
42
|
+
return Math.ceil(cents * 10 / 17) / 10;
|
|
43
|
+
}
|
|
44
|
+
export function validateSpriteInput(input) {
|
|
45
|
+
if (input.operation !== "sprite_sheet")
|
|
46
|
+
return;
|
|
47
|
+
const options = input.options;
|
|
48
|
+
if (!options || (options.action === undefined) === (options.animation_prompt === undefined))
|
|
49
|
+
throw new Error("Sprite requests require exactly one of options.action or options.animation_prompt");
|
|
50
|
+
if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action))
|
|
51
|
+
throw new Error("Invalid sprite preset");
|
|
52
|
+
if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000))
|
|
53
|
+
throw new Error("animation_prompt must contain 1–4000 characters");
|
|
54
|
+
if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode))
|
|
55
|
+
throw new Error("animation_mode must be loop or once");
|
|
56
|
+
const price = spriteCreditPrice(options.frame_count ?? 12);
|
|
57
|
+
if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size))
|
|
58
|
+
throw new Error("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
|
|
59
|
+
if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100)
|
|
60
|
+
throw new Error(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
|
|
61
|
+
}
|
|
37
62
|
const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
|
|
38
63
|
const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
39
64
|
const LEGACY_INPUT_SUFFIXES = new Set([
|
|
@@ -41,24 +66,149 @@ const LEGACY_INPUT_SUFFIXES = new Set([
|
|
|
41
66
|
".arw", ".cr2", ".cr3", ".crw", ".dng", ".nef", ".nrw", ".orf", ".pef",
|
|
42
67
|
".raf", ".rw2", ".sr2", ".srw",
|
|
43
68
|
]);
|
|
69
|
+
export const PUBLIC_ERROR_REASONS = [
|
|
70
|
+
"invalid_request",
|
|
71
|
+
"authentication_failed",
|
|
72
|
+
"access_denied",
|
|
73
|
+
"resource_not_found",
|
|
74
|
+
"insufficient_credits",
|
|
75
|
+
"conflict",
|
|
76
|
+
"too_many_active_jobs",
|
|
77
|
+
"rate_limited",
|
|
78
|
+
"quota_exceeded",
|
|
79
|
+
"content_refused",
|
|
80
|
+
"temporarily_unavailable",
|
|
81
|
+
"generation_failed",
|
|
82
|
+
"insufficient_frames",
|
|
83
|
+
];
|
|
84
|
+
const PUBLIC_ERROR_SPECS = {
|
|
85
|
+
invalid_request: { message: "The request could not be validated.", retryable: false },
|
|
86
|
+
authentication_failed: { message: "Authentication failed.", retryable: false },
|
|
87
|
+
access_denied: {
|
|
88
|
+
message: "This request is not available for this account.",
|
|
89
|
+
retryable: false,
|
|
90
|
+
},
|
|
91
|
+
resource_not_found: { message: "The requested item was not found.", retryable: false },
|
|
92
|
+
insufficient_credits: {
|
|
93
|
+
message: "The account has insufficient credits.",
|
|
94
|
+
retryable: false,
|
|
95
|
+
},
|
|
96
|
+
conflict: { message: "The request conflicts with the current state.", retryable: false },
|
|
97
|
+
too_many_active_jobs: {
|
|
98
|
+
message: "Too many image jobs are already in progress.",
|
|
99
|
+
retryable: true,
|
|
100
|
+
},
|
|
101
|
+
rate_limited: { message: "Too many requests. Please try again shortly.", retryable: true },
|
|
102
|
+
quota_exceeded: { message: "The account quota has been reached.", retryable: false },
|
|
103
|
+
content_refused: {
|
|
104
|
+
message: "The request could not be completed under the service policy.",
|
|
105
|
+
retryable: false,
|
|
106
|
+
},
|
|
107
|
+
temporarily_unavailable: {
|
|
108
|
+
message: "The service is temporarily unavailable. Please try again.",
|
|
109
|
+
retryable: true,
|
|
110
|
+
},
|
|
111
|
+
insufficient_frames: { message: "Not enough distinct animation frames. Try a lower frame count.", retryable: false },
|
|
112
|
+
generation_failed: { message: "Image generation failed.", retryable: false },
|
|
113
|
+
};
|
|
114
|
+
const PUBLIC_ERROR_REASON_SET = new Set(PUBLIC_ERROR_REASONS);
|
|
115
|
+
function publicReason(value) {
|
|
116
|
+
return typeof value === "string" && PUBLIC_ERROR_REASON_SET.has(value)
|
|
117
|
+
? value
|
|
118
|
+
: null;
|
|
119
|
+
}
|
|
120
|
+
function reasonForStatus(status) {
|
|
121
|
+
if (status === 400 || status === 405 || status === 422)
|
|
122
|
+
return "invalid_request";
|
|
123
|
+
if (status === 401)
|
|
124
|
+
return "authentication_failed";
|
|
125
|
+
if (status === 403)
|
|
126
|
+
return "access_denied";
|
|
127
|
+
if (status === 404)
|
|
128
|
+
return "resource_not_found";
|
|
129
|
+
if (status === 402)
|
|
130
|
+
return "insufficient_credits";
|
|
131
|
+
if (status === 409)
|
|
132
|
+
return "conflict";
|
|
133
|
+
if (status === 429)
|
|
134
|
+
return "rate_limited";
|
|
135
|
+
if (status === 502 || status === 503 || status === 504)
|
|
136
|
+
return "temporarily_unavailable";
|
|
137
|
+
return "generation_failed";
|
|
138
|
+
}
|
|
139
|
+
function defaultCode(reason) {
|
|
140
|
+
const codes = {
|
|
141
|
+
invalid_request: "VALIDATION_FAILED",
|
|
142
|
+
authentication_failed: "AUTHENTICATION_FAILED",
|
|
143
|
+
access_denied: "FORBIDDEN",
|
|
144
|
+
resource_not_found: "NOT_FOUND",
|
|
145
|
+
insufficient_credits: "BUDGET_EXCEEDED",
|
|
146
|
+
conflict: "CONFLICT",
|
|
147
|
+
too_many_active_jobs: "RATE_LIMITED",
|
|
148
|
+
rate_limited: "RATE_LIMITED",
|
|
149
|
+
quota_exceeded: "BUDGET_EXCEEDED",
|
|
150
|
+
content_refused: "CONTENT_REFUSED",
|
|
151
|
+
temporarily_unavailable: "SERVICE_UNAVAILABLE",
|
|
152
|
+
generation_failed: "INTERNAL_ERROR",
|
|
153
|
+
insufficient_frames: "INSUFFICIENT_FRAMES",
|
|
154
|
+
};
|
|
155
|
+
return codes[reason];
|
|
156
|
+
}
|
|
157
|
+
function statusForReason(reason) {
|
|
158
|
+
const statuses = {
|
|
159
|
+
invalid_request: 422,
|
|
160
|
+
authentication_failed: 401,
|
|
161
|
+
access_denied: 403,
|
|
162
|
+
resource_not_found: 404,
|
|
163
|
+
insufficient_credits: 402,
|
|
164
|
+
conflict: 409,
|
|
165
|
+
too_many_active_jobs: 429,
|
|
166
|
+
rate_limited: 429,
|
|
167
|
+
quota_exceeded: 402,
|
|
168
|
+
content_refused: 422,
|
|
169
|
+
temporarily_unavailable: 503,
|
|
170
|
+
generation_failed: 500,
|
|
171
|
+
insufficient_frames: 422,
|
|
172
|
+
};
|
|
173
|
+
return statuses[reason];
|
|
174
|
+
}
|
|
175
|
+
const ERROR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
176
|
+
function publicIdentifier(value) {
|
|
177
|
+
return typeof value === "string" && ERROR_IDENTIFIER_PATTERN.test(value) ? value : null;
|
|
178
|
+
}
|
|
44
179
|
export class ApiError extends Error {
|
|
45
180
|
status;
|
|
46
181
|
detail;
|
|
47
182
|
requestId;
|
|
183
|
+
code;
|
|
184
|
+
reason;
|
|
48
185
|
constructor(status, surface = "DreamLayer Agent API", detail = null,
|
|
49
186
|
/** Server-assigned id for this failure. The only handle support can search on. */
|
|
50
|
-
requestId = null) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
: `${surface} request failed (${status})`);
|
|
187
|
+
requestId = null, code = null, reason = reasonForStatus(status)) {
|
|
188
|
+
const safeDetail = detail ?? PUBLIC_ERROR_SPECS[reason].message;
|
|
189
|
+
super(`${safeDetail || `${surface} request failed (${status})`}${requestId ? ` (request ${requestId})` : ""}`);
|
|
54
190
|
this.status = status;
|
|
55
|
-
this.detail = detail;
|
|
56
|
-
this.requestId = requestId;
|
|
57
191
|
this.name = "ApiError";
|
|
192
|
+
this.detail = safeDetail;
|
|
193
|
+
this.requestId = requestId;
|
|
194
|
+
this.code = code ?? defaultCode(reason);
|
|
195
|
+
this.reason = reason;
|
|
58
196
|
}
|
|
59
197
|
/** Whether retrying with the same idempotency key is worth doing. */
|
|
60
198
|
get retryable() {
|
|
61
|
-
return this.
|
|
199
|
+
return PUBLIC_ERROR_SPECS[this.reason].retryable;
|
|
200
|
+
}
|
|
201
|
+
/** The same stable fields exposed by REST and MCP, with no private response text. */
|
|
202
|
+
toPublicEnvelope() {
|
|
203
|
+
return {
|
|
204
|
+
error: {
|
|
205
|
+
code: this.code,
|
|
206
|
+
reason: this.reason,
|
|
207
|
+
message: this.detail,
|
|
208
|
+
retryable: this.retryable,
|
|
209
|
+
request_id: this.requestId,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
62
212
|
}
|
|
63
213
|
}
|
|
64
214
|
/**
|
|
@@ -84,7 +234,6 @@ export class UploadTimeoutError extends Error {
|
|
|
84
234
|
}
|
|
85
235
|
}
|
|
86
236
|
const ERROR_BODY_LIMIT = 16 * 1024;
|
|
87
|
-
const ERROR_DETAIL_LIMIT = 300;
|
|
88
237
|
/**
|
|
89
238
|
* A plain request: send, get a body back. Bounded work, so a total cap is right.
|
|
90
239
|
*/
|
|
@@ -130,40 +279,52 @@ const STREAM_IDLE_TIMEOUT_MS = (() => {
|
|
|
130
279
|
function isRecord(value) {
|
|
131
280
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
132
281
|
}
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
282
|
+
async function boundedErrorBody(response) {
|
|
283
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
284
|
+
if (!Number.isFinite(length) || length < 0 || length > ERROR_BODY_LIMIT || !response.body) {
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
const reader = response.body.getReader();
|
|
288
|
+
const decoder = new TextDecoder();
|
|
289
|
+
let total = 0;
|
|
290
|
+
let body = "";
|
|
291
|
+
try {
|
|
292
|
+
for (;;) {
|
|
293
|
+
const { value, done } = await reader.read();
|
|
294
|
+
if (done)
|
|
295
|
+
return body + decoder.decode();
|
|
296
|
+
total += value.byteLength;
|
|
297
|
+
if (total > ERROR_BODY_LIMIT) {
|
|
298
|
+
await reader.cancel();
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
body += decoder.decode(value, { stream: true });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
reader.releaseLock();
|
|
306
|
+
}
|
|
141
307
|
}
|
|
142
308
|
async function apiError(response, surface = "DreamLayer Agent API") {
|
|
143
|
-
let
|
|
144
|
-
let
|
|
309
|
+
let reason = reasonForStatus(response.status);
|
|
310
|
+
let code = null;
|
|
311
|
+
let requestId = publicRequestId(response.headers.get("x-request-id"));
|
|
145
312
|
try {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// why every failure used to print a bare status code and nothing else.
|
|
156
|
-
detail = sanitizedErrorDetail(parsed.detail);
|
|
157
|
-
if (isRecord(parsed.error)) {
|
|
158
|
-
detail = detail ?? sanitizedErrorDetail(parsed.error.message);
|
|
159
|
-
requestId = sanitizedErrorDetail(parsed.error.request_id);
|
|
160
|
-
}
|
|
313
|
+
const raw = await boundedErrorBody(response);
|
|
314
|
+
const parsed = raw ? JSON.parse(raw) : null;
|
|
315
|
+
if (isRecord(parsed) && isRecord(parsed.error)) {
|
|
316
|
+
// Treat only the closed reason/code/id fields as data. The local message table
|
|
317
|
+
// deliberately ignores arbitrary remote detail so a private upstream response
|
|
318
|
+
// cannot leak through a client even if a server regression serializes it.
|
|
319
|
+
reason = publicReason(parsed.error.reason) ?? reason;
|
|
320
|
+
code = publicIdentifier(parsed.error.code);
|
|
321
|
+
requestId = publicRequestId(parsed.error.request_id) ?? requestId;
|
|
161
322
|
}
|
|
162
323
|
}
|
|
163
324
|
catch {
|
|
164
|
-
|
|
325
|
+
// A malformed or oversized response still becomes a closed, status-derived error.
|
|
165
326
|
}
|
|
166
|
-
return new ApiError(response.status, surface,
|
|
327
|
+
return new ApiError(response.status, surface, null, requestId, code, reason);
|
|
167
328
|
}
|
|
168
329
|
const MANAGED_EVENT_NAMES = new Set([
|
|
169
330
|
"started",
|
|
@@ -175,6 +336,44 @@ const MANAGED_EVENT_NAMES = new Set([
|
|
|
175
336
|
"done",
|
|
176
337
|
]);
|
|
177
338
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
339
|
+
function publicRequestId(value) {
|
|
340
|
+
return typeof value === "string" && UUID_PATTERN.test(value) ? value : null;
|
|
341
|
+
}
|
|
342
|
+
export function managedBalance(value) {
|
|
343
|
+
if (!isRecord(value))
|
|
344
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
345
|
+
const exact = ["available", "credit_usd", "promotional", "purchased"];
|
|
346
|
+
if (Object.keys(value).sort().join("\0") !== exact.join("\0")) {
|
|
347
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
348
|
+
}
|
|
349
|
+
for (const field of ["promotional", "purchased", "available"]) {
|
|
350
|
+
if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) {
|
|
351
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (value.credit_usd !== "0.17" ||
|
|
355
|
+
Math.round(Number(value.available) * 10) < Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) ||
|
|
356
|
+
Math.round(Number(value.available) * 10) > Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) + 1) {
|
|
357
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
358
|
+
}
|
|
359
|
+
if ([value.promotional, value.purchased, value.available].some(v => Math.abs(Number(v) * 10 - Math.round(Number(v) * 10)) > 1e-7))
|
|
360
|
+
throw new Error("Invalid DreamLayer balance response");
|
|
361
|
+
return {
|
|
362
|
+
promotional: Number(value.promotional),
|
|
363
|
+
purchased: Number(value.purchased),
|
|
364
|
+
available: Number(value.available),
|
|
365
|
+
credit_usd: "0.17",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** Convert a terminal job failure into the same safe contract used by HTTP errors. */
|
|
369
|
+
export function terminalExecutionError(execution) {
|
|
370
|
+
if (!isRecord(execution.image_job) || !isRecord(execution.image_job.sanitized_error)) {
|
|
371
|
+
return execution.status === "failed" ? new ApiError(500) : null;
|
|
372
|
+
}
|
|
373
|
+
const error = execution.image_job.sanitized_error;
|
|
374
|
+
const reason = publicReason(error.reason) ?? "generation_failed";
|
|
375
|
+
return new ApiError(statusForReason(reason), "DreamLayer execution", null, publicRequestId(error.request_id), publicIdentifier(error.code), reason);
|
|
376
|
+
}
|
|
178
377
|
/**
|
|
179
378
|
* Validate one sanitized event against the published contract.
|
|
180
379
|
*
|
|
@@ -351,6 +550,7 @@ export class ManagedClient {
|
|
|
351
550
|
* so a caller could not even resume what it had already paid for.
|
|
352
551
|
*/
|
|
353
552
|
async *execute(input, options) {
|
|
553
|
+
validateSpriteInput(input);
|
|
354
554
|
const stream = await this.fetchStream("/v1/execute", {
|
|
355
555
|
method: "POST",
|
|
356
556
|
headers: {
|
|
@@ -362,6 +562,50 @@ export class ManagedClient {
|
|
|
362
562
|
});
|
|
363
563
|
yield* this.parse(stream);
|
|
364
564
|
}
|
|
565
|
+
/** Follow a durable job across finite streams without submitting it twice. */
|
|
566
|
+
async *follow(input, options) {
|
|
567
|
+
let executionId;
|
|
568
|
+
let cursor;
|
|
569
|
+
let stream = this.execute(input, options);
|
|
570
|
+
const deadline = Date.now() + 16 * 60_000;
|
|
571
|
+
let failures = 0;
|
|
572
|
+
while (Date.now() < deadline) {
|
|
573
|
+
try {
|
|
574
|
+
for await (const event of stream) {
|
|
575
|
+
if (event.event === "started")
|
|
576
|
+
executionId = String(event.data.execution_id);
|
|
577
|
+
if (event.id)
|
|
578
|
+
cursor = event.id;
|
|
579
|
+
yield event;
|
|
580
|
+
if (event.event === "done")
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
failures = 0;
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
if (error instanceof StreamIdleError)
|
|
587
|
+
throw error;
|
|
588
|
+
if (!executionId || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)) || ++failures > 5)
|
|
589
|
+
throw error;
|
|
590
|
+
}
|
|
591
|
+
if (!executionId)
|
|
592
|
+
throw new Error("Execution stream ended before an identifier was received; reuse your idempotency key.");
|
|
593
|
+
const state = await this.getExecution(executionId);
|
|
594
|
+
if (["completed", "failed", "cancelled"].includes(state.status)) {
|
|
595
|
+
if (state.status === "completed") {
|
|
596
|
+
const assets = state.image_job?.finished_assets;
|
|
597
|
+
if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
|
|
598
|
+
throw new Error(`Execution ${executionId} has no downloadable asset yet.`);
|
|
599
|
+
yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url });
|
|
600
|
+
}
|
|
601
|
+
yield managedEvent("done", null, { status: state.status });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures)));
|
|
605
|
+
stream = this.events(executionId, cursor);
|
|
606
|
+
}
|
|
607
|
+
throw new Error(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
|
|
608
|
+
}
|
|
365
609
|
/** Resume a stream after a drop. Pass the last event id you actually processed. */
|
|
366
610
|
async *events(executionId, lastEventId) {
|
|
367
611
|
const headers = { Accept: "text/event-stream" };
|
|
@@ -386,6 +630,9 @@ export class ManagedClient {
|
|
|
386
630
|
getExecution(executionId) {
|
|
387
631
|
return this.request(`/v1/executions/${encodeURIComponent(executionId)}`);
|
|
388
632
|
}
|
|
633
|
+
async getBalance() {
|
|
634
|
+
return managedBalance(await this.request("/v1/balance"));
|
|
635
|
+
}
|
|
389
636
|
cancel(executionId) {
|
|
390
637
|
return this.request(`/v1/executions/${encodeURIComponent(executionId)}/cancel`, {
|
|
391
638
|
method: "POST",
|