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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ 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. 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 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.

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.
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.

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

Expand Down
17 changes: 13 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spriteCreditPrice } from "./client.js";
import { spriteCreditPrice, type SpriteBackground } from "./client.js";
/**
* DreamLayer CLI.
*
Expand Down Expand Up @@ -46,7 +46,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 sprite <image> --action <walk|run|idle> [--frames <7–100>] [--background <remove|keep>] --max-credits <n> [--out <zip>]
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
dreamlayer download <execution-id> --out <file>
dreamlayer status <execution-id>
Expand All @@ -63,6 +63,8 @@ OPTIONS
--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
--background <remove|keep> remove cuts every frame out (default). keep leaves
the background in place: plain frames, about half the price
--max-credits <n> Maximum approved charge for the sprite job
--json JSON results on stdout; JSON errors on stderr
--quiet No progress on stderr
Expand Down Expand Up @@ -91,6 +93,7 @@ type Options = {
frameSize?: 32 | 64 | 128 | 256 | 512 | 720 | 1080;
maxCredits: number;
frameCount: number;
background?: SpriteBackground;
out: string | null;
image: string | null;
aspect: string;
Expand Down Expand Up @@ -175,6 +178,10 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options
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 === "--background") {
const value = argv[++i];
if (value !== "remove" && value !== "keep") throw new UsageError("--background must be remove or keep");
options.background = value;
} 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");
Expand Down Expand Up @@ -400,9 +407,11 @@ async function main(argv: string[]): Promise<number> {
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);
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);
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);
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);
}
case "generate": {
const prompt = positional[0];
Expand Down
32 changes: 27 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,27 +71,49 @@ 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 };
options?: { action?: "walk" | "run" | "idle"; animation_prompt?: string; animation_mode?: "loop" | "once"; frame_count?: number; frame_size?: 32 | 64 | 128 | 256 | 512 | 720 | 1080; background?: SpriteBackground };
max_credits?: number;
};

export class InputValidationError extends Error {}
export class RecoveryRequiredError extends Error {}

export function spriteCreditPrice(frameCount: number): number {
/**
* 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.
*/
export const SPRITE_BACKGROUNDS = ["remove", "keep"] as const;
export type SpriteBackground = (typeof SPRITE_BACKGROUNDS)[number];

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 = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
const cents = background === "keep"
? 7 * frameCount
: 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
return Math.ceil(cents * 10 / 17) / 10;
}

/**
* "remove" is the default the server applies to an absent field, so sending it spells out
* a request that is byte-different from the identical omitted one. The server fingerprints
* the options it receives, so passing it through would split one job into two idempotency
* identities; normalising here keeps a spelled-out default and an omitted one the same job.
*/
export function wireExecuteInput(input: ManagedExecuteInput): ManagedExecuteInput {
if (input.options?.background !== "remove") return input;
const { background: _dropped, ...options } = input.options;
return { ...input, options };
}

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 InputValidationError("Sprite requests require exactly one of options.action or options.animation_prompt");
if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action)) throw new InputValidationError("Invalid sprite preset");
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");
const price = spriteCreditPrice(options.frame_count ?? 12);
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");
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.`);
}
Expand Down Expand Up @@ -681,7 +703,7 @@ export class ManagedClient {
"Content-Type": "application/json",
"Idempotency-Key": options.idempotencyKey,
},
body: JSON.stringify(input),
body: JSON.stringify(wireExecuteInput(input)),
});
yield* this.parse(stream);
}
Expand Down
65 changes: 65 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1214,3 +1214,68 @@ test('help after positional arguments requires no key or network', async () => {
const result = await runCli(['generate', 'a tree', '--help'], {DREAMLAYER_API_KEY:'', DREAMLAYER_API_URL:'http://127.0.0.1:1'});
assert.equal(result.code,0); assert.match(result.stdout,/USAGE/);
});

// --- sprite sheets that keep their background --------------------------------
// The server advertises the flat rate as sprite_pricing.plain_frame_cents. A CLI
// that quoted the transparent tier for a plain sheet would ask for roughly twice
// the credits the job actually needs.
const PLAIN_CAPS = {api_version:'1',operations:['sprite_sheet'],sprite_pricing:{minimum_frames:7,maximum_frames:100,plain_frame_cents:7}};
const plainApi = () => fakeApi({capabilities:PLAIN_CAPS,events:[started,{event:'asset',data:{asset_id:'44444444-4444-4444-8444-444444444444',download_url:'ASSET'}},{event:'done',data:{status:'completed'}}]});

test('sprite CLI sends a kept background at the flat rate a transparent sheet would refuse', async()=>{
const api=await listen(plainApi());
const dir=await mkdtemp(path.join(tmpdir(),'sprite-keep-'));
const input=path.join(dir,'reference.png');await writeFile(input,PNG);
try {
const refused=await runCli(['sprite',input,'--frames','12','--max-credits','5','--out',path.join(dir,'transparent.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url});
assert.notEqual(refused.code,0);assert.match(refused.stderr,/9\.9 credits/);
assert.equal(api.calls.filter(c=>c.url==='/v1/execute').length,0,'a refused quote must not submit');
const result=await runCli(['sprite',input,'--frames','12','--background','keep','--max-credits','5','--out',path.join(dir,'plain.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url});
assert.equal(result.code,0,result.stderr);
const body=api.calls.find(c=>c.url==='/v1/execute').body;
assert.equal(body.options.background,'keep');assert.equal(body.options.frame_count,12);assert.equal(body.max_credits,5);
} finally {api.close();}
});

test('sprite CLI refuses 4.9 credits for twelve plain frames', async()=>{
const api=await listen(plainApi());
const dir=await mkdtemp(path.join(tmpdir(),'sprite-keep-cap-'));
const input=path.join(dir,'reference.png');await writeFile(input,PNG);
try {
const result=await runCli(['sprite',input,'--frames','12','--background','keep','--max-credits','4.9','--out',path.join(dir,'plain.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url});
assert.notEqual(result.code,0);assert.match(result.stderr,/require 5 credits/);
assert.equal(api.calls.filter(c=>c.url==='/v1/execute').length,0);
} finally {api.close();}
});

test('a spelled-out remove is the same job on the wire as an omitted one', async()=>{
const api=await listen(plainApi());
const dir=await mkdtemp(path.join(tmpdir(),'sprite-remove-'));
const input=path.join(dir,'reference.png');await writeFile(input,PNG);
try {
const result=await runCli(['sprite',input,'--frames','12','--background','remove','--max-credits','9.9','--out',path.join(dir,'sheet.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url});
assert.equal(result.code,0,result.stderr);
const body=api.calls.find(c=>c.url==='/v1/execute').body;
assert.ok(!('background' in body.options),`background must not reach the wire: ${JSON.stringify(body.options)}`);
} finally {api.close();}
});

test('a server without the flat rate refuses a kept background before uploading', async()=>{
const api=await listen(fakeApi({capabilities:{api_version:'1',operations:['sprite_sheet'],sprite_pricing:{minimum_frames:7,maximum_frames:100}},events:[]}));
const dir=await mkdtemp(path.join(tmpdir(),'sprite-old-'));
const input=path.join(dir,'reference.png');await writeFile(input,PNG);
try {
const result=await runCli(['sprite',input,'--frames','12','--background','keep','--max-credits','5','--out',path.join(dir,'plain.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url});
assert.notEqual(result.code,0);assert.match(result.stderr,/keep their background/);
assert.equal(api.calls.filter(c=>c.url==='/v1/execute').length,0);
assert.equal(api.calls.filter(c=>c.url.startsWith('/v1/input-assets')).length,0,'nothing may be uploaded');
} finally {api.close();}
});

test('an unknown background is rejected before any network call', async()=>{
const api=await listen(fakeApi({events:[]}));
try {
const result=await runCli(['sprite','missing.png','--background','transparent','--max-credits','100'],{DREAMLAYER_API_URL:api.url});
assert.notEqual(result.code,0);assert.match(result.stderr,/--background must be remove or keep/);assert.equal(api.calls.length,0);
} finally {api.close();}
});
Loading