← back to Wallco Ai
etsy bundles: option C v2 — replace count-based cap with byte-budget packing (75 MB default). Empirical finding from yolo-task-49: per-design upscaled PNGs vary 0.77-10.55 MB (5× spread driven by pattern complexity); count caps fail catastrophically when heavy designs cluster (warm-red 9-pack hit 114 MB, warm-orange-01 12-pack hit 109 MB despite the option-C 12-cap). Byte-budget targets total ZIP size and is robust to future heavy designs. estimateBundleZipBytes() uses cached all_designs.upscale_bytes_2400 + source_1024 file size. push-etsy-bundle.js: source_1024.png now ACTUALLY resized to 1024² (was preserving original ~20MB file). mass-push-etsy.js: --target-size → --byte-budget flag. Authored by yolo worker (task 49 instances all hit max-turns/timeout but shipped the refactor before dying)
334af5abae21aa5e15d2cfb69ceee184d1567513 · 2026-05-28 16:52:49 -0700 · Steve Abrams
Files touched
M scripts/generate-etsy-bundles.jsM scripts/mass-push-etsy.jsM scripts/push-etsy-bundle.js
Diff
commit 334af5abae21aa5e15d2cfb69ceee184d1567513
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 28 16:52:49 2026 -0700
etsy bundles: option C v2 — replace count-based cap with byte-budget packing (75 MB default). Empirical finding from yolo-task-49: per-design upscaled PNGs vary 0.77-10.55 MB (5× spread driven by pattern complexity); count caps fail catastrophically when heavy designs cluster (warm-red 9-pack hit 114 MB, warm-orange-01 12-pack hit 109 MB despite the option-C 12-cap). Byte-budget targets total ZIP size and is robust to future heavy designs. estimateBundleZipBytes() uses cached all_designs.upscale_bytes_2400 + source_1024 file size. push-etsy-bundle.js: source_1024.png now ACTUALLY resized to 1024² (was preserving original ~20MB file). mass-push-etsy.js: --target-size → --byte-budget flag. Authored by yolo worker (task 49 instances all hit max-turns/timeout but shipped the refactor before dying)
---
scripts/generate-etsy-bundles.js | 125 ++++++++++++++++++++++++++++++++-------
scripts/mass-push-etsy.js | 6 +-
scripts/push-etsy-bundle.js | 6 +-
3 files changed, 111 insertions(+), 26 deletions(-)
diff --git a/scripts/generate-etsy-bundles.js b/scripts/generate-etsy-bundles.js
index 27a0d26..30363c1 100644
--- a/scripts/generate-etsy-bundles.js
+++ b/scripts/generate-etsy-bundles.js
@@ -57,13 +57,18 @@ const opt = {
by: ARG('--by', 'category'),
fromBucket: !HAS('--from-tag'),
fromTag: ARG('--from-tag', null),
- // Option C (Steve 2026-05-28 via /dtd): cap at 12 designs/bundle so each
- // ZIP stays under Etsy's 100 MB file-upload limit when paired with the
- // 2400px upscale in push-etsy-bundle.js (~6 MB per print PNG × 12 = ~75 MB
- // ZIP, well under the cap with headroom).
- targetSize: parseInt(ARG('--target-size', '12'), 10),
- maxSize: parseInt(ARG('--max-size', '12'), 10), // hard ceiling per bundle
- minSize: parseInt(ARG('--min-size', '5'), 10),
+ // Option C v2 (Steve 2026-05-28 via /dtd, second pass): size-aware byte
+ // budget packing. Empirically per-design upscaled PNGs range 0.77–10.55 MB
+ // (5x spread driven by pattern complexity, not count). Count caps fail
+ // catastrophically when a bundle lands several heavy designs together
+ // (warm-red 9-pack hit 114 MB, warm-orange-01 12-pack hit 109 MB). The
+ // byteBudget targets total ZIP size and is independent of how many designs
+ // it takes to fill — robust to any future heavy designs. targetSize/maxSize
+ // are kept as safety guardrails only.
+ byteBudget: Math.round(parseFloat(ARG('--byte-budget', '75')) * 1024 * 1024),
+ targetSize: parseInt(ARG('--target-size', '20'), 10),
+ maxSize: parseInt(ARG('--max-size', '20'), 10), // guardrail only — byte budget is the real cap
+ minSize: parseInt(ARG('--min-size', '4'), 10), // 4-packs viable on Etsy; saves edge buckets from being orphaned
maxBundles: parseInt(ARG('--max-bundles', '0'), 10),
price: ARG('--price') ? parseFloat(ARG('--price')) : null,
bundleName: ARG('--bundle-name', null),
@@ -125,7 +130,7 @@ function loadPool() {
if (!opt.bundleName) throw new Error('--by manual requires --bundle-name');
if (!opt.ids.length) throw new Error('--by manual requires --ids "id1,id2,…"');
const sql = `SELECT row_to_json(t) FROM (
- SELECT id, category, dominant_hex, local_path, prompt
+ SELECT id, category, dominant_hex, local_path, prompt, upscale_bytes_2400
FROM all_designs WHERE id IN (${opt.ids.join(',')})
) t;`;
return psqlJson(sql);
@@ -133,7 +138,7 @@ function loadPool() {
if (opt.fromTag) {
const tag = opt.fromTag.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40);
const sql = `SELECT row_to_json(t) FROM (
- SELECT id, category, dominant_hex, local_path, prompt
+ SELECT id, category, dominant_hex, local_path, prompt, upscale_bytes_2400
FROM all_designs WHERE '${tag}' = ANY(COALESCE(tags, ARRAY[]::text[]))
ORDER BY id
) t;`;
@@ -141,7 +146,8 @@ function loadPool() {
}
// default: from-bucket (state='queued')
const sql = `SELECT row_to_json(t) FROM (
- SELECT d.id, d.category, d.dominant_hex, d.local_path, d.prompt, eb.target_dpi, eb.upscale_px
+ SELECT d.id, d.category, d.dominant_hex, d.local_path, d.prompt, eb.target_dpi, eb.upscale_px,
+ d.upscale_bytes_2400
FROM wallco_etsy_bucket eb JOIN all_designs d ON d.id = eb.design_id
WHERE eb.state = 'queued' ORDER BY eb.added_at
) t;`;
@@ -149,6 +155,78 @@ function loadPool() {
}
// ── grouping ────────────────────────────────────────────────────────────────
+// Per-design byte estimate for ZIP packing. Uses the empirically-measured
+// upscale_bytes_2400 (cached on first dry-run) plus the source_1024 file size
+// (the ZIP contains BOTH per design). Fallback for unmeasured designs is the
+// observed worst-case 11 MB so packing stays safe rather than optimistic.
+const COVER_OVERHEAD_BYTES = 4 * 1024 * 1024; // cover_grid.png is ~3-4 MB
+const FALLBACK_UPSCALE_BYTES = 11 * 1024 * 1024; // worst observed
+function estimateBundleZipBytes(designs) {
+ let total = COVER_OVERHEAD_BYTES;
+ for (const d of designs) {
+ const upscaleB = d.upscale_bytes_2400 || FALLBACK_UPSCALE_BYTES;
+ let srcB = 0;
+ try { if (d.local_path && fs.existsSync(d.local_path)) srcB = fs.statSync(d.local_path).size; }
+ catch { srcB = 0; }
+ total += upscaleB + srcB;
+ }
+ return total;
+}
+
+// LPT (longest-processing-time) pack: pre-size the bundle count from total
+// bytes, then place each design heaviest-first into the bundle with the
+// smallest current load. This produces balanced bundles instead of a
+// "fill-then-leftover-tail" pattern. Naturally avoids under-min sub-bundles
+// when total bytes split evenly across the target count.
+function packByByteBudget(list) {
+ const totalBytes = list.reduce((s, d) => {
+ const u = d.upscale_bytes_2400 || FALLBACK_UPSCALE_BYTES;
+ let src = 0;
+ try { if (d.local_path && fs.existsSync(d.local_path)) src = fs.statSync(d.local_path).size; }
+ catch { src = 0; }
+ return s + u + src;
+ }, 0);
+ // +COVER_OVERHEAD per target bundle; estimate from a 1-bundle floor
+ const numBundles = Math.max(1, Math.ceil((totalBytes + COVER_OVERHEAD_BYTES) / opt.byteBudget));
+ const bundles = Array.from({ length: numBundles }, () => []);
+ const sorted = [...list].sort((a, b) => {
+ const ba = (a.upscale_bytes_2400 || FALLBACK_UPSCALE_BYTES);
+ const bb = (b.upscale_bytes_2400 || FALLBACK_UPSCALE_BYTES);
+ return bb - ba;
+ });
+ for (const d of sorted) {
+ let bestIdx = 0, bestSum = estimateBundleZipBytes(bundles[0]);
+ for (let i = 1; i < bundles.length; i++) {
+ const s = estimateBundleZipBytes(bundles[i]);
+ if (s < bestSum) { bestSum = s; bestIdx = i; }
+ }
+ bundles[bestIdx].push(d);
+ }
+ // Rescue under-min bundles: redistribute their designs into the smallest
+ // other bundles that still have byte headroom. Preserves designs that would
+ // otherwise be dropped at the minSize gate when a leftover tail is too small.
+ const kept = [], rescue = [];
+ for (const b of bundles) {
+ if (b.length >= opt.minSize) kept.push(b);
+ else rescue.push(...b);
+ }
+ for (const d of rescue.sort((a, b) =>
+ (b.upscale_bytes_2400 || FALLBACK_UPSCALE_BYTES) - (a.upscale_bytes_2400 || FALLBACK_UPSCALE_BYTES))) {
+ let bestIdx = -1, bestSum = Infinity;
+ for (let i = 0; i < kept.length; i++) {
+ const trial = [...kept[i], d];
+ const s = estimateBundleZipBytes(trial);
+ if (s <= opt.byteBudget && trial.length <= opt.maxSize && s < bestSum) {
+ bestSum = s; bestIdx = i;
+ }
+ }
+ if (bestIdx >= 0) kept[bestIdx].push(d);
+ // else: orphan, drop. Singleton color buckets (e.g. lone "neutral" design)
+ // genuinely can't be saved without cross-bucket merging.
+ }
+ return kept;
+}
+
function group(pool) {
if (opt.by === 'manual') {
return new Map([[opt.bundleName, pool]]);
@@ -161,18 +239,16 @@ function group(pool) {
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(d);
}
- // Split oversized buckets into target-sized chunks
+ // Byte-budget pack each color/category bucket. Bundles below minSize get
+ // dropped. Naming: <bucket> for single sub-bundle, <bucket>-NN for multiple.
const out = new Map();
for (const [k, list] of buckets) {
- if (list.length < opt.minSize) continue;
- // Hard cap at maxSize (option C — Etsy 100 MB ZIP limit). Even buckets
- // slightly over targetSize get split so we never produce an oversized ZIP.
- if (list.length <= opt.maxSize) { out.set(k, list); continue; }
- let chunks = Math.ceil(list.length / opt.maxSize);
- const size = Math.ceil(list.length / chunks);
- for (let i = 0; i < chunks; i++) {
- out.set(`${k}-${String(i + 1).padStart(2, '0')}`, list.slice(i * size, (i + 1) * size));
- }
+ const packed = packByByteBudget(list);
+ if (!packed.length) continue;
+ if (packed.length === 1) { out.set(k, packed[0]); continue; }
+ packed.forEach((sub, i) => {
+ out.set(`${k}-${String(i + 1).padStart(2, '0')}`, sub);
+ });
}
return out;
}
@@ -271,7 +347,7 @@ function bundleCopy(name, n, designs) {
// ── main ────────────────────────────────────────────────────────────────────
function main() {
console.log(`Etsy bundle generator → ${OUT_DIR}`);
- console.log(` by=${opt.by} source=${opt.fromTag ? `tag:${opt.fromTag}` : 'bucket'} target-size=${opt.targetSize} min-size=${opt.minSize} commit=${opt.commit}`);
+ console.log(` by=${opt.by} source=${opt.fromTag ? `tag:${opt.fromTag}` : 'bucket'} byte-budget=${(opt.byteBudget/1024/1024).toFixed(0)}MB max-size=${opt.maxSize} min-size=${opt.minSize} commit=${opt.commit}`);
const pool = loadPool();
console.log(`Pool: ${pool.length} designs`);
@@ -287,10 +363,15 @@ function main() {
if (opt.maxBundles > 0) bundles.length = Math.min(bundles.length, opt.maxBundles);
console.log(`\nWould create ${bundles.length} bundle(s):`);
+ let overBudget = 0;
for (const b of bundles) {
- console.log(` ${b.slug.padEnd(34)} → ${String(b.n).padStart(3)} designs · $${(opt.price != null ? opt.price : priceFor(b.n)).toFixed(2)}`);
+ const estMB = estimateBundleZipBytes(b.designs) / 1024 / 1024;
+ const flag = estMB > opt.byteBudget / 1024 / 1024 ? ' ⚠️ OVER BUDGET' : '';
+ if (estMB > opt.byteBudget / 1024 / 1024) overBudget++;
+ console.log(` ${b.slug.padEnd(34)} → ${String(b.n).padStart(3)} designs · est ${estMB.toFixed(1)} MB · $${(opt.price != null ? opt.price : priceFor(b.n)).toFixed(2)}${flag}`);
}
console.log(`Total designs bundled: ${bundles.reduce((s, b) => s + b.n, 0)} / ${pool.length}`);
+ if (overBudget) console.log(`\n⚠️ ${overBudget} bundle(s) projected over byte budget — packing fallback hit. Investigate before live push.`);
if (!opt.commit) { console.log('\n(dry-run; pass --commit to write assets + update bucket states)'); return; }
diff --git a/scripts/mass-push-etsy.js b/scripts/mass-push-etsy.js
index 8dcbf0e..45cce49 100644
--- a/scripts/mass-push-etsy.js
+++ b/scripts/mass-push-etsy.js
@@ -45,7 +45,7 @@ const HAS = k => argv.includes(k);
const opt = {
date: ARG('--date', new Date().toISOString().slice(0, 10)),
by: ARG('--by', 'color'),
- targetSize: parseInt(ARG('--target-size', '20'), 10),
+ byteBudget: parseFloat(ARG('--byte-budget', '75')), // MB per bundle ZIP target
rateLimitSec: parseInt(ARG('--rate-limit-sec', '5'), 10),
maxBundles: parseInt(ARG('--max-bundles', '0'), 10),
state: ARG('--state', 'draft'),
@@ -95,7 +95,7 @@ async function main() {
console.log('\n══════════════════════════════════════════════════════');
console.log(' Mass-push Etsy — wallco_etsy_bucket → Etsy drafts');
console.log('══════════════════════════════════════════════════════');
- console.log(` date=${opt.date} by=${opt.by} target-size=${opt.targetSize}`);
+ console.log(` date=${opt.date} by=${opt.by} byte-budget=${opt.byteBudget}MB`);
console.log(` rate-limit=${opt.rateLimitSec}s max-bundles=${opt.maxBundles || 'all'}`);
console.log(` state=${opt.state} dry-run=${opt.dryRun} resume=${opt.resume}`);
console.log('');
@@ -116,7 +116,7 @@ async function main() {
path.join(HERE, 'generate-etsy-bundles.js'),
'--from-bucket',
'--by', opt.by,
- '--target-size', String(opt.targetSize),
+ '--byte-budget', String(opt.byteBudget),
'--commit',
];
const r = spawnSync('node', genArgs, { stdio: 'inherit' });
diff --git a/scripts/push-etsy-bundle.js b/scripts/push-etsy-bundle.js
index a81f3b4..83c7c77 100644
--- a/scripts/push-etsy-bundle.js
+++ b/scripts/push-etsy-bundle.js
@@ -154,7 +154,11 @@ canvas.save(${JSON.stringify(path.join(BUNDLE_DIR, 'cover_grid.png'))}, optimize
files_dir = ${JSON.stringify(files)}
for it in items:
src = Image.open(it['src']).convert('RGB')
- src.save(os.path.join(files_dir, f"{it['id']}_source_1024.png"), optimize=True)
+ # Source file: resize to true 1024x1024 to honor the filename + listing
+ # description (~1.5 MB instead of 20+ MB for old high-res sources).
+ src.resize((1024, 1024), Image.LANCZOS).save(
+ os.path.join(files_dir, f"{it['id']}_source_1024.png"), optimize=True
+ )
src.resize((upscale_px, upscale_px), Image.LANCZOS).save(
os.path.join(files_dir, f"{it['id']}_print_3600.png"), optimize=True
)
← e4cb8b8 rooms: add Mac2→prod auto-sync wrapper so room PNGs land on
·
back to Wallco Ai
·
tile-period-detect: PIL+numpy quantitative validator for mur 82f333f →