Skip to content
Open
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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,21 @@ MIT. See LICENSE and NOTICE.

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. Pass `--background keep` when transparency is not needed: the frames keep the generated background, plain rather than cut out, at the flat plain rate. A frame that cannot be cut out is retried at nearby moments of the same clip, and a single stubborn frame ships with its background rather than failing the sheet; `atlas.json` lists any such frame in `kept_background_frames`. 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.
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. Pass `--background keep` when transparency is not needed: the frames keep the generated background, plain rather than cut out, at the background-kept rate shown in the current server estimate. A frame that cannot be cut out is retried at nearby moments of the same clip, and a single stubborn frame ships with its background rather than failing the sheet; `atlas.json` lists any such frame in `kept_background_frames`. 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. With `--background keep` every frame is a flat $0.07 with no tier, roughly half a transparent sheet (12 frames: 5 credits instead of 9.9). 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.
New transparent sprite sheets cost 4.2 API credits plus 0.2 per frame (12 frames: 6.6 credits). Kept-background sheets cost $0.32 plus $0.01 per frame, converted at the account's undiscounted purchase rate and rounded upward to four decimal credits. For a public account, 12 kept-background frames cost 2.9334 credits. Export size does not change price. Existing accepted jobs keep their original charges. Credits are held during processing, settled after complete ZIP delivery and restored on failure or timeout. There is no customer cancellation. Keep the execution ID to resume status. An estimate is not a reservation: the server checks the actual price and your approved ceiling at admission.

The CLI reads the free authenticated `/v1/sprite-pricing` estimate before uploading or submitting a sprite. It never raises `--max-credits` for you and refuses if the estimate endpoint is unavailable. Recover accepted work with its saved execution ID using `status` and `download`. Rerunning the file-based `sprite` command uploads a new asset and checks today's estimate, so supplying the same idempotency key is not an exact request replay. Do not rerun it to recover an existing charge. Balance reads request `pricing=current` and preserve four decimal places.

```sh
dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip
dreamlayer sprite character.png --action walk --frames 12 --max-credits 6.6 --out walk.zip
dreamlayer sprite character.png --action walk --frames 12 --background keep --max-credits 5 --out walk-plain.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.
For affordability, compare the complete rounded quote in **credits** with `available`. Current balances preserve four decimal places, and a credit's dollar equivalent depends on your account's purchase rate. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.0001 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.

## Automating recovery

Expand Down
6 changes: 3 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spriteCreditPrice, type SpriteBackground } from "./client.js";
import { type SpriteBackground } from "./client.js";
/**
* DreamLayer CLI.
*
Expand Down Expand Up @@ -416,8 +416,8 @@ async function main(argv: string[]): Promise<number> {
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 background = options.background ?? "remove";
if (background === "keep" && typeof caps.sprite_pricing === "object" && caps.sprite_pricing !== null && !("plain_frame_cents" in caps.sprite_pricing)) throw new UsageError("This server does not offer sprite sheets that keep their background yet");
const price = spriteCreditPrice(options.frameCount, background);
const estimate = await api.getSpritePrice(options.frameCount, background);
const price = Number(estimate.credits);
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 } : {}), ...(background === "keep" ? { background } : {}), frame_count: options.frameCount }, max_credits: options.maxCredits }, options);
}
Expand Down
51 changes: 38 additions & 13 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,12 @@ export class RecoveryRequiredError extends Error {}
export class ResponseContractError extends Error {}

/**
* Transparent frames are tiered; plain frames are a flat rate with no tier, because the
* tier exists to price background removal and a plain frame never buys any.
* Background intent is independent of the server-owned account price.
*/
export const SPRITE_BACKGROUNDS = ["remove", "keep"] as const;
export type SpriteBackground = (typeof SPRITE_BACKGROUNDS)[number];

/** @deprecated Historical estimate only. Use getSpritePrice for new work. */
export function spriteCreditPrice(frameCount: number, background: SpriteBackground = "remove"): number {
if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100) throw new InputValidationError("frame_count must be an integer from 7 to 100");
const cents = background === "keep"
Expand Down Expand Up @@ -114,9 +114,12 @@ export function validateSpriteInput(input: ManagedExecuteInput): void {
if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000)) throw new InputValidationError("animation_prompt must contain 1–4000 characters");
if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode)) throw new InputValidationError("animation_mode must be loop or once");
if (options.background !== undefined && !SPRITE_BACKGROUNDS.includes(options.background)) throw new InputValidationError("background must be remove or keep");
const price = spriteCreditPrice(options.frame_count ?? 12, options.background ?? "remove");
const frames = options.frame_count ?? 12;
if (!Number.isInteger(frames) || frames < 7 || frames > 100) throw new InputValidationError("frame_count must be an integer from 7 to 100");
if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size)) throw new InputValidationError("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 InputValidationError(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
// Validate the caller's ceiling, not a cached tariff. Replays retain their
// original charge; fresh admission is priced and bounded by the server.
if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits <= 0 || input.max_credits > 100) throw new InputValidationError("Supply an explicit max_credits limit greater than zero and at most100. Read the account sprite estimate before new work.");
}

export type ManagedInputAsset = {
Expand Down Expand Up @@ -315,7 +318,7 @@ export type ManagedBalance = {
promotional: number;
purchased: number;
available: number;
credit_usd: "0.17";
credit_usd: "0.10" | "0.15" | "0.17" | "0.25";
};

/**
Expand Down Expand Up @@ -465,24 +468,29 @@ export function managedBalance(value: unknown): ManagedBalance {
if (Object.keys(value).sort().join("\0") !== exact.join("\0")) {
throw new Error("Invalid DreamLayer balance response");
}
if (typeof value.credit_usd !== "string" || !["0.10", "0.15", "0.17", "0.25"].includes(value.credit_usd)) throw new Error("Invalid DreamLayer balance response");
const scale = value.credit_usd === "0.10" || value.credit_usd === "0.15" ? 10000 : 10;
for (const field of ["promotional", "purchased", "available"] as const) {
if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) {
if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / scale) {
throw new Error("Invalid DreamLayer balance response");
}
}
if (
value.credit_usd !== "0.17" ||
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
Math.round(Number(value.available) * scale) < Math.round(Number(value.promotional) * scale) + Math.round(Number(value.purchased) * scale) ||
Math.round(Number(value.available) * scale) > Math.round(Number(value.promotional) * scale) + Math.round(Number(value.purchased) * scale) + 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");
const places = scale === 10000 ? 4 : 1;
if ([value.promotional, value.purchased, value.available].some(v =>
!Number.isSafeInteger(Math.round(Number(v) * scale)) ||
Number(Number(v).toFixed(places)) !== v
)) throw new Error("Invalid DreamLayer balance response");
return {
promotional: Number(value.promotional),
purchased: Number(value.purchased),
available: Number(value.available),
credit_usd: "0.17",
credit_usd: value.credit_usd as ManagedBalance["credit_usd"],
};
}

Expand Down Expand Up @@ -827,7 +835,7 @@ async quoteVideo(input: Record<string, unknown>): Promise<Record<string, unknown
}

async getCapabilities(): Promise<Record<string, unknown>> {
this.capabilitiesPromise ??= this.request<Record<string, unknown>>("/v1/capabilities").catch(
this.capabilitiesPromise ??= this.request<Record<string, unknown>>("/v1/capabilities?pricing=current").catch(
(error: unknown) => {
// Cache a successful contract for the process, but never pin a transient
// capabilities failure as a permanent result.
Expand All @@ -847,7 +855,24 @@ async quoteVideo(input: Record<string, unknown>): Promise<Record<string, unknown
}

async getBalance(): Promise<ManagedBalance> {
return managedBalance(await this.request<unknown>("/v1/balance"));
return managedBalance(await this.request<unknown>("/v1/balance?pricing=current"));
}

/** Free, account-specific estimate. Never a reservation or spending approval. */
async getSpritePrice(frameCount = 12, background: SpriteBackground = "remove"): Promise<Record<string, unknown> & { credits: string }> {
if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100 || !SPRITE_BACKGROUNDS.includes(background)) throw new InputValidationError("Invalid sprite estimate options");
const value = await this.request<unknown>(`/v1/sprite-pricing?frame_count=${frameCount}&background=${background}`);
const keys = ["background", "binding", "credits", "frame_count", "price_version", "reference_credit_cents", "reference_usd"];
if (!isRecord(value) || Object.keys(value).sort().join("\0") !== keys.join("\0")
|| value.price_version !== "sprite-base-frame-20261017-v1"
|| value.frame_count !== frameCount || value.background !== background || value.binding !== false
|| ![10, 15].includes(Number(value.reference_credit_cents)) || typeof value.reference_credit_cents !== "number"
|| typeof value.credits !== "string" || !/^\d+(?:\.\d{1,4})?$/.test(value.credits)
|| Number(value.credits) <= 0 || Number(value.credits) > 100
|| typeof value.reference_usd !== "string" || !/^\d+\.\d{2}$/.test(value.reference_usd)) {
throw new ResponseContractError("Invalid DreamLayer sprite estimate response");
}
return value as Record<string, unknown> & { credits: string };
}

cancel(executionId: string): Promise<ManagedExecution> {
Expand Down
Loading
Loading