[object Object]

← back to Wallco Ai

wallco-etsy bundles: option C wired — target-size 12 (was 15) + hard maxSize=12 + 2400px upscale (was 3600px). Each ZIP now ~75 MB (12 designs × ~6 MB), comfortably under Etsy's 100 MB file-upload cap. Listing description text updated to 2400×2400/16in. Picked by /dtd panel 2026-05-28 (2/2 valid panelists voted C — keeps bundle-count manageable + comfortable cap headroom). Old bundle metadata wiped for fresh rebuild

23b1207cdc347d6ac2da66d5209405946fa7ca86 · 2026-05-28 13:14:31 -0700 · Steve Abrams

Files touched

Diff

commit 23b1207cdc347d6ac2da66d5209405946fa7ca86
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 13:14:31 2026 -0700

    wallco-etsy bundles: option C wired — target-size 12 (was 15) + hard maxSize=12 + 2400px upscale (was 3600px). Each ZIP now ~75 MB (12 designs × ~6 MB), comfortably under Etsy's 100 MB file-upload cap. Listing description text updated to 2400×2400/16in. Picked by /dtd panel 2026-05-28 (2/2 valid panelists voted C — keeps bundle-count manageable + comfortable cap headroom). Old bundle metadata wiped for fresh rebuild
---
 scripts/attach-shopify-bundle-files.js | 227 +++++++++++++++++++++++++++++++++
 scripts/generate-etsy-bundles.js       |  15 ++-
 scripts/generate_room_mockups.py       |  45 +++++--
 scripts/push-etsy-bundle.js            |   6 +-
 4 files changed, 279 insertions(+), 14 deletions(-)

diff --git a/scripts/attach-shopify-bundle-files.js b/scripts/attach-shopify-bundle-files.js
new file mode 100644
index 0000000..63fd4fc
--- /dev/null
+++ b/scripts/attach-shopify-bundle-files.js
@@ -0,0 +1,227 @@
+#!/usr/bin/env node
+/**
+ * attach-shopify-bundle-files — generate the bundle's ZIP (cover + 1024 sources
+ * + LANCZOS-upscaled 3600 prints), upload to Shopify Files API, and attach the
+ * resulting URL to the product as a metafield (wallco_ai.bundle_download_url).
+ *
+ * Why: push-shopify-digital-bundle.js creates the product page but Shopify's
+ * core REST API doesn't have a "downloadable file" field on products. The
+ * standard pattern is:
+ *   1. Upload the ZIP via /admin/api/2026-01/files.json
+ *   2. Store the public file URL in a product metafield
+ *   3. Customer delivery via order-confirmation email (manual for now; later
+ *      the Shopify "Digital Downloads" app can fully automate)
+ *
+ * Asset generation is ported verbatim from push-etsy-bundle.js — same PIL
+ * cover_grid + LANCZOS upscale + zip wrapper, so Etsy and Shopify deliver an
+ * identical ZIP shape.
+ *
+ * Usage:
+ *   node scripts/attach-shopify-bundle-files.js --date 2026-05-28 --slug cactus --product 7843826270259
+ *   node scripts/attach-shopify-bundle-files.js --date 2026-05-28 --slug cactus --product 7843826270259 --no-cleanup
+ *   node scripts/attach-shopify-bundle-files.js --date 2026-05-28 --slug cactus --product 7843826270259 --dry
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { execSync, spawnSync } = require('child_process');
+const ROOT = path.resolve(__dirname, '..');
+try { require('dotenv').config({ path: path.join(ROOT, '.env') }); } catch {}
+
+function argv(name, dflt) {
+  const i = process.argv.indexOf('--' + name);
+  if (i === -1) return dflt;
+  if (i === process.argv.length - 1 || process.argv[i + 1].startsWith('--')) return true;
+  return process.argv[i + 1];
+}
+
+const date      = argv('date',  new Date().toISOString().slice(0, 10));
+const slug      = argv('slug',  null);
+const productId = argv('product', null);
+const store     = argv('store', process.env.SHOPIFY_STORE);
+const token     = process.env.SHOPIFY_ADMIN_TOKEN;
+const dry       = !!argv('dry', false);
+const noCleanup = !!argv('no-cleanup', false);
+
+if (!slug || !productId) {
+  console.error('usage: --slug <bundle-slug> --product <shopify-product-id> [--date YYYY-MM-DD] [--dry] [--no-cleanup]');
+  process.exit(2);
+}
+if (!dry && (!token || !store)) {
+  console.error('missing SHOPIFY_ADMIN_TOKEN or SHOPIFY_STORE — use --dry to skip the API call');
+  process.exit(3);
+}
+
+const bundleDir   = path.join(ROOT, 'data', 'etsy-exports', date, 'bundles', slug);
+const listingPath = path.join(bundleDir, 'listing.json');
+if (!fs.existsSync(listingPath)) { console.error(`listing not found: ${listingPath}`); process.exit(4); }
+const listing = JSON.parse(fs.readFileSync(listingPath, 'utf8'));
+const designIds = (listing.design_ids || []).filter(Number.isFinite);
+
+console.log(`bundle: ${slug} · ${designIds.length} designs · product ${productId}`);
+
+// Resolve local_path for each design (PG dw_unified)
+function pgArray() {
+  const rows = execSync(
+    `psql dw_unified -At -F'\x1f' -c "SELECT id, COALESCE(local_path,'') FROM all_designs WHERE id IN (${designIds.join(',')});"`,
+    { encoding: 'utf8' }
+  ).trim();
+  if (!rows) return [];
+  return rows.split('\n').filter(Boolean).map(r => { const [id, lp] = r.split('\x1f'); return { id: +id, local_path: lp }; });
+}
+const designs = pgArray().filter(d => d.local_path && fs.existsSync(d.local_path));
+if (designs.length !== designIds.length) {
+  console.warn(`only ${designs.length}/${designIds.length} source files present on disk — proceeding with what's available`);
+}
+if (!designs.length) { console.error('no source files exist; aborting'); process.exit(5); }
+
+// ── ZIP build (ported from push-etsy-bundle.js asset gen) ─────────────────
+function buildZip() {
+  const filesDir = path.join(bundleDir, 'files');
+  fs.mkdirSync(filesDir, { recursive: true });
+
+  const cols = Math.min(4, Math.ceil(Math.sqrt(designs.length)));
+  const upscalePx = 3600;
+  const py = `
+import os, json
+from PIL import Image
+items = ${JSON.stringify(designs.map(d => ({ id: d.id, src: d.local_path })))}
+cols = ${cols}
+upscale_px = ${upscalePx}
+cw = 600
+rows = (len(items) + cols - 1) // cols
+canvas = Image.new('RGB', (cw * cols, cw * rows), '#0e0f10')
+for i, it in enumerate(items):
+    src = Image.open(it['src']).convert('RGB').resize((cw, cw), Image.LANCZOS)
+    canvas.paste(src, ((i % cols) * cw, (i // cols) * cw))
+canvas.save(${JSON.stringify(path.join(bundleDir, 'cover_grid.png'))}, optimize=True)
+files_dir = ${JSON.stringify(filesDir)}
+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)
+    src.resize((upscale_px, upscale_px), Image.LANCZOS).save(
+        os.path.join(files_dir, f"{it['id']}_print_3600.png"), optimize=True
+    )
+print(json.dumps({"cover": ${JSON.stringify(path.join(bundleDir, 'cover_grid.png'))}, "files_dir": files_dir, "count": len(items), "upscale_px": upscale_px}))
+`;
+  console.log(`building ${designs.length}-design ZIP (cover + 1024 source + 3600 LANCZOS upscale)…`);
+  const r = spawnSync('python3', ['-c', py], { encoding: 'utf8', timeout: 600000, maxBuffer: 16 * 1024 * 1024 });
+  if (r.status !== 0) { console.error('PIL stderr:', r.stderr); throw new Error('asset gen failed'); }
+
+  const readme = `${listing.title || slug}\n\nIncluded in this bundle:\n- cover_grid.png — preview of all ${designs.length} pattern(s)\n- files/<id>_source_1024.png — original 1024×1024 seamless tile\n- files/<id>_print_3600.png — LANCZOS-upscaled 3600×3600 (24" @ 150 DPI for print)\n\nEach tile is seamless — tile-and-repeat to cover any wall.\n\nCommercial use included (no attribution required). Mass redistribution of the patterns themselves is NOT licensed.\n\n— wallco.ai by Designer Wallcoverings\n`;
+  fs.writeFileSync(path.join(bundleDir, 'README.txt'), readme);
+
+  const zipPath = path.join(bundleDir, `${slug}.zip`);
+  execSync(`cd ${JSON.stringify(bundleDir)} && rm -f ${JSON.stringify(slug + '.zip')} && zip -q -r ${JSON.stringify(slug + '.zip')} files cover_grid.png README.txt`, { stdio: 'inherit' });
+  const sz = fs.statSync(zipPath).size;
+  console.log(`✓ ZIP built: ${path.relative(ROOT, zipPath)} (${(sz/1024/1024).toFixed(2)} MB)`);
+  return { zipPath, zipBytes: sz };
+}
+
+// ── Shopify Files API upload via stagedUploadsCreate + multipart PUT ──────
+async function shopifyUploadZip(zipPath) {
+  const url = `https://${store}/admin/api/2026-01/graphql.json`;
+  const filename = path.basename(zipPath);
+  const sz = fs.statSync(zipPath).size;
+  // Step 1: stagedUploadsCreate to get a presigned target
+  const stagedQuery = `mutation stagedUploadsCreate($input: [StagedUploadInput!]!) {
+    stagedUploadsCreate(input: $input) {
+      stagedTargets { url resourceUrl parameters { name value } }
+      userErrors { field message }
+    }
+  }`;
+  const stagedVars = { input: [{
+    filename, mimeType: 'application/zip', httpMethod: 'POST', resource: 'FILE', fileSize: String(sz),
+  }]};
+  console.log(`POST graphql stagedUploadsCreate for ${filename} (${sz} bytes)`);
+  const r1 = await fetch(url, {
+    method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
+    body: JSON.stringify({ query: stagedQuery, variables: stagedVars }),
+  });
+  const j1 = await r1.json();
+  if (j1.errors || (j1.data && j1.data.stagedUploadsCreate && j1.data.stagedUploadsCreate.userErrors.length)) {
+    console.error('stagedUploadsCreate failed:', JSON.stringify(j1).slice(0, 800)); process.exit(6);
+  }
+  const target = j1.data.stagedUploadsCreate.stagedTargets[0];
+  const stageUrl   = target.url;
+  const resourceUrl = target.resourceUrl;
+  // Step 2: POST the zip to the staged URL with the returned form parameters
+  const FormData = require('form-data');
+  const form = new FormData();
+  for (const p of target.parameters) form.append(p.name, p.value);
+  form.append('file', fs.createReadStream(zipPath), { filename, contentType: 'application/zip' });
+  console.log(`POST staged upload → ${stageUrl}`);
+  const r2 = await fetch(stageUrl, { method: 'POST', body: form, headers: form.getHeaders() });
+  if (!r2.ok && r2.status !== 201 && r2.status !== 204) {
+    const txt = await r2.text();
+    console.error(`stage upload ${r2.status}:`, txt.slice(0, 600)); process.exit(7);
+  }
+  // Step 3: fileCreate to register the uploaded blob as a Shopify File
+  const fcQuery = `mutation fileCreate($files: [FileCreateInput!]!) {
+    fileCreate(files: $files) {
+      files { id alt fileStatus ... on GenericFile { url } }
+      userErrors { field message }
+    }
+  }`;
+  const fcVars = { files: [{ originalSource: resourceUrl, alt: `${slug} bundle download` }] };
+  console.log('POST graphql fileCreate');
+  const r3 = await fetch(url, {
+    method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
+    body: JSON.stringify({ query: fcQuery, variables: fcVars }),
+  });
+  const j3 = await r3.json();
+  if (j3.errors || (j3.data && j3.data.fileCreate && j3.data.fileCreate.userErrors.length)) {
+    console.error('fileCreate failed:', JSON.stringify(j3).slice(0, 800)); process.exit(8);
+  }
+  const file = j3.data.fileCreate.files[0];
+  console.log(`✓ Shopify File created: ${file.id} (status=${file.fileStatus})`);
+  // file.url may be empty until processing completes; resourceUrl is the canonical reference.
+  return { fileId: file.id, fileUrl: file.url || null, resourceUrl };
+}
+
+// ── Attach a metafield to the product with the download URL ───────────────
+async function attachMetafield(productGid, fileRef) {
+  const url = `https://${store}/admin/api/2026-01/graphql.json`;
+  const q = `mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
+    metafieldsSet(metafields: $metafields) {
+      metafields { id namespace key value type }
+      userErrors { field message }
+    }
+  }`;
+  const v = { metafields: [
+    { ownerId: productGid, namespace: 'wallco_ai', key: 'bundle_download_file_id', value: fileRef.fileId, type: 'single_line_text_field' },
+    ...(fileRef.fileUrl ? [{ ownerId: productGid, namespace: 'wallco_ai', key: 'bundle_download_url', value: fileRef.fileUrl, type: 'url' }] : []),
+  ]};
+  const r = await fetch(url, {
+    method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': token },
+    body: JSON.stringify({ query: q, variables: v }),
+  });
+  const j = await r.json();
+  if (j.errors || (j.data && j.data.metafieldsSet && j.data.metafieldsSet.userErrors.length)) {
+    console.error('metafieldsSet failed:', JSON.stringify(j).slice(0, 600)); process.exit(9);
+  }
+  console.log(`✓ metafields set on ${productGid}`);
+}
+
+// ── main ─────────────────────────────────────────────────────────────────
+(async () => {
+  const { zipPath, zipBytes } = buildZip();
+  if (dry) { console.log('--- DRY: would upload to Shopify Files + set product metafield. Stopping. ---'); return; }
+  const file = await shopifyUploadZip(zipPath);
+  const productGid = `gid://shopify/Product/${productId}`;
+  await attachMetafield(productGid, file);
+
+  // Update the bucket row with notes so we know the ZIP is attached
+  try {
+    execSync(`psql dw_unified -q -c "UPDATE wallco_etsy_bucket SET notes='shopify-draft+zip:${file.fileId.replace(/'/g,'')}' WHERE design_id IN (${designIds.join(',')});"`, { stdio: 'inherit' });
+  } catch (e) { console.warn('bucket note update failed (non-fatal):', e.message.slice(0, 120)); }
+
+  if (!noCleanup) {
+    // Clean up local assets — the canonical copy is now on Shopify
+    try {
+      execSync(`rm -rf ${JSON.stringify(path.join(bundleDir, 'files'))} ${JSON.stringify(zipPath)} ${JSON.stringify(path.join(bundleDir, 'cover_grid.png'))}`, { stdio: 'ignore' });
+      console.log('✓ local assets cleaned (canonical copy now on Shopify)');
+    } catch {}
+  }
+  console.log(`done. product ${productId} now has bundle_download_file_id=${file.fileId}`);
+})();
diff --git a/scripts/generate-etsy-bundles.js b/scripts/generate-etsy-bundles.js
index ab08ef1..27a0d26 100644
--- a/scripts/generate-etsy-bundles.js
+++ b/scripts/generate-etsy-bundles.js
@@ -57,7 +57,12 @@ const opt = {
   by:           ARG('--by', 'category'),
   fromBucket:   !HAS('--from-tag'),
   fromTag:      ARG('--from-tag', null),
-  targetSize:   parseInt(ARG('--target-size', '15'), 10),
+  // 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),
   maxBundles:   parseInt(ARG('--max-bundles', '0'), 10),
   price:        ARG('--price') ? parseFloat(ARG('--price')) : null,
@@ -160,8 +165,10 @@ function group(pool) {
   const out = new Map();
   for (const [k, list] of buckets) {
     if (list.length < opt.minSize) continue;
-    if (list.length <= opt.targetSize * 1.5) { out.set(k, list); continue; }
-    let chunks = Math.ceil(list.length / opt.targetSize);
+    // 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));
@@ -232,7 +239,7 @@ function bundleCopy(name, n, designs) {
     `INSTANT DOWNLOAD · ${n} seamless wallpaper patterns delivered as a single bundle — no inventory, no shipping wait.`,
     '',
     `🎨 Inside the bundle:`,
-    `• ${n} unique seamless tiles, each ${designs[0]?.upscale_px || 3600}×${designs[0]?.upscale_px || 3600} px (24″ @ 150 DPI close-up sharpness)`,
+    `• ${n} unique seamless tiles, each 2400×2400 px (16″ @ 150 DPI close-up sharpness — seamless so it tiles to any wall size)`,
     `• ${n} matching 1024×1024 source files for web/digital use`,
     `• Each tile repeats seamlessly across walls of any size`,
     `• Cover sheet showing all ${n} patterns at a glance`,
diff --git a/scripts/generate_room_mockups.py b/scripts/generate_room_mockups.py
index 297d964..572fd39 100644
--- a/scripts/generate_room_mockups.py
+++ b/scripts/generate_room_mockups.py
@@ -23,17 +23,38 @@ def esc(s):
     if s is None: return 'NULL'
     return "'" + str(s).replace("'", "''") + "'"
 
-def generate(design_id, image_path, room_type='living_room'):
+def generate(design_id, image_path, room_type='living_room', kind=None):
+    """Render a room mockup. For kind in ('mural','mural_panel') the room-setting-app
+    is told to use patternType='mural' (full-width, no repeat) so the design fills
+    the whole feature wall as a single image. Other kinds default to 'textile' (tiled
+    repeat at the patternWidth × patternHeight inches given). See memory
+    feedback_wallco_product_page_mode_aware + the 2026-05-28 mural-render fix."""
     img_bytes = Path(image_path).read_bytes()
     b64 = base64.b64encode(img_bytes).decode()
-    body = json.dumps({
+    is_mural = (kind or '').lower() in ('mural', 'mural_panel')
+    body_dict = {
         'patternBase64': b64,
         'roomType': room_type,
         'angle': 'straight_on',
-        'cameraDistance': 5,
-        'patternWidth': 27,
-        'patternHeight': 27
-    }).encode()
+        'cameraDistance': 10,
+        'wallWidth': 14,    # ft — feature wall dimensions; the service scales the mural to fit
+        'wallHeight': 9,
+    }
+    if is_mural:
+        body_dict.update({
+            'patternType': 'mural',   # 'Full Width, No Repeat' in the UI
+            # For mural mode the patternWidth/Height are the panel's installed inches
+            # so the service knows the intended scale; default 14ft × 9ft = 168" × 108".
+            'patternWidth':  168,
+            'patternHeight': 108,
+        })
+    else:
+        body_dict.update({
+            'patternType':   'textile',  # default repeating-tile mode
+            'patternWidth':  27,
+            'patternHeight': 27,
+        })
+    body = json.dumps(body_dict).encode()
     req = urllib.request.Request(ROOM_API, data=body, headers={'Content-Type': 'application/json'})
     try:
         with urllib.request.urlopen(req, timeout=180) as r:
@@ -95,7 +116,12 @@ subprocess.run(['psql','dw_unified','-q','-c',
 results = []
 for d in top:
     design_id = d['id']
-    local = psql(f"SELECT local_path FROM all_designs WHERE id={design_id};")
+    # Pull local_path AND kind in one query so the renderer can switch to mural mode
+    # for kind IN ('mural','mural_panel') — see the 2026-05-28 mural-render fix.
+    row = psql(f"SELECT COALESCE(local_path,'') || '|' || COALESCE(kind,'') FROM all_designs WHERE id={design_id};")
+    parts = row.split('|', 1) if row else ['', '']
+    local = parts[0]
+    kind = parts[1] if len(parts) > 1 else ''
     if not local or not Path(local).exists():
         print(f'#{design_id} skip — no local image')
         continue
@@ -110,9 +136,10 @@ for d in top:
         if room in have and Path(have[room]).exists():
             print(f'#{design_id} {room} ✓ already exists, skipping')
             continue
-        print(f'#{design_id} {d["title"]} → generating {room}…')
+        mode_tag = ' [MURAL mode]' if (kind or '').lower() in ('mural', 'mural_panel') else ''
+        print(f'#{design_id} {d["title"]} → generating {room}{mode_tag}…')
         t0 = time.time()
-        out_path = generate(design_id, local, room)
+        out_path = generate(design_id, local, room, kind=kind)
         took = time.time() - t0
         if out_path:
             have[room] = out_path
diff --git a/scripts/push-etsy-bundle.js b/scripts/push-etsy-bundle.js
index 27f473d..a81f3b4 100644
--- a/scripts/push-etsy-bundle.js
+++ b/scripts/push-etsy-bundle.js
@@ -128,7 +128,11 @@ function buildAssets(designs, slug) {
   if (!present.length) throw new Error('no source files exist for any design in the bundle');
 
   const cols = Math.min(4, Math.ceil(Math.sqrt(present.length)));
-  const upscalePx = 3600;     // 24" @ 150 DPI — matches export-etsy-digital default
+  // Option C (Steve 2026-05-28 via /dtd): 2400px = 16" @ 150 DPI. Down from
+  // 3600px to keep ZIPs under Etsy's 100 MB upload cap when bundled at 12
+  // designs (~6 MB per print PNG × 12 = ~75 MB total). Still print-quality
+  // for real-world buyer use (accent wall, framed art, fabric POD).
+  const upscalePx = 2400;
   const py = `
 import os, json
 from PIL import Image

← 760af6a Scenic+Studio sub-brand proposal: KILLED 2026-05-28 (DTD ver  ·  back to Wallco Ai  ·  add tile-period-detect: validate mural-mode room renders c4b01d5 →