dreamlayer 0.3.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 CHANGED
@@ -107,3 +107,20 @@ Node.js 22.12 or later.
107
107
  ## License
108
108
 
109
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
  *
@@ -28,6 +29,7 @@ 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>
33
35
  dreamlayer balance
@@ -37,6 +39,12 @@ OPTIONS
37
39
  --out <file> Where to write the image. Default: dreamlayer-<n>.png
38
40
  --image <file> Attach an image when answering a question that asks for one
39
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
40
48
  --json Machine-readable output on stdout
41
49
  --quiet No progress on stderr
42
50
  --idempotency-key <key> Reuse to retry safely after an uncertain response
@@ -45,13 +53,15 @@ ENVIRONMENT
45
53
  DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
46
54
  DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
47
55
 
48
- Every finished image costs one credit. A new account starts at zero.
56
+ Image operations cost one credit. Sprite pricing is listed in capabilities. A new account starts at zero.
49
57
  `;
50
58
  class UsageError extends Error {
51
59
  }
52
60
  function parseOptions(argv) {
53
61
  const positional = [];
54
62
  const options = {
63
+ maxCredits: 1,
64
+ frameCount: 12,
55
65
  out: null,
56
66
  image: null,
57
67
  aspect: "1:1",
@@ -71,6 +81,42 @@ function parseOptions(argv) {
71
81
  throw new UsageError("--out needs a file path");
72
82
  options.out = value;
73
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
+ }
74
120
  else if (token === "--image") {
75
121
  const value = argv[++i];
76
122
  if (!value)
@@ -130,7 +176,7 @@ function defaultOut() {
130
176
  async function run(api, input, options) {
131
177
  const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
132
178
  const idempotencyKey = options.idempotencyKey ?? randomUUID();
133
- const outcome = await consume(api.execute(input, { idempotencyKey }), progress);
179
+ const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
134
180
  if (outcome.question) {
135
181
  progress.stop();
136
182
  if (options.json) {
@@ -161,7 +207,7 @@ async function run(api, input, options) {
161
207
  }
162
208
  progress.set("Downloading");
163
209
  const bytes = await api.download(outcome.asset.download_url);
164
- const target = options.out ?? defaultOut();
210
+ const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
165
211
  await writeFile(target, bytes);
166
212
  progress.stop();
167
213
  if (options.json) {
@@ -218,7 +264,7 @@ function warnIfOperationsDrifted(capabilities) {
218
264
  const server = new Set(listed);
219
265
  const mine = new Set(KNOWN_OPERATIONS);
220
266
  const serverOnly = [...server].filter((o) => !mine.has(o));
221
- const clientOnly = [...mine].filter((o) => !server.has(o));
267
+ const clientOnly = [...mine].filter((o) => !server.has(o) && o !== "sprite_sheet");
222
268
  if (serverOnly.length === 0 && clientOnly.length === 0)
223
269
  return;
224
270
  process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
@@ -250,6 +296,23 @@ async function main(argv) {
250
296
  }
251
297
  const { positional, options } = parseOptions(rest);
252
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
+ }
253
316
  case "generate": {
254
317
  const prompt = positional[0];
255
318
  if (!prompt)
@@ -309,6 +372,9 @@ async function main(argv) {
309
372
  process.stdout.write(`${balance.available} credits available ` +
310
373
  `(${balance.promotional} promotional, ${balance.purchased} purchased)\n`);
311
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
+ }
312
378
  return 0;
313
379
  }
314
380
  case "capabilities": {
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,7 +76,7 @@ export type ManagedExecution = {
66
76
  status: string;
67
77
  image_job: Record<string, unknown> | null;
68
78
  };
69
- 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"];
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"];
70
80
  export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number];
71
81
  export declare class ApiError extends Error {
72
82
  readonly status: number;
@@ -130,6 +140,10 @@ export declare class ManagedClient {
130
140
  execute(input: ManagedExecuteInput, options: {
131
141
  idempotencyKey: string;
132
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>;
133
147
  /** Resume a stream after a drop. Pass the last event id you actually processed. */
134
148
  events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
135
149
  getCapabilities(): Promise<Record<string, unknown>>;
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([
@@ -54,6 +79,7 @@ export const PUBLIC_ERROR_REASONS = [
54
79
  "content_refused",
55
80
  "temporarily_unavailable",
56
81
  "generation_failed",
82
+ "insufficient_frames",
57
83
  ];
58
84
  const PUBLIC_ERROR_SPECS = {
59
85
  invalid_request: { message: "The request could not be validated.", retryable: false },
@@ -82,6 +108,7 @@ const PUBLIC_ERROR_SPECS = {
82
108
  message: "The service is temporarily unavailable. Please try again.",
83
109
  retryable: true,
84
110
  },
111
+ insufficient_frames: { message: "Not enough distinct animation frames. Try a lower frame count.", retryable: false },
85
112
  generation_failed: { message: "Image generation failed.", retryable: false },
86
113
  };
87
114
  const PUBLIC_ERROR_REASON_SET = new Set(PUBLIC_ERROR_REASONS);
@@ -123,6 +150,7 @@ function defaultCode(reason) {
123
150
  content_refused: "CONTENT_REFUSED",
124
151
  temporarily_unavailable: "SERVICE_UNAVAILABLE",
125
152
  generation_failed: "INTERNAL_ERROR",
153
+ insufficient_frames: "INSUFFICIENT_FRAMES",
126
154
  };
127
155
  return codes[reason];
128
156
  }
@@ -140,6 +168,7 @@ function statusForReason(reason) {
140
168
  content_refused: 422,
141
169
  temporarily_unavailable: 503,
142
170
  generation_failed: 500,
171
+ insufficient_frames: 422,
143
172
  };
144
173
  return statuses[reason];
145
174
  }
@@ -318,14 +347,17 @@ export function managedBalance(value) {
318
347
  throw new Error("Invalid DreamLayer balance response");
319
348
  }
320
349
  for (const field of ["promotional", "purchased", "available"]) {
321
- if (!Number.isSafeInteger(value[field]) || Number(value[field]) < 0) {
350
+ if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) {
322
351
  throw new Error("Invalid DreamLayer balance response");
323
352
  }
324
353
  }
325
354
  if (value.credit_usd !== "0.17" ||
326
- Number(value.available) !== Number(value.promotional) + Number(value.purchased)) {
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) {
327
357
  throw new Error("Invalid DreamLayer balance response");
328
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");
329
361
  return {
330
362
  promotional: Number(value.promotional),
331
363
  purchased: Number(value.purchased),
@@ -518,6 +550,7 @@ export class ManagedClient {
518
550
  * so a caller could not even resume what it had already paid for.
519
551
  */
520
552
  async *execute(input, options) {
553
+ validateSpriteInput(input);
521
554
  const stream = await this.fetchStream("/v1/execute", {
522
555
  method: "POST",
523
556
  headers: {
@@ -529,6 +562,50 @@ export class ManagedClient {
529
562
  });
530
563
  yield* this.parse(stream);
531
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
+ }
532
609
  /** Resume a stream after a drop. Pass the last event id you actually processed. */
533
610
  async *events(executionId, lastEventId) {
534
611
  const headers = { Accept: "text/event-stream" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamlayer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-beta.1",
4
4
  "description": "Generate and edit images from your terminal, over local files, with one API key.",
5
5
  "license": "MIT",
6
6
  "type": "module",