diff --git a/README.md b/README.md index da9e63b..b695718 100644 --- a/README.md +++ b/README.md @@ -184,19 +184,21 @@ MIT. See LICENSE and NOTICE. Sprite requests accept exactly one of `options.animation_prompt` (1–4000 characters) or an `options.action` preset (`walk`, `run`, `idle`). Custom prompts can describe characters, creatures, objects, effects or 360° turntables. `animation_mode` is `loop` or `once`; presets default to loop, custom prompts to once. For a turntable, request a stationary camera and rotating subject. Broad requests do not guarantee correct motion, unseen details or successful effect transparency. -Request integer `frame_count` 7–100 (default 12) and `frame_size` 32, 64, 128, 256, 512, 720 or 1080 (default 512). These are square export canvases; a larger export does not guarantee additional detail. Aspect ratio and shared alignment are preserved with transparent padding. A bundle contains transparent PNG frames, sheet, atlas, preview and import instructions, including each frame's playback duration. Pass `--background keep` when transparency is not needed: the frames keep the generated background, plain rather than cut out, at the flat plain rate. A frame that cannot be cut out is retried at nearby moments of the same clip, and a single stubborn frame ships with its background rather than failing the sheet; `atlas.json` lists any such frame in `kept_background_frames`. Choose a repeating loop or a one-time action with a beginning and ending. If the requested number of distinct frames cannot be delivered, the job fails and held credits are returned. Translucent effects can lose detail or fail; small exports are not automatically pixel art. +Request integer `frame_count` 7–100 (default 12) and `frame_size` 32, 64, 128, 256, 512, 720 or 1080 (default 512). These are square export canvases; a larger export does not guarantee additional detail. Aspect ratio and shared alignment are preserved with transparent padding. A bundle contains transparent PNG frames, sheet, atlas, preview and import instructions, including each frame's playback duration. Pass `--background keep` when transparency is not needed: the frames keep the generated background, plain rather than cut out, at the background-kept rate shown in the current server estimate. A frame that cannot be cut out is retried at nearby moments of the same clip, and a single stubborn frame ships with its background rather than failing the sheet; `atlas.json` lists any such frame in `kept_background_frames`. Choose a repeating loop or a one-time action with a beginning and ending. If the requested number of distinct frames cannot be delivered, the job fails and held credits are returned. Translucent effects can lose detail or fail; small exports are not automatically pixel art. -Pricing is unchanged across sizes: frames 1–14 cost $0.14 each; additional frames $0.07 each. With `--background keep` every frame is a flat $0.07 with no tier, roughly half a transparent sheet (12 frames: 5 credits instead of 9.9). One credit is $0.17. Round the complete order upward once to a tenth of a credit. Check `sprite_pricing` in capabilities and approve the quote with `max_credits`. Credits are held during processing, settled after complete delivery and restored on failure/timeout. There is no customer cancellation. Keep the execution ID to resume status. Custom requests need the matching broad-animation server release; older servers reject them. New live generation quality, 100-frame duration and actual cost remain unverified. +New transparent sprite sheets cost 4.2 API credits plus 0.2 per frame (12 frames: 6.6 credits). Kept-background sheets cost $0.32 plus $0.01 per frame, converted at the account's undiscounted purchase rate and rounded upward to four decimal credits. For a public account, 12 kept-background frames cost 2.9334 credits. Export size does not change price. Existing accepted jobs keep their original charges. Credits are held during processing, settled after complete ZIP delivery and restored on failure or timeout. There is no customer cancellation. Keep the execution ID to resume status. An estimate is not a reservation: the server checks the actual price and your approved ceiling at admission. + +The CLI reads the free authenticated `/v1/sprite-pricing` estimate before uploading or submitting a sprite. It never raises `--max-credits` for you and refuses if the estimate endpoint is unavailable. Recover accepted work with its saved execution ID using `status` and `download`. Rerunning the file-based `sprite` command uploads a new asset and checks today's estimate, so supplying the same idempotency key is not an exact request replay. Do not rerun it to recover an existing charge. Balance reads request `pricing=current` and preserve four decimal places. ```sh -dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip +dreamlayer sprite character.png --action walk --frames 12 --max-credits 6.6 --out walk.zip dreamlayer sprite character.png --action walk --frames 12 --background keep --max-credits 5 --out walk-plain.zip dreamlayer status EXECUTION_ID ``` Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes. -For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier. +For affordability, compare the complete rounded quote in **credits** with `available`. Current balances preserve four decimal places, and a credit's dollar equivalent depends on your account's purchase rate. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.0001 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier. ## Automating recovery diff --git a/src/cli.ts b/src/cli.ts index 5f98281..81ebecf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spriteCreditPrice, type SpriteBackground } from "./client.js"; +import { type SpriteBackground } from "./client.js"; /** * DreamLayer CLI. * @@ -416,8 +416,8 @@ async function main(argv: string[]): Promise { if (!Array.isArray(caps.operations) || !caps.operations.includes("sprite_sheet")) throw new UsageError("sprite beta access is not enabled for this account"); if (!caps.sprite_pricing) throw new UsageError("The server does not support configurable sprite pricing yet"); const background = options.background ?? "remove"; - if (background === "keep" && typeof caps.sprite_pricing === "object" && caps.sprite_pricing !== null && !("plain_frame_cents" in caps.sprite_pricing)) throw new UsageError("This server does not offer sprite sheets that keep their background yet"); - const price = spriteCreditPrice(options.frameCount, background); + const estimate = await api.getSpritePrice(options.frameCount, background); + const price = Number(estimate.credits); if (options.maxCredits < price) throw new UsageError(`Sprite jobs require ${price} credits. Set --max-credits to approve that amount.`); return run(api, { operation: "sprite_sheet", input_asset_id: await upload(api, file), options: { ...(options.animationPrompt ? { animation_prompt: options.animationPrompt } : { action: options.action ?? "walk" }), ...(options.animationMode ? { animation_mode: options.animationMode } : {}), ...(options.frameSize ? { frame_size: options.frameSize } : {}), ...(background === "keep" ? { background } : {}), frame_count: options.frameCount }, max_credits: options.maxCredits }, options); } diff --git a/src/client.ts b/src/client.ts index b6e7a52..62d9670 100644 --- a/src/client.ts +++ b/src/client.ts @@ -80,12 +80,12 @@ export class RecoveryRequiredError extends Error {} export class ResponseContractError extends Error {} /** - * Transparent frames are tiered; plain frames are a flat rate with no tier, because the - * tier exists to price background removal and a plain frame never buys any. + * Background intent is independent of the server-owned account price. */ export const SPRITE_BACKGROUNDS = ["remove", "keep"] as const; export type SpriteBackground = (typeof SPRITE_BACKGROUNDS)[number]; +/** @deprecated Historical estimate only. Use getSpritePrice for new work. */ export function spriteCreditPrice(frameCount: number, background: SpriteBackground = "remove"): number { if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100) throw new InputValidationError("frame_count must be an integer from 7 to 100"); const cents = background === "keep" @@ -114,9 +114,12 @@ export function validateSpriteInput(input: ManagedExecuteInput): void { if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000)) throw new InputValidationError("animation_prompt must contain 1–4000 characters"); if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode)) throw new InputValidationError("animation_mode must be loop or once"); if (options.background !== undefined && !SPRITE_BACKGROUNDS.includes(options.background)) throw new InputValidationError("background must be remove or keep"); - const price = spriteCreditPrice(options.frame_count ?? 12, options.background ?? "remove"); + const frames = options.frame_count ?? 12; + if (!Number.isInteger(frames) || frames < 7 || frames > 100) throw new InputValidationError("frame_count must be an integer from 7 to 100"); if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size)) throw new InputValidationError("frame_size must be 32, 64, 128, 256, 512, 720 or 1080"); - if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100) throw new InputValidationError(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`); + // Validate the caller's ceiling, not a cached tariff. Replays retain their + // original charge; fresh admission is priced and bounded by the server. + if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits <= 0 || input.max_credits > 100) throw new InputValidationError("Supply an explicit max_credits limit greater than zero and at most100. Read the account sprite estimate before new work."); } export type ManagedInputAsset = { @@ -315,7 +318,7 @@ export type ManagedBalance = { promotional: number; purchased: number; available: number; - credit_usd: "0.17"; + credit_usd: "0.10" | "0.15" | "0.17" | "0.25"; }; /** @@ -465,24 +468,29 @@ export function managedBalance(value: unknown): ManagedBalance { if (Object.keys(value).sort().join("\0") !== exact.join("\0")) { throw new Error("Invalid DreamLayer balance response"); } + if (typeof value.credit_usd !== "string" || !["0.10", "0.15", "0.17", "0.25"].includes(value.credit_usd)) throw new Error("Invalid DreamLayer balance response"); + const scale = value.credit_usd === "0.10" || value.credit_usd === "0.15" ? 10000 : 10; for (const field of ["promotional", "purchased", "available"] as const) { - if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) { + if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / scale) { throw new Error("Invalid DreamLayer balance response"); } } if ( - value.credit_usd !== "0.17" || - Math.round(Number(value.available) * 10) < Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) || - Math.round(Number(value.available) * 10) > Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) + 1 + Math.round(Number(value.available) * scale) < Math.round(Number(value.promotional) * scale) + Math.round(Number(value.purchased) * scale) || + Math.round(Number(value.available) * scale) > Math.round(Number(value.promotional) * scale) + Math.round(Number(value.purchased) * scale) + 1 ) { throw new Error("Invalid DreamLayer balance response"); } - if ([value.promotional, value.purchased, value.available].some(v => Math.abs(Number(v) * 10 - Math.round(Number(v) * 10)) > 1e-7)) throw new Error("Invalid DreamLayer balance response"); + const places = scale === 10000 ? 4 : 1; + if ([value.promotional, value.purchased, value.available].some(v => + !Number.isSafeInteger(Math.round(Number(v) * scale)) || + Number(Number(v).toFixed(places)) !== v + )) throw new Error("Invalid DreamLayer balance response"); return { promotional: Number(value.promotional), purchased: Number(value.purchased), available: Number(value.available), - credit_usd: "0.17", + credit_usd: value.credit_usd as ManagedBalance["credit_usd"], }; } @@ -827,7 +835,7 @@ async quoteVideo(input: Record): Promise> { - this.capabilitiesPromise ??= this.request>("/v1/capabilities").catch( + this.capabilitiesPromise ??= this.request>("/v1/capabilities?pricing=current").catch( (error: unknown) => { // Cache a successful contract for the process, but never pin a transient // capabilities failure as a permanent result. @@ -847,7 +855,24 @@ async quoteVideo(input: Record): Promise { - return managedBalance(await this.request("/v1/balance")); + return managedBalance(await this.request("/v1/balance?pricing=current")); + } + + /** Free, account-specific estimate. Never a reservation or spending approval. */ + async getSpritePrice(frameCount = 12, background: SpriteBackground = "remove"): Promise & { credits: string }> { + if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100 || !SPRITE_BACKGROUNDS.includes(background)) throw new InputValidationError("Invalid sprite estimate options"); + const value = await this.request(`/v1/sprite-pricing?frame_count=${frameCount}&background=${background}`); + const keys = ["background", "binding", "credits", "frame_count", "price_version", "reference_credit_cents", "reference_usd"]; + if (!isRecord(value) || Object.keys(value).sort().join("\0") !== keys.join("\0") + || value.price_version !== "sprite-base-frame-20261017-v1" + || value.frame_count !== frameCount || value.background !== background || value.binding !== false + || ![10, 15].includes(Number(value.reference_credit_cents)) || typeof value.reference_credit_cents !== "number" + || typeof value.credits !== "string" || !/^\d+(?:\.\d{1,4})?$/.test(value.credits) + || Number(value.credits) <= 0 || Number(value.credits) > 100 + || typeof value.reference_usd !== "string" || !/^\d+\.\d{2}$/.test(value.reference_usd)) { + throw new ResponseContractError("Invalid DreamLayer sprite estimate response"); + } + return value as Record & { credits: string }; } cancel(executionId: string): Promise { diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 0f94883..9d999f0 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -29,12 +29,23 @@ function fakeApi(behaviour) { const server = createServer((request, response) => { calls.push({ method: request.method, url: request.url, headers: request.headers }); - if (request.url === "/v1/capabilities") { + if (request.url.startsWith("/v1/sprite-pricing?")) { + if (behaviour.noSpritePrice) { response.writeHead(404); response.end(); return; } + const query = new URL(request.url, "http://localhost").searchParams; + const frames = Number(query.get("frame_count")); + const background = query.get("background"); + const credits = background === "keep" ? Math.ceil((32 + frames) * 10000 / 15) / 10000 : (42 + 2 * frames) / 10; + response.writeHead(200, {"content-type": "application/json"}); + response.end(JSON.stringify({price_version: "sprite-base-frame-20261017-v1", frame_count: frames, background, credits: String(credits), reference_usd: (credits * .15).toFixed(2), reference_credit_cents: 15, binding: false})); + return; + } + + if (request.url === "/v1/capabilities?pricing=current") { response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify(behaviour.capabilities ?? { api_version: "1", key_mode: "live" })); return; } - if (request.url === "/v1/balance") { + if (request.url === "/v1/balance?pricing=current") { response.writeHead(behaviour.balanceStatus ?? 200, { "content-type": "application/json", "cache-control": "private, no-store", @@ -291,14 +302,14 @@ test("balance reads only the authenticated key's balance in human and JSON modes for (const args of [["balance"], ["balance", "--json"]]) { const api = await listen(fakeApi({ events: [] })); const result = await runCli(args, { DREAMLAYER_API_URL: api.url }); - const balanceCall = api.calls.find((call) => call.url === "/v1/balance"); + const balanceCall = api.calls.find((call) => call.url === "/v1/balance?pricing=current"); api.close(); assert.equal(result.code, 0, result.stderr); assert.equal(balanceCall.method, "GET"); assert.equal(balanceCall.headers.authorization, "Bearer dlr_live_test"); assert.equal(balanceCall.headers["dreamlayer-version"], "1"); - assert.equal(balanceCall.url, "/v1/balance", "no account selector may be sent"); + assert.equal(balanceCall.url, "/v1/balance?pricing=current", "no account selector may be sent"); if (args.includes("--json")) { assert.deepEqual(JSON.parse(result.stdout), { promotional: 3, @@ -1228,7 +1239,7 @@ test('sprite CLI sends a kept background at the flat rate a transparent sheet wo 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.notEqual(refused.code,0);assert.match(refused.stderr,/6\.6 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); @@ -1237,13 +1248,13 @@ test('sprite CLI sends a kept background at the flat rate a transparent sheet wo } finally {api.close();} }); -test('sprite CLI refuses 4.9 credits for twelve plain frames', async()=>{ +test('sprite CLI refuses a cap below the current four-decimal account estimate', 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/); + const result=await runCli(['sprite',input,'--frames','12','--background','keep','--max-credits','2.9333','--out',path.join(dir,'plain.zip'),'--quiet'],{DREAMLAYER_API_URL:api.url}); + assert.notEqual(result.code,0);assert.match(result.stderr,/require 2\.9334 credits/); assert.equal(api.calls.filter(c=>c.url==='/v1/execute').length,0); } finally {api.close();} }); @@ -1260,13 +1271,13 @@ test('a spelled-out remove is the same job on the wire as an omitted one', async } 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:[]})); +test('a server without the current estimate refuses sprite work before uploading', async()=>{ + const api=await listen(fakeApi({noSpritePrice:true,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.notEqual(result.code,0); 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();} diff --git a/test/current-pricing.test.mjs b/test/current-pricing.test.mjs new file mode 100644 index 0000000..f4c5d1f --- /dev/null +++ b/test/current-pricing.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createServer} from 'node:http'; +import {ManagedClient,managedBalance} from '../dist/client.js'; + +const quote={price_version:'sprite-base-frame-20261017-v1',frame_count:12,background:'keep',credits:'2.9334',reference_usd:'0.44',reference_credit_cents:15,binding:false}; +async function fixture(body, run, status=200) { + const requests=[]; + const server=createServer((req,res)=>{requests.push(req.method+' '+req.url);res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(body));}); + await new Promise(r=>server.listen(0,'127.0.0.1',r)); + try { await run(new ManagedClient('fixture',`http://127.0.0.1:${server.address().port}`),requests); } + finally {server.closeAllConnections();await new Promise(r=>server.close(r));} +} +test('sprite estimate is a free account-bound read and preserves four decimal credits',async()=>{ + await fixture(quote,async(client,requests)=>{ + assert.deepEqual(await client.getSpritePrice(12,'keep'),quote); + assert.deepEqual(requests,['GET /v1/sprite-pricing?frame_count=12&background=keep']); + }); +}); +for(const patch of [{binding:true},{price_version:'unknown'},{frame_count:7},{background:'remove'},{credits:'NaN'},{credits:'0'},{credits:'101'},{credits:'2.93345'},{credits:2.9334},{reference_credit_cents:'15'},{reference_credit_cents:17},{reference_usd:'.44'},{private_value:'do-not-expose'}]) { + test('invalid estimate fails closed: '+JSON.stringify(patch),async()=>{ + await fixture({...quote,...patch},async client=>{await assert.rejects(client.getSpritePrice(12,'keep'),/Invalid DreamLayer sprite estimate response/);}); + }); +} +test('missing estimate does not fall back to historical local pricing',async()=>{ + await fixture({error:{reason:'not_found',message:'Unavailable',retryable:false}},async(client,requests)=>{ + await assert.rejects(client.getSpritePrice(12,'keep'));assert.equal(requests.length,1); + },404); +}); +test('invalid estimate arguments make no request',async()=>{ + await fixture(quote,async(client,requests)=>{ + for(const args of [[6,'keep'],[101,'keep'],['12','keep'],[12,'transparent']])await assert.rejects(client.getSpritePrice(...args)); + assert.equal(requests.length,0); + }); +}); +for(const rate of ['0.10','0.15']) { + test('large current balance preserves exact decimal representation at '+rate,()=>{ + const value={promotional:0,purchased:65536.0014,available:65536.0014,credit_usd:rate}; + assert.deepEqual(managedBalance(value),value); + assert.throws(()=>managedBalance({...value,purchased:65536.00141,available:65536.00141})); + assert.throws(()=>managedBalance({...value,purchased:1e12,available:1e12})); + }); + test('current balance preserves four decimal precision at '+rate,()=>{ + const value={promotional:0.0001,purchased:2.9333,available:2.9334,credit_usd:rate}; + assert.deepEqual(managedBalance(value),value); + assert.throws(()=>managedBalance({...value,available:2.93341})); + assert.throws(()=>managedBalance({...value,available:2.9332})); + }); +} +test('legacy balance remains accepted without relaxing closed fields or types',()=>{ + const value={promotional:0,purchased:5.8,available:5.8,credit_usd:'0.17'}; + assert.deepEqual(managedBalance(value),value); + assert.throws(()=>managedBalance({...value,credit_usd:0.17})); + assert.throws(()=>managedBalance({...value,private_value:'hidden'})); + assert.throws(()=>managedBalance({...value,purchased:5.81,available:5.81})); +});