From 23ab835ae6c33a0cb8e5ef7bf9ff3a7bd68ff047 Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Fri, 11 Sep 2026 20:07:50 -0700 Subject: [PATCH 1/4] Add sprite bundles and resumable execution to the CLI --- README.md | 12 ++++++++ package.json | 2 +- src/cli.ts | 39 +++++++++++++++++++++++--- src/client.ts | 40 ++++++++++++++++++++++++++ test/packaged-install.test.mjs | 4 +-- test/sprite.test.mjs | 51 ++++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 test/sprite.test.mjs diff --git a/README.md b/README.md index 6177ccd..0047f7d 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,15 @@ Node.js 22.12 or later. ## License MIT. See LICENSE and NOTICE. + +## Sprite-sheet beta + +Eligible accounts can create walk, run, or idle sprite bundles. Check `sprite_sheet` in capabilities and the returned `sprite_sheet_credits` price before starting. A bundle includes twelve transparent frame PNGs, a 2048 by 1536 sheet, atlas, preview, and import instructions. Jobs may take several minutes. Keep the execution ID to resume status or cancel. + +```sh +dreamlayer sprite character.png --action walk --max-credits 20 --out walk.zip +dreamlayer status EXECUTION_ID +dreamlayer cancel 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. diff --git a/package.json b/package.json index e476bf1..eb6e546 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index bcfe6e3..34ea233 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,8 @@ USAGE dreamlayer edit [--out ] dreamlayer cutout [--out ] dreamlayer upscale [--out ] + dreamlayer sprite --action --max-credits [--out ] + dreamlayer cancel dreamlayer answer [--image ] [--out ] dreamlayer status dreamlayer balance @@ -52,6 +54,8 @@ OPTIONS --out Where to write the image. Default: dreamlayer-.png --image Attach an image when answering a question that asks for one --aspect 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1 + --action Sprite animation: walk, run, idle + --max-credits Maximum approved charge for the sprite job --json Machine-readable output on stdout --quiet No progress on stderr --idempotency-key Reuse to retry safely after an uncertain response @@ -60,10 +64,12 @@ 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"; + maxCredits: number; out: string | null; image: string | null; aspect: string; @@ -77,6 +83,8 @@ class UsageError extends Error {} function parseOptions(argv: string[]): { positional: string[]; options: Options } { const positional: string[] = []; const options: Options = { + action: "walk", + maxCredits: 1, out: null, image: null, aspect: "1:1", @@ -92,6 +100,14 @@ 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 === "--max-credits") { + const value = Number(argv[++i]); + if (!Number.isInteger(value) || value < 1 || value > 100) throw new UsageError("--max-credits must be 1 to 100"); + options.maxCredits = value; } else if (token === "--image") { const value = argv[++i]; if (!value) throw new UsageError("--image needs a file path"); @@ -156,7 +172,7 @@ async function run( ): Promise { 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(); @@ -188,7 +204,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(); @@ -255,7 +271,7 @@ function warnIfOperationsDrifted(capabilities: unknown): void { const server = new Set(listed as string[]); const mine = new Set(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"); @@ -293,6 +309,21 @@ async function main(argv: string[]): Promise { const { positional, options } = parseOptions(rest); switch (command) { + case "sprite": { + 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"); + const price = Number(caps.sprite_sheet_credits); + if (!Number.isInteger(price) || 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: { action: options.action }, max_credits: options.maxCredits }, options); + } + case "cancel": { + if (!positional[0]) throw new UsageError("cancel needs an execution id"); + process.stdout.write(`${JSON.stringify(await client().cancel(positional[0]), null, 2)}\n`); + return 0; + } case "generate": { const prompt = positional[0]; if (!prompt) throw new UsageError("generate needs a prompt"); diff --git a/src/client.ts b/src/client.ts index 373c22b..b638f70 100644 --- a/src/client.ts +++ b/src/client.ts @@ -49,6 +49,7 @@ export const KNOWN_OPERATIONS = [ "image_to_image", "background_remove", "upscale", + "sprite_sheet", ] as const; /** @@ -70,6 +71,8 @@ export type ManagedExecuteInput = { aspect_ratio?: string; /** Requires the gateway build that added it. See ManagedOperation. */ operation?: ManagedOperation; + options?: { action: "walk" | "run" | "idle" }; + max_credits?: number; }; export type ManagedInputAsset = { @@ -655,6 +658,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 { + 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 { const headers: Record = { Accept: "text/event-stream" }; diff --git a/test/packaged-install.test.mjs b/test/packaged-install.test.mjs index 7576c80..f66fd2f 100644 --- a/test/packaged-install.test.mjs +++ b/test/packaged-install.test.mjs @@ -25,7 +25,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: assert.equal(packed.status, 0, packed.stderr); const metadata = JSON.parse(packed.stdout)[0]; assert.equal(metadata.name, "dreamlayer"); - assert.equal(metadata.version, "0.3.0"); + assert.equal(metadata.version, "0.4.0-beta.1"); assert.ok(metadata.integrity.startsWith("sha512-")); assert.deepEqual( metadata.files.map(({ path: file }) => file).sort(), @@ -57,7 +57,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout: const packageJson = JSON.parse( await readFile(path.join(installRoot, "node_modules", "dreamlayer", "package.json"), "utf8"), ); - assert.equal(packageJson.version, "0.3.0"); + assert.equal(packageJson.version, "0.4.0-beta.1"); assert.equal(packageJson.bin.dreamlayer, "./dist/cli.js"); const env = { ...process.env, DREAMLAYER_API_KEY: "" }; diff --git a/test/sprite.test.mjs b/test/sprite.test.mjs new file mode 100644 index 0000000..c6e1b3d --- /dev/null +++ b/test/sprite.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { ManagedClient } from '../dist/client.js'; +const eid='22222222-2222-4222-8222-222222222222'; +const cid='33333333-3333-4333-8333-333333333333'; +const event=(id,name,data)=>`id: ${id}\nevent: ${name}\ndata: ${JSON.stringify(data)}\n\n`; +test('sprite follows a finite stream using the cursor without a second submit', async()=>{ + let submitted=0, resumed=0; + const server=http.createServer((req,res)=>{ + if(req.url==='/v1/execute'){ + submitted++;res.writeHead(200,{'Content-Type':'text/event-stream'}); + res.end(event(1,'started',{execution_id:eid,conversation_id:cid})+event(2,'progress',{text:'Creating the animation.'})); + }else if(req.url.endsWith('/events')){ + resumed++;assert.equal(req.headers['last-event-id'],'2'); + res.writeHead(200,{'Content-Type':'text/event-stream'}); + res.end(event(3,'asset',{asset_id:eid,download_url:`http://127.0.0.1:${server.address().port}/asset`})+event(4,'done',{status:'completed'})); + }else{res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({execution_id:eid,conversation_id:cid,status:'running'}));} + }); + await new Promise(r=>server.listen(0,'127.0.0.1',r)); + try{ + const api=new ManagedClient('dlr_live_fixture',`http://127.0.0.1:${server.address().port}`); + const events=[]; + for await(const e of api.follow({operation:'sprite_sheet',input_asset_id:eid,options:{action:'walk'},max_credits:20},{idempotencyKey:'sprite'}))events.push(e); + assert.equal(events.at(-1).data.status,'completed');assert.equal(submitted,1);assert.equal(resumed,1); + }finally{server.closeAllConnections();await new Promise(r=>server.close(r));} +}); + +test('six minute sprite execution reconnects twice and keeps one charge', async()=>{ + let submitted=0, resumed=0, elapsed=0; + const realNow=Date.now; + const start=realNow(); + Date.now=()=>start+elapsed; + const server=http.createServer((req,res)=>{ + if(req.url==='/v1/execute'){ + submitted++;elapsed=120_000;res.writeHead(200,{'Content-Type':'text/event-stream'}); + res.end(event(1,'started',{execution_id:eid,conversation_id:cid})); + }else if(req.url.endsWith('/events')){ + resumed++;assert.equal(req.headers['last-event-id'],String(resumed));elapsed+=120_000; + res.writeHead(200,{'Content-Type':'text/event-stream'}); + res.end(resumed===1?event(2,'progress',{text:'Preparing your bundle.'}):event(3,'asset',{asset_id:eid,download_url:`http://127.0.0.1:${server.address().port}/asset`})+event(4,'done',{status:'completed'})); + }else{res.writeHead(200,{'Content-Type':'application/json'});res.end(JSON.stringify({execution_id:eid,conversation_id:cid,status:'running',credits_used:20}));} + }); + await new Promise(r=>server.listen(0,'127.0.0.1',r)); + try{ + const api=new ManagedClient('dlr_live_fixture',`http://127.0.0.1:${server.address().port}`); + const events=[]; + for await(const e of api.follow({operation:'sprite_sheet',input_asset_id:eid,options:{action:'run'},max_credits:20},{idempotencyKey:'six-minute'}))events.push(e); + assert.equal(events.at(-1).data.status,'completed');assert.equal(submitted,1);assert.equal(resumed,2);assert.equal(elapsed,360_000); + }finally{Date.now=realNow;server.closeAllConnections();await new Promise(r=>server.close(r));} +}); From 39f0b557601c02fbe2fab91d00e3a5e7f904ec03 Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Sat, 12 Sep 2026 11:52:23 -0700 Subject: [PATCH 2/4] Support configurable sprite frames and fractional credit pricing --- README.md | 5 ++--- src/cli.ts | 25 ++++++++++++++----------- src/client.ts | 26 +++++++++++++++++++++++--- test/cli.test.mjs | 22 ++++++++++++++++++++++ test/sprite.test.mjs | 8 ++++++++ 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0047f7d..21658b2 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,11 @@ MIT. See LICENSE and NOTICE. ## Sprite-sheet beta -Eligible accounts can create walk, run, or idle sprite bundles. Check `sprite_sheet` in capabilities and the returned `sprite_sheet_credits` price before starting. A bundle includes twelve transparent frame PNGs, a 2048 by 1536 sheet, atlas, preview, and import instructions. Jobs may take several minutes. Keep the execution ID to resume status or cancel. +Eligible accounts can create walk, run, or idle sprite bundles. Request an integer `frame_count` from 7 to 100 (default 12). Frames 1–14 cost $0.14 each; additional frames cost $0.07 each. One credit is $0.17. The total request charge rounds up to one decimal credit; displayed balances round down without changing stored funds. Check `sprite_pricing` in capabilities and approve the total with `max_credits`. A bundle includes transparent frames, sheet, atlas, preview, and import instructions. Large requests may contain a sequence rather than one seamless loop; the atlas identifies the sampling mode. Insufficient distinct frames fail without padding or interpolation. Jobs may take several minutes. Hold credits at admission, charge after complete delivery, and restore the hold if generation fails or times out. Sprite requests have no customer cancellation. Keep the execution ID to resume status. ```sh -dreamlayer sprite character.png --action walk --max-credits 20 --out walk.zip +dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip dreamlayer status EXECUTION_ID -dreamlayer cancel 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. diff --git a/src/cli.ts b/src/cli.ts index 34ea233..4d0b93e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { spriteCreditPrice } from "./client.js"; /** * DreamLayer CLI. * @@ -43,8 +44,7 @@ USAGE dreamlayer edit [--out ] dreamlayer cutout [--out ] dreamlayer upscale [--out ] - dreamlayer sprite --action --max-credits [--out ] - dreamlayer cancel + dreamlayer sprite --action [--frames <7–100>] --max-credits [--out ] dreamlayer answer [--image ] [--out ] dreamlayer status dreamlayer balance @@ -55,6 +55,7 @@ OPTIONS --image Attach an image when answering a question that asks for one --aspect 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1 --action Sprite animation: walk, run, idle + --frames Frame count: integer 7–100, default 12 --max-credits Maximum approved charge for the sprite job --json Machine-readable output on stdout --quiet No progress on stderr @@ -70,6 +71,7 @@ Image operations cost one credit. Sprite pricing is listed in capabilities. A ne type Options = { action: "walk" | "run" | "idle"; maxCredits: number; + frameCount: number; out: string | null; image: string | null; aspect: string; @@ -85,6 +87,7 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options const options: Options = { action: "walk", maxCredits: 1, + frameCount: 12, out: null, image: null, aspect: "1:1", @@ -104,9 +107,13 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options 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 === "--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.isInteger(value) || value < 1 || value > 100) throw new UsageError("--max-credits must be 1 to 100"); + 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]; @@ -315,14 +322,10 @@ async function main(argv: string[]): Promise { 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"); - const price = Number(caps.sprite_sheet_credits); - if (!Number.isInteger(price) || 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: { action: options.action }, max_credits: options.maxCredits }, options); - } - case "cancel": { - if (!positional[0]) throw new UsageError("cancel needs an execution id"); - process.stdout.write(`${JSON.stringify(await client().cancel(positional[0]), null, 2)}\n`); - return 0; + 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: { action: options.action, 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 b638f70..410f4a9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -71,10 +71,23 @@ export type ManagedExecuteInput = { aspect_ratio?: string; /** Requires the gateway build that added it. See ManagedOperation. */ operation?: ManagedOperation; - options?: { action: "walk" | "run" | "idle" }; + options?: { action: "walk" | "run" | "idle"; frame_count?: number }; 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; + if (!input.options || !["walk", "run", "idle"].includes(input.options.action)) throw new Error("Sprite requests require options.action"); + const price = spriteCreditPrice(input.options.frame_count ?? 12); + 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; @@ -120,6 +133,7 @@ export const PUBLIC_ERROR_REASONS = [ "content_refused", "temporarily_unavailable", "generation_failed", + "insufficient_frames", ] as const; export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number]; @@ -154,6 +168,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 }, }; @@ -191,6 +206,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]; } @@ -209,6 +225,7 @@ function statusForReason(reason: PublicErrorReason): number { content_refused: 422, temporarily_unavailable: 503, generation_failed: 500, + insufficient_frames: 422, }; return statuses[reason]; } @@ -417,16 +434,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), @@ -646,6 +665,7 @@ export class ManagedClient { input: ManagedExecuteInput, options: { idempotencyKey: string }, ): AsyncGenerator { + validateSpriteInput(input); const stream = await this.fetchStream("/v1/execute", { method: "POST", headers: { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index eeb374c..7cf962d 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -140,6 +140,8 @@ function fakeApi(behaviour) { "input_asset_id", "aspect_ratio", "operation", + "options", + "max_credits", ]; const extra = Object.keys(body).filter((key) => !allowed.includes(key)); if (extra.length > 0) { @@ -926,3 +928,23 @@ test("capabilities stays quiet when the lists agree", async () => { assert.equal(result.code, 0); assert.doesNotMatch(result.stderr, /disagree/, "no warning when there is nothing to warn about"); }); + +for (const [count, credits] of [[7,5.8],[14,11.6],[15,12],[99,46.6],[100,47]]) { + test(`sprite CLI passes ${count} frames and its fractional approved limit`, async()=>{ + const api=await listen(fakeApi({capabilities:{api_version:'1',operations:['sprite_sheet'],sprite_pricing:{minimum_frames:7,maximum_frames:100}},events:[started,{event:'asset',data:{asset_id:'44444444-4444-4444-8444-444444444444',download_url:'ASSET'}},{event:'done',data:{status:'completed'}}]})); + const dir=await mkdtemp(path.join(tmpdir(),'sprite-count-')); + const input=path.join(dir,'reference.png');await writeFile(input,PNG); + try { + const result=await runCli(['sprite',input,'--frames',String(count),'--max-credits',String(credits),'--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.equal(body.options.frame_count,count);assert.equal(body.max_credits,credits); + } finally {api.close();} + }); +} +for (const count of [6,101,7.5]) { + test(`sprite CLI rejects ${count} frames before a network call`,async()=>{ + const api=await listen(fakeApi({events:[]})); + try {const result=await runCli(['sprite','missing.png','--frames',String(count),'--max-credits','100'],{DREAMLAYER_API_URL:api.url});assert.notEqual(result.code,0);assert.match(result.stderr,/7 to 100/);assert.equal(api.calls.length,0);}finally{api.close();} + }); +} diff --git a/test/sprite.test.mjs b/test/sprite.test.mjs index c6e1b3d..67e1d5f 100644 --- a/test/sprite.test.mjs +++ b/test/sprite.test.mjs @@ -49,3 +49,11 @@ test('six minute sprite execution reconnects twice and keeps one charge', async( assert.equal(events.at(-1).data.status,'completed');assert.equal(submitted,1);assert.equal(resumed,2);assert.equal(elapsed,360_000); }finally{Date.now=realNow;server.closeAllConnections();await new Promise(r=>server.close(r));} }); + +test('fractional balance permits conservative display without increasing credits',async()=>{ + const {managedBalance,spriteCreditPrice}=await import('../dist/client.js'); + assert.equal(managedBalance({promotional:0.7,purchased:10,available:10.8,credit_usd:'0.17'}).available,10.8); + assert.throws(()=>managedBalance({promotional:0.7,purchased:10,available:10.9,credit_usd:'0.17'})); + assert.throws(()=>managedBalance({promotional:0.78,purchased:10,available:10.78,credit_usd:'0.17'})); + assert.equal(spriteCreditPrice(7),5.8);assert.equal(spriteCreditPrice(14),11.6);assert.equal(spriteCreditPrice(15),12);assert.equal(spriteCreditPrice(100),47); +}); From 9e4f19173370ef6e93b1acfd0fb43de5e7b5882d Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Sat, 12 Sep 2026 12:41:32 -0700 Subject: [PATCH 3/4] Explain available credit totals when funding balances are rounded --- README.md | 2 ++ src/cli.ts | 3 +++ test/cli.test.mjs | 15 +++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 21658b2..959244e 100644 --- a/README.md +++ b/README.md @@ -118,3 +118,5 @@ 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. diff --git a/src/cli.ts b/src/cli.ts index 4d0b93e..9ca1b24 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -388,6 +388,9 @@ async function main(argv: string[]): Promise { `(${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": { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 7cf962d..d21f644 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -948,3 +948,18 @@ for (const count of [6,101,7.5]) { try {const result=await runCli(['sprite','missing.png','--frames',String(count),'--max-credits','100'],{DREAMLAYER_API_URL:api.url});assert.notEqual(result.code,0);assert.match(result.stderr,/7 to 100/);assert.equal(api.calls.length,0);}finally{api.close();} }); } + + +test("fractional funding explains affordability without changing JSON balances", async () => { + const body = { promotional: 0, purchased: 5.7, available: 5.8, credit_usd: "0.17" }; + const api = await listen(fakeApi({ events: [], balanceBody: body })); + try { + const human = await runCli(["balance"], { DREAMLAYER_API_URL: api.url }); + assert.equal(human.code, 0, human.stderr); + assert.match(human.stdout, /5.8 credits available/); + assert.match(human.stdout, /Use the available total for affordability/); + const json = await runCli(["balance", "--json"], { DREAMLAYER_API_URL: api.url }); + assert.equal(json.code, 0, json.stderr); + assert.deepEqual(JSON.parse(json.stdout), body); + } finally { api.close(); } +}); From 0001dc16c35d3f6b85621fe241444c244db54d23 Mon Sep 17 00:00:00 2001 From: TheDesignFounder Date: Sun, 13 Sep 2026 19:07:38 -0700 Subject: [PATCH 4/4] feat: custom sprite prompts and selectable frame sizes (#4) * feat: accept custom sprite animations and selectable export sizes * docs: describe sprite output without internal processing terminology --- README.md | 6 +++++- src/cli.ts | 26 ++++++++++++++++++++++---- src/client.ts | 11 ++++++++--- test/cli.test.mjs | 18 ++++++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 959244e..1a5c097 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,11 @@ MIT. See LICENSE and NOTICE. ## Sprite-sheet beta -Eligible accounts can create walk, run, or idle sprite bundles. Request an integer `frame_count` from 7 to 100 (default 12). Frames 1–14 cost $0.14 each; additional frames cost $0.07 each. One credit is $0.17. The total request charge rounds up to one decimal credit; displayed balances round down without changing stored funds. Check `sprite_pricing` in capabilities and approve the total with `max_credits`. A bundle includes transparent frames, sheet, atlas, preview, and import instructions. Large requests may contain a sequence rather than one seamless loop; the atlas identifies the sampling mode. Insufficient distinct frames fail without padding or interpolation. Jobs may take several minutes. Hold credits at admission, charge after complete delivery, and restore the hold if generation fails or times out. Sprite requests have no customer cancellation. Keep the execution ID to resume status. +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 diff --git a/src/cli.ts b/src/cli.ts index 9ca1b24..616d452 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,10 @@ OPTIONS --out Where to write the image. Default: dreamlayer-.png --image Attach an image when answering a question that asks for one --aspect 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1 - --action Sprite animation: walk, run, idle + --action Sprite preset: walk, run, idle (walk if no custom prompt) + --animation-prompt Custom animation; cannot combine with --action + --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 --max-credits Maximum approved charge for the sprite job --json Machine-readable output on stdout @@ -69,7 +72,10 @@ Image operations cost one credit. Sprite pricing is listed in capabilities. A ne `; type Options = { - action: "walk" | "run" | "idle"; + action?: "walk" | "run" | "idle"; + animationPrompt?: string; + animationMode?: "loop" | "once"; + frameSize?: 32 | 64 | 128 | 256 | 512 | 720 | 1080; maxCredits: number; frameCount: number; out: string | null; @@ -85,7 +91,6 @@ class UsageError extends Error {} function parseOptions(argv: string[]): { positional: string[]; options: Options } { const positional: string[] = []; const options: Options = { - action: "walk", maxCredits: 1, frameCount: 12, out: null, @@ -107,6 +112,18 @@ function parseOptions(argv: string[]): { positional: string[]; options: Options 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"); @@ -317,6 +334,7 @@ async function main(argv: string[]): Promise { 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(); @@ -325,7 +343,7 @@ async function main(argv: string[]): Promise { 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: { action: options.action, 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 } : {}), 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 410f4a9..6c30966 100644 --- a/src/client.ts +++ b/src/client.ts @@ -71,7 +71,7 @@ export type ManagedExecuteInput = { aspect_ratio?: string; /** Requires the gateway build that added it. See ManagedOperation. */ operation?: ManagedOperation; - options?: { action: "walk" | "run" | "idle"; frame_count?: number }; + 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; }; @@ -83,8 +83,13 @@ export function spriteCreditPrice(frameCount: number): number { export function validateSpriteInput(input: ManagedExecuteInput): void { if (input.operation !== "sprite_sheet") return; - if (!input.options || !["walk", "run", "idle"].includes(input.options.action)) throw new Error("Sprite requests require options.action"); - const price = spriteCreditPrice(input.options.frame_count ?? 12); + 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.`); } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index d21f644..96a3a83 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -942,6 +942,24 @@ for (const [count, credits] of [[7,5.8],[14,11.6],[15,12],[99,46.6],[100,47]]) { } finally {api.close();} }); } +for (const size of [32,64,128,256,512,720,1080]) { + test(`custom sprite CLI forwards prompt, mode and ${size}px export`, async()=>{ + const api=await listen(fakeApi({capabilities:{api_version:'1',operations:['sprite_sheet'],sprite_pricing:{minimum_frames:7,maximum_frames:100}},events:[started,{event:'asset',data:{asset_id:'44444444-4444-4444-8444-444444444444',download_url:'ASSET'}},{event:'done',data:{status:'completed'}}]})); + const dir=await mkdtemp(path.join(tmpdir(),'sprite-custom-')); + const input=path.join(dir,'reference.png');await writeFile(input,PNG); + try { + const result=await runCli(['sprite',input,'--animation-prompt','Rotate this character 360 degrees','--animation-mode','loop','--frame-size',String(size),'--frames','7','--max-credits','5.8','--out',path.join(dir,'sheet.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url}); + assert.equal(result.code,0,result.stderr); + assert.deepEqual(api.calls.find(c=>c.url==='/v1/execute').body.options,{animation_prompt:'Rotate this character 360 degrees',animation_mode:'loop',frame_size:size,frame_count:7}); + } finally {api.close();} + }); +} +for (const args of [['--action','walk','--animation-prompt','spin'],['--frame-size','33'],['--animation-prompt',' '],['--animation-mode','maybe']]) { + test(`invalid sprite options rejected before upload: ${args}`,async()=>{ + const api=await listen(fakeApi({events:[]})); + try {const result=await runCli(['sprite','missing.png',...args,'--max-credits','100'],{DREAMLAYER_API_URL:api.url});assert.notEqual(result.code,0);assert.equal(api.calls.length,0);}finally{api.close();} + }); +} for (const count of [6,101,7.5]) { test(`sprite CLI rejects ${count} frames before a network call`,async()=>{ const api=await listen(fakeApi({events:[]}));