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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ 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. Send `background: "keep"` in `options` when transparency is not needed: the frames ship with the generated background in place, plain rather than cut out, and cost 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.0 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.

Call `dreamlayer_upload_image`, then `dreamlayer_generate` with `operation: "sprite_sheet"`, the uploaded `input_asset_id`, `options: {action: "walk", frame_count: 12}`, and `max_credits` set to your approved limit.

Expand Down
32 changes: 27 additions & 5 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,50 @@ 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 ResponseContractError 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 @@ -696,7 +718,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
9 changes: 5 additions & 4 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export const TOOL_DEFINITIONS = [
{
name: "dreamlayer_capabilities",
description:
"Use before choosing an image operation or quoting a sprite job. Read supported operations, input limits and sprite_pricing for this API key. Returns the current API contract; no image input, generation or credits required. Do not use this to create an asset.",
"Use before choosing an image operation or quoting a sprite job. Read supported operations, input limits and sprite_pricing for this API key, including plain_frame_cents for sprites that keep their background. Returns the current API contract; no image input, generation or credits required. Do not use this to create an asset.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
},
{
Expand All @@ -365,7 +365,7 @@ export const TOOL_DEFINITIONS = [
{
name: "dreamlayer_generate",
description:
"Use when the user needs an original image, raster logo concept, product or marketing visual, an edit, transparent cutout, upscale, or reference-based sprite animation. Starts paid work within the user's authorized scope and budget. Text-to-image needs a prompt; other operations need input_asset_id. Select operation explicitly. Image operations cost one credit; sprite beta accepts 7–100 frames and returns a ZIP. Read sprite_pricing, round the whole quote upward once to 0.1 credit and set max_credits. Returns execution_id, status, asset, question and last_event_id. needs_input is a question; running work must be resumed, not replaced. No sprite cancellation; failed/expired holds are restored. Not a vector-logo, print-validation, product-fidelity or animation-quality guarantee.",
"Use when the user needs an original image, raster logo concept, product or marketing visual, an edit, transparent cutout, upscale, or reference-based sprite animation. Starts paid work within the user's authorized scope and budget. Text-to-image needs a prompt; other operations need input_asset_id. Select operation explicitly. Image operations cost one credit; sprite beta accepts 7–100 frames and returns a ZIP, transparent unless options.background is keep. Read sprite_pricing, quote a transparent sheet from first_tier_cents and additional_frame_cents and a kept-background sheet from the flat plain_frame_cents, round the whole quote upward once to 0.1 credit and set max_credits. Returns execution_id, status, asset, question and last_event_id. needs_input is a question; running work must be resumed, not replaced. No sprite cancellation; failed/expired holds are restored. Not a vector-logo, print-validation, product-fidelity or animation-quality guarantee.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -388,8 +388,9 @@ export const TOOL_DEFINITIONS = [
action: { type: "string", enum: ["walk", "run", "idle"], description: "Legacy motion preset. Use either action or animation_prompt, not both." },
animation_prompt: { type: "string", minLength: 1, maxLength: 4000, description: "The motion to animate, in plain language (1–4000 characters), e.g. a jump or a turntable." },
animation_mode: { type: "string", enum: ["loop", "once"], description: "loop for a seamless cycle, once for a single pass. Defaults to loop for presets and once for custom prompts." },
frame_count: { type: "integer", minimum: 7, maximum: 100, default: 12, description: "Number of frames in the sheet (7–100). The sprite price depends only on this value." },
frame_size: { type: "integer", enum: [32, 64, 128, 256, 512, 720, 1080], default: 512, description: "Square export canvas, not source detail. Larger exports may be enlarged. Pricing depends only on frame count." },
frame_count: { type: "integer", minimum: 7, maximum: 100, default: 12, description: "Number of frames in the sheet (7–100). With frame count and background, this is the whole sprite price." },
frame_size: { type: "integer", enum: [32, 64, 128, 256, 512, 720, 1080], default: 512, description: "Square export canvas, not source detail. Larger exports may be enlarged. Frame size never changes the price." },
background: { type: "string", enum: ["remove", "keep"], default: "remove", description: "remove cuts every frame out for transparent frames and is the default. keep leaves the generated background in place: the frames are plain, not transparent, and every frame costs the flat plain_frame_cents rate from sprite_pricing, about half the transparent price. Choose keep only when the user does not need transparency." },
},
oneOf: [{ required: ["action"] }, { required: ["animation_prompt"] }],
additionalProperties: false,
Expand Down
49 changes: 49 additions & 0 deletions test/sprite.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,52 @@ for(const [count,credits] of [[7,5.8],[14,11.6],[15,12],[99,46.6],[100,47],[6,0]
}finally{server.closeAllConnections();await new Promise(r=>server.close(r));}
});
}

// --- background: keep --------------------------------------------------------
// The sheet ships plain instead of transparent, at the flat plain_frame_cents rate.
import {spriteCreditPrice,wireExecuteInput} from '../dist/client.js';

for(const [count,transparent,plain] of [[7,5.8,2.9],[12,9.9,5.0],[14,11.6,5.8],[15,12,6.2],[100,47,41.2]]){
test(`MCP quotes ${count} frames at ${transparent} transparent and ${plain} kept`,()=>{
assert.equal(spriteCreditPrice(count),transparent);
assert.equal(spriteCreditPrice(count,'remove'),transparent);
assert.equal(spriteCreditPrice(count,'keep'),plain);
});
}

test('a kept-background sprite dispatches at the plain cap a transparent one would refuse',async()=>{
const received=[];
const server=http.createServer((req,res)=>{let raw='';req.on('data',c=>raw+=c);req.on('end',()=>{
received.push(JSON.parse(raw));res.writeHead(200,{'Content-Type':'text/event-stream'});
res.end(`id: 1\nevent: started\ndata: ${JSON.stringify({execution_id:eid,conversation_id:eid})}\n\nid: 2\nevent: done\ndata: {"status":"completed"}\n\n`);
});});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
try{
const client=new ManagedClient('dlr_live_fixture',`http://127.0.0.1:${server.address().port}`);
const transparent=await callTool(client,'dreamlayer_generate',{operation:'sprite_sheet',input_asset_id:eid,options:{action:'walk',frame_count:12},max_credits:5});
assert.ok(transparent.isError);assert.match(transparent.content[0].text,/9\.9 credits/);assert.equal(received.length,0);
const kept=await callTool(client,'dreamlayer_generate',{operation:'sprite_sheet',input_asset_id:eid,options:{action:'walk',frame_count:12,background:'keep'},max_credits:5});
assert.ok(!kept.isError,JSON.stringify(kept));
assert.equal(received.length,1);assert.equal(received[0].options.background,'keep');assert.equal(received[0].max_credits,5);
}finally{server.closeAllConnections();await new Promise(r=>server.close(r));}
});

test('a spelled-out remove reaches the wire as the same job an omitted one does',()=>{
const omitted={operation:'sprite_sheet',input_asset_id:eid,options:{action:'walk',frame_count:12},max_credits:9.9};
const spelled={...omitted,options:{...omitted.options,background:'remove'}};
assert.deepEqual(wireExecuteInput(spelled),omitted);
assert.deepEqual(JSON.stringify(wireExecuteInput(spelled)),JSON.stringify(omitted));
const kept={...omitted,options:{...omitted.options,background:'keep'},max_credits:5};
assert.equal(wireExecuteInput(kept).options.background,'keep');
});

test('an unknown background is refused before any dispatch',async()=>{
let submissions=0;
const server=http.createServer((req,res)=>{submissions++;res.writeHead(200,{'Content-Type':'text/event-stream'});res.end('event: done\ndata: {"status":"completed"}\n\n');});
await new Promise(r=>server.listen(0,'127.0.0.1',r));
try{
const client=new ManagedClient('dlr_live_fixture',`http://127.0.0.1:${server.address().port}`);
const result=await callTool(client,'dreamlayer_generate',{operation:'sprite_sheet',input_asset_id:eid,options:{action:'walk',frame_count:12,background:'transparent'},max_credits:9.9});
assert.ok(result.isError);assert.match(result.content[0].text,/background must be remove or keep/);assert.equal(submissions,0);
}finally{server.closeAllConnections();await new Promise(r=>server.close(r));}
});
Loading