Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,20 @@ Node.js 22.12 or later.
## License

MIT. See LICENSE and NOTICE.

## Sprite-sheet beta

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.

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.

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.

```sh
dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip
dreamlayer status EXECUTION_ID
```

Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes.

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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dreamlayer",
"version": "0.3.0",
"version": "0.4.0-beta.1",
"description": "Generate and edit images from your terminal, over local files, with one API key.",
"license": "MIT",
"type": "module",
Expand Down
63 changes: 59 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { spriteCreditPrice } from "./client.js";
/**
* DreamLayer CLI.
*
Expand Down Expand Up @@ -43,6 +44,7 @@ USAGE
dreamlayer edit <image> <prompt> [--out <file>]
dreamlayer cutout <image> [--out <file>]
dreamlayer upscale <image> [--out <file>]
dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
dreamlayer status <execution-id>
dreamlayer balance
Expand All @@ -52,6 +54,12 @@ OPTIONS
--out <file> Where to write the image. Default: dreamlayer-<n>.png
--image <file> Attach an image when answering a question that asks for one
--aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
--action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
--animation-prompt <text> Custom animation; cannot combine with --action
--animation-mode <loop|once> Default: loop for presets, once for custom
--frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
--frames <n> Frame count: integer 7–100, default 12
--max-credits <n> Maximum approved charge for the sprite job
--json Machine-readable output on stdout
--quiet No progress on stderr
--idempotency-key <key> Reuse to retry safely after an uncertain response
Expand All @@ -60,10 +68,16 @@ ENVIRONMENT
DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io

Every finished image costs one credit. A new account starts at zero.
Image operations cost one credit. Sprite pricing is listed in capabilities. A new account starts at zero.
`;

type Options = {
action?: "walk" | "run" | "idle";
animationPrompt?: string;
animationMode?: "loop" | "once";
frameSize?: 32 | 64 | 128 | 256 | 512 | 720 | 1080;
maxCredits: number;
frameCount: number;
out: string | null;
image: string | null;
aspect: string;
Expand All @@ -77,6 +91,8 @@ class UsageError extends Error {}
function parseOptions(argv: string[]): { positional: string[]; options: Options } {
const positional: string[] = [];
const options: Options = {
maxCredits: 1,
frameCount: 12,
out: null,
image: null,
aspect: "1:1",
Expand All @@ -92,6 +108,30 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options
const value = argv[++i];
if (!value) throw new UsageError("--out needs a file path");
options.out = value;
} else if (token === "--action") {
const value = argv[++i];
if (value !== "walk" && value !== "run" && value !== "idle") throw new UsageError("--action must be walk, run, or idle");
options.action = value;
} else if (token === "--animation-prompt") {
const value = argv[++i];
if (!value?.trim() || [...value].length > 4000) throw new UsageError("--animation-prompt needs 1–4000 characters");
options.animationPrompt = value;
} else if (token === "--animation-mode") {
const value = argv[++i];
if (value !== "loop" && value !== "once") throw new UsageError("--animation-mode must be loop or once");
options.animationMode = value;
} else if (token === "--frame-size") {
const value = Number(argv[++i]);
if (![32, 64, 128, 256, 512, 720, 1080].includes(value)) throw new UsageError("--frame-size must be 32, 64, 128, 256, 512, 720 or 1080");
options.frameSize = value as Options["frameSize"];
} else if (token === "--frames") {
const value = Number(argv[++i]);
if (!Number.isInteger(value) || value < 7 || value > 100) throw new UsageError("--frames must be an integer from 7 to 100");
options.frameCount = value;
} else if (token === "--max-credits") {
const value = Number(argv[++i]);
if (!Number.isFinite(value) || value < 0.1 || value > 100) throw new UsageError("--max-credits must be 0.1 to 100");
options.maxCredits = value;
} else if (token === "--image") {
const value = argv[++i];
if (!value) throw new UsageError("--image needs a file path");
Expand Down Expand Up @@ -156,7 +196,7 @@ async function run(
): Promise<number> {
const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
const idempotencyKey = options.idempotencyKey ?? randomUUID();
const outcome = await consume(api.execute(input, { idempotencyKey }), progress);
const outcome = await consume(api.follow(input, { idempotencyKey }), progress);

if (outcome.question) {
progress.stop();
Expand Down Expand Up @@ -188,7 +228,7 @@ async function run(

progress.set("Downloading");
const bytes = await api.download(outcome.asset.download_url);
const target = options.out ?? defaultOut();
const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
await writeFile(target, bytes);
progress.stop();

Expand Down Expand Up @@ -255,7 +295,7 @@ function warnIfOperationsDrifted(capabilities: unknown): void {
const server = new Set(listed as string[]);
const mine = new Set<string>(KNOWN_OPERATIONS);
const serverOnly = [...server].filter((o) => !mine.has(o));
const clientOnly = [...mine].filter((o) => !server.has(o));
const clientOnly = [...mine].filter((o) => !server.has(o) && o !== "sprite_sheet");
if (serverOnly.length === 0 && clientOnly.length === 0) return;

process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
Expand Down Expand Up @@ -293,6 +333,18 @@ async function main(argv: string[]): Promise<number> {
const { positional, options } = parseOptions(rest);

switch (command) {
case "sprite": {
if (options.action && options.animationPrompt) throw new UsageError("Use either --action or --animation-prompt, not both");
const file = positional[0];
if (!file) throw new UsageError("sprite needs a reference image");
const api = client();
const caps = await api.getCapabilities();
if (!Array.isArray(caps.operations) || !caps.operations.includes("sprite_sheet")) throw new UsageError("sprite beta access is not enabled for this account");
if (!caps.sprite_pricing) throw new UsageError("The server does not support configurable sprite pricing yet");
const price = spriteCreditPrice(options.frameCount);
if (options.maxCredits < price) throw new UsageError(`Sprite jobs require ${price} credits. Set --max-credits to approve that amount.`);
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);
}
case "generate": {
const prompt = positional[0];
if (!prompt) throw new UsageError("generate needs a prompt");
Expand Down Expand Up @@ -354,6 +406,9 @@ async function main(argv: string[]): Promise<number> {
`(${balance.promotional} promotional, ${balance.purchased} purchased)\n`,
);
}
if (!options.json && Math.round(balance.available * 10) > Math.round(balance.promotional * 10) + Math.round(balance.purchased * 10)) {
process.stdout.write("Use the available total for affordability. Funding balances are rounded down separately; stored fractions are preserved.\n");
}
return 0;
}
case "capabilities": {
Expand Down
69 changes: 67 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const KNOWN_OPERATIONS = [
"image_to_image",
"background_remove",
"upscale",
"sprite_sheet",
] as const;

/**
Expand All @@ -70,8 +71,28 @@ export type ManagedExecuteInput = {
aspect_ratio?: string;
/** Requires the gateway build that added it. See ManagedOperation. */
operation?: ManagedOperation;
options?: { action?: "walk" | "run" | "idle"; animation_prompt?: string; animation_mode?: "loop" | "once"; frame_count?: number; frame_size?: 32 | 64 | 128 | 256 | 512 | 720 | 1080 };
max_credits?: number;
};

export function spriteCreditPrice(frameCount: number): number {
if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100) throw new Error("frame_count must be an integer from 7 to 100");
const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
return Math.ceil(cents * 10 / 17) / 10;
}

export function validateSpriteInput(input: ManagedExecuteInput): void {
if (input.operation !== "sprite_sheet") return;
const options = input.options;
if (!options || (options.action === undefined) === (options.animation_prompt === undefined)) throw new Error("Sprite requests require exactly one of options.action or options.animation_prompt");
if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action)) throw new Error("Invalid sprite preset");
if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000)) throw new Error("animation_prompt must contain 1–4000 characters");
if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode)) throw new Error("animation_mode must be loop or once");
const price = spriteCreditPrice(options.frame_count ?? 12);
if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size)) throw new Error("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100) throw new Error(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
}

export type ManagedInputAsset = {
input_asset_id: string;
width: number;
Expand Down Expand Up @@ -117,6 +138,7 @@ export const PUBLIC_ERROR_REASONS = [
"content_refused",
"temporarily_unavailable",
"generation_failed",
"insufficient_frames",
] as const;

export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number];
Expand Down Expand Up @@ -151,6 +173,7 @@ const PUBLIC_ERROR_SPECS: Record<
message: "The service is temporarily unavailable. Please try again.",
retryable: true,
},
insufficient_frames: { message: "Not enough distinct animation frames. Try a lower frame count.", retryable: false },
generation_failed: { message: "Image generation failed.", retryable: false },
};

Expand Down Expand Up @@ -188,6 +211,7 @@ function defaultCode(reason: PublicErrorReason): string {
content_refused: "CONTENT_REFUSED",
temporarily_unavailable: "SERVICE_UNAVAILABLE",
generation_failed: "INTERNAL_ERROR",
insufficient_frames: "INSUFFICIENT_FRAMES",
};
return codes[reason];
}
Expand All @@ -206,6 +230,7 @@ function statusForReason(reason: PublicErrorReason): number {
content_refused: 422,
temporarily_unavailable: 503,
generation_failed: 500,
insufficient_frames: 422,
};
return statuses[reason];
}
Expand Down Expand Up @@ -414,16 +439,18 @@ export function managedBalance(value: unknown): ManagedBalance {
throw new Error("Invalid DreamLayer balance response");
}
for (const field of ["promotional", "purchased", "available"] as const) {
if (!Number.isSafeInteger(value[field]) || Number(value[field]) < 0) {
if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) {
throw new Error("Invalid DreamLayer balance response");
}
}
if (
value.credit_usd !== "0.17" ||
Number(value.available) !== Number(value.promotional) + Number(value.purchased)
Math.round(Number(value.available) * 10) < Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) ||
Math.round(Number(value.available) * 10) > Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) + 1
) {
throw new Error("Invalid DreamLayer balance response");
}
if ([value.promotional, value.purchased, value.available].some(v => Math.abs(Number(v) * 10 - Math.round(Number(v) * 10)) > 1e-7)) throw new Error("Invalid DreamLayer balance response");
return {
promotional: Number(value.promotional),
purchased: Number(value.purchased),
Expand Down Expand Up @@ -643,6 +670,7 @@ export class ManagedClient {
input: ManagedExecuteInput,
options: { idempotencyKey: string },
): AsyncGenerator<ManagedEvent> {
validateSpriteInput(input);
const stream = await this.fetchStream("/v1/execute", {
method: "POST",
headers: {
Expand All @@ -655,6 +683,43 @@ export class ManagedClient {
yield* this.parse(stream);
}

/** Follow a durable job across finite streams without submitting it twice. */
async *follow(input: ManagedExecuteInput, options: { idempotencyKey: string }): AsyncGenerator<ManagedEvent> {
let executionId: string | undefined;
let cursor: string | undefined;
let stream = this.execute(input, options);
const deadline = Date.now() + 16 * 60_000;
let failures = 0;
while (Date.now() < deadline) {
try {
for await (const event of stream) {
if (event.event === "started") executionId = String(event.data.execution_id);
if (event.id) cursor = event.id;
yield event;
if (event.event === "done") return;
}
failures = 0;
} catch (error) {
if (error instanceof StreamIdleError) throw error;
if (!executionId || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)) || ++failures > 5) throw error;
}
if (!executionId) throw new Error("Execution stream ended before an identifier was received; reuse your idempotency key.");
const state = await this.getExecution(executionId);
if (["completed", "failed", "cancelled"].includes(state.status)) {
if (state.status === "completed") {
const assets = state.image_job?.finished_assets;
if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new Error(`Execution ${executionId} has no downloadable asset yet.`);
yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url });
}
yield managedEvent("done", null, {status: state.status});
return;
}
await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures)));
stream = this.events(executionId, cursor);
}
throw new Error(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
}

/** Resume a stream after a drop. Pass the last event id you actually processed. */
async *events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent> {
const headers: Record<string, string> = { Accept: "text/event-stream" };
Expand Down
Loading