diff --git a/README.md b/README.md index 6177ccd..1a5c097 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,20 @@ Node.js 22.12 or later. ## License MIT. See LICENSE and NOTICE. + +## Sprite-sheet beta + +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 +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/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..616d452 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,6 +44,7 @@ USAGE dreamlayer edit [--out ] dreamlayer cutout [--out ] dreamlayer upscale [--out ] + dreamlayer sprite --action [--frames <7–100>] --max-credits [--out ] dreamlayer answer [--image ] [--out ] dreamlayer status dreamlayer balance @@ -52,6 +54,12 @@ 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 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 --quiet No progress on stderr --idempotency-key Reuse to retry safely after an uncertain response @@ -60,10 +68,16 @@ 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"; + animationPrompt?: string; + animationMode?: "loop" | "once"; + frameSize?: 32 | 64 | 128 | 256 | 512 | 720 | 1080; + maxCredits: number; + frameCount: number; out: string | null; image: string | null; aspect: string; @@ -77,6 +91,8 @@ class UsageError extends Error {} function parseOptions(argv: string[]): { positional: string[]; options: Options } { const positional: string[] = []; const options: Options = { + maxCredits: 1, + frameCount: 12, out: null, image: null, aspect: "1:1", @@ -92,6 +108,30 @@ 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 === "--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"); + options.frameCount = value; + } else if (token === "--max-credits") { + const value = Number(argv[++i]); + 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]; if (!value) throw new UsageError("--image needs a file path"); @@ -156,7 +196,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 +228,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 +295,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 +333,18 @@ async function main(argv: string[]): Promise { const { positional, options } = parseOptions(rest); 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(); + 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); + 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); + } case "generate": { const prompt = positional[0]; if (!prompt) throw new UsageError("generate needs a prompt"); @@ -354,6 +406,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/src/client.ts b/src/client.ts index 373c22b..6c30966 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,8 +71,28 @@ 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 }; + 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; + 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.`); +} + export type ManagedInputAsset = { input_asset_id: string; width: number; @@ -117,6 +138,7 @@ export const PUBLIC_ERROR_REASONS = [ "content_refused", "temporarily_unavailable", "generation_failed", + "insufficient_frames", ] as const; export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number]; @@ -151,6 +173,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 }, }; @@ -188,6 +211,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]; } @@ -206,6 +230,7 @@ function statusForReason(reason: PublicErrorReason): number { content_refused: 422, temporarily_unavailable: 503, generation_failed: 500, + insufficient_frames: 422, }; return statuses[reason]; } @@ -414,16 +439,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), @@ -643,6 +670,7 @@ export class ManagedClient { input: ManagedExecuteInput, options: { idempotencyKey: string }, ): AsyncGenerator { + validateSpriteInput(input); const stream = await this.fetchStream("/v1/execute", { method: "POST", headers: { @@ -655,6 +683,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/cli.test.mjs b/test/cli.test.mjs index eeb374c..96a3a83 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,56 @@ 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 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:[]})); + 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(); } +}); 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..67e1d5f --- /dev/null +++ b/test/sprite.test.mjs @@ -0,0 +1,59 @@ +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));} +}); + +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); +});