estimate() adds prompt tokens (about one per three characters), 1,024 tokens per 1024x1024 input image and the output formula times n, assuming the worst case for auto. BookNest's plan for Cover Art and Icons:
import { estimate, guard, CAP } from './budget.mjs';
const prompt = 'Book-cover art for "The Quiet Harbor": a lighthouse keeper\'s daughter on a '
+ 'harbor wall at dusk, painterly, deep blue and saffron, no text';
const plan = { covers: { size: '1024x1536', n: 6 }, icons: {}, signEdit: { imageIn: 1024 },
banner: { size: '1536x864', quality: 'medium' } };
const costs = Object.entries(plan).map(([what, p]) => [what, estimate({ ...p, prompt })]);
const total = costs.reduce((sum, [, cost]) => sum + cost, 0);
console.log(costs.map(([what, cost]) => `${what} $${cost.toFixed(4)}`).join(', '));
const spent = guard(total * 1.5); // throws if plan + 50% retries > CAP
console.log(`plan $${total.toFixed(4)}, x1.5 = $${(total * 1.5).toFixed(4)}; spent`,
`$${spent.toFixed(4)} of $${CAP}: approved`);Output
covers $0.0298, icons $0.0061, signEdit $0.0143, banner $0.0086 plan $0.0589, x1.5 = $0.0883; spent $0.1622 of $2: approved
Nine images cost under nine cents with retries; the six covers alone would cost $0.99 at max.
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; max-width: 600px; }
table { border-collapse: collapse; background: #fff; width: 100%; }
td, th { border: 1px solid #ddd; padding: 3px 6px; }
select, input { font: inherit; }
svg { width: 100%; display: block; margin-top: 8px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<table id="plan"><tr><th>item</th><th>size</th><th>quality</th><th>n</th><th>input images</th><th>estimate</th></tr></table>
<svg viewBox="0 0 600 90" font-size="12"></svg>
<script>
const PATCHES = { low: 16, medium: 24, high: 48, xhigh: 64, max: 96 };
function outputTokens(size, quality) {
const [w, h] = size.split('x').map(Number);
const p = PATCHES[quality], s = p / (Math.max(w, h) / Math.min(w, h));
const f = Math.floor(s), q = s - f === 0.5 ? f + (f % 2) : Math.round(s);
return Math.ceil(p * q * (2e6 + w * h) / 4e6);
}
const prompt = 'Book-cover art for "The Quiet Harbor": a lighthouse keeper\'s daughter on a harbor wall at dusk, painterly, deep blue and saffron, no text';
// estimate(): prompt tokens (~1 per 3 characters), 1,024 tokens per input image, output formula × n
const estimate = ({ size, quality, n, imageIn }) =>
(Math.ceil(prompt.length / 3) * 5 + imageIn * 1024 * 8 + outputTokens(size, quality) * n * 30) / 1e6;
const plan = [
{ what: 'covers', size: '1024x1536', quality: 'low', n: 6, imageIn: 0 },
{ what: 'icons', size: '1024x1024', quality: 'low', n: 1, imageIn: 0 },
{ what: 'sign edit', size: '1024x1024', quality: 'low', n: 1, imageIn: 1 },
{ what: 'banner', size: '1536x864', quality: 'medium', n: 1, imageIn: 0 },
];
const CAP = 5, spent = 0.1622; // the ledger's running total
const table = d3.select('#plan'), sizes = ['1024x1024', '1024x1536', '1536x1024', '1536x864', '3840x2160'];
const rows = table.selectAll('tr.p').data(plan).join('tr').attr('class', 'p');
rows.append('td').text(d => d.what);
rows.append('td').append('select').on('change', function (e, d) { d.size = this.value; update(); })
.selectAll('option').data(d => sizes.map(s => [s, s === d.size])).join('option').text(([s]) => s).property('selected', ([, on]) => on);
rows.append('td').append('select').on('change', function (e, d) { d.quality = this.value; update(); })
.selectAll('option').data(d => Object.keys(PATCHES).map(q => [q, q === d.quality])).join('option').text(([q]) => q).property('selected', ([, on]) => on);
rows.append('td').append('input').attr('type', 'number').attr('min', 1).attr('max', 10).style('width', '44px')
.property('value', d => d.n).on('input', function (e, d) { d.n = +this.value || 1; update(); });
rows.append('td').text(d => d.imageIn);
const cost = rows.append('td').attr('class', 'est');
const svg = d3.select('svg'), x = d3.scaleLinear([0, CAP], [0, 580]);
function update() {
cost.text(d => `$${estimate(d).toFixed(4)}`);
const total = d3.sum(plan, estimate), withRetries = total * 1.5, ok = spent + withRetries <= CAP;
const bars = [['spent', spent, '#90a4ae'], ['plan × 1.5', withRetries, ok ? '#3f7d3a' : '#b5452f']];
let at = 0;
svg.selectAll('rect.b').data(bars).join('rect').attr('class', 'b').attr('y', 20).attr('height', 26)
.attr('x', d => { const x0 = x(at); at += d[1]; return 10 + x0; }).attr('width', d => Math.max(1, x(Math.min(d[1], CAP))))
.attr('fill', d => d[2]);
svg.selectAll('rect.cap').data([0]).join('rect').attr('class', 'cap').attr('x', 10).attr('y', 20).attr('width', 580).attr('height', 26)
.attr('fill', 'none').attr('stroke', '#263238');
svg.selectAll('text.t').data([0]).join('text').attr('class', 't').attr('x', 10).attr('y', 70)
.attr('fill', ok ? '#3f7d3a' : '#b5452f')
.text(`plan $${total.toFixed(4)}, × 1.5 = $${withRetries.toFixed(4)}; spent $${spent} of $${CAP}: ${ok ? 'approved' : 'REFUSED by guard()'}`);
}
update();
</script>