← 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
A scripts/attach-shopify-bundle-files.jsM scripts/generate-etsy-bundles.jsM scripts/generate_room_mockups.pyM scripts/push-etsy-bundle.js
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 →