diff --git a/README.md b/README.md index e933945..5c3ae63 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/cli.ts b/src/cli.ts index 0c74666..6a54e04 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spriteCreditPrice } from "./client.js"; +import { spriteCreditPrice, type SpriteBackground } from "./client.js"; /** * DreamLayer CLI. * @@ -46,7 +46,7 @@ USAGE dreamlayer edit [--out ] dreamlayer cutout [--out ] dreamlayer upscale [--out ] - dreamlayer sprite --action [--frames <7–100>] --max-credits [--out ] + dreamlayer sprite --action [--frames <7–100>] [--background ] --max-credits [--out ] dreamlayer answer [--image ] [--out ] dreamlayer download --out dreamlayer status @@ -63,6 +63,8 @@ OPTIONS --animation-mode Default: loop for presets, once for custom --frame-size Square export: 32, 64, 128, 256, 512 (default), 720, 1080 --frames Frame count: integer 7–100, default 12 + --background remove cuts every frame out (default). keep leaves + the background in place: plain frames, about half the price --max-credits Maximum approved charge for the sprite job --json JSON results on stdout; JSON errors on stderr --quiet No progress on stderr @@ -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; @@ -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"); @@ -400,9 +407,11 @@ async function main(argv: string[]): Promise { 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]; diff --git a/src/client.ts b/src/client.ts index 1752cbe..19ae58c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -71,19 +71,40 @@ 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; @@ -91,7 +112,8 @@ export function validateSpriteInput(input: ManagedExecuteInput): void { 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.`); } @@ -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); } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 9c8123b..0f94883 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -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();} +});