[object Object]

← 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

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 →